use serde::{de::DeserializeOwned, Serialize};
use std::time::Duration;
use ureq::{Agent, Response};
use crate::config::ServiceConfig;
use crate::error::Result;
use crate::output::new_spinner;
#[derive(Debug, serde::Deserialize)]
pub struct Workspace {
pub id: String,
pub name: String,
pub email: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub environment: String,
#[allow(dead_code)]
pub fk_workspace: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct ApiEnvVar {
pub key: String,
pub value: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct Service {
pub id: String,
pub name: String,
pub deploy_type: String,
pub runtime: String,
pub external_sd_url: Option<String>,
#[allow(dead_code)]
pub internal_sd_url: Option<String>,
pub repository_url: Option<String>,
pub repository_branch: Option<String>,
pub registry_url: Option<String>,
pub registry_repository_url: Option<String>,
pub fk_service_secret: Option<String>,
pub root_path: Option<String>,
pub build_path: Option<String>,
pub build_command: Option<String>,
pub pre_deploy_command: Option<String>,
pub run_command: Option<String>,
pub fk_region: Option<String>,
pub fk_pod: Option<String>,
pub fk_project: Option<String>,
pub fk_workspace: Option<String>,
pub health_check_path: Option<String>,
pub maintenance_mode: Option<bool>,
pub active: Option<bool>,
pub env: Option<Vec<ApiEnvVar>>,
pub deploy_tag: Option<String>,
#[allow(dead_code)]
pub created_at: Option<String>,
#[allow(dead_code)]
pub updated_at: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PrometheusResult {
pub values: Vec<(f64, String)>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PrometheusData {
pub result: Vec<PrometheusResult>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PrometheusResponse {
pub data: PrometheusData,
}
#[derive(Debug, serde::Deserialize)]
pub struct LogLine {
pub timestamp: String,
pub message: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct LogsResponse {
pub logs: Vec<LogLine>,
}
#[derive(Debug, serde::Deserialize)]
pub struct NetworkMetricsResponse {
pub download: PrometheusResponse,
pub upload: PrometheusResponse,
}
#[derive(Debug, serde::Deserialize)]
pub struct Job {
#[allow(dead_code)]
pub id: String,
#[allow(dead_code)]
pub fk_service: String,
#[serde(rename = "type")]
pub job_type: String,
pub status: String,
pub deploy_ref: Option<String>,
pub created_at: Option<String>,
#[allow(dead_code)]
pub updated_at: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PaginatedJobs {
pub data: Vec<Job>,
#[allow(dead_code)]
pub total: usize,
}
#[derive(Debug, serde::Deserialize)]
pub struct Pod {
pub id: String,
pub name: String,
pub label: Option<String>,
pub cpu: Option<String>,
pub ram: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Region {
pub id: String,
pub name: String,
pub label: Option<String>,
pub country_code: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct WorkspaceSecret {
pub id: String,
pub name: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct HealthCheckResult {
pub status: Option<u16>,
pub ok: bool,
#[allow(dead_code)]
pub error: bool,
pub attempts: u32,
}
#[derive(Debug, serde::Serialize)]
struct CreateProjectBody<'a> {
name: &'a str,
environment: &'a str,
fk_workspace: &'a str,
}
#[derive(Debug, serde::Serialize)]
struct ServiceBody<'a> {
#[serde(flatten)]
service: &'a ServiceConfig,
fk_project: &'a str,
fk_workspace: &'a str,
}
pub struct ApiClient {
agent: Agent,
base_url: String,
api_key: String,
}
impl ApiClient {
pub fn new() -> Result<Self> {
let api_key = crate::modules::auth::read_key()
.ok_or("No API key found.\n Run 'partiri auth' to configure your key.")?;
let base_url = std::env::var("PARTIRI_API_URL")
.unwrap_or_else(|_| "https://api.partiri.cloud".to_string());
if !base_url.starts_with("https://") {
return Err(format!("PARTIRI_API_URL must use HTTPS. Got: {base_url}").into());
}
let timeout_secs: u64 = std::env::var("PARTIRI_TIMEOUT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(timeout_secs))
.build();
Ok(ApiClient {
agent,
base_url,
api_key,
})
}
fn send_with_retry<F>(&self, build: F) -> Result<Response>
where
F: Fn() -> std::result::Result<Response, Box<ureq::Error>>,
{
const MAX_RETRIES: u32 = 3;
for attempt in 0..=MAX_RETRIES {
let response = match build() {
Ok(resp) => resp,
Err(err) => match *err {
ureq::Error::Status(_, resp) => resp,
ureq::Error::Transport(t) => {
return Err(format!("Network request failed: {t}").into());
}
},
};
if response.status() != 429 || attempt == MAX_RETRIES {
return Ok(response);
}
let wait = response
.header("retry-after")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1 << attempt);
std::thread::sleep(Duration::from_secs(wait));
}
unreachable!()
}
fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.agent
.get(&format!("{}{}", self.base_url, path))
.set("x-api-key", &self.api_key)
.call()
.map_err(Box::new)
})?;
spinner.finish_and_clear();
self.handle_response(response)
}
fn get_query<T: DeserializeOwned>(&self, path: &str, params: &[(&str, &str)]) -> Result<T> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
let mut req = self
.agent
.get(&format!("{}{}", self.base_url, path))
.set("x-api-key", &self.api_key);
for (k, v) in params {
req = req.query(k, v);
}
req.call().map_err(Box::new)
})?;
spinner.finish_and_clear();
self.handle_response(response)
}
fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.agent
.post(&format!("{}{}", self.base_url, path))
.set("x-api-key", &self.api_key)
.send_json(body)
.map_err(Box::new)
})?;
spinner.finish_and_clear();
self.handle_response(response)
}
fn post_empty<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.agent
.post(&format!("{}{}", self.base_url, path))
.set("x-api-key", &self.api_key)
.send_json(body)
.map_err(Box::new)
})?;
spinner.finish_and_clear();
self.handle_response_empty(response)
}
fn put_empty<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.agent
.put(&format!("{}{}", self.base_url, path))
.set("x-api-key", &self.api_key)
.send_json(body)
.map_err(Box::new)
})?;
spinner.finish_and_clear();
self.handle_response_empty(response)
}
fn error_message(response: Response) -> crate::error::Error {
let status = response.status();
let body = response.into_string().unwrap_or_default();
let msg = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v["message"].as_str().map(String::from))
.unwrap_or_else(|| "An unexpected error occurred.".to_string());
let hint = match status {
400 => Some("Check that your configuration values are valid."),
401 => Some("Run 'partiri auth' to update your API key."),
402 => Some("Your workspace balance is insufficient. Top up at https://partiri.cloud/settings/billing"),
403 => Some("Your account may lack permission, or a workspace limit has been reached."),
404 => Some("The resource was not found. It may have been deleted."),
409 => Some("A conflicting operation is in progress. Wait for it to finish, then retry."),
422 => Some("The request data is invalid. Check your configuration values."),
429 => Some("Rate limit exceeded. Please wait a moment and try again."),
500..=599 => Some("This is a server-side error. Try again later, or contact support."),
_ => None,
};
let mut err = crate::error::CliError::new(status.to_string(), msg);
if let Some(h) = hint {
err = err.with_hint(h);
}
Box::new(err.enriched())
}
fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
let status = response.status();
if (200..300).contains(&status) {
let body = response
.into_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
serde_json::from_str::<T>(&body).map_err(|e| {
let preview = if body.len() > 200 {
&body[..200]
} else {
&body
};
format!("Failed to parse API response: {e}\n Body: {preview}").into()
})
} else {
Err(Self::error_message(response))
}
}
fn handle_response_empty(&self, response: Response) -> Result<()> {
if (200..300).contains(&response.status()) {
Ok(())
} else {
Err(Self::error_message(response))
}
}
pub fn list_workspaces(&self) -> Result<Vec<Workspace>> {
self.get("/workspaces")
}
pub fn list_projects(&self, workspace_id: &str) -> Result<Vec<Project>> {
self.get_query("/projects", &[("workspace", workspace_id)])
}
pub fn create_project(&self, name: &str, environment: &str, workspace_id: &str) -> Result<()> {
let body = CreateProjectBody {
name,
environment,
fk_workspace: workspace_id,
};
self.post_empty("/projects", &body)
}
pub fn list_regions(&self, workspace_id: &str) -> Result<Vec<Region>> {
self.get_query("/resources/regions", &[("workspace", workspace_id)])
}
pub fn list_pods(&self, workspace_id: &str) -> Result<Vec<Pod>> {
self.get_query("/resources/pods", &[("workspace", workspace_id)])
}
pub fn list_services(&self, project_id: &str) -> Result<Vec<Service>> {
self.get_query("/services", &[("project", project_id)])
}
pub fn list_registry_secrets(&self, workspace_id: &str) -> Result<Vec<WorkspaceSecret>> {
self.get(&format!("/workspaces/secrets/registry/{}", workspace_id))
}
pub fn list_repository_secrets(&self, workspace_id: &str) -> Result<Vec<WorkspaceSecret>> {
self.get(&format!("/workspaces/secrets/repository/{}", workspace_id))
}
pub fn validate_registry(&self, registry_url: &str, secret_id: Option<&str>) -> Result<bool> {
let mut params: Vec<(&str, &str)> = vec![("registry_url", registry_url)];
if let Some(id) = secret_id {
params.push(("id", id));
}
self.get_query("/resources/utils/reg", ¶ms)
}
pub fn load_repository_branches(
&self,
url: &str,
secret_id: Option<&str>,
) -> Result<Vec<String>> {
let mut params: Vec<(&str, &str)> = vec![("url", url)];
if let Some(id) = secret_id {
params.push(("id", id));
}
self.get_query("/resources/utils/git", ¶ms)
}
pub fn probe_health_check(
&self,
workspace_id: &str,
health_check_path: &str,
) -> Result<HealthCheckResult> {
self.get_query(
"/resources/utils/health-check",
&[
("workspace", workspace_id),
("health_check_path", health_check_path),
],
)
}
pub fn create_service(
&self,
service: &ServiceConfig,
project_id: &str,
workspace_id: &str,
) -> Result<Service> {
let body = ServiceBody {
service,
fk_project: project_id,
fk_workspace: workspace_id,
};
self.post("/services", &body)
}
pub fn read_service(&self, id: &str) -> Result<Service> {
self.get(&format!("/services/{}", id))
}
pub fn read_metrics_cpu(
&self,
id: &str,
deploy_tag: Option<&str>,
) -> Result<PrometheusResponse> {
let path = format!("/metrics/cpu/{}", id);
match deploy_tag {
Some(tag) => self.get_query(&path, &[("deployTag", tag)]),
None => self.get(&path),
}
}
pub fn read_metrics_memory(
&self,
id: &str,
deploy_tag: Option<&str>,
) -> Result<PrometheusResponse> {
let path = format!("/metrics/memory/{}", id);
match deploy_tag {
Some(tag) => self.get_query(&path, &[("deployTag", tag)]),
None => self.get(&path),
}
}
pub fn read_metrics_network(
&self,
id: &str,
deploy_tag: Option<&str>,
) -> Result<NetworkMetricsResponse> {
let path = format!("/metrics/network/{}", id);
match deploy_tag {
Some(tag) => self.get_query(&path, &[("deployTag", tag)]),
None => self.get(&path),
}
}
pub fn read_service_logs(&self, id: &str, deploy_tag: Option<&str>) -> Result<LogsResponse> {
let path = format!("/logs/{}", id);
match deploy_tag {
Some(tag) => self.get_query(&path, &[("deployTag", tag)]),
None => self.get(&path),
}
}
pub fn update_service(&self, id: &str, service: &ServiceConfig) -> Result<()> {
self.put_empty(&format!("/services/{}", id), service)
}
pub fn list_service_jobs(&self, id: &str) -> Result<Vec<Job>> {
let resp: PaginatedJobs = self.get(&format!("/jobs/services/{}", id))?;
Ok(resp.data)
}
pub fn deploy_service(&self, id: &str) -> Result<()> {
self.post_empty(
&format!("/jobs/services/deploy/{}", id),
&serde_json::json!({}),
)
}
pub fn pause_service(&self, id: &str) -> Result<()> {
self.post_empty(
&format!("/jobs/services/pause/{}", id),
&serde_json::json!({}),
)
}
pub fn unpause_service(&self, id: &str) -> Result<()> {
self.post_empty(
&format!("/jobs/services/unpause/{}", id),
&serde_json::json!({}),
)
}
pub fn kill_service(&self, id: &str) -> Result<()> {
self.post_empty(
&format!("/jobs/services/kill/{}", id),
&serde_json::json!({}),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::prelude::*;
use serde_json::json;
fn test_client(server: &MockServer) -> ApiClient {
ApiClient {
agent: ureq::AgentBuilder::new()
.timeout(Duration::from_secs(5))
.build(),
base_url: server.base_url(),
api_key: "test-api-key-123".to_string(),
}
}
#[test]
fn list_workspaces_success_parses_response() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/workspaces")
.header("x-api-key", "test-api-key-123");
then.status(200).json_body(json!([
{"id": "ws-1", "name": "My Workspace", "email": "user@example.com"}
]));
});
let client = test_client(&server);
let result = client.list_workspaces().unwrap();
mock.assert();
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "ws-1");
assert_eq!(result[0].name, "My Workspace");
assert_eq!(result[0].email.as_deref(), Some("user@example.com"));
}
#[test]
fn list_workspaces_with_null_email_parses_as_none() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(200).json_body(json!([
{"id": "ws-1", "name": "No-Email Workspace", "email": null}
]));
});
let client = test_client(&server);
let result = client.list_workspaces().unwrap();
assert!(result[0].email.is_none());
}
#[test]
fn list_workspaces_empty_array_returns_empty_vec() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(200).json_body(json!([]));
});
let result = test_client(&server).list_workspaces().unwrap();
assert!(result.is_empty());
}
#[test]
fn status_401_includes_partiri_key_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(401)
.json_body(json!({"message": "Unauthorized"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("401"), "should mention 401: {err}");
assert!(
err.contains("partiri auth"),
"should include auth hint: {err}"
);
}
#[test]
fn status_403_includes_permission_or_limit_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(403).json_body(json!({"message": "Forbidden"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("403"), "{err}");
assert!(
err.contains("permission") && err.contains("limit"),
"should include permission and limit hint: {err}"
);
}
#[test]
fn status_404_includes_not_found_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(404).body("not found");
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("404"), "{err}");
assert!(
err.to_lowercase().contains("not found") || err.contains("resource"),
"should include not-found hint: {err}"
);
}
#[test]
fn status_402_includes_balance_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/jobs/services/deploy/svc-1");
then.status(402)
.json_body(json!({"message": "Insufficient balance to deploy this service"}));
});
let err = test_client(&server)
.deploy_service("svc-1")
.unwrap_err()
.to_string();
assert!(err.contains("402"), "should mention 402: {err}");
assert!(
err.contains("balance"),
"should include balance hint: {err}"
);
}
#[test]
fn status_409_includes_conflict_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/jobs/services/unpause/svc-1");
then.status(409)
.json_body(json!({"message": "Active job already exists for this service"}));
});
let err = test_client(&server)
.unpause_service("svc-1")
.unwrap_err()
.to_string();
assert!(err.contains("409"), "should mention 409: {err}");
assert!(
err.contains("conflicting operation"),
"should include conflict hint: {err}"
);
}
#[test]
fn status_400_includes_validation_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(400)
.json_body(json!({"message": "Invalid health check URL"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("400"), "should mention 400: {err}");
assert!(
err.contains("configuration values"),
"should include validation hint: {err}"
);
}
#[test]
fn status_429_includes_rate_limit_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(429)
.json_body(json!({"message": "Too many requests"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("429"), "should mention 429: {err}");
assert!(
err.contains("Rate limit"),
"should include rate limit hint: {err}"
);
}
#[test]
fn status_500_includes_server_error_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(500)
.json_body(json!({"message": "Internal server error"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("500"), "should mention 500: {err}");
assert!(
err.contains("server-side error"),
"should include server error hint: {err}"
);
}
#[test]
fn json_message_field_extracted_from_error_body() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(400)
.json_body(json!({"message": "Workspace limit exceeded"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(
err.contains("Workspace limit exceeded"),
"should extract message field: {err}"
);
}
#[test]
fn non_json_error_body_returned_raw() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(503).body("<html>Service Unavailable</html>");
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(
err.contains("Service Unavailable") || err.contains("503"),
"should include raw body or status: {err}"
);
}
#[test]
fn api_key_sent_as_x_api_key_header() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/workspaces")
.header("x-api-key", "test-api-key-123");
then.status(200).json_body(json!([]));
});
test_client(&server).list_workspaces().unwrap();
mock.assert();
}
#[test]
fn list_projects_includes_workspace_query_param() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/projects").query_param("workspace", "ws-uuid-123");
then.status(200).json_body(json!([
{"id": "proj-1", "name": "My Project", "environment": "production", "fk_workspace": "ws-uuid-123"}
]));
});
let client = test_client(&server);
let result = client.list_projects("ws-uuid-123").unwrap();
mock.assert();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "My Project");
}
#[test]
fn list_service_jobs_parses_paginated_response() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/jobs/services/svc-42");
then.status(200).json_body(json!({
"data": [
{
"id": "job-1",
"fk_service": "svc-42",
"type": "deploy",
"status": "succeeded",
"cluster": "us-east-1",
"deploy_ref": "abc1234def5678",
"notes": [],
"created_at": "2025-03-18T10:00:00Z",
"updated_at": "2025-03-18T10:05:00Z"
},
{
"id": "job-2",
"fk_service": "svc-42",
"type": "kill",
"status": "open",
"cluster": "any",
"deploy_ref": null,
"notes": null,
"created_at": "2025-03-18T11:00:00Z",
"updated_at": null
}
],
"total": 2
}));
});
let client = test_client(&server);
let jobs = client.list_service_jobs("svc-42").unwrap();
mock.assert();
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0].id, "job-1");
assert_eq!(jobs[0].job_type, "deploy");
assert_eq!(jobs[0].status, "succeeded");
assert_eq!(jobs[0].deploy_ref.as_deref(), Some("abc1234def5678"));
assert_eq!(jobs[1].job_type, "kill");
assert!(jobs[1].deploy_ref.is_none());
}
#[test]
fn read_service_logs_with_deploy_tag_sends_query_param() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/logs/svc-123")
.query_param("deployTag", "ab12c");
then.status(200).json_body(json!({ "logs": [] }));
});
let result = test_client(&server)
.read_service_logs("svc-123", Some("ab12c"))
.unwrap();
mock.assert();
assert!(result.logs.is_empty());
}
#[test]
fn read_service_logs_without_deploy_tag_omits_query_param() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/logs/svc-123");
then.status(200).json_body(json!({ "logs": [] }));
});
test_client(&server)
.read_service_logs("svc-123", None)
.unwrap();
mock.assert();
}
#[test]
fn read_metrics_cpu_with_deploy_tag_sends_query_param() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/metrics/cpu/svc-123")
.query_param("deployTag", "ab12c");
then.status(200)
.json_body(json!({ "data": { "result": [] } }));
});
test_client(&server)
.read_metrics_cpu("svc-123", Some("ab12c"))
.unwrap();
mock.assert();
}
#[test]
fn list_service_jobs_empty_data_returns_empty_vec() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/jobs/services/svc-99");
then.status(200).json_body(json!({
"data": [],
"total": 0
}));
});
let jobs = test_client(&server).list_service_jobs("svc-99").unwrap();
assert!(jobs.is_empty());
}
#[test]
fn new_succeeds_when_credentials_file_exists() {
let has_key = crate::modules::auth::credentials_path()
.and_then(|p| std::fs::read_to_string(&p).ok())
.filter(|s| !s.trim().is_empty())
.is_some();
if has_key {
assert!(ApiClient::new().is_ok(), "should succeed with key file");
}
}
}