Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion helix-cli/src/commands/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::prompts;
use eyre::{Result, eyre};

pub async fn run(target: Option<AddTarget>) -> Result<()> {
let mut project = ProjectContext::find_and_load(None)?;
let mut project = ProjectContext::find_and_load_allow_no_instances(None)?;
let config_path = project.root.join("helix.toml");
let target = match target {
Some(target) => target,
Expand Down
110 changes: 108 additions & 2 deletions helix-cli/src/commands/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::commands::auth::require_auth;
use crate::config::WorkspaceConfig;
use crate::enterprise_cloud::{
CliEnterpriseCluster, cloud_base_url, fetch_projects, fetch_workspaces, find_project_by_id,
CliClusterIndex, CliClusterIndexes, CliEnterpriseCluster, cloud_base_url,
fetch_indexes_for_cluster, fetch_projects, fetch_workspaces, find_project_by_id,
find_project_by_name, find_workspace_by_id, find_workspace_by_slug, list_clusters_for_context,
resolve_enterprise_cluster,
};
Expand Down Expand Up @@ -63,8 +64,13 @@ pub async fn run_cluster(action: Option<ClusterConfigAction>) -> Result<()> {
project_id,
format,
}) => cluster_list(workspace_id, project_id, format).await,
Some(ClusterConfigAction::Indexes { cluster_id, format }) => {
list_indexes_for_cluster(cluster_id, format).await
}
None if prompts::is_interactive() => cluster_select().await,
None => Err(eyre!("Specify a cluster command: 'helix cluster list'")),
None => Err(eyre!(
"Specify a cluster command: 'helix cluster list' or 'helix cluster indexes'"
)),
}
}

Expand Down Expand Up @@ -320,6 +326,106 @@ async fn cluster_list(
Ok(())
}

async fn list_indexes_for_cluster(
cluster_id: Option<String>,
format: ConfigOutputFormat,
) -> Result<()> {
let cluster_id = resolve_cluster_id_for_indexes(cluster_id)?;
let credentials = require_auth().await?;
let client = reqwest::Client::new();
let indexes = fetch_indexes_for_cluster(
&client,
&cloud_base_url(),
&credentials.helix_admin_key,
cluster_id.as_str(),
)
.await?;

if format == ConfigOutputFormat::Json {
return print_json(&indexes);
}

print_cluster_indexes(&cluster_id, &indexes);
Ok(())
}

fn resolve_cluster_id_for_indexes(cluster_id: Option<String>) -> Result<String> {
if let Some(cluster_id) = cluster_id {
let cluster_id = cluster_id.trim();
if !cluster_id.is_empty() {
return Ok(cluster_id.to_string());
}
}

let project = ProjectContext::find_and_load(None).map_err(|_| {
eyre!("Provide --cluster-id, or run inside a Helix project with an Enterprise instance.")
Comment thread
xav-db marked this conversation as resolved.
Outdated
})?;

let mut enterprise_instances = project
.config
.enterprise
.keys()
.map(|name| (name.clone(), "Enterprise".to_string()))
.collect::<Vec<_>>();
enterprise_instances.sort_by(|a, b| a.0.cmp(&b.0));

let instance_name = match enterprise_instances.len() {
0 => return Err(eyre!("No Enterprise instances found in helix.toml")),
1 => enterprise_instances[0].0.clone(),
_ if prompts::is_interactive() => prompts::select_instance(
&enterprise_instances,
"List indexes for which Enterprise instance?",
)?,
_ => {
let available = enterprise_instances
.into_iter()
.map(|(name, _)| name)
.collect::<Vec<_>>()
.join(", ");
return Err(eyre!(
"No Enterprise instance specified. Available Enterprise instances: {available}. Pass --cluster-id to select one."
));
}
};

project
.config
.enterprise
.get(&instance_name)
.map(|config| config.cluster_id.clone())
.ok_or_else(|| eyre!("Enterprise instance '{instance_name}' was not found"))
}

fn print_cluster_indexes(cluster_id: &str, indexes: &CliClusterIndexes) {
println!("{}", "Cluster indexes".bold());
println!(" Cluster: {cluster_id}");
print_index_group("Vector indexes", &indexes.vector_indexes);
print_index_group("Equality indexes", &indexes.equality_indexes);
print_index_group("Range indexes", &indexes.range_indexes);
}

fn print_index_group(title: &str, indexes: &[CliClusterIndex]) {
println!(" {title}:");
if indexes.is_empty() {
println!(" (none)");
return;
}

for index in indexes {
let name = if index.index_name.trim().is_empty() {
"<unnamed>"
} else {
index.index_name.as_str()
};
match index.index_type.as_deref() {
Some(index_type) if !index_type.trim().is_empty() => {
println!(" {name} ({index_type})");
}
_ => println!(" {name}"),
}
}
}

fn print_enterprise_clusters(clusters: &[CliEnterpriseCluster]) {
println!("{}", "Enterprise clusters".bold());
for cluster in clusters {
Expand Down
72 changes: 69 additions & 3 deletions helix-cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,17 @@ impl InstanceInfo<'_> {

impl HelixConfig {
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
Self::from_file_inner(path, true)
}

/// Like [`from_file`](Self::from_file), but tolerates a `helix.toml` that defines zero
/// instances. Used by `helix add`, whose whole job is to add the first instance back —
/// it would otherwise be locked out by the "at least one instance" check.
pub fn from_file_allow_no_instances(path: &Path) -> Result<Self, ConfigError> {
Self::from_file_inner(path, false)
}

fn from_file_inner(path: &Path, require_instances: bool) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path).map_err(|source| ConfigError::ReadHelixConfig {
path: path.to_path_buf(),
source,
Expand All @@ -353,7 +364,7 @@ impl HelixConfig {
source,
})?;

config.validate(path)?;
config.validate(path, require_instances)?;
Ok(config)
}

Expand All @@ -367,7 +378,7 @@ impl HelixConfig {
Ok(())
}

fn validate(&self, path: &Path) -> Result<(), ConfigError> {
fn validate(&self, path: &Path, require_instances: bool) -> Result<(), ConfigError> {
let relative_path = std::env::current_dir()
.ok()
.and_then(|cwd| path.strip_prefix(&cwd).ok())
Expand All @@ -380,7 +391,7 @@ impl HelixConfig {
});
}

if self.local.is_empty() && self.enterprise.is_empty() {
if require_instances && self.local.is_empty() && self.enterprise.is_empty() {
return Err(ConfigError::MissingInstances {
path: relative_path,
});
Expand Down Expand Up @@ -509,6 +520,61 @@ tag = "latest"
assert_eq!(local.storage, LocalStorageMode::Memory);
}

#[test]
fn zero_instance_config_rejected_by_default_but_allowed_leniently() {
let config: HelixConfig = toml::from_str(
r#"
[project]
name = "demo"
"#,
)
.expect("config with no instances should still deserialize");

let path = Path::new("helix.toml");
// Default validation (used by every command except `add`) rejects it.
assert!(matches!(
config.validate(path, true),
Err(ConfigError::MissingInstances { .. })
));
// Lenient validation (used by `helix add`) accepts it so the first
// instance can be re-added after the last one was deleted.
assert!(config.validate(path, false).is_ok());
}

#[test]
fn lenient_validation_still_enforces_other_checks() {
let path = Path::new("helix.toml");

// Empty project name is rejected even leniently.
let empty_name: HelixConfig = toml::from_str(
r#"
[project]
name = " "
"#,
)
.unwrap();
assert!(matches!(
empty_name.validate(path, false),
Err(ConfigError::EmptyProjectName { .. })
));

// Enterprise instance without a cluster_id is rejected even leniently.
let no_cluster: HelixConfig = toml::from_str(
r#"
[project]
name = "demo"

[enterprise.production]
cluster_id = ""
"#,
)
.unwrap();
assert!(matches!(
no_cluster.validate(path, false),
Err(ConfigError::MissingClusterId { .. })
));
}

#[test]
fn local_config_can_use_disk_storage() {
let config: HelixConfig = toml::from_str(
Expand Down
36 changes: 36 additions & 0 deletions helix-cli/src/enterprise_cloud.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use eyre::{Result, eyre};
use reqwest::Client;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::collections::BTreeMap;
use std::sync::LazyLock;

const DEFAULT_CLOUD_AUTHORITY: &str = "cloud.helix-db.com";
Expand Down Expand Up @@ -62,6 +63,26 @@ pub struct CliWorkspaceClusters {
pub enterprise: Vec<CliEnterpriseCluster>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliClusterIndex {
#[serde(default, alias = "name")]
pub index_name: String,
#[serde(default, alias = "type")]
pub index_type: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
Comment thread
xav-db marked this conversation as resolved.

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliClusterIndexes {
#[serde(default)]
pub vector_indexes: Vec<CliClusterIndex>,
#[serde(default)]
pub equality_indexes: Vec<CliClusterIndex>,
#[serde(default)]
pub range_indexes: Vec<CliClusterIndex>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliEnterpriseCluster {
pub cluster_id: String,
Expand Down Expand Up @@ -258,6 +279,21 @@ pub async fn fetch_workspace_clusters(
.await
}

pub async fn fetch_indexes_for_cluster(
client: &Client,
base_url: &str,
api_key: &str,
cluster_id: &str,
) -> Result<CliClusterIndexes> {
get_json(
client,
format!("{base_url}/api/cli/enterprise-clusters/{cluster_id}/indexes"),
api_key,
"fetch cluster indexes",
)
.await
}

pub async fn fetch_enterprise_cluster_project(
client: &Client,
base_url: &str,
Expand Down
3 changes: 2 additions & 1 deletion helix-cli/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@ impl ConfigError {
ConfigError::MissingInstances { path } => CliError::new(format!(
"at least one instance must be defined in {}",
path.display()
)),
))
.with_hint("add one with `helix add local --name dev` (or `helix add enterprise`)"),
ConfigError::EmptyInstanceName { path } => CliError::new(format!(
"instance name cannot be empty in {}",
path.display()
Expand Down
10 changes: 10 additions & 0 deletions helix-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,14 @@ pub enum ClusterConfigAction {
#[arg(long, value_enum, default_value_t = ConfigOutputFormat::Human)]
format: ConfigOutputFormat,
},

/// List indexes in an Enterprise cluster
#[command(alias = "indices")]
Indexes {
/// Enterprise cluster ID; defaults to the current project's Enterprise instance
#[arg(long, value_name = "CLUSTER_ID")]
cluster_id: Option<String>,
#[arg(long, value_enum, default_value_t = ConfigOutputFormat::Human)]
format: ConfigOutputFormat,
},
}
16 changes: 16 additions & 0 deletions helix-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,22 @@ mod tests {
}
}

#[test]
fn root_cluster_indexes_command_parses() {
let cli = Cli::parse_from(["helix", "cluster", "indexes", "--cluster-id", "ent_123"]);

match cli.command {
Some(Commands::Cluster {
action:
Some(ClusterConfigAction::Indexes {
cluster_id,
format: _,
}),
}) => assert_eq!(cluster_id.as_deref(), Some("ent_123")),
_ => panic!("expected cluster indexes command"),
}
}

#[test]
fn status_accepts_optional_instance() {
let cli = Cli::parse_from(["helix", "status", "qa"]);
Expand Down
19 changes: 18 additions & 1 deletion helix-cli/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,31 @@ pub struct ProjectContext {

impl ProjectContext {
pub fn find_and_load(start_dir: Option<&Path>) -> Result<Self, ProjectError> {
Self::load_with(start_dir, true)
}

/// Like [`find_and_load`](Self::find_and_load), but tolerates a `helix.toml` that defines
/// zero instances. Used by `helix add` so it can re-add the first instance after the last
/// one was deleted.
pub fn find_and_load_allow_no_instances(
start_dir: Option<&Path>,
) -> Result<Self, ProjectError> {
Self::load_with(start_dir, false)
}

fn load_with(start_dir: Option<&Path>, require_instances: bool) -> Result<Self, ProjectError> {
let start = match start_dir {
Some(dir) => dir.to_path_buf(),
None => env::current_dir().map_err(|source| ProjectError::CurrentDir { source })?,
};

let root = find_project_root(&start)?;
let config_path = root.join("helix.toml");
let config = HelixConfig::from_file(&config_path)?;
let config = if require_instances {
HelixConfig::from_file(&config_path)?
} else {
HelixConfig::from_file_allow_no_instances(&config_path)?
};
let helix_dir = root.join(".helix");

Ok(Self {
Expand Down
Loading