1
0
Fork 0
mirror of https://codeberg.org/icewind/haze.git synced 2026-10-01 08:44:09 +02:00
haze/src/main.rs

737 lines
26 KiB
Rust

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, prune_worktrees, 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::script::run_script;
use crate::service::ServiceTrait;
use crate::service::{RedisTls, Service};
use crate::sources::Sources;
use bollard::Docker;
use futures_util::future::{Either, join_all, select};
use itertools::Itertools;
use miette::{IntoDiagnostic, Report, Result, WrapErr};
use std::collections::HashMap;
use std::env::{var, vars};
use std::fs::{create_dir_all, remove_dir_all, remove_file, write};
use std::io::stdout;
use std::os::unix::process::CommandExt;
use std::pin::pin;
use std::process::{Command, ExitCode};
use std::time::Duration;
use tokio::time::sleep;
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 script;
mod service;
mod sources;
static FORWARD_ENV: &[&str] = &[
"OCC_LOG",
"OC_PASS",
"XDEBUG_MODE",
"XDEBUG_TRIGGER",
"XDEBUG_CONFIG",
];
fn get_forward_env() -> Vec<String> {
vars()
.filter(|(var, _)| FORWARD_ENV.contains(&var.as_str()))
.map(|(var, value)| format!("{var}={value}"))
.collect()
}
#[tokio::main]
async fn main() -> Result<ExitCode> {
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 mut retain = Vec::new();
let mut remove = Vec::new();
for cloud in list {
if cloud.is_pinned(&docker).await.unwrap_or(false) {
retain.push(cloud);
} else {
remove.push(cloud);
}
}
for cloud in remove {
if let Err(e) = cloud.destroy(&docker, &config).await {
eprintln!("Error while removing cloud: {:#}", e);
}
}
clear_networks(&docker, &retain).await?;
for cache_dir in config
.work_dir
.read_dir()
.into_diagnostic()
.wrap_err_with(|| format!("Failed to read dir {}", config.work_dir))?
{
let cache_dir = cache_dir
.into_diagnostic()
.wrap_err_with(|| "Failed to unwrap cache_dir")?;
if let Some(id) = cache_dir.file_name().to_str()
&& id.starts_with("haze-")
&& !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.
if path == config.worktree_dir {
continue;
}
if path.is_dir() {
remove_dir_all(&path).into_diagnostic().wrap_err_with(|| {
format!("Failed to cleanup stray cache dir: {}", path.display())
})?;
} else {
remove_file(&path).into_diagnostic().wrap_err_with(|| {
format!("Failed to cleanup stray cache file: {}", path.display())
})?;
}
}
}
prune_worktrees(&config).wrap_err_with(|| "Failed to prune worktree")?;
}
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.is_pinned(&docker).await.unwrap_or(false) {
"*"
} else {
""
};
let version = match cloud.options.version.as_ref() {
Some(version) => format!(", v{version}"),
None => String::new(),
};
let detached = if cloud.options.detach {
", detached"
} else {
""
};
println!(
"Cloud {}{}, {}, {}{}{}, running on {}",
cloud.id,
pin,
cloud.php().name(),
services,
version,
detached,
cloud.address,
);
}
}
HazeArgs::Start { options } => {
setup(&docker, options, &config, false, false).await?;
}
HazeArgs::Stop { filter } => {
let cloud = Cloud::get_by_filter(&docker, filter, &config).await?;
cloud.destroy(&docker, &config).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?;
let env = get_forward_env();
let tty = atty::is(atty::Stream::Stdout);
match service {
None => {
let command = if command.is_empty() {
vec!["bash".to_string()]
} else {
command
};
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) => {
let command = if command.is_empty() {
vec!["bash".to_string()]
} else {
command
};
cloud.db().exec_sh(&docker, &cloud.id, command, tty).await?;
}
Some(ExecService::Service(service)) => {
let Some(container) = service.container_name(&cloud.id) else {
eprintln!(
"Service {} can't be exec'ed as it has no associated container",
service.name()
);
return Ok(ExitCode::FAILURE);
};
if service.exec_shell() == "" && command.is_empty() {
eprintln!(
"Service {} can't be exec'ed as it has no shell",
service.name()
);
return Ok(ExitCode::FAILURE);
}
let command = if command.is_empty() {
vec![service.exec_shell().to_string()]
} else {
command
};
let user = if root { "root" } else { service.exec_user() };
if tty {
exec_tty(&docker, &container, user, command, env).await?;
} else {
exec(&docker, &container, user, command, env, Some(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 = setup(&docker, options, &config, true, true).await?;
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, &config).await?;
return Ok(result.into());
}
HazeArgs::Integration { options, mut args } => {
let cloud = setup(&docker, options, &config, true, true).await?;
args.insert(0, "integration".to_string());
cloud.exec(&docker, args, false, get_forward_env()).await?;
cloud.destroy(&docker, &config).await?;
}
HazeArgs::Fmt { path } => {
let cloud = Cloud::create(&docker, CloudOptions::new(&config), &config).await?;
let mut out_buffer = Vec::<u8>::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::<String>::default(),
)
.await
.and_then(|c| c.to_result())
{
eprintln!("{}", String::from_utf8_lossy(&out_buffer));
cloud.destroy(&docker, &config).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::<String>::default(),
)
.await
{
cloud.destroy(&docker, &config).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::<String>::default(),
)
.await
.and_then(|c| c.to_result())
{
eprintln!("{}", String::from_utf8_lossy(&out_buffer));
cloud.destroy(&docker, &config).await?;
return Err(e);
}
if let Err(e) = cloud
.exec_with_output(
&docker,
vec!["git", "checkout", "lib/composer"],
Some(&mut out_buffer),
Vec::<String>::default(),
)
.await
.and_then(|c| c.to_result())
{
eprintln!("{}", String::from_utf8_lossy(&out_buffer));
cloud.destroy(&docker, &config).await?;
return Err(e);
}
cloud.destroy(&docker, &config).await?;
}
HazeArgs::Shell { command, options } => {
let cloud = setup(&docker, options, &config, true, false).await?;
cloud
.exec(
&docker,
if command.is_empty() {
vec!["bash".to_string()]
} else {
command
},
true,
get_forward_env(),
)
.await?;
cloud.destroy(&docker, &config).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, verbose } => {
let sources = Sources::new(&config.sources_root)?;
checkout_all(
&config,
&branch.unwrap_or_else(|| sources.get_server_version_branch()),
verbose,
)?;
}
GitOperation::Pull => {
pull_all(&config)?;
}
},
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)))?;
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`"));
}
_ => {}
}
let mut command = Command::new(command);
command
.args(args)
.env("REDIS_URL", format!("redis://{}", ip))
.env("NEXTCLOUD_URL", &cloud.address)
.env(
"DATABASE_URL",
cloud.db().location(&docker, &cloud.id, &config).await?,
);
if cloud.services().contains(&Service::RedisTls(RedisTls)) {
create_dir_all(config.work_dir.join("certificates/redis"))
.into_diagnostic()
.wrap_err("Failed to create redis certificate directory")?;
let redis_cert_path = config.work_dir.join("certificates/redis/client.cert");
let redis_key_path = config.work_dir.join("certificates/redis/client.key");
let redis_ca_path = config.work_dir.join("certificates/redis/ca.cert");
if !redis_cert_path.exists() {
write(
&redis_cert_path,
include_bytes!("../certificates/redis/client.crt"),
)
.into_diagnostic()
.wrap_err("Failed to write redis client certificate")?;
}
if !redis_key_path.exists() {
write(
&redis_key_path,
include_bytes!("../certificates/redis/client.key"),
)
.into_diagnostic()
.wrap_err("Failed to write redis client key")?;
}
if !redis_ca_path.exists() {
write(
&redis_ca_path,
include_bytes!("../certificates/redis/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!["supervisorctl", "restart", "php-fpm"],
Vec::<String>::new(),
Some(stdout()),
)
.await?;
}
HazeArgs::Script { path, mode } => {
run_script(path, &config, &docker, mode).await?;
}
};
Ok(ExitCode::SUCCESS)
}
async fn setup(
docker: &Docker,
options: CloudOptions,
config: &HazeConfig,
always_setup: bool,
force_wait: bool,
) -> Result<Cloud> {
let cloud = Cloud::create(docker, options, config).await?;
println!("{}", cloud.address);
let host = cloud.address.split_once("://").expect("no address?").1;
match cloud.db().location(docker, &cloud.id, config).await {
Ok(value) => {
println!("Database: {}", value);
}
Err(e) => {
println!("Failed to print DB location: {}", e);
}
}
if always_setup || 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", "<?php $CONFIG=json_decode(file_get_contents(__DIR__ . '/preset.config.json'), true);").await?;
}
println!(
"Installing with username {} and password {}",
config.auto_setup.username, config.auto_setup.password
);
let ip_str = format!("{}", cloud.ip.unwrap());
cloud
.exec(
docker,
vec![
"install",
&config.auto_setup.username,
&config.auto_setup.password,
],
false,
Vec::<String>::default(),
)
.await?;
cloud
.occ(
docker,
vec![
"config:system:set",
"overwrite.cli.url",
"--value",
&cloud.address,
],
None,
Vec::<String>::default(),
)
.await?;
cloud
.occ(
docker,
vec!["config:system:set", "overwritehost", "--value", host],
None,
Vec::<String>::default(),
)
.await?;
if cloud.address.contains("https://") {
cloud
.occ(
docker,
vec!["config:system:set", "overwriteprotocol", "--value", "https"],
None,
Vec::<String>::default(),
)
.await?;
}
cloud
.occ(
docker,
vec![
"config:app:set",
"core",
"backgroundjobs_mode",
"--value",
"cron",
],
None,
Vec::<String>::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::<String>::default(),
)
.await?;
}
for app in &config.auto_setup.enable_apps {
cloud
.exec(
docker,
vec!["occ", "app:enable", app.as_str(), "--force"],
false,
Vec::<String>::default(),
)
.await?;
}
for app in &config.auto_setup.disable_apps {
cloud
.exec(
docker,
vec!["occ", "app:disable", app.as_str()],
false,
Vec::<String>::default(),
)
.await?;
}
for service in cloud.services() {
for app in service.apps() {
cloud
.exec(
docker,
vec!["occ", "app:enable", *app, "--force"],
false,
Vec::<String>::default(),
)
.await?;
}
}
for service in cloud.services() {
for cmd in service.post_setup(docker, &cloud.id, config).await? {
cloud
.exec(docker, cmd, false, Vec::<String>::default())
.await?;
}
}
for cmd in &config.auto_setup.post_setup {
cloud
.exec(
docker,
shell_words::split(cmd).into_diagnostic()?,
false,
Vec::<String>::default(),
)
.await?;
}
}
{
let mut waiting = HashMap::new();
for service in cloud.services() {
// give the service 100ms before we mark them as pending
let sleep = pin!(sleep(Duration::from_millis(100)));
let future = service.final_wait(docker, &cloud.id);
if let Either::Left((_, future)) = select(sleep, future).await {
waiting.insert(service.name(), future);
}
}
if !waiting.is_empty() {
println!();
println!("The folowing services are still starting in the background:");
for service in waiting.keys() {
println!(" - {service}");
}
println!();
if !force_wait {
println!(
"You can safely terminate this command with CTRL-C and use the instance, but some functionality provided by the service might not be ready yet."
)
}
join_all(waiting.into_values())
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
}
}
Ok(cloud)
}