1
0
Fork 0
mirror of https://codeberg.org/icewind/haze.git synced 2026-06-03 09:04:12 +02:00

allow using release sources

This commit is contained in:
Robin Appelman 2026-02-27 22:01:44 +01:00
commit f569ca17e2
9 changed files with 1226 additions and 113 deletions

1104
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -15,12 +15,12 @@ tokio = { version = "1.38.0", features = ["fs", "macros", "signal", "rt-multi-th
futures-util = "0.3.30" futures-util = "0.3.30"
termion = "4.0.1" termion = "4.0.1"
opener = "0.7.1" opener = "0.7.1"
toml = "0.8.14" toml = "1.0.3"
directories-next = "2.0.0" directories-next = "2.0.0"
serde = "1.0.203" serde = "1.0.203"
serde_json = "1.0.117" serde_json = "1.0.117"
petname = "2.0.2" petname = "2.0.2"
reqwest = { version = "0.12.4", default-features = false } reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls-native-roots"] }
tar = "0.4.41" tar = "0.4.41"
flate2 = "1.0.30" flate2 = "1.0.30"
async-trait = "0.1.80" async-trait = "0.1.80"
@ -35,6 +35,10 @@ itertools = { version = "0.14.0", features = ["use_alloc"] }
local-ip-address = "0.6.5" local-ip-address = "0.6.5"
strum = { version = "0.27.2", features = ["derive"] } strum = { version = "0.27.2", features = ["derive"] }
owo-colors = { version = "4.2.2", features = ["supports-colors"] } owo-colors = { version = "4.2.2", features = ["supports-colors"] }
zip = "8.1.0"
sha2 = "0.11.0-rc.5"
base16ct = { version = "1.0.0", features = ["alloc"] }
indicatif = "0.18.4"
hyper-reverse-proxy = { version = "0.5.2-dev", git = "https://github.com/chpio/hyper-reverse-proxy", rev = "6934877eb74465204f605cc1c05ca5a9772db7c0" } hyper-reverse-proxy = { version = "0.5.2-dev", git = "https://github.com/chpio/hyper-reverse-proxy", rev = "6934877eb74465204f605cc1c05ca5a9772db7c0" }
hyper = "1.6.0" hyper = "1.6.0"

View file

@ -56,7 +56,7 @@ See the [configuration section](#configuration) for more options.
#### Start an instance #### Start an instance
```bash ```bash
haze start [database] [php-version] [services] haze start [database] [php-version] [services] [vX.Y.Z]
``` ```
Where `database` is one of `sqlite`, `mysql`, `mariadb`, `pgsql` or `oracle` Where `database` is one of `sqlite`, `mysql`, `mariadb`, `pgsql` or `oracle`
@ -69,6 +69,9 @@ might be missing some newer features.
Each php version also comes with a `-dbg` variant that has php compiled in debug Each php version also comes with a `-dbg` variant that has php compiled in debug
mode and can be used for debugging php itself with gdb. mode and can be used for debugging php itself with gdb.
You can specify a version number (e.g. `v32.0.2`) to use the sources from a
release instead of using the local sources.
Additionally, you can use the following options when starting an instance: Additionally, you can use the following options when starting an instance:
- `s3`: set up an S3 server and configure to Nextcloud to use it as primary - `s3`: set up an S3 server and configure to Nextcloud to use it as primary

View file

@ -5,6 +5,7 @@ use crate::mapping::{default_mappings, Mapping};
use crate::php::{PhpVersion, PHP_MEMORY_LIMIT}; use crate::php::{PhpVersion, PHP_MEMORY_LIMIT};
use crate::service::Service; use crate::service::Service;
use crate::service::ServiceTrait; use crate::service::ServiceTrait;
use crate::sources::download_nc;
use bollard::container::{ListContainersOptions, RemoveContainerOptions, UpdateContainerOptions}; use bollard::container::{ListContainersOptions, RemoveContainerOptions, UpdateContainerOptions};
use bollard::models::ContainerState; use bollard::models::ContainerState;
use bollard::network::CreateNetworkOptions; use bollard::network::CreateNetworkOptions;
@ -57,6 +58,7 @@ pub struct CloudOptions {
pub php: PhpVersion, pub php: PhpVersion,
pub services: Vec<Service>, pub services: Vec<Service>,
pub app_packages: Vec<Utf8PathBuf>, pub app_packages: Vec<Utf8PathBuf>,
pub version: Option<String>,
} }
impl CloudOptions { impl CloudOptions {
@ -69,6 +71,7 @@ impl CloudOptions {
db: Database::default(), db: Database::default(),
services: vec![], services: vec![],
app_packages: vec![], app_packages: vec![],
version: None,
} }
} }
@ -82,6 +85,7 @@ impl CloudOptions {
let mut name = None; let mut name = None;
let mut services = Vec::new(); let mut services = Vec::new();
let mut app_package = Vec::new(); let mut app_package = Vec::new();
let mut version = None;
while let Some(option) = args.peek() { while let Some(option) = args.peek() {
if let Ok(db_option) = Database::from_str(option.as_ref()) { if let Ok(db_option) = Database::from_str(option.as_ref()) {
@ -96,6 +100,9 @@ impl CloudOptions {
} else if option.as_ref().ends_with(".tar.gz") { } else if option.as_ref().ends_with(".tar.gz") {
app_package.push(option.to_string().into()); app_package.push(option.to_string().into());
let _ = args.next(); let _ = args.next();
} else if let Some(v) = option.as_ref().strip_prefix("v") {
version = Some(v.into());
let _ = args.next();
} else if option.as_ref() == "--name" { } else if option.as_ref() == "--name" {
let _ = args.next(); let _ = args.next();
name = args.next().map(|s| s.into()); name = args.next().map(|s| s.into());
@ -112,6 +119,7 @@ impl CloudOptions {
.unwrap_or_default(), .unwrap_or_default(),
services, services,
app_packages: app_package, app_packages: app_package,
version,
}) })
} }
} }
@ -245,6 +253,12 @@ impl Cloud {
.wrap_err("Failed to create directory for app packages")?; .wrap_err("Failed to create directory for app packages")?;
} }
let source_root = if let Some(version) = options.version.as_deref() {
download_nc(config, version).await?
} else {
config.sources_root.clone()
};
let app_volumes = options let app_volumes = options
.app_packages .app_packages
.iter() .iter()
@ -327,7 +341,7 @@ impl Cloud {
]; ];
let volumes: Vec<String> = mappings let volumes: Vec<String> = mappings
.into_iter() .into_iter()
.filter_map(|mapping| mapping.get_volume_arg(&id, config)) .filter_map(|mapping| mapping.get_volume_arg(&id, config, &source_root))
.collect(); .collect();
if let Some(db_name) = options if let Some(db_name) = options
@ -382,6 +396,7 @@ impl Cloud {
gateway, gateway,
&options.services, &options.services,
&config.proxy, &config.proxy,
options.version.as_deref(),
) )
.await .await
.wrap_err("Failed to start php container") .wrap_err("Failed to start php container")
@ -620,6 +635,7 @@ impl Cloud {
let labels = cloud.labels?; let labels = cloud.labels?;
let db = labels.get("haze-db")?.parse().ok()?; let db = labels.get("haze-db")?.parse().ok()?;
let php = labels.get("haze-php")?.parse().ok()?; let php = labels.get("haze-php")?.parse().ok()?;
let version = labels.get("haze-version").cloned();
let found_services = labels let found_services = labels
.get("haze-services")? .get("haze-services")?
@ -665,6 +681,7 @@ impl Cloud {
db, db,
services: found_services, services: found_services,
app_packages: vec![], app_packages: vec![],
version,
}, },
pinned, pinned,
address, address,
@ -788,7 +805,11 @@ impl Cloud {
for mapping in mappings { for mapping in mappings {
if let Some(rel_path) = path.strip_prefix(mapping.target.as_str()) { if let Some(rel_path) = path.strip_prefix(mapping.target.as_str()) {
let rel_path = rel_path.trim_matches('/'); let rel_path = rel_path.trim_matches('/');
return Some(mapping.source(&self.id, config).join(rel_path)); return Some(
mapping
.source(&self.id, config, &config.sources_root)
.join(rel_path),
);
} }
} }
None None

View file

@ -88,6 +88,7 @@ fn subcommand_help(command: &dyn SubCommand) {
print!(" {}", "[php version]".green()); print!(" {}", "[php version]".green());
print!(" {}", "[database type]".green()); print!(" {}", "[database type]".green());
print!(" {}", "[services]".green()); print!(" {}", "[services]".green());
print!(" {}", "[vX.Y.Z]".green());
} }
let args = if let Some(args) = command.get_str("Args") { let args = if let Some(args) = command.get_str("Args") {

View file

@ -103,13 +103,18 @@ async fn main() -> Result<ExitCode> {
services.push(cloud.db().name()); services.push(cloud.db().name());
let services = services.join(", "); let services = services.join(", ");
let pin = if cloud.pinned { "*" } else { "" }; let pin = if cloud.pinned { "*" } else { "" };
let version = match cloud.options.version.as_ref() {
Some(version) => format!(", v{version}"),
None => String::new(),
};
println!( println!(
"Cloud {}{}, {}, {}, running on {}", "Cloud {}{}, {}, {}{}, running on {}",
cloud.id, cloud.id,
pin, pin,
cloud.php().name(), cloud.php().name(),
services, services,
cloud.address version,
cloud.address,
); );
} }
} }

View file

@ -78,20 +78,25 @@ impl<'a> Mapping<'a> {
Ok(()) Ok(())
} }
pub fn source(&self, id: &str, config: &HazeConfig) -> Utf8PathBuf { pub fn source(&self, id: &str, config: &HazeConfig, source_root: &Utf8Path) -> Utf8PathBuf {
match self.source_type { match self.source_type {
MappingSourceType::WorkDir => config.work_dir.join(id).join(self.source), MappingSourceType::WorkDir => config.work_dir.join(id).join(self.source),
MappingSourceType::GlobalWorkDir => config.work_dir.join(self.source), MappingSourceType::GlobalWorkDir => config.work_dir.join(self.source),
MappingSourceType::Sources => config.sources_root.join(self.source), MappingSourceType::Sources => source_root.join(self.source),
MappingSourceType::Absolute => self.source.into(), MappingSourceType::Absolute => self.source.into(),
} }
} }
pub fn get_volume_arg(&self, id: &str, config: &HazeConfig) -> Option<String> { pub fn get_volume_arg(
&self,
id: &str,
config: &HazeConfig,
source_root: &Utf8Path,
) -> Option<String> {
if !self.map { if !self.map {
return None; return None;
} }
let source = self.source(id, config); let source = self.source(id, config, source_root);
Some(if self.read_only { Some(if self.read_only {
format!("{}:{}:ro", source, self.target) format!("{}:{}:ro", source, self.target)
} else { } else {

View file

@ -164,6 +164,7 @@ impl PhpVersion {
host: &str, host: &str,
services: &[Service], services: &[Service],
proxy_config: &ProxyConfig, proxy_config: &ProxyConfig,
version: Option<&str>,
) -> Result<String> { ) -> Result<String> {
ensure_network_exists(docker, "haze").await?; ensure_network_exists(docker, "haze").await?;
pull_image(docker, self.image()).await?; pull_image(docker, self.image()).await?;
@ -192,6 +193,17 @@ impl PhpVersion {
proxy_config.addr(id, IpAddr::V4(Ipv4Addr::LOCALHOST)) proxy_config.addr(id, IpAddr::V4(Ipv4Addr::LOCALHOST))
)); ));
let mut labels = hashmap! {
"haze-type".to_string() => "cloud".to_string(),
"haze-db".to_string() => db.name().to_string(),
"haze-php".to_string() => self.name().to_string(),
"haze-cloud-id".to_string() => id.to_string(),
"haze-services".to_string() => services.iter().map(|s| s.name()).join(","),
};
if let Some(version) = version {
labels.insert("haze-version".to_string(), version.to_string());
}
let config = Config { let config = Config {
image: Some(self.image().to_string()), image: Some(self.image().to_string()),
env: Some(env), env: Some(env),
@ -211,13 +223,7 @@ impl PhpVersion {
} }
}, },
}), }),
labels: Some(hashmap! { labels: Some(labels),
"haze-type".to_string() => "cloud".to_string(),
"haze-db".to_string() => db.name().to_string(),
"haze-php".to_string() => self.name().to_string(),
"haze-cloud-id".to_string() => id.to_string(),
"haze-services".to_string() => services.iter().map(|s| s.name()).join(","),
}),
..Default::default() ..Default::default()
}; };

View file

@ -1,6 +1,17 @@
use crate::config::HazeConfig;
use camino::Utf8PathBuf;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use miette::{Context, IntoDiagnostic, Report, Result}; use miette::{Context, IntoDiagnostic, Report, Result};
use reqwest::header::HeaderName;
use reqwest::{Client, IntoUrl, Response};
use sha2::{Digest, Sha512};
use std::fs::read_to_string; use std::fs::read_to_string;
use std::io::Cursor;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::fs::create_dir_all;
use zip::read::root_dir_common_filter;
use zip::ZipArchive;
pub struct Sources { pub struct Sources {
#[allow(dead_code)] #[allow(dead_code)]
@ -49,3 +60,116 @@ impl Sources {
} }
} }
} }
pub async fn download_nc(config: &HazeConfig, version: &str) -> Result<Utf8PathBuf> {
if !version.chars().all(|c| c.is_ascii_digit() || c == '.') {
return Err(Report::msg(format!("Invalid version: {version}")));
}
let root = config.work_dir.join("sources");
create_dir_all(&root)
.await
.into_diagnostic()
.wrap_err("failed to create parent directory for sources")?;
let dest = root.join(version);
if !dest.exists() {
let progress = MultiProgress::new();
let download_style = ProgressStyle::with_template("{spinner:.green} {msg} [{elapsed_precise}] [{bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")
.unwrap();
let download_bar = ProgressBar::new(0)
.with_message("Downloading")
.with_style(download_style.clone());
let download_bar = progress.add(download_bar);
let archive = download_url(
format!("https://download.nextcloud.com/server/releases/nextcloud-{version}.zip"),
|size| {
download_bar.set_length(size);
},
|count| {
download_bar.inc(count);
},
)
.await
.wrap_err_with(|| format!("Failed to download archive for {}", version))?;
download_bar.finish();
let expected_hash = download_text(format!(
"https://download.nextcloud.com/server/releases/nextcloud-{version}.zip.sha512"
))
.await
.wrap_err_with(|| format!("Failed to download hash for {}", version))?;
let expected_hash = expected_hash
.split_once(' ')
.map(|(hash, _)| hash)
.unwrap_or(expected_hash.trim());
let hash_bar = ProgressBar::new(download_bar.length().unwrap_or_default())
.with_message("Validating")
.with_style(download_style.clone());
let hash_bar = progress.add(hash_bar);
let mut hasher = Sha512::new();
for chunk in archive.chunks(1014 * 1024) {
hash_bar.inc(chunk.len() as u64);
hasher.update(chunk);
}
let hash = hasher.finalize();
let hash = base16ct::lower::encode_string(&hash);
if expected_hash != hash {
return Err(Report::msg(format!(
"Invalid hash for downloaded: {version}, expected {expected_hash} but got {hash}"
)));
}
hash_bar.finish();
let extract_bar = ProgressBar::new_spinner().with_message("Extracing");
extract_bar.enable_steady_tick(Duration::from_millis(100));
let extract_bar = progress.add(extract_bar);
let mut archive = ZipArchive::new(Cursor::new(archive)).into_diagnostic()?;
archive
.extract_unwrapped_root_dir(&dest, root_dir_common_filter)
.into_diagnostic()
.wrap_err("Failed to extract archive")?;
extract_bar.finish();
}
Ok(dest)
}
async fn download_url<U: IntoUrl, SizeFN: FnOnce(u64), ProgressFN: Fn(u64)>(
url: U,
size: SizeFN,
progress: ProgressFN,
) -> Result<Vec<u8>> {
let mut res = get_url(url).await?.error_for_status().into_diagnostic()?;
let mut buff = Vec::new();
size(res.content_length().unwrap_or_default());
while let Some(chunk) = res.chunk().await.into_diagnostic()? {
progress(chunk.len() as u64);
buff.extend(chunk);
}
Ok(buff)
}
async fn download_text<U: IntoUrl>(url: U) -> Result<String> {
get_url(url)
.await?
.error_for_status()
.into_diagnostic()?
.text()
.await
.into_diagnostic()
}
async fn get_url<U: IntoUrl>(url: U) -> Result<Response> {
Client::builder()
.build()
.into_diagnostic()?
.get(url)
.header(
HeaderName::from_static("user-agent"),
format!("haze {}", env!("CARGO_PKG_VERSION")),
)
.send()
.await
.into_diagnostic()
}