From fa71c8631bf798821015f53010d65a774d9f13f4 Mon Sep 17 00:00:00 2001 From: Huan-Cheng Chang Date: Thu, 16 Jan 2025 12:07:48 +0000 Subject: [PATCH] feat(cli): run containerised jstzd with 'sandbox start' --- Cargo.lock | 1 + crates/jstz_cli/Cargo.toml | 1 + crates/jstz_cli/src/main.rs | 13 +- crates/jstz_cli/src/sandbox/container.rs | 448 +++++++++++++++++++++++ crates/jstz_cli/src/sandbox/mod.rs | 57 ++- 5 files changed, 499 insertions(+), 21 deletions(-) create mode 100644 crates/jstz_cli/src/sandbox/container.rs diff --git a/Cargo.lock b/Cargo.lock index e4b65ecfb..a08248b11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2738,6 +2738,7 @@ dependencies = [ "anyhow", "boa_engine", "boa_gc", + "bollard", "bs58", "clap 4.5.20", "clap_complete", diff --git a/crates/jstz_cli/Cargo.toml b/crates/jstz_cli/Cargo.toml index 603de4cea..5f4db1331 100644 --- a/crates/jstz_cli/Cargo.toml +++ b/crates/jstz_cli/Cargo.toml @@ -19,6 +19,7 @@ ansi_term.workspace = true anyhow.workspace = true boa_engine.workspace = true boa_gc.workspace = true +bollard.workspace = true bs58.workspace = true clap.workspace = true clap_complete.workspace = true diff --git a/crates/jstz_cli/src/main.rs b/crates/jstz_cli/src/main.rs index f649b7a39..deea46969 100644 --- a/crates/jstz_cli/src/main.rs +++ b/crates/jstz_cli/src/main.rs @@ -69,8 +69,13 @@ enum Command { Bridge(bridge::Command), /// 🏝️ Start/Stop/Restart the local jstz sandbox - #[command(subcommand)] - Sandbox(sandbox::Command), + Sandbox { + /// Start/Stop/Restart the sandbox in a container + #[clap(long)] + container: bool, + #[command(subcommand)] + command: sandbox::Command, + }, /// ⚡️ Start a REPL session with jstz's JavaScript runtime {n} Repl { /// Sets the address of the REPL environment. @@ -114,7 +119,9 @@ async fn exec(command: Command) -> Result<()> { match command { Command::Docs => docs::exec(), Command::Completions { shell } => completions::exec(shell), - Command::Sandbox(sandbox_command) => sandbox::exec(sandbox_command).await, + Command::Sandbox { container, command } => { + sandbox::exec(container, command).await + } Command::Bridge(bridge_command) => bridge::exec(bridge_command).await, Command::Account(account_command) => account::exec(account_command).await, Command::Deploy { diff --git a/crates/jstz_cli/src/sandbox/container.rs b/crates/jstz_cli/src/sandbox/container.rs new file mode 100644 index 000000000..313183a17 --- /dev/null +++ b/crates/jstz_cli/src/sandbox/container.rs @@ -0,0 +1,448 @@ +use std::{collections::HashMap, path::PathBuf}; + +use anyhow::{Context, Result}; +use bollard::{ + container::{ + AttachContainerOptions, AttachContainerResults, Config as ContainerConfig, + CreateContainerOptions, ListContainersOptions, RemoveContainerOptions, + }, + image::{CreateImageOptions, ListImagesOptions}, + secret::{HostConfig, Mount, MountTypeEnum, PortBinding}, + Docker, +}; +use futures::TryStreamExt; +use futures_util::StreamExt; +use signal_hook::consts::{SIGINT, SIGTERM}; + +pub use super::consts::*; +use log::info; +use tempfile::{NamedTempFile, TempDir}; +use tokio::{fs, io::AsyncWriteExt}; + +use crate::config::{Config, SandboxConfig}; + +pub(crate) async fn start_container( + container_name: &str, + image: &str, + detach: bool, + cfg: &mut Config, +) -> Result<()> { + let client = Docker::connect_with_local_defaults()?; + if container_exists(&client, container_name).await? { + return Err(anyhow::anyhow!("sandbox is already running")); + } + + pull_image_if_not_found(&client, image).await?; + + let (tmp_dir_path, config_file_path) = create_config_file_and_client_dir().await?; + let mounts = Some(HashMap::from_iter([ + ( + tmp_dir_path.to_string_lossy().to_string(), + "/tmp/octez-client-dir".to_owned(), + ), + ( + config_file_path.to_string_lossy().to_string(), + "/tmp/config.json".to_owned(), + ), + ])); + create_container( + &client, + container_name, + image, + mounts, + Some(vec![SANDBOX_OCTEZ_NODE_RPC_PORT, SANDBOX_JSTZ_NODE_PORT]), + Some(vec!["run".to_owned(), "/tmp/config.json".to_owned()]), + ) + .await + .context("failed to create the sandbox container")?; + client + .start_container::<&str>(container_name, None) + .await + .context("failed to start the sandbox container")?; + + // update config so that the following CLI commands can call the sandbox + cfg.sandbox = Some(SandboxConfig { + octez_client_dir: tmp_dir_path, + octez_node_dir: PathBuf::new(), + octez_rollup_node_dir: PathBuf::new(), + pid: 0, + }); + cfg.save()?; + + if !detach { + attach_container(&client, container_name, cfg.clone()) + .await + .context("failed to attach to the sandbox")?; + } + + Ok(()) +} + +pub(crate) async fn stop_container( + container_name: &str, + cfg: &mut Config, +) -> Result { + let client = Docker::connect_with_local_defaults()?; + if container_exists(&client, container_name).await? { + client + .remove_container( + container_name, + Some(RemoveContainerOptions { + v: true, + force: true, + link: false, + }), + ) + .await?; + cfg.sandbox.take(); + cfg.save()?; + info!("Sandbox stopped"); + Ok(true) + } else { + info!("Sandbox is not running"); + Ok(false) + } +} + +async fn container_exists(client: &Docker, target: &str) -> Result { + let containers = client + .list_containers(Some(ListContainersOptions::<&str> { + all: true, + filters: HashMap::from_iter([("name", vec![target])]), + ..Default::default() + })) + .await?; + for container in containers { + if let Some(names) = container.names { + for name in names { + // for some reason, the returned names may have a "/" prefix + if name.strip_prefix("/").unwrap_or(&name) == target { + return Ok(true); + } + } + } + } + Ok(false) +} + +async fn pull_image_if_not_found(client: &Docker, image: &str) -> Result<()> { + let images = client + .list_images(Some(ListImagesOptions::<&str> { + all: true, + //filters: HashMap::from_iter([("name", vec![image])]), + ..Default::default() + })) + .await?; + + for summary in images { + for tag in summary.repo_tags { + if tag == image { + return Ok(()); + } + } + } + + info!("Sandbox image '{image}' does not exist locally. Trying to pull it from the remote repository..."); + let _ = client + .create_image( + Some(CreateImageOptions { + from_image: image, + ..Default::default() + }), + None, + None, + ) + .try_collect::>() + .await?; + Ok(()) +} + +async fn create_config_file_and_client_dir() -> Result<(PathBuf, PathBuf)> { + let tmp_dir_path = TempDir::new() + .context("failed to create temporary directory for octez client")? + .into_path(); + let content = serde_json::to_string(&serde_json::json!({ + "octez_client": { + "octez_node_endpoint": format!("http://localhost:{SANDBOX_OCTEZ_NODE_RPC_PORT}"), + "base_dir": "/tmp/octez-client-dir", + }, + "octez_node": { + "rpc_endpoint": format!("localhost:{SANDBOX_OCTEZ_NODE_RPC_PORT}") + }, + })).context("failed to serialise sandbox config")?; + let config_file_path = NamedTempFile::new() + .context("failed to create a file as the config file")? + .into_temp_path() + .to_path_buf(); + fs::File::create(&config_file_path) + .await + .context("failed to create config file")? + .write_all(content.as_bytes()) + .await + .context("failed to write config file")?; + Ok((tmp_dir_path, config_file_path)) +} + +async fn create_container( + client: &Docker, + container_name: &str, + image: &str, + mounts: Option>, + ports: Option>, + cmd: Option>, +) -> Result<()> { + client + .create_container( + new_create_container_options(container_name), + new_create_container_config(image, mounts, ports, cmd), + ) + .await?; + Ok(()) +} + +fn new_create_container_options( + container_name: &str, +) -> Option> { + Some(CreateContainerOptions::<&str> { + name: container_name, + ..Default::default() + }) +} + +fn new_create_container_config( + image: &str, + mounts: Option>, + ports: Option>, + cmd: Option>, +) -> ContainerConfig { + ContainerConfig { + image: Some(image.to_owned()), + host_config: Some(HostConfig { + mounts: create_mounts(mounts), + port_bindings: create_port_bindings(ports.as_ref()), + ..Default::default() + }), + attach_stdin: Some(true), + attach_stdout: Some(true), + attach_stderr: Some(true), + open_stdin: Some(true), + exposed_ports: create_exposed_ports(ports.as_ref()), + cmd, + ..Default::default() + } +} + +fn create_port_bindings( + ports: Option<&Vec>, +) -> Option>>> { + ports.map(|v| { + HashMap::from_iter(v.iter().map(|p| { + ( + format!("{p}/tcp").to_string(), + Some(vec![PortBinding { + host_ip: None, + host_port: Some(p.to_string()), + }]), + ) + })) + }) +} + +fn create_exposed_ports( + ports: Option<&Vec>, +) -> Option>> { + ports.map(|v| { + HashMap::from_iter( + v.iter() + .map(|p| (format!("{p}/tcp").to_string(), HashMap::new())), + ) + }) +} + +fn create_mounts(mapping: Option>) -> Option> { + mapping.map(|v| { + v.iter() + .map(|(source, target)| Mount { + source: Some(source.to_owned()), + target: Some(target.to_owned()), + typ: Some(MountTypeEnum::BIND), + ..Default::default() + }) + .collect::>() + }) +} + +async fn attach_container( + client: &Docker, + container_name: &str, + mut cfg: Config, +) -> Result<()> { + let options = Some(AttachContainerOptions:: { + stdin: Some(true), + stdout: Some(true), + stderr: Some(true), + stream: Some(true), + logs: Some(true), + ..Default::default() + }); + + let AttachContainerResults { mut output, .. } = + client.attach_container(container_name, options).await?; + let mut signals = signal_hook::iterator::Signals::new([SIGINT, SIGTERM])?; + + let name = container_name.to_owned(); + tokio::spawn(async move { + if signals.forever().next().is_some() { + stop_container(&name, &mut cfg).await.unwrap(); + } + }); + + let mut stdout = tokio::io::stdout(); + + // pipe docker attach output into stdout + while let Some(Ok(output)) = output.next().await { + stdout.write_all(output.into_bytes().as_ref()).await?; + stdout.flush().await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::sandbox::SANDBOX_OCTEZ_NODE_RPC_PORT; + use bollard::{ + container::{Config as ContainerConfig, CreateContainerOptions}, + secret::{HostConfig, Mount, MountTypeEnum, PortBinding}, + }; + use serde_json::Value; + use std::collections::HashMap; + + #[test] + fn create_exposed_ports() { + assert_eq!(super::create_exposed_ports(None), None); + assert_eq!( + super::create_exposed_ports(Some(&vec![1234, 5678])), + Some(HashMap::from_iter([ + ("1234/tcp".to_owned(), HashMap::new()), + ("5678/tcp".to_owned(), HashMap::new()) + ])) + ); + } + + #[test] + fn create_port_bindings() { + assert_eq!(super::create_port_bindings(None), None); + assert_eq!( + super::create_port_bindings(Some(&vec![1234, 5678])), + Some(HashMap::from_iter([ + ( + "1234/tcp".to_owned(), + Some(vec![PortBinding { + host_ip: None, + host_port: Some("1234".to_owned()), + }]) + ), + ( + "5678/tcp".to_owned(), + Some(vec![PortBinding { + host_ip: None, + host_port: Some("5678".to_owned()), + }]) + ) + ])) + ); + } + + #[test] + fn create_mounts() { + assert_eq!(super::create_mounts(None), None); + assert_eq!(super::create_mounts(Some(HashMap::new())), Some(Vec::new())); + assert_eq!( + super::create_mounts(Some(HashMap::from_iter([( + "/foo".to_owned(), + "/bar".to_owned() + )]))), + Some(vec![Mount { + source: Some("/foo".to_owned()), + target: Some("/bar".to_owned()), + typ: Some(MountTypeEnum::BIND), + ..Default::default() + }]) + ); + } + + #[test] + fn new_create_container_config() { + let cmd = Some(vec!["cmd".to_owned()]); + let mounts = Some(HashMap::from_iter([("/foo".to_owned(), "/bar".to_owned())])); + assert_eq!( + super::new_create_container_config( + "test-image", + mounts.clone(), + Some(vec![1234]), + cmd.clone() + ), + ContainerConfig { + image: Some("test-image".to_owned()), + host_config: Some(HostConfig { + mounts: Some(vec![Mount { + source: Some("/foo".to_owned()), + target: Some("/bar".to_owned()), + typ: Some(MountTypeEnum::BIND), + ..Default::default() + }]), + port_bindings: Some(HashMap::from_iter([( + "1234/tcp".to_owned(), + Some(vec![PortBinding { + host_ip: None, + host_port: Some("1234".to_owned()), + }]) + )])), + ..Default::default() + }), + attach_stdin: Some(true), + attach_stdout: Some(true), + attach_stderr: Some(true), + open_stdin: Some(true), + exposed_ports: Some(HashMap::from_iter([( + "1234/tcp".to_owned(), + HashMap::new() + ),])), + cmd, + ..Default::default() + } + ); + } + + #[test] + fn new_create_container_options() { + assert_eq!( + super::new_create_container_options("foo"), + Some(CreateContainerOptions::<&str> { + name: "foo", + ..Default::default() + }) + ); + } + + #[tokio::test] + async fn create_config_file_and_client_dir() { + let (_, cfg_path) = super::create_config_file_and_client_dir().await.unwrap(); + + let value: Value = + serde_json::from_str(&tokio::fs::read_to_string(cfg_path).await.unwrap()) + .unwrap(); + assert_eq!( + value, + serde_json::json!({ + "octez_client": { + "octez_node_endpoint": format!("http://localhost:{SANDBOX_OCTEZ_NODE_RPC_PORT}"), + "base_dir": "/tmp/octez-client-dir", + }, + "octez_node": { + "rpc_endpoint": format!("localhost:{SANDBOX_OCTEZ_NODE_RPC_PORT}") + }, + }) + ); + } +} diff --git a/crates/jstz_cli/src/sandbox/mod.rs b/crates/jstz_cli/src/sandbox/mod.rs index ebcc4d2cf..b7e584e6d 100644 --- a/crates/jstz_cli/src/sandbox/mod.rs +++ b/crates/jstz_cli/src/sandbox/mod.rs @@ -1,13 +1,15 @@ -use anyhow::{bail, Ok, Result}; -use clap::Subcommand; - -pub mod daemon; - mod consts; +mod container; +pub mod daemon; +use crate::{config::Config, utils::using_jstzd}; +use anyhow::{bail, Result}; +use clap::Subcommand; pub use consts::*; +use container::*; -use crate::{config::Config, utils::using_jstzd}; +const SANDBOX_CONTAINER_NAME: &str = "jstz-sandbox"; +const SANDBOX_IMAGE: &str = "ghcr.io/jstz-dev/jstz/jstzd:0.1.0"; #[derive(Debug, Subcommand)] pub enum Command { @@ -30,32 +32,51 @@ pub enum Command { }, } -pub async fn start(detach: bool, background: bool) -> Result<()> { +pub async fn start(detach: bool, background: bool, use_container: bool) -> Result<()> { let mut cfg = Config::load_sync()?; - daemon::main(detach, background, &mut cfg).await?; + match use_container { + true => { + start_container(SANDBOX_CONTAINER_NAME, SANDBOX_IMAGE, detach, &mut cfg) + .await? + } + _ => daemon::main(detach, background, &mut cfg).await?, + }; Ok(()) } -pub fn stop() -> Result<()> { - daemon::stop_sandbox(false)?; - Ok(()) +pub async fn stop(use_container: bool) -> Result { + let mut cfg = Config::load_sync()?; + match use_container { + true => stop_container(SANDBOX_CONTAINER_NAME, &mut cfg).await, + _ => { + daemon::stop_sandbox(false)?; + Ok(true) + } + } } -pub async fn restart(detach: bool) -> Result<()> { - daemon::stop_sandbox(true)?; - start(detach, false).await +pub async fn restart(detach: bool, use_container: bool) -> Result<()> { + if !stop(use_container).await? { + return Ok(()); + } + start(detach, false, use_container).await } -pub async fn exec(command: Command) -> Result<()> { +pub async fn exec(use_container: bool, command: Command) -> Result<()> { if using_jstzd() { bail!( "Jstz sandbox is not available when environment variable `USE_JSTZD` is truthy." ); } match command { - Command::Start { detach, background } => start(detach, background).await, - Command::Stop => stop(), - Command::Restart { detach } => restart(detach).await, + Command::Start { detach, background } => { + start(detach, background, use_container).await + } + Command::Stop => { + stop(use_container).await?; + Ok(()) + } + Command::Restart { detach } => restart(detach, use_container).await, } }