extern crate core; use crate::args::{ExecService, GitOperation, HazeArgs}; use crate::cloud::{Cloud, CloudOptions}; use crate::config::HazeConfig; use crate::database::DatabaseFamily; use crate::exec::{container_logs, exec, exec_tty}; use crate::git::{checkout_all, pull_all}; use crate::help::help; use crate::image::update_image; use crate::network::clear_networks; use crate::php::PhpVersion; use crate::proxy::proxy; use crate::service::ServiceTrait; use crate::service::{RedisTls, Service}; use crate::sources::Sources; use bollard::Docker; use itertools::Itertools; use miette::{IntoDiagnostic, Report, Result, WrapErr}; use std::env::{var, vars}; use std::fs::{create_dir_all, remove_dir_all, write}; use std::io::stdout; use std::os::unix::process::CommandExt; use std::process::{Command, ExitCode}; mod args; mod cloud; mod config; mod database; mod exec; mod git; mod help; mod image; mod mapping; mod network; mod php; mod proxy; mod service; mod sources; static FORWARD_ENV: &[&str] = &[ "OCC_LOG", "OC_PASS", "XDEBUG_MODE", "XDEBUG_TRIGGER", "XDEBUG_CONFIG", ]; fn get_forward_env() -> Vec { vars() .filter(|(var, _)| FORWARD_ENV.contains(&var.as_str())) .map(|(var, value)| format!("{var}={value}")) .collect() } #[tokio::main] async fn main() -> Result { miette::set_panic_hook(); tracing_subscriber::fmt::init(); let docker = Docker::connect_with_local_defaults() .into_diagnostic() .wrap_err("Failed to connect to docker")?; let config = HazeConfig::load().wrap_err("Failed to load config")?; let args = HazeArgs::parse(&config, std::env::args())?; match args { HazeArgs::Clean => { let list = Cloud::list(&docker, None, &config).await?; let (retain, remove) = list .into_iter() .partition::, _>(|cloud| cloud.pinned); for cloud in remove { if let Err(e) = cloud.destroy(&docker).await { eprintln!("Error while removing cloud: {:#}", e); } } clear_networks(&docker, &retain).await?; 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() .and_then(|name| name.strip_prefix("haze-")) { if !retain.iter().any(|cloud| cloud.id == id) { let path = cache_dir.path(); remove_dir_all(&path).into_diagnostic().wrap_err_with(|| { format!("Failed to cleanup stray workdir: {}", path.display()) })?; } } } } HazeArgs::List { filter } => { let list = Cloud::list(&docker, filter, &config).await?; for cloud in list { let mut services: Vec<_> = cloud.services().map(Service::name).collect(); services.push(cloud.db().name()); let services = services.join(", "); let pin = if cloud.pinned { "*" } else { "" }; let version = match cloud.options.version.as_ref() { Some(version) => format!(", v{version}"), None => String::new(), }; println!( "Cloud {}{}, {}, {}{}, running on {}", cloud.id, pin, cloud.php().name(), services, version, cloud.address, ); } } HazeArgs::Start { options } => { setup(&docker, options, &config).await?; } HazeArgs::Stop { filter } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; cloud.destroy(&docker).await?; } HazeArgs::Logs { filter, follow, count, service, } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; let container = if let Some(service) = service { service .container_name(&cloud.id) .ok_or_else(|| Report::msg("service has no logs".to_string()))? } else { cloud.id }; container_logs(&docker, stdout(), &container, count.unwrap_or(20), follow).await?; } HazeArgs::Exec { filter, service, command, root, } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; match service { None => { let command = if command.is_empty() { vec!["bash".to_string()] } else { command }; let env = get_forward_env(); let tty = atty::is(atty::Stream::Stdout); let user = if root { "root" } else { "haze" }; if tty { exec_tty(&docker, &cloud.id, user, command, env).await?; } else { exec(&docker, &cloud.id, user, command, env, Some(stdout())).await?; } } Some(ExecService::Db) => { cloud .db() .exec_sh( &docker, &cloud.id, if command.is_empty() { vec!["bash".to_string()] } else { command }, atty::is(atty::Stream::Stdout), ) .await?; } } } HazeArgs::Occ { filter, mut command, } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; command.insert(0, "occ".to_string()); cloud .exec( &docker, command, atty::is(atty::Stream::Stdout), get_forward_env(), ) .await?; } HazeArgs::Db { filter, root, command, index, } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; if Some("all") == index.as_deref() { for index in ["1", "2", "3", "4"] { cloud .db() .exec(&docker, &cloud.id, root, &command, Some(index)) .await?; } } else { cloud .db() .exec(&docker, &cloud.id, root, &command, index.as_deref()) .await?; } } HazeArgs::Open { filter } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; match cloud.ip { Some(_) => opener::open(cloud.address).into_diagnostic()?, None => eprintln!("{} is not running", cloud.id), } } HazeArgs::Test { options, mut args } => { let cloud = Cloud::create(&docker, options, &config).await?; println!("Waiting for servers to start"); cloud.wait_for_start(&docker).await?; if !cloud.preset_config.is_empty() { println!("Writing preset config"); let encoded_preset_config = serde_json::to_string(&cloud.preset_config).into_diagnostic()?; cloud .write_file(&docker, "config/preset.config.json", encoded_preset_config) .await?; cloud.write_file(&docker, "config/preset.config.php", "::default(), ) .await { cloud.destroy(&docker).await?; return Err(e); } if let Some(app) = args .first() .as_ref() .and_then(|path| path.strip_prefix("apps/")) .map(|path| &path[0..path.find('/').unwrap_or(path.len())]) { if app.starts_with("files_") { cloud.enable_app(&docker, "files_external").await?; } println!("Enabling {}", app); cloud.enable_app(&docker, app).await?; } args.insert(0, "tests".to_string()); let result = cloud.exec(&docker, args, false, get_forward_env()).await?; cloud.destroy(&docker).await?; return Ok(result.into()); } HazeArgs::Integration { options, mut args } => { let cloud = setup(&docker, options, &config).await?; args.insert(0, "integration".to_string()); cloud.exec(&docker, args, false, get_forward_env()).await?; cloud.destroy(&docker).await?; } HazeArgs::Fmt { path } => { let cloud = Cloud::create(&docker, CloudOptions::new(&config), &config).await?; let mut out_buffer = Vec::::with_capacity(1024); println!("Waiting for servers to start"); cloud.wait_for_start(&docker).await?; println!("Installing composer"); if let Err(e) = cloud .exec_with_output( &docker, vec!["composer", "install"], Some(&mut out_buffer), Vec::::default(), ) .await .and_then(|c| c.to_result()) { eprintln!("{}", String::from_utf8_lossy(&out_buffer)); cloud.destroy(&docker).await?; return Err(e); } out_buffer.clear(); println!("Formatting"); if let Err(e) = cloud .exec( &docker, vec!["composer", "run", "cs:fix", path.as_str()], false, Vec::::default(), ) .await { cloud.destroy(&docker).await?; return Err(e); } println!("Cleanup"); if let Err(e) = cloud .exec_with_output( &docker, vec!["git", "clean", "-fd", "lib/composer"], Some(&mut out_buffer), Vec::::default(), ) .await .and_then(|c| c.to_result()) { eprintln!("{}", String::from_utf8_lossy(&out_buffer)); cloud.destroy(&docker).await?; return Err(e); } if let Err(e) = cloud .exec_with_output( &docker, vec!["git", "checkout", "lib/composer"], Some(&mut out_buffer), Vec::::default(), ) .await .and_then(|c| c.to_result()) { eprintln!("{}", String::from_utf8_lossy(&out_buffer)); cloud.destroy(&docker).await?; return Err(e); } cloud.destroy(&docker).await?; } HazeArgs::Shell { command, options } => { let cloud = setup(&docker, options, &config).await?; cloud .exec( &docker, if command.is_empty() { vec!["bash".to_string()] } else { command }, true, get_forward_env(), ) .await?; cloud.destroy(&docker).await?; } HazeArgs::Pin { filter } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; cloud.pin(&docker).await?; } HazeArgs::Unpin { filter } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; cloud.unpin(&docker).await?; } HazeArgs::Proxy => { proxy(docker, config).await?; } HazeArgs::Git { operation } => match operation { GitOperation::Checkout { branch } => { let sources = Sources::new(&config.sources_root)?; checkout_all( &config.sources_root, &branch.unwrap_or_else(|| sources.get_server_version_branch()), )?; } GitOperation::Pull => { pull_all(&config.sources_root)?; } }, HazeArgs::Env { filter, command, args, } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; let ip = cloud .ip .ok_or_else(|| Report::msg(format!("{} is not running", cloud.id)))?; let db_type = match cloud.db().family() { DatabaseFamily::Sqlite => { return Err(Report::msg("sqlite is not supported with `haze env`")); } DatabaseFamily::Oracle => { return Err(Report::msg("oracle is not supported with `haze env`")); } DatabaseFamily::Mysql | DatabaseFamily::MariaDB => "mysql", DatabaseFamily::Postgres => "postgresql", }; let db_ip = cloud .db() .ip(&docker, &cloud.id) .await .ok_or_else(|| Report::msg(format!("{}-db is not running", cloud.id)))?; let mut command = Command::new(command); command .args(args) .env("REDIS_URL", format!("redis://{}", ip)) .env("NEXTCLOUD_URL", &cloud.address) .env( "DATABASE_URL", format!("{}://haze:haze@{}/haze", db_type, db_ip), ); if cloud.services().contains(&Service::RedisTls(RedisTls)) { create_dir_all(config.work_dir.join("redis_certificates")) .into_diagnostic() .wrap_err("Failed to create redis certificate directory")?; let redis_cert_path = config.work_dir.join("redis_certificates/client.cert"); let redis_key_path = config.work_dir.join("redis_certificates/client.key"); let redis_ca_path = config.work_dir.join("redis_certificates/ca.cert"); if !redis_cert_path.exists() { write( &redis_cert_path, include_bytes!("../redis-certificates/client.crt"), ) .into_diagnostic() .wrap_err("Failed to write redis client certificate")?; } if !redis_key_path.exists() { write( &redis_key_path, include_bytes!("../redis-certificates/client.key"), ) .into_diagnostic() .wrap_err("Failed to write redis client key")?; } if !redis_ca_path.exists() { write( &redis_ca_path, include_bytes!("../redis-certificates/ca.crt"), ) .into_diagnostic() .wrap_err("Failed to write redis ca certificate")?; } command .env("REDIS_URL", format!("rediss://{}", ip)) .env("REDIS_TLS_DONT_VALIDATE_HOSTNAME", "1") .env("REDIS_TLS_CERT", redis_cert_path) .env("REDIS_TLS_KEY", redis_key_path) .env("REDIS_TLS_CA", redis_ca_path); } let err = command.exec(); return Err(err).into_diagnostic(); } HazeArgs::Update => { for php in PhpVersion::all() { update_image(&docker, php.image()).await?; } } HazeArgs::Version => { const VERSION: &str = env!("CARGO_PKG_VERSION"); println!("haze v{}", VERSION); } HazeArgs::Help { command } => { help(command.as_deref()); } HazeArgs::Edit { filter, path } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; let path = cloud .get_local_path(&config, &path) .ok_or_else(|| Report::msg(format!("{path} not found on the instance")))?; let editor = var("EDITOR").map_err(|_| Report::msg("$EDITOR not set"))?; let err = Command::new(editor).arg(path).exec(); // this never exists without error return Err(err) .into_diagnostic() .wrap_err("Failed to start $EDITOR"); } HazeArgs::Reload { filter } => { let cloud = Cloud::get_by_filter(&docker, filter, &config).await?; exec( &docker, &cloud.id, "root", vec!["pkill", "php-fpm"], Vec::::new(), Some(stdout()), ) .await?; exec( &docker, &cloud.id, "root", vec!["sh", "-c", "php-fpm --fpm-config /etc/php-fpm.conf&"], Vec::::new(), Some(stdout()), ) .await?; } }; Ok(ExitCode::SUCCESS) } async fn setup(docker: &Docker, options: CloudOptions, config: &HazeConfig) -> Result { let cloud = Cloud::create(docker, options, config).await?; println!("{}", cloud.address); let host = cloud.address.split_once("://").expect("no address?").1; if config.auto_setup.enabled { println!("Waiting for servers to start"); cloud.wait_for_start(docker).await?; if !cloud.preset_config.is_empty() { println!("Writing preset config"); let encoded_preset_config = serde_json::to_string(&cloud.preset_config).into_diagnostic()?; cloud .write_file(docker, "config/preset.config.json", encoded_preset_config) .await?; cloud.write_file(docker, "config/preset.config.php", "::default(), ) .await?; cloud .occ( docker, vec![ "config:system:set", "overwrite.cli.url", "--value", &cloud.address, ], None, Vec::::default(), ) .await?; cloud .occ( docker, vec!["config:system:set", "overwritehost", "--value", host], None, Vec::::default(), ) .await?; if cloud.address.contains("https://") { cloud .occ( docker, vec!["config:system:set", "overwriteprotocol", "--value", "https"], None, Vec::::default(), ) .await?; } let domains = [ip_str.as_str(), "cloud", &cloud.id, host]; for (i, domain) in domains.iter().enumerate() { cloud .occ( docker, vec![ "config:system:set", "trusted_domains", &format!("{}", i), "--value", domain, ], None, Vec::::default(), ) .await?; } for app in &config.auto_setup.enable_apps { cloud .exec( docker, vec!["occ", "app:enable", app.as_str(), "--force"], false, Vec::::default(), ) .await?; } for app in &config.auto_setup.disable_apps { cloud .exec( docker, vec!["occ", "app:disable", app.as_str()], false, Vec::::default(), ) .await?; } for service in cloud.services() { for app in service.apps() { cloud .exec( docker, vec!["occ", "app:enable", *app, "--force"], false, Vec::::default(), ) .await?; } } for service in cloud.services() { for cmd in service.post_setup(docker, &cloud.id, config).await? { cloud .exec( docker, shell_words::split(&cmd).into_diagnostic()?, false, Vec::::default(), ) .await?; } } for cmd in &config.auto_setup.post_setup { cloud .exec( docker, shell_words::split(cmd).into_diagnostic()?, false, Vec::::default(), ) .await?; } } Ok(cloud) }