Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/aleph-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
memsizes = { workspace = true }
futures-util = { workspace = true }
hex = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
Expand Down
47 changes: 47 additions & 0 deletions crates/aleph-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ pub enum Commands {
#[clap(subcommand)]
command: AuthorizationCommand,
},
/// Control VMs on a Compute Resource Node (CRN)
Crn {
#[clap(subcommand)]
command: CrnCommand,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -1031,3 +1036,45 @@ pub struct AuthorizationRevokeArgs {
#[command(flatten)]
pub signing: SigningArgs,
}

#[derive(Subcommand)]
pub enum CrnCommand {
/// Start (allocate) a VM instance on the CRN.
Start(CrnStartArgs),
/// Stop a running VM instance.
Stop(CrnArgs),
/// Reboot a VM instance.
Reboot(CrnArgs),
/// Erase a VM instance's data.
Erase(CrnArgs),
/// Stream logs from a running VM instance.
Logs(CrnArgs),
}

#[derive(Args)]
pub struct CrnArgs {
/// CRN endpoint URL.
#[arg(long)]
pub crn_url: String,

/// VM instance item hash.
pub vm_id: ItemHash,

#[command(flatten)]
pub signing: SigningArgs,
}

/// Start is separate because it's unauthenticated — signing args are still
/// required to construct the CrnClient but no auth headers are sent.
#[derive(Args)]
pub struct CrnStartArgs {
/// CRN endpoint URL.
#[arg(long)]
pub crn_url: String,

/// VM instance item hash.
pub vm_id: ItemHash,

#[command(flatten)]
pub signing: SigningArgs,
}
188 changes: 188 additions & 0 deletions crates/aleph-cli/src/commands/crn.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
use aleph_sdk::crn::CrnClient;
use futures_util::StreamExt;
use url::Url;

use crate::cli::{CrnArgs, CrnCommand, CrnStartArgs, SigningArgs};
use crate::common::resolve_account;

pub async fn handle_crn_command(
json: bool,
command: CrnCommand,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
CrnCommand::Start(args) => handle_start(json, args).await,
CrnCommand::Stop(args) => handle_operation(json, args, "stop").await,
CrnCommand::Reboot(args) => handle_operation(json, args, "reboot").await,
CrnCommand::Erase(args) => handle_operation(json, args, "erase").await,
CrnCommand::Logs(args) => handle_logs(json, args).await,
}
}

fn build_client(
crn_url: &str,
signing: &SigningArgs,
) -> Result<CrnClient, Box<dyn std::error::Error>> {
let account = resolve_account(signing)?;
let url = Url::parse(crn_url)?;
Ok(CrnClient::new(&account, url)?)
}

async fn handle_start(json: bool, args: CrnStartArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = build_client(&args.crn_url, &args.signing)?;
let response = client.start_instance(&args.vm_id).await?;

if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"success": response.success,
"successful": response.successful,
"failing": response.failing,
"errors": response.errors,
}))?
);
} else if response.successful {
eprintln!("Instance {} started on {}", args.vm_id, args.crn_url);
} else {
eprintln!("Instance {} failed to start", args.vm_id);
if !response.failing.is_empty() {
eprintln!(" Failing: {}", response.failing.join(", "));
}
for (id, err) in &response.errors {
eprintln!(" {id}: {err}");
}
}

Ok(())
}

async fn handle_operation(
json: bool,
args: CrnArgs,
operation: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = build_client(&args.crn_url, &args.signing)?;

match operation {
"stop" => client.stop_instance(&args.vm_id).await?,
"reboot" => client.reboot_instance(&args.vm_id).await?,
"erase" => client.erase_instance(&args.vm_id).await?,
_ => unreachable!(),
}

if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"vm_id": args.vm_id.to_string(),
"operation": operation,
"status": "ok",
}))?
);
} else {
let past_tense = match operation {
"stop" => "stopped",
"reboot" => "rebooted",
"erase" => "erased",
_ => unreachable!(),
};
eprintln!("Instance {} {past_tense} on {}", args.vm_id, args.crn_url);
}

Ok(())
}

/// Strip ANSI escape sequences and control characters from log output.
/// QEMU serial console output contains terminal mode changes, cursor movement,
/// etc. that corrupt the user's terminal if forwarded raw.
fn sanitize_log(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
match c {
'\x1b' => {
// Skip ANSI escape sequence
if let Some(next) = chars.next()
&& next == '['
{
// CSI sequence: skip until final byte (0x40-0x7E)
for c in chars.by_ref() {
if ('\x40'..='\x7e').contains(&c) {
break;
}
}
}
// else: 2-char escape — already consumed
}
'\n' | '\t' => result.push(c),
c if c.is_control() => {} // strip CR, BEL, etc.
c => result.push(c),
}
}
result
}

async fn handle_logs(json: bool, args: CrnArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = build_client(&args.crn_url, &args.signing)?;
let mut stream = std::pin::pin!(client.stream_logs(&args.vm_id).await?);

while let Some(result) = stream.next().await {
let entry = result?;
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"type": format!("{:?}", entry.log_type).to_lowercase(),
"message": entry.message,
}))?
);
} else {
use aleph_sdk::crn::LogType;
let msg = sanitize_log(&entry.message);
match entry.log_type {
LogType::Stdout => println!("{msg}"),
LogType::Stderr => eprintln!("{msg}"),
LogType::System => eprintln!("[system] {msg}"),
}
}
}

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn sanitize_strips_csi_sequences() {
assert_eq!(sanitize_log("\x1b[31mred\x1b[0m"), "red");
assert_eq!(sanitize_log("\x1b[2J\x1b[Hhello"), "hello");
}

#[test]
fn sanitize_strips_control_chars() {
assert_eq!(sanitize_log("hello\rworld"), "helloworld");
assert_eq!(sanitize_log("beep\x07!"), "beep!");
}

#[test]
fn sanitize_preserves_newlines_and_tabs() {
assert_eq!(sanitize_log("line1\nline2\tok"), "line1\nline2\tok");
}

#[test]
fn sanitize_preserves_utf8() {
assert_eq!(sanitize_log("café ☕"), "café ☕");
}

#[test]
fn sanitize_handles_bare_esc() {
assert_eq!(sanitize_log("a\x1bb"), "a");
}

#[test]
fn sanitize_handles_trailing_esc() {
assert_eq!(sanitize_log("hello\x1b"), "hello");
}
}
1 change: 1 addition & 0 deletions crates/aleph-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod account;
pub mod aggregate;
pub mod authorization;
pub mod crn;
pub mod file;
pub mod instance;
pub mod message;
Expand Down
3 changes: 3 additions & 0 deletions crates/aleph-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
)
.await?
}
cli::Commands::Crn {
command: crn_command,
} => commands::crn::handle_crn_command(json, crn_command).await?,
}

Ok(())
Expand Down
Loading