Files
harmony/docs/guides/writing-a-score.md
Jean-Gabriel Gill-Couture 64582caa64
Some checks failed
Run Check Script / check (pull_request) Failing after 10s
docs: Major rehaul of documentation
2026-03-19 22:38:55 -04:00

4.6 KiB

Writing a Score

A Score declares what you want to achieve. It is decoupled from how it is achieved — that logic lives in an Interpret.

The Pattern

A Score consists of two parts:

  1. A struct — holds the configuration for your desired state
  2. A Score<T> implementation — returns an Interpret that knows how to execute

An Interpret contains the actual execution logic and connects your Score to the capabilities exposed by a Topology.

Example: A Simple Score

Here's a simplified version of NtfyScore from the ntfy module:

use async_trait::async_trait;
use harmony::{
    interpret::{Interpret, InterpretError, Outcome},
    inventory::Inventory,
    score::Score,
    topology::{HelmCommand, K8sclient, Topology},
};

/// MyScore declares "I want to install the ntfy server"
#[derive(Debug, Clone)]
pub struct MyScore {
    pub namespace: String,
    pub host: String,
}

impl<T: Topology + HelmCommand + K8sclient> Score<T> for MyScore {
    fn create_interpret(&self) -> Box<dyn Interpret<T>> {
        Box::new(MyInterpret { score: self.clone() })
    }

    fn name(&self) -> String {
        "ntfy [MyScore]".into()
    }
}

/// MyInterpret knows _how_ to install ntfy using the Topology's capabilities
#[derive(Debug)]
pub struct MyInterpret {
    pub score: MyScore,
}

#[async_trait]
impl<T: Topology + HelmCommand + K8sclient> Interpret<T> for MyInterpret {
    async fn execute(
        &self,
        inventory: &Inventory,
        topology: &T,
    ) -> Result<Outcome, InterpretError> {
        // 1. Get a Kubernetes client from the Topology
        let client = topology.k8s_client().await?;

        // 2. Use Helm to install the ntfy chart
        // (via topology's HelmCommand capability)

        // 3. Wait for the deployment to be ready
        client
            .wait_until_deployment_ready("ntfy", Some(&self.score.namespace), None)
            .await?;

        Ok(Outcome::success("ntfy installed".to_string()))
    }
}

The Compile-Time Safety Check

The generic Score<T> trait is bounded by T: Topology. This means the compiler enforces that your Score only runs on Topologies that expose the capabilities your Interpret needs:

// This only compiles if K8sAnywhereTopology (or any T)
// implements HelmCommand and K8sclient
impl<T: Topology + HelmCommand + K8sclient> Score<T> for MyScore { ... }

If you try to run this Score against a Topology that doesn't expose HelmCommand, you get a compile error — before any code runs.

Using Your Score

Once defined, your Score integrates with the Harmony CLI:

use harmony::{
    inventory::Inventory,
    topology::K8sAnywhereTopology,
};

#[tokio::main]
async fn main() {
    let my_score = MyScore {
        namespace: "monitoring".to_string(),
        host: "ntfy.example.com".to_string(),
    };

    harmony_cli::run(
        Inventory::autoload(),
        K8sAnywhereTopology::from_env(),
        vec![Box::new(my_score)],
        None,
    )
    .await
    .unwrap();
}

Key Patterns

Composing Scores

Scores can include other Scores via features:

let app = ApplicationScore {
    features: vec![
        Box::new(PackagingDeployment { application: app.clone() }),
        Box::new(Monitoring { application: app.clone(), alert_receiver: vec![] }),
    ],
    application: app,
};

Reusing Interpret Logic

Many Scores delegate to shared Interpret implementations. For example, HelmChartScore provides a reusable Interpret for any Helm-based deployment. Your Score can wrap it:

impl<T: Topology + HelmCommand> Score<T> for MyScore {
    fn create_interpret(&self) -> Box<dyn Interpret<T>> {
        Box::new(HelmChartInterpret { /* your config */ })
    }
}

Accessing Topology Capabilities

Your Interpret accesses infrastructure through Capabilities exposed by the Topology:

// Via the Topology trait directly
let k8s_client = topology.k8s_client().await?;
let helm = topology.get_helm_command();

// Or via Capability traits
impl<T: Topology + K8sclient> Interpret<T> for MyInterpret {
    async fn execute(...) {
        let client = topology.k8s_client().await?;
        // use client...
    }
}

Best Practices

  • Keep Scores focused — one Score per concern (deployment, monitoring, networking)
  • Use ..Default::default() for optional fields so callers only need to specify what they care about
  • Return Outcome — use Outcome::success, Outcome::failure, or Outcome::success_with_details to communicate results clearly
  • Handle errors gracefully — return meaningful InterpretError messages that help operators debug issues