KeiSeiKit-1.0/_primitives/_rust/kei-compute-linode/src/api.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

234 lines
7.6 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//
//! Linode v4 REST API client. Thin wrapper over `reqwest::Client` —
//! one method per provider verb. Wire types live alongside.
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
const DEFAULT_BASE_URL: &str = "https://api.linode.com/v4";
/// Linode HTTP client. Holds bearer token + base URL (overridable for tests).
#[derive(Debug, Clone)]
pub struct LinodeClient {
base_url: String,
token: String,
http: reqwest::Client,
}
impl LinodeClient {
/// Construct from explicit token. For prod, prefer
/// `LinodeClient::from_env()` which reads `LINODE_TOKEN` (RULE 0.8).
pub fn new(token: impl Into<String>) -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_string(),
token: token.into(),
http: reqwest::Client::new(),
}
}
/// Read `LINODE_TOKEN` from environment.
pub fn from_env() -> Result<Self> {
let token = std::env::var("LINODE_TOKEN").map_err(|_| {
Error::Auth("LINODE_TOKEN not set; source ~/.claude/secrets/.env".into())
})?;
Ok(Self::new(token))
}
/// Override the base URL (test injection).
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
/// `POST /linode/instances` — create instance.
pub async fn create_instance(
&self,
req: &CreateInstanceRequest,
) -> Result<InstanceResponse> {
let url = format!("{}/linode/instances", self.base_url);
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.json(req)
.send()
.await?;
decode(resp).await
}
/// `GET /linode/instances/{id}` — read instance.
pub async fn get_instance(&self, id: i64) -> Result<InstanceResponse> {
let url = format!("{}/linode/instances/{id}", self.base_url);
let resp = self.http.get(&url).bearer_auth(&self.token).send().await?;
decode(resp).await
}
/// `DELETE /linode/instances/{id}` — destroy.
pub async fn delete_instance(&self, id: i64) -> Result<()> {
let url = format!("{}/linode/instances/{id}", self.base_url);
let resp = self.http.delete(&url).bearer_auth(&self.token).send().await?;
ok_no_body(resp).await
}
/// `POST /linode/instances/{id}/boot`
pub async fn boot(&self, id: i64) -> Result<()> {
let url = format!("{}/linode/instances/{id}/boot", self.base_url);
let resp = self.http.post(&url).bearer_auth(&self.token).send().await?;
ok_no_body(resp).await
}
/// `POST /linode/instances/{id}/shutdown`
pub async fn shutdown(&self, id: i64) -> Result<()> {
let url = format!("{}/linode/instances/{id}/shutdown", self.base_url);
let resp = self.http.post(&url).bearer_auth(&self.token).send().await?;
ok_no_body(resp).await
}
/// `POST /linode/instances/{id}/resize` — change tier slug.
pub async fn resize(&self, id: i64, new_type: &str) -> Result<()> {
let url = format!("{}/linode/instances/{id}/resize", self.base_url);
let body = serde_json::json!({ "type": new_type });
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await?;
ok_no_body(resp).await
}
}
async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Api {
status: status.as_u16(),
body,
});
}
let bytes = resp.bytes().await?;
Ok(serde_json::from_slice(&bytes)?)
}
async fn ok_no_body(resp: reqwest::Response) -> Result<()> {
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::Api {
status: status.as_u16(),
body,
});
}
Ok(())
}
// ---- Wire types ----
/// `POST /linode/instances` body. `metadata.user_data` carries the
/// base64-encoded cloud-init blob (see `cloud_init::render_base64`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateInstanceRequest {
pub label: String,
pub region: String,
#[serde(rename = "type")]
pub type_: String,
pub image: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub root_pass: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authorized_keys: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stackscript_data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<InstanceMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceMetadata {
/// Base64-encoded cloud-init user-data.
pub user_data: String,
}
/// `GET /linode/instances/{id}` response (subset we use).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceResponse {
pub id: i64,
pub label: String,
pub status: String,
#[serde(default)]
pub ipv4: Vec<String>,
pub ipv6: Option<String>,
pub region: String,
#[serde(rename = "type")]
pub type_: String,
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{bearer_token, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn create_instance_round_trip() {
let server = MockServer::start().await;
let body = serde_json::json!({
"id": 12345,
"label": "kei-test",
"status": "provisioning",
"ipv4": ["192.0.2.10"],
"ipv6": "2001:db8::1/128",
"region": "us-east",
"type": "g6-nanode-1"
});
Mock::given(method("POST"))
.and(path("/linode/instances"))
.and(bearer_token("tkn"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let cli = LinodeClient::new("tkn").with_base_url(server.uri());
let req = CreateInstanceRequest {
label: "kei-test".into(),
region: "us-east".into(),
type_: "g6-nanode-1".into(),
image: "linode/debian12".into(),
root_pass: None,
authorized_keys: Some(vec!["ssh-ed25519 AAAA…".into()]),
stackscript_data: None,
metadata: Some(InstanceMetadata {
user_data: "I2Nsb3VkLWNvbmZpZw==".into(),
}),
tags: None,
};
let resp = cli.create_instance(&req).await.expect("ok");
assert_eq!(resp.id, 12345);
assert_eq!(resp.status, "provisioning");
assert_eq!(resp.type_, "g6-nanode-1");
assert_eq!(resp.ipv4, vec!["192.0.2.10".to_string()]);
}
#[tokio::test]
async fn get_instance_404_maps_to_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/linode/instances/999"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let cli = LinodeClient::new("tkn").with_base_url(server.uri());
let err = cli.get_instance(999).await.unwrap_err();
match err {
Error::Api { status, .. } => assert_eq!(status, 404),
other => panic!("expected Api error, got {other:?}"),
}
}
}