Skip to content

Commit

Permalink
Extension installation commands (#1385)
Browse files Browse the repository at this point in the history
This PR adds two supported commands:

```
Manage local extensions

Usage: edgedb extension <COMMAND>

Commands:
  list-available  List available extensions for a local instance
  install         Install an extension for a local instance
  help            Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help
```
  • Loading branch information
mmastrac authored Oct 25, 2024
1 parent 75a7dfe commit 290579b
Show file tree
Hide file tree
Showing 11 changed files with 440 additions and 30 deletions.
4 changes: 4 additions & 0 deletions src/commands/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
2 changes: 1 addition & 1 deletion src/portable/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
171 changes: 171 additions & 0 deletions src/portable/extension.rs
Original file line number Diff line number Diff line change
@@ -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<InstanceName>) -> Result<InstanceInfo, anyhow::Error> {
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<impl AsRef<OsStr>>,
file: Option<impl AsRef<OsStr>>,
) -> 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(())
}
3 changes: 2 additions & 1 deletion src/portable/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ fn check_metadata(dir: &Path, pkg_info: &PackageInfo) -> anyhow::Result<InstallI
}

#[context("failed to download {}", pkg_info)]
fn download_package(pkg_info: &PackageInfo) -> anyhow::Result<PathBuf> {
pub fn download_package(pkg_info: &PackageInfo) -> anyhow::Result<PathBuf> {
let cache_dir = platform::cache_dir()?;
let download_dir = cache_dir.join("downloads");
fs::create_dir_all(&download_dir)?;
Expand Down Expand Up @@ -209,6 +209,7 @@ pub fn package(pkg_info: &PackageInfo) -> anyhow::Result<InstallInfo> {
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)
Expand Down
4 changes: 3 additions & 1 deletion src/portable/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
70 changes: 63 additions & 7 deletions src/portable/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PathBuf> {
Expand Down Expand Up @@ -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<Option<InstanceInfo>> {
if cfg!(windows) {
let data = match windows::get_instance_info(name) {
Expand Down Expand Up @@ -344,15 +345,35 @@ impl InstanceInfo {
data.name = name.into();
Ok(data)
}

pub fn data_dir(&self) -> anyhow::Result<PathBuf> {
instance_data_dir(&self.name)
}
pub fn server_path(&self) -> anyhow::Result<PathBuf> {

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<PathBuf> {
self.get_installation()?.server_path()
}

#[allow(unused)]
pub fn base_path(&self) -> anyhow::Result<PathBuf> {
self.get_installation()?.base_path()
}

#[allow(unused)]
pub fn extension_path(&self) -> anyhow::Result<PathBuf> {
self.get_installation()?.extension_path()
}

pub fn extension_loader_path(&self) -> anyhow::Result<PathBuf> {
self.get_installation()?.extension_loader_path()
}

pub fn admin_conn_params(&self) -> anyhow::Result<Builder> {
let mut builder = Builder::new();
builder.port(self.port)?;
Expand All @@ -372,9 +393,44 @@ impl InstallInfo {
pub fn base_path(&self) -> anyhow::Result<PathBuf> {
installation_path(&self.version.specific())
}

pub fn server_path(&self) -> anyhow::Result<PathBuf> {
Ok(self.base_path()?.join("bin").join("edgedb-server"))
}

pub fn extension_path(&self) -> anyhow::Result<PathBuf> {
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<PathBuf> {
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 {
Expand Down
2 changes: 2 additions & 0 deletions src/portable/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod control;
mod create;
mod credentials;
mod destroy;
mod extension;
mod info;
pub mod install;
mod link;
Expand All @@ -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;
Loading

0 comments on commit 290579b

Please sign in to comment.