This commit is contained in:
Till Wegmueller 2025-11-01 14:56:46 +01:00
parent 374dff5c04
commit 033f9b5ab0
No known key found for this signature in database
17 changed files with 820 additions and 232 deletions

View file

@ -36,10 +36,16 @@ async fn main() -> Result<()> {
let _t = common::init_tracing("ciadm")?; let _t = common::init_tracing("ciadm")?;
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Trigger { repo, r#ref, workflow } => { Commands::Trigger {
repo,
r#ref,
workflow,
} => {
info!(%repo, %r#ref, %workflow, "trigger requested"); info!(%repo, %r#ref, %workflow, "trigger requested");
// TODO: Call orchestrator API to enqueue job // TODO: Call orchestrator API to enqueue job
println!("Triggered job for {repo}@{ref} using {workflow}", r#ref = r#ref); println!(
"Triggered job for {repo}@{ref} using {workflow}",
);
} }
Commands::Status { job_id } => { Commands::Status { job_id } => {
info!(%job_id, "status requested"); info!(%job_id, "status requested");

View file

@ -2,7 +2,11 @@ use clap::{Parser, Subcommand};
use miette::Result; use miette::Result;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "cidev", version, about = "Solstice CI Dev CLI — validate and inspect KDL workflows")] #[command(
name = "cidev",
version,
about = "Solstice CI Dev CLI — validate and inspect KDL workflows"
)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Command, command: Command,
@ -11,11 +15,22 @@ struct Cli {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum Command { enum Command {
/// Validate a workflow KDL file /// Validate a workflow KDL file
Validate { #[arg(long)] path: String }, Validate {
#[arg(long)]
path: String,
},
/// List jobs in a workflow /// List jobs in a workflow
List { #[arg(long)] path: String }, List {
#[arg(long)]
path: String,
},
/// Show a job's steps (by job id) /// Show a job's steps (by job id)
Show { #[arg(long)] path: String, #[arg(long)] job: String }, Show {
#[arg(long)]
path: String,
#[arg(long)]
job: String,
},
} }
#[tokio::main(flavor = "multi_thread")] #[tokio::main(flavor = "multi_thread")]
@ -25,14 +40,24 @@ async fn main() -> Result<()> {
match cli.command { match cli.command {
Command::Validate { path } => { Command::Validate { path } => {
let wf = common::parse_workflow_file(&path)?; let wf = common::parse_workflow_file(&path)?;
println!("OK: parsed workflow{} with {} job(s)", println!(
wf.name.as_ref().map(|n| format!(" '{n}'")).unwrap_or_default(), "OK: parsed workflow{} with {} job(s)",
wf.jobs.len()); wf.name
.as_ref()
.map(|n| format!(" '{n}'"))
.unwrap_or_default(),
wf.jobs.len()
);
} }
Command::List { path } => { Command::List { path } => {
let wf = common::parse_workflow_file(&path)?; let wf = common::parse_workflow_file(&path)?;
for (id, job) in wf.jobs { for (id, job) in wf.jobs {
println!("{id}{}", job.runs_on.map(|ro| format!(" (runs_on: {ro})")).unwrap_or_default()); println!(
"{id}{}",
job.runs_on
.map(|ro| format!(" (runs_on: {ro})"))
.unwrap_or_default()
);
} }
} }
Command::Show { path, job } => { Command::Show { path, job } => {
@ -40,7 +65,9 @@ async fn main() -> Result<()> {
match wf.jobs.get(&job) { match wf.jobs.get(&job) {
Some(j) => { Some(j) => {
println!("Job: {}", j.id); println!("Job: {}", j.id);
if let Some(ro) = &j.runs_on { println!("runs_on: {ro}"); } if let Some(ro) = &j.runs_on {
println!("runs_on: {ro}");
}
for (i, s) in j.steps.iter().enumerate() { for (i, s) in j.steps.iter().enumerate() {
let name = s.name.as_deref().unwrap_or("(unnamed)"); let name = s.name.as_deref().unwrap_or("(unnamed)");
println!("- Step {}/{}: {}", i + 1, j.steps.len(), name); println!("- Step {}/{}: {}", i + 1, j.steps.len(), name);

View file

@ -1,6 +1,6 @@
use std::{collections::BTreeMap, fs, path::Path};
use kdl::{KdlDocument, KdlNode}; use kdl::{KdlDocument, KdlNode};
use miette::{IntoDiagnostic, Report, Result}; use miette::{IntoDiagnostic, Report, Result};
use std::{collections::BTreeMap, fs, path::Path};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Workflow { pub struct Workflow {

View file

@ -1,12 +1,12 @@
pub mod telemetry;
pub mod job; pub mod job;
pub mod messages; pub mod messages;
pub mod mq; pub mod mq;
pub mod telemetry;
pub use telemetry::{init_tracing, TelemetryGuard}; pub use job::{Job, Step, Workflow, parse_workflow_file, parse_workflow_str};
pub use job::{Workflow, Job, Step, parse_workflow_str, parse_workflow_file};
pub use messages::{JobRequest, JobResult, SourceSystem}; pub use messages::{JobRequest, JobResult, SourceSystem};
pub use mq::{MqConfig, publish_job, publish_job_result, consume_jobs, consume_jobs_until}; pub use mq::{MqConfig, consume_jobs, consume_jobs_until, publish_job, publish_job_result};
pub use telemetry::{TelemetryGuard, init_tracing};
// Generated gRPC module for runner <-> orchestrator // Generated gRPC module for runner <-> orchestrator
pub mod runner { pub mod runner {

View file

@ -27,7 +27,9 @@ pub struct JobRequest {
pub submitted_at: OffsetDateTime, pub submitted_at: OffsetDateTime,
} }
fn default_jobrequest_schema() -> String { "jobrequest.v1".to_string() } fn default_jobrequest_schema() -> String {
"jobrequest.v1".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@ -38,7 +40,11 @@ pub enum SourceSystem {
} }
impl JobRequest { impl JobRequest {
pub fn new(source: SourceSystem, repo_url: impl Into<String>, commit_sha: impl Into<String>) -> Self { pub fn new(
source: SourceSystem,
repo_url: impl Into<String>,
commit_sha: impl Into<String>,
) -> Self {
Self { Self {
schema_version: default_jobrequest_schema(), schema_version: default_jobrequest_schema(),
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
@ -73,10 +79,19 @@ pub struct JobResult {
pub completed_at: OffsetDateTime, pub completed_at: OffsetDateTime,
} }
fn default_jobresult_schema() -> String { "jobresult.v1".to_string() } fn default_jobresult_schema() -> String {
"jobresult.v1".to_string()
}
impl JobResult { impl JobResult {
pub fn new(request_id: Uuid, repo_url: String, commit_sha: String, success: bool, exit_code: i32, summary: Option<String>) -> Self { pub fn new(
request_id: Uuid,
repo_url: String,
commit_sha: String,
success: bool,
exit_code: i32,
summary: Option<String>,
) -> Self {
Self { Self {
schema_version: default_jobresult_schema(), schema_version: default_jobresult_schema(),
request_id, request_id,

View file

@ -2,16 +2,17 @@ use std::time::Duration;
use futures_util::StreamExt; use futures_util::StreamExt;
use lapin::{ use lapin::{
BasicProperties, Channel, Connection, ConnectionProperties, Consumer,
options::{ options::{
BasicAckOptions, BasicConsumeOptions, BasicNackOptions, BasicPublishOptions, BasicQosOptions, BasicAckOptions, BasicConsumeOptions, BasicNackOptions, BasicPublishOptions,
ConfirmSelectOptions, ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions, BasicQosOptions, ConfirmSelectOptions, ExchangeDeclareOptions, QueueBindOptions,
QueueDeclareOptions,
}, },
types::{AMQPValue, FieldTable, LongString, ShortString}, types::{AMQPValue, FieldTable, LongString, ShortString},
BasicProperties, Channel, Connection, ConnectionProperties, Consumer,
}; };
use miette::{IntoDiagnostic as _, Result}; use miette::{IntoDiagnostic as _, Result};
use tracing::{error, info, instrument, warn};
use tracing::Instrument; use tracing::Instrument;
use tracing::{error, info, instrument, warn};
use crate::messages::{JobRequest, JobResult}; use crate::messages::{JobRequest, JobResult};
@ -31,11 +32,15 @@ impl Default for MqConfig {
Self { Self {
url: std::env::var("AMQP_URL").unwrap_or_else(|_| "amqp://127.0.0.1:5672/%2f".into()), url: std::env::var("AMQP_URL").unwrap_or_else(|_| "amqp://127.0.0.1:5672/%2f".into()),
exchange: std::env::var("AMQP_EXCHANGE").unwrap_or_else(|_| "solstice.jobs".into()), exchange: std::env::var("AMQP_EXCHANGE").unwrap_or_else(|_| "solstice.jobs".into()),
routing_key: std::env::var("AMQP_ROUTING_KEY").unwrap_or_else(|_| "jobrequest.v1".into()), routing_key: std::env::var("AMQP_ROUTING_KEY")
.unwrap_or_else(|_| "jobrequest.v1".into()),
queue: std::env::var("AMQP_QUEUE").unwrap_or_else(|_| "solstice.jobs.v1".into()), queue: std::env::var("AMQP_QUEUE").unwrap_or_else(|_| "solstice.jobs.v1".into()),
dlx: std::env::var("AMQP_DLX").unwrap_or_else(|_| "solstice.dlx".into()), dlx: std::env::var("AMQP_DLX").unwrap_or_else(|_| "solstice.dlx".into()),
dlq: std::env::var("AMQP_DLQ").unwrap_or_else(|_| "solstice.jobs.v1.dlq".into()), dlq: std::env::var("AMQP_DLQ").unwrap_or_else(|_| "solstice.jobs.v1.dlq".into()),
prefetch: std::env::var("AMQP_PREFETCH").ok().and_then(|s| s.parse().ok()).unwrap_or(64), prefetch: std::env::var("AMQP_PREFETCH")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(64),
} }
} }
} }
@ -71,7 +76,13 @@ pub async fn declare_topology(channel: &Channel, cfg: &MqConfig) -> Result<()> {
.exchange_declare( .exchange_declare(
&cfg.dlx, &cfg.dlx,
lapin::ExchangeKind::Fanout, lapin::ExchangeKind::Fanout,
ExchangeDeclareOptions { durable: true, auto_delete: false, internal: false, nowait: false, passive: false }, ExchangeDeclareOptions {
durable: true,
auto_delete: false,
internal: false,
nowait: false,
passive: false,
},
FieldTable::default(), FieldTable::default(),
) )
.await .await
@ -86,7 +97,13 @@ pub async fn declare_topology(channel: &Channel, cfg: &MqConfig) -> Result<()> {
channel channel
.queue_declare( .queue_declare(
&cfg.dlq, &cfg.dlq,
QueueDeclareOptions { durable: true, auto_delete: false, exclusive: false, nowait: false, passive: false }, QueueDeclareOptions {
durable: true,
auto_delete: false,
exclusive: false,
nowait: false,
passive: false,
},
dlq_args, dlq_args,
) )
.await .await
@ -113,7 +130,13 @@ pub async fn declare_topology(channel: &Channel, cfg: &MqConfig) -> Result<()> {
channel channel
.queue_declare( .queue_declare(
&cfg.queue, &cfg.queue,
QueueDeclareOptions { durable: true, auto_delete: false, exclusive: false, nowait: false, passive: false }, QueueDeclareOptions {
durable: true,
auto_delete: false,
exclusive: false,
nowait: false,
passive: false,
},
q_args, q_args,
) )
.await .await
@ -159,7 +182,10 @@ pub async fn publish_job(cfg: &MqConfig, job: &JobRequest) -> Result<()> {
.basic_publish( .basic_publish(
&cfg.exchange, &cfg.exchange,
&cfg.routing_key, &cfg.routing_key,
BasicPublishOptions { mandatory: true, ..Default::default() }, BasicPublishOptions {
mandatory: true,
..Default::default()
},
&payload, &payload,
props, props,
) )
@ -206,7 +232,10 @@ where
.basic_consume( .basic_consume(
&cfg.queue, &cfg.queue,
"orchestrator", "orchestrator",
BasicConsumeOptions { no_ack: false, ..Default::default() }, BasicConsumeOptions {
no_ack: false,
..Default::default()
},
FieldTable::default(), FieldTable::default(),
) )
.await .await
@ -215,7 +244,8 @@ where
info!(queue = %cfg.queue, prefetch = cfg.prefetch, "consumer started"); info!(queue = %cfg.queue, prefetch = cfg.prefetch, "consumer started");
tokio::pin!(consumer); tokio::pin!(consumer);
let mut shutdown: core::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> = Box::pin(shutdown); let mut shutdown: core::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> =
Box::pin(shutdown);
'consume: loop { 'consume: loop {
tokio::select! { tokio::select! {
@ -272,12 +302,12 @@ where
// Close channel and connection to stop heartbeats and background tasks // Close channel and connection to stop heartbeats and background tasks
match tokio::time::timeout(Duration::from_secs(2), channel.close(200, "shutdown")).await { match tokio::time::timeout(Duration::from_secs(2), channel.close(200, "shutdown")).await {
Ok(Ok(_)) => {}, Ok(Ok(_)) => {}
Ok(Err(e)) => warn!(error = %e, "failed to close AMQP channel"), Ok(Err(e)) => warn!(error = %e, "failed to close AMQP channel"),
Err(_) => warn!("timeout while closing AMQP channel"), Err(_) => warn!("timeout while closing AMQP channel"),
} }
match tokio::time::timeout(Duration::from_secs(2), conn.close(200, "shutdown")).await { match tokio::time::timeout(Duration::from_secs(2), conn.close(200, "shutdown")).await {
Ok(Ok(_)) => {}, Ok(Ok(_)) => {}
Ok(Err(e)) => warn!(error = %e, "failed to close AMQP connection"), Ok(Err(e)) => warn!(error = %e, "failed to close AMQP connection"),
Err(_) => warn!("timeout while closing AMQP connection"), Err(_) => warn!("timeout while closing AMQP connection"),
} }
@ -286,7 +316,6 @@ where
Ok(()) Ok(())
} }
#[instrument(skip(cfg, result))] #[instrument(skip(cfg, result))]
pub async fn publish_job_result(cfg: &MqConfig, result: &JobResult) -> Result<()> { pub async fn publish_job_result(cfg: &MqConfig, result: &JobResult) -> Result<()> {
let conn = connect(cfg).await?; let conn = connect(cfg).await?;
@ -297,7 +326,13 @@ pub async fn publish_job_result(cfg: &MqConfig, result: &JobResult) -> Result<()
.exchange_declare( .exchange_declare(
&cfg.exchange, &cfg.exchange,
lapin::ExchangeKind::Direct, lapin::ExchangeKind::Direct,
ExchangeDeclareOptions { durable: true, auto_delete: false, internal: false, nowait: false, passive: false }, ExchangeDeclareOptions {
durable: true,
auto_delete: false,
internal: false,
nowait: false,
passive: false,
},
FieldTable::default(), FieldTable::default(),
) )
.await .await
@ -324,7 +359,10 @@ pub async fn publish_job_result(cfg: &MqConfig, result: &JobResult) -> Result<()
.basic_publish( .basic_publish(
&cfg.exchange, &cfg.exchange,
routing_key, routing_key,
BasicPublishOptions { mandatory: true, ..Default::default() }, BasicPublishOptions {
mandatory: true,
..Default::default()
},
&payload, &payload,
props, props,
) )

View file

@ -15,13 +15,14 @@ pub fn init_tracing(_service_name: &str) -> miette::Result<TelemetryGuard> {
.with_writer(nb_writer) .with_writer(nb_writer)
.with_ansi(atty::is(atty::Stream::Stderr)); .with_ansi(atty::is(atty::Stream::Stderr));
let filter = EnvFilter::try_from_default_env() let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
.unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry() tracing_subscriber::registry()
.with(filter) .with(filter)
.with(fmt_layer) .with(fmt_layer)
.init(); .init();
Ok(TelemetryGuard { _guard: Some(guard) }) Ok(TelemetryGuard {
_guard: Some(guard),
})
} }

View file

@ -2,12 +2,12 @@ use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Router,
body::Bytes, body::Bytes,
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::IntoResponse, response::IntoResponse,
routing::post, routing::post,
Router,
}; };
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
@ -33,7 +33,11 @@ enum Cmd {
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "solstice-forge", version, about = "Solstice CI — Forge Integration Layer")] #[command(
name = "solstice-forge",
version,
about = "Solstice CI — Forge Integration Layer"
)]
struct Opts { struct Opts {
/// HTTP bind address for webhooks (e.g., 0.0.0.0:8080) /// HTTP bind address for webhooks (e.g., 0.0.0.0:8080)
#[arg(long, env = "HTTP_ADDR", default_value = "0.0.0.0:8080")] #[arg(long, env = "HTTP_ADDR", default_value = "0.0.0.0:8080")]
@ -87,12 +91,25 @@ async fn main() -> Result<()> {
// Apply AMQP overrides if provided // Apply AMQP overrides if provided
let mut mq_cfg = common::MqConfig::default(); let mut mq_cfg = common::MqConfig::default();
if let Some(u) = opts.amqp_url { mq_cfg.url = u; } if let Some(u) = opts.amqp_url {
if let Some(x) = opts.amqp_exchange { mq_cfg.exchange = x; } mq_cfg.url = u;
if let Some(q) = opts.amqp_queue { mq_cfg.queue = q; } }
if let Some(rk) = opts.amqp_routing_key { mq_cfg.routing_key = rk; } if let Some(x) = opts.amqp_exchange {
mq_cfg.exchange = x;
}
if let Some(q) = opts.amqp_queue {
mq_cfg.queue = q;
}
if let Some(rk) = opts.amqp_routing_key {
mq_cfg.routing_key = rk;
}
if let Some(Cmd::Enqueue { repo_url, commit_sha, runs_on }) = opts.cmd { if let Some(Cmd::Enqueue {
repo_url,
commit_sha,
runs_on,
}) = opts.cmd
{
let mut jr = common::JobRequest::new(common::SourceSystem::Manual, repo_url, commit_sha); let mut jr = common::JobRequest::new(common::SourceSystem::Manual, repo_url, commit_sha);
jr.runs_on = runs_on; jr.runs_on = runs_on;
common::publish_job(&mq_cfg, &jr).await?; common::publish_job(&mq_cfg, &jr).await?;
@ -101,10 +118,15 @@ async fn main() -> Result<()> {
} }
if opts.webhook_secret.is_none() { if opts.webhook_secret.is_none() {
warn!("WEBHOOK_SECRET is not set — accepting webhooks without signature validation (dev mode)"); warn!(
"WEBHOOK_SECRET is not set — accepting webhooks without signature validation (dev mode)"
);
} }
let state = Arc::new(AppState { mq_cfg, webhook_secret: opts.webhook_secret }); let state = Arc::new(AppState {
mq_cfg,
webhook_secret: opts.webhook_secret,
});
// Leak the path string to satisfy 'static requirement for axum route API // Leak the path string to satisfy 'static requirement for axum route API
let path: &'static str = Box::leak(opts.webhook_path.clone().into_boxed_str()); let path: &'static str = Box::leak(opts.webhook_path.clone().into_boxed_str());
@ -114,9 +136,12 @@ async fn main() -> Result<()> {
.with_state(state.clone()); .with_state(state.clone());
let addr: SocketAddr = opts.http_addr.parse().expect("invalid HTTP_ADDR"); let addr: SocketAddr = opts.http_addr.parse().expect("invalid HTTP_ADDR");
axum::serve(tokio::net::TcpListener::bind(addr).await.expect("bind"), router) axum::serve(
.await tokio::net::TcpListener::bind(addr).await.expect("bind"),
.expect("server error"); router,
)
.await
.expect("server error");
Ok(()) Ok(())
} }

View file

@ -3,7 +3,11 @@ use miette::Result;
use tracing::{info, warn}; use tracing::{info, warn};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "solstice-github", version, about = "Solstice CI — GitHub Integration (GitHub App)")] #[command(
name = "solstice-github",
version,
about = "Solstice CI — GitHub Integration (GitHub App)"
)]
struct Opts { struct Opts {
/// HTTP bind address for GitHub webhooks (e.g., 0.0.0.0:8081) /// HTTP bind address for GitHub webhooks (e.g., 0.0.0.0:8081)
#[arg(long, env = "HTTP_ADDR", default_value = "0.0.0.0:8081")] #[arg(long, env = "HTTP_ADDR", default_value = "0.0.0.0:8081")]

View file

@ -26,20 +26,35 @@ mod m2025_10_25_000001_create_jobs {
Table::create() Table::create()
.table(Jobs::Table) .table(Jobs::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(Jobs::RequestId).uuid().not_null().primary_key()) .col(
ColumnDef::new(Jobs::RequestId)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Jobs::RepoUrl).string().not_null()) .col(ColumnDef::new(Jobs::RepoUrl).string().not_null())
.col(ColumnDef::new(Jobs::CommitSha).string().not_null()) .col(ColumnDef::new(Jobs::CommitSha).string().not_null())
.col(ColumnDef::new(Jobs::RunsOn).string().null()) .col(ColumnDef::new(Jobs::RunsOn).string().null())
.col(ColumnDef::new(Jobs::State).string().not_null()) .col(ColumnDef::new(Jobs::State).string().not_null())
.col(ColumnDef::new(Jobs::CreatedAt).timestamp_with_time_zone().not_null()) .col(
.col(ColumnDef::new(Jobs::UpdatedAt).timestamp_with_time_zone().not_null()) ColumnDef::new(Jobs::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Jobs::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(), .to_owned(),
) )
.await .await
} }
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Jobs::Table).to_owned()).await manager
.drop_table(Table::drop().table(Jobs::Table).to_owned())
.await
} }
} }
@ -76,15 +91,25 @@ mod m2025_10_25_000002_create_vms {
.col(ColumnDef::new(Vms::SeedPath).string().null()) .col(ColumnDef::new(Vms::SeedPath).string().null())
.col(ColumnDef::new(Vms::Backend).string().not_null()) .col(ColumnDef::new(Vms::Backend).string().not_null())
.col(ColumnDef::new(Vms::State).string().not_null()) .col(ColumnDef::new(Vms::State).string().not_null())
.col(ColumnDef::new(Vms::CreatedAt).timestamp_with_time_zone().not_null()) .col(
.col(ColumnDef::new(Vms::UpdatedAt).timestamp_with_time_zone().not_null()) ColumnDef::new(Vms::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Vms::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(), .to_owned(),
) )
.await .await
} }
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Vms::Table).to_owned()).await manager
.drop_table(Table::drop().table(Vms::Table).to_owned())
.await
} }
} }

View file

@ -1,4 +1,8 @@
use std::{collections::BTreeMap, fs, path::{Path, PathBuf}}; use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use miette::{IntoDiagnostic as _, Result}; use miette::{IntoDiagnostic as _, Result};
use serde::Deserialize; use serde::Deserialize;
@ -45,7 +49,6 @@ pub struct ImageDefaults {
pub disk_gb: Option<u32>, pub disk_gb: Option<u32>,
} }
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Decompress { pub enum Decompress {
@ -54,7 +57,9 @@ pub enum Decompress {
} }
impl Default for Decompress { impl Default for Decompress {
fn default() -> Self { Decompress::None } fn default() -> Self {
Decompress::None
}
} }
impl OrchestratorConfig { impl OrchestratorConfig {
@ -65,8 +70,7 @@ impl OrchestratorConfig {
}; };
// Use blocking read via spawn_blocking to avoid blocking Tokio // Use blocking read via spawn_blocking to avoid blocking Tokio
let cfg: OrchestratorConfig = task::spawn_blocking(move || { let cfg: OrchestratorConfig = task::spawn_blocking(move || {
let builder = config::Config::builder() let builder = config::Config::builder().add_source(config::File::from(path));
.add_source(config::File::from(path));
let cfg = builder.build().into_diagnostic()?; let cfg = builder.build().into_diagnostic()?;
cfg.try_deserialize().into_diagnostic() cfg.try_deserialize().into_diagnostic()
}) })
@ -113,10 +117,16 @@ pub async fn ensure_images(cfg: &OrchestratorConfig) -> Result<()> {
let resp = reqwest::get(&image.source).await.into_diagnostic()?; let resp = reqwest::get(&image.source).await.into_diagnostic()?;
let status = resp.status(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
miette::bail!("failed to download {url}: {status}", url = image.source, status = status); miette::bail!(
"failed to download {url}: {status}",
url = image.source,
status = status
);
} }
let bytes = resp.bytes().await.into_diagnostic()?; let bytes = resp.bytes().await.into_diagnostic()?;
tokio::fs::write(&tmp_path, &bytes).await.into_diagnostic()?; tokio::fs::write(&tmp_path, &bytes)
.await
.into_diagnostic()?;
// Decompress or move into place // Decompress or move into place
match image.decompress.unwrap_or(Decompress::None) { match image.decompress.unwrap_or(Decompress::None) {
@ -146,7 +156,6 @@ pub async fn ensure_images(cfg: &OrchestratorConfig) -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -172,7 +181,10 @@ mod tests {
// resolve default // resolve default
assert_eq!(cfg.resolve_label(None), Some("openindiana-hipster")); assert_eq!(cfg.resolve_label(None), Some("openindiana-hipster"));
// alias mapping // alias mapping
assert_eq!(cfg.resolve_label(Some("illumos-latest")), Some("openindiana-hipster")); assert_eq!(
cfg.resolve_label(Some("illumos-latest")),
Some("openindiana-hipster")
);
// image for canonical key // image for canonical key
let img = cfg.image_for("openindiana-hipster").expect("image exists"); let img = cfg.image_for("openindiana-hipster").expect("image exists");
assert!(img.nocloud); assert!(img.nocloud);

View file

@ -1,18 +1,23 @@
use std::net::SocketAddr;
use futures_util::StreamExt; use futures_util::StreamExt;
use miette::{IntoDiagnostic as _, Result}; use miette::{IntoDiagnostic as _, Result};
use std::net::SocketAddr;
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use common::runner::v1::{
Ack, LogItem,
runner_server::{Runner, RunnerServer},
};
use common::{MqConfig, publish_job_result}; use common::{MqConfig, publish_job_result};
use common::runner::v1::{runner_server::{Runner, RunnerServer}, LogItem, Ack};
pub struct RunnerSvc { pub struct RunnerSvc {
mq_cfg: MqConfig, mq_cfg: MqConfig,
} }
impl RunnerSvc { impl RunnerSvc {
pub fn new(mq_cfg: MqConfig) -> Self { Self { mq_cfg } } pub fn new(mq_cfg: MqConfig) -> Self {
Self { mq_cfg }
}
} }
#[tonic::async_trait] #[tonic::async_trait]
@ -28,7 +33,12 @@ impl Runner for RunnerSvc {
let mut exit_code: i32 = 0; let mut exit_code: i32 = 0;
let mut success: bool = true; let mut success: bool = true;
while let Some(item) = stream.next().await.transpose().map_err(|e| Status::internal(e.to_string()))? { while let Some(item) = stream
.next()
.await
.transpose()
.map_err(|e| Status::internal(e.to_string()))?
{
// Correlate request id // Correlate request id
if req_id.is_none() { if req_id.is_none() {
match uuid::Uuid::parse_str(&item.request_id) { match uuid::Uuid::parse_str(&item.request_id) {
@ -58,20 +68,38 @@ impl Runner for RunnerSvc {
} }
// Publish final status if we have enough context // Publish final status if we have enough context
if let (Some(id), Some(repo), Some(sha)) = (req_id.as_ref(), repo_url.as_ref(), commit_sha.as_ref()) { if let (Some(id), Some(repo), Some(sha)) =
let result = common::messages::JobResult::new(id.clone(), repo.clone(), sha.clone(), success, exit_code, None); (req_id.as_ref(), repo_url.as_ref(), commit_sha.as_ref())
{
let result = common::messages::JobResult::new(
id.clone(),
repo.clone(),
sha.clone(),
success,
exit_code,
None,
);
if let Err(e) = publish_job_result(&self.mq_cfg, &result).await { if let Err(e) = publish_job_result(&self.mq_cfg, &result).await {
error!(error = %e, request_id = %id, "failed to publish JobResult"); error!(error = %e, request_id = %id, "failed to publish JobResult");
} }
} else { } else {
warn!(have_req_id = req_id.is_some(), have_repo = repo_url.is_some(), have_sha = commit_sha.is_some(), "missing context for JobResult; skipping publish"); warn!(
have_req_id = req_id.is_some(),
have_repo = repo_url.is_some(),
have_sha = commit_sha.is_some(),
"missing context for JobResult; skipping publish"
);
} }
Ok(Response::new(Ack { ok: true })) Ok(Response::new(Ack { ok: true }))
} }
} }
pub async fn serve_with_shutdown(addr: SocketAddr, mq_cfg: MqConfig, shutdown: impl std::future::Future<Output = ()>) -> Result<()> { pub async fn serve_with_shutdown(
addr: SocketAddr,
mq_cfg: MqConfig,
shutdown: impl std::future::Future<Output = ()>,
) -> Result<()> {
info!(%addr, "gRPC server starting"); info!(%addr, "gRPC server starting");
tonic::transport::Server::builder() tonic::transport::Server::builder()
.add_service(RunnerServer::new(RunnerSvc::new(mq_cfg))) .add_service(RunnerServer::new(RunnerSvc::new(mq_cfg)))

View file

@ -1,12 +1,18 @@
use std::{path::PathBuf, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
use miette::{Result, IntoDiagnostic as _}; use miette::{IntoDiagnostic as _, Result};
use std::os::unix::prelude::PermissionsExt;
use std::{path::PathBuf, time::Duration};
use tracing::info; use tracing::info;
// Backend tag is used internally to remember which backend handled this VM. // Backend tag is used internally to remember which backend handled this VM.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum BackendTag { Noop, #[cfg(all(target_os = "linux", feature = "libvirt"))] Libvirt, #[cfg(target_os = "illumos")] Zones } pub enum BackendTag {
Noop,
#[cfg(all(target_os = "linux", feature = "libvirt"))]
Libvirt,
#[cfg(target_os = "illumos")]
Zones,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct VmSpec { pub struct VmSpec {
@ -51,7 +57,9 @@ pub trait Hypervisor: Send + Sync {
async fn start(&self, vm: &VmHandle) -> Result<()>; async fn start(&self, vm: &VmHandle) -> Result<()>;
async fn stop(&self, vm: &VmHandle, graceful_timeout: Duration) -> Result<()>; async fn stop(&self, vm: &VmHandle, graceful_timeout: Duration) -> Result<()>;
async fn destroy(&self, vm: VmHandle) -> Result<()>; async fn destroy(&self, vm: VmHandle) -> Result<()>;
async fn state(&self, _vm: &VmHandle) -> Result<VmState> { Ok(VmState::Prepared) } async fn state(&self, _vm: &VmHandle) -> Result<VmState> {
Ok(VmState::Prepared)
}
} }
/// A router that delegates to the correct backend implementation per job. /// A router that delegates to the correct backend implementation per job.
@ -70,16 +78,27 @@ impl RouterHypervisor {
{ {
return RouterHypervisor { return RouterHypervisor {
noop: NoopHypervisor::default(), noop: NoopHypervisor::default(),
libvirt: Some(LibvirtHypervisor { uri: libvirt_uri, network: libvirt_network }), libvirt: Some(LibvirtHypervisor {
uri: libvirt_uri,
network: libvirt_network,
}),
}; };
} }
#[cfg(target_os = "illumos")] #[cfg(target_os = "illumos")]
{ {
return RouterHypervisor { noop: NoopHypervisor::default(), zones: Some(ZonesHypervisor) }; return RouterHypervisor {
noop: NoopHypervisor::default(),
zones: Some(ZonesHypervisor),
};
} }
#[cfg(all(not(target_os = "illumos"), not(all(target_os = "linux", feature = "libvirt"))))] #[cfg(all(
not(target_os = "illumos"),
not(all(target_os = "linux", feature = "libvirt"))
))]
{ {
return RouterHypervisor { noop: NoopHypervisor::default() }; return RouterHypervisor {
noop: NoopHypervisor::default(),
};
} }
} }
} }
@ -89,11 +108,15 @@ impl Hypervisor for RouterHypervisor {
async fn prepare(&self, spec: &VmSpec, ctx: &JobContext) -> Result<VmHandle> { async fn prepare(&self, spec: &VmSpec, ctx: &JobContext) -> Result<VmHandle> {
#[cfg(all(target_os = "linux", feature = "libvirt"))] #[cfg(all(target_os = "linux", feature = "libvirt"))]
{ {
if let Some(ref hv) = self.libvirt { return hv.prepare(spec, ctx).await; } if let Some(ref hv) = self.libvirt {
return hv.prepare(spec, ctx).await;
}
} }
#[cfg(target_os = "illumos")] #[cfg(target_os = "illumos")]
{ {
if let Some(ref hv) = self.zones { return hv.prepare(spec, ctx).await; } if let Some(ref hv) = self.zones {
return hv.prepare(spec, ctx).await;
}
} }
self.noop.prepare(spec, ctx).await self.noop.prepare(spec, ctx).await
} }
@ -101,11 +124,19 @@ impl Hypervisor for RouterHypervisor {
match vm.backend { match vm.backend {
#[cfg(all(target_os = "linux", feature = "libvirt"))] #[cfg(all(target_os = "linux", feature = "libvirt"))]
BackendTag::Libvirt => { BackendTag::Libvirt => {
if let Some(ref hv) = self.libvirt { hv.start(vm).await } else { self.noop.start(vm).await } if let Some(ref hv) = self.libvirt {
hv.start(vm).await
} else {
self.noop.start(vm).await
}
} }
#[cfg(target_os = "illumos")] #[cfg(target_os = "illumos")]
BackendTag::Zones => { BackendTag::Zones => {
if let Some(ref hv) = self.zones { hv.start(vm).await } else { self.noop.start(vm).await } if let Some(ref hv) = self.zones {
hv.start(vm).await
} else {
self.noop.start(vm).await
}
} }
_ => self.noop.start(vm).await, _ => self.noop.start(vm).await,
} }
@ -114,11 +145,19 @@ impl Hypervisor for RouterHypervisor {
match vm.backend { match vm.backend {
#[cfg(all(target_os = "linux", feature = "libvirt"))] #[cfg(all(target_os = "linux", feature = "libvirt"))]
BackendTag::Libvirt => { BackendTag::Libvirt => {
if let Some(ref hv) = self.libvirt { hv.stop(vm, t).await } else { self.noop.stop(vm, t).await } if let Some(ref hv) = self.libvirt {
hv.stop(vm, t).await
} else {
self.noop.stop(vm, t).await
}
} }
#[cfg(target_os = "illumos")] #[cfg(target_os = "illumos")]
BackendTag::Zones => { BackendTag::Zones => {
if let Some(ref hv) = self.zones { hv.stop(vm, t).await } else { self.noop.stop(vm, t).await } if let Some(ref hv) = self.zones {
hv.stop(vm, t).await
} else {
self.noop.stop(vm, t).await
}
} }
_ => self.noop.stop(vm, t).await, _ => self.noop.stop(vm, t).await,
} }
@ -127,11 +166,19 @@ impl Hypervisor for RouterHypervisor {
match vm.backend { match vm.backend {
#[cfg(all(target_os = "linux", feature = "libvirt"))] #[cfg(all(target_os = "linux", feature = "libvirt"))]
BackendTag::Libvirt => { BackendTag::Libvirt => {
if let Some(ref hv) = self.libvirt { hv.destroy(vm).await } else { self.noop.destroy(vm).await } if let Some(ref hv) = self.libvirt {
hv.destroy(vm).await
} else {
self.noop.destroy(vm).await
}
} }
#[cfg(target_os = "illumos")] #[cfg(target_os = "illumos")]
BackendTag::Zones => { BackendTag::Zones => {
if let Some(ref hv) = self.zones { hv.destroy(vm).await } else { self.noop.destroy(vm).await } if let Some(ref hv) = self.zones {
hv.destroy(vm).await
} else {
self.noop.destroy(vm).await
}
} }
_ => self.noop.destroy(vm).await, _ => self.noop.destroy(vm).await,
} }
@ -140,11 +187,19 @@ impl Hypervisor for RouterHypervisor {
match vm.backend { match vm.backend {
#[cfg(all(target_os = "linux", feature = "libvirt"))] #[cfg(all(target_os = "linux", feature = "libvirt"))]
BackendTag::Libvirt => { BackendTag::Libvirt => {
if let Some(ref hv) = self.libvirt { hv.state(vm).await } else { Ok(VmState::Prepared) } if let Some(ref hv) = self.libvirt {
hv.state(vm).await
} else {
Ok(VmState::Prepared)
}
} }
#[cfg(target_os = "illumos")] #[cfg(target_os = "illumos")]
BackendTag::Zones => { BackendTag::Zones => {
if let Some(ref hv) = self.zones { hv.state(vm).await } else { Ok(VmState::Prepared) } if let Some(ref hv) = self.zones {
hv.state(vm).await
} else {
Ok(VmState::Prepared)
}
} }
_ => Ok(VmState::Prepared), _ => Ok(VmState::Prepared),
} }
@ -160,9 +215,17 @@ impl Hypervisor for NoopHypervisor {
async fn prepare(&self, spec: &VmSpec, ctx: &JobContext) -> Result<VmHandle> { async fn prepare(&self, spec: &VmSpec, ctx: &JobContext) -> Result<VmHandle> {
let id = format!("noop-{}", ctx.request_id); let id = format!("noop-{}", ctx.request_id);
let work_dir = std::env::temp_dir().join("solstice-noop").join(&id); let work_dir = std::env::temp_dir().join("solstice-noop").join(&id);
tokio::fs::create_dir_all(&work_dir).await.into_diagnostic()?; tokio::fs::create_dir_all(&work_dir)
.await
.into_diagnostic()?;
info!(id = %id, label = %spec.label, image = ?spec.image_path, "noop prepare"); info!(id = %id, label = %spec.label, image = ?spec.image_path, "noop prepare");
Ok(VmHandle { id, backend: BackendTag::Noop, work_dir, overlay_path: None, seed_iso_path: None }) Ok(VmHandle {
id,
backend: BackendTag::Noop,
work_dir,
overlay_path: None,
seed_iso_path: None,
})
} }
async fn start(&self, vm: &VmHandle) -> Result<()> { async fn start(&self, vm: &VmHandle) -> Result<()> {
info!(id = %vm.id, "noop start"); info!(id = %vm.id, "noop start");
@ -214,7 +277,8 @@ impl Hypervisor for LibvirtHypervisor {
let net_name = self.network.clone(); let net_name = self.network.clone();
tokio::task::spawn_blocking(move || -> miette::Result<()> { tokio::task::spawn_blocking(move || -> miette::Result<()> {
use virt::{connect::Connect, network::Network}; use virt::{connect::Connect, network::Network};
let conn = Connect::open(Some(&uri)).map_err(|e| miette::miette!("libvirt connect failed: {e}"))?; let conn = Connect::open(Some(&uri))
.map_err(|e| miette::miette!("libvirt connect failed: {e}"))?;
if let Ok(net) = Network::lookup_by_name(&conn, &net_name) { if let Ok(net) = Network::lookup_by_name(&conn, &net_name) {
// If not active, try to create (activate). Then set autostart. // If not active, try to create (activate). Then set autostart.
let active = net.is_active().unwrap_or(false); let active = net.is_active().unwrap_or(false);
@ -224,7 +288,9 @@ impl Hypervisor for LibvirtHypervisor {
let _ = net.set_autostart(true); let _ = net.set_autostart(true);
} }
Ok(()) Ok(())
}).await.into_diagnostic()??; })
.await
.into_diagnostic()??;
// Create qcow2 overlay // Create qcow2 overlay
let overlay = work_dir.join("overlay.qcow2"); let overlay = work_dir.join("overlay.qcow2");
@ -255,7 +321,7 @@ impl Hypervisor for LibvirtHypervisor {
}; };
let out = Command::new("qemu-img") let out = Command::new("qemu-img")
.args(["create","-f","qcow2","-F"]) .args(["create", "-f", "qcow2", "-F"])
.arg(&base_fmt) .arg(&base_fmt)
.args(["-b"]) .args(["-b"])
.arg(&base) .arg(&base)
@ -263,22 +329,35 @@ impl Hypervisor for LibvirtHypervisor {
.arg(&size_arg) .arg(&size_arg)
.output() .output()
.map_err(|e| miette::miette!("qemu-img not found or failed: {e}"))?; .map_err(|e| miette::miette!("qemu-img not found or failed: {e}"))?;
if !out.status.success() { return Err(miette::miette!("qemu-img create failed: {}", String::from_utf8_lossy(&out.stderr))); } if !out.status.success() {
return Err(miette::miette!(
"qemu-img create failed: {}",
String::from_utf8_lossy(&out.stderr)
));
}
Ok(()) Ok(())
} }
}).await.into_diagnostic()??; })
.await
.into_diagnostic()??;
let _ = status; // appease compiler if unused let _ = status; // appease compiler if unused
// Build NoCloud seed ISO if user_data provided // Build NoCloud seed ISO if user_data provided
let mut seed_iso: Option<PathBuf> = None; let mut seed_iso: Option<PathBuf> = None;
if let Some(ref user_data) = spec.user_data { if let Some(ref user_data) = spec.user_data {
let seed_dir = work_dir.join("seed"); let seed_dir = work_dir.join("seed");
tokio::fs::create_dir_all(&seed_dir).await.into_diagnostic()?; tokio::fs::create_dir_all(&seed_dir)
.await
.into_diagnostic()?;
let ud_path = seed_dir.join("user-data"); let ud_path = seed_dir.join("user-data");
let md_path = seed_dir.join("meta-data"); let md_path = seed_dir.join("meta-data");
tokio::fs::write(&ud_path, user_data).await.into_diagnostic()?; tokio::fs::write(&ud_path, user_data)
.await
.into_diagnostic()?;
let meta = format!("instance-id: {}\nlocal-hostname: {}\n", id, id); let meta = format!("instance-id: {}\nlocal-hostname: {}\n", id, id);
tokio::fs::write(&md_path, meta.as_bytes()).await.into_diagnostic()?; tokio::fs::write(&md_path, meta.as_bytes())
.await
.into_diagnostic()?;
// mkisofs or genisoimage // mkisofs or genisoimage
let iso_path = work_dir.join("seed.iso"); let iso_path = work_dir.join("seed.iso");
@ -288,17 +367,25 @@ impl Hypervisor for LibvirtHypervisor {
move || -> miette::Result<()> { move || -> miette::Result<()> {
let try_mk = |bin: &str| -> std::io::Result<std::process::Output> { let try_mk = |bin: &str| -> std::io::Result<std::process::Output> {
Command::new(bin) Command::new(bin)
.args(["-V","cidata","-J","-R","-o"]) .args(["-V", "cidata", "-J", "-R", "-o"])
.arg(&iso_path) .arg(&iso_path)
.arg(&seed_dir) .arg(&seed_dir)
.output() .output()
}; };
let out = try_mk("mkisofs").or_else(|_| try_mk("genisoimage")) let out = try_mk("mkisofs")
.or_else(|_| try_mk("genisoimage"))
.map_err(|e| miette::miette!("mkisofs/genisoimage not found: {e}"))?; .map_err(|e| miette::miette!("mkisofs/genisoimage not found: {e}"))?;
if !out.status.success() { return Err(miette::miette!("mkisofs failed: {}", String::from_utf8_lossy(&out.stderr))); } if !out.status.success() {
return Err(miette::miette!(
"mkisofs failed: {}",
String::from_utf8_lossy(&out.stderr)
));
}
Ok(()) Ok(())
} }
}).await.into_diagnostic()??; })
.await
.into_diagnostic()??;
seed_iso = Some(iso_path); seed_iso = Some(iso_path);
} }
@ -310,8 +397,10 @@ impl Hypervisor for LibvirtHypervisor {
let seed_str = seed_iso.as_ref().map(|p| p.display().to_string()); let seed_str = seed_iso.as_ref().map(|p| p.display().to_string());
let net = self.network.clone(); let net = self.network.clone();
let cdrom = seed_str.map(|p| format!("<disk type='file' device='cdrom'>\n <driver name='qemu' type='raw'/>\n <source file='{}'/>\n <target dev='hdb' bus='ide'/>\n <readonly/>\n</disk>", p)).unwrap_or_default(); let cdrom = seed_str.map(|p| format!("<disk type='file' device='cdrom'>\n <driver name='qemu' type='raw'/>\n <source file='{}'/>\n <target dev='hdb' bus='ide'/>\n <readonly/>\n</disk>", p)).unwrap_or_default();
format!("<domain type='kvm'>\n<name>{}</name>\n<memory unit='MiB'>{}</memory>\n<vcpu>{}</vcpu>\n<os>\n <type arch='x86_64' machine='pc'>hvm</type>\n <boot dev='hd'/>\n</os>\n<features><acpi/></features>\n<devices>\n <disk type='file' device='disk'>\n <driver name='qemu' type='qcow2' cache='none'/>\n <source file='{}'/>\n <target dev='vda' bus='virtio'/>\n </disk>\n {}\n <interface type='network'>\n <source network='{}'/>\n <model type='virtio'/>\n </interface>\n <graphics type='vnc' autoport='yes' listen='127.0.0.1'/>\n <serial type='pty'>\n <target port='0'/>\n </serial>\n <console type='pty'>\n <target type='serial' port='0'/>\n </console>\n</devices>\n<on_poweroff>destroy</on_poweroff>\n<on_crash>destroy</on_crash>\n</domain>", format!(
id, mem, vcpus, overlay_str, cdrom, net) "<domain type='kvm'>\n<name>{}</name>\n<memory unit='MiB'>{}</memory>\n<vcpu>{}</vcpu>\n<os>\n <type arch='x86_64' machine='pc'>hvm</type>\n <boot dev='hd'/>\n</os>\n<features><acpi/></features>\n<devices>\n <disk type='file' device='disk'>\n <driver name='qemu' type='qcow2' cache='none'/>\n <source file='{}'/>\n <target dev='vda' bus='virtio'/>\n </disk>\n {}\n <interface type='network'>\n <source network='{}'/>\n <model type='virtio'/>\n </interface>\n <graphics type='vnc' autoport='yes' listen='127.0.0.1'/>\n <serial type='pty'>\n <target port='0'/>\n </serial>\n <console type='pty'>\n <target type='serial' port='0'/>\n </console>\n</devices>\n<on_poweroff>destroy</on_poweroff>\n<on_crash>destroy</on_crash>\n</domain>",
id, mem, vcpus, overlay_str, cdrom, net
)
}; };
// Define via virt crate // Define via virt crate
@ -319,13 +408,23 @@ impl Hypervisor for LibvirtHypervisor {
let xml_clone = xml.clone(); let xml_clone = xml.clone();
tokio::task::spawn_blocking(move || -> miette::Result<()> { tokio::task::spawn_blocking(move || -> miette::Result<()> {
use virt::{connect::Connect, domain::Domain}; use virt::{connect::Connect, domain::Domain};
let conn = Connect::open(Some(&uri2)).map_err(|e| miette::miette!("libvirt connect failed: {e}"))?; let conn = Connect::open(Some(&uri2))
let _dom = Domain::define_xml(&conn, &xml_clone).map_err(|e| miette::miette!("define domain failed: {e}"))?; .map_err(|e| miette::miette!("libvirt connect failed: {e}"))?;
let _dom = Domain::define_xml(&conn, &xml_clone)
.map_err(|e| miette::miette!("define domain failed: {e}"))?;
Ok(()) Ok(())
}).await.into_diagnostic()??; })
.await
.into_diagnostic()??;
info!(domain = %id, image = ?spec.image_path, cpu = spec.cpu, ram_mb = spec.ram_mb, "libvirt prepared"); info!(domain = %id, image = ?spec.image_path, cpu = spec.cpu, ram_mb = spec.ram_mb, "libvirt prepared");
Ok(VmHandle { id, backend: BackendTag::Libvirt, work_dir, overlay_path: Some(overlay), seed_iso_path: seed_iso }) Ok(VmHandle {
id,
backend: BackendTag::Libvirt,
work_dir,
overlay_path: Some(overlay),
seed_iso_path: seed_iso,
})
} }
async fn start(&self, vm: &VmHandle) -> Result<()> { async fn start(&self, vm: &VmHandle) -> Result<()> {
@ -333,12 +432,17 @@ impl Hypervisor for LibvirtHypervisor {
let uri = self.uri.clone(); let uri = self.uri.clone();
tokio::task::spawn_blocking(move || -> miette::Result<()> { tokio::task::spawn_blocking(move || -> miette::Result<()> {
use virt::{connect::Connect, domain::Domain}; use virt::{connect::Connect, domain::Domain};
let conn = Connect::open(Some(&uri)).map_err(|e| miette::miette!("libvirt connect failed: {e}"))?; let conn = Connect::open(Some(&uri))
.map_err(|e| miette::miette!("libvirt connect failed: {e}"))?;
// Lookup domain by name and start // Lookup domain by name and start
let dom = Domain::lookup_by_name(&conn, &id).map_err(|e| miette::miette!("lookup domain failed: {e}"))?; let dom = Domain::lookup_by_name(&conn, &id)
dom.create().map_err(|e| miette::miette!("domain start failed: {e}"))?; .map_err(|e| miette::miette!("lookup domain failed: {e}"))?;
dom.create()
.map_err(|e| miette::miette!("domain start failed: {e}"))?;
Ok(()) Ok(())
}).await.into_diagnostic()??; })
.await
.into_diagnostic()??;
info!(domain = %vm.id, "libvirt started"); info!(domain = %vm.id, "libvirt started");
Ok(()) Ok(())
} }
@ -348,8 +452,10 @@ impl Hypervisor for LibvirtHypervisor {
let uri = self.uri.clone(); let uri = self.uri.clone();
tokio::task::spawn_blocking(move || -> miette::Result<()> { tokio::task::spawn_blocking(move || -> miette::Result<()> {
use virt::{connect::Connect, domain::Domain}; use virt::{connect::Connect, domain::Domain};
let conn = Connect::open(Some(&uri)).map_err(|e| miette::miette!("libvirt connect failed: {e}"))?; let conn = Connect::open(Some(&uri))
let dom = Domain::lookup_by_name(&conn, &id).map_err(|e| miette::miette!("lookup domain failed: {e}"))?; .map_err(|e| miette::miette!("libvirt connect failed: {e}"))?;
let dom = Domain::lookup_by_name(&conn, &id)
.map_err(|e| miette::miette!("lookup domain failed: {e}"))?;
let _ = dom.shutdown(); let _ = dom.shutdown();
let start = std::time::Instant::now(); let start = std::time::Instant::now();
while start.elapsed() < t { while start.elapsed() < t {
@ -362,7 +468,9 @@ impl Hypervisor for LibvirtHypervisor {
// Force destroy if still active // Force destroy if still active
let _ = dom.destroy(); let _ = dom.destroy();
Ok(()) Ok(())
}).await.into_diagnostic()??; })
.await
.into_diagnostic()??;
info!(domain = %vm.id, "libvirt stopped"); info!(domain = %vm.id, "libvirt stopped");
Ok(()) Ok(())
} }
@ -373,15 +481,22 @@ impl Hypervisor for LibvirtHypervisor {
let id_for_task = id.clone(); let id_for_task = id.clone();
tokio::task::spawn_blocking(move || -> miette::Result<()> { tokio::task::spawn_blocking(move || -> miette::Result<()> {
use virt::{connect::Connect, domain::Domain}; use virt::{connect::Connect, domain::Domain};
let conn = Connect::open(Some(&uri)).map_err(|e| miette::miette!("libvirt connect failed: {e}"))?; let conn = Connect::open(Some(&uri))
.map_err(|e| miette::miette!("libvirt connect failed: {e}"))?;
if let Ok(dom) = Domain::lookup_by_name(&conn, &id_for_task) { if let Ok(dom) = Domain::lookup_by_name(&conn, &id_for_task) {
let _ = dom.undefine(); let _ = dom.undefine();
} }
Ok(()) Ok(())
}).await.into_diagnostic()??; })
.await
.into_diagnostic()??;
// Cleanup files // Cleanup files
if let Some(p) = vm.overlay_path.as_ref() { let _ = tokio::fs::remove_file(p).await; } if let Some(p) = vm.overlay_path.as_ref() {
if let Some(p) = vm.seed_iso_path.as_ref() { let _ = tokio::fs::remove_file(p).await; } let _ = tokio::fs::remove_file(p).await;
}
if let Some(p) = vm.seed_iso_path.as_ref() {
let _ = tokio::fs::remove_file(p).await;
}
let _ = tokio::fs::remove_dir_all(&vm.work_dir).await; let _ = tokio::fs::remove_dir_all(&vm.work_dir).await;
info!(domain = %id, "libvirt destroyed"); info!(domain = %id, "libvirt destroyed");
Ok(()) Ok(())
@ -392,12 +507,20 @@ impl Hypervisor for LibvirtHypervisor {
let uri = self.uri.clone(); let uri = self.uri.clone();
let active = tokio::task::spawn_blocking(move || -> miette::Result<bool> { let active = tokio::task::spawn_blocking(move || -> miette::Result<bool> {
use virt::{connect::Connect, domain::Domain}; use virt::{connect::Connect, domain::Domain};
let conn = Connect::open(Some(&uri)).map_err(|e| miette::miette!("libvirt connect failed: {e}"))?; let conn = Connect::open(Some(&uri))
let dom = Domain::lookup_by_name(&conn, &id).map_err(|e| miette::miette!("lookup domain failed: {e}"))?; .map_err(|e| miette::miette!("libvirt connect failed: {e}"))?;
let dom = Domain::lookup_by_name(&conn, &id)
.map_err(|e| miette::miette!("lookup domain failed: {e}"))?;
let active = dom.is_active().unwrap_or(false); let active = dom.is_active().unwrap_or(false);
Ok(active) Ok(active)
}).await.into_diagnostic()??; })
Ok(if active { VmState::Running } else { VmState::Stopped }) .await
.into_diagnostic()??;
Ok(if active {
VmState::Running
} else {
VmState::Stopped
})
} }
} }
@ -428,16 +551,25 @@ impl Hypervisor for ZonesHypervisor {
let base = spec.image_path.clone(); let base = spec.image_path.clone();
let base_fmt = tokio::task::spawn_blocking(move || -> miette::Result<String> { let base_fmt = tokio::task::spawn_blocking(move || -> miette::Result<String> {
let out = Command::new("qemu-img") let out = Command::new("qemu-img")
.args(["info", "--output=json"]).arg(&base) .args(["info", "--output=json"])
.arg(&base)
.output() .output()
.map_err(|e| miette::miette!("qemu-img not found or failed: {e}"))?; .map_err(|e| miette::miette!("qemu-img not found or failed: {e}"))?;
if !out.status.success() { if !out.status.success() {
return Err(miette::miette!("qemu-img info failed: {}", String::from_utf8_lossy(&out.stderr))); return Err(miette::miette!(
"qemu-img info failed: {}",
String::from_utf8_lossy(&out.stderr)
));
} }
let v: serde_json::Value = serde_json::from_slice(&out.stdout) let v: serde_json::Value = serde_json::from_slice(&out.stdout)
.map_err(|e| miette::miette!("parse qemu-img info json failed: {e}"))?; .map_err(|e| miette::miette!("parse qemu-img info json failed: {e}"))?;
Ok(v.get("format").and_then(|f| f.as_str()).unwrap_or("raw").to_string()) Ok(v.get("format")
}).await.into_diagnostic()??; .and_then(|f| f.as_str())
.unwrap_or("raw")
.to_string())
})
.await
.into_diagnostic()??;
// Ensure raw image for bhyve: convert if needed // Ensure raw image for bhyve: convert if needed
let raw_path = if base_fmt != "raw" { let raw_path = if base_fmt != "raw" {
@ -452,10 +584,15 @@ impl Hypervisor for ZonesHypervisor {
.output() .output()
.map_err(|e| miette::miette!("qemu-img convert failed to start: {e}"))?; .map_err(|e| miette::miette!("qemu-img convert failed to start: {e}"))?;
if !out.status.success() { if !out.status.success() {
return Err(miette::miette!("qemu-img convert failed: {}", String::from_utf8_lossy(&out.stderr))); return Err(miette::miette!(
"qemu-img convert failed: {}",
String::from_utf8_lossy(&out.stderr)
));
} }
Ok(()) Ok(())
}).await.into_diagnostic()??; })
.await
.into_diagnostic()??;
info!(label = %spec.label, src = ?spec.image_path, out = ?out_path, "converted image to raw for bhyve"); info!(label = %spec.label, src = ?spec.image_path, out = ?out_path, "converted image to raw for bhyve");
out_path out_path
} else { } else {
@ -463,9 +600,21 @@ impl Hypervisor for ZonesHypervisor {
}; };
// Seed ISO creation left to future; for now, return handle with path in overlay_path // Seed ISO creation left to future; for now, return handle with path in overlay_path
Ok(VmHandle { id, backend: BackendTag::Zones, work_dir, overlay_path: Some(raw_path), seed_iso_path: None }) Ok(VmHandle {
id,
backend: BackendTag::Zones,
work_dir,
overlay_path: Some(raw_path),
seed_iso_path: None,
})
}
async fn start(&self, _vm: &VmHandle) -> Result<()> {
Ok(())
}
async fn stop(&self, _vm: &VmHandle, _t: Duration) -> Result<()> {
Ok(())
}
async fn destroy(&self, _vm: VmHandle) -> Result<()> {
Ok(())
} }
async fn start(&self, _vm: &VmHandle) -> Result<()> { Ok(()) }
async fn stop(&self, _vm: &VmHandle, _t: Duration) -> Result<()> { Ok(()) }
async fn destroy(&self, _vm: VmHandle) -> Result<()> { Ok(()) }
} }

View file

@ -1,8 +1,8 @@
mod config; mod config;
mod hypervisor;
mod scheduler;
mod persist;
mod grpc; mod grpc;
mod hypervisor;
mod persist;
mod scheduler;
use std::{collections::HashMap, path::PathBuf, time::Duration}; use std::{collections::HashMap, path::PathBuf, time::Duration};
@ -10,15 +10,19 @@ use clap::Parser;
use miette::{IntoDiagnostic as _, Result}; use miette::{IntoDiagnostic as _, Result};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::persist::{JobState, Persist};
use config::OrchestratorConfig; use config::OrchestratorConfig;
use hypervisor::{RouterHypervisor, VmSpec, JobContext}; use hypervisor::{JobContext, RouterHypervisor, VmSpec};
use scheduler::{Scheduler, SchedItem}; use scheduler::{SchedItem, Scheduler};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Notify; use tokio::sync::Notify;
use crate::persist::{Persist, JobState};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "solstice-orchestrator", version, about = "Solstice CI Orchestrator")] #[command(
name = "solstice-orchestrator",
version,
about = "Solstice CI Orchestrator"
)]
struct Opts { struct Opts {
/// Path to orchestrator YAML config (image map) /// Path to orchestrator YAML config (image map)
#[arg(long, env = "ORCH_CONFIG")] #[arg(long, env = "ORCH_CONFIG")]
@ -37,7 +41,11 @@ struct Opts {
grpc_addr: String, grpc_addr: String,
/// Postgres connection string /// Postgres connection string
#[arg(long, env = "DATABASE_URL", default_value = "postgres://user:pass@localhost:5432/solstice")] #[arg(
long,
env = "DATABASE_URL",
default_value = "postgres://user:pass@localhost:5432/solstice"
)]
database_url: String, database_url: String,
/// RabbitMQ URL (AMQP) /// RabbitMQ URL (AMQP)
@ -114,11 +122,13 @@ async fn main() -> Result<()> {
let grpc_task = tokio::spawn(async move { let grpc_task = tokio::spawn(async move {
let _ = crate::grpc::serve_with_shutdown(grpc_addr, mq_cfg_for_grpc, async move { let _ = crate::grpc::serve_with_shutdown(grpc_addr, mq_cfg_for_grpc, async move {
let _ = grpc_shutdown_rx.await; let _ = grpc_shutdown_rx.await;
}).await; })
.await;
}); });
// Orchestrator contact address for runner to dial back (can override via ORCH_CONTACT_ADDR) // Orchestrator contact address for runner to dial back (can override via ORCH_CONTACT_ADDR)
let orch_contact = std::env::var("ORCH_CONTACT_ADDR").unwrap_or_else(|_| opts.grpc_addr.clone()); let orch_contact =
std::env::var("ORCH_CONTACT_ADDR").unwrap_or_else(|_| opts.grpc_addr.clone());
// Scheduler // Scheduler
let sched = Scheduler::new( let sched = Scheduler::new(
@ -219,22 +229,34 @@ fn parse_capacity_map(s: Option<&str>) -> HashMap<String, usize> {
let mut m = HashMap::new(); let mut m = HashMap::new();
if let Some(s) = s { if let Some(s) = s {
for part in s.split(',') { for part in s.split(',') {
if part.trim().is_empty() { continue; } if part.trim().is_empty() {
if let Some((k,v)) = part.split_once('=') { continue;
}
if let Some((k, v)) = part.split_once('=') {
let k = k.trim(); let k = k.trim();
if k.is_empty() { continue; } if k.is_empty() {
if let Ok(n) = v.parse::<usize>() { m.insert(k.to_string(), n); } continue;
}
if let Ok(n) = v.parse::<usize>() {
m.insert(k.to_string(), n);
}
} }
} }
} }
m m
} }
fn make_cloud_init_userdata(repo_url: &str, commit_sha: &str, request_id: uuid::Uuid, orch_addr: &str) -> Vec<u8> { fn make_cloud_init_userdata(
repo_url: &str,
commit_sha: &str,
request_id: uuid::Uuid,
orch_addr: &str,
) -> Vec<u8> {
// Allow local dev to inject one or more runner URLs that the VM can fetch. // Allow local dev to inject one or more runner URLs that the VM can fetch.
let runner_url = std::env::var("SOLSTICE_RUNNER_URL").unwrap_or_default(); let runner_url = std::env::var("SOLSTICE_RUNNER_URL").unwrap_or_default();
let runner_urls = std::env::var("SOLSTICE_RUNNER_URLS").unwrap_or_default(); let runner_urls = std::env::var("SOLSTICE_RUNNER_URLS").unwrap_or_default();
let s = format!(r#"#cloud-config let s = format!(
r#"#cloud-config
write_files: write_files:
- path: /etc/solstice/job.yaml - path: /etc/solstice/job.yaml
permissions: '0644' permissions: '0644'
@ -311,18 +333,26 @@ write_files:
(command -v poweroff >/dev/null 2>&1 && poweroff) || (command -v shutdown >/dev/null 2>&1 && shutdown -y -i5 -g0) || true (command -v poweroff >/dev/null 2>&1 && poweroff) || (command -v shutdown >/dev/null 2>&1 && shutdown -y -i5 -g0) || true
runcmd: runcmd:
- [ /usr/local/bin/solstice-bootstrap.sh ] - [ /usr/local/bin/solstice-bootstrap.sh ]
"#, repo = repo_url, sha = commit_sha, req_id = request_id, orch_addr = orch_addr, runner_url = runner_url, runner_urls = runner_urls); "#,
repo = repo_url,
sha = commit_sha,
req_id = request_id,
orch_addr = orch_addr,
runner_url = runner_url,
runner_urls = runner_urls
);
s.into_bytes() s.into_bytes()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_parse_capacity_map_mixed_input() { fn test_parse_capacity_map_mixed_input() {
let m = parse_capacity_map(Some("illumos-latest=2, ubuntu-22.04=4, bad=, =3, other=notnum, foo=5, ,")); let m = parse_capacity_map(Some(
"illumos-latest=2, ubuntu-22.04=4, bad=, =3, other=notnum, foo=5, ,",
));
assert_eq!(m.get("illumos-latest"), Some(&2)); assert_eq!(m.get("illumos-latest"), Some(&2));
assert_eq!(m.get("ubuntu-22.04"), Some(&4)); assert_eq!(m.get("ubuntu-22.04"), Some(&4));
assert_eq!(m.get("foo"), Some(&5)); assert_eq!(m.get("foo"), Some(&5));
@ -334,7 +364,12 @@ mod tests {
#[test] #[test]
fn test_make_cloud_init_userdata_includes_fields() { fn test_make_cloud_init_userdata_includes_fields() {
let req_id = uuid::Uuid::new_v4(); let req_id = uuid::Uuid::new_v4();
let data = make_cloud_init_userdata("https://example.com/repo.git", "deadbeef", req_id, "127.0.0.1:50051"); let data = make_cloud_init_userdata(
"https://example.com/repo.git",
"deadbeef",
req_id,
"127.0.0.1:50051",
);
let s = String::from_utf8(data).unwrap(); let s = String::from_utf8(data).unwrap();
assert!(s.contains("#cloud-config")); assert!(s.contains("#cloud-config"));
assert!(s.contains("repo_url: https://example.com/repo.git")); assert!(s.contains("repo_url: https://example.com/repo.git"));

View file

@ -1,10 +1,13 @@
use chrono::Utc;
use miette::{IntoDiagnostic as _, Result}; use miette::{IntoDiagnostic as _, Result};
use sea_orm::{entity::prelude::*, Database, DatabaseConnection, Set, ActiveModelTrait, QueryFilter, ColumnTrait}; use sea_orm::sea_query::{Expr, OnConflict};
use sea_orm::sea_query::{OnConflict, Expr}; use sea_orm::{
ActiveModelTrait, ColumnTrait, Database, DatabaseConnection, QueryFilter, Set,
entity::prelude::*,
};
use sea_orm_migration::MigratorTrait; use sea_orm_migration::MigratorTrait;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use uuid::Uuid; use uuid::Uuid;
use chrono::Utc;
/// Minimal persistence module for the Orchestrator with real upserts for jobs & vms. /// Minimal persistence module for the Orchestrator with real upserts for jobs & vms.
#[derive(Clone)] #[derive(Clone)]
@ -13,20 +16,40 @@ pub struct Persist {
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum JobState { Queued, Running, Succeeded, Failed } pub enum JobState {
Queued,
Running,
Succeeded,
Failed,
}
impl JobState { impl JobState {
fn as_str(self) -> &'static str { fn as_str(self) -> &'static str {
match self { JobState::Queued => "queued", JobState::Running => "running", JobState::Succeeded => "succeeded", JobState::Failed => "failed" } match self {
JobState::Queued => "queued",
JobState::Running => "running",
JobState::Succeeded => "succeeded",
JobState::Failed => "failed",
}
} }
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum VmPersistState { Prepared, Running, Stopped, Destroyed } pub enum VmPersistState {
Prepared,
Running,
Stopped,
Destroyed,
}
impl VmPersistState { impl VmPersistState {
fn as_str(self) -> &'static str { fn as_str(self) -> &'static str {
match self { VmPersistState::Prepared => "prepared", VmPersistState::Running => "running", VmPersistState::Stopped => "stopped", VmPersistState::Destroyed => "destroyed" } match self {
VmPersistState::Prepared => "prepared",
VmPersistState::Running => "running",
VmPersistState::Stopped => "stopped",
VmPersistState::Destroyed => "destroyed",
}
} }
} }
@ -37,7 +60,6 @@ impl VmPersistState {
mod jobs { mod jobs {
use super::*; use super::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "jobs")] #[sea_orm(table_name = "jobs")]
pub struct Model { pub struct Model {
@ -60,7 +82,6 @@ mod jobs {
mod vms { mod vms {
use super::*; use super::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "vms")] #[sea_orm(table_name = "vms")]
pub struct Model { pub struct Model {
@ -120,7 +141,9 @@ impl Persist {
Ok(Self { db: None }) Ok(Self { db: None })
} }
pub fn is_enabled(&self) -> bool { self.db.is_some() } pub fn is_enabled(&self) -> bool {
self.db.is_some()
}
/// Upsert a job row by request_id. /// Upsert a job row by request_id.
pub async fn record_job_state( pub async fn record_job_state(
@ -182,8 +205,14 @@ impl Persist {
let state_val = state.as_str(); let state_val = state.as_str();
let res = vms::Entity::update_many() let res = vms::Entity::update_many()
.col_expr(vms::Column::OverlayPath, Expr::value(overlay_path.map(|s| s.to_string()))) .col_expr(
.col_expr(vms::Column::SeedPath, Expr::value(seed_path.map(|s| s.to_string()))) vms::Column::OverlayPath,
Expr::value(overlay_path.map(|s| s.to_string())),
)
.col_expr(
vms::Column::SeedPath,
Expr::value(seed_path.map(|s| s.to_string())),
)
.col_expr(vms::Column::Backend, Expr::value(backend_val)) .col_expr(vms::Column::Backend, Expr::value(backend_val))
.col_expr(vms::Column::State, Expr::value(state_val)) .col_expr(vms::Column::State, Expr::value(state_val))
.col_expr(vms::Column::UpdatedAt, Expr::value(now)) .col_expr(vms::Column::UpdatedAt, Expr::value(now))
@ -216,8 +245,12 @@ mod tests {
async fn sqlite_memory_db() -> DatabaseConnection { async fn sqlite_memory_db() -> DatabaseConnection {
let mut opts = sea_orm::ConnectOptions::new("sqlite::memory:".to_string()); let mut opts = sea_orm::ConnectOptions::new("sqlite::memory:".to_string());
opts.max_connections(1).min_connections(1).sqlx_logging(false); opts.max_connections(1)
let db = Database::connect(opts).await.expect("sqlite memory connect"); .min_connections(1)
.sqlx_logging(false);
let db = Database::connect(opts)
.await
.expect("sqlite memory connect");
// Create tables from entities to avoid using migrator (faster and avoids migration bookkeeping table) // Create tables from entities to avoid using migrator (faster and avoids migration bookkeeping table)
let backend = db.get_database_backend(); let backend = db.get_database_backend();
let schema = sea_orm::Schema::new(backend); let schema = sea_orm::Schema::new(backend);
@ -233,18 +266,32 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_job_upsert_sqlite() { async fn test_job_upsert_sqlite() {
let db = sqlite_memory_db().await; let db = sqlite_memory_db().await;
let p = Persist { db: Some(db.clone()) }; let p = Persist {
db: Some(db.clone()),
};
let req = Uuid::new_v4(); let req = Uuid::new_v4();
let repo = "https://example.com/repo.git"; let repo = "https://example.com/repo.git";
let sha = "deadbeef"; let sha = "deadbeef";
// Insert queued // Insert queued
p.record_job_state(req, repo, sha, Some("illumos-latest"), JobState::Queued).await.expect("insert queued"); p.record_job_state(req, repo, sha, Some("illumos-latest"), JobState::Queued)
.await
.expect("insert queued");
// Fetch // Fetch
let row = jobs::Entity::find_by_id(req).one(&db).await.expect("query").expect("row exists"); let row = jobs::Entity::find_by_id(req)
.one(&db)
.await
.expect("query")
.expect("row exists");
assert_eq!(row.state, "queued"); assert_eq!(row.state, "queued");
// Update to running // Update to running
p.record_job_state(req, repo, sha, Some("illumos-latest"), JobState::Running).await.expect("update running"); p.record_job_state(req, repo, sha, Some("illumos-latest"), JobState::Running)
let row2 = jobs::Entity::find_by_id(req).one(&db).await.expect("query").expect("row exists"); .await
.expect("update running");
let row2 = jobs::Entity::find_by_id(req)
.one(&db)
.await
.expect("query")
.expect("row exists");
assert_eq!(row2.state, "running"); assert_eq!(row2.state, "running");
assert!(row2.updated_at >= row.created_at); assert!(row2.updated_at >= row.created_at);
} }
@ -252,13 +299,42 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_vm_event_upsert_sqlite() { async fn test_vm_event_upsert_sqlite() {
let db = sqlite_memory_db().await; let db = sqlite_memory_db().await;
let p = Persist { db: Some(db.clone()) }; let p = Persist {
db: Some(db.clone()),
};
let req = Uuid::new_v4(); let req = Uuid::new_v4();
let domain = format!("job-{}", req); let domain = format!("job-{}", req);
// prepared -> running -> destroyed // prepared -> running -> destroyed
p.record_vm_event(req, &domain, Some("/tmp/ovl.qcow2"), Some("/tmp/seed.iso"), Some("noop"), VmPersistState::Prepared).await.expect("prepared"); p.record_vm_event(
p.record_vm_event(req, &domain, Some("/tmp/ovl.qcow2"), Some("/tmp/seed.iso"), Some("noop"), VmPersistState::Running).await.expect("running"); req,
p.record_vm_event(req, &domain, Some("/tmp/ovl.qcow2"), Some("/tmp/seed.iso"), Some("noop"), VmPersistState::Destroyed).await.expect("destroyed"); &domain,
Some("/tmp/ovl.qcow2"),
Some("/tmp/seed.iso"),
Some("noop"),
VmPersistState::Prepared,
)
.await
.expect("prepared");
p.record_vm_event(
req,
&domain,
Some("/tmp/ovl.qcow2"),
Some("/tmp/seed.iso"),
Some("noop"),
VmPersistState::Running,
)
.await
.expect("running");
p.record_vm_event(
req,
&domain,
Some("/tmp/ovl.qcow2"),
Some("/tmp/seed.iso"),
Some("noop"),
VmPersistState::Destroyed,
)
.await
.expect("destroyed");
use sea_orm::QuerySelect; use sea_orm::QuerySelect;
let rows = vms::Entity::find() let rows = vms::Entity::find()
.filter(vms::Column::RequestId.eq(req)) .filter(vms::Column::RequestId.eq(req))
@ -275,7 +351,6 @@ mod tests {
} }
} }
#[cfg(test)] #[cfg(test)]
mod noop_tests { mod noop_tests {
use super::*; use super::*;
@ -286,7 +361,18 @@ mod noop_tests {
assert!(!p.is_enabled()); assert!(!p.is_enabled());
// Calls should succeed without DB // Calls should succeed without DB
let req = Uuid::new_v4(); let req = Uuid::new_v4();
p.record_job_state(req, "https://x", "sha", Some("illumos"), JobState::Queued).await.expect("job noop ok"); p.record_job_state(req, "https://x", "sha", Some("illumos"), JobState::Queued)
p.record_vm_event(req, "job-x", None, None, Some("noop"), VmPersistState::Prepared).await.expect("vm noop ok"); .await
.expect("job noop ok");
p.record_vm_event(
req,
"job-x",
None,
None,
Some("noop"),
VmPersistState::Prepared,
)
.await
.expect("vm noop ok");
} }
} }

View file

@ -2,11 +2,11 @@ use std::{collections::HashMap, sync::Arc, time::Duration};
use dashmap::DashMap; use dashmap::DashMap;
use miette::Result; use miette::Result;
use tokio::sync::{mpsc, Semaphore, Notify}; use tokio::sync::{Notify, Semaphore, mpsc};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::hypervisor::{Hypervisor, VmSpec, JobContext, BackendTag}; use crate::hypervisor::{BackendTag, Hypervisor, JobContext, VmSpec};
use crate::persist::{Persist, VmPersistState, JobState}; use crate::persist::{JobState, Persist, VmPersistState};
pub struct Scheduler<H: Hypervisor + 'static> { pub struct Scheduler<H: Hypervisor + 'static> {
hv: Arc<H>, hv: Arc<H>,
@ -26,7 +26,13 @@ pub struct SchedItem {
} }
impl<H: Hypervisor + 'static> Scheduler<H> { impl<H: Hypervisor + 'static> Scheduler<H> {
pub fn new(hv: H, max_concurrency: usize, capacity_map: &HashMap<String, usize>, persist: Arc<Persist>, placeholder_runtime: Duration) -> Self { pub fn new(
hv: H,
max_concurrency: usize,
capacity_map: &HashMap<String, usize>,
persist: Arc<Persist>,
placeholder_runtime: Duration,
) -> Self {
let (tx, rx) = mpsc::channel::<SchedItem>(max_concurrency * 4); let (tx, rx) = mpsc::channel::<SchedItem>(max_concurrency * 4);
let label_sems = DashMap::new(); let label_sems = DashMap::new();
for (label, cap) in capacity_map.iter() { for (label, cap) in capacity_map.iter() {
@ -43,10 +49,20 @@ impl<H: Hypervisor + 'static> Scheduler<H> {
} }
} }
pub fn sender(&self) -> mpsc::Sender<SchedItem> { self.tx.clone() } pub fn sender(&self) -> mpsc::Sender<SchedItem> {
self.tx.clone()
}
pub async fn run_with_shutdown(self, shutdown: Arc<Notify>) -> Result<()> { pub async fn run_with_shutdown(self, shutdown: Arc<Notify>) -> Result<()> {
let Scheduler { hv, mut rx, global_sem, label_sems, persist, placeholder_runtime, .. } = self; let Scheduler {
hv,
mut rx,
global_sem,
label_sems,
persist,
placeholder_runtime,
..
} = self;
let mut handles = Vec::new(); let mut handles = Vec::new();
let mut shutting_down = false; let mut shutting_down = false;
'scheduler: loop { 'scheduler: loop {
@ -138,7 +154,9 @@ impl<H: Hypervisor + 'static> Scheduler<H> {
} }
} }
// Wait for all in-flight tasks to finish // Wait for all in-flight tasks to finish
for h in handles { let _ = h.await; } for h in handles {
let _ = h.await;
}
info!("scheduler: completed"); info!("scheduler: completed");
Ok(()) Ok(())
} }
@ -149,14 +167,13 @@ impl<H: Hypervisor + 'static> Scheduler<H> {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait; use async_trait::async_trait;
use dashmap::DashMap; use dashmap::DashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::hypervisor::{VmHandle, VmState}; use crate::hypervisor::{VmHandle, VmState};
@ -172,7 +189,13 @@ mod tests {
impl MockHypervisor { impl MockHypervisor {
fn new(active_all: Arc<AtomicUsize>, peak_all: Arc<AtomicUsize>) -> Self { fn new(active_all: Arc<AtomicUsize>, peak_all: Arc<AtomicUsize>) -> Self {
Self { active_all, peak_all, per_curr: Arc::new(DashMap::new()), per_peak: Arc::new(DashMap::new()), id_to_label: Arc::new(DashMap::new()) } Self {
active_all,
peak_all,
per_curr: Arc::new(DashMap::new()),
per_peak: Arc::new(DashMap::new()),
id_to_label: Arc::new(DashMap::new()),
}
} }
fn update_peak(peak: &AtomicUsize, current: usize) { fn update_peak(peak: &AtomicUsize, current: usize) {
let mut prev = peak.load(Ordering::Relaxed); let mut prev = peak.load(Ordering::Relaxed);
@ -191,9 +214,16 @@ mod tests {
let now = self.active_all.fetch_add(1, Ordering::SeqCst) + 1; let now = self.active_all.fetch_add(1, Ordering::SeqCst) + 1;
Self::update_peak(&self.peak_all, now); Self::update_peak(&self.peak_all, now);
// per-label current/peak // per-label current/peak
let entry = self.per_curr.entry(spec.label.clone()).or_insert_with(|| Arc::new(AtomicUsize::new(0))); let entry = self
.per_curr
.entry(spec.label.clone())
.or_insert_with(|| Arc::new(AtomicUsize::new(0)));
let curr = entry.fetch_add(1, Ordering::SeqCst) + 1; let curr = entry.fetch_add(1, Ordering::SeqCst) + 1;
let peak_entry = self.per_peak.entry(spec.label.clone()).or_insert_with(|| Arc::new(AtomicUsize::new(0))).clone(); let peak_entry = self
.per_peak
.entry(spec.label.clone())
.or_insert_with(|| Arc::new(AtomicUsize::new(0)))
.clone();
Self::update_peak(&peak_entry, curr); Self::update_peak(&peak_entry, curr);
let id = format!("job-{}", ctx.request_id); let id = format!("job-{}", ctx.request_id);
@ -207,8 +237,12 @@ mod tests {
seed_iso_path: None, seed_iso_path: None,
}) })
} }
async fn start(&self, _vm: &VmHandle) -> miette::Result<()> { Ok(()) } async fn start(&self, _vm: &VmHandle) -> miette::Result<()> {
async fn stop(&self, _vm: &VmHandle, _t: Duration) -> miette::Result<()> { Ok(()) } Ok(())
}
async fn stop(&self, _vm: &VmHandle, _t: Duration) -> miette::Result<()> {
Ok(())
}
async fn destroy(&self, vm: VmHandle) -> miette::Result<()> { async fn destroy(&self, vm: VmHandle) -> miette::Result<()> {
// decrement overall current // decrement overall current
self.active_all.fetch_sub(1, Ordering::SeqCst); self.active_all.fetch_sub(1, Ordering::SeqCst);
@ -220,7 +254,9 @@ mod tests {
} }
Ok(()) Ok(())
} }
async fn state(&self, _vm: &VmHandle) -> miette::Result<VmState> { Ok(VmState::Prepared) } async fn state(&self, _vm: &VmHandle) -> miette::Result<VmState> {
Ok(VmState::Prepared)
}
} }
fn make_spec(label: &str) -> VmSpec { fn make_spec(label: &str) -> VmSpec {
@ -237,7 +273,12 @@ mod tests {
} }
fn make_ctx() -> JobContext { fn make_ctx() -> JobContext {
JobContext { request_id: uuid::Uuid::new_v4(), repo_url: "https://example.com/r.git".into(), commit_sha: "deadbeef".into(), workflow_job_id: None } JobContext {
request_id: uuid::Uuid::new_v4(),
repo_url: "https://example.com/r.git".into(),
commit_sha: "deadbeef".into(),
workflow_job_id: None,
}
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
@ -253,15 +294,25 @@ mod tests {
let sched = Scheduler::new(hv, 2, &caps, persist, Duration::from_millis(10)); let sched = Scheduler::new(hv, 2, &caps, persist, Duration::from_millis(10));
let tx = sched.sender(); let tx = sched.sender();
let run = tokio::spawn(async move { let _ = sched.run().await; }); let run = tokio::spawn(async move {
let _ = sched.run().await;
});
for _ in 0..5 { for _ in 0..5 {
let _ = tx.send(SchedItem { spec: make_spec("x"), ctx: make_ctx() }).await; let _ = tx
.send(SchedItem {
spec: make_spec("x"),
ctx: make_ctx(),
})
.await;
} }
drop(tx); drop(tx);
// Allow time for tasks to execute under concurrency limits // Allow time for tasks to execute under concurrency limits
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
assert!(hv_probe.peak_all.load(Ordering::SeqCst) <= 2, "peak should not exceed global limit"); assert!(
hv_probe.peak_all.load(Ordering::SeqCst) <= 2,
"peak should not exceed global limit"
);
// Stop the scheduler task // Stop the scheduler task
run.abort(); run.abort();
} }
@ -280,19 +331,46 @@ mod tests {
let sched = Scheduler::new(hv, 4, &caps, persist, Duration::from_millis(10)); let sched = Scheduler::new(hv, 4, &caps, persist, Duration::from_millis(10));
let tx = sched.sender(); let tx = sched.sender();
let run = tokio::spawn(async move { let _ = sched.run().await; }); let run = tokio::spawn(async move {
let _ = sched.run().await;
});
for _ in 0..3 { let _ = tx.send(SchedItem { spec: make_spec("a"), ctx: make_ctx() }).await; } for _ in 0..3 {
for _ in 0..3 { let _ = tx.send(SchedItem { spec: make_spec("b"), ctx: make_ctx() }).await; } let _ = tx
.send(SchedItem {
spec: make_spec("a"),
ctx: make_ctx(),
})
.await;
}
for _ in 0..3 {
let _ = tx
.send(SchedItem {
spec: make_spec("b"),
ctx: make_ctx(),
})
.await;
}
drop(tx); drop(tx);
tokio::time::sleep(Duration::from_millis(800)).await; tokio::time::sleep(Duration::from_millis(800)).await;
// read per-label peaks // read per-label peaks
let a_peak = hv_probe.per_peak.get("a").map(|p| p.load(Ordering::SeqCst)).unwrap_or(0); let a_peak = hv_probe
let b_peak = hv_probe.per_peak.get("b").map(|p| p.load(Ordering::SeqCst)).unwrap_or(0); .per_peak
.get("a")
.map(|p| p.load(Ordering::SeqCst))
.unwrap_or(0);
let b_peak = hv_probe
.per_peak
.get("b")
.map(|p| p.load(Ordering::SeqCst))
.unwrap_or(0);
assert!(a_peak <= 1, "label a peak should be <= 1, got {}", a_peak); assert!(a_peak <= 1, "label a peak should be <= 1, got {}", a_peak);
assert!(b_peak <= 2, "label b peak should be <= 2, got {}", b_peak); assert!(b_peak <= 2, "label b peak should be <= 2, got {}", b_peak);
assert!(hv_probe.peak_all.load(Ordering::SeqCst) <= 4, "global peak should respect global limit"); assert!(
hv_probe.peak_all.load(Ordering::SeqCst) <= 4,
"global peak should respect global limit"
);
run.abort(); run.abort();
} }
} }

View file

@ -1,15 +1,23 @@
use clap::Parser; use clap::Parser;
use common::runner::v1::{JobEnd, LogChunk, LogItem, log_item::Event, runner_client::RunnerClient};
use miette::{IntoDiagnostic as _, Result}; use miette::{IntoDiagnostic as _, Result};
use serde::Deserialize; use serde::Deserialize;
use tokio::{fs, process::Command, io::{AsyncBufReadExt, BufReader}};
use std::process::Stdio; use std::process::Stdio;
use tokio::sync::mpsc;
use tokio::{
fs,
io::{AsyncBufReadExt, BufReader},
process::Command,
};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use tokio::sync::mpsc;
use common::runner::v1::{runner_client::RunnerClient, log_item::Event, LogItem, LogChunk, JobEnd};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "solstice-runner", version, about = "Solstice CI Workflow Runner (VM agent)")] #[command(
name = "solstice-runner",
version,
about = "Solstice CI Workflow Runner (VM agent)"
)]
struct Opts { struct Opts {
/// Optional path to workflow KDL file (for local testing only) /// Optional path to workflow KDL file (for local testing only)
#[arg(long, env = "SOL_WORKFLOW_PATH")] #[arg(long, env = "SOL_WORKFLOW_PATH")]
@ -23,7 +31,8 @@ struct JobFile {
} }
async fn read_job_file() -> Result<JobFile> { async fn read_job_file() -> Result<JobFile> {
let path = std::env::var("SOLSTICE_JOB_FILE").unwrap_or_else(|_| "/etc/solstice/job.yaml".into()); let path =
std::env::var("SOLSTICE_JOB_FILE").unwrap_or_else(|_| "/etc/solstice/job.yaml".into());
let bytes = fs::read(&path).await.into_diagnostic()?; let bytes = fs::read(&path).await.into_diagnostic()?;
let jf: JobFile = serde_yaml::from_slice(&bytes).into_diagnostic()?; let jf: JobFile = serde_yaml::from_slice(&bytes).into_diagnostic()?;
Ok(jf) Ok(jf)
@ -31,7 +40,12 @@ async fn read_job_file() -> Result<JobFile> {
async fn run_shell(cmd: &str) -> Result<i32> { async fn run_shell(cmd: &str) -> Result<i32> {
info!(%cmd, "exec"); info!(%cmd, "exec");
let status = Command::new("/bin/sh").arg("-lc").arg(cmd).status().await.into_diagnostic()?; let status = Command::new("/bin/sh")
.arg("-lc")
.arg(cmd)
.status()
.await
.into_diagnostic()?;
Ok(status.code().unwrap_or(1)) Ok(status.code().unwrap_or(1))
} }
@ -40,15 +54,23 @@ async fn ensure_repo(repo: &str, sha: &str, workdir: &str) -> Result<()> {
// Use system git to avoid libgit2 cross issues // Use system git to avoid libgit2 cross issues
let cmds = vec![ let cmds = vec![
format!("cd {workdir} && git init"), format!("cd {workdir} && git init"),
format!("cd {workdir} && git remote remove origin >/dev/null 2>&1 || true && git remote add origin {repo}"), format!(
"cd {workdir} && git remote remove origin >/dev/null 2>&1 || true && git remote add origin {repo}"
),
format!("cd {workdir} && git fetch --depth=1 origin {sha}"), format!("cd {workdir} && git fetch --depth=1 origin {sha}"),
format!("cd {workdir} && git checkout -q FETCH_HEAD"), format!("cd {workdir} && git checkout -q FETCH_HEAD"),
]; ];
for c in cmds { let _ = run_shell(&c).await?; } for c in cmds {
let _ = run_shell(&c).await?;
}
Ok(()) Ok(())
} }
async fn run_job_script_streamed(workdir: &str, tx: Option<mpsc::Sender<LogItem>>, request_id: &str) -> Result<i32> { async fn run_job_script_streamed(
workdir: &str,
tx: Option<mpsc::Sender<LogItem>>,
request_id: &str,
) -> Result<i32> {
let script = format!("{}/.solstice/job.sh", workdir); let script = format!("{}/.solstice/job.sh", workdir);
if !fs::try_exists(&script).await.into_diagnostic()? { if !fs::try_exists(&script).await.into_diagnostic()? {
warn!(path = %script, "job script not found"); warn!(path = %script, "job script not found");
@ -57,7 +79,8 @@ async fn run_job_script_streamed(workdir: &str, tx: Option<mpsc::Sender<LogItem>
let _ = run_shell(&format!("chmod +x {} || true", script)).await?; let _ = run_shell(&format!("chmod +x {} || true", script)).await?;
let mut cmd = Command::new("/bin/sh"); let mut cmd = Command::new("/bin/sh");
cmd.arg("-lc").arg(format!("cd {workdir} && {}", script)) cmd.arg("-lc")
.arg(format!("cd {workdir} && {}", script))
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
let mut child = cmd.spawn().into_diagnostic()?; let mut child = cmd.spawn().into_diagnostic()?;
@ -69,7 +92,15 @@ async fn run_job_script_streamed(workdir: &str, tx: Option<mpsc::Sender<LogItem>
let req = request_id.to_string(); let req = request_id.to_string();
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
let _ = tx2.send(LogItem { request_id: req.clone(), event: Some(Event::Log(LogChunk { line, stderr: false })) }).await; let _ = tx2
.send(LogItem {
request_id: req.clone(),
event: Some(Event::Log(LogChunk {
line,
stderr: false,
})),
})
.await;
} }
}); });
} }
@ -79,7 +110,12 @@ async fn run_job_script_streamed(workdir: &str, tx: Option<mpsc::Sender<LogItem>
let req = request_id.to_string(); let req = request_id.to_string();
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
let _ = tx2.send(LogItem { request_id: req.clone(), event: Some(Event::Log(LogChunk { line, stderr: true })) }).await; let _ = tx2
.send(LogItem {
request_id: req.clone(),
event: Some(Event::Log(LogChunk { line, stderr: true })),
})
.await;
} }
}); });
} }
@ -122,7 +158,7 @@ async fn main() -> Result<()> {
let stream = ReceiverStream::new(rx); let stream = ReceiverStream::new(rx);
// Spawn client task // Spawn client task
tokio::spawn(async move { tokio::spawn(async move {
match RunnerClient::connect(format!("http://{addr}" )).await { match RunnerClient::connect(format!("http://{addr}")).await {
Ok(mut client) => { Ok(mut client) => {
let _ = client.stream_logs(stream).await; // ignore result let _ = client.stream_logs(stream).await; // ignore result
} }
@ -134,16 +170,39 @@ async fn main() -> Result<()> {
tx_opt = Some(tx); tx_opt = Some(tx);
// Send a first line // Send a first line
if let Some(ref tx) = tx_opt { if let Some(ref tx) = tx_opt {
let _ = tx.send(LogItem { request_id: req_id.clone(), event: Some(Event::Log(LogChunk { line: format!("runner starting: repo={repo} sha={sha}"), stderr: false })) }).await; let _ = tx
.send(LogItem {
request_id: req_id.clone(),
event: Some(Event::Log(LogChunk {
line: format!("runner starting: repo={repo} sha={sha}"),
stderr: false,
})),
})
.await;
} }
} }
ensure_repo(&repo, &sha, &workdir).await?; ensure_repo(&repo, &sha, &workdir).await?;
let code = run_job_script_streamed(&workdir, tx_opt.clone(), request_id.as_deref().unwrap_or("")).await?; let code = run_job_script_streamed(
&workdir,
tx_opt.clone(),
request_id.as_deref().unwrap_or(""),
)
.await?;
// Send JobEnd if streaming enabled // Send JobEnd if streaming enabled
if let (Some(tx), Some(req_id)) = (tx_opt.clone(), request_id.clone()) { if let (Some(tx), Some(req_id)) = (tx_opt.clone(), request_id.clone()) {
let _ = tx.send(LogItem { request_id: req_id.clone(), event: Some(Event::End(JobEnd { exit_code: code, success: code == 0, repo_url: repo.clone(), commit_sha: sha.clone() })) }).await; let _ = tx
.send(LogItem {
request_id: req_id.clone(),
event: Some(Event::End(JobEnd {
exit_code: code,
success: code == 0,
repo_url: repo.clone(),
commit_sha: sha.clone(),
})),
})
.await;
// Give the client task a brief moment to flush // Give the client task a brief moment to flush
tokio::time::sleep(std::time::Duration::from_millis(50)).await; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
} }