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

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(())
}