clusterflux-public/crates/clusterflux-cli/src/project.rs
2026-07-21 18:07:02 +02:00

381 lines
14 KiB
Rust

use std::path::PathBuf;
use anyhow::Result;
use serde_json::{json, Value};
use crate::client::{
authenticated_or_local_trusted_request, list_attached_nodes_if_available_with_session,
list_task_events_if_available_with_session, stored_session_for_coordinator, JsonLineSession,
};
use crate::config::{
effective_project_scope, effective_scope_value, project_config_file, read_cli_session,
read_project_config, write_project_config, ProjectConfig, StoredCliSession,
};
use crate::process::process_list_report_with_session;
use crate::process_events::project_quota_posture;
use crate::{
bundle_inspection, discovered_environment_names, BundleInspectArgs, ProcessListArgs,
ProjectInitArgs, ProjectListArgs, ProjectSelectArgs, ProjectStatusArgs,
};
pub(crate) fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result<Value> {
let stored_session = read_cli_session(&cwd)?;
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let project = args.new_project.clone();
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.user,
|session| session.user.as_str(),
"user",
);
let coordinator = args.scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
let name = args.name.clone();
let config = ProjectConfig {
tenant: tenant.clone(),
project: project.clone(),
user: user.clone(),
coordinator: coordinator.clone(),
};
let config_file = project_config_file(&cwd);
if config_file.exists() && !args.yes {
anyhow::bail!(
"{} already exists; rerun with --yes to update the project link",
config_file.display()
);
}
let mut coordinator_session_requests = 0;
let coordinator_response = if let Some(coordinator) = &coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "create_project",
"project": project,
"name": name,
}),
json!({
"type": "create_project",
"tenant": tenant,
"actor_user": user,
"project": project,
"name": name,
}),
)?;
let response = session.request(request)?;
coordinator_session_requests = session.requests();
Some(response)
} else {
None
};
write_project_config(&cwd, &config)?;
let created_or_linked_project = coordinator_response
.as_ref()
.and_then(|response| response.get("project"))
.cloned()
.unwrap_or_else(|| {
json!({
"id": config.project.clone(),
"tenant": config.tenant.clone(),
"name": args.name.clone(),
})
});
Ok(json!({
"command": "project init",
"source": if coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" },
"private_website_required": false,
"project_config_written": true,
"project_config_write_after_coordinator_acceptance": coordinator.is_some(),
"coordinator_create_before_local_write": coordinator.is_some(),
"coordinator_session_requests": coordinator_session_requests,
"created_or_linked_project": created_or_linked_project,
"current_directory_link": {
"cwd": cwd,
"config_file": config_file,
"config_format": "clusterflux_project_config_v1",
"links_current_directory": true,
"writes_current_directory_only": true,
"private_website_required": false,
},
"safe_defaults": {
"tenant": config.tenant.clone(),
"project": config.project.clone(),
"user": config.user.clone(),
"coordinator": config.coordinator.clone(),
"project_name": args.name.clone(),
"default_project_id_used": args.new_project == "project",
"default_project_name_used": args.name == "Clusterflux Project",
"browser_interaction_required": false,
"private_website_required": false,
},
"project_config": config,
"config_file": project_config_file(&cwd),
"coordinator_response": coordinator_response,
}))
}
pub(crate) fn project_status_report(args: ProjectStatusArgs, cwd: PathBuf) -> Result<Value> {
let config = read_project_config(&cwd)?;
let stored_session = read_cli_session(&cwd)?;
let mut effective_scope = effective_project_scope(&args.scope, config.as_ref());
if effective_scope.coordinator.is_none() {
effective_scope.coordinator = stored_session
.as_ref()
.filter(|session| session.session_secret.is_some())
.map(|session| session.coordinator.clone());
}
if let (Some(config), Some(session)) = (config.as_ref(), stored_session.as_ref()) {
let same_coordinator = effective_scope
.coordinator
.as_deref()
.is_some_and(|coordinator| {
crate::client::control_endpoint_identity(coordinator).ok()
== crate::client::control_endpoint_identity(&session.coordinator).ok()
});
if same_coordinator
&& (session.tenant != config.tenant
|| session.project != config.project
|| session.user != config.user)
{
anyhow::bail!(
"stored CLI session is for {}/{}/{} but this workspace is configured for {}/{}/{}; run `clusterflux login --browser` from this workspace",
session.tenant,
session.project,
session.user,
config.tenant,
config.project,
config.user,
);
}
}
if let Some(bound_session) = effective_scope
.coordinator
.as_deref()
.and_then(|coordinator| {
stored_session_for_coordinator(coordinator, stored_session.as_ref())
})
{
effective_scope.tenant = bound_session.tenant.clone();
effective_scope.project = bound_session.project.clone();
effective_scope.user = bound_session.user.clone();
}
let inspection = bundle_inspection(
BundleInspectArgs {
project: Some(cwd.clone()),
source_provider: None,
disabled_source_providers: Vec::new(),
json: true,
},
cwd.clone(),
)
.ok();
let coordinator = effective_scope.coordinator.clone();
let attached_nodes = list_attached_nodes_if_available_with_session(
coordinator.as_deref(),
&effective_scope,
stored_session.as_ref(),
)?;
let coordinator_response = list_task_events_if_available_with_session(
coordinator.as_deref(),
&effective_scope,
None,
stored_session.as_ref(),
)?;
let process_report = process_list_report_with_session(
ProcessListArgs {
scope: effective_scope.clone(),
},
stored_session.as_ref(),
)?;
let discovered_environments = discovered_environment_names(inspection.as_ref());
let active_process = process_report
.get("processes")
.and_then(Value::as_array)
.and_then(|processes| processes.first())
.and_then(|process| process.get("process"))
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| {
if process_report.get("status").and_then(Value::as_str) == Some("ok") {
"none".to_owned()
} else {
"unknown_without_coordinator".to_owned()
}
});
let quota_posture = project_quota_posture(&attached_nodes, coordinator_response.as_ref());
Ok(json!({
"command": "project status",
"cwd": cwd,
"tenant": effective_scope.tenant,
"project": effective_scope.project,
"user": effective_scope.user,
"coordinator": coordinator,
"project_identity": {
"tenant": effective_scope.tenant,
"project": effective_scope.project,
"user": effective_scope.user,
"source": if config.is_some() { "project_config_with_cli_overrides" } else { "cli_scope" }
},
"project_config": config,
"bundle": inspection,
"discovered_environments": discovered_environments,
"active_process": active_process,
"processes": process_report.get("processes").cloned().unwrap_or_else(|| json!([])),
"attached_nodes": attached_nodes,
"quota_posture": quota_posture,
"coordinator_response": coordinator_response,
}))
}
pub(crate) fn project_list_report(args: ProjectListArgs, cwd: PathBuf) -> Result<Value> {
let stored_session = read_cli_session(&cwd)?;
let coordinator = args.scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.user,
|session| session.user.as_str(),
"user",
);
if let Some(coordinator) = &coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "list_projects",
}),
json!({
"type": "list_projects",
"tenant": tenant,
"actor_user": user,
}),
)?;
let response = session.request(request)?;
let projects = response
.get("projects")
.cloned()
.unwrap_or_else(|| json!([]));
let project_count = projects.as_array().map(Vec::len).unwrap_or(0);
return Ok(json!({
"command": "project list",
"source": "public_coordinator_api",
"coordinator": coordinator,
"tenant": tenant,
"user": user,
"projects": projects,
"project_count": project_count,
"private_website_required": false,
"response": response,
"coordinator_session_requests": session.requests(),
}));
}
let projects = read_project_config(&cwd)?.into_iter().collect::<Vec<_>>();
let project_count = projects.len();
Ok(json!({
"command": "project list",
"source": "local_project_config",
"projects": projects,
"project_count": project_count,
"private_website_required": false,
}))
}
pub(crate) fn project_select_report(args: ProjectSelectArgs, cwd: PathBuf) -> Result<Value> {
let stored_session = read_cli_session(&cwd)?;
let tenant = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.tenant,
|session| session.tenant.as_str(),
"tenant",
);
let user = session_or_effective_scope_value(
stored_session.as_ref(),
&args.scope.user,
|session| session.user.as_str(),
"user",
);
let coordinator = args.scope.coordinator.clone().or_else(|| {
stored_session
.as_ref()
.map(|session| session.coordinator.clone())
});
let config = ProjectConfig {
tenant: tenant.clone(),
project: args.selected_project.clone(),
user: user.clone(),
coordinator: coordinator.clone(),
};
let coordinator_response = if let Some(coordinator) = &coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let request = authenticated_or_local_trusted_request(
coordinator,
stored_session.as_ref(),
json!({
"type": "select_project",
"project": args.selected_project,
}),
json!({
"type": "select_project",
"tenant": tenant,
"actor_user": user,
"project": args.selected_project,
}),
)?;
Some(session.request(request)?)
} else {
None
};
write_project_config(&cwd, &config)?;
let selected_project = coordinator_response
.as_ref()
.and_then(|response| response.get("project"))
.cloned()
.unwrap_or_else(|| {
json!({
"id": config.project.clone(),
"tenant": config.tenant.clone(),
"name": config.project.clone(),
})
});
Ok(json!({
"command": "project select",
"source": if coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" },
"selected_project": selected_project,
"project_config_written": true,
"private_website_required": false,
"project_config": config,
"coordinator_response": coordinator_response,
}))
}
fn session_or_effective_scope_value(
stored_session: Option<&StoredCliSession>,
cli_value: &str,
session_value: impl FnOnce(&StoredCliSession) -> &str,
default_value: &str,
) -> String {
if let Some(session) = stored_session.filter(|session| session.session_secret.is_some()) {
session_value(session).to_owned()
} else {
effective_scope_value(cli_value, stored_session.map(session_value), default_value)
}
}