hyperi_rustlib/cli/
app.rs1use std::fmt::Debug;
35
36use serde::de::DeserializeOwned;
37
38use super::error::CliError;
39use super::version::VersionInfo;
40use super::{CommonArgs, StandardCommand, output};
41
42pub trait DfeApp: Sized {
48 type Config: DeserializeOwned + Debug + Send + Sync;
50
51 fn name(&self) -> &str;
53
54 fn env_prefix(&self) -> &str;
56
57 fn version_info(&self) -> VersionInfo;
59
60 fn common_args(&self) -> &CommonArgs;
62
63 fn command(&self) -> Option<&StandardCommand> {
67 None
68 }
69
70 fn load_config(&self, path: Option<&str>) -> Result<Self::Config, CliError>;
76
77 fn run_service(
88 &self,
89 config: Self::Config,
90 runtime: super::ServiceRuntime,
91 ) -> impl std::future::Future<Output = Result<(), CliError>> + Send;
92
93 #[cfg(feature = "scaling")]
98 fn scaling_components(&self, _config: &Self::Config) -> Vec<crate::ScalingComponent> {
99 vec![]
100 }
101
102 #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
109 fn register_metrics(&self, _manager: &crate::metrics::MetricsManager) {}
110
111 #[cfg(feature = "deployment")]
117 fn deployment_contract(&self) -> Option<crate::deployment::DeploymentContract> {
118 None
119 }
120}
121
122pub async fn run_app<A: DfeApp>(app: A) -> Result<(), CliError> {
133 let command = app.command().cloned().unwrap_or(StandardCommand::Run);
134 let args = app.common_args();
135
136 match command {
137 StandardCommand::Version => {
138 let info = app.version_info();
139 println!("{info}");
140 Ok(())
141 }
142
143 StandardCommand::ConfigCheck => {
144 init_logger(args)?;
146
147 let config_path = args.config.as_deref();
148 match app.load_config(config_path) {
149 Ok(config) => {
150 output::print_success("configuration is valid");
151 if !args.quiet {
152 eprintln!();
153 output::print_kv("service", &app.name());
154 output::print_kv("config", &config_path.unwrap_or("(defaults)"));
155 output::print_kv("log_level", &args.effective_log_level());
156 output::print_kv("log_format", &args.log_format);
157 output::print_kv("metrics_addr", &args.metrics_addr);
158 eprintln!();
159 let raw = format!("{config:#?}");
166 #[cfg(feature = "logger")]
167 let masked = {
168 let default_fields = crate::logger::default_sensitive_fields();
169 let patterns: Vec<&str> =
170 default_fields.iter().map(String::as_str).collect();
171 crate::logger::mask_sensitive_string(&raw, &patterns)
172 };
173 #[cfg(not(feature = "logger"))]
174 let masked = raw;
175 eprintln!(" config: {masked}");
176 }
177 Ok(())
178 }
179 Err(e) => {
180 output::print_error(&format!("configuration invalid: {e}"));
181 Err(e)
182 }
183 }
184 }
185
186 #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
187 StandardCommand::MetricsManifest => {
188 let mgr = crate::metrics::MetricsManager::new(app.name());
189 app.register_metrics(&mgr);
190 let manifest = mgr.registry().manifest();
191 println!(
192 "{}",
193 serde_json::to_string_pretty(&manifest)
194 .map_err(|e| CliError::Service(format!("JSON serialisation failed: {e}")))?
195 );
196 Ok(())
197 }
198 #[cfg(not(any(feature = "metrics", feature = "otel-metrics")))]
199 StandardCommand::MetricsManifest => {
200 output::print_error("metrics feature not enabled -- no manifest available");
201 Err(CliError::Service("metrics feature not enabled".into()))
202 }
203
204 StandardCommand::GenerateArtefacts(ref artefact_args) => {
205 generate_artefacts(&app, artefact_args)?;
206 Ok(())
207 }
208
209 StandardCommand::Run => {
210 let version_info = app.version_info();
211 init_logger_for_service(args, app.name(), &version_info.version)?;
212
213 tracing::info!(
214 service = app.name(),
215 version = version_info.version,
216 "starting service"
217 );
218
219 #[cfg(feature = "config")]
228 if crate::config::try_get().is_none() {
229 let opts = crate::config::ConfigOptions {
230 env_prefix: app.env_prefix().to_string(),
231 app_name: Some(app.name().to_string()),
232 config_file: args.config.as_deref().map(std::path::PathBuf::from),
233 ..Default::default()
234 };
235 if let Err(e) = crate::config::setup(opts) {
236 tracing::warn!(
237 error = %e,
238 "config cascade setup failed; from_cascade subsystems will use defaults"
239 );
240 }
241 }
242
243 let config_path = args.config.as_deref();
244 let config = app.load_config(config_path)?;
245
246 tracing::debug!(?config, "configuration loaded");
247
248 let commit = option_env!("GIT_COMMIT").unwrap_or("unknown");
250 let runtime = super::ServiceRuntime::build(
251 app.name(),
252 app.env_prefix(),
253 &args.metrics_addr,
254 &version_info.version,
255 commit,
256 #[cfg(feature = "scaling")]
257 app.scaling_components(&config),
258 )
259 .await?;
260
261 app.run_service(config, runtime).await
262 }
263
264 #[cfg(feature = "top")]
265 StandardCommand::Top(ref top_args) => {
266 let top_config = crate::top::TopConfig::from_args(top_args);
267 crate::top::run_top(&top_config).map_err(|e| CliError::Service(e.to_string()))
268 }
269 }
270}
271
272#[cfg(feature = "logger")]
274fn init_logger(args: &CommonArgs) -> Result<(), CliError> {
275 let opts = args.to_logger_options()?;
276 crate::logger::setup(opts)?;
277 Ok(())
278}
279
280#[cfg(feature = "logger")]
282fn init_logger_for_service(
283 args: &CommonArgs,
284 service_name: &str,
285 service_version: &str,
286) -> Result<(), CliError> {
287 let opts = args.to_logger_options()?;
288 crate::logger::setup(crate::logger::LoggerOptions {
289 service_name: Some(service_name.to_string()),
290 service_version: Some(service_version.to_string()),
291 ..opts
292 })?;
293 Ok(())
294}
295
296#[cfg(not(feature = "logger"))]
298fn init_logger(_args: &CommonArgs) -> Result<(), CliError> {
299 Ok(())
300}
301
302#[cfg(not(feature = "logger"))]
304fn init_logger_for_service(
305 _args: &CommonArgs,
306 _service_name: &str,
307 _service_version: &str,
308) -> Result<(), CliError> {
309 Ok(())
310}
311
312fn generate_artefacts<A: DfeApp>(
318 app: &A,
319 args: &super::commands::GenerateArtefactsArgs,
320) -> Result<(), CliError> {
321 let output_dir = std::path::Path::new(&args.output_dir);
322 std::fs::create_dir_all(output_dir)
323 .map_err(|e| CliError::Service(format!("failed to create output dir: {e}")))?;
324
325 let mut generated: Vec<String> = Vec::new();
326
327 #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
329 {
330 let mgr = crate::metrics::MetricsManager::new(app.name());
331 app.register_metrics(&mgr);
332 let manifest = mgr.registry().manifest();
333 let path = output_dir.join("metrics-manifest.json");
334 let json = serde_json::to_string_pretty(&manifest)
335 .map_err(|e| CliError::Service(format!("metrics manifest JSON failed: {e}")))?;
336 std::fs::write(&path, &json)
337 .map_err(|e| CliError::Service(format!("failed to write {}: {e}", path.display())))?;
338 generated.push(format!(
339 "metrics-manifest.json ({} metrics)",
340 manifest.metrics.len()
341 ));
342 }
343
344 #[cfg(feature = "deployment")]
346 let deployment_contract = app.deployment_contract();
347 #[cfg(feature = "deployment")]
348 if deployment_contract.is_none() {
349 output::print_warn(&format!(
350 "DfeApp::deployment_contract() returned None for `{}` -- \
351 only metrics-manifest.json will be generated. \
352 Implement the trait hook to emit deployment-contract.json, \
353 container-manifest.json, and Dockerfile.runtime.",
354 app.name()
355 ));
356 }
357 #[cfg(feature = "deployment")]
358 if let Some(contract) = deployment_contract {
359 let path = output_dir.join("deployment-contract.json");
361 let json = serde_json::to_string_pretty(&contract)
362 .map_err(|e| CliError::Service(format!("deployment contract JSON failed: {e}")))?;
363 std::fs::write(&path, &json)
364 .map_err(|e| CliError::Service(format!("failed to write {}: {e}", path.display())))?;
365 generated.push("deployment-contract.json".to_string());
366
367 let cm_path = output_dir.join("container-manifest.json");
369 let cm_json = crate::deployment::generate::generate_container_manifest(&contract)
370 .map_err(|e| CliError::Service(format!("container manifest failed: {e}")))?;
371 std::fs::write(&cm_path, &cm_json).map_err(|e| {
372 CliError::Service(format!("failed to write {}: {e}", cm_path.display()))
373 })?;
374 generated.push("container-manifest.json".to_string());
375
376 let rt_path = output_dir.join("Dockerfile.runtime");
378 let rt_content = crate::deployment::generate::generate_runtime_stage(&contract);
379 std::fs::write(&rt_path, &rt_content).map_err(|e| {
380 CliError::Service(format!("failed to write {}: {e}", rt_path.display()))
381 })?;
382 generated.push("Dockerfile.runtime".to_string());
383
384 let argo_path = output_dir.join("argocd-application.yaml");
387 let argo_cfg = crate::deployment::ArgocdConfig {
388 repo_url: crate::deployment::argocd_repo_url_from_cascade(&contract.app_name),
389 ..Default::default()
390 };
391 let argo_content =
392 crate::deployment::generate::generate_argocd_application(&contract, &argo_cfg, None);
393 std::fs::write(&argo_path, &argo_content).map_err(|e| {
394 CliError::Service(format!("failed to write {}: {e}", argo_path.display()))
395 })?;
396 generated.push("argocd-application.yaml".to_string());
397 }
398
399 if generated.is_empty() {
400 output::print_warn("no artefacts generated (no metrics or deployment features enabled)");
401 } else {
402 output::print_success(&format!(
403 "generated {} artefact(s) in {}",
404 generated.len(),
405 output_dir.display()
406 ));
407 for name in &generated {
408 output::print_kv(" wrote", name);
409 }
410 }
411
412 Ok(())
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn test_standard_command_default_is_run() {
421 let cmd = StandardCommand::Run;
423 assert!(matches!(cmd, StandardCommand::Run));
424 }
425}