cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit e5a23a888b61202c04e930f255fb7ca5fd369ade
parent e42b6344a3c97ad363bb49718e807e6d01736317
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 22:06:40 +0000

runtime: add generic runtime management commands

Diffstat:
Msrc/cli.rs | 150++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/commands/doctor.rs | 1+
Msrc/commands/find.rs | 1+
Msrc/commands/identity.rs | 3+++
Msrc/commands/listing.rs | 12++++++++++++
Msrc/commands/local.rs | 9+++++++++
Msrc/commands/mod.rs | 16+++++++++++++++-
Msrc/commands/myc.rs | 1+
Msrc/commands/net.rs | 1+
Msrc/commands/order.rs | 15+++++++++++++++
Msrc/commands/relay.rs | 1+
Msrc/commands/runtime.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/commands/signer.rs | 3+++
Msrc/commands/sync.rs | 1+
Msrc/domain/runtime.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 228++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Asrc/runtime/management.rs | 743+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 6+++++-
Atests/runtime_management.rs | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 1686 insertions(+), 7 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -65,6 +65,7 @@ pub enum Command { Order(OrderArgs), Relay(RelayArgs), Rpc(RpcArgs), + Runtime(RuntimeArgs), Signer(SignerArgs), Sync(SyncArgs), } @@ -124,6 +125,19 @@ impl Command { RpcCommand::Status => "rpc status", RpcCommand::Sessions => "rpc sessions", }, + Self::Runtime(runtime) => match &runtime.command { + RuntimeCommand::Install(_) => "runtime install", + RuntimeCommand::Uninstall(_) => "runtime uninstall", + RuntimeCommand::Status(_) => "runtime status", + RuntimeCommand::Start(_) => "runtime start", + RuntimeCommand::Stop(_) => "runtime stop", + RuntimeCommand::Restart(_) => "runtime restart", + RuntimeCommand::Logs(_) => "runtime logs", + RuntimeCommand::Config(runtime_config) => match &runtime_config.command { + RuntimeConfigCommand::Show(_) => "runtime config show", + RuntimeConfigCommand::Set(_) => "runtime config set", + }, + }, Self::Signer(signer) => match signer.command { SignerCommand::Status => "signer status", }, @@ -398,6 +412,51 @@ pub enum RpcCommand { } #[derive(Debug, Clone, Args)] +pub struct RuntimeArgs { + #[command(subcommand)] + pub command: RuntimeCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum RuntimeCommand { + Install(RuntimeTargetArgs), + Uninstall(RuntimeTargetArgs), + Status(RuntimeTargetArgs), + Start(RuntimeTargetArgs), + Stop(RuntimeTargetArgs), + Restart(RuntimeTargetArgs), + Logs(RuntimeTargetArgs), + Config(RuntimeConfigArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct RuntimeTargetArgs { + pub runtime: String, + #[arg(long)] + pub instance: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct RuntimeConfigArgs { + #[command(subcommand)] + pub command: RuntimeConfigCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum RuntimeConfigCommand { + Show(RuntimeTargetArgs), + Set(RuntimeConfigSetArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct RuntimeConfigSetArgs { + #[command(flatten)] + pub target: RuntimeTargetArgs, + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, Args)] pub struct OrderArgs { #[command(subcommand)] pub command: OrderCommand, @@ -454,7 +513,8 @@ mod tests { use super::{ AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, MycCommand, NetCommand, OrderCommand, OrderWatchArgs, - RelayCommand, RpcCommand, SignerCommand, SyncCommand, SyncWatchArgs, + RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, SignerCommand, + SyncCommand, SyncWatchArgs, }; use crate::runtime::config::OutputFormat; use clap::Parser; @@ -831,6 +891,85 @@ mod tests { _ => panic!("unexpected command variant"), } + let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]); + match runtime_status.command { + Command::Runtime(args) => match args.command { + RuntimeCommand::Status(target) => { + assert_eq!(target.runtime, "radrootsd"); + assert!(target.instance.is_none()); + } + _ => panic!("unexpected runtime subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let runtime_logs = CliArgs::parse_from([ + "radroots", + "runtime", + "logs", + "radrootsd", + "--instance", + "local", + ]); + match runtime_logs.command { + Command::Runtime(args) => match args.command { + RuntimeCommand::Logs(target) => { + assert_eq!(target.runtime, "radrootsd"); + assert_eq!(target.instance.as_deref(), Some("local")); + } + _ => panic!("unexpected runtime subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let runtime_config_show = CliArgs::parse_from([ + "radroots", + "runtime", + "config", + "show", + "radrootsd", + ]); + match runtime_config_show.command { + Command::Runtime(args) => match args.command { + RuntimeCommand::Config(runtime_config) => match runtime_config.command { + RuntimeConfigCommand::Show(target) => { + assert_eq!(target.runtime, "radrootsd"); + assert!(target.instance.is_none()); + } + _ => panic!("unexpected runtime config subcommand"), + }, + _ => panic!("unexpected runtime subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let runtime_config_set = CliArgs::parse_from([ + "radroots", + "runtime", + "config", + "set", + "radrootsd", + "--instance", + "local", + "bridge.enabled", + "true", + ]); + match runtime_config_set.command { + Command::Runtime(args) => match args.command { + RuntimeCommand::Config(runtime_config) => match runtime_config.command { + RuntimeConfigCommand::Set(set) => { + assert_eq!(set.target.runtime, "radrootsd"); + assert_eq!(set.target.instance.as_deref(), Some("local")); + assert_eq!(set.key, "bridge.enabled"); + assert_eq!(set.value, "true"); + } + _ => panic!("unexpected runtime config subcommand"), + }, + _ => panic!("unexpected runtime subcommand"), + }, + _ => panic!("unexpected command variant"), + } + let order_new = CliArgs::parse_from([ "radroots", "order", @@ -971,5 +1110,14 @@ mod tests { let order_submit = CliArgs::parse_from(["radroots", "order", "submit", "ord_demo"]); assert_eq!(order_submit.command.display_name(), "order submit"); assert!(order_submit.command.supports_dry_run()); + + let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]); + assert_eq!(runtime_status.command.display_name(), "runtime status"); + assert!(runtime_status.command.supports_dry_run()); + assert!( + !runtime_status + .command + .supports_output_format(OutputFormat::Ndjson) + ); } } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs @@ -83,6 +83,7 @@ pub fn report( CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::Doctor(view)) } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::Doctor(view)), CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::Doctor(view)) } diff --git a/src/commands/find.rs b/src/commands/find.rs @@ -11,6 +11,7 @@ pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<CommandOutput, CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::Find(view)) } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::Find(view)), CommandDisposition::InternalError => CommandOutput::internal_error(CommandView::Find(view)), }) } diff --git a/src/commands/identity.rs b/src/commands/identity.rs @@ -63,6 +63,9 @@ pub fn show(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::AccountWhoami(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::AccountWhoami(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::AccountWhoami(view)) } diff --git a/src/commands/listing.rs b/src/commands/listing.rs @@ -28,6 +28,9 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput crate::domain::runtime::CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::ListingGet(view)) } + crate::domain::runtime::CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::ListingGet(view)) + } crate::domain::runtime::CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::ListingGet(view)) } @@ -50,6 +53,9 @@ pub fn publish( crate::domain::runtime::CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::ListingMutation(view)) } + crate::domain::runtime::CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::ListingMutation(view)) + } crate::domain::runtime::CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::ListingMutation(view)) } @@ -71,6 +77,9 @@ pub fn update( crate::domain::runtime::CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::ListingMutation(view)) } + crate::domain::runtime::CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::ListingMutation(view)) + } crate::domain::runtime::CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::ListingMutation(view)) } @@ -92,6 +101,9 @@ pub fn archive( crate::domain::runtime::CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::ListingMutation(view)) } + crate::domain::runtime::CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::ListingMutation(view)) + } crate::domain::runtime::CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::ListingMutation(view)) } diff --git a/src/commands/local.rs b/src/commands/local.rs @@ -19,6 +19,9 @@ pub fn status(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::LocalStatus(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::LocalStatus(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::LocalStatus(view)) } @@ -38,6 +41,9 @@ pub fn backup( CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::LocalBackup(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::LocalBackup(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::LocalBackup(view)) } @@ -57,6 +63,9 @@ pub fn export( CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::LocalExport(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::LocalExport(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::LocalExport(view)) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -15,7 +15,8 @@ pub mod sync; use crate::cli::{ AccountCommand, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand, MycCommand, - NetCommand, OrderCommand, RelayCommand, RpcCommand, SignerCommand, SyncCommand, + NetCommand, OrderCommand, RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, + SignerCommand, SyncCommand, }; use crate::domain::runtime::{CommandOutput, CommandView}; use crate::runtime::RuntimeError; @@ -89,6 +90,19 @@ pub fn dispatch( RpcCommand::Status => Ok(rpc::status(config)), RpcCommand::Sessions => Ok(rpc::sessions(config)), }, + Command::Runtime(runtime_command) => match &runtime_command.command { + RuntimeCommand::Install(args) => runtime::install(config, args), + RuntimeCommand::Uninstall(args) => runtime::uninstall(config, args), + RuntimeCommand::Status(args) => runtime::status(config, args), + RuntimeCommand::Start(args) => runtime::start(config, args), + RuntimeCommand::Stop(args) => runtime::stop(config, args), + RuntimeCommand::Restart(args) => runtime::restart(config, args), + RuntimeCommand::Logs(args) => runtime::logs(config, args), + RuntimeCommand::Config(runtime_config) => match &runtime_config.command { + RuntimeConfigCommand::Show(args) => runtime::config_show(config, logging, args), + RuntimeConfigCommand::Set(args) => runtime::config_set(config, args), + }, + }, Command::Sync(sync) => match &sync.command { SyncCommand::Status => sync::status(config), SyncCommand::Pull => sync::pull(config), diff --git a/src/commands/myc.rs b/src/commands/myc.rs @@ -11,6 +11,7 @@ pub fn status(config: &RuntimeConfig) -> CommandOutput { CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::MycStatus(view)) } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::MycStatus(view)), CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::MycStatus(view)) } diff --git a/src/commands/net.rs b/src/commands/net.rs @@ -13,6 +13,7 @@ pub fn status(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::NetStatus(view)) } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::NetStatus(view)), CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::NetStatus(view)) } diff --git a/src/commands/order.rs b/src/commands/order.rs @@ -13,6 +13,7 @@ pub fn new(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<CommandOutput, CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::OrderNew(view)) } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderNew(view)), CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::OrderNew(view)) } @@ -29,6 +30,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::OrderGet(view)) } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderGet(view)), CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::OrderGet(view)) } @@ -45,6 +47,7 @@ pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::OrderList(view)) } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderList(view)), CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::OrderList(view)) } @@ -64,6 +67,9 @@ pub fn submit( CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::OrderSubmit(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::OrderSubmit(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::OrderSubmit(view)) } @@ -80,6 +86,9 @@ pub fn watch(config: &RuntimeConfig, args: &OrderWatchArgs) -> Result<CommandOut CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::OrderWatch(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::OrderWatch(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::OrderWatch(view)) } @@ -96,6 +105,9 @@ pub fn cancel(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOut CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::OrderCancel(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::OrderCancel(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::OrderCancel(view)) } @@ -112,6 +124,9 @@ pub fn history(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::OrderHistory(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::OrderHistory(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::OrderHistory(view)) } diff --git a/src/commands/relay.rs b/src/commands/relay.rs @@ -12,6 +12,7 @@ pub fn list(config: &RuntimeConfig) -> CommandOutput { CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::RelayList(view)) } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::RelayList(view)), CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::RelayList(view)) } diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -1,18 +1,23 @@ use crate::domain::runtime::{ AccountRuntimeView, AccountSecretRuntimeView, CapabilityBindingRuntimeView, - ConfigFilesRuntimeView, ConfigShowView, HyfProviderRuntimeView, HyfRuntimeView, - LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView, MigrationRuntimeView, - MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, + CommandOutput, CommandView, ConfigFilesRuntimeView, ConfigShowView, HyfProviderRuntimeView, + HyfRuntimeView, LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView, + MigrationRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, ResolvedProviderRuntimeView, RpcRuntimeView, SignerRuntimeView, WorkflowRuntimeView, WritePlaneRuntimeView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; +use crate::runtime::management::{ + RuntimeCommandAvailability, RuntimeConfigMutationRequest, RuntimeLifecycleAction, + inspect_action, inspect_config_set, inspect_config_show, inspect_logs, inspect_status, +}; use crate::runtime::provider::{ resolve_capability_providers, resolve_hyf_provider, resolve_workflow_provider, resolve_write_plane_provider, }; +use crate::cli::{RuntimeConfigSetArgs, RuntimeTargetArgs}; pub fn show( config: &RuntimeConfig, @@ -188,6 +193,140 @@ pub fn show( }) } +pub fn status( + config: &RuntimeConfig, + args: &RuntimeTargetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = inspect_status(config, args.runtime.as_str(), args.instance.as_deref())?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeStatus(inspection.view), + )) +} + +pub fn install( + config: &RuntimeConfig, + args: &RuntimeTargetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = inspect_action( + config, + args.runtime.as_str(), + args.instance.as_deref(), + RuntimeLifecycleAction::Install, + )?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeAction(inspection.view), + )) +} + +pub fn uninstall( + config: &RuntimeConfig, + args: &RuntimeTargetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = inspect_action( + config, + args.runtime.as_str(), + args.instance.as_deref(), + RuntimeLifecycleAction::Uninstall, + )?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeAction(inspection.view), + )) +} + +pub fn start( + config: &RuntimeConfig, + args: &RuntimeTargetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = inspect_action( + config, + args.runtime.as_str(), + args.instance.as_deref(), + RuntimeLifecycleAction::Start, + )?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeAction(inspection.view), + )) +} + +pub fn stop( + config: &RuntimeConfig, + args: &RuntimeTargetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = inspect_action( + config, + args.runtime.as_str(), + args.instance.as_deref(), + RuntimeLifecycleAction::Stop, + )?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeAction(inspection.view), + )) +} + +pub fn restart( + config: &RuntimeConfig, + args: &RuntimeTargetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = inspect_action( + config, + args.runtime.as_str(), + args.instance.as_deref(), + RuntimeLifecycleAction::Restart, + )?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeAction(inspection.view), + )) +} + +pub fn logs( + config: &RuntimeConfig, + args: &RuntimeTargetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = inspect_logs(config, args.runtime.as_str(), args.instance.as_deref())?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeLogs(inspection.view), + )) +} + +pub fn config_show( + config: &RuntimeConfig, + _logging: &LoggingState, + args: &RuntimeTargetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = + inspect_config_show(config, args.runtime.as_str(), args.instance.as_deref())?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeConfigShow(inspection.view), + )) +} + +pub fn config_set( + config: &RuntimeConfig, + args: &RuntimeConfigSetArgs, +) -> Result<CommandOutput, RuntimeError> { + let inspection = inspect_config_set( + config, + &RuntimeConfigMutationRequest { + runtime_id: args.target.runtime.clone(), + instance_id: args.target.instance.clone(), + key: args.key.clone(), + value: args.value.clone(), + }, + )?; + Ok(command_output( + inspection.availability, + CommandView::RuntimeAction(inspection.view), + )) +} + fn migration_runtime_view(config: &RuntimeConfig) -> MigrationRuntimeView { let report = &config.migration.report; let detected_legacy_paths = report @@ -222,3 +361,11 @@ fn migration_runtime_view(config: &RuntimeConfig) -> MigrationRuntimeView { actions, } } + +fn command_output(availability: RuntimeCommandAvailability, view: CommandView) -> CommandOutput { + match availability { + RuntimeCommandAvailability::Success => CommandOutput::success(view), + RuntimeCommandAvailability::Unconfigured => CommandOutput::unconfigured(view), + RuntimeCommandAvailability::Unsupported => CommandOutput::unsupported(view), + } +} diff --git a/src/commands/signer.rs b/src/commands/signer.rs @@ -12,6 +12,9 @@ pub fn status(config: &RuntimeConfig) -> CommandOutput { CommandDisposition::ExternalUnavailable => { CommandOutput::external_unavailable(CommandView::SignerStatus(view)) } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::SignerStatus(view)) + } CommandDisposition::InternalError => { CommandOutput::internal_error(CommandView::SignerStatus(view)) } diff --git a/src/commands/sync.rs b/src/commands/sync.rs @@ -40,6 +40,7 @@ fn output_from_disposition(disposition: CommandDisposition, view: CommandView) - CommandDisposition::Success => CommandOutput::success(view), CommandDisposition::Unconfigured => CommandOutput::unconfigured(view), CommandDisposition::ExternalUnavailable => CommandOutput::external_unavailable(view), + CommandDisposition::Unsupported => CommandOutput::unsupported(view), CommandDisposition::InternalError => CommandOutput::internal_error(view), } } diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -31,6 +31,13 @@ impl CommandOutput { } } + pub fn unsupported(view: CommandView) -> Self { + Self { + disposition: CommandDisposition::Unsupported, + view, + } + } + pub fn internal_error(view: CommandView) -> Self { Self { disposition: CommandDisposition::InternalError, @@ -52,6 +59,7 @@ pub enum CommandDisposition { Success, Unconfigured, ExternalUnavailable, + Unsupported, InternalError, } @@ -61,6 +69,7 @@ impl CommandDisposition { Self::Success => ExitCode::SUCCESS, Self::Unconfigured => ExitCode::from(3), Self::ExternalUnavailable => ExitCode::from(4), + Self::Unsupported => ExitCode::from(5), Self::InternalError => ExitCode::from(1), } } @@ -98,6 +107,10 @@ pub enum CommandView { RpcSessions(RpcSessionsView), RpcStatus(RpcStatusView), RelayList(RelayListView), + RuntimeAction(RuntimeActionView), + RuntimeConfigShow(RuntimeManagedConfigView), + RuntimeLogs(RuntimeLogsView), + RuntimeStatus(RuntimeStatusView), SignerStatus(SignerStatusView), SyncPull(SyncActionView), SyncPush(SyncActionView), @@ -128,6 +141,123 @@ pub struct ConfigShowView { } #[derive(Debug, Clone, Serialize)] +pub struct RuntimeActionView { + pub action: String, + pub runtime_id: String, + pub instance_id: String, + pub instance_source: String, + pub runtime_group: String, + pub state: String, + pub source: String, + pub detail: String, + pub mutates_bindings: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_step: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeManagedConfigView { + pub runtime_id: String, + pub instance_id: String, + pub instance_source: String, + pub runtime_group: String, + pub state: String, + pub source: String, + pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_format: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_path: Option<String>, + pub config_present: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_bootstrap_secret: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_config_bootstrap: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_signer_provider: Option<bool>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeLogsView { + pub runtime_id: String, + pub instance_id: String, + pub instance_source: String, + pub runtime_group: String, + pub state: String, + pub source: String, + pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub stdout_log_path: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stderr_log_path: Option<String>, + pub stdout_log_present: bool, + pub stderr_log_present: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeStatusView { + pub runtime_id: String, + pub instance_id: String, + pub instance_source: String, + pub runtime_group: String, + pub management_posture: String, + pub state: String, + pub source: String, + pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub management_mode: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_manager_integration: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub uses_absolute_binary_paths: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_cli_binding: Option<bool>, + pub install_state: String, + pub health_state: String, + pub health_source: String, + pub registry_path: String, + pub lifecycle_actions: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_paths: Option<RuntimeInstancePathsView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_record: Option<RuntimeInstanceRecordView>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeInstancePathsView { + pub install_dir: String, + pub state_dir: String, + pub logs_dir: String, + pub run_dir: String, + pub secrets_dir: String, + pub pid_file_path: String, + pub stdout_log_path: String, + pub stderr_log_path: String, + pub metadata_path: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeInstanceRecordView { + pub management_mode: String, + pub install_state: String, + pub binary_path: String, + pub config_path: String, + pub logs_path: String, + pub run_path: String, + pub installed_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub health_endpoint: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret_material_ref: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_started_at: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_stopped_at: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct MigrationRuntimeView { pub posture: String, pub state: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -6,7 +6,8 @@ use crate::domain::runtime::{ ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, - RelayListView, RpcSessionsView, RpcStatusView, SyncActionView, SyncStatusView, SyncWatchView, + RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView, + RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -151,6 +152,18 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::RelayList(view) => { render_relay_list(stdout, view)?; } + CommandView::RuntimeAction(view) => { + render_runtime_action(stdout, view)?; + } + CommandView::RuntimeConfigShow(view) => { + render_runtime_config_show(stdout, view)?; + } + CommandView::RuntimeLogs(view) => { + render_runtime_logs(stdout, view)?; + } + CommandView::RuntimeStatus(view) => { + render_runtime_status(stdout, view)?; + } CommandView::SignerStatus(view) => { write_context( stdout, @@ -328,6 +341,22 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::RuntimeAction(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::RuntimeConfigShow(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::RuntimeLogs(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::RuntimeStatus(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::SignerStatus(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -739,6 +768,191 @@ fn render_config_show( Ok(()) } +fn render_runtime_action( + stdout: &mut dyn Write, + view: &RuntimeActionView, +) -> Result<(), RuntimeError> { + write_context( + stdout, + format!("runtime · {} · {}", view.runtime_id, view.action.replace('_', " ")).as_str(), + )?; + render_pairs( + stdout, + "runtime", + &[ + ("runtime", view.runtime_id.as_str()), + ("instance", view.instance_id.as_str()), + ("instance source", view.instance_source.as_str()), + ("group", view.runtime_group.as_str()), + ("state", view.state.as_str()), + ("mutates bindings", yes_no(view.mutates_bindings)), + ], + )?; + writeln!(stdout, "detail: {}", view.detail)?; + if let Some(next_step) = &view.next_step { + writeln!(stdout, "next step: {next_step}")?; + } + writeln!(stdout, "source: {}", view.source)?; + Ok(()) +} + +fn render_runtime_config_show( + stdout: &mut dyn Write, + view: &RuntimeManagedConfigView, +) -> Result<(), RuntimeError> { + write_context(stdout, format!("runtime · {} · config", view.runtime_id).as_str())?; + let mut rows = vec![ + ("runtime", view.runtime_id.as_str()), + ("instance", view.instance_id.as_str()), + ("instance source", view.instance_source.as_str()), + ("group", view.runtime_group.as_str()), + ("state", view.state.as_str()), + ("config present", yes_no(view.config_present)), + ]; + if let Some(config_format) = &view.config_format { + rows.push(("config format", config_format.as_str())); + } + if let Some(config_path) = &view.config_path { + rows.push(("config path", config_path.as_str())); + } + if let Some(requires_bootstrap_secret) = view.requires_bootstrap_secret { + rows.push(( + "requires bootstrap secret", + yes_no(requires_bootstrap_secret), + )); + } + if let Some(requires_config_bootstrap) = view.requires_config_bootstrap { + rows.push(( + "requires config bootstrap", + yes_no(requires_config_bootstrap), + )); + } + if let Some(requires_signer_provider) = view.requires_signer_provider { + rows.push(( + "requires signer provider", + yes_no(requires_signer_provider), + )); + } + render_pairs(stdout, "runtime config", rows.as_slice())?; + writeln!(stdout, "detail: {}", view.detail)?; + writeln!(stdout, "source: {}", view.source)?; + Ok(()) +} + +fn render_runtime_logs( + stdout: &mut dyn Write, + view: &RuntimeLogsView, +) -> Result<(), RuntimeError> { + write_context(stdout, format!("runtime · {} · logs", view.runtime_id).as_str())?; + let mut rows = vec![ + ("runtime", view.runtime_id.as_str()), + ("instance", view.instance_id.as_str()), + ("instance source", view.instance_source.as_str()), + ("group", view.runtime_group.as_str()), + ("state", view.state.as_str()), + ("stdout present", yes_no(view.stdout_log_present)), + ("stderr present", yes_no(view.stderr_log_present)), + ]; + if let Some(stdout_log_path) = &view.stdout_log_path { + rows.push(("stdout log", stdout_log_path.as_str())); + } + if let Some(stderr_log_path) = &view.stderr_log_path { + rows.push(("stderr log", stderr_log_path.as_str())); + } + render_pairs(stdout, "runtime logs", rows.as_slice())?; + writeln!(stdout, "detail: {}", view.detail)?; + writeln!(stdout, "source: {}", view.source)?; + Ok(()) +} + +fn render_runtime_status( + stdout: &mut dyn Write, + view: &RuntimeStatusView, +) -> Result<(), RuntimeError> { + write_context(stdout, format!("runtime · {} · status", view.runtime_id).as_str())?; + let mut rows = vec![ + ("runtime", view.runtime_id.as_str()), + ("instance", view.instance_id.as_str()), + ("instance source", view.instance_source.as_str()), + ("group", view.runtime_group.as_str()), + ("posture", view.management_posture.as_str()), + ("state", view.state.as_str()), + ("install state", view.install_state.as_str()), + ("health state", view.health_state.as_str()), + ("health source", view.health_source.as_str()), + ("registry", view.registry_path.as_str()), + ]; + if let Some(mode) = &view.management_mode { + rows.push(("management mode", mode.as_str())); + } + if let Some(service_manager_integration) = view.service_manager_integration { + rows.push(( + "service manager integration", + yes_no(service_manager_integration), + )); + } + if let Some(uses_absolute_binary_paths) = view.uses_absolute_binary_paths { + rows.push(( + "uses absolute binary paths", + yes_no(uses_absolute_binary_paths), + )); + } + if let Some(preferred_cli_binding) = view.preferred_cli_binding { + rows.push(("preferred cli binding", yes_no(preferred_cli_binding))); + } + render_pairs(stdout, "runtime status", rows.as_slice())?; + writeln!(stdout, "detail: {}", view.detail)?; + if let Some(instance_paths) = &view.instance_paths { + render_pairs( + stdout, + "instance paths", + &[ + ("install dir", instance_paths.install_dir.as_str()), + ("state dir", instance_paths.state_dir.as_str()), + ("logs dir", instance_paths.logs_dir.as_str()), + ("run dir", instance_paths.run_dir.as_str()), + ("secrets dir", instance_paths.secrets_dir.as_str()), + ("pid file", instance_paths.pid_file_path.as_str()), + ("stdout log", instance_paths.stdout_log_path.as_str()), + ("stderr log", instance_paths.stderr_log_path.as_str()), + ("metadata", instance_paths.metadata_path.as_str()), + ], + )?; + } + if let Some(record) = &view.instance_record { + let mut record_rows = vec![ + ("management mode", record.management_mode.as_str()), + ("install state", record.install_state.as_str()), + ("binary path", record.binary_path.as_str()), + ("config path", record.config_path.as_str()), + ("logs path", record.logs_path.as_str()), + ("run path", record.run_path.as_str()), + ("installed version", record.installed_version.as_str()), + ]; + if let Some(health_endpoint) = &record.health_endpoint { + record_rows.push(("health endpoint", health_endpoint.as_str())); + } + if let Some(secret_material_ref) = &record.secret_material_ref { + record_rows.push(("secret material ref", secret_material_ref.as_str())); + } + if let Some(last_started_at) = &record.last_started_at { + record_rows.push(("last started at", last_started_at.as_str())); + } + if let Some(last_stopped_at) = &record.last_stopped_at { + record_rows.push(("last stopped at", last_stopped_at.as_str())); + } + if let Some(notes) = &record.notes { + record_rows.push(("notes", notes.as_str())); + } + render_pairs(stdout, "instance record", record_rows.as_slice())?; + } + if !view.lifecycle_actions.is_empty() { + writeln!(stdout, "lifecycle actions: {}", view.lifecycle_actions.join(", "))?; + } + writeln!(stdout, "source: {}", view.source)?; + Ok(()) +} + fn format_capability_binding_target( binding: &crate::domain::runtime::CapabilityBindingRuntimeView, ) -> String { @@ -2321,6 +2535,18 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::RpcSessions(_) => "rpc sessions", CommandView::RpcStatus(_) => "rpc status", CommandView::RelayList(_) => "relay ls", + CommandView::RuntimeAction(view) => match view.action.as_str() { + "install" => "runtime install", + "uninstall" => "runtime uninstall", + "start" => "runtime start", + "stop" => "runtime stop", + "restart" => "runtime restart", + "config_set" => "runtime config set", + _ => "runtime", + }, + CommandView::RuntimeConfigShow(_) => "runtime config show", + CommandView::RuntimeLogs(_) => "runtime logs", + CommandView::RuntimeStatus(_) => "runtime status", CommandView::SignerStatus(_) => "signer status", CommandView::SyncPull(_) => "sync pull", CommandView::SyncPush(_) => "sync push", diff --git a/src/runtime/management.rs b/src/runtime/management.rs @@ -0,0 +1,743 @@ +use std::path::PathBuf; + +use radroots_runtime_manager::{ + BootstrapRuntimeContract, ManagedRuntimeHealthState, ManagedRuntimeInstanceRecord, + ManagedRuntimeInstanceRegistry, ManagedRuntimeInstallState, ManagementModeContract, + RadrootsRuntimeManagementContract, parse_contract_str, resolve_instance_paths, + resolve_shared_paths, +}; +use radroots_runtime_paths::{RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver}; + +use crate::domain::runtime::{ + RuntimeActionView, RuntimeInstancePathsView, RuntimeInstanceRecordView, RuntimeLogsView, + RuntimeManagedConfigView, RuntimeStatusView, +}; +use crate::runtime::{RuntimeError, config::RuntimeConfig}; + +const MANAGEMENT_CONTRACT_RAW: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../../foundation/contracts/runtime/management.toml" +)); +const DEFERRED_LIFECYCLE_SLICE: &str = "rpv1-rpi.5"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeCommandAvailability { + Success, + Unconfigured, + Unsupported, +} + +#[derive(Debug, Clone)] +pub struct RuntimeInspection<T> { + pub availability: RuntimeCommandAvailability, + pub view: T, +} + +#[derive(Debug, Clone, Copy)] +pub enum RuntimeLifecycleAction { + Install, + Uninstall, + Start, + Stop, + Restart, + ConfigSet, +} + +impl RuntimeLifecycleAction { + pub fn as_str(self) -> &'static str { + match self { + Self::Install => "install", + Self::Uninstall => "uninstall", + Self::Start => "start", + Self::Stop => "stop", + Self::Restart => "restart", + Self::ConfigSet => "config_set", + } + } +} + +#[derive(Debug, Clone)] +pub struct RuntimeConfigMutationRequest { + pub runtime_id: String, + pub instance_id: Option<String>, + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RuntimeGroup { + ActiveManagedTarget, + DefinedManagedTarget, + BootstrapOnly, + Unknown, +} + +impl RuntimeGroup { + fn as_str(self) -> &'static str { + match self { + Self::ActiveManagedTarget => "active_managed_target", + Self::DefinedManagedTarget => "defined_managed_target", + Self::BootstrapOnly => "bootstrap_only", + Self::Unknown => "unknown", + } + } + + fn posture(self) -> &'static str { + match self { + Self::ActiveManagedTarget => "active_managed_target", + Self::DefinedManagedTarget => "defined_future_target", + Self::BootstrapOnly => "bootstrap_only_direct_binding", + Self::Unknown => "unknown_runtime", + } + } +} + +#[derive(Debug, Clone)] +struct RuntimeManagementContext { + contract: RadrootsRuntimeManagementContract, + shared_paths: radroots_runtime_manager::ManagedRuntimeSharedPaths, + registry: ManagedRuntimeInstanceRegistry, +} + +#[derive(Debug, Clone)] +struct RuntimeTarget { + runtime_id: String, + instance_id: String, + instance_source: String, + runtime_group: RuntimeGroup, + management_mode: Option<String>, + mode_contract: Option<ManagementModeContract>, + bootstrap: Option<BootstrapRuntimeContract>, + instance_record: Option<ManagedRuntimeInstanceRecord>, + predicted_paths: Option<radroots_runtime_manager::ManagedRuntimeInstancePaths>, + registry_path: PathBuf, +} + +pub fn inspect_status( + config: &RuntimeConfig, + runtime_id: &str, + instance_id: Option<&str>, +) -> Result<RuntimeInspection<RuntimeStatusView>, RuntimeError> { + let context = load_management_context(config)?; + let target = resolve_runtime_target(&context, runtime_id, instance_id); + let availability = if target.runtime_group == RuntimeGroup::Unknown { + RuntimeCommandAvailability::Unconfigured + } else { + RuntimeCommandAvailability::Success + }; + Ok(RuntimeInspection { + availability, + view: status_view(&target, &context.contract.lifecycle.actions), + }) +} + +pub fn inspect_logs( + config: &RuntimeConfig, + runtime_id: &str, + instance_id: Option<&str>, +) -> Result<RuntimeInspection<RuntimeLogsView>, RuntimeError> { + let context = load_management_context(config)?; + let target = resolve_runtime_target(&context, runtime_id, instance_id); + let (availability, view) = logs_view(&target); + Ok(RuntimeInspection { availability, view }) +} + +pub fn inspect_config_show( + config: &RuntimeConfig, + runtime_id: &str, + instance_id: Option<&str>, +) -> Result<RuntimeInspection<RuntimeManagedConfigView>, RuntimeError> { + let context = load_management_context(config)?; + let target = resolve_runtime_target(&context, runtime_id, instance_id); + let (availability, view) = config_show_view(&target); + Ok(RuntimeInspection { availability, view }) +} + +pub fn inspect_action( + config: &RuntimeConfig, + runtime_id: &str, + instance_id: Option<&str>, + action: RuntimeLifecycleAction, +) -> Result<RuntimeInspection<RuntimeActionView>, RuntimeError> { + let context = load_management_context(config)?; + let target = resolve_runtime_target(&context, runtime_id, instance_id); + let (availability, view) = action_view(&target, action, None); + Ok(RuntimeInspection { availability, view }) +} + +pub fn inspect_config_set( + config: &RuntimeConfig, + request: &RuntimeConfigMutationRequest, +) -> Result<RuntimeInspection<RuntimeActionView>, RuntimeError> { + let context = load_management_context(config)?; + let target = resolve_runtime_target( + &context, + request.runtime_id.as_str(), + request.instance_id.as_deref(), + ); + let detail = Some(format!( + "requested managed config mutation {}={} for runtime `{}` instance `{}`; generic config mutation lands in {}", + request.key, request.value, target.runtime_id, target.instance_id, DEFERRED_LIFECYCLE_SLICE + )); + let (availability, view) = action_view(&target, RuntimeLifecycleAction::ConfigSet, detail); + Ok(RuntimeInspection { availability, view }) +} + +fn load_management_context(config: &RuntimeConfig) -> Result<RuntimeManagementContext, RuntimeError> { + let contract = parse_contract_str(MANAGEMENT_CONTRACT_RAW)?; + let profile = cli_path_profile(config)?; + let overrides = cli_path_overrides(config)?; + let resolver = RadrootsPathResolver::current(); + let mode_id = active_management_mode_for_profile(&contract, profile)?; + let shared_paths = resolve_shared_paths(&contract, &resolver, profile, &overrides, mode_id)?; + let registry = radroots_runtime_manager::load_registry(&shared_paths.instance_registry_path)?; + Ok(RuntimeManagementContext { + contract, + shared_paths, + registry, + }) +} + +fn cli_path_profile(config: &RuntimeConfig) -> Result<RadrootsPathProfile, RuntimeError> { + match config.paths.profile.as_str() { + "interactive_user" => Ok(RadrootsPathProfile::InteractiveUser), + "repo_local" => Ok(RadrootsPathProfile::RepoLocal), + other => Err(RuntimeError::Config(format!( + "runtime management only supports cli path profiles `interactive_user` and `repo_local`, got `{other}`" + ))), + } +} + +fn cli_path_overrides(config: &RuntimeConfig) -> Result<RadrootsPathOverrides, RuntimeError> { + match config.paths.profile.as_str() { + "interactive_user" => Ok(RadrootsPathOverrides::default()), + "repo_local" => { + let Some(repo_local_root) = &config.paths.repo_local_root else { + return Err(RuntimeError::Config( + "repo_local runtime management requires a repo-local root override".to_owned(), + )); + }; + Ok(RadrootsPathOverrides::repo_local(repo_local_root)) + } + other => Err(RuntimeError::Config(format!( + "runtime management only supports cli path profiles `interactive_user` and `repo_local`, got `{other}`" + ))), + } +} + +fn active_management_mode_for_profile<'a>( + contract: &'a RadrootsRuntimeManagementContract, + profile: RadrootsPathProfile, +) -> Result<&'a str, RuntimeError> { + let profile_id = profile.to_string(); + contract + .mode + .iter() + .find(|(_, mode)| { + mode.contract_state == "active" + && mode.supported_profiles.iter().any(|entry| entry == &profile_id) + }) + .map(|(mode_id, _)| mode_id.as_str()) + .ok_or_else(|| { + RuntimeError::Config(format!( + "no active runtime-management mode supports cli profile `{profile_id}`" + )) + }) +} + +fn resolve_runtime_target( + context: &RuntimeManagementContext, + runtime_id: &str, + requested_instance_id: Option<&str>, +) -> RuntimeTarget { + let runtime_group = runtime_group(&context.contract, runtime_id); + let bootstrap = context.contract.bootstrap.get(runtime_id).cloned(); + let instance_id = requested_instance_id + .map(ToOwned::to_owned) + .or_else(|| bootstrap.as_ref().map(|entry| entry.default_instance_id.clone())) + .unwrap_or_else(|| "default".to_owned()); + let instance_source = if requested_instance_id.is_some() { + "command_arg".to_owned() + } else if bootstrap.is_some() { + "bootstrap_default".to_owned() + } else { + "implicit_default".to_owned() + }; + let management_mode = bootstrap.as_ref().map(|entry| entry.management_mode.clone()); + let mode_contract = management_mode + .as_ref() + .and_then(|mode_id| context.contract.mode.get(mode_id).cloned()); + let instance_record = context + .registry + .instances + .iter() + .find(|record| record.runtime_id == runtime_id && record.instance_id == instance_id) + .cloned(); + let predicted_paths = if runtime_group == RuntimeGroup::ActiveManagedTarget { + Some(resolve_instance_paths( + &context.shared_paths, + runtime_id, + instance_id.as_str(), + )) + } else { + None + }; + + RuntimeTarget { + runtime_id: runtime_id.to_owned(), + instance_id, + instance_source, + runtime_group, + management_mode, + mode_contract, + bootstrap, + instance_record, + predicted_paths, + registry_path: context.shared_paths.instance_registry_path.clone(), + } +} + +fn runtime_group(contract: &RadrootsRuntimeManagementContract, runtime_id: &str) -> RuntimeGroup { + if contract + .managed_runtime_targets + .active + .iter() + .any(|entry| entry == runtime_id) + { + RuntimeGroup::ActiveManagedTarget + } else if contract + .managed_runtime_targets + .defined + .iter() + .any(|entry| entry == runtime_id) + { + RuntimeGroup::DefinedManagedTarget + } else if contract + .managed_runtime_targets + .bootstrap_only + .iter() + .any(|entry| entry == runtime_id) + { + RuntimeGroup::BootstrapOnly + } else { + RuntimeGroup::Unknown + } +} + +fn status_view(target: &RuntimeTarget, lifecycle_actions: &[String]) -> RuntimeStatusView { + let install_state = target + .instance_record + .as_ref() + .map(|record| install_state_label(record.install_state)) + .unwrap_or_else(|| install_state_label(ManagedRuntimeInstallState::NotInstalled)); + let (health_state, health_source) = infer_health_state(target); + + RuntimeStatusView { + runtime_id: target.runtime_id.clone(), + instance_id: target.instance_id.clone(), + instance_source: target.instance_source.clone(), + runtime_group: target.runtime_group.as_str().to_owned(), + management_posture: target.runtime_group.posture().to_owned(), + state: status_state(target).to_owned(), + source: "runtime management contract + shared instance registry".to_owned(), + detail: status_detail(target), + management_mode: target.management_mode.clone(), + service_manager_integration: target + .mode_contract + .as_ref() + .map(|mode| mode.service_manager_integration), + uses_absolute_binary_paths: target + .mode_contract + .as_ref() + .map(|mode| mode.uses_absolute_binary_paths), + preferred_cli_binding: target.bootstrap.as_ref().map(|entry| entry.preferred_cli_binding), + install_state: install_state.to_owned(), + health_state: health_state.to_owned(), + health_source: health_source.to_owned(), + registry_path: target.registry_path.display().to_string(), + lifecycle_actions: if target.runtime_group == RuntimeGroup::ActiveManagedTarget { + lifecycle_actions.to_vec() + } else { + Vec::new() + }, + instance_paths: target.predicted_paths.as_ref().map(instance_paths_view), + instance_record: target.instance_record.as_ref().map(instance_record_view), + } +} + +fn logs_view(target: &RuntimeTarget) -> (RuntimeCommandAvailability, RuntimeLogsView) { + let stdout_log_path = target + .predicted_paths + .as_ref() + .map(|paths| paths.stdout_log_path.display().to_string()); + let stderr_log_path = target + .predicted_paths + .as_ref() + .map(|paths| paths.stderr_log_path.display().to_string()); + let availability = match target.runtime_group { + RuntimeGroup::Unknown => RuntimeCommandAvailability::Unconfigured, + RuntimeGroup::ActiveManagedTarget => RuntimeCommandAvailability::Success, + RuntimeGroup::DefinedManagedTarget | RuntimeGroup::BootstrapOnly => { + if target.instance_record.is_some() { + RuntimeCommandAvailability::Success + } else { + RuntimeCommandAvailability::Unsupported + } + } + }; + let detail = match target.runtime_group { + RuntimeGroup::ActiveManagedTarget => { + "runtime logs report the managed stdout/stderr locations; lifecycle execution lands in rpv1-rpi.5" + .to_owned() + } + RuntimeGroup::DefinedManagedTarget => format!( + "runtime `{}` is only a defined future managed target; no active generic logs surface exists without a registered instance", + target.runtime_id + ), + RuntimeGroup::BootstrapOnly => format!( + "runtime `{}` remains bootstrap_only and direct-bindable in this wave; generic managed logs are not admitted", + target.runtime_id + ), + RuntimeGroup::Unknown => unknown_runtime_detail(target), + }; + + ( + availability, + RuntimeLogsView { + runtime_id: target.runtime_id.clone(), + instance_id: target.instance_id.clone(), + instance_source: target.instance_source.clone(), + runtime_group: target.runtime_group.as_str().to_owned(), + state: match availability { + RuntimeCommandAvailability::Success => "ready".to_owned(), + RuntimeCommandAvailability::Unconfigured => "unknown_runtime".to_owned(), + RuntimeCommandAvailability::Unsupported => "unsupported".to_owned(), + }, + source: "runtime management contract + shared instance registry".to_owned(), + detail, + stdout_log_path: stdout_log_path.clone().or_else(|| { + target + .instance_record + .as_ref() + .map(|record| record.logs_path.join("stdout.log").display().to_string()) + }), + stderr_log_path: stderr_log_path.clone().or_else(|| { + target + .instance_record + .as_ref() + .map(|record| record.logs_path.join("stderr.log").display().to_string()) + }), + stdout_log_present: path_present(stdout_log_path.as_deref()).unwrap_or_else(|| { + target + .instance_record + .as_ref() + .is_some_and(|record| record.logs_path.join("stdout.log").exists()) + }), + stderr_log_present: path_present(stderr_log_path.as_deref()).unwrap_or_else(|| { + target + .instance_record + .as_ref() + .is_some_and(|record| record.logs_path.join("stderr.log").exists()) + }), + }, + ) +} + +fn config_show_view( + target: &RuntimeTarget, +) -> (RuntimeCommandAvailability, RuntimeManagedConfigView) { + let availability = match target.runtime_group { + RuntimeGroup::Unknown => RuntimeCommandAvailability::Unconfigured, + RuntimeGroup::ActiveManagedTarget => RuntimeCommandAvailability::Success, + RuntimeGroup::DefinedManagedTarget | RuntimeGroup::BootstrapOnly => { + if target.instance_record.is_some() { + RuntimeCommandAvailability::Success + } else { + RuntimeCommandAvailability::Unsupported + } + } + }; + let config_path = target + .instance_record + .as_ref() + .map(|record| record.config_path.display().to_string()); + let detail = match target.runtime_group { + RuntimeGroup::ActiveManagedTarget => { + if config_path.is_some() { + "runtime config show reports the managed config location without mutating bindings or lifecycle state" + .to_owned() + } else { + format!( + "managed runtime `{}` has no registered instance config yet; config bootstrap lands in {}", + target.runtime_id, DEFERRED_LIFECYCLE_SLICE + ) + } + } + RuntimeGroup::DefinedManagedTarget => format!( + "runtime `{}` is only a defined future managed target; generic config surfaces are not admitted without a registered instance", + target.runtime_id + ), + RuntimeGroup::BootstrapOnly => format!( + "runtime `{}` remains bootstrap_only and direct-bindable in this wave; generic managed config is not admitted", + target.runtime_id + ), + RuntimeGroup::Unknown => unknown_runtime_detail(target), + }; + + ( + availability, + RuntimeManagedConfigView { + runtime_id: target.runtime_id.clone(), + instance_id: target.instance_id.clone(), + instance_source: target.instance_source.clone(), + runtime_group: target.runtime_group.as_str().to_owned(), + state: match availability { + RuntimeCommandAvailability::Success => { + if config_path.is_some() { + "ready".to_owned() + } else { + "not_installed".to_owned() + } + } + RuntimeCommandAvailability::Unconfigured => "unknown_runtime".to_owned(), + RuntimeCommandAvailability::Unsupported => "unsupported".to_owned(), + }, + source: "runtime management contract + shared instance registry".to_owned(), + detail, + config_format: target.bootstrap.as_ref().map(|entry| entry.config_format.clone()), + config_path: config_path.clone(), + config_present: config_path + .as_deref() + .is_some_and(|path| PathBuf::from(path).exists()), + requires_bootstrap_secret: target + .bootstrap + .as_ref() + .map(|entry| entry.requires_bootstrap_secret), + requires_config_bootstrap: target + .bootstrap + .as_ref() + .map(|entry| entry.requires_config_bootstrap), + requires_signer_provider: target + .bootstrap + .as_ref() + .map(|entry| entry.requires_signer_provider), + }, + ) +} + +fn action_view( + target: &RuntimeTarget, + action: RuntimeLifecycleAction, + detail_override: Option<String>, +) -> (RuntimeCommandAvailability, RuntimeActionView) { + let (availability, state, detail, next_step) = match target.runtime_group { + RuntimeGroup::ActiveManagedTarget => ( + RuntimeCommandAvailability::Unsupported, + "deferred", + detail_override.unwrap_or_else(|| { + format!( + "runtime {} `{}` is reserved for {} so lifecycle execution can land after the generic command family is stable", + action.as_str().replace('_', " "), + target.runtime_id, + DEFERRED_LIFECYCLE_SLICE + ) + }), + Some(DEFERRED_LIFECYCLE_SLICE.to_owned()), + ), + RuntimeGroup::DefinedManagedTarget => ( + RuntimeCommandAvailability::Unsupported, + "unsupported", + detail_override.unwrap_or_else(|| { + format!( + "runtime `{}` is only a defined future managed target; `{}` is not admitted in the current wave", + target.runtime_id, + action.as_str().replace('_', " ") + ) + }), + None, + ), + RuntimeGroup::BootstrapOnly => ( + RuntimeCommandAvailability::Unsupported, + "unsupported", + detail_override.unwrap_or_else(|| { + format!( + "runtime `{}` remains bootstrap_only and direct-bindable in this wave; generic managed `{}` is not admitted", + target.runtime_id, + action.as_str().replace('_', " ") + ) + }), + None, + ), + RuntimeGroup::Unknown => ( + RuntimeCommandAvailability::Unconfigured, + "unknown_runtime", + detail_override.unwrap_or_else(|| unknown_runtime_detail(target)), + None, + ), + }; + + ( + availability, + RuntimeActionView { + action: action.as_str().to_owned(), + runtime_id: target.runtime_id.clone(), + instance_id: target.instance_id.clone(), + instance_source: target.instance_source.clone(), + runtime_group: target.runtime_group.as_str().to_owned(), + state: state.to_owned(), + source: "generic runtime-management command family".to_owned(), + detail, + mutates_bindings: false, + next_step, + }, + ) +} + +fn status_state(target: &RuntimeTarget) -> &'static str { + match target.runtime_group { + RuntimeGroup::ActiveManagedTarget => match target.instance_record.as_ref() { + Some(record) => install_state_label(record.install_state), + None => "not_installed", + }, + RuntimeGroup::DefinedManagedTarget => "defined_not_active", + RuntimeGroup::BootstrapOnly => "bootstrap_only", + RuntimeGroup::Unknown => "unknown_runtime", + } +} + +fn status_detail(target: &RuntimeTarget) -> String { + match target.runtime_group { + RuntimeGroup::ActiveManagedTarget => match &target.instance_record { + Some(record) => format!( + "managed runtime `{}` instance `{}` is registered with config at {}; generic lifecycle execution lands in {}", + target.runtime_id, + target.instance_id, + record.config_path.display(), + DEFERRED_LIFECYCLE_SLICE + ), + None => format!( + "managed runtime `{}` has no registered instance `{}` in {}; lifecycle bootstrap lands in {}", + target.runtime_id, + target.instance_id, + target.registry_path.display(), + DEFERRED_LIFECYCLE_SLICE + ), + }, + RuntimeGroup::DefinedManagedTarget => format!( + "runtime `{}` is defined in the management contract but not yet admitted as an active managed target", + target.runtime_id + ), + RuntimeGroup::BootstrapOnly => format!( + "runtime `{}` is bootstrap_only in the management contract and remains direct-bindable outside managed lifecycle in this wave", + target.runtime_id + ), + RuntimeGroup::Unknown => unknown_runtime_detail(target), + } +} + +fn unknown_runtime_detail(target: &RuntimeTarget) -> String { + format!( + "runtime `{}` is not present in the current runtime-management contract", + target.runtime_id + ) +} + +fn infer_health_state(target: &RuntimeTarget) -> (&'static str, &'static str) { + let Some(record) = &target.instance_record else { + return ( + health_state_label(ManagedRuntimeHealthState::NotInstalled), + "registry_absent", + ); + }; + if record.install_state == ManagedRuntimeInstallState::Failed { + return ( + health_state_label(ManagedRuntimeHealthState::Failed), + "registry_install_state", + ); + } + + let pid_path = target + .predicted_paths + .as_ref() + .map(|paths| paths.pid_file_path.clone()) + .unwrap_or_else(|| record.run_path.join("runtime.pid")); + + if pid_path.exists() { + return ( + health_state_label(ManagedRuntimeHealthState::Running), + "pid_file_presence", + ); + } + + match record.install_state { + ManagedRuntimeInstallState::NotInstalled => ( + health_state_label(ManagedRuntimeHealthState::NotInstalled), + "registry_install_state", + ), + ManagedRuntimeInstallState::Installed | ManagedRuntimeInstallState::Configured => ( + health_state_label(ManagedRuntimeHealthState::Stopped), + "pid_file_absent", + ), + ManagedRuntimeInstallState::Failed => ( + health_state_label(ManagedRuntimeHealthState::Failed), + "registry_install_state", + ), + } +} + +fn install_state_label(state: ManagedRuntimeInstallState) -> &'static str { + match state { + ManagedRuntimeInstallState::NotInstalled => "not_installed", + ManagedRuntimeInstallState::Installed => "installed", + ManagedRuntimeInstallState::Configured => "configured", + ManagedRuntimeInstallState::Failed => "failed", + } +} + +fn health_state_label(state: ManagedRuntimeHealthState) -> &'static str { + match state { + ManagedRuntimeHealthState::NotInstalled => "not_installed", + ManagedRuntimeHealthState::Stopped => "stopped", + ManagedRuntimeHealthState::Starting => "starting", + ManagedRuntimeHealthState::Running => "running", + ManagedRuntimeHealthState::Degraded => "degraded", + ManagedRuntimeHealthState::Failed => "failed", + } +} + +fn instance_paths_view( + paths: &radroots_runtime_manager::ManagedRuntimeInstancePaths, +) -> RuntimeInstancePathsView { + RuntimeInstancePathsView { + install_dir: paths.install_dir.display().to_string(), + state_dir: paths.state_dir.display().to_string(), + logs_dir: paths.logs_dir.display().to_string(), + run_dir: paths.run_dir.display().to_string(), + secrets_dir: paths.secrets_dir.display().to_string(), + pid_file_path: paths.pid_file_path.display().to_string(), + stdout_log_path: paths.stdout_log_path.display().to_string(), + stderr_log_path: paths.stderr_log_path.display().to_string(), + metadata_path: paths.metadata_path.display().to_string(), + } +} + +fn instance_record_view(record: &ManagedRuntimeInstanceRecord) -> RuntimeInstanceRecordView { + RuntimeInstanceRecordView { + management_mode: record.management_mode.clone(), + install_state: install_state_label(record.install_state).to_owned(), + binary_path: record.binary_path.display().to_string(), + config_path: record.config_path.display().to_string(), + logs_path: record.logs_path.display().to_string(), + run_path: record.run_path.display().to_string(), + installed_version: record.installed_version.clone(), + health_endpoint: record.health_endpoint.clone(), + secret_material_ref: record.secret_material_ref.clone(), + last_started_at: record.last_started_at.clone(), + last_stopped_at: record.last_stopped_at.clone(), + notes: record.notes.clone(), + } +} + +fn path_present(path: Option<&str>) -> Option<bool> { + path.map(|value| PathBuf::from(value).exists()) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -7,6 +7,7 @@ pub mod job; pub mod listing; pub mod local; pub mod logging; +pub mod management; pub mod myc; pub mod network; pub mod order; @@ -33,6 +34,8 @@ pub enum RuntimeError { Json(#[from] serde_json::Error), #[error("failed to write output: {0}")] Io(#[from] std::io::Error), + #[error("runtime manager error: {0}")] + RuntimeManager(#[from] radroots_runtime_manager::RadrootsRuntimeManagerError), } impl RuntimeError { @@ -44,7 +47,8 @@ impl RuntimeError { | Self::Sql(_) | Self::ReplicaSync(_) | Self::Json(_) - | Self::Io(_) => ExitCode::from(1), + | Self::Io(_) + | Self::RuntimeManager(_) => ExitCode::from(1), } } } diff --git a/tests/runtime_management.rs b/tests/runtime_management.rs @@ -0,0 +1,219 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn appdata_root(workdir: &Path) -> std::path::PathBuf { + workdir.join("roaming").join("Radroots") +} + +fn localappdata_root(workdir: &Path) -> std::path::PathBuf { + workdir.join("local").join("Radroots") +} + +fn interactive_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + localappdata_root(workdir) + } else { + workdir.join("home").join(".radroots") + } +} + +fn config_root(workdir: &Path) -> std::path::PathBuf { + if cfg!(windows) { + appdata_root(workdir).join("config") + } else { + interactive_root(workdir).join("config") + } +} + +fn runtime_manager_registry_path(workdir: &Path) -> std::path::PathBuf { + config_root(workdir).join("shared/runtime-manager/instances.toml") +} + +fn runtime_command_in(workdir: &Path) -> Command { + let mut command = Command::cargo_bin("radroots").expect("binary"); + command.current_dir(workdir); + command.env("HOME", workdir.join("home")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER", + "RADROOTS_RELAYS", + "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_RPC_URL", + "RADROOTS_RPC_BEARER_TOKEN", + ] { + command.env_remove(key); + } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); + command +} + +#[test] +fn runtime_status_reports_active_managed_target_truth() { + let dir = tempdir().expect("tempdir"); + let output = runtime_command_in(dir.path()) + .args(["--json", "runtime", "status", "radrootsd"]) + .output() + .expect("runtime status"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["runtime_id"], "radrootsd"); + assert_eq!(json["runtime_group"], "active_managed_target"); + assert_eq!(json["management_posture"], "active_managed_target"); + assert_eq!(json["state"], "not_installed"); + assert_eq!(json["install_state"], "not_installed"); + assert_eq!(json["health_state"], "not_installed"); + assert_eq!(json["instance_id"], "local"); + assert_eq!(json["instance_source"], "bootstrap_default"); + assert_eq!(json["management_mode"], "interactive_user_managed"); +} + +#[test] +fn runtime_status_reports_defined_future_target_truth() { + let dir = tempdir().expect("tempdir"); + let output = runtime_command_in(dir.path()) + .args(["--json", "runtime", "status", "myc"]) + .output() + .expect("runtime status"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["runtime_id"], "myc"); + assert_eq!(json["runtime_group"], "defined_managed_target"); + assert_eq!(json["management_posture"], "defined_future_target"); + assert_eq!(json["state"], "defined_not_active"); + assert_eq!(json["lifecycle_actions"], Value::Array(vec![])); +} + +#[test] +fn runtime_install_is_exposed_but_truthfully_deferred() { + let dir = tempdir().expect("tempdir"); + let output = runtime_command_in(dir.path()) + .args(["--json", "runtime", "install", "radrootsd"]) + .output() + .expect("runtime install"); + + assert_eq!(output.status.code(), Some(5)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["action"], "install"); + assert_eq!(json["runtime_id"], "radrootsd"); + assert_eq!(json["state"], "deferred"); + assert_eq!(json["mutates_bindings"], false); + assert_eq!(json["next_step"], "rpv1-rpi.5"); +} + +#[test] +fn runtime_logs_reports_managed_log_locations() { + let dir = tempdir().expect("tempdir"); + let output = runtime_command_in(dir.path()) + .args(["--json", "runtime", "logs", "radrootsd"]) + .output() + .expect("runtime logs"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["runtime_id"], "radrootsd"); + assert_eq!(json["state"], "ready"); + assert!(json["stdout_log_path"] + .as_str() + .expect("stdout log path") + .ends_with("shared/runtime-manager/radrootsd/local/stdout.log")); + assert!(json["stderr_log_path"] + .as_str() + .expect("stderr log path") + .ends_with("shared/runtime-manager/radrootsd/local/stderr.log")); +} + +#[test] +fn runtime_config_show_uses_registered_instance_config_path() { + let dir = tempdir().expect("tempdir"); + let registry_path = runtime_manager_registry_path(dir.path()); + let config_path = dir.path().join("managed").join("radrootsd-local.toml"); + fs::create_dir_all(config_path.parent().expect("config parent")).expect("create config dir"); + fs::write( + &config_path, + "[config.rpc]\naddr = \"127.0.0.1:7070\"\n[config.bridge]\nenabled = true\nbearer_token = \"redacted\"\n", + ) + .expect("write config"); + fs::create_dir_all(registry_path.parent().expect("registry parent")).expect("registry dir"); + fs::write( + &registry_path, + format!( + r#"schema = "radroots_runtime-instance-registry" +schema_version = 1 + +[[instances]] +runtime_id = "radrootsd" +instance_id = "local" +management_mode = "interactive_user_managed" +install_state = "configured" +binary_path = "{binary_path}" +config_path = "{config_path}" +logs_path = "{logs_path}" +run_path = "{run_path}" +installed_version = "0.1.0" +"#, + binary_path = dir.path().join("bin/radrootsd").display(), + config_path = config_path.display(), + logs_path = dir.path().join("managed/logs/radrootsd-local").display(), + run_path = dir.path().join("managed/run/radrootsd-local").display(), + ), + ) + .expect("write registry"); + + let output = runtime_command_in(dir.path()) + .args(["--json", "runtime", "config", "show", "radrootsd"]) + .output() + .expect("runtime config show"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["runtime_id"], "radrootsd"); + assert_eq!(json["config_format"], "toml"); + assert_eq!(json["config_path"], config_path.display().to_string()); + assert_eq!(json["config_present"], true); + assert_eq!(json["requires_bootstrap_secret"], true); + assert_eq!(json["requires_config_bootstrap"], true); +} + +#[test] +fn runtime_logs_rejects_bootstrap_only_runtime() { + let dir = tempdir().expect("tempdir"); + let output = runtime_command_in(dir.path()) + .args(["--json", "runtime", "logs", "hyf"]) + .output() + .expect("runtime logs"); + + assert_eq!(output.status.code(), Some(5)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + let json: Value = serde_json::from_str(stdout.as_str()).expect("json output"); + assert_eq!(json["runtime_id"], "hyf"); + assert_eq!(json["runtime_group"], "bootstrap_only"); + assert_eq!(json["state"], "unsupported"); +}