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

@ -413,6 +413,29 @@ class Unsandbox {
return $response['snapshots'] ?? [];
}
/**
* Get details of a specific snapshot.
*
* @param string $snapshotId Snapshot ID to get details for
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Snapshot details array containing:
* - id: Snapshot ID
* - name: Snapshot name
* - type: "session" or "service"
* - source_id: Original resource ID
* - hot: Whether snapshot preserves running state
* - locked: Whether snapshot is locked
* - created_at: Creation timestamp
* - size_bytes: Size in bytes
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function getSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('GET', "/snapshots/{$snapshotId}", $publicKey, $secretKey);
}
/**
* Restore a snapshot.
*
@ -1360,6 +1383,22 @@ class Unsandbox {
return $response;
}
/**
* Resize a service's vCPU allocation.
*
* @param string $serviceId Service ID to resize
* @param int $vcpu Number of vCPUs (1-8 typically)
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with updated service info
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function resizeService(string $serviceId, int $vcpu, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('PATCH', "/services/{$serviceId}", $publicKey, $secretKey, ['vcpu' => $vcpu]);
}
// =========================================================================
// Key Validation
// =========================================================================
@ -1420,6 +1459,172 @@ class Unsandbox {
return $this->makeRequest('POST', '/image', $publicKey, $secretKey, $payload);
}
// =========================================================================
// PaaS Logs Functions
// =========================================================================
private static ?string $lastError = null;
/**
* Fetch batch logs from the PaaS platform.
*
* @param string $source Log source - "all", "api", "portal", "pool/cammy", "pool/ai"
* @param int $lines Number of lines to fetch (1-10000)
* @param string $since Time window - "1m", "5m", "1h", "1d"
* @param string|null $grep Optional filter pattern
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Log entries
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function logsFetch(
string $source = 'all',
int $lines = 100,
string $since = '5m',
?string $grep = null,
?string $publicKey = null,
?string $secretKey = null
): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$path = "/logs?source=" . urlencode($source) . "&lines={$lines}&since=" . urlencode($since);
if ($grep !== null) {
$path .= "&grep=" . urlencode($grep);
}
return $this->makeRequest('GET', $path, $publicKey, $secretKey);
}
/**
* Stream logs via Server-Sent Events.
*
* Blocks until interrupted or server closes connection.
*
* @param string $source Log source - "all", "api", "portal", "pool/cammy", "pool/ai"
* @param string|null $grep Optional filter pattern
* @param callable|null $callback Function called for each log line (signature: callback(source, line))
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function logsStream(
string $source = 'all',
?string $grep = null,
?callable $callback = null,
?string $publicKey = null,
?string $secretKey = null
): void {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$path = "/logs/stream?source=" . urlencode($source);
if ($grep !== null) {
$path .= "&grep=" . urlencode($grep);
}
$timestamp = time();
$signature = $this->signRequest($secretKey, $timestamp, 'GET', $path);
$ch = curl_init(self::API_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => false,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$publicKey}",
"X-Timestamp: {$timestamp}",
"X-Signature: {$signature}",
"Accept: text/event-stream",
],
CURLOPT_WRITEFUNCTION => function($ch, $data) use ($source, $callback) {
$lines = explode("\n", $data);
foreach ($lines as $line) {
if (strpos($line, 'data: ') === 0) {
$json = substr($line, 6);
$entry = @json_decode($json, true);
$entrySource = $entry['source'] ?? $source;
$entryLine = $entry['line'] ?? $json;
if ($callback !== null) {
$callback($entrySource, $entryLine);
} else {
echo "[{$entrySource}] {$entryLine}\n";
}
}
}
return strlen($data);
},
]);
curl_exec($ch);
curl_close($ch);
}
// =========================================================================
// Utility Functions
// =========================================================================
public const SDK_VERSION = '4.2.0';
/**
* Get the SDK version string.
*
* @return string Version string (e.g., "4.2.0")
*/
public static function version(): string {
return self::SDK_VERSION;
}
/**
* Check if the API is healthy and responding.
*
* @return bool true if API is healthy, false otherwise
*/
public static function healthCheck(): bool {
$ch = curl_init(self::API_BASE . '/health');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
self::$lastError = "Health check failed: {$error}";
return false;
}
if ($httpCode !== 200) {
self::$lastError = "Health check failed: HTTP {$httpCode}";
return false;
}
return true;
}
/**
* Get the last error message.
*
* @return string|null Last error message or null
*/
public static function lastError(): ?string {
return self::$lastError;
}
/**
* Sign a message using HMAC-SHA256.
*
* This is the underlying signing function used for request authentication.
* Exposed for testing and debugging purposes.
*
* @param string $secretKey The secret key for signing
* @param string $message The message to sign
* @return string 64-character lowercase hex string
*/
public static function hmacSign(string $secretKey, string $message): string {
return hash_hmac('sha256', $message, $secretKey);
}
/**
* Get path to ~/.unsandbox directory, creating if necessary.
*

View file

@ -0,0 +1,298 @@
<?php
/**
* Tests for new SDK functions (feature parity with C implementation)
*/
declare(strict_types=1);
namespace Unsandbox\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../src/un.php';
use Unsandbox\Unsandbox;
class NewFunctionsTest extends TestCase
{
private Unsandbox $client;
protected function setUp(): void
{
$this->client = new Unsandbox();
}
// =========================================================================
// Utility Functions Tests
// =========================================================================
public function testVersionReturnsString(): void
{
$v = Unsandbox::version();
$this->assertIsString($v);
$this->assertNotEmpty($v);
}
public function testVersionIsSemanticFormat(): void
{
$v = Unsandbox::version();
$parts = explode('.', $v);
$this->assertGreaterThanOrEqual(2, count($parts), "Version should be semantic: $v");
}
public function testHmacSignReturns64HexChars(): void
{
$signature = Unsandbox::hmacSign('secret_key', 'message_to_sign');
$this->assertIsString($signature);
$this->assertEquals(64, strlen($signature));
$this->assertMatchesRegularExpression('/^[0-9a-f]+$/', $signature);
}
public function testHmacSignIsDeterministic(): void
{
$sig1 = Unsandbox::hmacSign('secret', 'message');
$sig2 = Unsandbox::hmacSign('secret', 'message');
$this->assertEquals($sig1, $sig2);
}
public function testHmacSignDifferentSecretsDifferentSignatures(): void
{
$sig1 = Unsandbox::hmacSign('secret1', 'message');
$sig2 = Unsandbox::hmacSign('secret2', 'message');
$this->assertNotEquals($sig1, $sig2);
}
public function testHmacSignDifferentMessagesDifferentSignatures(): void
{
$sig1 = Unsandbox::hmacSign('secret', 'message1');
$sig2 = Unsandbox::hmacSign('secret', 'message2');
$this->assertNotEquals($sig1, $sig2);
}
public function testHmacSignMatchesPhpHashHmac(): void
{
$secret = 'test_secret';
$message = '1234567890:POST:/execute:';
$signature = Unsandbox::hmacSign($secret, $message);
$expected = hash_hmac('sha256', $message, $secret);
$this->assertEquals($expected, $signature);
}
public function testLastErrorReturnsNullOrString(): void
{
$error = Unsandbox::lastError();
$this->assertTrue($error === null || is_string($error));
}
// =========================================================================
// Method Exports Tests
// =========================================================================
public function testExecutionMethodsExist(): void
{
// 8 execution methods
$this->assertTrue(method_exists($this->client, 'executeCode'));
$this->assertTrue(method_exists($this->client, 'executeAsync'));
$this->assertTrue(method_exists($this->client, 'getJob'));
$this->assertTrue(method_exists($this->client, 'waitForJob'));
$this->assertTrue(method_exists($this->client, 'cancelJob'));
$this->assertTrue(method_exists($this->client, 'listJobs'));
$this->assertTrue(method_exists($this->client, 'getLanguages'));
$this->assertTrue(method_exists(Unsandbox::class, 'detectLanguage'));
}
public function testSessionMethodsExist(): void
{
// 9 session methods
$this->assertTrue(method_exists($this->client, 'listSessions'));
$this->assertTrue(method_exists($this->client, 'getSession'));
$this->assertTrue(method_exists($this->client, 'createSession'));
$this->assertTrue(method_exists($this->client, 'deleteSession'));
$this->assertTrue(method_exists($this->client, 'freezeSession'));
$this->assertTrue(method_exists($this->client, 'unfreezeSession'));
$this->assertTrue(method_exists($this->client, 'boostSession'));
$this->assertTrue(method_exists($this->client, 'unboostSession'));
$this->assertTrue(method_exists($this->client, 'shellSession'));
}
public function testServiceMethodsExist(): void
{
// 17 service methods
$this->assertTrue(method_exists($this->client, 'listServices'));
$this->assertTrue(method_exists($this->client, 'createService'));
$this->assertTrue(method_exists($this->client, 'getService'));
$this->assertTrue(method_exists($this->client, 'updateService'));
$this->assertTrue(method_exists($this->client, 'deleteService'));
$this->assertTrue(method_exists($this->client, 'freezeService'));
$this->assertTrue(method_exists($this->client, 'unfreezeService'));
$this->assertTrue(method_exists($this->client, 'lockService'));
$this->assertTrue(method_exists($this->client, 'unlockService'));
$this->assertTrue(method_exists($this->client, 'setUnfreezeOnDemand'));
$this->assertTrue(method_exists($this->client, 'getServiceLogs'));
$this->assertTrue(method_exists($this->client, 'getServiceEnv'));
$this->assertTrue(method_exists($this->client, 'setServiceEnv'));
$this->assertTrue(method_exists($this->client, 'deleteServiceEnv'));
$this->assertTrue(method_exists($this->client, 'exportServiceEnv'));
$this->assertTrue(method_exists($this->client, 'redeployService'));
$this->assertTrue(method_exists($this->client, 'executeInService'));
$this->assertTrue(method_exists($this->client, 'resizeService'), 'resizeService should exist (NEW)');
}
public function testSnapshotMethodsExist(): void
{
// 9 snapshot methods
$this->assertTrue(method_exists($this->client, 'sessionSnapshot'));
$this->assertTrue(method_exists($this->client, 'serviceSnapshot'));
$this->assertTrue(method_exists($this->client, 'listSnapshots'));
$this->assertTrue(method_exists($this->client, 'getSnapshot'), 'getSnapshot should exist (NEW)');
$this->assertTrue(method_exists($this->client, 'restoreSnapshot'));
$this->assertTrue(method_exists($this->client, 'deleteSnapshot'));
$this->assertTrue(method_exists($this->client, 'lockSnapshot'));
$this->assertTrue(method_exists($this->client, 'unlockSnapshot'));
$this->assertTrue(method_exists($this->client, 'cloneSnapshot'));
}
public function testImageMethodsExist(): void
{
// 13 image methods
$this->assertTrue(method_exists($this->client, 'imagePublish'));
$this->assertTrue(method_exists($this->client, 'listImages'));
$this->assertTrue(method_exists($this->client, 'getImage'));
$this->assertTrue(method_exists($this->client, 'deleteImage'));
$this->assertTrue(method_exists($this->client, 'lockImage'));
$this->assertTrue(method_exists($this->client, 'unlockImage'));
$this->assertTrue(method_exists($this->client, 'setImageVisibility'));
$this->assertTrue(method_exists($this->client, 'grantImageAccess'));
$this->assertTrue(method_exists($this->client, 'revokeImageAccess'));
$this->assertTrue(method_exists($this->client, 'listImageTrusted'));
$this->assertTrue(method_exists($this->client, 'transferImage'));
$this->assertTrue(method_exists($this->client, 'spawnFromImage'));
$this->assertTrue(method_exists($this->client, 'cloneImage'));
}
public function testLogsMethodsExist(): void
{
// 2 PaaS logs methods (NEW)
$this->assertTrue(method_exists($this->client, 'logsFetch'), 'logsFetch should exist (NEW)');
$this->assertTrue(method_exists($this->client, 'logsStream'), 'logsStream should exist (NEW)');
}
public function testUtilityMethodsExist(): void
{
$this->assertTrue(method_exists($this->client, 'validateKeys'));
$this->assertTrue(method_exists(Unsandbox::class, 'version'), 'version should exist (NEW)');
$this->assertTrue(method_exists(Unsandbox::class, 'healthCheck'), 'healthCheck should exist (NEW)');
$this->assertTrue(method_exists(Unsandbox::class, 'lastError'), 'lastError should exist (NEW)');
$this->assertTrue(method_exists(Unsandbox::class, 'hmacSign'), 'hmacSign should exist (NEW)');
}
// =========================================================================
// Language Detection Tests
// =========================================================================
public function testDetectLanguagePython(): void
{
$this->assertEquals('python', Unsandbox::detectLanguage('test.py'));
}
public function testDetectLanguageJavascript(): void
{
$this->assertEquals('javascript', Unsandbox::detectLanguage('test.js'));
}
public function testDetectLanguageTypescript(): void
{
$this->assertEquals('typescript', Unsandbox::detectLanguage('test.ts'));
}
public function testDetectLanguageRuby(): void
{
$this->assertEquals('ruby', Unsandbox::detectLanguage('test.rb'));
}
public function testDetectLanguageGo(): void
{
$this->assertEquals('go', Unsandbox::detectLanguage('test.go'));
}
public function testDetectLanguageRust(): void
{
$this->assertEquals('rust', Unsandbox::detectLanguage('test.rs'));
}
public function testDetectLanguageUnknownReturnsNull(): void
{
$this->assertNull(Unsandbox::detectLanguage('test.unknown'));
}
}
/**
* Functional tests that require API credentials
*/
class FunctionalAPITest extends TestCase
{
private ?Unsandbox $client = null;
protected function setUp(): void
{
if (!$this->credentialsAvailable()) {
$this->markTestSkipped('UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY required');
}
$this->client = new Unsandbox();
}
private function credentialsAvailable(): bool
{
return getenv('UNSANDBOX_PUBLIC_KEY') && getenv('UNSANDBOX_SECRET_KEY');
}
public function testHealthCheck(): void
{
$result = Unsandbox::healthCheck();
$this->assertIsBool($result);
}
public function testValidateKeys(): void
{
$result = $this->client->validateKeys();
$this->assertIsArray($result);
}
public function testGetLanguages(): void
{
$languages = $this->client->getLanguages();
$this->assertIsArray($languages);
$this->assertContains('python', $languages);
}
public function testListSessions(): void
{
$sessions = $this->client->listSessions();
$this->assertIsArray($sessions);
}
public function testListServices(): void
{
$services = $this->client->listServices();
$this->assertIsArray($services);
}
public function testListSnapshots(): void
{
$snapshots = $this->client->listSnapshots();
$this->assertIsArray($snapshots);
}
public function testListImages(): void
{
$images = $this->client->listImages();
$this->assertIsArray($images);
}
public function testExecuteCode(): void
{
$result = $this->client->executeCode('python', 'print("hello")');
$this->assertIsArray($result);
$this->assertContains($result['status'] ?? '', ['completed', 'pending']);
}
}