diff --git a/README.md b/README.md index b84da44..d5060ba 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,62 @@ Additionally, the proxy allows access to the server containers trough either `rolling-bees-mail.haze.example.com` will give access to the smtp4dev web interface of the `rolling-bees` instance. +## Haze scripts + +Haze scripts combine a set of instance options and a script to run in the +instance. + +Haze scripts are intended to way to create automated ways of running more +complex tests are setting up more complex instances. + +A script contains of 3 paths + +1. An optional shebang line setting `haze` as the interperter, e.g. + `#! /usr/bin/env haze` +2. A shebang line setting the options for the instance creation as the + interperter, e.g. `#! haze shell pgsql s3` +3. The rest that is ran as a script inside the created instace. + +For example, the following script will create an instance with `postgresql` and +`s3` primary storage. Then creates a new file, gets the file id, read the object +of the file from S3, validate that it contains the expected contents, and +cleanup the instance. + +```bash +#! /home/robin/Projects/haze/target/debug/haze script +#! haze shell pgsql s3 + +echo 'test' | occ file:put '-' /admin/files/test.txt +FILE_ID=$(occ info:file /admin/files/test.txt | grep fileid: | grep -oE '[0-9]+') +OBJECT_CONTENTS=$(occ file:object:get urn:oid:$FILE_ID -) +if [ "$OBJECT_CONTENTS" == 'test' ]; then + echo "object contains expected contents" +else + echo "object does not contains expected contents!" +fi +``` + +### Script modes + +Scripts can be either `shell` or `start` scripts. + +`shell` scripts will automatically remove the created instance once the script +is done, just like `haze shell` will. The intended use case for this is +performing some tests in the created instance without leaving state behind. + +`start` scripts on the other hand will keep the instance, like `haze start` +will. The intended use case for this is creating more complex instances without +having to configure a dedicated preset. + +The script mode is determined based on the shebang line. The mode set in a +script can be overriden by running the script with +`haze script [shell|start] path-to-script.sh`. + +## Nushell scripts + +By default, the script contents are executed as a `bash` script, you can instead +execute the script as a `nushell` script by using `.nu` as the file extention. + ## Configuration Configuration is loaded from `~/.config/haze/haze.toml` and has the following diff --git a/example-script.sh b/example-script.sh new file mode 100755 index 0000000..17814c0 --- /dev/null +++ b/example-script.sh @@ -0,0 +1,11 @@ +#! /home/robin/Projects/haze/target/debug/haze script +#! haze shell pgsql s3 + +echo 'test' | occ file:put '-' /admin/files/test.txt +FILE_ID=$(occ info:file /admin/files/test.txt | grep fileid: | grep -oE '[0-9]+') +OBJECT_CONTENTS=$(occ file:object:get urn:oid:$FILE_ID -) +if [ "$OBJECT_CONTENTS" == 'test' ]; then + echo "object contains expected contents" +else + echo "object does not contains expected contents!" +fi diff --git a/src/args.rs b/src/args.rs index 94daee3..3eec778 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,5 +1,6 @@ use crate::cloud::CloudOptions; use crate::config::{HazeConfig, Preset}; +use crate::script::ScriptMode; use crate::service::{Service, ServiceTrait}; use miette::{IntoDiagnostic, Report, Result}; use std::fmt::{Debug, Display}; @@ -102,6 +103,10 @@ pub enum HazeArgs { Reload { filter: Option, }, + Script { + mode: Option, + path: String, + }, } #[derive( @@ -397,6 +402,23 @@ impl HazeArgs { .into(), }), HazeCommand::Reload => Ok(HazeArgs::Reload { filter }), + HazeCommand::Script => { + let mut args = args.peekable(); + let mode = match args.peek().map(S::as_ref).map(ScriptMode::from_str) { + Some(Ok(mode)) => { + let _ = args.next(); + Some(mode) + } + _ => None, + }; + Ok(HazeArgs::Script { + mode, + path: args + .next() + .ok_or_else(|| Report::msg("No path provided"))? + .into(), + }) + } } } } @@ -490,6 +512,9 @@ pub enum HazeCommand { Details = "note: you can overwrite php.ini settings with haze [filter] edit /config/php.ini" ))] Reload, + /// Run a haze script + #[strum(props(Args = "[command] path of the haze script to run"))] + Script, } impl SubCommand for HazeCommand { diff --git a/src/cloud.rs b/src/cloud.rs index 9fdd5b2..7783d99 100644 --- a/src/cloud.rs +++ b/src/cloud.rs @@ -1,16 +1,16 @@ use crate::config::{HazeConfig, HazeVolumeConfig}; use crate::database::Database; -use crate::exec::{exec, exec_io, exec_tty, ExitCode}; +use crate::exec::{ExitCode, exec, exec_io, exec_tty}; use crate::git::{create_worktree, find_app_repo, init_submodules, remove_worktree}; -use crate::mapping::{for_config, Mapping}; +use crate::mapping::{Mapping, for_config}; use crate::php::PhpVersion; use crate::service::Service; use crate::service::ServiceTrait; use crate::sources::download_nc; +use bollard::Docker; use bollard::config::NetworkCreateRequest; use bollard::models::ContainerState; use bollard::query_parameters::{ListContainersOptions, RemoveContainerOptions}; -use bollard::Docker; use camino::{Utf8Path, Utf8PathBuf}; use flate2::read::GzDecoder; use futures_util::future::try_join_all; @@ -22,8 +22,8 @@ use std::collections::HashMap; use std::fmt::Display; use std::fs; use std::fs::{read_to_string, write}; -use std::io::{stdout, Cursor, Read, Stdout, Write}; -use std::iter::{once, Peekable}; +use std::io::{Cursor, Read, Stdout, Write, stdout}; +use std::iter::{Peekable, once}; use std::net::IpAddr; use std::os::unix::fs::MetadataExt; use std::str::FromStr; @@ -177,6 +177,7 @@ pub struct CloudOptions { pub services: Vec, pub app_packages: Vec, pub version: Option, + pub mappings: Vec>, pub detach: bool, } @@ -191,6 +192,7 @@ impl CloudOptions { services: vec![], app_packages: vec![], version: None, + mappings: vec![], detach: false, } } @@ -243,6 +245,7 @@ impl CloudOptions { .unwrap_or_default(), services, app_packages: app_package, + mappings: vec![], version, detach, }) @@ -414,6 +417,7 @@ impl Cloud { let mappings = for_config(config) .chain(source_volumes.iter().map(Mapping::from)) .chain(app_volumes.iter().map(Mapping::from)) + .chain(options.mappings.iter().cloned()) .collect::>(); for mapping in &mappings { mapping @@ -874,6 +878,7 @@ impl Cloud { db, services: found_services, app_packages: vec![], + mappings: vec![], version, detach, }, diff --git a/src/main.rs b/src/main.rs index 7107908..db2febc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use crate::image::update_image; use crate::network::clear_networks; use crate::php::PhpVersion; use crate::proxy::proxy; +use crate::script::run_script; use crate::service::ServiceTrait; use crate::service::{RedisTls, Service}; use crate::sources::Sources; @@ -35,6 +36,7 @@ mod mapping; mod network; mod php; mod proxy; +mod script; mod service; mod sources; @@ -88,7 +90,9 @@ async fn main() -> Result { for cache_dir in config.work_dir.read_dir().into_diagnostic()? { let cache_dir = cache_dir.into_diagnostic()?; - if let Some(id) = cache_dir.file_name().to_str() && id.starts_with("haze-") { + if let Some(id) = cache_dir.file_name().to_str() + && id.starts_with("haze-") + { if !retain.iter().any(|cloud| cloud.id == id) { let path = cache_dir.path(); // By default, the worktrees live within the work dir, we do not want to delete it here. @@ -556,6 +560,9 @@ async fn main() -> Result { ) .await?; } + HazeArgs::Script { path, mode } => { + run_script(path, &config, &docker, mode).await?; + } }; Ok(ExitCode::SUCCESS) diff --git a/src/mapping.rs b/src/mapping.rs index 946c161..6291c53 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -4,7 +4,7 @@ use miette::{Context, IntoDiagnostic, Result}; use std::borrow::Cow; use tokio::fs::{create_dir_all, write}; -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Mapping<'a> { source_type: MappingSourceType, pub source: Cow<'a, Utf8Path>, @@ -244,7 +244,7 @@ pub fn for_config<'a>(config: &'a HazeConfig) -> impl Iterator, +) -> Result<()> { + let path: Utf8PathBuf = Utf8PathBuf::from(path) + .canonicalize() + .into_diagnostic() + .wrap_err("Failed to normalize script path")? + .try_into() + .into_diagnostic() + .wrap_err("Non-utf8 script path")?; + let contents = read_to_string(&path) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to read script file {path}"))?; + let (mut options, mode) = parse_script(&contents, config) + .wrap_err_with(|| format!("Failed to parse script file {path}"))?; + let mode = mode_override.unwrap_or(mode); + + let target_path = Utf8PathBuf::from(format!("/{}", path.file_name().unwrap())); + let shell = if path.extension() == Some("nu") { + "nu" + } else { + "bash" + }; + + options.mappings.push( + Mapping::owned( + MappingSourceType::Absolute, + path.clone(), + target_path.clone(), + ) + .read_only() + .dont_create(), + ); + + let cloud = setup(docker, options, &config).await?; + cloud + .exec( + &docker, + vec![shell.to_string(), target_path.into_string()], + true, + get_forward_env(), + ) + .await?; + + if mode == ScriptMode::Shell { + cloud.destroy(&docker, &config).await?; + } + + Ok(()) +} + +fn parse_script(script: &str, config: &HazeConfig) -> Result<(CloudOptions, ScriptMode)> { + let (options, mode) = script + .lines() + .find_map(|line| line.strip_prefix("#! haze ")) + .and_then( + |line| match (line.strip_prefix("shell"), line.strip_prefix("start")) { + (Some(shell_options), None) => Some((shell_options.trim(), ScriptMode::Shell)), + (None, Some(script_options)) => Some((script_options.trim(), ScriptMode::Start)), + _ => None, + }, + ) + .ok_or_else(|| Report::msg("No '#! haze shell' or '#! haze start' found in script"))?; + let mut options = options.split(' ').peekable(); + + let options = + CloudOptions::parse(config, &mut options).wrap_err("Failed to parse shell options")?; + Ok((options, mode)) +}