use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vgi_forge::{Capabilities, ForgeAccount, NamespaceBinding, Resource, RoleAssignment};
use zeroize::Zeroizing;
use crate::seal::MasterKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Table {
Jobs,
Namespaces,
Repos,
Pending,
Outbox,
Deliveries,
Secrets,
Meta,
Branches,
Mirror,
}
impl Table {
const ALL: [Table; 10] = [
Table::Jobs,
Table::Namespaces,
Table::Repos,
Table::Pending,
Table::Outbox,
Table::Deliveries,
Table::Secrets,
Table::Meta,
Table::Branches,
Table::Mirror,
];
pub fn name(self) -> &'static str {
match self {
Table::Jobs => "jobs",
Table::Namespaces => "namespaces",
Table::Repos => "repos",
Table::Pending => "pending",
Table::Outbox => "outbox",
Table::Deliveries => "deliveries",
Table::Secrets => "secrets",
Table::Meta => "meta",
Table::Branches => "branches",
Table::Mirror => "mirror",
}
}
pub fn from_name(name: &str) -> Option<Table> {
Table::ALL.into_iter().find(|t| t.name() == name)
}
fn def(self) -> TableDefinition<'static, &'static str, &'static [u8]> {
TableDefinition::new(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum JobState {
Queued,
Running,
Waiting,
Finished,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct JobRecord {
pub job_id: String,
pub digest: String,
pub namespace: String,
pub kind: String,
pub payload: Option<Value>,
pub state: JobState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
pub received_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issued_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<i64>,
}
impl JobRecord {
pub fn queued(
job_id: impl Into<String>,
digest: impl Into<String>,
namespace: impl Into<String>,
kind: impl Into<String>,
payload: Value,
received_at: i64,
) -> Self {
JobRecord {
job_id: job_id.into(),
digest: digest.into(),
namespace: namespace.into(),
kind: kind.into(),
payload: Some(payload),
state: JobState::Queued,
next: None,
result: None,
received_at,
issued_at: None,
finished_at: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum NamespaceState {
Pending,
Bound,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PinRecord {
pub repository_id: u64,
pub sha: String,
pub check: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct NamespaceRecord {
pub id: String,
pub resource: Resource,
pub state: NamespaceState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<NamespaceBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<Capabilities>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required_workflow: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pin: Option<PinRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bridge_checks: Option<bool>,
#[serde(default)]
pub managed: BTreeSet<u64>,
}
impl NamespaceRecord {
pub fn pending(id: impl Into<String>, resource: Resource) -> Self {
NamespaceRecord {
id: id.into(),
resource,
state: NamespaceState::Pending,
binding: None,
capabilities: None,
required_workflow: None,
pin: None,
bridge_checks: None,
managed: BTreeSet::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RepoRecord {
pub namespace: String,
pub resource: Resource,
pub forge_id: u64,
#[serde(default)]
pub owners: Vec<ForgeAccount>,
#[serde(default)]
pub roles: Vec<RoleAssignment>,
#[serde(default)]
pub roles_known: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required_check: Option<String>,
#[serde(default)]
pub archived: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_drift: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guard: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_check: Option<LastCheck>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_map: Option<vgi_forge::RoleMap>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LastCheck {
pub sha: String,
pub conclusion: String,
pub at: i64,
}
impl LastCheck {
pub fn new(sha: impl Into<String>, conclusion: impl Into<String>, at: i64) -> Self {
LastCheck {
sha: sha.into(),
conclusion: conclusion.into(),
at,
}
}
}
impl RepoRecord {
pub fn new(namespace: impl Into<String>, resource: Resource, forge_id: u64) -> Self {
RepoRecord {
namespace: namespace.into(),
resource,
forge_id,
owners: Vec::new(),
roles: Vec::new(),
roles_known: false,
required_check: None,
archived: false,
last_drift: None,
guard: None,
last_check: None,
role_map: None,
}
}
pub fn key(&self) -> String {
repo_key(self.resource.host(), self.forge_id)
}
}
pub fn repo_key(host: &str, forge_id: u64) -> String {
format!("{host}#{forge_id}")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PushRecord {
pub before: String,
pub after: String,
pub sender_login: String,
pub sender_id: u64,
pub created: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery_id: Option<String>,
pub at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OwnPush {
pub before: String,
pub after: String,
pub at: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct BranchLedger {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<Resource>,
#[serde(default)]
pub branch: String,
#[serde(default)]
pub pushes: Vec<PushRecord>,
#[serde(default)]
pub own: Vec<OwnPush>,
#[serde(default)]
pub overflow: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<u64>,
#[serde(default)]
pub touched: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_pushed_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_skip: Option<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[non_exhaustive]
pub enum PendingFlow {
Bind {
job_id: String,
namespace: String,
resource: Resource,
expires_at: i64,
},
Link {
job_id: String,
namespace: String,
host: String,
member: String,
expires_at: i64,
#[serde(default)]
device: Option<DevicePoll>,
},
Manifest {
host: String,
owner: Option<String>,
expires_at: i64,
},
}
impl PendingFlow {
pub fn expires_at(&self) -> i64 {
match self {
PendingFlow::Bind { expires_at, .. }
| PendingFlow::Link { expires_at, .. }
| PendingFlow::Manifest { expires_at, .. } => *expires_at,
}
}
pub fn job_id(&self) -> Option<&str> {
match self {
PendingFlow::Bind { job_id, .. } | PendingFlow::Link { job_id, .. } => Some(job_id),
PendingFlow::Manifest { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DevicePoll {
pub interval: u64,
pub expires_in: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OutboxEntry {
pub kind: OutboxKind,
pub payload: Value,
#[serde(default)]
pub doc_ids: Vec<String>,
pub last_sent: i64,
pub attempts: u32,
}
impl OutboxEntry {
pub fn result(payload: Value) -> Self {
OutboxEntry {
kind: OutboxKind::Result,
payload,
doc_ids: Vec::new(),
last_sent: 0,
attempts: 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum OutboxKind {
Result,
Event,
}
#[derive(Clone)]
pub struct Store {
db: Arc<Database>,
key: Arc<MasterKey>,
mirror: Option<Arc<crate::appstate::Mirror>>,
}
impl std::fmt::Debug for Store {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Store").finish_non_exhaustive()
}
}
impl Store {
pub fn open(path: &Path, key: MasterKey) -> Result<Self> {
let db = Database::create(path).with_context(|| {
format!(
"opening the state store {} (is another bridge running on it?)",
path.display()
)
})?;
Self::with_db(db, key)
}
pub fn in_memory(key: MasterKey) -> Result<Self> {
let db = Database::builder().create_with_backend(redb::backends::InMemoryBackend::new())?;
Self::with_db(db, key)
}
fn with_db(db: Database, key: MasterKey) -> Result<Self> {
let w = db.begin_write()?;
for t in Table::ALL {
w.open_table(t.def())?;
}
w.commit()?;
Ok(Store {
db: Arc::new(db),
key: Arc::new(key),
mirror: None,
})
}
pub fn with_mirror(mut self, mirror: Arc<crate::appstate::Mirror>) -> Self {
self.mirror = Some(mirror);
self
}
pub fn mirror(&self) -> Option<&Arc<crate::appstate::Mirror>> {
self.mirror.as_ref()
}
pub async fn flush(&self, timeout: std::time::Duration) -> bool {
match &self.mirror {
Some(m) => m.flush(timeout).await,
None => true,
}
}
fn touched(&self, table: Table, key: &str) {
if let Some(m) = &self.mirror
&& crate::appstate::record_is_mirrored(table, key)
{
m.mark(crate::appstate::Dirty::Record(table, key.to_string()));
}
}
pub(crate) fn put_cached<T: Serialize>(
&self,
table: Table,
key: &str,
value: &T,
) -> Result<()> {
let bytes = serde_json::to_vec(value)?;
let w = self.db.begin_write()?;
w.open_table(table.def())?.insert(key, bytes.as_slice())?;
w.commit()?;
Ok(())
}
pub(crate) fn delete_cached(&self, table: Table, key: &str) -> Result<()> {
let w = self.db.begin_write()?;
w.open_table(table.def())?.remove(key)?;
w.commit()?;
Ok(())
}
pub(crate) fn get_raw(&self, table: Table, key: &str) -> Result<Option<Value>> {
self.get(table, key)
}
pub(crate) fn keys(&self, table: Table) -> Result<Vec<String>> {
let r = self.db.begin_read()?;
let t = r.open_table(table.def())?;
let mut out = Vec::new();
for row in t.iter()? {
out.push(row?.0.value().to_string());
}
Ok(out)
}
pub fn is_empty(&self, table: Table) -> Result<bool> {
let r = self.db.begin_read()?;
let t = r.open_table(table.def())?;
Ok(t.iter()?.next().is_none())
}
#[cfg(test)]
pub(crate) fn raw_secret_rows(&self) -> Result<Vec<String>> {
self.keys(Table::Secrets)
}
pub fn get<T: DeserializeOwned>(&self, table: Table, key: &str) -> Result<Option<T>> {
let r = self.db.begin_read()?;
let t = r.open_table(table.def())?;
match t.get(key)? {
Some(v) => Ok(Some(
serde_json::from_slice(v.value())
.with_context(|| format!("decoding {table:?}/{key}"))?,
)),
None => Ok(None),
}
}
pub fn put<T: Serialize>(&self, table: Table, key: &str, value: &T) -> Result<()> {
let bytes = serde_json::to_vec(value)?;
let w = self.db.begin_write()?;
w.open_table(table.def())?.insert(key, bytes.as_slice())?;
w.commit()?;
self.touched(table, key);
Ok(())
}
pub fn put_new<T: Serialize>(&self, table: Table, key: &str, value: &T) -> Result<bool> {
let bytes = serde_json::to_vec(value)?;
let w = self.db.begin_write()?;
{
let mut t = w.open_table(table.def())?;
if t.get(key)?.is_some() {
return Ok(false);
}
t.insert(key, bytes.as_slice())?;
}
w.commit()?;
self.touched(table, key);
Ok(true)
}
pub fn update<T, R>(
&self,
table: Table,
key: &str,
f: impl FnOnce(Option<T>) -> Result<(Option<T>, R)>,
) -> Result<R>
where
T: Serialize + DeserializeOwned,
{
let w = self.db.begin_write()?;
let out = {
let mut t = w.open_table(table.def())?;
let current = match t.get(key)? {
Some(v) => Some(serde_json::from_slice(v.value())?),
None => None,
};
let (next, out) = f(current)?;
match next {
Some(v) => {
let bytes = serde_json::to_vec(&v)?;
t.insert(key, bytes.as_slice())?;
}
None => {
t.remove(key)?;
}
}
out
};
w.commit()?;
self.touched(table, key);
Ok(out)
}
pub fn finish_job(&self, job_id: &str, result: &Value, finished_at: i64) -> Result<bool> {
let w = self.db.begin_write()?;
{
let mut jobs = w.open_table(Table::Jobs.def())?;
let Some(current) = jobs.get(job_id)? else {
return Ok(false);
};
let mut rec: JobRecord = serde_json::from_slice(current.value())?;
drop(current);
if rec.state == JobState::Finished {
return Ok(false);
}
rec.state = JobState::Finished;
rec.result = Some(result.clone());
rec.payload = None;
rec.finished_at = Some(finished_at);
let bytes = serde_json::to_vec(&rec)?;
jobs.insert(job_id, bytes.as_slice())?;
let entry = OutboxEntry::result(result.clone());
let bytes = serde_json::to_vec(&entry)?;
w.open_table(Table::Outbox.def())?
.insert(format!("result:{job_id}").as_str(), bytes.as_slice())?;
}
w.commit()?;
Ok(true)
}
pub fn delete(&self, table: Table, key: &str) -> Result<bool> {
let w = self.db.begin_write()?;
let existed = w.open_table(table.def())?.remove(key)?.is_some();
w.commit()?;
if existed {
self.touched(table, key);
}
Ok(existed)
}
pub fn list<T: DeserializeOwned>(&self, table: Table) -> Result<Vec<(String, T)>> {
let r = self.db.begin_read()?;
let t = r.open_table(table.def())?;
let mut out = Vec::new();
for row in t.iter()? {
let (k, v) = row?;
let key = k.value().to_string();
let value = serde_json::from_slice(v.value())
.with_context(|| format!("decoding {table:?}/{key}"))?;
out.push((key, value));
}
Ok(out)
}
pub fn put_secret(&self, name: &str, value: &[u8]) -> Result<()> {
if let Some(m) = &self.mirror {
m.secrets
.lock()
.expect("lock")
.insert(name.to_string(), Zeroizing::new(value.to_vec()));
if crate::appstate::secret_is_mirrored(name) {
m.mark(crate::appstate::Dirty::Secret(name.to_string()));
}
return Ok(());
}
let sealed = self.key.seal(name, value)?;
let w = self.db.begin_write()?;
w.open_table(Table::Secrets.def())?
.insert(name, sealed.as_slice())?;
w.commit()?;
Ok(())
}
pub fn get_secret(&self, name: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
if let Some(m) = &self.mirror {
return Ok(m.secrets.lock().expect("lock").get(name).cloned());
}
let r = self.db.begin_read()?;
let t = r.open_table(Table::Secrets.def())?;
match t.get(name)? {
Some(v) => Ok(Some(self.key.open(name, v.value())?)),
None => Ok(None),
}
}
pub fn get_secret_string(&self, name: &str) -> Result<Option<Zeroizing<String>>> {
match self.get_secret(name)? {
Some(bytes) => Ok(Some(Zeroizing::new(
String::from_utf8(bytes.to_vec())
.map_err(|_| anyhow::anyhow!("secret `{name}` is not UTF-8"))?,
))),
None => Ok(None),
}
}
pub fn delete_secret(&self, name: &str) -> Result<()> {
if let Some(m) = &self.mirror {
let existed = m.secrets.lock().expect("lock").remove(name).is_some();
if existed && crate::appstate::secret_is_mirrored(name) {
m.mark(crate::appstate::Dirty::Secret(name.to_string()));
}
return Ok(());
}
self.delete(Table::Secrets, name).map(|_| ())
}
pub fn secret_names(&self) -> Result<Vec<String>> {
if let Some(m) = &self.mirror {
return Ok(m.secrets.lock().expect("lock").keys().cloned().collect());
}
let r = self.db.begin_read()?;
let t = r.open_table(Table::Secrets.def())?;
let mut out = Vec::new();
for row in t.iter()? {
out.push(row?.0.value().to_string());
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> Store {
Store::in_memory(MasterKey::generate().unwrap()).unwrap()
}
#[test]
fn records_round_trip_and_update_is_atomic() {
let s = store();
let ns = NamespaceRecord::pending("ns_1", Resource::parse("github.com/acme").unwrap());
s.put(Table::Namespaces, "ns_1", &ns).unwrap();
let back: NamespaceRecord = s.get(Table::Namespaces, "ns_1").unwrap().unwrap();
assert_eq!(back.state, NamespaceState::Pending);
let n = s
.update::<NamespaceRecord, _>(Table::Namespaces, "ns_1", |r| {
let mut r = r.unwrap();
r.managed.insert(9);
let n = r.managed.len();
Ok((Some(r), n))
})
.unwrap();
assert_eq!(n, 1);
assert!(!s.put_new(Table::Namespaces, "ns_1", &back).unwrap());
assert_eq!(
s.list::<NamespaceRecord>(Table::Namespaces).unwrap().len(),
1
);
assert!(s.delete(Table::Namespaces, "ns_1").unwrap());
assert!(
s.get::<NamespaceRecord>(Table::Namespaces, "ns_1")
.unwrap()
.is_none()
);
}
#[test]
fn finishing_a_job_records_the_result_and_queues_it_together() {
let s = store();
let job = JobRecord::queued("j1", "d", "ns", "inspect", serde_json::json!({}), 0);
s.put(Table::Jobs, "j1", &job).unwrap();
let result = serde_json::json!({ "jobId": "j1", "outcome": "succeeded" });
assert!(s.finish_job("j1", &result, 5).unwrap());
let rec: JobRecord = s.get(Table::Jobs, "j1").unwrap().unwrap();
assert_eq!(rec.state, JobState::Finished);
assert_eq!(rec.result.as_ref(), Some(&result));
assert!(rec.payload.is_none());
let queued: OutboxEntry = s.get(Table::Outbox, "result:j1").unwrap().unwrap();
assert_eq!(queued.payload, result);
s.delete(Table::Outbox, "result:j1").unwrap();
assert!(!s.finish_job("j1", &serde_json::json!({}), 6).unwrap());
assert!(
s.get::<OutboxEntry>(Table::Outbox, "result:j1")
.unwrap()
.is_none()
);
assert!(!s.finish_job("unknown", &result, 6).unwrap());
}
#[test]
fn secrets_are_stored_sealed() {
let s = store();
s.put_secret("github/github.com/app", b"pem-bytes").unwrap();
assert_eq!(
&*s.get_secret("github/github.com/app").unwrap().unwrap(),
b"pem-bytes"
);
let raw =
s.db.begin_read()
.unwrap()
.open_table(Table::Secrets.def())
.unwrap()
.get("github/github.com/app")
.unwrap()
.unwrap()
.value()
.to_vec();
assert!(
!raw.windows(9).any(|w| w == b"pem-bytes"),
"ciphertext only"
);
assert_eq!(s.secret_names().unwrap(), ["github/github.com/app"]);
}
#[test]
fn a_file_store_survives_reopening() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.redb");
let key = MasterKey::generate().unwrap();
let text = key.to_text();
{
let s = Store::open(&path, key).unwrap();
s.put_secret("x", b"y").unwrap();
s.put(Table::Deliveries, "d-1", &1_i64).unwrap();
}
let s = Store::open(&path, MasterKey::from_text(&text).unwrap()).unwrap();
assert_eq!(&*s.get_secret("x").unwrap().unwrap(), b"y");
assert_eq!(s.get::<i64>(Table::Deliveries, "d-1").unwrap(), Some(1));
}
}