use std::collections::BTreeMap;
use std::fmt;
use secrecy::{ExposeSecret, SecretString};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct PodId(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("`{0}` is not a Runpod pod ID")]
pub struct InvalidPodId(String);
impl PodId {
pub fn new(id: &str) -> Result<Self, InvalidPodId> {
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()) {
Ok(Self(id.to_string()))
} else {
Err(InvalidPodId(id.to_string()))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for PodId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for PodId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::new(&raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RemoteStatus {
Provisioning,
Starting,
Running,
Exited,
Error,
Terminated,
#[default]
#[serde(other)]
Unknown,
}
impl RemoteStatus {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Provisioning => "PROVISIONING",
Self::Starting => "STARTING",
Self::Running => "RUNNING",
Self::Exited => "EXITED",
Self::Error => "ERROR",
Self::Terminated => "TERMINATED",
Self::Unknown => "UNKNOWN",
}
}
#[must_use]
pub fn is_dead(self) -> bool {
matches!(self, Self::Exited | Self::Error | Self::Terminated)
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Pod {
pub id: PodId,
#[serde(default)]
pub name: String,
#[serde(default)]
pub status: RemoteStatus,
#[serde(default)]
pub cost: Option<f64>,
#[serde(default)]
pub data_center_id: Option<String>,
#[serde(default)]
pub gpu: Option<PodGpu>,
#[serde(default)]
pub ssh: Option<PodSsh>,
#[serde(default)]
pub env: Option<PodEnv>,
#[serde(default)]
pub created_at: Option<String>,
}
impl Pod {
#[must_use]
pub fn run_id(&self) -> Option<&str> {
self.env.as_ref()?.run_id.as_deref()
}
#[must_use]
pub fn direct(&self) -> Option<&SshDirect> {
self.ssh.as_ref()?.direct.as_ref()
}
#[must_use]
pub fn gpu_type(&self) -> Option<&str> {
self.gpu.as_ref()?.id.as_deref()
}
#[must_use]
pub fn rate(&self) -> Option<f64> {
self.cost.filter(|cost| *cost > 0.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct PodGpu {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub count: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct PodSsh {
#[serde(default)]
pub direct: Option<SshDirect>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct SshDirect {
pub host: String,
pub port: u16,
pub username: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct PodEnv {
#[serde(rename = "OVERBRAINER_RUN_ID", default)]
pub run_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PodPage {
#[serde(default)]
pub pods: Vec<Pod>,
#[serde(default)]
pub pagination: Option<Pagination>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Pagination {
#[serde(default)]
pub has_next_page: bool,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePod {
pub name: String,
pub image: String,
pub cloud: &'static str,
pub gpu: GpuRequest,
pub disk: u32,
pub ports: Vec<String>,
pub start_ssh: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_center_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mounts: Option<Mounts>,
pub env: CreateEnv,
pub cmd: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GpuRequest {
pub id: String,
pub count: u32,
pub min_cuda_version: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Mounts {
pub network: Vec<NetworkMount>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkMount {
pub volume_id: String,
pub path: String,
}
#[derive(Clone)]
pub struct CreateEnv {
pub plain: BTreeMap<String, String>,
pub host_key_name: &'static str,
pub host_key: SecretString,
}
impl Serialize for CreateEnv {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.plain.len() + 1))?;
for (name, value) in &self.plain {
map.serialize_entry(name, value)?;
}
map.serialize_entry(self.host_key_name, self.host_key.expose_secret())?;
map.end()
}
}
impl fmt::Debug for CreateEnv {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let names: Vec<&str> = self
.plain
.keys()
.map(String::as_str)
.chain([self.host_key_name])
.collect();
f.debug_struct("CreateEnv")
.field("names", &names)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn request() -> CreatePod {
CreatePod {
name: "overbrainer-r1-1".into(),
image: "img@sha256:abc".into(),
cloud: "SECURE",
gpu: GpuRequest {
id: "NVIDIA A40".into(),
count: 1,
min_cuda_version: "13.0",
},
disk: 50,
ports: vec!["22/tcp".into()],
start_ssh: false,
data_center_ids: None,
mounts: None,
env: CreateEnv {
plain: BTreeMap::from([("OVERBRAINER_RUN_ID".into(), "r1".into())]),
host_key_name: "OVERBRAINER_HOST_KEY",
host_key: SecretString::from("c2VjcmV0LWhvc3Qta2V5"),
},
cmd: vec!["bash".into(), "-c".into(), "true".into()],
}
}
#[test]
fn the_request_uses_the_v2_field_names_only() -> TestResult {
let json = serde_json::to_value(request())?;
let keys: Vec<&str> = json
.as_object()
.ok_or("not an object")?
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
vec![
"cloud", "cmd", "disk", "env", "gpu", "image", "name", "ports", "startSsh"
]
);
assert_eq!(json["gpu"]["minCudaVersion"], "13.0");
assert_eq!(json["env"]["OVERBRAINER_HOST_KEY"], "c2VjcmV0LWhvc3Qta2V5");
for v1 in [
"imageName",
"gpuTypeIds",
"interruptible",
"supportPublicIp",
"cloudType",
] {
assert!(json.get(v1).is_none(), "{v1}");
}
let mut with_volume = request();
with_volume.data_center_ids = Some(vec!["EU-RO-1".into()]);
with_volume.mounts = Some(Mounts {
network: vec![NetworkMount {
volume_id: "vol1".into(),
path: "/workspace/data".into(),
}],
});
let json = serde_json::to_value(with_volume)?;
assert_eq!(json["dataCenterIds"][0], "EU-RO-1");
assert_eq!(json["mounts"]["network"][0]["volumeId"], "vol1");
Ok(())
}
#[test]
fn debug_never_shows_the_host_key() {
let text = format!("{:?}", request());
assert!(!text.contains("c2VjcmV0LWhvc3Qta2V5"), "{text}");
assert!(text.contains("OVERBRAINER_HOST_KEY"), "{text}");
}
#[test]
fn a_pod_reads_only_the_run_marker_from_its_env() -> TestResult {
let pod: Pod = serde_json::from_str(
r#"{"id": "k3x9abc", "name": "overbrainer-r1-1", "status": "RUNNING", "cost": 0.53,
"dataCenterId": "EU-RO-1", "gpu": {"id": "NVIDIA A40", "count": 1},
"ssh": {"direct": {"host": "203.0.113.7", "port": 40122, "username": "root"}},
"env": {"OVERBRAINER_RUN_ID": "r1", "OVERBRAINER_HOST_KEY": "c2VjcmV0"},
"createdAt": "2026-09-22T14:30:08Z", "somethingNew": true}"#,
)?;
assert_eq!(pod.id.as_str(), "k3x9abc");
assert_eq!(pod.run_id(), Some("r1"));
assert_eq!(pod.rate(), Some(0.53));
assert_eq!(pod.gpu_type(), Some("NVIDIA A40"));
assert_eq!(pod.direct().map(|direct| direct.port), Some(40122));
assert!(!format!("{pod:?}").contains("c2VjcmV0"));
Ok(())
}
#[test]
fn unknown_statuses_and_missing_fields_are_tolerated() -> TestResult {
let pod: Pod = serde_json::from_str(
r#"{"id": "p1", "status": "MIGRATING", "ssh": {"direct": null}, "cost": 0.0}"#,
)?;
assert_eq!(pod.status, RemoteStatus::Unknown);
assert_eq!(pod.direct(), None);
assert_eq!(pod.rate(), None);
assert_eq!(pod.run_id(), None);
Ok(())
}
#[test]
fn pod_ids_are_letters_and_digits() {
assert!(PodId::new("k3x9abc").is_ok());
for bad in ["", "../x", "a/b", "a b", "a-b"] {
assert!(PodId::new(bad).is_err(), "{bad}");
}
assert!(serde_json::from_str::<Pod>(r#"{"id": "../etc"}"#).is_err());
}
#[test]
fn a_rejected_pod_id_is_an_error_of_its_own() {
let error: Option<InvalidPodId> = PodId::new("a-b").err();
let source: Option<&dyn std::error::Error> =
error.as_ref().map(|e| e as &dyn std::error::Error);
assert_eq!(
source.map(ToString::to_string).as_deref(),
Some("`a-b` is not a Runpod pod ID")
);
}
}