Structure the Harmony core to rely on a DAG for declaring & executing Scores

This commit is contained in:
Ian Letourneau 2025-09-12 20:43:50 -04:00
commit a351fd1228
8 changed files with 1235 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
### Rust ###
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
### rust-analyzer ###
# Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules)
rust-project.json

3
Cargo.toml Normal file
View File

@ -0,0 +1,3 @@
[workspace]
resolver = "3"
members = [ "examples/harmony","harmony-core", "harmony-derive"]

View File

@ -0,0 +1,13 @@
[package]
name = "example-harmony"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.89"
harmony-core = { version = "0.1.0", path = "../../harmony-core" }
harmony-derive = { version = "0.1.0", path = "../../harmony-derive" }
inquire = "0.7.5"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
tokio = "1.47.1"

View File

@ -0,0 +1,801 @@
use std::fmt::Display;
use async_trait::async_trait;
use harmony_core::{
Dependency, ExecutionContext, Id, Interpret, InterpretError, InterpretOutcome, Inventory,
LinkedValue, Maestro, Score, Topology, dependency,
};
use serde::{Deserialize, Serialize};
trait Ingress {
fn get_domain(&self, service: &str) -> String;
}
trait DhcpServer {
fn get_gateway_ip(&self) -> String;
}
trait LoadBalancer {
fn get_domain_name(&self) -> String;
fn get_load_balancer_ip(&self) -> String;
}
#[derive(Debug, Default)]
pub struct HaClusterTopology;
impl Topology for HaClusterTopology {
fn name(&self) -> &str {
"HaCluster"
}
}
impl Ingress for HaClusterTopology {
fn get_domain(&self, service: &str) -> String {
format!("https://{service}.domain.com")
}
}
impl DhcpServer for HaClusterTopology {
fn get_gateway_ip(&self) -> String {
"1.1.1.1".into()
}
}
impl LoadBalancer for HaClusterTopology {
fn get_domain_name(&self) -> String {
"domain.com".into()
}
fn get_load_balancer_ip(&self) -> String {
"192.168.1.1".into()
}
}
#[derive(Clone, Debug)]
pub struct IngressScore {
service: String,
}
impl Display for IngressScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("IngressScore[{}]", self.service))
}
}
impl<T: Topology + Ingress> Score<T> for IngressScore {
fn id(&self) -> Id {
Id(format!("ingress-{}", self.service))
}
fn name(&self) -> String {
format!("IngressScore[{}]", self.service)
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(IngressInterpret {
score: self.clone(),
})
}
}
#[derive(Debug)]
pub struct IngressInterpret {
score: IngressScore,
}
#[async_trait]
impl<T: Topology + Ingress> Interpret<T> for IngressInterpret {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
let domain = topology.get_domain(&self.score.service);
context.insert(Id("domain".into()), &domain);
println!("Ingress domain: {domain}");
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct ApplicationScore {
name: String,
domain: LinkedValue<String>,
}
impl Display for ApplicationScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("ApplicationScore[{}]", self.name))
}
}
impl ApplicationScore {
fn new(name: String) -> Self {
Self {
name: name.clone(),
domain: LinkedValue::ContextKey(Id("ingress.domain".into())),
}
}
}
impl<T: Topology + Ingress> Score<T> for ApplicationScore {
fn id(&self) -> Id {
Id(format!("application-{}", self.name))
}
fn name(&self) -> String {
format!("ApplicationScore[{}]", self.name)
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![dependency!(
Id("ingress".into()),
IngressScore {
service: self.name.clone()
}
)]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(ApplicationInterpret {
score: self.clone(),
})
}
}
#[derive(Debug)]
struct ApplicationInterpret {
score: ApplicationScore,
}
#[async_trait]
impl<T: Topology + Ingress> Interpret<T> for ApplicationInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
let domain = self.score.domain.resolve(&context)?;
println!("{}: {}", self.score.name, domain);
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct DhcpServerScore {}
impl Display for DhcpServerScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DhcpServerScore")
}
}
impl<T: Topology + DhcpServer> Score<T> for DhcpServerScore {
fn id(&self) -> Id {
Id("dhcp-server".into())
}
fn name(&self) -> String {
"DhcpServerScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(DhcpServerInterpret {})
}
}
#[derive(Debug)]
struct DhcpServerInterpret {}
#[async_trait]
impl<T: Topology + DhcpServer> Interpret<T> for DhcpServerInterpret {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
let gateway_ip = topology.get_gateway_ip();
context.insert(Id("gateway-ip".into()), gateway_ip);
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct DhcpScore {
host_binding: Vec<String>,
domain: Option<String>,
next_server: LinkedValue<String>,
}
impl DhcpScore {
fn new(host_binding: Vec<String>, domain: Option<String>, next_server: Option<String>) -> Self {
Self {
host_binding,
domain,
next_server: match next_server {
Some(next_server) => LinkedValue::Value(next_server),
None => LinkedValue::ContextKey(Id("dhcp-server.gateway-ip".into())),
},
}
}
}
impl Display for DhcpScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DhcpScore")
}
}
impl<T: Topology + DhcpServer> Score<T> for DhcpScore {
fn id(&self) -> Id {
Id("dhcp".into())
}
fn name(&self) -> String {
"DhcpScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
let mut dependencies: Vec<Dependency<T>> = vec![];
if let LinkedValue::ContextKey(_) = &self.next_server {
dependencies.push(dependency!(Id("dhcp-server".into()), DhcpServerScore {}));
}
dependencies
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(DhcpInterpret {
score: self.clone(),
})
}
}
#[derive(Debug)]
struct DhcpInterpret {
score: DhcpScore,
}
#[async_trait]
impl<T: Topology> Interpret<T> for DhcpInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
let next_server = self.score.next_server.resolve(&context)?;
println!(
"Configuring DHCP [\n - next_server: {:?}\n - host_binding: {:?}\n - domain: {:?}\n]",
next_server, self.score.host_binding, self.score.domain
);
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct OkdIpxeScore {
kickstart_filename: String,
harmony_inventory_agent: String,
}
impl Display for OkdIpxeScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("OkdIpxeScore[{}]", self.kickstart_filename))
}
}
impl<T: Topology + DhcpServer> Score<T> for OkdIpxeScore {
fn id(&self) -> Id {
Id("okd-ipxe".into())
}
fn name(&self) -> String {
format!("OkdIpxeScore[{}]", self.kickstart_filename)
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![
dependency!(Id("dhcp".into()), DhcpScore::new(vec![], None, None)),
dependency!(
Id("tftp".into()),
TftpScore {
files_to_serve: "./data/pxe/okd/tftpboot/".into(),
}
),
]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(OkdIpxeInterpret {})
}
}
#[derive(Debug)]
struct OkdIpxeInterpret {}
#[async_trait]
impl<T: Topology> Interpret<T> for OkdIpxeInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
_context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct TftpScore {
files_to_serve: String,
}
impl Display for TftpScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("TftpScore[{}]", self.files_to_serve))
}
}
impl<T: Topology> Score<T> for TftpScore {
fn id(&self) -> Id {
Id("TftpScore".into())
}
fn name(&self) -> String {
"TftpScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(TftpInterpret {})
}
}
#[derive(Debug)]
struct TftpInterpret {}
#[async_trait]
impl<T: Topology> Interpret<T> for TftpInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
_context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct OkdInstallationScore {}
impl Display for OkdInstallationScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("OkdInstallationScore")
}
}
impl<T: Topology + LoadBalancer> Score<T> for OkdInstallationScore {
fn id(&self) -> Id {
Id("okd-installation".into())
}
fn name(&self) -> String {
"OkdInstallationScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![
dependency!(Id("prepare".into()), OkdInstallationPrepareScore::new()),
dependency!(Id("bootstrap".into()), OkdInstallationBootstrapScore {}),
]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(OkdInstallationInterpret {})
}
}
#[derive(Debug)]
struct OkdInstallationInterpret {}
#[async_trait]
impl<T: Topology + LoadBalancer> Interpret<T> for OkdInstallationInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
_context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PhysicalHost {
id: Id,
category: HostCategory,
}
impl Display for PhysicalHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("Host[{}, {:?}]", self.id, self.category))
}
}
#[derive(Clone, Debug)]
enum HostRole {
Bootstrap,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum HostCategory {
Server,
Firewall,
Switch,
}
#[derive(Clone, Debug)]
struct OkdInstallationPrepareScore {
bootstrap_host: LinkedValue<PhysicalHost>,
}
impl OkdInstallationPrepareScore {
fn new() -> Self {
Self {
bootstrap_host: LinkedValue::ContextKey(Id("discover-host.host".into())),
}
}
}
impl Display for OkdInstallationPrepareScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("OkdInstallationPrepareScore")
}
}
impl<T: Topology + LoadBalancer> Score<T> for OkdInstallationPrepareScore {
fn id(&self) -> Id {
Id("okd-installation-prepare".into())
}
fn name(&self) -> String {
"OkdInstallationPrepareScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![
dependency!(Id("opnsense-hosts".into()), OPNsenseHostsScore {}),
dependency!(
Id("discover-host".into()),
DiscoverHostForRoleScore::new(HostRole::Bootstrap)
),
]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(OkdInstallationPrepareInterpret {
score: self.clone(),
})
}
}
#[derive(Debug)]
struct OkdInstallationPrepareInterpret {
score: OkdInstallationPrepareScore,
}
#[async_trait]
impl<T: Topology + LoadBalancer> Interpret<T> for OkdInstallationPrepareInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
let bootstrap_host = self.score.bootstrap_host.resolve(&context)?;
println!("Bootstrap host: {}", bootstrap_host);
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct DiscoverHostForRoleScore {
role: HostRole,
hosts: LinkedValue<Vec<PhysicalHost>>,
}
impl DiscoverHostForRoleScore {
fn new(role: HostRole) -> Self {
Self {
role,
hosts: LinkedValue::ContextKey(Id("available-hosts.hosts".into())),
}
}
}
impl Display for DiscoverHostForRoleScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("DiscoverHostForRoleScore[{:?}]", self.role))
}
}
impl<T: Topology> Score<T> for DiscoverHostForRoleScore {
fn id(&self) -> Id {
Id(format!("discover-host-{:?}", self.role))
}
fn name(&self) -> String {
format!("DiscoverHostForRoleScore[{:?}]", self.role)
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![dependency!(
Id("available-hosts".into()),
DiscoverAvailableHostsScore {}
)]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(DiscoverHostForRoleInterpret {
score: self.clone(),
})
}
}
#[derive(Debug)]
struct DiscoverHostForRoleInterpret {
score: DiscoverHostForRoleScore,
}
#[async_trait]
impl<T: Topology> Interpret<T> for DiscoverHostForRoleInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
let hosts = self.score.hosts.resolve(&context)?;
let host = hosts.first().unwrap();
context.insert(Id("host".into()), host);
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct DiscoverAvailableHostsScore {}
impl Display for DiscoverAvailableHostsScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DiscoverAvailableHostsScore")
}
}
impl<T: Topology> Score<T> for DiscoverAvailableHostsScore {
fn id(&self) -> Id {
Id("discover-available-hosts".into())
}
fn name(&self) -> String {
"DiscoverAvailableHostsScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(DiscoverAvailableHostsInterpret {})
}
}
#[derive(Debug)]
struct DiscoverAvailableHostsInterpret {}
#[async_trait]
impl<T: Topology> Interpret<T> for DiscoverAvailableHostsInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
context.insert(
Id("hosts".into()),
vec![PhysicalHost {
id: Id("host-1".into()),
category: HostCategory::Server,
}],
);
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct OPNsenseHostsScore {}
impl Display for OPNsenseHostsScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("OPNsenseHostsScore")
}
}
impl<T: Topology + LoadBalancer> Score<T> for OPNsenseHostsScore {
fn id(&self) -> Id {
Id("opnsense-hosts".into())
}
fn name(&self) -> String {
"OPNsenseHostsScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(OPNsenseHostsInterpret {})
}
}
#[derive(Debug)]
struct OPNsenseHostsInterpret {}
#[async_trait]
impl<T: Topology + LoadBalancer> Interpret<T> for OPNsenseHostsInterpret {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
_context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
let cluster_domain = &topology.get_domain_name();
let load_balancer_ip = &topology.get_load_balancer_ip();
inquire::Confirm::new(&format!(
"Set hostnames manually in your opnsense dnsmasq config :
*.apps.{cluster_domain} -> {load_balancer_ip}
api.{cluster_domain} -> {load_balancer_ip}
api-int.{cluster_domain} -> {load_balancer_ip}
When you can dig them, confirm to continue.
"
))
.prompt()
.expect("Prompt error");
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct OkdInstallationBootstrapScore {}
impl Display for OkdInstallationBootstrapScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("OkdInstallationBootstrapScore")
}
}
impl<T: Topology> Score<T> for OkdInstallationBootstrapScore {
fn id(&self) -> Id {
Id("okd-installation-bootstrap".into())
}
fn name(&self) -> String {
"OkdInstallationBootstrapScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(OkdInstallationBootstrapInterpret {})
}
}
#[derive(Debug)]
struct OkdInstallationBootstrapInterpret {}
#[async_trait]
impl<T: Topology> Interpret<T> for OkdInstallationBootstrapInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
_context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
Ok(InterpretOutcome {})
}
}
#[derive(Clone, Debug)]
struct ProvisionHighAvailabilityClusterScore {}
impl Display for ProvisionHighAvailabilityClusterScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ProvisionHighAvailabilityClusterScore")
}
}
impl<T: Topology + DhcpServer + LoadBalancer> Score<T> for ProvisionHighAvailabilityClusterScore {
fn id(&self) -> Id {
Id("provision-high-availability-cluster".into())
}
fn name(&self) -> String {
"ProvisionHighAvailabilityClusterScore".into()
}
fn depends_on(&self) -> Vec<Dependency<T>> {
vec![
dependency!(
Id("okd-ipxe".into()),
OkdIpxeScore {
kickstart_filename: "inventory.kickstart".into(),
harmony_inventory_agent: "harmony_inventory_agent".into(),
}
),
dependency!(Id("okd-installation".into()), OkdInstallationScore {}),
]
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(ProvisionHighAvailabilityClusterInterpret {})
}
}
#[derive(Debug)]
struct ProvisionHighAvailabilityClusterInterpret {}
#[async_trait]
impl<T: Topology> Interpret<T> for ProvisionHighAvailabilityClusterInterpret {
async fn execute(
&self,
_inventory: &Inventory,
_topology: &T,
_context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError> {
Ok(InterpretOutcome {})
}
}
#[tokio::main]
async fn main() -> Result<(), InterpretError> {
let inventory = Inventory::autoload();
let topology = HaClusterTopology;
let maestro = Maestro::init(
inventory,
topology,
vec![Box::new(ProvisionHighAvailabilityClusterScore {})],
);
let _ = maestro.plan();
maestro.execute().await?;
Ok(())
}

11
harmony-core/Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "harmony-core"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.89"
petgraph = "0.8.2"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }

319
harmony-core/src/lib.rs Normal file
View File

@ -0,0 +1,319 @@
use async_trait::async_trait;
use petgraph::{
algo,
dot::Dot,
graph::{DiGraph, NodeIndex},
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{
collections::HashMap,
fmt::{Debug, Display},
sync::{Arc, Mutex},
};
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct Id(pub String);
impl Default for Id {
fn default() -> Self {
Id("default_id".into())
}
}
impl Display for Id {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0.as_str())
}
}
#[derive(Clone, Debug)]
pub enum LinkedValue<T> {
Value(T),
ContextKey(Id),
}
impl<T> LinkedValue<T>
where
T: Clone + Serialize + DeserializeOwned,
{
pub fn resolve(&self, context: &ExecutionContext) -> Result<T, InterpretError> {
match self {
LinkedValue::Value(v) => Ok(v.clone()),
LinkedValue::ContextKey(key_id) => {
context.get::<T>(key_id).ok_or_else(|| InterpretError {
msg: format!("Value for key '{}' not found in context.", key_id),
})
}
}
}
}
pub struct Inventory;
impl Inventory {
pub fn autoload() -> Self {
Inventory
}
}
pub trait Topology: Send + Sync {
fn name(&self) -> &str;
}
#[derive(Debug, Clone, Copy)]
pub struct InterpretOutcome {}
#[derive(Debug, Clone)]
pub struct InterpretError {
pub msg: String,
}
pub struct Dependency<T>(pub Id, pub Box<dyn Score<T>>);
impl<T: Topology> Clone for Dependency<T> {
fn clone(&self) -> Self {
Self(self.0.clone(), self.1.clone_box())
}
}
impl<T: Topology> Display for Dependency<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("{}", self.1))
}
}
#[macro_export]
macro_rules! dependency {
($id:expr, $score:expr) => {
Dependency($id, Box::new($score))
};
}
#[async_trait]
pub trait Score<T: Topology>: Display + Send + Sync + CloneBoxScore<T> {
fn id(&self) -> Id;
fn name(&self) -> String;
fn depends_on(&self) -> Vec<Dependency<T>>;
fn create_interpret(&self) -> Box<dyn Interpret<T>>;
}
pub trait CloneBoxScore<T: Topology> {
fn clone_box(&self) -> Box<dyn Score<T>>;
}
impl<S, T> CloneBoxScore<T> for S
where
T: Topology,
S: Score<T> + Clone + 'static,
{
fn clone_box(&self) -> Box<dyn Score<T>> {
Box::new(self.clone())
}
}
#[async_trait]
pub trait Interpret<T>: Debug + Send {
async fn execute(
&self,
inventory: &Inventory,
topology: &T,
context: ExecutionContext,
) -> Result<InterpretOutcome, InterpretError>;
}
#[derive(Default)]
pub struct Context {
data: std::collections::HashMap<Id, serde_json::Value>,
}
impl Context {
pub fn get<T: DeserializeOwned>(&self, id: &Id) -> Option<T> {
self.data
.get(id)
.and_then(|value| serde_json::from_value(value.clone()).ok())
}
pub fn insert<T: Serialize>(&mut self, id: Id, data: T) {
if let Ok(value) = serde_json::to_value(data) {
self.data.insert(id, value);
}
}
}
pub struct ExecutionContext {
id: Id,
context: Arc<Mutex<Context>>,
}
impl ExecutionContext {
fn wrap<T: Topology>(context: Arc<Mutex<Context>>, dependency: Dependency<T>) -> Self {
Self {
id: dependency.0,
context,
}
}
pub fn get<T: DeserializeOwned>(&self, id: &Id) -> Option<T> {
let key = self.map_key(id);
let context = self.context.lock().unwrap();
context
.get(&key)
.and_then(|value: serde_json::Value| serde_json::from_value(value.clone()).ok())
}
pub fn insert<T: Serialize>(&self, id: Id, data: T) {
let key = self.map_key(&id);
let mut context = self.context.lock().unwrap();
context.insert(key, data);
}
fn map_key(&self, id: &Id) -> Id {
Id(format!("{}.{}", self.id.0, id.0))
}
}
pub struct Maestro<T: Topology> {
inventory: Inventory,
topology: T,
scores: DiGraph<Dependency<T>, &'static str>,
}
impl<T: Topology> Maestro<T> {
pub fn init(inventory: Inventory, topology: T, scores: Vec<Box<dyn Score<T>>>) -> Self {
let scores = build_dag(scores).expect("Invalid scores DAG");
Self {
inventory,
topology,
scores,
}
}
pub fn plan(&self) -> Result<(), String> {
let dot = Dot::new(&self.scores);
println!("{}", dot);
Ok(())
}
pub async fn execute(&self) -> Result<(), InterpretError> {
println!("Executing scores based on dependencies:");
let context = Arc::new(Mutex::new(Context::default()));
// Use petgraph's topological sort to get a guaranteed order.
let mut sorted_nodes = match algo::toposort(&self.scores, None) {
Ok(nodes) => nodes,
Err(e) => {
let cycle_node_id = &self.scores.node_weight(e.node_id()).unwrap().0;
return Err(InterpretError {
msg: format!(
"A cycle was detected in the graph, involving score ID: {}",
cycle_node_id
),
});
}
};
sorted_nodes.reverse();
for node_index in sorted_nodes {
let dependency = self.scores.node_weight(node_index).ok_or(InterpretError {
msg: "Failed to get score from graph node.".into(),
})?;
let execution_context = ExecutionContext::wrap(context.clone(), dependency.clone());
println!(" - Executing {}", dependency.0);
let interpret = dependency.1.create_interpret();
interpret
.execute(&self.inventory, &self.topology, execution_context)
.await?;
}
Ok(())
}
}
/// Builds a Directed Acyclic Graph (DAG) from a list of scores.
///
/// This function uses the `petgraph` library to perform cycle validation.
/// It correctly handles scores that are only present as dependencies,
/// ensuring all nodes in the graph are discovered and validated.
///
/// # Arguments
/// * `scores` - A vector of `Box<dyn Score>` objects. These are the starting
/// nodes for the DAG. The function will recursively find all
/// transitive dependencies.
///
/// # Returns
/// * `Ok(DiGraph<Box<dyn Score<'a, T>>, ()>)` - The final DAG, represented as a
/// `petgraph::DiGraph`, if no cycles were found. The node weights are the `Box<dyn Score>` objects.
/// * `Err(String)` - An error message if a cycle or duplicate ID was detected.
fn build_dag<T: Topology>(
scores: Vec<Box<dyn Score<T>>>,
) -> Result<DiGraph<Dependency<T>, &'static str>, String> {
// A map to link each score's ID to its petgraph NodeIndex.
let mut id_to_node_index: HashMap<Id, NodeIndex> = HashMap::new();
// The directed graph. The node weights are the score objects.
let mut graph: DiGraph<Dependency<T>, &str> = DiGraph::new();
// A queue for processing new nodes and their dependencies.
let mut processing_queue: Vec<NodeIndex> = Vec::new();
// First, add all initial scores to the graph and the queue.
for score in scores {
let id = score.id();
let dependency = Dependency(id.clone(), score.clone_box());
// Check for duplicate root scores.
if id_to_node_index.contains_key(&id) {
return Err(format!("Duplicate score ID found: {}", id));
}
let node_index = graph.add_node(dependency);
id_to_node_index.insert(id, node_index);
processing_queue.push(node_index);
}
// Now, process the queue to build the full graph.
while let Some(current_node_index) = processing_queue.pop() {
let current_node = graph.node_weight(current_node_index).unwrap();
let current_id = &current_node.0.clone();
let dependencies = current_node.1.depends_on();
for dependency in dependencies {
let dep_id = Id(format!("{}.{}", current_id, &dependency.0));
let dependency = Dependency(dep_id.clone(), dependency.1.clone_box());
let dep_node_index = match id_to_node_index.get(&dep_id) {
Some(index) => *index,
None => {
// This is a new dependency, add it to the graph.
let new_node_index = graph.add_node(dependency.clone());
id_to_node_index.insert(dep_id.clone(), new_node_index);
processing_queue.push(new_node_index);
new_node_index
}
};
// Add an edge from the current score to its dependency.
graph.add_edge(current_node_index, dep_node_index, "");
}
}
// Use petgraph's `toposort` to validate the DAG.
match algo::toposort(&graph, None) {
Ok(_) => Ok(graph),
Err(e) => {
let cycle_node_weight = graph
.node_weight(e.node_id())
.ok_or_else(|| "Cycle detected, but cycle node not found".to_string())?;
Err(format!(
"Cycle detected in dependencies, involving score with ID: {}",
cycle_node_weight.0
))
}
}
}

11
harmony-derive/Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "harmony-derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.40"
syn = "2.0.106"

58
harmony-derive/src/lib.rs Normal file
View File

@ -0,0 +1,58 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{Attribute, Ident, ItemStruct, parse_macro_input};
// This was an attempt to get a derive macro to simplify implementing new scores
//
// The goal was to get a syntax similar to this:
// #[derive(Score)]
// #[interpret(MyInterpret)]
// #[dependencies(OtherScore("foo"), OtherScore("bar"), ExtraScore("baz"))]
// struct MyScore {
// foo: LinkedValue<String>,
// bar: LinkedValue<String>,
// baz: LinkedValue<Vec<u32>>,
// }
#[proc_macro_derive(Score, attributes(interpret))]
pub fn score_derive(input: TokenStream) -> TokenStream {
let input_struct = parse_macro_input!(input as ItemStruct);
let score_name = &input_struct.ident;
let interpret_name = get_interpret_name(&input_struct.attrs);
let expanded = quote! {
#[derive(Debug)]
pub struct #interpret_name {
score: #score_name,
}
impl<T: Topology> crate::Score<T> for #score_name {
fn create_interpret(&self) -> Box<dyn crate::Interpret<T>> {
Box::new(#interpret_name {
score: self.clone(),
})
}
}
};
TokenStream::from(expanded)
}
fn get_interpret_name(attrs: &Vec<Attribute>) -> Ident {
for attr in attrs {
if attr.path().is_ident("interpret") {
let nested_meta = attr
.parse_args_with(
syn::punctuated::Punctuated::<syn::Ident, syn::Token![,]>::parse_terminated,
)
.unwrap();
if let Some(ident) = nested_meta.first() {
return ident.clone();
}
}
}
// Return a default or panic if the attribute is not found
Ident::new("DefaultInterpret", proc_macro::Span::call_site().into())
}