Files
harmony/harmony_app/src/app.rs
2026-09-15 22:08:08 -04:00

328 lines
10 KiB
Rust

//! The [`HarmonyApp`] trait and the lifecycle **verbs** as plain async
//! functions returning structured results — the UI-agnostic core every
//! front-end (CLI, TUI, web) drives.
use async_trait::async_trait;
use harmony::interpret::InterpretStatus;
use harmony::inventory::Inventory;
use harmony::maestro::Maestro;
use harmony::score::Score;
use harmony::topology::Topology;
use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::core::v1::Pod;
use crate::context::AppContext;
use crate::error::AppError;
use crate::publish::{
ImageRefs, ImageSpec, PublicationTopology, build_images, publish_images, resolve_tagged_images,
};
/// Identity-only description of an app (ADR-026 §3): what it's called and
/// where it runs. No behavioral knobs — those come from the context/profile.
#[derive(Debug, Clone)]
pub struct AppIdentity {
pub name: String,
pub namespace: String,
}
/// A deployable application, **generic over its target `Topology`**. An app
/// crate implements this once; every UI drives it through the verbs below.
/// The Scores it returns carry their own capability bounds, so an app is
/// deployable to any topology that can host them — a missing capability is a
/// compile error, not a runtime surprise.
#[async_trait]
pub trait HarmonyApp<T: Topology>: Send + Sync {
fn identity(&self, ctx: &AppContext) -> AppIdentity;
/// The desired state for this context — the app composes its Scores,
/// branching on `ctx.profile()`. Called by `deploy`/`ship`.
async fn scores(
&self,
ctx: &AppContext,
images: &ImageRefs,
) -> Result<Vec<Box<dyn Score<T>>>, AppError>;
async fn scores_with_options(
&self,
ctx: &AppContext,
options: DeployOptions,
) -> Result<Vec<Box<dyn Score<T>>>, AppError> {
self.scores(ctx, &options.images).await
}
fn images(&self, _ctx: &AppContext) -> Result<Vec<ImageSpec>, AppError> {
Ok(Vec::new())
}
fn build(&self, ctx: &AppContext) -> Result<ImageRefs, AppError> {
Ok(build_images(&self.images(ctx)?, &ctx.publisher())?)
}
async fn publish(&self, ctx: &AppContext, images: &ImageRefs) -> Result<ImageRefs, AppError> {
let publisher = ctx.publisher();
let credentials = publisher.credentials(ctx.config_client()).await?;
Ok(publish_images(
&self.images(ctx)?,
images,
&publisher,
credentials.as_ref(),
)?)
}
/// Validate image references supplied directly to `deploy`.
fn validate_deploy_images(&self, images: &ImageRefs) -> Result<(), AppError> {
images.require_digest_pinned()?;
Ok(())
}
}
pub fn build<T: Topology>(
app: &dyn HarmonyApp<T>,
ctx: &AppContext,
) -> Result<ImageRefs, AppError> {
app.build(ctx)
}
pub async fn publish<T: Topology>(
app: &dyn HarmonyApp<T>,
ctx: &AppContext,
images: &ImageRefs,
) -> Result<ImageRefs, AppError> {
app.publish(ctx, images).await
}
// ---- structured results (rendered by the front-end, never printed here) ----
#[derive(Debug, Clone, serde::Serialize)]
pub struct StepOutcome {
pub name: String,
pub message: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DeployReport {
pub context: String,
pub namespace: String,
pub cluster: Option<String>,
pub tag: String,
pub images: ImageRefs,
pub steps: Vec<StepOutcome>,
}
#[derive(Debug, Clone, Default)]
pub struct DeployOptions {
pub force_conflicts: bool,
pub images: ImageRefs,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct WorkloadStatus {
pub name: String,
pub ready: i32,
pub desired: i32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StatusReport {
pub namespace: String,
pub workloads: Vec<WorkloadStatus>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PodLogs {
pub pod: String,
pub logs: String,
}
// ---- the verbs ----
/// Converge the app's Scores onto `topology` (no build).
pub async fn deploy<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>,
topology: T,
ctx: &AppContext,
) -> Result<DeployReport, AppError> {
deploy_with_options(app, topology, ctx, DeployOptions::default()).await
}
pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>,
topology: T,
ctx: &AppContext,
options: DeployOptions,
) -> Result<DeployReport, AppError> {
let images = if options.images.is_empty() {
match ctx.publisher() {
PublicationTopology::Registry { registry } => {
let credentials = ctx.publisher().credentials(ctx.config_client()).await?;
resolve_tagged_images(&app.images(ctx)?, &registry, credentials.as_ref())?
}
PublicationTopology::K3d { .. } => {
return Err(AppError::Image(crate::ImageError::Missing(
"deploy --tag without --image only resolves a registry context; ship or pass --image NAME=IMAGE@sha256:DIGEST".into(),
)));
}
}
} else {
options.images.clone()
};
app.validate_deploy_images(&images)?;
let options = DeployOptions {
images: images.clone(),
..options
};
let scores = app.scores_with_options(ctx, options).await?;
let steps = interpret_scores(topology, scores).await?;
Ok(DeployReport {
context: ctx.name().to_string(),
namespace: ctx.namespace().to_string(),
cluster: ctx.cluster_target().map(str::to_string),
tag: ctx.version().to_string(),
images,
steps,
})
}
pub async fn interpret_scores<T: Topology + Send + Sync + 'static>(
topology: T,
scores: Vec<Box<dyn Score<T>>>,
) -> Result<Vec<StepOutcome>, AppError> {
interpret_scores_with_progress(topology, scores, |_, _| Ok(())).await
}
pub async fn interpret_scores_with_progress<T: Topology + Send + Sync + 'static>(
topology: T,
scores: Vec<Box<dyn Score<T>>>,
mut completed: impl FnMut(&StepOutcome, bool) -> Result<(), AppError>,
) -> Result<Vec<StepOutcome>, AppError> {
let to_run: Vec<Box<dyn Score<T>>> = scores.iter().map(|score| score.clone_box()).collect();
let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology);
maestro.register_all(scores);
maestro
.prepare_topology()
.await
.map_err(|e| AppError::Deploy(format!("topology preparation failed: {e}")))?;
let mut steps = Vec::new();
let score_count = to_run.len();
for (index, score) in to_run.into_iter().enumerate() {
let name = score.name();
let outcome = maestro
.interpret(score)
.await
.map_err(|error| AppError::Deploy(format!("{name}: {error}")))?;
if !matches!(
outcome.status,
InterpretStatus::SUCCESS | InterpretStatus::NOOP
) {
return Err(AppError::Deploy(format!(
"{name}: {}: {}",
outcome.status, outcome.message
)));
}
let step = StepOutcome {
name,
message: outcome.message,
};
completed(&step, index + 1 < score_count)?;
steps.push(step);
}
Ok(steps)
}
/// Build + publish, then deploy (ADR-026 §4).
pub async fn ship<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>,
topology: T,
ctx: &AppContext,
) -> Result<DeployReport, AppError> {
ship_with_options(app, topology, ctx, DeployOptions::default()).await
}
pub async fn ship_with_options<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>,
topology: T,
ctx: &AppContext,
options: DeployOptions,
) -> Result<DeployReport, AppError> {
let images = app.build(ctx)?;
let images = app.publish(ctx, &images).await?;
deploy_with_options(
app,
topology,
ctx,
DeployOptions {
images: images.clone(),
..options
},
)
.await
.map_err(|error| {
let images = images
.iter()
.map(|(name, image)| format!("{name}={image}"))
.collect::<Vec<_>>()
.join(", ");
AppError::Deploy(format!(
"ship tag={} published [{images}]; deployment failed: {error}",
ctx.version()
))
})
}
/// Workload readiness in the app's namespace (operational, read-only).
pub async fn status<T: Topology>(
app: &dyn HarmonyApp<T>,
ctx: &AppContext,
) -> Result<StatusReport, AppError> {
let id = app.identity(ctx);
let client = ctx.k8s_client().await?;
let deployments = client
.list_resources::<Deployment>(Some(&id.namespace), None)
.await
.map_err(|e| AppError::Deploy(format!("listing deployments: {e}")))?;
let workloads = deployments
.items
.iter()
.map(|d| WorkloadStatus {
name: d.metadata.name.clone().unwrap_or_default(),
desired: d.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0),
ready: d
.status
.as_ref()
.and_then(|s| s.ready_replicas)
.unwrap_or(0),
})
.collect();
Ok(StatusReport {
namespace: id.namespace,
workloads,
})
}
/// Recent logs from each pod in the app's namespace (operational, read-only).
pub async fn logs<T: Topology>(
app: &dyn HarmonyApp<T>,
ctx: &AppContext,
tail: Option<i64>,
) -> Result<Vec<PodLogs>, AppError> {
let id = app.identity(ctx);
let client = ctx.k8s_client().await?;
let pods = client
.list_resources::<Pod>(Some(&id.namespace), None)
.await
.map_err(|e| AppError::Deploy(format!("listing pods: {e}")))?;
let mut out = Vec::new();
for p in pods.items {
let pod = p.metadata.name.unwrap_or_default();
let logs = client
.pod_logs(&id.namespace, &pod, tail)
.await
.unwrap_or_else(|e| format!("<logs unavailable: {e}>"));
out.push(PodLogs { pod, logs });
}
Ok(out)
}