feat: prepare for alternative stores

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-01-17 00:19:18 +01:00
parent f19cea610b
commit 41b6479137
10 changed files with 316 additions and 179 deletions

View File

@@ -11,7 +11,9 @@ use crossterm::{
};
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use nocontrol::{ControlPlane, Operator, Specification, manifests::ManifestState};
use nocontrol::{
ControlPlane, Operator, Specification, manifests::ManifestState, stores::BackingStoreEdge,
};
use ratatui::{
Frame, Terminal,
backend::{Backend, CrosstermBackend},
@@ -28,8 +30,8 @@ enum InputMode {
Search,
}
struct App<TOperator: Operator> {
control_plane: ControlPlane<TOperator>,
struct App<TOperator: Operator, TStore: BackingStoreEdge<TOperator::Specifications>> {
control_plane: ControlPlane<TOperator, TStore>,
manifests: Vec<ManifestState<TOperator::Specifications>>,
filtered_indices: Vec<usize>,
list_state: ListState,
@@ -47,8 +49,10 @@ struct App<TOperator: Operator> {
fuzzy_matcher: SkimMatcherV2,
}
impl<TOperator: Operator> App<TOperator> {
fn new(control_plane: ControlPlane<TOperator>) -> Self {
impl<TOperator: Operator, TStore: BackingStoreEdge<TOperator::Specifications>>
App<TOperator, TStore>
{
fn new(control_plane: ControlPlane<TOperator, TStore>) -> Self {
let mut list_state = ListState::default();
list_state.select(Some(0));
@@ -309,9 +313,13 @@ impl<TOperator: Operator> App<TOperator> {
}
}
async fn run_app<B: Backend, TOperator: Operator + Send + Sync + 'static>(
async fn run_app<
B: Backend,
TOperator: Operator + Send + Sync + 'static,
TStore: BackingStoreEdge<TOperator::Specifications> + Send + Sync + 'static,
>(
terminal: &mut Terminal<B>,
app: Arc<Mutex<App<TOperator>>>,
app: Arc<Mutex<App<TOperator, TStore>>>,
) -> anyhow::Result<()>
where
TOperator::Specifications: Send + Sync,
@@ -414,7 +422,10 @@ where
Ok(())
}
fn ui<TOperator: Operator>(f: &mut Frame, app: &mut App<TOperator>) {
fn ui<TOperator: Operator, TStore: BackingStoreEdge<TOperator::Specifications>>(
f: &mut Frame,
app: &mut App<TOperator, TStore>,
) {
// Create layout
let chunks = Layout::default()
.direction(Direction::Vertical)
@@ -472,7 +483,14 @@ fn ui<TOperator: Operator>(f: &mut Frame, app: &mut App<TOperator>) {
render_command_input(f, app, chunks[3]);
}
fn render_manifest_list<TOperator: Operator>(f: &mut Frame, app: &mut App<TOperator>, area: Rect) {
fn render_manifest_list<
TOperator: Operator,
TStore: BackingStoreEdge<TOperator::Specifications>,
>(
f: &mut Frame,
app: &mut App<TOperator, TStore>,
area: Rect,
) {
// Collect filtered manifests data before borrowing list_state
let filtered_data: Vec<_> = app
.filtered_indices
@@ -530,7 +548,14 @@ fn render_manifest_list<TOperator: Operator>(f: &mut Frame, app: &mut App<TOpera
f.render_stateful_widget(list, area, &mut app.list_state);
}
fn render_manifest_details<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>, area: Rect) {
fn render_manifest_details<
TOperator: Operator,
TStore: BackingStoreEdge<TOperator::Specifications>,
>(
f: &mut Frame,
app: &App<TOperator, TStore>,
area: Rect,
) {
let content = if let Some(manifest) = app.get_selected_manifest() {
let mut lines = vec![
Line::from(vec![
@@ -599,7 +624,11 @@ fn render_manifest_details<TOperator: Operator>(f: &mut Frame, app: &App<TOperat
f.render_widget(paragraph, area);
}
fn render_messages<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>, area: Rect) {
fn render_messages<TOperator: Operator, TStore: BackingStoreEdge<TOperator::Specifications>>(
f: &mut Frame,
app: &App<TOperator, TStore>,
area: Rect,
) {
let messages: Vec<Line> = app
.messages
.iter()
@@ -616,7 +645,14 @@ fn render_messages<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>, are
f.render_widget(paragraph, area);
}
fn render_command_input<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>, area: Rect) {
fn render_command_input<
TOperator: Operator,
TStore: BackingStoreEdge<TOperator::Specifications>,
>(
f: &mut Frame,
app: &App<TOperator, TStore>,
area: Rect,
) {
let (title, input_text, style) = match app.input_mode {
InputMode::Normal => {
let hist_info = if let Some(idx) = app.history_index {
@@ -650,10 +686,13 @@ fn render_command_input<TOperator: Operator>(f: &mut Frame, app: &App<TOperator>
}
/// Run the TUI with the given control plane
pub async fn run<TOperator>(control_plane: ControlPlane<TOperator>) -> anyhow::Result<()>
pub async fn run<TOperator, TStore>(
control_plane: ControlPlane<TOperator, TStore>,
) -> anyhow::Result<()>
where
TOperator: Operator + Send + Sync + 'static,
TOperator::Specifications: Send + Sync,
TStore: BackingStoreEdge<TOperator::Specifications> + Send + Sync + 'static,
{
// Setup terminal
enable_raw_mode()?;

View File

@@ -2,7 +2,10 @@ use tokio_util::sync::CancellationToken;
use crate::{
Operator, OperatorState,
control_plane::{backing_store::BackingStore, reconciler::Reconciler},
control_plane::{
backing_store::{BackingStore, BackingStoreEdge},
reconciler::Reconciler,
},
manifests::{Manifest, ManifestState},
};
@@ -10,18 +13,22 @@ pub mod backing_store;
pub mod reconciler;
#[derive(Clone)]
pub struct ControlPlane<TOperator: Operator> {
reconciler: Reconciler<TOperator>,
pub struct ControlPlane<TOperator: Operator, TStore: BackingStoreEdge<TOperator::Specifications>> {
reconciler: Reconciler<TOperator, TStore>,
worker_id: uuid::Uuid,
store: BackingStore<TOperator::Specifications>,
store: BackingStore<TOperator::Specifications, TStore>,
deadline: Option<std::time::Duration>,
}
impl<TOperator: Operator> ControlPlane<TOperator> {
pub fn new(operator: OperatorState<TOperator>) -> Self {
impl<TOperator: Operator, TStore: BackingStoreEdge<TOperator::Specifications>>
ControlPlane<TOperator, TStore>
{
pub fn new(
operator: OperatorState<TOperator>,
store: BackingStore<TOperator::Specifications, TStore>,
) -> Self {
let worker_id = uuid::Uuid::now_v7();
let store = BackingStore::<TOperator::Specifications>::new();
let reconciler = Reconciler::new(worker_id, &store, operator);

View File

@@ -1,174 +1,80 @@
use std::sync::Arc;
use std::{marker::PhantomData, ops::Deref};
use jiff::ToSpan;
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{
Specification,
manifests::{
Manifest, ManifestChangeEvent, ManifestChangeEventType, ManifestLease, ManifestState,
ManifestStatus, ManifestStatusState, WorkerId,
},
control_plane::backing_store::in_process::BackingStoreInProcess,
manifests::{Manifest, ManifestState, WorkerId},
};
pub mod in_process;
#[derive(Clone)]
pub struct BackingStore<T: Specification> {
manifests: Arc<RwLock<Vec<ManifestState<T>>>>,
pub struct BackingStore<T: Specification, TStore: BackingStoreEdge<T>> {
inner: TStore,
_marker: PhantomData<T>,
}
impl<T: Specification, TStore: BackingStoreEdge<T>> BackingStore<T, TStore> {
pub fn new(store: TStore) -> Self {
Self {
inner: store,
_marker: PhantomData,
}
}
}
impl<T: Specification> BackingStore<T> {
pub fn new() -> Self {
impl<T: Specification, TStore: BackingStoreEdge<T>> Deref for BackingStore<T, TStore> {
type Target = TStore;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T: Specification> BackingStore<T, BackingStoreInProcess<T>> {
pub fn in_process() -> Self {
Self {
manifests: Arc::new(RwLock::new(Vec::new())),
inner: BackingStoreInProcess::new(),
_marker: PhantomData,
}
}
}
pub async fn get_owned_and_potential_leases(
pub trait BackingStoreEdge<T: Specification>: Send + Sync + Clone {
fn get_owned_and_potential_leases(
&self,
worker_id: &WorkerId,
) -> anyhow::Result<Vec<ManifestState<T>>> {
let now = jiff::Timestamp::now().checked_sub(10.second())?;
let manifests = self
.manifests
.read()
.await
.iter()
.filter(|m| match &m.lease {
Some(lease) if lease.last_seen < now => true,
Some(lease) if &lease.owner == worker_id && lease.last_seen > now => true,
Some(_lease) => false,
None => true,
})
.cloned()
.collect::<Vec<_>>();
worker_id: &Uuid,
) -> impl std::future::Future<Output = anyhow::Result<Vec<ManifestState<T>>>> + Send;
Ok(manifests)
}
fn get_manifests(
&self,
) -> impl std::future::Future<Output = anyhow::Result<Vec<ManifestState<T>>>> + Send;
pub async fn get_manifests(&self) -> anyhow::Result<Vec<ManifestState<T>>> {
Ok(self.manifests.read().await.clone())
}
fn get(
&self,
name: &str,
) -> impl std::future::Future<Output = anyhow::Result<Option<ManifestState<T>>>> + Send;
pub async fn get(&self, name: &str) -> anyhow::Result<Option<ManifestState<T>>> {
Ok(self
.manifests
.read()
.await
.iter()
.find(|m| m.manifest.name == name)
.cloned())
}
fn update_lease(
&self,
manifest_state: &ManifestState<T>,
) -> impl std::future::Future<Output = anyhow::Result<()>> + Send;
pub async fn update_lease(&self, manifest_state: &ManifestState<T>) -> anyhow::Result<()> {
tracing::trace!(manifest_state.manifest.name, "updating lease");
let mut manifests = self.manifests.write().await;
match manifests
.iter_mut()
.find(|m| m.manifest.name == manifest_state.manifest.name)
{
Some(manifest) => {
let mut manifest_state = manifest_state.clone();
if let Some(lease) = manifest_state.lease.as_mut() {
lease.last_seen = jiff::Timestamp::now();
}
manifest.lease = manifest_state.lease
}
None => anyhow::bail!("manifest is not found"),
}
Ok(())
}
pub async fn acquire_lease(
fn acquire_lease(
&self,
manifest_state: &ManifestState<T>,
worker_id: &WorkerId,
) -> anyhow::Result<()> {
tracing::trace!(manifest_state.manifest.name, "acquiring lease");
let mut manifests = self.manifests.write().await;
) -> impl std::future::Future<Output = anyhow::Result<()>> + Send;
match manifests
.iter_mut()
.find(|m| m.manifest.name == manifest_state.manifest.name)
{
Some(manifest) => {
let mut manifest_state = manifest_state.clone();
manifest_state.lease = Some(ManifestLease {
owner: *worker_id,
last_seen: jiff::Timestamp::now(),
});
manifest.lease = manifest_state.lease
}
None => anyhow::bail!("manifest is not found"),
}
fn upsert_manifest(
&self,
manifest: Manifest<T>,
) -> impl std::future::Future<Output = anyhow::Result<()>> + Send;
Ok(())
}
pub async fn upsert_manifest(&self, manifest: Manifest<T>) -> anyhow::Result<()> {
let mut manifests = self.manifests.write().await;
let now = jiff::Timestamp::now();
match manifests
.iter_mut()
.find(|m| m.manifest.name == manifest.name)
{
Some(current_manifest) => {
tracing::debug!("updating manifest");
current_manifest.manifest = manifest;
current_manifest.updated = now;
}
None => {
tracing::debug!("adding manifest");
let content = serde_json::to_vec(&manifest)?;
let output = Sha256::digest(&content);
manifests.push(ManifestState {
manifest,
manifest_hash: output[..].to_vec(),
generation: 0,
status: ManifestStatus {
status: ManifestStatusState::Pending,
events: Vec::default(),
changes: vec![ManifestChangeEvent {
created: now,
handled: false,
event: ManifestChangeEventType::Changed,
}],
},
created: now,
updated: now,
lease: None,
});
}
}
Ok(())
}
pub async fn update_state(&self, manifest: &ManifestState<T>) -> anyhow::Result<()> {
let mut manifests = self.manifests.write().await;
let Some(current_manifest) = manifests
.iter_mut()
.find(|m| m.manifest.name == manifest.manifest.name)
else {
anyhow::bail!(
"manifest state was not found for {}",
manifest.manifest.name
)
};
let manifest = manifest.clone();
current_manifest.generation += 1;
current_manifest.status = manifest.status;
current_manifest.updated = manifest.updated;
Ok(())
}
fn update_state(
&self,
manifest: &ManifestState<T>,
) -> impl std::future::Future<Output = anyhow::Result<()>> + Send;
}

View File

@@ -0,0 +1,177 @@
use std::sync::Arc;
use jiff::ToSpan;
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
use crate::{
Specification,
control_plane::backing_store::BackingStoreEdge,
manifests::{
Manifest, ManifestChangeEvent, ManifestChangeEventType, ManifestLease, ManifestState,
ManifestStatus, ManifestStatusState, WorkerId,
},
};
#[derive(Clone, Default)]
pub struct BackingStoreInProcess<T: Specification> {
manifests: Arc<RwLock<Vec<ManifestState<T>>>>,
}
impl<T: Specification> BackingStoreEdge<T> for BackingStoreInProcess<T> {
async fn get_owned_and_potential_leases(
&self,
worker_id: &WorkerId,
) -> anyhow::Result<Vec<ManifestState<T>>> {
let now = jiff::Timestamp::now().checked_sub(10.second())?;
let manifests = self
.manifests
.read()
.await
.iter()
.filter(|m| match &m.lease {
Some(lease) if lease.last_seen < now => true,
Some(lease) if &lease.owner == worker_id && lease.last_seen > now => true,
Some(_lease) => false,
None => true,
})
.cloned()
.collect::<Vec<_>>();
Ok(manifests)
}
async fn get_manifests(&self) -> anyhow::Result<Vec<ManifestState<T>>> {
Ok(self.manifests.read().await.clone())
}
async fn get(&self, name: &str) -> anyhow::Result<Option<ManifestState<T>>> {
Ok(self
.manifests
.read()
.await
.iter()
.find(|m| m.manifest.name == name)
.cloned())
}
async fn update_lease(&self, manifest_state: &ManifestState<T>) -> anyhow::Result<()> {
tracing::trace!(manifest_state.manifest.name, "updating lease");
let mut manifests = self.manifests.write().await;
match manifests
.iter_mut()
.find(|m| m.manifest.name == manifest_state.manifest.name)
{
Some(manifest) => {
let mut manifest_state = manifest_state.clone();
if let Some(lease) = manifest_state.lease.as_mut() {
lease.last_seen = jiff::Timestamp::now();
}
manifest.lease = manifest_state.lease
}
None => anyhow::bail!("manifest is not found"),
}
Ok(())
}
async fn acquire_lease(
&self,
manifest_state: &ManifestState<T>,
worker_id: &WorkerId,
) -> anyhow::Result<()> {
tracing::trace!(manifest_state.manifest.name, "acquiring lease");
let mut manifests = self.manifests.write().await;
match manifests
.iter_mut()
.find(|m| m.manifest.name == manifest_state.manifest.name)
{
Some(manifest) => {
let mut manifest_state = manifest_state.clone();
manifest_state.lease = Some(ManifestLease {
owner: *worker_id,
last_seen: jiff::Timestamp::now(),
});
manifest.lease = manifest_state.lease
}
None => anyhow::bail!("manifest is not found"),
}
Ok(())
}
async fn upsert_manifest(&self, manifest: Manifest<T>) -> anyhow::Result<()> {
let mut manifests = self.manifests.write().await;
let now = jiff::Timestamp::now();
match manifests
.iter_mut()
.find(|m| m.manifest.name == manifest.name)
{
Some(current_manifest) => {
tracing::debug!("updating manifest");
current_manifest.manifest = manifest;
current_manifest.updated = now;
}
None => {
tracing::debug!("adding manifest");
let content = serde_json::to_vec(&manifest)?;
let output = Sha256::digest(&content);
manifests.push(ManifestState {
manifest,
manifest_hash: output[..].to_vec(),
generation: 0,
status: ManifestStatus {
status: ManifestStatusState::Pending,
events: Vec::default(),
changes: vec![ManifestChangeEvent {
created: now,
handled: false,
event: ManifestChangeEventType::Changed,
}],
},
created: now,
updated: now,
lease: None,
});
}
}
Ok(())
}
async fn update_state(&self, manifest: &ManifestState<T>) -> anyhow::Result<()> {
let mut manifests = self.manifests.write().await;
let Some(current_manifest) = manifests
.iter_mut()
.find(|m| m.manifest.name == manifest.manifest.name)
else {
anyhow::bail!(
"manifest state was not found for {}",
manifest.manifest.name
)
};
let manifest = manifest.clone();
current_manifest.generation += 1;
current_manifest.status = manifest.status;
current_manifest.updated = manifest.updated;
Ok(())
}
}
impl<T: Specification> BackingStoreInProcess<T> {
pub fn new() -> Self {
Self {
manifests: Arc::new(RwLock::new(Vec::new())),
}
}
}

View File

@@ -4,23 +4,23 @@ use tokio_util::sync::CancellationToken;
use crate::{
Operator, OperatorState,
control_plane::backing_store::BackingStore,
control_plane::backing_store::{BackingStore, BackingStoreEdge},
manifests::{Action, ManifestName, ManifestState, ManifestStatusState, WorkerId},
reconcile_queue::ReconcileQueue,
};
#[derive(Clone)]
pub struct Reconciler<T: Operator> {
pub struct Reconciler<T: Operator, TStore: BackingStoreEdge<T::Specifications>> {
worker_id: WorkerId,
store: BackingStore<T::Specifications>,
store: BackingStore<T::Specifications, TStore>,
operator: OperatorState<T>,
reconcile_queue: ReconcileQueue<T::Specifications>,
}
impl<T: Operator> Reconciler<T> {
impl<T: Operator, TStore: BackingStoreEdge<T::Specifications>> Reconciler<T, TStore> {
pub fn new(
worker_id: WorkerId,
store: &BackingStore<T::Specifications>,
store: &BackingStore<T::Specifications, TStore>,
operator: OperatorState<T>,
) -> Self {
Self {

View File

@@ -1,6 +1,10 @@
mod control_plane;
pub use control_plane::ControlPlane;
pub mod stores {
pub use crate::control_plane::backing_store::*;
}
pub mod manifests;
mod operator_state;

View File

@@ -2,7 +2,7 @@ use serde::{Serialize, de::DeserializeOwned};
use crate::manifests::{Action, ManifestState};
pub trait Specification: Clone + Serialize + DeserializeOwned {
pub trait Specification: Clone + Serialize + DeserializeOwned + Send + Sync + 'static {
fn kind(&self) -> &'static str;
}

View File

@@ -1,6 +1,7 @@
use nocontrol::{
Operator, OperatorState, Specification,
manifests::{Action, Manifest, ManifestMetadata, ManifestState},
stores::BackingStore,
};
use serde::{Deserialize, Serialize};
use tracing_test::traced_test;
@@ -9,7 +10,9 @@ use tracing_test::traced_test;
#[traced_test]
async fn test_can_run_reconciler() -> anyhow::Result<()> {
let operator = OperatorState::new(MyOperator {});
let mut control_plane = nocontrol::ControlPlane::new(operator);
let store = BackingStore::in_process();
let mut control_plane = nocontrol::ControlPlane::new(operator, store);
control_plane.with_deadline(std::time::Duration::from_secs(3));
tokio::spawn({

View File

@@ -3,6 +3,7 @@ use std::{collections::HashMap, future::Future, sync::Arc, time::Duration};
use nocontrol::{
Operator, OperatorState, Specification,
manifests::{Action, Manifest, ManifestMetadata, ManifestState, ManifestStatusState},
stores::BackingStore,
};
use noprocess::{HandleID, Process, ProcessHandler, ProcessManager, ProcessResult, ProcessState};
use serde::{Deserialize, Serialize};
@@ -25,7 +26,7 @@ async fn main() -> anyhow::Result<()> {
let process_manager = ProcessManager::new();
let operator = OperatorState::new(ProcessOperator::new(process_manager));
let control_plane = nocontrol::ControlPlane::new(operator);
let control_plane = nocontrol::ControlPlane::new(operator, BackingStore::in_process());
// Add initial process manifest - desired state is Running
control_plane

View File

@@ -7,4 +7,4 @@ run = "cargo nextest run"
[tasks.example]
alias = "e"
run = "cargo run --example kubernetes-like"
run = "cargo run --bin kubernetes-like"