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>,
}
fn nullable<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
}
fn clean<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
nullable::<D, String>(deserializer).map(|text| super::printable(&text))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Availability {
None,
Low,
Medium,
High,
#[default]
#[serde(other)]
Unknown,
}
impl Availability {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::None => "NONE",
Self::Low => "LOW",
Self::Medium => "MEDIUM",
Self::High => "HIGH",
Self::Unknown => "UNKNOWN",
}
}
#[must_use]
pub fn is_in_stock(self) -> bool {
matches!(self, Self::Low | Self::Medium | Self::High)
}
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GpuType {
#[serde(default, deserialize_with = "clean")]
pub id: String,
#[serde(default, deserialize_with = "clean")]
pub name: String,
#[serde(default, deserialize_with = "clean")]
pub manufacturer: String,
#[serde(default, deserialize_with = "nullable")]
pub memory: u32,
#[serde(default)]
pub secure: Option<bool>,
#[serde(default, deserialize_with = "nullable")]
pub price: GpuPrice,
#[serde(default, deserialize_with = "nullable")]
pub max_count: GpuMaxCount,
#[serde(default, deserialize_with = "nullable")]
pub availability: Availability,
#[serde(default, deserialize_with = "nullable")]
pub data_centers: Vec<Stock>,
#[serde(default, deserialize_with = "nullable")]
pub cuda_versions: Vec<CudaVersion>,
}
impl GpuType {
#[must_use]
pub fn secure_price(&self) -> Option<f64> {
self.price
.secure
.filter(|price| price.is_finite() && *price > 0.0)
}
#[must_use]
pub fn on_secure_cloud(&self) -> bool {
self.secure != Some(false)
}
#[must_use]
pub fn stock_in(&self, data_center: &str) -> Availability {
self.data_centers
.iter()
.find(|entry| entry.id == data_center)
.map_or(Availability::None, |entry| entry.availability)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Deserialize)]
pub struct GpuPrice {
#[serde(default)]
pub secure: Option<f64>,
#[serde(default)]
pub community: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
pub struct GpuMaxCount {
#[serde(default, deserialize_with = "nullable")]
pub secure: u32,
#[serde(default, deserialize_with = "nullable")]
pub community: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
pub struct Stock {
#[serde(default, deserialize_with = "clean")]
pub id: String,
#[serde(default, deserialize_with = "clean")]
pub name: String,
#[serde(default, deserialize_with = "nullable")]
pub availability: Availability,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
pub struct CudaVersion {
#[serde(default, deserialize_with = "clean")]
pub version: String,
#[serde(default, deserialize_with = "nullable")]
pub available: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct GpuTypeList {
#[serde(default, deserialize_with = "nullable")]
pub gpus: Vec<GpuType>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataCenter {
#[serde(default, deserialize_with = "clean")]
pub id: String,
#[serde(default, deserialize_with = "clean")]
pub name: String,
#[serde(default, deserialize_with = "clean")]
pub region: String,
#[serde(default, deserialize_with = "nullable")]
pub gpu_availability: Vec<Stock>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataCenterList {
#[serde(default, deserialize_with = "nullable")]
pub data_centers: Vec<DataCenter>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkVolume {
#[serde(default, deserialize_with = "clean")]
pub id: String,
#[serde(default, deserialize_with = "clean")]
pub name: String,
#[serde(default, deserialize_with = "nullable")]
pub size: u32,
#[serde(default, alias = "dataCenterId", deserialize_with = "clean")]
pub data_center: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkVolumeList {
#[serde(default, deserialize_with = "nullable")]
pub network_volumes: Vec<NetworkVolume>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
pub struct Template {
#[serde(default, deserialize_with = "clean")]
pub id: String,
#[serde(default, deserialize_with = "clean")]
pub name: String,
#[serde(default, deserialize_with = "clean")]
pub image: String,
#[serde(default, deserialize_with = "nullable")]
pub serverless: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TemplatePage {
#[serde(default, deserialize_with = "nullable")]
pub templates: Vec<Template>,
#[serde(default)]
pub pagination: Option<Pagination>,
}
#[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: BTreeMap<String, String>,
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(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Secret {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SecretList {
#[serde(default, deserialize_with = "nullable")]
pub secrets: Vec<Secret>,
}
#[derive(Clone)]
pub struct NewSecret {
pub name: String,
pub value: SecretString,
pub description: String,
}
impl Serialize for NewSecret {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(3))?;
map.serialize_entry("name", &self.name)?;
map.serialize_entry("value", self.value.expose_secret())?;
map.serialize_entry("description", &self.description)?;
map.end()
}
}
impl fmt::Debug for NewSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NewSecret")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
pub(crate) struct SecretValue<'a>(pub &'a SecretString);
impl Serialize for SecretValue<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("value", self.0.expose_secret())?;
map.end()
}
}
#[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: BTreeMap::from([
("OVERBRAINER_RUN_ID".into(), "r1".into()),
(
"OVERBRAINER_HOST_KEY".into(),
"{{ RUNPOD_SECRET_overbrainer_host_key_r1 }}".into(),
),
]),
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"],
"{{ RUNPOD_SECRET_overbrainer_host_key_r1 }}"
);
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 a_new_secret_shows_its_value_to_the_serializer_only() -> TestResult {
let secret = NewSecret {
name: "overbrainer_host_key_r1".into(),
value: SecretString::from("c2VjcmV0LWhvc3Qta2V5"),
description: "d".into(),
};
let text = format!("{secret:?}");
assert!(
!text.contains("c2VjcmV0LWhvc3Qta2V5"),
"Debug shows the secret value"
);
assert!(
text.contains("overbrainer_host_key_r1"),
"Debug hides the secret name"
);
let json = serde_json::to_value(&secret)?;
assert_eq!(json["value"], "c2VjcmV0LWhvc3Qta2V5");
let patch = serde_json::to_value(SecretValue(&secret.value))?;
assert_eq!(patch, serde_json::json!({"value": "c2VjcmV0LWhvc3Qta2V5"}));
Ok(())
}
#[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")
);
}
}