1
0
Fork 0
mirror of https://codeberg.org/icewind/haze.git synced 2026-10-01 16:54:08 +02:00

add haze scripts

fixes #27
This commit is contained in:
Robin Appelman 2026-08-17 17:54:27 +02:00
commit 125b68e08a
7 changed files with 207 additions and 9 deletions

View file

@ -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 `rolling-bees-mail.haze.example.com` will give access to the smtp4dev web
interface of the `rolling-bees` instance. 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
Configuration is loaded from `~/.config/haze/haze.toml` and has the following Configuration is loaded from `~/.config/haze/haze.toml` and has the following

11
example-script.sh Executable file
View file

@ -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

View file

@ -1,5 +1,6 @@
use crate::cloud::CloudOptions; use crate::cloud::CloudOptions;
use crate::config::{HazeConfig, Preset}; use crate::config::{HazeConfig, Preset};
use crate::script::ScriptMode;
use crate::service::{Service, ServiceTrait}; use crate::service::{Service, ServiceTrait};
use miette::{IntoDiagnostic, Report, Result}; use miette::{IntoDiagnostic, Report, Result};
use std::fmt::{Debug, Display}; use std::fmt::{Debug, Display};
@ -102,6 +103,10 @@ pub enum HazeArgs {
Reload { Reload {
filter: Option<String>, filter: Option<String>,
}, },
Script {
mode: Option<ScriptMode>,
path: String,
},
} }
#[derive( #[derive(
@ -397,6 +402,23 @@ impl HazeArgs {
.into(), .into(),
}), }),
HazeCommand::Reload => Ok(HazeArgs::Reload { filter }), 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 <yellow>php.ini</yellow> settings with <literal>haze</literal> <arg>[filter]</arg> <literal>edit /config/php.ini</literal>" Details = "note: you can overwrite <yellow>php.ini</yellow> settings with <literal>haze</literal> <arg>[filter]</arg> <literal>edit /config/php.ini</literal>"
))] ))]
Reload, Reload,
/// Run a haze script
#[strum(props(Args = "[command] path of the haze script to run"))]
Script,
} }
impl SubCommand for HazeCommand { impl SubCommand for HazeCommand {

View file

@ -1,16 +1,16 @@
use crate::config::{HazeConfig, HazeVolumeConfig}; use crate::config::{HazeConfig, HazeVolumeConfig};
use crate::database::Database; 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::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::php::PhpVersion;
use crate::service::Service; use crate::service::Service;
use crate::service::ServiceTrait; use crate::service::ServiceTrait;
use crate::sources::download_nc; use crate::sources::download_nc;
use bollard::Docker;
use bollard::config::NetworkCreateRequest; use bollard::config::NetworkCreateRequest;
use bollard::models::ContainerState; use bollard::models::ContainerState;
use bollard::query_parameters::{ListContainersOptions, RemoveContainerOptions}; use bollard::query_parameters::{ListContainersOptions, RemoveContainerOptions};
use bollard::Docker;
use camino::{Utf8Path, Utf8PathBuf}; use camino::{Utf8Path, Utf8PathBuf};
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
use futures_util::future::try_join_all; use futures_util::future::try_join_all;
@ -22,8 +22,8 @@ use std::collections::HashMap;
use std::fmt::Display; use std::fmt::Display;
use std::fs; use std::fs;
use std::fs::{read_to_string, write}; use std::fs::{read_to_string, write};
use std::io::{stdout, Cursor, Read, Stdout, Write}; use std::io::{Cursor, Read, Stdout, Write, stdout};
use std::iter::{once, Peekable}; use std::iter::{Peekable, once};
use std::net::IpAddr; use std::net::IpAddr;
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
use std::str::FromStr; use std::str::FromStr;
@ -177,6 +177,7 @@ pub struct CloudOptions {
pub services: Vec<Service>, pub services: Vec<Service>,
pub app_packages: Vec<Utf8PathBuf>, pub app_packages: Vec<Utf8PathBuf>,
pub version: Option<String>, pub version: Option<String>,
pub mappings: Vec<Mapping<'static>>,
pub detach: bool, pub detach: bool,
} }
@ -191,6 +192,7 @@ impl CloudOptions {
services: vec![], services: vec![],
app_packages: vec![], app_packages: vec![],
version: None, version: None,
mappings: vec![],
detach: false, detach: false,
} }
} }
@ -243,6 +245,7 @@ impl CloudOptions {
.unwrap_or_default(), .unwrap_or_default(),
services, services,
app_packages: app_package, app_packages: app_package,
mappings: vec![],
version, version,
detach, detach,
}) })
@ -414,6 +417,7 @@ impl Cloud {
let mappings = for_config(config) let mappings = for_config(config)
.chain(source_volumes.iter().map(Mapping::from)) .chain(source_volumes.iter().map(Mapping::from))
.chain(app_volumes.iter().map(Mapping::from)) .chain(app_volumes.iter().map(Mapping::from))
.chain(options.mappings.iter().cloned())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for mapping in &mappings { for mapping in &mappings {
mapping mapping
@ -874,6 +878,7 @@ impl Cloud {
db, db,
services: found_services, services: found_services,
app_packages: vec![], app_packages: vec![],
mappings: vec![],
version, version,
detach, detach,
}, },

View file

@ -11,6 +11,7 @@ use crate::image::update_image;
use crate::network::clear_networks; use crate::network::clear_networks;
use crate::php::PhpVersion; use crate::php::PhpVersion;
use crate::proxy::proxy; use crate::proxy::proxy;
use crate::script::run_script;
use crate::service::ServiceTrait; use crate::service::ServiceTrait;
use crate::service::{RedisTls, Service}; use crate::service::{RedisTls, Service};
use crate::sources::Sources; use crate::sources::Sources;
@ -35,6 +36,7 @@ mod mapping;
mod network; mod network;
mod php; mod php;
mod proxy; mod proxy;
mod script;
mod service; mod service;
mod sources; mod sources;
@ -88,7 +90,9 @@ async fn main() -> Result<ExitCode> {
for cache_dir in config.work_dir.read_dir().into_diagnostic()? { for cache_dir in config.work_dir.read_dir().into_diagnostic()? {
let cache_dir = cache_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) { if !retain.iter().any(|cloud| cloud.id == id) {
let path = cache_dir.path(); let path = cache_dir.path();
// By default, the worktrees live within the work dir, we do not want to delete it here. // 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<ExitCode> {
) )
.await?; .await?;
} }
HazeArgs::Script { path, mode } => {
run_script(path, &config, &docker, mode).await?;
}
}; };
Ok(ExitCode::SUCCESS) Ok(ExitCode::SUCCESS)

View file

@ -4,7 +4,7 @@ use miette::{Context, IntoDiagnostic, Result};
use std::borrow::Cow; use std::borrow::Cow;
use tokio::fs::{create_dir_all, write}; use tokio::fs::{create_dir_all, write};
#[derive(Debug)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mapping<'a> { pub struct Mapping<'a> {
source_type: MappingSourceType, source_type: MappingSourceType,
pub source: Cow<'a, Utf8Path>, pub source: Cow<'a, Utf8Path>,
@ -244,7 +244,7 @@ pub fn for_config<'a>(config: &'a HazeConfig) -> impl Iterator<Item = Mapping<'a
.chain(default_mappings()) .chain(default_mappings())
} }
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum MappingSourceType { pub enum MappingSourceType {
Sources, Sources,
WorkDir, WorkDir,
@ -252,7 +252,7 @@ pub enum MappingSourceType {
Absolute, Absolute,
} }
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum MappingType { pub enum MappingType {
Folder, Folder,
File, File,

94
src/script.rs Normal file
View file

@ -0,0 +1,94 @@
use std::fs::read_to_string;
use bollard::Docker;
use camino::Utf8PathBuf;
use miette::{Context, IntoDiagnostic, Report, Result};
use strum::EnumString;
use crate::{
cloud::CloudOptions,
config::HazeConfig,
get_forward_env,
mapping::{Mapping, MappingSourceType},
setup,
};
#[derive(Debug, PartialEq, Eq, Clone, Copy, EnumString)]
#[strum(serialize_all = "lowercase")]
pub enum ScriptMode {
Shell,
Start,
}
pub async fn run_script(
path: String,
config: &HazeConfig,
docker: &Docker,
mode_override: Option<ScriptMode>,
) -> 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))
}