diff --git a/src/commands/cli.rs b/src/commands/cli.rs index 1498fb13e..fd4d57205 100644 --- a/src/commands/cli.rs +++ b/src/commands/cli.rs @@ -51,6 +51,10 @@ pub fn main(options: &Options) -> Result<(), anyhow::Error> { directory_check::check_and_error()?; portable::server_main(cmd) } + Command::Extension(cmd) => { + directory_check::check_and_error()?; + portable::extension_main(cmd) + } Command::Instance(cmd) => { directory_check::check_and_error()?; portable::instance_main(cmd, options) diff --git a/src/options.rs b/src/options.rs index 074c55799..ae92eebce 100644 --- a/src/options.rs +++ b/src/options.rs @@ -355,6 +355,8 @@ pub enum Command { Instance(portable::options::ServerInstanceCommand), /// Manage local EdgeDB installations Server(portable::options::ServerCommand), + /// Manage local extensions + Extension(portable::options::ServerInstanceExtensionCommand), /// Generate shell completions #[command(name = "_gen_completions")] #[command(hide = true)] diff --git a/src/portable/control.rs b/src/portable/control.rs index 465f2f05a..62c4a5b86 100644 --- a/src/portable/control.rs +++ b/src/portable/control.rs @@ -111,7 +111,7 @@ fn write_lock_info( use std::io::Write; lock.set_len(0)?; - lock.write_all(marker.as_ref().map(|x| &x[..]).unwrap_or("user").as_bytes())?; + lock.write_all(marker.as_deref().unwrap_or("user").as_bytes())?; Ok(()) } diff --git a/src/portable/extension.rs b/src/portable/extension.rs new file mode 100644 index 000000000..ce96023dd --- /dev/null +++ b/src/portable/extension.rs @@ -0,0 +1,171 @@ +use std::ffi::OsStr; +use std::path::Path; +use std::process::Command; + +use anyhow::Context; +use log::trace; +use prettytable::{row, Table}; + +use super::options::{ + ExtensionInstall, ExtensionList, ExtensionListExtensions, ExtensionUninstall, +}; +use crate::hint::HintExt; +use crate::portable::install::download_package; +use crate::portable::local::InstanceInfo; +use crate::portable::options::{instance_arg, InstanceName, ServerInstanceExtensionCommand}; +use crate::portable::platform::get_server; +use crate::portable::repository::{get_platform_extension_packages, Channel}; +use crate::table; + +pub fn extension_main(c: &ServerInstanceExtensionCommand) -> Result<(), anyhow::Error> { + use crate::portable::options::InstanceExtensionCommand::*; + match &c.subcommand { + Install(c) => install(c), + List(c) => list(c), + ListAvailable(c) => list_extensions(c), + Uninstall(c) => uninstall(c), + } +} + +fn get_local_instance(instance: &Option) -> Result { + let name = match instance_arg(&None, instance)? { + InstanceName::Local(name) => name, + inst_name => { + return Err(anyhow::anyhow!( + "cannot install extensions in cloud instance {}.", + inst_name + )) + .with_hint(|| { + format!( + "only local instances can install extensions ({} is remote)", + inst_name + ) + })?; + } + }; + let Some(inst) = InstanceInfo::try_read(&name)? else { + return Err(anyhow::anyhow!( + "cannot install extensions in cloud instance {}.", + name + )) + .with_hint(|| { + format!( + "only local instances can install extensions ({} is remote)", + name + ) + })?; + }; + Ok(inst) +} + +fn list(options: &ExtensionList) -> Result<(), anyhow::Error> { + let inst = get_local_instance(&options.instance)?; + let extension_loader = inst.extension_loader_path()?; + run_extension_loader(&extension_loader, Some("--list"), None::<&str>)?; + Ok(()) +} + +fn uninstall(options: &ExtensionUninstall) -> Result<(), anyhow::Error> { + let inst = get_local_instance(&options.instance)?; + let extension_loader = inst.extension_loader_path()?; + run_extension_loader( + &extension_loader, + Some("--uninstall".to_string()), + Some(Path::new(&options.extension)), + )?; + Ok(()) +} + +fn install(options: &ExtensionInstall) -> Result<(), anyhow::Error> { + let inst = get_local_instance(&options.instance)?; + let extension_loader = inst.extension_loader_path()?; + + let version = inst.get_version()?.specific(); + let channel = options.channel.unwrap_or(Channel::from_version(&version)?); + let slot = options.slot.clone().unwrap_or(version.slot()); + trace!("Instance: {version} {channel:?} {slot}"); + let packages = get_platform_extension_packages(channel, &slot, get_server()?)?; + + let package = packages + .iter() + .find(|pkg| pkg.tags.get("extension").cloned().unwrap_or_default() == options.extension); + + match package { + Some(pkg) => { + println!( + "Found extension package: {} version {}", + options.extension, pkg.version + ); + let zip = download_package(&pkg)?; + let command = if options.reinstall { + Some("--reinstall") + } else { + None + }; + run_extension_loader(&extension_loader, command, Some(&zip))?; + println!("Extension '{}' installed successfully.", options.extension); + } + None => { + return Err(anyhow::anyhow!( + "Extension '{}' not found in available packages.", + options.extension + )); + } + } + + Ok(()) +} + +fn run_extension_loader( + extension_installer: &Path, + command: Option>, + file: Option>, +) -> Result<(), anyhow::Error> { + let mut cmd = Command::new(extension_installer); + + if let Some(cmd_str) = command { + cmd.arg(cmd_str); + } + + if let Some(file_path) = file { + cmd.arg(file_path); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to execute {}", extension_installer.display()))?; + + if !output.status.success() { + eprintln!("STDOUT:\n{}", String::from_utf8_lossy(&output.stdout)); + eprintln!("STDERR:\n{}", String::from_utf8_lossy(&output.stderr)); + return Err(anyhow::anyhow!( + "Extension installation failed with exit code: {}", + output.status + )); + } else { + trace!("STDOUT:\n{}", String::from_utf8_lossy(&output.stdout)); + trace!("STDERR:\n{}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) +} + +fn list_extensions(options: &ExtensionListExtensions) -> Result<(), anyhow::Error> { + let inst = get_local_instance(&options.instance)?; + + let version = inst.get_version()?.specific(); + let channel = options.channel.unwrap_or(Channel::from_version(&version)?); + let slot = options.slot.clone().unwrap_or(version.slot()); + trace!("Instance: {version} {channel:?} {slot}"); + let packages = get_platform_extension_packages(channel, &slot, get_server()?)?; + + let mut table = Table::new(); + table.set_format(*table::FORMAT); + table.add_row(row!["Name", "Version"]); + for pkg in packages { + let ext = pkg.tags.get("extension").cloned().unwrap_or_default(); + table.add_row(row![ext, pkg.version]); + } + table.printstd(); + Ok(()) +} diff --git a/src/portable/install.rs b/src/portable/install.rs index 15944e45d..5f5acc6cb 100644 --- a/src/portable/install.rs +++ b/src/portable/install.rs @@ -45,7 +45,7 @@ fn check_metadata(dir: &Path, pkg_info: &PackageInfo) -> anyhow::Result anyhow::Result { +pub fn download_package(pkg_info: &PackageInfo) -> anyhow::Result { let cache_dir = platform::cache_dir()?; let download_dir = cache_dir.join("downloads"); fs::create_dir_all(&download_dir)?; @@ -209,6 +209,7 @@ pub fn package(pkg_info: &PackageInfo) -> anyhow::Result { package_url: pkg_info.url.clone(), package_hash: pkg_info.hash.clone(), installed_at: SystemTime::now(), + slot: pkg_info.slot.clone(), }; write_json(&tmp_target.join("install_info.json"), "metadata", &info)?; fs::rename(&tmp_target, &target_dir) diff --git a/src/portable/link.rs b/src/portable/link.rs index 04ccf1d34..9eb3d7cb3 100644 --- a/src/portable/link.rs +++ b/src/portable/link.rs @@ -453,7 +453,9 @@ pub fn unlink(options: &Unlink) -> anyhow::Result<()> { .into()); } with_projects(name, options.force, print_warning, || { - fs::remove_file(credentials::path(name)?).with_context(|| format!("cannot unlink {}", name)) + let path = credentials::path(name)?; + fs::remove_file(&path) + .with_context(|| format!("Credentials for {name} missing from {path:?}")) })?; Ok(()) } diff --git a/src/portable/local.rs b/src/portable/local.rs index d81cd2c10..d1e078d26 100644 --- a/src/portable/local.rs +++ b/src/portable/local.rs @@ -14,6 +14,7 @@ use edgedb_tokio::Builder; use crate::bug; use crate::credentials; +use crate::hint::HintExt; use crate::platform::{cache_dir, config_dir, data_dir, portable_dir}; use crate::portable::repository::PackageHash; use crate::portable::ver; @@ -47,6 +48,8 @@ pub struct InstallInfo { pub package_hash: PackageHash, #[serde(with = "serde_millis")] pub installed_at: SystemTime, + #[serde(default)] + pub slot: String, } fn port_file() -> anyhow::Result { @@ -295,11 +298,9 @@ impl Paths { impl InstanceInfo { pub fn get_version(&self) -> anyhow::Result<&ver::Build> { - self.installation - .as_ref() - .map(|v| &v.version) - .ok_or_else(|| bug::error("no installation info at this point")) + Ok(&self.get_installation()?.version) } + pub fn try_read(name: &str) -> anyhow::Result> { if cfg!(windows) { let data = match windows::get_instance_info(name) { @@ -344,15 +345,35 @@ impl InstanceInfo { data.name = name.into(); Ok(data) } + pub fn data_dir(&self) -> anyhow::Result { instance_data_dir(&self.name) } - pub fn server_path(&self) -> anyhow::Result { + + fn get_installation(&self) -> anyhow::Result<&InstallInfo> { self.installation .as_ref() - .ok_or_else(|| bug::error("version should be set"))? - .server_path() + .ok_or_else(|| bug::error("installation should be set")) + } + + pub fn server_path(&self) -> anyhow::Result { + self.get_installation()?.server_path() + } + + #[allow(unused)] + pub fn base_path(&self) -> anyhow::Result { + self.get_installation()?.base_path() + } + + #[allow(unused)] + pub fn extension_path(&self) -> anyhow::Result { + self.get_installation()?.extension_path() + } + + pub fn extension_loader_path(&self) -> anyhow::Result { + self.get_installation()?.extension_loader_path() } + pub fn admin_conn_params(&self) -> anyhow::Result { let mut builder = Builder::new(); builder.port(self.port)?; @@ -372,9 +393,44 @@ impl InstallInfo { pub fn base_path(&self) -> anyhow::Result { installation_path(&self.version.specific()) } + pub fn server_path(&self) -> anyhow::Result { Ok(self.base_path()?.join("bin").join("edgedb-server")) } + + pub fn extension_path(&self) -> anyhow::Result { + let path = self + .base_path()? + .join("share") + .join("data") + .join("extensions"); + if !path.exists() { + Err( + bug::error("no extension directory available for this server") + .with_hint(|| { + format!("Extension installation requires EdgeDB server version 6 or later") + }) + .into(), + ) + } else { + Ok(path) + } + } + + pub fn extension_loader_path(&self) -> anyhow::Result { + let path = self.base_path()?.join("bin").join("edgedb-load-ext"); + if path.exists() { + Ok(path) + } else { + Err( + anyhow::anyhow!("edgedb-load-ext not found in the installation") + .with_hint(|| { + format!("Extension installation requires EdgeDB server version 6 or later") + }) + .into(), + ) + } + } } pub fn is_valid_local_instance_name(name: &str) -> bool { diff --git a/src/portable/mod.rs b/src/portable/mod.rs index bceba6390..8a6b4cbb0 100644 --- a/src/portable/mod.rs +++ b/src/portable/mod.rs @@ -16,6 +16,7 @@ mod control; mod create; mod credentials; mod destroy; +mod extension; mod info; pub mod install; mod link; @@ -28,5 +29,6 @@ pub mod status; mod uninstall; mod upgrade; +pub use extension::extension_main; pub use main::{instance_main, project_main, server_main}; pub use reset_password::password_hash; diff --git a/src/portable/options.rs b/src/portable/options.rs index 259a80ca1..89f311844 100644 --- a/src/portable/options.rs +++ b/src/portable/options.rs @@ -73,6 +73,80 @@ pub enum InstanceCommand { Credentials(ShowCredentials), } +#[derive(clap::Args, Debug, Clone)] +#[command(version = "help_expand")] +#[command(disable_version_flag = true)] +pub struct ServerInstanceExtensionCommand { + #[command(subcommand)] + pub subcommand: InstanceExtensionCommand, +} + +#[derive(clap::Subcommand, Clone, Debug)] +pub enum InstanceExtensionCommand { + /// List installed extensions for a local instance. + #[command(hide = true)] + List(ExtensionList), + /// List available extensions for a local instance. + ListAvailable(ExtensionListExtensions), + /// Install an extension for a local instance. + Install(ExtensionInstall), + /// Uninstall an extension from a local instance. + #[command(hide = true)] + Uninstall(ExtensionUninstall), +} + +#[derive(clap::Args, IntoArgs, Debug, Clone)] +pub struct ExtensionList { + /// Specify local instance name. + #[arg(short = 'I', long)] + #[arg(value_hint=ValueHint::Other)] // TODO complete instance name + pub instance: Option, +} + +#[derive(clap::Args, IntoArgs, Debug, Clone)] +pub struct ExtensionListExtensions { + /// Specify local instance name. + #[arg(short = 'I', long)] + #[arg(value_hint=ValueHint::Other)] // TODO complete instance name + pub instance: Option, + /// Specify the channel override (stable, testing, or nightly) + #[arg(long, hide = true)] + pub channel: Option, + /// Specify the slot override (for development use) + #[arg(long, hide = true)] + pub slot: Option, +} + +#[derive(clap::Args, IntoArgs, Debug, Clone)] +pub struct ExtensionInstall { + /// Specify local instance name. + #[arg(short = 'I', long)] + #[arg(value_hint=ValueHint::Other)] // TODO complete instance name + pub instance: Option, + /// Name of the extension to install + #[arg(short = 'E', long)] + pub extension: String, + /// Specify the channel override (stable, testing, or nightly) + #[arg(long, hide = true)] + pub channel: Option, + /// Specify the slot override (for development use) + #[arg(long, hide = true)] + pub slot: Option, + /// Reinstall the extension if it's already installed + #[arg(long, hide = true)] + pub reinstall: bool, +} +/// Represents the options for uninstalling an extension from a local EdgeDB instance. +#[derive(clap::Args, IntoArgs, Debug, Clone)] +pub struct ExtensionUninstall { + /// Specify local instance name. + #[arg(short = 'I', long)] + pub instance: Option, + /// The name of the extension to uninstall. + #[arg(short = 'E', long)] + pub extension: String, +} + #[derive(clap::Subcommand, Clone, Debug)] pub enum Command { /// Show locally installed EdgeDB versions. diff --git a/src/portable/repository.rs b/src/portable/repository.rs index ca3711904..8d56fceb1 100644 --- a/src/portable/repository.rs +++ b/src/portable/repository.rs @@ -1,4 +1,5 @@ use std::cmp::min; +use std::collections::HashMap; use std::env; use std::fmt; use std::future; @@ -19,6 +20,7 @@ use crate::async_util::timeout; use crate::portable::platform; use crate::portable::ver; use crate::portable::windows; +use crate::process::IntoArg; pub const USER_AGENT: &str = "edgedb"; pub const DEFAULT_TIMEOUT: Duration = Duration::new(60, 0); @@ -38,6 +40,7 @@ pub enum Channel { #[derive(Debug, Clone, serde::Serialize)] pub enum PackageType { TarZst, + Zip, } #[derive(Debug, Clone, serde::Serialize)] @@ -82,6 +85,9 @@ pub struct PackageData { pub basename: String, pub version: String, pub installrefs: Vec, + pub slot: String, + #[serde(default)] + pub tags: HashMap, } #[derive(Deserialize, Debug, Clone)] @@ -92,11 +98,14 @@ pub struct Verification { #[derive(Debug, Clone, serde::Serialize)] pub struct PackageInfo { + pub name: String, pub version: ver::Build, pub url: Url, pub size: u64, pub hash: PackageHash, pub kind: PackageType, + pub slot: String, + pub tags: HashMap, } #[derive(Debug, Clone)] @@ -119,6 +128,7 @@ impl PackageType { fn as_ext(&self) -> &str { match self { PackageType::TarZst => ".tar.zst", + PackageType::Zip => ".zip", } } } @@ -207,30 +217,77 @@ where } } -fn filter_package(pkg_root: &Url, pkg: &PackageData) -> Option { - let result = _filter_package(pkg_root, pkg); +fn filter_package( + pkg_root: &Url, + pkg: &PackageData, + expected_package_type: PackageType, +) -> Option { + let result = _filter_package(pkg_root, pkg, expected_package_type); if result.is_none() { log::info!("Skipping package {:?}", pkg); } result } -fn _filter_package(pkg_root: &Url, pkg: &PackageData) -> Option { +fn _filter_package( + pkg_root: &Url, + pkg: &PackageData, + expected_package_type: PackageType, +) -> Option { let iref = pkg.installrefs.iter().find(|r| { - r.kind == "application/x-tar" - && r.encoding.as_ref().map(|x| &x[..]) == Some("zstd") - && r.verification - .blake2b - .as_ref() - .map(valid_hash) - .unwrap_or(false) + let matches_type = match expected_package_type { + PackageType::TarZst => { + r.kind == "application/x-tar" && r.encoding.as_deref() == Some("zstd") + } + PackageType::Zip => { + r.kind == "application/zip" && r.encoding.as_deref() == Some("identity") + } + }; + if !matches_type { + log::trace!( + "Package type mismatch: expected {:?}, got kind '{}' and encoding '{:?}'", + expected_package_type, + r.kind, + r.encoding + ); + return false; + } + let valid_verification = r + .verification + .blake2b + .as_ref() + .map(valid_hash) + .unwrap_or(false); + if !valid_verification { + log::trace!("Invalid or missing blake2b hash for package"); + return false; + } + true })?; + let version = match pkg.version.parse() { + Ok(v) => v, + Err(e) => { + log::trace!("Failed to parse package version: {}", e); + return None; + } + }; + let url = match pkg_root.join(&iref.path) { + Ok(u) => u, + Err(e) => { + log::trace!("Failed to join package URL: {}", e); + return None; + } + }; + let hash = PackageHash::Blake2b(iref.verification.blake2b.as_ref()?[..].into()); Some(PackageInfo { - version: pkg.version.parse().ok()?, - url: pkg_root.join(&iref.path).ok()?, - hash: PackageHash::Blake2b(iref.verification.blake2b.as_ref()?[..].into()), - kind: PackageType::TarZst, + name: pkg.basename.clone(), + version, + url, + hash, + kind: expected_package_type, size: iref.verification.size, + slot: pkg.slot.clone(), + tags: pkg.tags.clone(), }) } @@ -247,7 +304,7 @@ fn _filter_cli_package(pkg_root: &Url, pkg: &PackageData) -> Option Option Option anyhow::Result> { + let pkg_root = pkg_root()?; + + let data: RepositoryData = match get_json(&json_url(platform, channel)?, DEFAULT_TIMEOUT) { + Ok(data) => data, + Err(e) if e.is::() => RepositoryData { packages: vec![] }, + Err(e) => return Err(e), + }; + let packages = data + .packages + .iter() + .filter(|pkg| { + pkg.tags.contains_key("extension") + && pkg.tags.get("server_slot").map(|s| s.as_str()) == Some(slot) + }) + .filter_map(|p| filter_package(pkg_root, p, PackageType::Zip)) .collect(); Ok(packages) } @@ -423,7 +504,7 @@ pub async fn download( impl fmt::Display for PackageInfo { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "edgdb-server@{}", self.version) + write!(f, "{}@{}", self.name, self.version) } } @@ -758,6 +839,12 @@ impl Channel { } } +impl IntoArg for &Channel { + fn add_arg(self, process: &mut crate::process::Native) { + process.arg(self.as_str()); + } +} + impl fmt::Display for QueryDisplay<'_> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use ver::FilterMinor::*; diff --git a/src/portable/ver.rs b/src/portable/ver.rs index ebdd5397c..7ba22878b 100644 --- a/src/portable/ver.rs +++ b/src/portable/ver.rs @@ -50,7 +50,8 @@ pub enum FilterMinor { } static BUILD: Lazy = Lazy::new(|| { - Regex::new(r#"^\d+\.\d+(?:-(?:alpha|beta|rc|dev)\.\d+)?\+(?:[a-f0-9]{7}|local)$"#).unwrap() + Regex::new(r#"^\d+\.\d+(?:\.\d+)?(?:-(?:alpha|beta|rc|dev)\.\d+)?\+(?:[a-f0-9]{7}|local)$"#) + .unwrap() }); static SPECIFIC: Lazy = Lazy::new(|| { @@ -212,6 +213,16 @@ impl Specific { pub fn is_stable(&self) -> bool { matches!(self.minor, MinorVersion::Minor(_)) } + + pub fn slot(&self) -> String { + match self.minor { + MinorVersion::Minor(_) => self.major.to_string(), + MinorVersion::Dev(v) => format!("{}-dev{}", self.major, v), + MinorVersion::Alpha(v) => format!("{}-alpha{}", self.major, v), + MinorVersion::Beta(v) => format!("{}-beta{}", self.major, v), + MinorVersion::Rc(v) => format!("{}-rc{}", self.major, v), + } + } } impl Filter {