use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as B64;
use serde_json::{Value, json};
use tokio::sync::Notify;
use zeroize::Zeroizing;
use crate::seal::MasterKey;
use crate::store::Table;
type Versioned = (u64, BTreeMap<String, (u64, Option<Value>)>);
pub const NAMESPACE: &str = "vgi-bridge";
pub const MAX_VALUE_BYTES: usize = 65_536;
pub const MIRRORED: [Table; 4] = [
Table::Namespaces,
Table::Repos,
Table::Branches,
Table::Meta,
];
pub fn record_is_mirrored(table: Table, key: &str) -> bool {
MIRRORED.contains(&table) && !(table == Table::Meta && key == crate::vta::BINDING_META)
}
pub fn secret_is_mirrored(name: &str) -> bool {
!name.starts_with("pending/")
}
#[derive(Debug, Clone)]
pub struct Record {
pub key: String,
pub version: u64,
pub deleted: bool,
pub value: Value,
pub updated_at: Option<i64>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ListingMode {
#[default]
Feed,
Snapshot,
}
#[derive(Debug)]
pub struct StateRefused(pub String);
impl std::fmt::Display for StateRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for StateRefused {}
pub fn is_state_refused(e: &anyhow::Error) -> bool {
e.chain()
.any(|c| c.downcast_ref::<StateRefused>().is_some())
}
#[derive(Debug, Clone, Default)]
pub struct Listing {
pub records: Vec<Record>,
pub watermark: u64,
pub mode: ListingMode,
}
#[derive(Debug)]
pub enum PutError {
Conflict(Option<u64>),
Other(anyhow::Error),
}
impl std::fmt::Display for PutError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PutError::Conflict(v) => write!(f, "version conflict (the record is at {v:?})"),
PutError::Other(e) => write!(f, "{e:#}"),
}
}
}
#[async_trait]
pub trait AppState: Send + Sync {
async fn list(&self) -> Result<Listing>;
async fn get(&self, key: &str) -> Result<Option<Record>>;
async fn put(
&self,
key: &str,
value: Value,
expected: Option<u64>,
) -> std::result::Result<u64, PutError>;
async fn delete(
&self,
key: &str,
expected: Option<u64>,
) -> std::result::Result<Option<u64>, PutError>;
}
#[derive(Default)]
pub struct MemoryAppState {
inner: Mutex<Versioned>,
down: std::sync::atomic::AtomicBool,
puts: Mutex<BTreeMap<String, usize>>,
stamps: Mutex<BTreeMap<String, i64>>,
reaped: std::sync::atomic::AtomicBool,
}
impl MemoryAppState {
pub fn new() -> Self {
MemoryAppState::default()
}
pub fn set_down(&self, down: bool) {
self.down.store(down, std::sync::atomic::Ordering::SeqCst);
}
pub fn snapshot(&self) -> BTreeMap<String, Value> {
let g = self.inner.lock().expect("lock");
g.1.iter()
.filter_map(|(k, (_, v))| v.as_ref().map(|v| (k.clone(), v.clone())))
.collect()
}
pub fn puts(&self, key: &str) -> usize {
self.puts
.lock()
.expect("lock")
.get(key)
.copied()
.unwrap_or(0)
}
pub fn put_as_other(&self, key: &str, value: Value) -> u64 {
self.put_as_other_at(key, value, chrono::Utc::now().timestamp())
}
pub fn put_as_other_at(&self, key: &str, value: Value, updated_at: i64) -> u64 {
let mut g = self.inner.lock().expect("lock");
g.0 += 1;
let v = g.0;
g.1.insert(key.to_string(), (v, Some(value)));
self.stamp(key, updated_at);
v
}
pub fn forget(&self, key: &str) {
self.inner.lock().expect("lock").1.remove(key);
}
pub fn bump(&self, n: u64) {
self.inner.lock().expect("lock").0 += n;
}
fn stamp(&self, key: &str, at: i64) {
self.stamps
.lock()
.expect("lock")
.insert(key.to_string(), at);
}
fn stamp_of(&self, key: &str) -> Option<i64> {
self.stamps.lock().expect("lock").get(key).copied()
}
pub fn raw(&self, key: &str) -> Option<(u64, Value)> {
let g = self.inner.lock().expect("lock");
g.1.get(key).and_then(|(v, x)| x.clone().map(|x| (*v, x)))
}
pub fn reap_tombstones(&self) {
let mut g = self.inner.lock().expect("lock");
g.1.retain(|_, (_, v)| v.is_some());
self.reaped.store(true, std::sync::atomic::Ordering::SeqCst);
}
fn check(&self) -> Result<()> {
if self.down.load(std::sync::atomic::Ordering::SeqCst) {
Err(anyhow!("the VTA is unreachable (test)"))
} else {
Ok(())
}
}
fn precondition(
current: Option<u64>,
expected: Option<u64>,
) -> std::result::Result<(), PutError> {
match (expected, current) {
(None, _) => Ok(()),
(Some(0), None) => Ok(()),
(Some(e), Some(c)) if e == c => Ok(()),
_ => Err(PutError::Conflict(current)),
}
}
}
#[async_trait]
impl AppState for MemoryAppState {
async fn list(&self) -> Result<Listing> {
self.check()?;
let g = self.inner.lock().expect("lock");
Ok(Listing {
records: g
.1
.iter()
.map(|(k, (v, value))| Record {
key: k.clone(),
version: *v,
deleted: value.is_none(),
value: value.clone().unwrap_or(Value::Null),
updated_at: self.stamp_of(k),
})
.collect(),
watermark: g.0,
mode: if self.reaped.load(std::sync::atomic::Ordering::SeqCst) {
ListingMode::Snapshot
} else {
ListingMode::Feed
},
})
}
async fn get(&self, key: &str) -> Result<Option<Record>> {
self.check()?;
let g = self.inner.lock().expect("lock");
Ok(g.1.get(key).and_then(|(v, value)| {
value.as_ref().map(|value| Record {
key: key.to_string(),
version: *v,
deleted: false,
value: value.clone(),
updated_at: self.stamp_of(key),
})
}))
}
async fn put(
&self,
key: &str,
value: Value,
expected: Option<u64>,
) -> std::result::Result<u64, PutError> {
self.check().map_err(PutError::Other)?;
let mut g = self.inner.lock().expect("lock");
let current = g.1.get(key).and_then(|(v, x)| x.as_ref().map(|_| *v));
Self::precondition(current, expected)?;
g.0 += 1;
let v = g.0;
g.1.insert(key.to_string(), (v, Some(value)));
self.stamp(key, chrono::Utc::now().timestamp());
*self
.puts
.lock()
.expect("lock")
.entry(key.to_string())
.or_insert(0) += 1;
Ok(v)
}
async fn delete(
&self,
key: &str,
expected: Option<u64>,
) -> std::result::Result<Option<u64>, PutError> {
self.check().map_err(PutError::Other)?;
let mut g = self.inner.lock().expect("lock");
let current = g.1.get(key).and_then(|(v, x)| x.as_ref().map(|_| *v));
if current.is_none() {
return Ok(None);
}
Self::precondition(current, expected)?;
g.0 += 1;
let v = g.0;
g.1.insert(key.to_string(), (v, None));
self.stamp(key, chrono::Utc::now().timestamp());
Ok(Some(v))
}
}
fn parse_state_key(key: &str) -> Option<(Table, &str)> {
let rest = key.strip_prefix("state/")?;
let (table, local) = rest.split_once('/')?;
let table = Table::from_name(table).filter(|t| record_is_mirrored(*t, local))?;
Some((table, local))
}
pub fn state_key(table: Table, key: &str) -> String {
format!("state/{}/{key}", table.name())
}
pub fn secret_key(name: &str) -> String {
format!("secret/{name}")
}
fn secret_aad(name: &str, version: u64) -> String {
format!("{}@{version}", secret_key(name))
}
pub fn secret_value(seal: &MasterKey, name: &str, version: u64, bytes: &[u8]) -> Result<Value> {
Ok(json!({ "sealed": B64.encode(seal.seal(&secret_aad(name, version), bytes)?) }))
}
pub fn secret_bytes(
seal: &MasterKey,
name: &str,
version: u64,
v: &Value,
) -> Result<Zeroizing<Vec<u8>>> {
let s = v
.get("sealed")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("not a sealed secret record"))?;
seal.open(&secret_aad(name, version), &B64.decode(s)?).map_err(|_| {
anyhow!(
"the secret `{name}` does not open with this context's sealing key at the version it \
is stored at: it was written by someone other than this bridge (or put back from an \
older copy). Re-set it (`vgi-bridge secret set`, or register the App again), and \
revoke any credential that is not the bridge's"
)
})
}
pub const LEASE_KEY: &str = "lease/writer";
pub const LEASE_TTL_SECS: i64 = 120;
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const _: () = assert!(
REQUEST_TIMEOUT.as_secs() * 3 <= (LEASE_TTL_SECS as u64) / 2,
"an app-state request must fit well inside half a lease"
);
pub fn lease_expiry(r: &Record) -> i64 {
let until = r.value.get("until").and_then(Value::as_i64).unwrap_or(0);
match r.updated_at {
Some(at) => until.min(at.saturating_add(LEASE_TTL_SECS)),
None => i64::MIN,
}
}
#[derive(Debug)]
pub struct Lease {
holder: String,
version: u64,
deadline: tokio::time::Instant,
next: u64,
}
impl Lease {
pub async fn acquire(remote: &dyn AppState, holder: &str, wait: Duration) -> Result<Lease> {
let deadline = tokio::time::Instant::now() + wait;
loop {
let now = chrono::Utc::now().timestamp();
let current = remote.get(LEASE_KEY).await?;
let expected = match ¤t {
None => Some(0),
Some(r) => {
let theirs = r.value.get("holder").and_then(Value::as_str).unwrap_or("");
(lease_expiry(r) < now || theirs == holder).then_some(r.version)
}
};
if let Some(expected) = expected {
let sent = tokio::time::Instant::now();
match remote
.put(
LEASE_KEY,
json!({ "holder": holder, "until": now + LEASE_TTL_SECS }),
Some(expected),
)
.await
{
Ok(version) => {
return Ok(Lease {
holder: holder.to_string(),
version,
deadline: sent + Duration::from_secs(LEASE_TTL_SECS as u64),
next: version + 1,
});
}
Err(PutError::Conflict(_)) => {}
Err(PutError::Other(e)) => return Err(e),
}
}
if tokio::time::Instant::now() >= deadline {
bail!(
"another writer holds the bridge's app-state lease (`{LEASE_KEY}`); try again \
shortly"
);
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
pub fn observe(&mut self, version: u64) {
self.next = self.next.max(version + 1);
}
pub fn next(&self) -> u64 {
self.next
}
pub async fn renew_if_due(&mut self, remote: &dyn AppState) -> Result<()> {
let left = self
.deadline
.saturating_duration_since(tokio::time::Instant::now());
if left > Duration::from_secs((LEASE_TTL_SECS / 2) as u64) {
return Ok(());
}
if left.is_zero() {
bail!("the app-state lease ran out before this pass finished; it is taken again");
}
let sent = tokio::time::Instant::now();
let until = chrono::Utc::now().timestamp() + LEASE_TTL_SECS;
let v = remote
.put(
LEASE_KEY,
json!({ "holder": self.holder, "until": until }),
Some(self.version),
)
.await
.map_err(|e| anyhow!("renewing the app-state lease: {e}"))?;
self.version = v;
self.deadline = sent + Duration::from_secs(LEASE_TTL_SECS as u64);
self.observe(v);
Ok(())
}
pub async fn release(self, remote: &dyn AppState) {
if let Err(e) = remote.delete(LEASE_KEY, Some(self.version)).await {
tracing::debug!(error = %e, "could not release the app-state lease; it expires");
}
}
}
pub async fn put_sealed(
remote: &dyn AppState,
seal: &MasterKey,
name: &str,
bytes: &[u8],
current: Option<u64>,
lease: &mut Lease,
) -> std::result::Result<u64, PutError> {
let key = secret_key(name);
let mut expected = current.unwrap_or(0);
for _ in 0..4 {
lease.renew_if_due(remote).await.map_err(PutError::Other)?;
let predicted = lease.next();
let value = secret_value(seal, name, predicted, bytes).map_err(PutError::Other)?;
let v = remote.put(&key, value, Some(expected)).await?;
lease.observe(v);
if v == predicted {
return Ok(v);
}
tracing::warn!(key = %key, "a write outside the app-state lease landed in between; sealing again");
expected = v;
}
Err(PutError::Other(anyhow!(
"`{key}` could not be written at a predictable version: something ignores the bridge's \
app-state lease and keeps writing its VTA context"
)))
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Dirty {
Record(Table, String),
Secret(String),
}
impl Dirty {
fn remote_key(&self) -> String {
match self {
Dirty::Record(t, k) => state_key(*t, k),
Dirty::Secret(n) => secret_key(n),
}
}
}
pub struct Mirror {
seal: MasterKey,
pub(crate) secrets: Mutex<BTreeMap<String, Zeroizing<Vec<u8>>>>,
dirty: Mutex<BTreeSet<Dirty>>,
in_flight: Mutex<usize>,
versions: Mutex<HashMap<String, u64>>,
holder: String,
wake: Notify,
idle: Notify,
conflicts: std::sync::atomic::AtomicU64,
stalled_since: Mutex<Option<tokio::time::Instant>>,
}
pub const STALL_LIMIT: Duration = Duration::from_secs(300);
impl std::fmt::Debug for Mirror {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Mirror")
.field("pending", &self.pending())
.finish_non_exhaustive()
}
}
impl Mirror {
pub fn new(seal: MasterKey) -> Arc<Self> {
Arc::new(Mirror {
seal,
secrets: Mutex::default(),
dirty: Mutex::default(),
in_flight: Mutex::default(),
versions: Mutex::default(),
holder: format!("mirror-{}", crate::wire::new_id()),
wake: Notify::new(),
idle: Notify::new(),
conflicts: Default::default(),
stalled_since: Mutex::default(),
})
}
pub fn stopped(&self) -> bool {
self.conflicts() > 0
}
pub(crate) fn mark(&self, d: Dirty) {
self.dirty.lock().expect("lock").insert(d);
self.wake.notify_one();
}
pub fn pending(&self) -> usize {
self.dirty.lock().expect("lock").len() + *self.in_flight.lock().expect("lock")
}
pub fn conflicts(&self) -> u64 {
self.conflicts.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn health(&self) -> std::result::Result<(), String> {
self.health_at(tokio::time::Instant::now())
}
pub fn health_at(&self, now: tokio::time::Instant) -> std::result::Result<(), String> {
if self.stopped() {
return Err("state conflict: another writer on the bridge's VTA context".into());
}
let since = *self.stalled_since.lock().expect("lock");
let pending = self.pending();
if let Some(since) = since
&& pending > 0
{
let stalled = now.saturating_duration_since(since);
if stalled >= STALL_LIMIT {
return Err(format!(
"state not reaching the VTA: {pending} change(s) waiting, every write failing \
for {}s (the VTA unreachable, or its app-state lease held by another writer)",
stalled.as_secs()
));
}
}
Ok(())
}
pub async fn pull(&self, remote: &dyn AppState, store: &crate::store::Store) -> Result<()> {
let listing = remote.list().await?;
let refuse = |msg: String| -> anyhow::Error {
self.conflicts
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
anyhow::Error::new(StateRefused(msg))
};
let mirrored: BTreeMap<String, u64> =
store.list::<u64>(Table::Mirror)?.into_iter().collect();
let mut latest: BTreeMap<String, Record> = BTreeMap::new();
for r in listing.records {
match latest.get(&r.key) {
Some(have) if have.version >= r.version => {}
_ => {
latest.insert(r.key.clone(), r);
}
}
}
if let Some((key, m)) = mirrored.iter().max_by_key(|(_, v)| **v)
&& listing.watermark < *m
{
return Err(refuse(format!(
"the VTA's counter for the bridge's state is at {}, behind the {m} this bridge \
wrote `{key}` at: its state was rolled back. The bridge does not run on it (it \
would take what is missing for deleted); restore the VTA's current state (or \
recreate the context) and start again",
listing.watermark
)));
}
for (key, m) in &mirrored {
if latest.contains_key(key) {
continue;
}
if *m > listing.watermark {
return Err(refuse(format!(
"`{key}` vanished from the VTA above its counter: its state was rolled back"
)));
}
if listing.mode == ListingMode::Feed {
return Err(refuse(format!(
"the VTA has no record of `{key}` — not even a deletion — although this \
bridge wrote it at version {m} and the VTA's change feed still reaches back \
to the start: its state was rolled back (restored from an older copy and \
written to since). The bridge does not run on it (it would take what is \
missing for deleted); restore the VTA's current state (or recreate the \
context) and start again"
)));
}
if key.starts_with("secret/") {
store.delete_cached(Table::Mirror, key)?;
}
}
for (key, r) in &latest {
if let Some(m) = mirrored.get(key)
&& r.version < *m
{
return Err(refuse(format!(
"the VTA holds `{key}` at version {}, older than the {m} this bridge wrote: \
its state was rolled back or replayed. The bridge does not run on it; restore \
the VTA's current state (or recreate the context) and start again",
r.version
)));
}
}
{
let mut versions = self.versions.lock().expect("lock");
versions.clear();
for (k, r) in &latest {
if !r.deleted {
versions.insert(k.clone(), r.version);
}
}
}
for (key, r) in &latest {
if let Some(name) = key.strip_prefix("secret/") {
if r.deleted {
continue;
}
let b = secret_bytes(&self.seal, name, r.version, &r.value)
.map_err(|e| refuse(format!("reading `{key}` from the VTA: {e:#}")))?;
self.secrets
.lock()
.expect("lock")
.insert(name.to_string(), b);
store.put_cached(Table::Mirror, key, &r.version)?;
} else if let Some((table, local)) = parse_state_key(key) {
if r.deleted {
if store.get_raw(table, local)?.is_some() {
tracing::info!(key = %key, "dropping a cached record deleted in the VTA");
}
store.delete_cached(table, local)?;
store.delete_cached(Table::Mirror, key)?;
} else {
store.put_cached(table, local, &r.value)?;
store.put_cached(Table::Mirror, key, &r.version)?;
}
} else {
tracing::debug!(key = %key, "ignoring a record this release does not mirror");
}
}
for table in MIRRORED {
for local in store.keys(table)? {
if !record_is_mirrored(table, &local) {
continue;
}
let key = state_key(table, &local);
if latest.contains_key(&key) {
continue;
}
if mirrored.contains_key(&key) {
tracing::info!(key = %key, "dropping a cached record the VTA no longer holds");
store.delete_cached(table, &local)?;
store.delete_cached(Table::Mirror, &key)?;
} else {
self.mark(Dirty::Record(table, local));
}
}
}
Ok(())
}
pub async fn sync_once(
&self,
remote: &dyn AppState,
store: &crate::store::Store,
) -> Result<()> {
let res = self.sync_pass(remote, store).await;
let mut stalled = self.stalled_since.lock().expect("lock");
match &res {
Ok(()) => *stalled = None,
Err(_) => {
stalled.get_or_insert_with(tokio::time::Instant::now);
}
}
res
}
async fn sync_pass(&self, remote: &dyn AppState, store: &crate::store::Store) -> Result<()> {
if self.dirty.lock().expect("lock").is_empty() {
self.idle.notify_waiters();
return Ok(());
}
let mut lease = Some(Lease::acquire(remote, &self.holder, Duration::from_secs(10)).await?);
let batch: Vec<Dirty> = {
let mut d = self.dirty.lock().expect("lock");
let batch: Vec<Dirty> = std::mem::take(&mut *d).into_iter().collect();
*self.in_flight.lock().expect("lock") += batch.len();
batch
};
let mut failed: Option<anyhow::Error> = None;
for item in batch {
if failed.is_none() {
let l = lease.as_mut().expect("held");
let res = match l.renew_if_due(remote).await {
Ok(()) => self.write(remote, store, &item, l).await,
Err(e) => Err(e),
};
if let Err(e) = res {
failed = Some(e);
}
}
if failed.is_some() {
self.dirty.lock().expect("lock").insert(item);
}
*self.in_flight.lock().expect("lock") -= 1;
}
if let Some(l) = lease.take() {
l.release(remote).await;
}
self.idle.notify_waiters();
match failed {
Some(e) => Err(e),
None => Ok(()),
}
}
async fn write(
&self,
remote: &dyn AppState,
store: &crate::store::Store,
item: &Dirty,
lease: &mut Lease,
) -> Result<()> {
let key = item.remote_key();
let value: Option<Value> = match item {
Dirty::Record(t, k) => store.get_raw(*t, k)?,
Dirty::Secret(n) => {
let bytes = self.secrets.lock().expect("lock").get(n).cloned();
match bytes {
Some(b) => Some(secret_value(&self.seal, n, u64::MAX, &b)?),
None => None,
}
}
};
if let Some(v) = &value {
let size = serde_json::to_vec(v)?.len();
if size > MAX_VALUE_BYTES {
tracing::error!(
key = %key,
size,
"a record is larger than the VTA stores ({MAX_VALUE_BYTES} bytes); it stays in \
the local cache only and a new host would not have it"
);
return Ok(());
}
}
let seen = self.versions.lock().expect("lock").get(&key).copied();
let res = match (&value, item) {
(Some(_), Dirty::Secret(n)) => {
let bytes = self.secrets.lock().expect("lock").get(n).cloned();
let Some(bytes) = bytes else { return Ok(()) };
put_sealed(remote, &self.seal, n, &bytes, seen, lease)
.await
.map(Some)
}
(Some(v), _) => remote
.put(&key, v.clone(), Some(seen.unwrap_or(0)))
.await
.map(Some),
(None, _) => remote.delete(&key, seen).await.map(|tombstone| {
if let Some(t) = tombstone {
lease.observe(t);
}
None
}),
};
match res {
Ok(Some(version)) => {
self.versions
.lock()
.expect("lock")
.insert(key.clone(), version);
lease.observe(version);
store.put_cached(Table::Mirror, &key, &version)?;
Ok(())
}
Ok(None) => {
self.versions.lock().expect("lock").remove(&key);
store.delete_cached(Table::Mirror, &key)?;
Ok(())
}
Err(PutError::Conflict(_)) => {
self.conflicts
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tracing::error!(
key = %key,
"the VTA holds a version of the bridge's state this bridge did not write: \
another bridge (or someone holding its credential) is writing the same VTA \
context. The bridge stops writing its state and holds its results; stop the \
other bridge, revoke the credential if it is not yours, and restart this one"
);
Err(anyhow!("`{key}` was written by someone else"))
}
Err(PutError::Other(e)) => Err(e),
}
}
pub async fn run(
self: Arc<Self>,
remote: Arc<dyn AppState>,
store: crate::store::Store,
mut stop: tokio::sync::watch::Receiver<bool>,
) {
let mut backoff = Duration::from_millis(500);
loop {
if self.stopped() {
let _ = stop.changed().await;
return;
}
if self.dirty.lock().expect("lock").is_empty() {
tokio::select! {
_ = self.wake.notified() => {}
_ = tokio::time::sleep(Duration::from_secs(30)) => {}
_ = stop.changed() => {
if !self.stopped() {
let _ = self.sync_once(remote.as_ref(), &store).await;
}
return;
}
}
}
match self.sync_once(remote.as_ref(), &store).await {
Ok(()) => {
if backoff > Duration::from_millis(500) {
tracing::info!("the bridge's state was written to the VTA");
}
backoff = Duration::from_millis(500);
}
Err(e) => {
tracing::warn!(
error = %e,
pending = self.pending(),
"could not write the bridge's state to the VTA; retrying in {backoff:?}"
);
tokio::select! {
_ = tokio::time::sleep(backoff) => {}
_ = stop.changed() => return,
}
backoff = (backoff * 2).min(Duration::from_secs(60));
}
}
}
}
pub async fn flush(&self, timeout: Duration) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if self.stopped() {
return false;
}
let idle = self.idle.notified();
if self.pending() == 0 {
return true;
}
self.wake.notify_one();
if tokio::time::timeout_at(deadline, idle).await.is_err() {
return self.pending() == 0;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seal::MasterKey;
use crate::store::Store;
fn vta_store() -> (Store, Arc<Mirror>) {
let m = Mirror::new(MasterKey::from_bytes([3u8; 32]));
let s = Store::in_memory(MasterKey::generate().unwrap())
.unwrap()
.with_mirror(m.clone());
(s, m)
}
#[tokio::test]
async fn changes_reach_the_remote_and_a_new_host_pulls_them_back() {
let remote = MemoryAppState::new();
let (s, m) = vta_store();
s.put(Table::Namespaces, "ns_1", &json!({"id": "ns_1"}))
.unwrap();
s.put(Table::Jobs, "j1", &json!({"not": "mirrored"}))
.unwrap();
s.put_secret("github/github.com/app", b"pem").unwrap();
s.put_secret("pending/abc/device", b"code").unwrap();
assert_eq!(m.pending(), 2);
m.sync_once(&remote, &s).await.unwrap();
assert_eq!(m.pending(), 0);
let snap = remote.snapshot();
assert_eq!(
snap.keys().cloned().collect::<Vec<_>>(),
["secret/github/github.com/app", "state/namespaces/ns_1"]
);
let (s2, m2) = vta_store();
m2.pull(&remote, &s2).await.unwrap();
assert_eq!(
s2.get::<Value>(Table::Namespaces, "ns_1").unwrap(),
Some(json!({"id": "ns_1"}))
);
assert_eq!(
&*s2.get_secret("github/github.com/app").unwrap().unwrap(),
b"pem"
);
assert!(s2.get::<Value>(Table::Jobs, "j1").unwrap().is_none());
assert_eq!(m2.pending(), 0);
s2.delete(Table::Namespaces, "ns_1").unwrap();
m2.sync_once(&remote, &s2).await.unwrap();
assert!(!remote.snapshot().contains_key("state/namespaces/ns_1"));
}
#[tokio::test]
async fn secrets_never_reach_the_local_file() {
let (s, _m) = vta_store();
s.put_secret("forgejo/codeberg.org/bot-token", b"tok")
.unwrap();
assert!(s.raw_secret_rows().unwrap().is_empty());
assert_eq!(
s.secret_names().unwrap(),
["forgejo/codeberg.org/bot-token"]
);
}
#[tokio::test]
async fn an_unreachable_vta_keeps_changes_pending_and_flush_says_so() {
let remote = Arc::new(MemoryAppState::new());
let (s, m) = vta_store();
remote.set_down(true);
s.put(Table::Repos, "github.com#1", &json!({"x": 1}))
.unwrap();
assert!(m.sync_once(remote.as_ref(), &s).await.is_err());
assert_eq!(m.pending(), 1);
assert!(!m.flush(Duration::from_millis(50)).await);
remote.set_down(false);
let (tx, rx) = tokio::sync::watch::channel(false);
let task = tokio::spawn(m.clone().run(remote.clone(), s.clone(), rx));
assert!(m.flush(Duration::from_secs(5)).await);
assert!(remote.snapshot().contains_key("state/repos/github.com#1"));
let _ = tx.send(true);
task.await.unwrap();
}
#[tokio::test]
async fn a_second_writer_stops_the_mirror() {
let remote = MemoryAppState::new();
let (s, m) = vta_store();
s.put(Table::Meta, "k", &json!(1)).unwrap();
m.sync_once(&remote, &s).await.unwrap();
remote.put("state/meta/k", json!(2), None).await.unwrap();
s.put(Table::Meta, "k", &json!(3)).unwrap();
assert!(m.sync_once(&remote, &s).await.is_err());
assert!(m.stopped());
assert_eq!(remote.snapshot()["state/meta/k"], json!(2));
s.put(Table::Meta, "other", &json!(1)).unwrap();
assert!(m.sync_once(&remote, &s).await.is_err());
assert!(!remote.snapshot().contains_key("state/meta/other"));
assert!(!m.flush(Duration::from_millis(10)).await);
}
#[tokio::test]
async fn secrets_leave_the_host_sealed() {
let remote = MemoryAppState::new();
let (s, m) = vta_store();
s.put_secret("github/github.com/app", b"-----BEGIN RSA PRIVATE KEY-----")
.unwrap();
m.sync_once(&remote, &s).await.unwrap();
let stored = remote.snapshot()["secret/github/github.com/app"].to_string();
assert!(!stored.contains("BEGIN RSA"), "{stored}");
assert!(!stored.contains(&B64.encode(b"-----BEGIN RSA PRIVATE KEY-----")));
let other = Mirror::new(MasterKey::from_bytes([4u8; 32]));
let s2 = Store::in_memory(MasterKey::generate().unwrap())
.unwrap()
.with_mirror(other.clone());
assert!(other.pull(&remote, &s2).await.is_err());
let seal = MasterKey::from_bytes([3u8; 32]);
let (ver, v) = remote.raw("secret/github/github.com/app").unwrap();
assert!(secret_bytes(&seal, "github/github.com/app", ver, &v).is_ok());
assert!(secret_bytes(&seal, "forgejo/x/bot-token", ver, &v).is_err());
assert!(secret_bytes(&seal, "github/github.com/app", ver + 1, &v).is_err());
}
#[tokio::test]
async fn a_removed_record_is_not_resurrected_by_a_stale_host() {
let remote = MemoryAppState::new();
let (a, ma) = vta_store();
a.put(Table::Namespaces, "ns_1", &json!({"id": "ns_1"}))
.unwrap();
a.put(Table::Repos, "github.com#812", &json!({"forgeId": 812}))
.unwrap();
ma.sync_once(&remote, &a).await.unwrap();
let (b, mb) = vta_store();
mb.pull(&remote, &b).await.unwrap();
b.delete(Table::Repos, "github.com#812").unwrap();
mb.sync_once(&remote, &b).await.unwrap();
assert!(!remote.snapshot().contains_key("state/repos/github.com#812"));
for reaped in [false, true] {
let (a2, ma2) = vta_store();
a2.put_cached(Table::Repos, "github.com#812", &json!({"forgeId": 812}))
.unwrap();
for (k, v) in a.list::<u64>(Table::Mirror).unwrap() {
a2.put_cached(Table::Mirror, &k, &v).unwrap();
}
if reaped {
remote.reap_tombstones();
}
ma2.pull(&remote, &a2).await.unwrap();
assert_eq!(ma2.pending(), 0, "nothing to push (reaped: {reaped})");
assert!(
a2.get::<Value>(Table::Repos, "github.com#812")
.unwrap()
.is_none()
);
ma2.sync_once(&remote, &a2).await.unwrap();
assert!(!remote.snapshot().contains_key("state/repos/github.com#812"));
assert!(remote.snapshot().contains_key("state/namespaces/ns_1"));
}
}
#[tokio::test]
async fn a_rolled_back_remote_is_refused() {
let remote = MemoryAppState::new();
let (a, ma) = vta_store();
a.put(Table::Meta, "k", &json!(1)).unwrap();
ma.sync_once(&remote, &a).await.unwrap();
let mirrored: Vec<(String, u64)> = a.list(Table::Mirror).unwrap();
let (b, mb) = vta_store();
for (k, v) in mirrored {
b.put_cached(Table::Mirror, &k, &(v + 5)).unwrap();
}
let err = mb.pull(&remote, &b).await.unwrap_err();
assert!(err.to_string().contains("rolled back"), "{err}");
assert!(mb.stopped());
}
#[tokio::test]
async fn a_replayed_secret_does_not_open() {
let remote = MemoryAppState::new();
let (s, m) = vta_store();
s.put_secret("forgejo/codeberg.org/bot-token", b"old-token")
.unwrap();
m.sync_once(&remote, &s).await.unwrap();
let (_, old) = remote.raw("secret/forgejo/codeberg.org/bot-token").unwrap();
s.put_secret("forgejo/codeberg.org/bot-token", b"new-token")
.unwrap();
m.sync_once(&remote, &s).await.unwrap();
remote.put_as_other("secret/forgejo/codeberg.org/bot-token", old);
let (s2, m2) = vta_store();
let err = m2.pull(&remote, &s2).await.unwrap_err();
assert!(format!("{err:#}").contains("does not open"), "{err:#}");
}
#[tokio::test]
async fn cached_records_the_remote_lacks_are_written_back() {
let remote = MemoryAppState::new();
let (s, m) = vta_store();
s.put_cached(Table::Namespaces, "ns_9", &json!({"id": "ns_9"}))
.unwrap();
m.pull(&remote, &s).await.unwrap();
assert_eq!(m.pending(), 1);
m.sync_once(&remote, &s).await.unwrap();
assert!(remote.snapshot().contains_key("state/namespaces/ns_9"));
}
#[tokio::test]
async fn a_vta_rolled_back_to_before_a_record_existed_is_refused() {
let remote = MemoryAppState::new();
let (s, m) = vta_store();
s.put(Table::Namespaces, "ns_1", &json!({"id": "ns_1"}))
.unwrap();
m.sync_once(&remote, &s).await.unwrap();
s.put(Table::Namespaces, "ns_2", &json!({"id": "ns_2"}))
.unwrap();
m.sync_once(&remote, &s).await.unwrap();
let (_, ns1) = remote.raw("state/namespaces/ns_1").unwrap();
let restored = MemoryAppState::new();
restored.put_as_other("state/namespaces/ns_1", ns1);
assert_eq!(restored.list().await.unwrap().watermark, 1);
let m2 = Mirror::new(MasterKey::from_bytes([3u8; 32]));
let s2 = s.clone().with_mirror(m2.clone());
let err = m2.pull(&restored, &s2).await.unwrap_err();
assert!(err.to_string().contains("rolled back"), "{err}");
assert!(m2.stopped(), "fails closed: 503, nothing written");
assert!(
s2.get::<Value>(Table::Namespaces, "ns_2")
.unwrap()
.is_some(),
"ns_2 is kept, not taken for deleted"
);
}
#[tokio::test]
async fn a_secret_after_a_deletion_is_sealed_once() {
let remote = MemoryAppState::new();
let (s, m) = vta_store();
s.put(Table::Meta, "a", &json!(1)).unwrap();
m.sync_once(&remote, &s).await.unwrap();
s.delete(Table::Meta, "a").unwrap();
s.put_secret("github/github.com/acme/app", b"pem").unwrap();
m.sync_once(&remote, &s).await.unwrap();
assert_eq!(
remote.puts("secret/github/github.com/acme/app"),
1,
"sealed once"
);
let (s2, m2) = vta_store();
m2.pull(&remote, &s2).await.unwrap();
assert_eq!(
&*s2.get_secret("github/github.com/acme/app")
.unwrap()
.unwrap(),
b"pem"
);
}
#[tokio::test]
async fn writers_take_turns_under_the_lease_and_every_secret_opens() {
let remote = Arc::new(MemoryAppState::new());
let (s, m) = vta_store();
let seal = MasterKey::from_bytes([3u8; 32]);
let mut op = Lease::acquire(remote.as_ref(), "operator", Duration::from_secs(1))
.await
.unwrap();
s.put_secret("forgejo/codeberg.org/bot-token", b"tok")
.unwrap();
let pass = {
let (m, s, remote) = (m.clone(), s.clone(), remote.clone());
tokio::spawn(async move { m.sync_once(remote.as_ref(), &s).await })
};
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(!pass.is_finished(), "the mirror waits for the lease");
put_sealed(
remote.as_ref(),
&seal,
"forgejo/codeberg.org/webhook-secret",
b"wh",
None,
&mut op,
)
.await
.unwrap();
op.release(remote.as_ref()).await;
pass.await.unwrap().unwrap();
let (s2, m2) = vta_store();
m2.pull(remote.as_ref(), &s2).await.unwrap();
assert_eq!(
&*s2.get_secret("forgejo/codeberg.org/bot-token")
.unwrap()
.unwrap(),
b"tok"
);
assert_eq!(
&*s2.get_secret("forgejo/codeberg.org/webhook-secret")
.unwrap()
.unwrap(),
b"wh"
);
}
#[tokio::test]
async fn a_write_outside_the_lease_is_resealed_before_returning() {
let remote = MemoryAppState::new();
let seal = MasterKey::from_bytes([3u8; 32]);
let mut lease = Lease::acquire(&remote, "me", Duration::from_secs(1))
.await
.unwrap();
remote.put_as_other("state/meta/x", json!(1));
let v = put_sealed(&remote, &seal, "a/b", b"s", None, &mut lease)
.await
.unwrap();
let (at, value) = remote.raw("secret/a/b").unwrap();
assert_eq!(v, at);
assert_eq!(&*secret_bytes(&seal, "a/b", at, &value).unwrap(), b"s");
assert_eq!(
remote.puts("secret/a/b"),
2,
"sealed again after the stray write"
);
}
#[tokio::test]
async fn an_abandoned_lease_expires() {
let remote = MemoryAppState::new();
remote.put_as_other(LEASE_KEY, json!({ "holder": "crashed", "until": 0 }));
Lease::acquire(&remote, "me", Duration::from_secs(1))
.await
.unwrap();
remote.put_as_other(
LEASE_KEY,
json!({ "holder": "alive", "until": chrono::Utc::now().timestamp() + 60 }),
);
assert!(
Lease::acquire(&remote, "me", Duration::from_millis(300))
.await
.is_err()
);
}
#[tokio::test]
async fn a_lease_lasts_one_ttl_past_the_vtas_time_whatever_its_holder_claims() {
let remote = MemoryAppState::new();
let now = chrono::Utc::now().timestamp();
let forever = json!({ "holder": "greedy", "until": now + 1_000_000_000 });
remote.put_as_other_at(LEASE_KEY, forever.clone(), now);
assert!(
Lease::acquire(&remote, "me", Duration::from_millis(300))
.await
.is_err()
);
remote.put_as_other_at(LEASE_KEY, forever, now - LEASE_TTL_SECS - 1);
Lease::acquire(&remote, "me", Duration::from_secs(1))
.await
.unwrap();
let r = Record {
key: LEASE_KEY.into(),
version: 1,
deleted: false,
value: json!({ "holder": "x", "until": now + 1_000 }),
updated_at: None,
};
assert!(lease_expiry(&r) < now);
let r = Record {
updated_at: Some(now),
value: json!({ "holder": "x", "until": now + 10 }),
..r
};
assert_eq!(lease_expiry(&r), now + 10);
}
#[test]
fn a_request_fits_well_inside_half_a_lease() {
assert!(REQUEST_TIMEOUT.as_secs() * 3 <= (LEASE_TTL_SECS / 2) as u64);
}
#[tokio::test]
async fn a_rollback_the_counter_has_moved_past_is_refused_from_the_feed() {
let remote = MemoryAppState::new();
let (a, ma) = vta_store();
a.put(Table::Namespaces, "ns_1", &json!({"id": "ns_1"}))
.unwrap();
a.put(Table::Namespaces, "ns_2", &json!({"id": "ns_2"}))
.unwrap();
ma.sync_once(&remote, &a).await.unwrap();
remote.forget("state/namespaces/ns_2");
remote.bump(10);
let restart = || {
let (a2, ma2) = vta_store();
for t in [Table::Namespaces, Table::Mirror] {
for (k, v) in a.list::<Value>(t).unwrap() {
a2.put_cached(t, &k, &v).unwrap();
}
}
(a2, ma2)
};
let (a2, ma2) = restart();
let err = ma2.pull(&remote, &a2).await.unwrap_err();
assert!(is_state_refused(&err), "{err:#}");
assert!(format!("{err:#}").contains("rolled back"), "{err:#}");
assert!(ma2.stopped());
assert!(
a2.get::<Value>(Table::Namespaces, "ns_2")
.unwrap()
.is_some()
);
remote.reap_tombstones();
let (a3, ma3) = restart();
ma3.pull(&remote, &a3).await.unwrap();
assert!(
a3.get::<Value>(Table::Namespaces, "ns_2")
.unwrap()
.is_none()
);
assert_eq!(ma3.pending(), 0);
}
#[tokio::test]
async fn a_refused_state_is_not_retried_at_start() {
let calls = std::sync::atomic::AtomicUsize::new(0);
let started = tokio::time::Instant::now();
let err = crate::vta::with_retry(Duration::from_secs(30), "pulling", || async {
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err::<(), _>(anyhow::Error::new(StateRefused("rolled back".into())))
})
.await
.unwrap_err();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(started.elapsed() < Duration::from_secs(1));
assert!(is_state_refused(&err));
let remote = MemoryAppState::new();
let (a, ma) = vta_store();
a.put(Table::Meta, "k", &json!(1)).unwrap();
ma.sync_once(&remote, &a).await.unwrap();
let (b, mb) = vta_store();
for (k, v) in a.list::<u64>(Table::Mirror).unwrap() {
b.put_cached(Table::Mirror, &k, &(v + 5)).unwrap();
}
let calls = std::sync::atomic::AtomicUsize::new(0);
crate::vta::with_retry(Duration::from_secs(30), "pulling", || {
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
mb.pull(&remote, &b)
})
.await
.unwrap_err();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn changes_that_stop_reaching_the_vta_fail_health() {
let remote = MemoryAppState::new();
let (s, m) = vta_store();
remote.set_down(true);
s.put(Table::Meta, "k", &json!(1)).unwrap();
assert!(m.sync_once(&remote, &s).await.is_err());
let now = tokio::time::Instant::now();
assert!(
m.health_at(now).is_ok(),
"a failed pass or two is not a stall"
);
let err = m.health_at(now + STALL_LIMIT).unwrap_err();
assert!(err.contains("not reaching the VTA"), "{err}");
assert!(err.contains("1 change"), "{err}");
assert!(m.sync_once(&remote, &s).await.is_err());
assert!(m.health_at(now + STALL_LIMIT).is_err());
remote.set_down(false);
m.sync_once(&remote, &s).await.unwrap();
assert!(m.health_at(now + STALL_LIMIT * 2).is_ok());
}
}