Some checks failed
Run Check Script / check (pull_request) Failing after -42s
328 lines
10 KiB
Rust
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)?, ®istry, 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)
|
|
}
|