feat: add very basic in memory coordination
Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2
.drone.yml
Normal file
2
.drone.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
kind: template
|
||||||
|
load: cuddle-rust-lib-plan.yaml
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
.cuddle/
|
||||||
1028
Cargo.lock
generated
Normal file
1028
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
Normal file
10
Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["crates/*"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
nocontrol = { path = "crates/nocontrol" }
|
||||||
|
|
||||||
|
anyhow = { version = "1.0.71" }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tracing = { version = "0.1", features = ["log"] }
|
||||||
23
crates/nocontrol/Cargo.toml
Normal file
23
crates/nocontrol/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "nocontrol"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
hex = "0.4.3"
|
||||||
|
jiff = { version = "0.2.17", features = ["serde"] }
|
||||||
|
rand = "0.9.2"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.148"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-util = "0.7.18"
|
||||||
|
tracing.workspace = true
|
||||||
|
uuid = { version = "1.19.0", features = ["serde", "v4", "v7"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
insta = "1.46.0"
|
||||||
|
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
|
||||||
|
tracing-test = { version = "0.2.5", features = ["no-env-filter"] }
|
||||||
200
crates/nocontrol/examples/kubernetes-like/main.rs
Normal file
200
crates/nocontrol/examples/kubernetes-like/main.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
use std::io::{BufRead, Write};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use nocontrol::{
|
||||||
|
manifests::{Manifest, ManifestMetadata, ManifestState},
|
||||||
|
Operator,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let output_file = std::fs::File::create("target/nocontrol.log")?;
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
// .pretty()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env())
|
||||||
|
.with_writer(output_file)
|
||||||
|
.with_file(false)
|
||||||
|
.with_line_number(false)
|
||||||
|
.with_target(false)
|
||||||
|
.without_time()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let operator = MyOperator {};
|
||||||
|
|
||||||
|
let mut control_plane = nocontrol::ControlPlane::new(operator);
|
||||||
|
// control_plane.with_deadline(std::time::Duration::from_secs(4));
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let control_plane = control_plane.clone();
|
||||||
|
async move {
|
||||||
|
control_plane
|
||||||
|
.add_manifest(Manifest {
|
||||||
|
name: "some-manifest".into(),
|
||||||
|
metadata: ManifestMetadata {},
|
||||||
|
spec: Specifications::Deployment(DeploymentControllerManifest {
|
||||||
|
name: "some-name".into(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let rand = {
|
||||||
|
use rand::prelude::*;
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
rng.random_range(2..5)
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(rand)).await;
|
||||||
|
|
||||||
|
let random = uuid::Uuid::now_v7();
|
||||||
|
|
||||||
|
control_plane
|
||||||
|
.add_manifest(Manifest {
|
||||||
|
name: "some-manifest".into(),
|
||||||
|
metadata: ManifestMetadata {},
|
||||||
|
spec: Specifications::Deployment(DeploymentControllerManifest {
|
||||||
|
name: format!("some-changed-name: {}", random),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debugging shell
|
||||||
|
tokio::spawn({
|
||||||
|
let control_plane = control_plane.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let ui = Ui {};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
ui.write("> ");
|
||||||
|
let cmd = ui.read_line();
|
||||||
|
|
||||||
|
let items = cmd.split(" ").map(|t| t.to_string()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let (command, args) = match &items[..] {
|
||||||
|
[first, rest @ ..] => (first, rest.to_vec()),
|
||||||
|
//[first] => (first, vec![]),
|
||||||
|
_ => {
|
||||||
|
ui.writeln("invalid command");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match (command.as_str(), args.as_slice()) {
|
||||||
|
("get", _) => {
|
||||||
|
// get all for now
|
||||||
|
let manifests = control_plane
|
||||||
|
.get_manifests()
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| ui.writeln(format!("get failed: {e:#}")))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ui.writeln("listing manifests");
|
||||||
|
|
||||||
|
for manifest in manifests {
|
||||||
|
ui.writeln(format!(" - {}", manifest.manifest.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("describe", [manifest_name, ..]) => {
|
||||||
|
let manifests = control_plane
|
||||||
|
.get_manifests()
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| ui.writeln(format!("get failed: {e:#}")))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if let Some(manifest) =
|
||||||
|
manifests.iter().find(|m| &m.manifest.name == manifest_name)
|
||||||
|
{
|
||||||
|
let output = serde_json::to_string_pretty(&manifest).unwrap();
|
||||||
|
ui.writeln(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(cmd, _) => ui.writeln(format!("command is not implemented: {}", cmd)),
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.writeln("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
control_plane.execute().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MyOperator {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Operator for MyOperator {
|
||||||
|
type Specifications = Specifications;
|
||||||
|
|
||||||
|
async fn reconcile(
|
||||||
|
&self,
|
||||||
|
desired_manifest: &mut ManifestState<Specifications>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let now = jiff::Timestamp::now();
|
||||||
|
|
||||||
|
desired_manifest.status.status = nocontrol::manifests::ManifestStatusState::Started;
|
||||||
|
desired_manifest.updated = now;
|
||||||
|
|
||||||
|
match &desired_manifest.manifest.spec {
|
||||||
|
Specifications::Deployment(spec) => {
|
||||||
|
tracing::info!(
|
||||||
|
"reconciliation was called for name = {}, value = {}",
|
||||||
|
desired_manifest.manifest.name,
|
||||||
|
spec.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
desired_manifest.status.status = nocontrol::manifests::ManifestStatusState::Running;
|
||||||
|
desired_manifest.updated = now;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub enum Specifications {
|
||||||
|
Deployment(DeploymentControllerManifest),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeploymentControllerManifest {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Ui {}
|
||||||
|
|
||||||
|
impl Ui {
|
||||||
|
pub fn write(&self, msg: &str) {
|
||||||
|
let mut stderr = std::io::stderr().lock();
|
||||||
|
stderr.write_all(msg.as_bytes()).unwrap();
|
||||||
|
stderr.flush().unwrap()
|
||||||
|
}
|
||||||
|
pub fn writeln(&self, msg: impl AsRef<str>) {
|
||||||
|
let msg = msg.as_ref();
|
||||||
|
|
||||||
|
let mut stderr = std::io::stderr().lock();
|
||||||
|
stderr.write_all(msg.as_bytes()).unwrap();
|
||||||
|
writeln!(stderr).unwrap();
|
||||||
|
stderr.flush().unwrap()
|
||||||
|
}
|
||||||
|
pub fn read_line(&self) -> String {
|
||||||
|
let mut stdin = std::io::stdin().lock();
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
stdin.read_line(&mut output).unwrap();
|
||||||
|
|
||||||
|
output.trim().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
77
crates/nocontrol/src/control_plane.rs
Normal file
77
crates/nocontrol/src/control_plane.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
control_plane::{backing_store::BackingStore, reconciler::Reconciler},
|
||||||
|
manifests::{Manifest, ManifestState},
|
||||||
|
Operator,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod backing_store;
|
||||||
|
pub mod reconciler;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ControlPlane<TOperator: Operator> {
|
||||||
|
reconciler: Reconciler<TOperator>,
|
||||||
|
worker_id: uuid::Uuid,
|
||||||
|
store: BackingStore<TOperator::Specifications>,
|
||||||
|
|
||||||
|
deadline: Option<std::time::Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<TOperator: Operator> ControlPlane<TOperator> {
|
||||||
|
pub fn new(operator: TOperator) -> Self {
|
||||||
|
let worker_id = uuid::Uuid::now_v7();
|
||||||
|
let store = BackingStore::<TOperator::Specifications>::new();
|
||||||
|
|
||||||
|
let reconciler = Reconciler::new(worker_id, &store, operator);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
reconciler,
|
||||||
|
worker_id,
|
||||||
|
deadline: None,
|
||||||
|
store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_deadline(&mut self, deadline: std::time::Duration) -> &mut Self {
|
||||||
|
self.deadline = Some(deadline);
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self) -> anyhow::Result<()> {
|
||||||
|
tracing::info!(worker_id = %self.worker_id, "starting control plane");
|
||||||
|
|
||||||
|
let cancellation_token = CancellationToken::new();
|
||||||
|
let child_token = cancellation_token.child_token();
|
||||||
|
if let Some(deadline) = self.deadline {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(deadline).await;
|
||||||
|
cancellation_token.cancel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.reconciler.reconcile(&child_token).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_manifest(
|
||||||
|
&self,
|
||||||
|
manifest: Manifest<TOperator::Specifications>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
tracing::info!(manifest.name, "adding manifest");
|
||||||
|
|
||||||
|
self.store.upsert_manifest(manifest).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_manifests(
|
||||||
|
&self,
|
||||||
|
) -> anyhow::Result<Vec<ManifestState<TOperator::Specifications>>> {
|
||||||
|
self.store.get_manifests().await
|
||||||
|
}
|
||||||
|
}
|
||||||
152
crates/nocontrol/src/control_plane/backing_store.rs
Normal file
152
crates/nocontrol/src/control_plane/backing_store.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use jiff::ToSpan;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::manifests::{
|
||||||
|
Manifest, ManifestLease, ManifestState, ManifestStatus, ManifestStatusState, WorkerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BackingStore<T: Clone + Serialize> {
|
||||||
|
manifests: Arc<RwLock<Vec<ManifestState<T>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Clone + Serialize> BackingStore<T> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
manifests: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_owned_and_potential_leases(&self) -> anyhow::Result<Vec<ManifestState<T>>> {
|
||||||
|
let now = jiff::Timestamp::now().checked_sub(1.second())?;
|
||||||
|
let manifests = self
|
||||||
|
.manifests
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.filter(|m| match &m.lease {
|
||||||
|
Some(lease) if lease.last_seen < now => true,
|
||||||
|
Some(_lease) => false,
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(manifests)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_manifests(&self) -> anyhow::Result<Vec<ManifestState<T>>> {
|
||||||
|
Ok(self.manifests.read().await.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
&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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
77
crates/nocontrol/src/control_plane/reconciler.rs
Normal file
77
crates/nocontrol/src/control_plane/reconciler.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::{control_plane::backing_store::BackingStore, manifests::WorkerId, Operator};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Reconciler<T: Operator> {
|
||||||
|
worker_id: WorkerId,
|
||||||
|
store: BackingStore<T::Specifications>,
|
||||||
|
operator: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Operator> Reconciler<T> {
|
||||||
|
pub fn new(worker_id: WorkerId, store: &BackingStore<T::Specifications>, operator: T) -> Self {
|
||||||
|
Self {
|
||||||
|
worker_id,
|
||||||
|
store: store.clone(),
|
||||||
|
operator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reconcile(&self, cancellation_token: &CancellationToken) -> anyhow::Result<()> {
|
||||||
|
let now = jiff::Timestamp::now();
|
||||||
|
tracing::debug!(%self.worker_id, %now, "running reconciler");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let now = jiff::Timestamp::now();
|
||||||
|
if cancellation_token.is_cancelled() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tracing::trace!(%self.worker_id, %now, "reconciler iteration");
|
||||||
|
|
||||||
|
let mut our_manifests = Vec::new();
|
||||||
|
// 1. read manifests from a backing store
|
||||||
|
for manifest_state in self.store.get_owned_and_potential_leases().await? {
|
||||||
|
// 3. Lease the manifest
|
||||||
|
match &manifest_state.lease {
|
||||||
|
Some(lease) if lease.owner == self.worker_id => {
|
||||||
|
// We own the lease, update
|
||||||
|
self.store
|
||||||
|
.update_lease(&manifest_state)
|
||||||
|
.await
|
||||||
|
.context("update lease")?;
|
||||||
|
our_manifests.push(manifest_state.clone());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// 2. If no lease
|
||||||
|
// Acquire lease
|
||||||
|
self.store
|
||||||
|
.acquire_lease(&manifest_state, &self.worker_id)
|
||||||
|
.await
|
||||||
|
.context("acquire lease")?;
|
||||||
|
our_manifests.push(manifest_state.clone());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Skipping manifest, as it is not vaid
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check desired vs actual
|
||||||
|
for manifest in our_manifests.iter_mut() {
|
||||||
|
// Currently periodic sync,
|
||||||
|
// TODO: this should also be made event based
|
||||||
|
self.operator.reconcile(manifest).await?;
|
||||||
|
self.store.update_state(manifest).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("reconciler shutting down");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/nocontrol/src/lib.rs
Normal file
17
crates/nocontrol/src/lib.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
mod control_plane;
|
||||||
|
pub mod manifests;
|
||||||
|
|
||||||
|
pub use control_plane::ControlPlane;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::manifests::{Manifest, ManifestState};
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait Operator {
|
||||||
|
type Specifications: Clone + Serialize;
|
||||||
|
|
||||||
|
async fn reconcile(
|
||||||
|
&self,
|
||||||
|
desired_manifest: &mut ManifestState<Self::Specifications>,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
|
}
|
||||||
79
crates/nocontrol/src/manifests.rs
Normal file
79
crates/nocontrol/src/manifests.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ManifestState<T: Clone> {
|
||||||
|
pub manifest: Manifest<T>,
|
||||||
|
pub manifest_hash: Vec<u8>,
|
||||||
|
pub generation: u64,
|
||||||
|
pub status: ManifestStatus,
|
||||||
|
|
||||||
|
pub created: jiff::Timestamp,
|
||||||
|
pub updated: jiff::Timestamp,
|
||||||
|
|
||||||
|
pub lease: Option<ManifestLease>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type WorkerId = uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ManifestLease {
|
||||||
|
pub owner: WorkerId,
|
||||||
|
pub last_seen: jiff::Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Manifest<T> {
|
||||||
|
pub name: String,
|
||||||
|
pub metadata: ManifestMetadata,
|
||||||
|
pub spec: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ManifestStatus {
|
||||||
|
pub status: ManifestStatusState,
|
||||||
|
pub events: Vec<ManifestEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum ManifestStatusState {
|
||||||
|
Pending,
|
||||||
|
Started,
|
||||||
|
Running,
|
||||||
|
Stopping,
|
||||||
|
Deleting,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ManifestEvent {
|
||||||
|
pub owner: WorkerId,
|
||||||
|
pub created: u64,
|
||||||
|
pub message: String,
|
||||||
|
pub state: Option<ManifestStatusState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ManifestMetadata {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "@type")]
|
||||||
|
pub enum ManifestSpecification {
|
||||||
|
SchemaApplication {},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::manifests::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest() -> anyhow::Result<()> {
|
||||||
|
let manifest = Manifest {
|
||||||
|
name: "ingest".into(),
|
||||||
|
metadata: ManifestMetadata {},
|
||||||
|
spec: ManifestSpecification::SchemaApplication {},
|
||||||
|
};
|
||||||
|
|
||||||
|
insta::assert_debug_snapshot!(manifest);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
source: crates/nocontrol/src/manifests.rs
|
||||||
|
expression: manifest
|
||||||
|
---
|
||||||
|
Manifest {
|
||||||
|
name: "ingest",
|
||||||
|
metadata: ManifestMetadata,
|
||||||
|
spec: SchemaApplication,
|
||||||
|
}
|
||||||
91
crates/nocontrol/tests/mod.rs
Normal file
91
crates/nocontrol/tests/mod.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use nocontrol::{
|
||||||
|
manifests::{Manifest, ManifestMetadata, ManifestState},
|
||||||
|
Operator,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing_test::traced_test;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[traced_test]
|
||||||
|
async fn test_can_run_reconciler() -> anyhow::Result<()> {
|
||||||
|
let operator = MyOperator {};
|
||||||
|
|
||||||
|
let mut control_plane = nocontrol::ControlPlane::new(operator);
|
||||||
|
control_plane.with_deadline(std::time::Duration::from_secs(3));
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let control_plane = control_plane.clone();
|
||||||
|
async move {
|
||||||
|
control_plane
|
||||||
|
.add_manifest(Manifest {
|
||||||
|
name: "some-manifest".into(),
|
||||||
|
metadata: ManifestMetadata {},
|
||||||
|
spec: Specifications::Deployment(DeploymentControllerManifest {
|
||||||
|
name: "some-name".into(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
|
||||||
|
control_plane
|
||||||
|
.add_manifest(Manifest {
|
||||||
|
name: "some-manifest".into(),
|
||||||
|
metadata: ManifestMetadata {},
|
||||||
|
spec: Specifications::Deployment(DeploymentControllerManifest {
|
||||||
|
name: "some-changed-name".into(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
control_plane.execute().await?;
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!("fail"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MyOperator {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Operator for MyOperator {
|
||||||
|
type Specifications = Specifications;
|
||||||
|
|
||||||
|
async fn reconcile(
|
||||||
|
&self,
|
||||||
|
desired_manifest: &mut ManifestState<Specifications>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let now = jiff::Timestamp::now();
|
||||||
|
|
||||||
|
desired_manifest.status.status = nocontrol::manifests::ManifestStatusState::Started;
|
||||||
|
desired_manifest.updated = now;
|
||||||
|
|
||||||
|
match &desired_manifest.manifest.spec {
|
||||||
|
Specifications::Deployment(spec) => {
|
||||||
|
tracing::info!(
|
||||||
|
"reconciliation was called for name = {}, value = {}",
|
||||||
|
desired_manifest.manifest.name,
|
||||||
|
spec.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
desired_manifest.status.status = nocontrol::manifests::ManifestStatusState::Running;
|
||||||
|
desired_manifest.updated = now;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub enum Specifications {
|
||||||
|
Deployment(DeploymentControllerManifest),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeploymentControllerManifest {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
17
cuddle.yaml
Normal file
17
cuddle.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# yaml-language-server: $schema=https://git.kjuulh.io/kjuulh/cuddle/raw/branch/main/schemas/base.json
|
||||||
|
|
||||||
|
base: "git@git.kjuulh.io:kjuulh/cuddle-rust-lib-plan.git"
|
||||||
|
|
||||||
|
vars:
|
||||||
|
service: "nocontrol"
|
||||||
|
registry: kasperhermansen
|
||||||
|
|
||||||
|
please:
|
||||||
|
project:
|
||||||
|
owner: kjuulh
|
||||||
|
repository: "nocontrol"
|
||||||
|
branch: main
|
||||||
|
settings:
|
||||||
|
api_url: "https://git.kjuulh.io"
|
||||||
|
actions:
|
||||||
|
rust:
|
||||||
Reference in New Issue
Block a user