use anyhow::{Context, Result, bail};
use reqwest::blocking::Response;
use serde::Deserialize;
use serde_json::Value;
use super::types::ManagedInstance;
use crate::{
constants::{ERROR_FAILED_TO_SEND_REQUEST, get_platform_management_api_url},
core::{
hmac::AuthMode,
http_client::{delete, get_with_auth, patch, post_unauthenticated, post_with_auth, put},
validate::resolve_auth,
},
};
const MANAGED_MODE_BASE: &str = "/managed-mode";
pub(super) const UNSUPPORTED_CONTROL_PLANE: &str = "this control plane does not support managed apps yet — upgrade the platform \
(the /managed-mode API this CLI needs is not mounted on the host it is pointed at)";
pub(super) fn managed_url(path: &str) -> String {
format!(
"{}{}{}",
get_platform_management_api_url(),
MANAGED_MODE_BASE,
path
)
}
pub(super) fn resolve_managed_auth() -> Result<AuthMode> {
let auth_mode = resolve_auth()?;
if auth_mode.is_hmac() {
bail!(
"managed commands require user/session auth — run `forklaunch login` first. \
HMAC (CI) credentials carry no organization identity, so the control plane \
cannot tell which organization's templates and instances to act on."
);
}
Ok(auth_mode)
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ManagedModeSummary {
#[serde(default)]
pub(super) available: Option<bool>,
#[serde(default)]
pub(super) unavailable_reason: Option<String>,
#[serde(default)]
pub(super) instances: Vec<ManagedInstance>,
}
impl ManagedModeSummary {
fn is_available(&self) -> bool {
self.available.unwrap_or(true)
}
}
pub(super) fn require_managed_mode(auth_mode: &AuthMode) -> Result<ManagedModeSummary> {
let url = managed_url("/summary");
let response = get_with_auth(auth_mode, &url).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
let status = response.status();
if status.as_u16() == 404 {
bail!("{}", UNSUPPORTED_CONTROL_PLANE);
}
if status.as_u16() == 401 || status.as_u16() == 403 {
bail!(
"not authorized to use managed apps ({}) — run `forklaunch login`, and check that your organization has managed apps enabled",
status
);
}
if !status.is_success() {
bail!(
"control plane returned {} for {} — {}",
status,
url,
body_snippet(response)
);
}
let summary: ManagedModeSummary = response
.json()
.with_context(|| "Failed to parse the managed mode summary response")?;
if !summary.is_available() {
let reason = summary
.unavailable_reason
.as_deref()
.unwrap_or("managed apps is not configured for this deployment")
.to_string();
bail!(
"managed apps is not available on this control plane: {}",
reason
);
}
Ok(summary)
}
pub(super) enum Missing {
Endpoint,
Resource(String),
Custom(String),
}
pub(super) fn ensure_success(response: Response, missing: Missing) -> Result<Response> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
match status.as_u16() {
404 => {
let snippet = body_snippet(response);
if is_unrouted_404(&snippet) {
bail!("{}", UNSUPPORTED_CONTROL_PLANE);
}
match missing {
Missing::Endpoint => bail!("{}", UNSUPPORTED_CONTROL_PLANE),
Missing::Resource(what) => bail!("{} not found — {}", what, snippet),
Missing::Custom(message) => bail!("{}", message),
}
}
503 => bail!(
"managed apps is temporarily unavailable — {}",
body_snippet(response)
),
401 | 403 => bail!("not authorized ({}) — {}", status, body_snippet(response)),
409 => bail!("conflict — {}", body_snippet(response)),
400 => bail!("{}", body_snippet(response)),
_ => bail!(
"control plane returned {} — {}",
status,
body_snippet(response)
),
}
}
fn is_unrouted_404(snippet: &str) -> bool {
let snippet = snippet.trim_start();
["GET", "POST", "PUT", "PATCH", "DELETE"]
.iter()
.any(|method| snippet.starts_with(&format!("Cannot {} /", method)))
}
pub(super) fn body_snippet(response: Response) -> String {
let text = response
.text()
.unwrap_or_else(|_| "unknown error".to_string());
snippet_from_text(&text)
}
fn snippet_from_text(text: &str) -> String {
if let Ok(value) = serde_json::from_str::<Value>(text) {
if let Some(string) = value.as_str() {
return string.to_string();
}
for key in ["message", "error", "detail"] {
if let Some(message) = value.get(key).and_then(Value::as_str) {
return message.to_string();
}
}
}
let trimmed = text.trim();
if trimmed.is_empty() {
return "unknown error".to_string();
}
if trimmed.chars().count() > 400 {
let truncated: String = trimmed.chars().take(400).collect();
return format!("{}…", truncated);
}
trimmed.to_string()
}
pub(super) fn extract_list<T: serde::de::DeserializeOwned>(
value: Value,
keys: &[&str],
) -> Result<Vec<T>> {
let array = match value {
Value::Array(items) => items,
Value::Object(map) => {
let found = keys
.iter()
.chain(["data", "items", "results"].iter())
.find_map(|key| map.get(*key));
match found {
Some(Value::Array(items)) => items.clone(),
_ => bail!(
"unexpected list response shape from the control plane (expected an array, or an object with one of: {})",
keys.join(", ")
),
}
}
other => bail!("unexpected list response from the control plane: {}", other),
};
array
.into_iter()
.map(|item| serde_json::from_value(item).map_err(anyhow::Error::from))
.collect()
}
pub(super) fn get_value(auth_mode: &AuthMode, path: &str, missing: Missing) -> Result<Value> {
let url = managed_url(path);
let response = get_with_auth(auth_mode, &url).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
let response = ensure_success(response, missing)?;
response
.json()
.with_context(|| format!("Failed to parse the response from {}", url))
}
pub(super) fn get_value_if_supported(auth_mode: &AuthMode, path: &str) -> Result<Option<Value>> {
let url = managed_url(path);
let response = get_with_auth(auth_mode, &url).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
if response.status().as_u16() == 404 {
return Ok(None);
}
let response = ensure_success(response, Missing::Endpoint)?;
let value = response
.json()
.with_context(|| format!("Failed to parse the response from {}", url))?;
Ok(Some(value))
}
pub(super) fn post_json<T: serde::de::DeserializeOwned>(
auth_mode: &AuthMode,
path: &str,
body: Value,
missing: Missing,
) -> Result<T> {
let url = managed_url(path);
let response =
post_with_auth(auth_mode, &url, body).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
let response = ensure_success(response, missing)?;
response
.json()
.with_context(|| format!("Failed to parse the response from {}", url))
}
pub(super) fn patch_json<T: serde::de::DeserializeOwned>(
path: &str,
body: Value,
missing: Missing,
) -> Result<T> {
let url = managed_url(path);
let response = patch(&url, body).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
let response = ensure_success(response, missing)?;
response
.json()
.with_context(|| format!("Failed to parse the response from {}", url))
}
pub(super) fn put_json_optional(
path: &str,
body: Value,
missing: Missing,
) -> Result<Option<Value>> {
let url = managed_url(path);
let response = put(&url, body).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
let response = ensure_success(response, missing)?;
let text = response
.text()
.with_context(|| format!("Failed to read the response from {}", url))?;
if text.trim().is_empty() {
return Ok(None);
}
Ok(serde_json::from_str(&text).ok())
}
pub(super) fn post_json_public<T: serde::de::DeserializeOwned>(
path: &str,
body: Value,
missing: Missing,
) -> Result<T> {
let url = managed_url(path);
let response =
post_unauthenticated(&url, body).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
let response = ensure_success(response, missing)?;
response
.json()
.with_context(|| format!("Failed to parse the response from {}", url))
}
pub(super) fn delete_text(path: &str, missing: Missing) -> Result<String> {
let url = managed_url(path);
let response = delete(&url).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
let response = ensure_success(response, missing)?;
Ok(body_snippet(response))
}
pub(super) fn print_dryrun(method: &str, path: &str, body: Option<&Value>) -> Result<()> {
println!("[DRYRUN] {} {}", method, managed_url(path));
if let Some(body) = body {
println!("{}", serde_json::to_string_pretty(body)?);
}
println!("[DRYRUN] no request was sent.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::managed::types::AppTemplate;
#[test]
fn managed_url_is_rooted_at_the_managed_mode_router() {
unsafe {
std::env::set_var(
"FORKLAUNCH_PLATFORM_MANAGEMENT_API_URL",
"https://platform.example.com",
);
}
assert_eq!(
managed_url("/templates"),
"https://platform.example.com/managed-mode/templates"
);
assert_eq!(
managed_url("/instances/abc/claim-link"),
"https://platform.example.com/managed-mode/instances/abc/claim-link"
);
assert_eq!(
managed_url("/instances/abc/claim"),
"https://platform.example.com/managed-mode/instances/abc/claim"
);
assert_eq!(
managed_url("/templates/clinic"),
"https://platform.example.com/managed-mode/templates/clinic"
);
unsafe {
std::env::remove_var("FORKLAUNCH_PLATFORM_MANAGEMENT_API_URL");
}
}
#[test]
fn summary_without_available_field_is_treated_as_available() {
let summary: ManagedModeSummary = serde_json::from_str("{}").unwrap();
assert!(summary.is_available());
}
#[test]
fn summary_reports_unavailable_with_reason() {
let summary: ManagedModeSummary = serde_json::from_str(
r#"{"available":false,"unavailableReason":"Managed apps is not configured for this deployment (MANAGED_APPS_URL is unset).","instances":[],"counts":{"total":0}}"#,
)
.unwrap();
assert!(!summary.is_available());
assert_eq!(
summary.unavailable_reason.as_deref(),
Some("Managed apps is not configured for this deployment (MANAGED_APPS_URL is unset).")
);
}
#[test]
fn summary_parses_embedded_instances() {
let summary: ManagedModeSummary = serde_json::from_str(
r#"{"available":true,"instances":[{"id":"i1","templateSlug":"clinic","host":"a.example.com","region":"us-west-2","state":"active","relayEligible":true}],"relayConfigs":[],"counts":{"total":1,"relayEligible":1,"failed":0}}"#,
)
.unwrap();
assert_eq!(summary.instances.len(), 1);
assert_eq!(
summary.instances[0].template_slug.as_deref(),
Some("clinic")
);
assert_eq!(summary.instances[0].state.as_deref(), Some("active"));
}
#[test]
fn extract_list_accepts_a_bare_array() {
let templates: Vec<AppTemplate> =
extract_list(serde_json::json!([{"slug":"a","name":"A"}]), &["templates"]).unwrap();
assert_eq!(templates.len(), 1);
assert_eq!(templates[0].slug.as_deref(), Some("a"));
}
#[test]
fn extract_list_accepts_a_wrapped_array() {
let templates: Vec<AppTemplate> = extract_list(
serde_json::json!({"templates":[{"slug":"a"},{"slug":"b"}]}),
&["templates"],
)
.unwrap();
assert_eq!(templates.len(), 2);
}
#[test]
fn extract_list_accepts_generic_envelope_keys() {
let templates: Vec<AppTemplate> =
extract_list(serde_json::json!({"data":[{"slug":"a"}]}), &["templates"]).unwrap();
assert_eq!(templates.len(), 1);
}
#[test]
fn extract_list_refuses_to_pretend_an_unknown_shape_is_empty() {
let result: Result<Vec<AppTemplate>> =
extract_list(serde_json::json!({"unexpected": 1}), &["templates"]);
assert!(result.is_err());
}
#[test]
fn snippet_prefers_a_json_message_field() {
assert_eq!(
snippet_from_text(r#"{"message":"No published template 'clinic'"}"#),
"No published template 'clinic'"
);
}
#[test]
fn snippet_unwraps_a_bare_json_string_body() {
assert_eq!(
snippet_from_text(r#""Destroy requested""#),
"Destroy requested"
);
}
#[test]
fn snippet_falls_back_to_trimmed_text() {
assert_eq!(
snippet_from_text(" Cannot POST /managed-mode/templates "),
"Cannot POST /managed-mode/templates"
);
}
#[test]
fn unrouted_404_bodies_are_recognized() {
assert!(is_unrouted_404("Cannot POST /managed-mode/templates"));
assert!(is_unrouted_404("Cannot GET /managed-mode/instances"));
assert!(is_unrouted_404("Cannot DELETE /managed-mode/instances/abc"));
}
#[test]
fn handler_authored_404_bodies_are_not_mistaken_for_missing_routes() {
assert!(!is_unrouted_404(
"No claim link available — it was already revealed, expired, or the instance is claimed"
));
assert!(!is_unrouted_404("No published template 'clinic'"));
assert!(!is_unrouted_404("Cannot find that instance"));
}
#[test]
fn snippet_truncates_a_flood_of_html() {
let flood = "x".repeat(5000);
let snippet = snippet_from_text(&flood);
assert_eq!(snippet.chars().count(), 401);
assert!(snippet.ends_with('…'));
}
}