feat: full feature parity for all 42 SDKs + comprehensive tests

All SDKs now implement 58+ functions matching C reference (un.h):
- Execution (8): execute, execute_async, wait_job, get_job, cancel_job, list_jobs, get_languages, detect_language
- Sessions (9): list, get, create, destroy, freeze, unfreeze, boost, unboost, execute
- Services (17): list, get, create, destroy, freeze, unfreeze, lock, unlock, set_unfreeze_on_demand, redeploy, logs, execute, env_get/set/delete/export, resize
- Snapshots (9): list, get, session, service, restore, delete, lock, unlock, clone
- Images (13): list, get, publish, delete, lock, unlock, set_visibility, grant/revoke_access, list_trusted, transfer, spawn, clone
- PaaS Logs (2): fetch, stream
- Utilities (5): validate_keys, hmac_sign, health_check, version, last_error

Test suites created for all SDKs with unit, integration, and functional tests.

Languages: AWK, Bash, C++, C#, Clojure, COBOL, Crystal, D, Dart, .NET, Elixir, Erlang, F#, Forth, Fortran, Go, Groovy, Haskell, Java, JavaScript, Julia, Kotlin, Lisp, Lua, Nim, Objective-C, OCaml, Perl, PHP, PowerShell, Prolog, Python, R, Raku, Ruby, Rust, Scheme, Swift, Tcl, TypeScript, V, Zig
This commit is contained in:
russell@unturf.com 2026-02-05 16:45:02 -05:00
parent a5155aed4f
commit 6e746ace44
90 changed files with 28955 additions and 3028 deletions

View file

@ -1302,6 +1302,19 @@ pub fn list_snapshots(creds: &Credentials) -> Result<Vec<Snapshot>> {
Ok(response.snapshots)
}
/// Get details of a specific snapshot.
///
/// # Arguments
/// * `snapshot_id` - Snapshot ID to retrieve
/// * `creds` - API credentials
///
/// # Returns
/// Snapshot information
pub fn get_snapshot(snapshot_id: &str, creds: &Credentials) -> Result<Snapshot> {
let path = format!("/snapshots/{}", snapshot_id);
make_request("GET", &path, creds, None::<&()>)
}
/// Restore a snapshot to create a new session or service.
///
/// # Arguments
@ -2265,6 +2278,30 @@ pub fn redeploy_service(service_id: &str, creds: &Credentials) -> Result<Service
make_request("POST", &path, creds, Some(&body))
}
/// Resize a service by changing its vCPU count.
///
/// # Arguments
/// * `service_id` - Service ID to resize
/// * `vcpu` - New vCPU count (1-8)
/// * `creds` - API credentials
///
/// # Returns
/// Updated Service information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let service = resize_service("service-123", 4, &creds)?;
/// println!("Service now has {} vCPUs", service.vcpu);
/// ```
pub fn resize_service(service_id: &str, vcpu: u32, creds: &Credentials) -> Result<Service> {
let path = format!("/services/{}/resize", service_id);
let body = serde_json::json!({
"vcpu": vcpu
});
make_request("POST", &path, creds, Some(&body))
}
/// Execute a command in a service container.
///
/// # Arguments
@ -2397,6 +2434,278 @@ pub fn image(prompt: &str, creds: &Credentials, opts: Option<ImageOptions>) -> R
make_request("POST", "/image", creds, Some(&payload))
}
// =============================================================================
// PaaS Logs API Functions
// =============================================================================
/// Options for fetching logs.
#[derive(Debug, Default)]
pub struct LogsFetchOptions {
/// Number of lines to fetch (1-10000)
pub lines: Option<u32>,
/// Time window ("1m", "5m", "1h", "1d")
pub since: Option<String>,
/// Optional filter pattern
pub grep: Option<String>,
}
/// Response from logs fetch.
#[derive(Debug, Clone, Deserialize)]
pub struct LogsEntry {
/// Log source
#[serde(default)]
pub source: String,
/// Log line content
#[serde(default)]
pub line: String,
/// Timestamp
#[serde(default)]
pub timestamp: String,
}
/// Fetch batch logs from the portal.
///
/// # Arguments
/// * `source` - Log source ("all", "api", "portal", "pool/cammy", "pool/ai")
/// * `opts` - Fetch options (can be None for defaults)
/// * `creds` - API credentials
///
/// # Returns
/// Vector of log entries
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Fetch last 100 lines from all sources
/// let opts = LogsFetchOptions {
/// lines: Some(100),
/// since: Some("1h".to_string()),
/// ..Default::default()
/// };
/// let logs = logs_fetch("all", Some(opts), &creds)?;
/// for entry in logs {
/// println!("[{}] {}", entry.source, entry.line);
/// }
/// ```
pub fn logs_fetch(source: &str, opts: Option<LogsFetchOptions>, creds: &Credentials) -> Result<Vec<LogsEntry>> {
let mut path = String::from("/paas/logs");
let mut params = vec![];
if !source.is_empty() {
params.push(format!("source={}", source));
}
if let Some(opts) = opts {
if let Some(lines) = opts.lines {
params.push(format!("lines={}", lines));
}
if let Some(since) = opts.since {
params.push(format!("since={}", since));
}
if let Some(grep) = opts.grep {
params.push(format!("grep={}", grep));
}
}
if !params.is_empty() {
path = format!("{}?{}", path, params.join("&"));
}
#[derive(Deserialize)]
struct LogsResponse {
#[serde(default)]
logs: Vec<LogsEntry>,
}
let response: LogsResponse = make_request("GET", &path, creds, None::<&()>)?;
Ok(response.logs)
}
/// Callback function for streaming logs.
pub type LogCallback = fn(source: &str, line: &str);
/// Stream logs via Server-Sent Events.
///
/// This function blocks until the stream is closed or an error occurs.
/// Note: This is a simplified implementation that reads until EOF.
///
/// # Arguments
/// * `source` - Log source ("all", "api", "portal", "pool/cammy", "pool/ai")
/// * `grep` - Optional filter pattern (empty string for no filter)
/// * `callback` - Function called for each log line
/// * `creds` - API credentials
///
/// # Returns
/// Ok(()) on clean shutdown, error on failure
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// fn handle_log(source: &str, line: &str) {
/// println!("[{}] {}", source, line);
/// }
///
/// logs_stream("all", "", handle_log, &creds)?;
/// ```
pub fn logs_stream(source: &str, grep: &str, callback: LogCallback, creds: &Credentials) -> Result<()> {
let mut path = String::from("/paas/logs/stream");
let mut params = vec![];
if !source.is_empty() {
params.push(format!("source={}", source));
}
if !grep.is_empty() {
params.push(format!("grep={}", grep));
}
if !params.is_empty() {
path = format!("{}?{}", path, params.join("&"));
}
let url = format!("{}{}", API_BASE, path);
let timestamp = get_timestamp();
let signature = sign_request(&creds.secret_key, timestamp, "GET", &path, "");
let client = reqwest::blocking::Client::builder()
.timeout(None) // No timeout for streaming
.build()?;
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", creds.public_key))
.header("X-Timestamp", timestamp.to_string())
.header("X-Signature", signature)
.header("Accept", "text/event-stream")
.header("User-Agent", "un-rust-sync/2.0")
.send()?;
let status = response.status().as_u16();
if status < 200 || status >= 300 {
let response_text = response.text()?;
return Err(UnsandboxError::ApiError {
status,
message: response_text,
});
}
let reader = BufReader::new(response);
let mut current_source = source.to_string();
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.is_empty() {
continue;
}
// Parse SSE format
if let Some(event) = line.strip_prefix("event:") {
current_source = event.trim().to_string();
} else if let Some(data) = line.strip_prefix("data:") {
let data = data.trim();
if !data.is_empty() {
callback(&current_source, data);
}
}
}
Ok(())
}
// =============================================================================
// Utility Functions
// =============================================================================
/// SDK version string
pub const SDK_VERSION: &str = "4.2.0";
/// Compute HMAC-SHA256 signature for a message.
///
/// # Arguments
/// * `secret_key` - Secret key for signing
/// * `message` - Message to sign
///
/// # Returns
/// Lowercase hex-encoded signature string
///
/// # Examples
/// ```ignore
/// let sig = hmac_sign("my-secret", "hello");
/// println!("Signature: {}", sig);
/// ```
pub fn hmac_sign(secret_key: &str, message: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())
.expect("HMAC can take key of any size");
mac.update(message.as_bytes());
let result = mac.finalize();
hex::encode(result.into_bytes())
}
/// Check if the API is reachable and responding.
///
/// # Returns
/// true if healthy, false otherwise
///
/// # Examples
/// ```ignore
/// if health_check() {
/// println!("API is healthy");
/// } else {
/// println!("API is unreachable");
/// }
/// ```
pub fn health_check() -> bool {
let client = match reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(_) => return false,
};
match client.get(format!("{}/health", API_BASE)).send() {
Ok(resp) => resp.status().as_u16() == 200,
Err(_) => false,
}
}
/// Get the SDK version string.
///
/// # Returns
/// Version string (e.g., "4.2.0")
pub fn version() -> &'static str {
SDK_VERSION
}
/// Thread-local storage for the last error message.
thread_local! {
static LAST_ERROR: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
/// Set the last error message (internal use).
pub fn set_last_error(msg: &str) {
LAST_ERROR.with(|e| {
*e.borrow_mut() = msg.to_string();
});
}
/// Get the last error message from the SDK.
///
/// # Returns
/// Last error message, or empty string if none
///
/// # Examples
/// ```ignore
/// if let Err(_) = some_operation() {
/// println!("Error: {}", last_error());
/// }
/// ```
pub fn last_error() -> String {
LAST_ERROR.with(|e| e.borrow().clone())
}
// =============================================================================
// CLI Exit Codes
// =============================================================================

View file

@ -0,0 +1,211 @@
// Tests for the Rust unsandbox SDK
// Run with: cargo test
use un::*;
use std::env;
// ============================================================================
// Unit Tests - Test exported library functions
// ============================================================================
#[test]
fn test_detect_language() {
assert_eq!(detect_language("script.py"), Some("python"));
assert_eq!(detect_language("script.js"), Some("javascript"));
assert_eq!(detect_language("script.ts"), Some("typescript"));
assert_eq!(detect_language("script.rb"), Some("ruby"));
assert_eq!(detect_language("script.go"), Some("go"));
assert_eq!(detect_language("script.rs"), Some("rust"));
assert_eq!(detect_language("script.c"), Some("c"));
assert_eq!(detect_language("script.cpp"), Some("cpp"));
assert_eq!(detect_language("script.d"), Some("d"));
assert_eq!(detect_language("script.zig"), Some("zig"));
assert_eq!(detect_language("script.sh"), Some("bash"));
assert_eq!(detect_language("script.lua"), Some("lua"));
assert_eq!(detect_language("script.php"), Some("php"));
assert_eq!(detect_language("script.unknown"), None);
assert_eq!(detect_language("script"), None);
}
#[test]
fn test_hmac_sign() {
let secret_key = "test-secret";
let message = "test-message";
let result = hmac_sign(secret_key, message);
// Should return a 64-character hex string
assert_eq!(result.len(), 64, "HMAC signature should be 64 characters");
// Should be deterministic
let result2 = hmac_sign(secret_key, message);
assert_eq!(result, result2, "HMAC sign is not deterministic");
// Different inputs should produce different outputs
let result3 = hmac_sign(secret_key, "different-message");
assert_ne!(result, result3, "Different inputs should produce different signatures");
}
#[test]
fn test_version() {
let v = version();
assert!(!v.is_empty(), "Version should not be empty");
// Should be in semver format (at least "0.0.0")
assert!(v.len() >= 5, "Version should be in semver format");
}
#[test]
fn test_last_error() {
// Set an error
set_last_error("test error message");
// Retrieve it
let err = last_error();
assert_eq!(err, "test error message");
// Clear it
set_last_error("");
let err = last_error();
assert!(err.is_empty(), "Error should be cleared");
}
#[test]
fn test_credentials_new() {
let pk = "unsb-pk-test-test-test-test";
let sk = "unsb-sk-test1-test2-test3-test4";
let creds = Credentials::new(pk, sk);
assert_eq!(creds.public_key, pk);
assert_eq!(creds.secret_key, sk);
}
// ============================================================================
// Integration Tests - Test SDK internal consistency
// ============================================================================
#[test]
fn test_resolve_credentials_from_env() {
// Save original env vars
let orig_pk = env::var("UNSANDBOX_PUBLIC_KEY").ok();
let orig_sk = env::var("UNSANDBOX_SECRET_KEY").ok();
// Set test env vars
let test_pk = "unsb-pk-test-test-test-test";
let test_sk = "unsb-sk-test1-test2-test3-test4";
env::set_var("UNSANDBOX_PUBLIC_KEY", test_pk);
env::set_var("UNSANDBOX_SECRET_KEY", test_sk);
// Test
let creds = resolve_credentials(None, None).expect("Should resolve from env");
assert_eq!(creds.public_key, test_pk);
assert_eq!(creds.secret_key, test_sk);
// Restore original env vars
match orig_pk {
Some(v) => env::set_var("UNSANDBOX_PUBLIC_KEY", v),
None => env::remove_var("UNSANDBOX_PUBLIC_KEY"),
}
match orig_sk {
Some(v) => env::set_var("UNSANDBOX_SECRET_KEY", v),
None => env::remove_var("UNSANDBOX_SECRET_KEY"),
}
}
#[test]
fn test_resolve_credentials_from_args() {
let test_pk = "unsb-pk-arg1-arg2-arg3-arg4";
let test_sk = "unsb-sk-arg11-arg22-arg33-arg44";
let creds = resolve_credentials(Some(test_pk), Some(test_sk))
.expect("Should resolve from args");
assert_eq!(creds.public_key, test_pk);
assert_eq!(creds.secret_key, test_sk);
}
// ============================================================================
// Functional Tests - Test against real API (requires credentials)
// ============================================================================
fn get_test_credentials() -> Option<Credentials> {
resolve_credentials(None, None).ok()
}
#[test]
#[ignore] // Run with: cargo test -- --ignored
fn test_health_check() {
let healthy = health_check();
if !healthy {
eprintln!("API health check returned unhealthy (API may be unreachable)");
}
}
#[test]
#[ignore] // Run with: cargo test -- --ignored
fn test_get_languages() {
let creds = get_test_credentials().expect("No credentials for functional test");
let languages = get_languages(&creds).expect("GetLanguages failed");
assert!(!languages.is_empty(), "Languages list should not be empty");
// Should include common languages
assert!(
languages.contains(&"python".to_string()),
"Languages should include python"
);
assert!(
languages.contains(&"javascript".to_string()),
"Languages should include javascript"
);
}
#[test]
#[ignore] // Run with: cargo test -- --ignored
fn test_validate_keys() {
let creds = get_test_credentials().expect("No credentials for functional test");
let result = validate_keys(&creds).expect("ValidateKeys failed");
assert!(result.valid, "Keys should be valid");
}
#[test]
#[ignore] // Run with: cargo test -- --ignored
fn test_execute_code() {
let creds = get_test_credentials().expect("No credentials for functional test");
let result = execute_code("python", "print('hello from rust test')", &creds)
.expect("ExecuteCode failed");
assert!(!result.output.is_empty(), "Output should not be empty");
}
#[test]
#[ignore] // Run with: cargo test -- --ignored
fn test_list_sessions() {
let creds = get_test_credentials().expect("No credentials for functional test");
let _sessions = list_sessions(&creds).expect("ListSessions failed");
// Should return a list (possibly empty)
}
#[test]
#[ignore] // Run with: cargo test -- --ignored
fn test_list_services() {
let creds = get_test_credentials().expect("No credentials for functional test");
let _services = list_services(&creds).expect("ListServices failed");
// Should return a list (possibly empty)
}
#[test]
#[ignore] // Run with: cargo test -- --ignored
fn test_list_snapshots() {
let creds = get_test_credentials().expect("No credentials for functional test");
let _snapshots = list_snapshots(&creds).expect("ListSnapshots failed");
// Should return a list (possibly empty)
}
#[test]
#[ignore] // Run with: cargo test -- --ignored
fn test_list_images() {
let creds = get_test_credentials().expect("No credentials for functional test");
let _images = list_images(None, &creds).expect("ListImages failed");
// Should return a list (possibly empty)
}