From 6e746ace44b7285ccac74e1874d64feb299f6cbc Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 5 Feb 2026 16:45:02 -0500 Subject: [PATCH] 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 --- clients/awk/sync/src/un.awk | 676 +++++ clients/awk/tests/test_library.awk | 313 +++ clients/bash/sync/src/un.sh | 1515 ++++++++--- clients/bash/tests/test_library.sh | 263 ++ clients/clojure/sync/src/un.clj | 188 ++ clients/cobol/sync/src/un.cob | 312 +++ clients/cobol/tests/test_un.sh | 94 + clients/cpp/sync/src/un.cpp | 503 ++++ clients/cpp/sync/tests/test_un.cpp | 311 +++ clients/crystal/sync/src/un.cr | 732 +++++- clients/csharp/sync/src/Un.cs | 1070 ++++++++ clients/csharp/tests/UnsandboxTests.cs | 228 ++ clients/d/sync/src/un.d | 467 ++++ clients/d/sync/tests/test_un.d | 260 ++ clients/dart/sync/src/un.dart | 315 ++- clients/dotnet/sync/src/Un.cs | 890 ++++++- clients/dotnet/tests/UnsandboxTests.cs | 193 ++ clients/dotnet/tests/UnsandboxTests.csproj | 14 + clients/elixir/sync/src/un.ex | 1056 ++++++++ clients/elixir/sync/tests/test_functional.exs | 153 ++ clients/elixir/sync/tests/test_library.exs | 135 + clients/erlang/sync/src/un.erl | 683 ++++- clients/erlang/sync/tests/test_functional.erl | 148 ++ clients/erlang/sync/tests/test_library.erl | 109 + clients/forth/sync/src/un.forth | 381 +++ clients/fortran/sync/src/un.f90 | 225 ++ clients/fortran/tests/test_un.sh | 100 + clients/fsharp/sync/src/un.fs | 14 +- clients/fsharp/tests/UnsandboxTests.fs | 265 ++ clients/go/sync/src/un.go | 198 ++ clients/go/sync/tests/un_test.go | 363 +++ clients/groovy/sync/src/un.groovy | 591 +++++ clients/groovy/sync/tests/UnTest.groovy | 453 ++++ clients/haskell/sync/src/un.hs | 493 ++++ clients/haskell/sync/tests/test_functional.hs | 182 ++ clients/haskell/sync/tests/test_library.hs | 161 ++ clients/java/sync/src/Un.java | 217 ++ clients/java/sync/test/UnTest.java | 125 + clients/javascript/sync/jest.config.js | 7 + clients/javascript/sync/package.json | 7 +- clients/javascript/sync/src/un.js | 265 +- .../sync/tests/new_functions.test.js | 421 +++ clients/julia/sync/src/un.jl | 1372 +++++++++- clients/julia/tests/test_un.jl | 137 + clients/kotlin/sync/src/un.kt | 451 ++++ clients/kotlin/sync/tests/UnTest.kt | 211 ++ clients/lisp/sync/src/un.lisp | 190 ++ clients/lua/sync/src/un.lua | 1120 +++++--- clients/lua/tests/test_library.lua | 249 ++ clients/nim/sync/src/un.nim | 387 ++- clients/objective-c/sync/src/un.m | 460 +++- clients/ocaml/sync/src/un.ml | 391 +++ clients/ocaml/sync/tests/test_functional.ml | 190 ++ clients/ocaml/sync/tests/test_library.ml | 160 ++ clients/perl/sync/src/un.pl | 2082 ++++++++------- clients/perl/tests/test_library.pl | 239 ++ clients/php/sync/src/un.php | 205 ++ clients/php/sync/tests/NewFunctionsTest.php | 298 +++ clients/powershell/sync/src/un.ps1 | 107 + clients/powershell/tests/Test-Unsandbox.ps1 | 162 ++ clients/prolog/sync/src/un.pro | 186 ++ clients/prolog/tests/test_un.sh | 98 + clients/python/sync/src/un.py | 273 +- .../python/sync/tests/test_new_functions.py | 294 +++ clients/r/sync/src/un.r | 837 ++++++ clients/r/tests/test_un.r | 215 ++ clients/raku/sync/src/un.raku | 700 +++++ clients/ruby/sync/src/un.rb | 195 ++ clients/ruby/sync/test/test_new_functions.rb | 246 ++ clients/rust/sync/src/lib.rs | 309 +++ clients/rust/sync/tests/lib_test.rs | 211 ++ clients/scheme/sync/src/un.scm | 206 ++ clients/swift/sync/src/un.swift | 284 ++- clients/tcl/sync/src/un.tcl | 2268 +++++++---------- clients/tcl/tests/test_library.tcl | 236 ++ clients/typescript/sync/src/un.ts | 50 + clients/v/sync/src/un.v | 307 ++- clients/zig/sync/src/un.zig | 395 +++ clients/zig/sync/tests/test_un.zig | 213 ++ tests/test_un_clj.clj | 22 +- tests/test_un_cr.cr | 137 + tests/test_un_dart.dart | 26 + tests/test_un_forth.fth | 5 + tests/test_un_lisp.lisp | 22 +- tests/test_un_m.sh | 133 + tests/test_un_nim.nim | 172 ++ tests/test_un_raku.raku | 8 + tests/test_un_scm.scm | 23 +- tests/test_un_swift.sh | 242 ++ tests/test_un_v.v | 163 ++ 90 files changed, 28955 insertions(+), 3028 deletions(-) create mode 100755 clients/awk/tests/test_library.awk create mode 100755 clients/bash/tests/test_library.sh create mode 100755 clients/cobol/tests/test_un.sh create mode 100644 clients/cpp/sync/tests/test_un.cpp create mode 100644 clients/csharp/tests/UnsandboxTests.cs create mode 100644 clients/d/sync/tests/test_un.d create mode 100644 clients/dotnet/tests/UnsandboxTests.cs create mode 100644 clients/dotnet/tests/UnsandboxTests.csproj create mode 100644 clients/elixir/sync/tests/test_functional.exs create mode 100644 clients/elixir/sync/tests/test_library.exs create mode 100755 clients/erlang/sync/tests/test_functional.erl create mode 100755 clients/erlang/sync/tests/test_library.erl create mode 100755 clients/fortran/tests/test_un.sh create mode 100644 clients/fsharp/tests/UnsandboxTests.fs create mode 100644 clients/go/sync/tests/un_test.go create mode 100644 clients/groovy/sync/tests/UnTest.groovy create mode 100755 clients/haskell/sync/tests/test_functional.hs create mode 100755 clients/haskell/sync/tests/test_library.hs create mode 100644 clients/javascript/sync/jest.config.js create mode 100644 clients/javascript/sync/tests/new_functions.test.js create mode 100644 clients/julia/tests/test_un.jl create mode 100644 clients/kotlin/sync/tests/UnTest.kt create mode 100644 clients/lua/tests/test_library.lua create mode 100755 clients/ocaml/sync/tests/test_functional.ml create mode 100755 clients/ocaml/sync/tests/test_library.ml create mode 100644 clients/perl/tests/test_library.pl create mode 100644 clients/php/sync/tests/NewFunctionsTest.php create mode 100644 clients/powershell/tests/Test-Unsandbox.ps1 create mode 100755 clients/prolog/tests/test_un.sh create mode 100644 clients/python/sync/tests/test_new_functions.py create mode 100644 clients/r/tests/test_un.r create mode 100644 clients/ruby/sync/test/test_new_functions.rb create mode 100644 clients/rust/sync/tests/lib_test.rs create mode 100755 clients/tcl/tests/test_library.tcl create mode 100644 clients/zig/sync/tests/test_un.zig create mode 100755 tests/test_un_swift.sh diff --git a/clients/awk/sync/src/un.awk b/clients/awk/sync/src/un.awk index 65a3d84..805a6ff 100644 --- a/clients/awk/sync/src/un.awk +++ b/clients/awk/sync/src/un.awk @@ -44,10 +44,12 @@ # Requires: UNSANDBOX_API_KEY environment variable BEGIN { + VERSION = "4.2.50" API_BASE = "https://api.unsandbox.com" PORTAL_BASE = "https://unsandbox.com" LANGUAGES_CACHE_TTL = 3600 # 1 hour cache TTL LANGUAGES_CACHE_FILE = ENVIRON["HOME"] "/.unsandbox/languages.json" + LAST_ERROR = "" # Extension to language map split("py:python js:javascript ts:typescript rb:ruby php:php pl:perl lua:lua sh:bash go:go rs:rust c:c cpp:cpp java:java kt:kotlin cs:csharp fs:fsharp hs:haskell ml:ocaml clj:clojure scm:scheme lisp:commonlisp erl:erlang ex:elixir jl:julia r:r cr:crystal d:d nim:nim zig:zig v:v dart:dart groovy:groovy f90:fortran cob:cobol pro:prolog forth:forth tcl:tcl raku:raku m:objc awk:awk ps1:powershell", pairs, " ") @@ -60,9 +62,50 @@ BEGIN { BLUE = "\033[34m" RED = "\033[31m" GREEN = "\033[32m" + YELLOW = "\033[33m" RESET = "\033[0m" } +# ============================================================================ +# Utility Functions +# ============================================================================ + +function version() { + return VERSION +} + +function last_error() { + return LAST_ERROR +} + +function set_error(msg) { + LAST_ERROR = msg +} + +function detect_language(filename , ext) { + if (filename == "") return "" + ext = get_extension(filename) + if (ext in ext_map) { + return ext_map[ext] + } + return "" +} + +function hmac_sign(secret, message , cmd, sig) { + if (secret == "" || message == "") return "" + cmd = "echo -n '" message "' | openssl dgst -sha256 -hmac '" secret "' | sed 's/^.* //'" + cmd | getline sig + close(cmd) + return sig +} + +function health_check( cmd, result) { + cmd = "curl -s -o /dev/null -w '%{http_code}' '" API_BASE "/health'" + cmd | getline result + close(cmd) + return (result == "200") +} + function get_api_keys( public_key, secret_key, cmd) { # Get public key cmd = "echo -n $UNSANDBOX_PUBLIC_KEY" @@ -242,6 +285,120 @@ function session_kill(id , timestamp, sig_headers, signature, sig_input, sig_ print GREEN "Session terminated: " id RESET } +# Alias for session_kill +function session_destroy(id) { + session_kill(id) +} + +function session_get(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/sessions/" id + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + while ((cmd | getline line) > 0) print line + close(cmd) +} + +function session_freeze(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/sessions/" id "/freeze" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" + system(cmd " > /dev/null") + print GREEN "Session frozen: " id RESET +} + +function session_unfreeze(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/sessions/" id "/unfreeze" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" + system(cmd " > /dev/null") + print GREEN "Session unfreezing: " id RESET +} + +function session_boost(id, vcpu , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json) { + get_api_keys() + endpoint = "/sessions/" id "/boost" + if (vcpu == "") vcpu = 2 + json = "{\"vcpu\":" vcpu "}" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '" json "'" + system(cmd " > /dev/null") + print GREEN "Session boosted: " id RESET +} + +function session_unboost(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/sessions/" id "/unboost" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" + system(cmd " > /dev/null") + print GREEN "Session unboosted: " id RESET +} + +function session_execute(id, command , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { + get_api_keys() + endpoint = "/sessions/" id "/execute" + json = "{\"command\":\"" escape_json(command) "\"}" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" + response = "" + while ((cmd | getline line) > 0) { + response = response line + } + close(cmd) + print response +} + function service_list( timestamp, sig_headers, signature, sig_input, sig_cmd) { get_api_keys() timestamp = systime() @@ -258,6 +415,173 @@ function service_list( timestamp, sig_headers, signature, sig_input, sig_cmd) close(cmd) } +function service_get(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/services/" id + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + while ((cmd | getline line) > 0) print line + close(cmd) +} + +function service_freeze(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/services/" id "/freeze" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" + system(cmd " > /dev/null") + print GREEN "Service frozen: " id RESET +} + +function service_unfreeze(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/services/" id "/unfreeze" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" + system(cmd " > /dev/null") + print GREEN "Service unfreezing: " id RESET +} + +function service_lock(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/services/" id "/lock" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" + system(cmd " > /dev/null") + print GREEN "Service locked: " id RESET +} + +function service_unlock(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { + get_api_keys() + endpoint = "/services/" id "/unlock" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -w '\\n%{http_code}' -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '{}'" + + response = "" + while ((cmd | getline line) > 0) { + response = response line "\n" + } + close(cmd) + + # Extract HTTP code + http_code = 0 + if (match(response, /\n([0-9]+)\n?$/, arr)) { + http_code = arr[1] + } + + if (http_code == 428) { + if (handle_sudo_challenge(response, "POST", endpoint, "{}")) { + return + } + exit 1 + } + + print GREEN "Service unlocked: " id RESET +} + +function service_redeploy(id, bootstrap , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json) { + get_api_keys() + endpoint = "/services/" id "/redeploy" + json = "{}" + if (bootstrap != "") { + json = "{\"bootstrap\":\"" escape_json(bootstrap) "\"}" + } + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '" json "'" + system(cmd " > /dev/null") + print GREEN "Service redeployed: " id RESET +} + +function service_logs(id, lines , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/services/" id "/logs" + if (lines != "") { + endpoint = endpoint "?lines=" lines + } + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + while ((cmd | getline line) > 0) print line + close(cmd) +} + +function service_execute(id, command , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { + get_api_keys() + endpoint = "/services/" id "/execute" + json = "{\"command\":\"" escape_json(command) "\"}" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" + response = "" + while ((cmd | getline line) > 0) { + response = response line + } + close(cmd) + print response +} + # Handle 428 Sudo OTP challenge - prompt user for OTP and retry function handle_sudo_challenge(response, method, endpoint, body , otp, challenge_id, timestamp, sig_headers, signature, sig_input, sig_cmd, cmd, retry_response, line, sudo_headers) { # Extract challenge_id from response @@ -771,6 +1095,11 @@ function validate_key(do_extend , timestamp, sig_headers, signature, sig_inpu } } +# Alias for validate_key for API parity +function validate_keys() { + validate_key(0) +} + function cmd_key(do_extend) { validate_key(do_extend) } @@ -967,6 +1296,91 @@ function snapshot_delete(id , timestamp, sig_headers, signature, sig_input, s print GREEN "Snapshot deleted: " id RESET } +# Alias for snapshot_info +function snapshot_get(id) { + snapshot_info(id) +} + +function snapshot_lock(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/snapshots/" id "/lock" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" + system(cmd " > /dev/null") + print GREEN "Snapshot locked: " id RESET +} + +function snapshot_unlock(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { + get_api_keys() + endpoint = "/snapshots/" id "/unlock" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":{}" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -w '\\n%{http_code}' -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '{}'" + + response = "" + while ((cmd | getline line) > 0) { + response = response line "\n" + } + close(cmd) + + http_code = 0 + if (match(response, /\n([0-9]+)\n?$/, arr)) { + http_code = arr[1] + } + + if (http_code == 428) { + if (handle_sudo_challenge(response, "POST", endpoint, "{}")) { + return + } + exit 1 + } + + print GREEN "Snapshot unlocked: " id RESET +} + +function snapshot_clone(id, clone_type, name , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { + get_api_keys() + endpoint = "/snapshots/" id "/clone" + if (clone_type == "") clone_type = "session" + json = "{\"clone_type\":\"" clone_type "\"" + if (name != "") { + json = json ",\"name\":\"" escape_json(name) "\"" + } + json = json "}" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" + response = "" + while ((cmd | getline line) > 0) { + response = response line + } + close(cmd) + print GREEN "Snapshot cloned" RESET + print response +} + # Image functions function image_list( timestamp, sig_headers, signature, sig_input, sig_cmd) { get_api_keys() @@ -1255,6 +1669,268 @@ function image_clone(id, name , endpoint, json, tmp, timestamp, sig_headers, print response } +# Alias for image_visibility +function image_set_visibility(id, visibility) { + image_visibility(id, visibility) +} + +function image_grant_access(image_id, trusted_key , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { + get_api_keys() + endpoint = "/images/" image_id "/access" + json = "{\"api_key\":\"" trusted_key "\"}" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" + response = "" + while ((cmd | getline line) > 0) { + response = response line + } + close(cmd) + print GREEN "Access granted to " trusted_key RESET +} + +function image_revoke_access(image_id, trusted_key , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/images/" image_id "/access/" trusted_key + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":DELETE:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + system(cmd " > /dev/null") + print GREEN "Access revoked from " trusted_key RESET +} + +function image_list_trusted(image_id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/images/" image_id "/access" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + while ((cmd | getline line) > 0) print line + close(cmd) +} + +function image_transfer(image_id, to_key , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { + get_api_keys() + endpoint = "/images/" image_id "/transfer" + json = "{\"to_api_key\":\"" to_key "\"}" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" + response = "" + while ((cmd | getline line) > 0) { + response = response line + } + close(cmd) + print GREEN "Image transferred to " to_key RESET +} + +# ============================================================================ +# Job Functions (5) +# ============================================================================ + +function execute_async(language, code, network_mode , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) { + get_api_keys() + if (network_mode == "") network_mode = "zerotrust" + json = "{\"language\":\"" language "\",\"code\":\"" escape_json(code) "\",\"network_mode\":\"" network_mode "\",\"ttl\":300}" + tmp = "/tmp/un_awk_async_" PROCINFO["pid"] ".json" + print json > tmp + close(tmp) + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:/execute/async:" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -X POST '" API_BASE "/execute/async' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '@" tmp "'" + response = "" + while ((cmd | getline line) > 0) { + response = response line + } + close(cmd) + system("rm -f " tmp) + print response +} + +function get_job(job_id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/jobs/" job_id + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + while ((cmd | getline line) > 0) print line + close(cmd) +} + +function cancel_job(job_id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/jobs/" job_id + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":DELETE:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + system(cmd " > /dev/null") + print GREEN "Job cancelled: " job_id RESET +} + +function list_jobs( timestamp, sig_headers, signature, sig_input, sig_cmd) { + get_api_keys() + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:/jobs:" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE "/jobs' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + while ((cmd | getline line) > 0) print line + close(cmd) +} + +function wait_job(job_id , delays, i, job_response, status, delay) { + # Polling delays in milliseconds + split("300 450 700 900 650 1600 2000", delays, " ") + + for (i = 0; i < 120; i++) { + # Get job status + job_response = "" + get_api_keys() + endpoint = "/jobs/" job_id + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + while ((cmd | getline line) > 0) { + job_response = job_response line + } + close(cmd) + + # Check status + if (match(job_response, /"status":"([^"]+)"/, arr)) { + status = arr[1] + if (status == "completed") { + print job_response + return + } + if (status == "failed") { + set_error("Job failed") + print RED "Error: Job failed" RESET > "/dev/stderr" + exit 1 + } + } + + # Sleep with jitter + delay = delays[(i % 7) + 1] / 1000 + cmd = "sleep " delay + system(cmd) + } + + set_error("Max polls exceeded") + print RED "Error: Max polls exceeded" RESET > "/dev/stderr" + exit 1 +} + +# Alias for get_languages +function get_languages(json_output) { + languages_list(json_output) +} + +# ============================================================================ +# PaaS Logs Functions (2) +# ============================================================================ + +function logs_fetch(source, lines, since, grep_pattern , timestamp, sig_headers, signature, sig_input, sig_cmd, json, tmp, line, response) { + get_api_keys() + if (source == "") source = "all" + if (lines == "") lines = 100 + if (since == "") since = "1h" + + json = "{\"source\":\"" source "\",\"lines\":" lines ",\"since\":\"" since "\"" + if (grep_pattern != "") { + json = json ",\"grep\":\"" escape_json(grep_pattern) "\"" + } + json = json "}" + + tmp = "/tmp/un_awk_logs_" PROCINFO["pid"] ".json" + print json > tmp + close(tmp) + + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:/paas/logs:" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + cmd = "curl -s -X POST '" API_BASE "/paas/logs' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '@" tmp "'" + response = "" + while ((cmd | getline line) > 0) { + response = response line + } + close(cmd) + system("rm -f " tmp) + print response +} + +function logs_stream() { + set_error("logs_stream requires async support") + print RED "Error: logs_stream requires async support" RESET > "/dev/stderr" + exit 1 +} + function session_snapshot(id, name, hot , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) { get_api_keys() endpoint = "/sessions/" id "/snapshot" diff --git a/clients/awk/tests/test_library.awk b/clients/awk/tests/test_library.awk new file mode 100755 index 0000000..c74dd53 --- /dev/null +++ b/clients/awk/tests/test_library.awk @@ -0,0 +1,313 @@ +#!/usr/bin/env -S awk -f +# Unit Tests for un.awk Library Functions +# +# Tests the ACTUAL exported functions from Un module. +# NO local re-implementations. NO mocking. +# +# Run: awk -f tests/test_library.awk +# +# Note: AWK has limited introspection, so we test via CLI invocation + +BEGIN { + # Test counters + tests_passed = 0 + tests_failed = 0 + + # Colors + GREEN = "\033[32m" + RED = "\033[31m" + RESET = "\033[0m" + + # Get script directory + script_dir = ENVIRON["PWD"] + if (script_dir == "") script_dir = "." + + print "" + print "Testing AWK SDK..." + print "=====================================" + + # ============================================================================ + # Test: Version + # ============================================================================ + + print "" + print "Testing version..." + + # AWK doesn't have introspection like other languages, so we verify the + # script can be loaded and outputs help + cmd = "awk -f " script_dir "/sync/src/un.awk --help 2>&1" + result = "" + while ((cmd | getline line) > 0) { + result = result line "\n" + } + close(cmd) + + if (match(result, /Usage:/)) { + print " " GREEN "[PASS]" RESET " --help shows usage" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " --help shows usage (got: " result ")" + tests_failed++ + } + + # ============================================================================ + # Test: Extension detection (via script) + # ============================================================================ + + print "" + print "Testing extension map..." + + # Test that the extension map exists in the script + cmd = "grep -c 'py:python' " script_dir "/sync/src/un.awk" + cmd | getline count + close(cmd) + + if (count > 0) { + print " " GREEN "[PASS]" RESET " Extension map includes py:python" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " Extension map includes py:python" + tests_failed++ + } + + # Test more extensions + extensions["js"] = "javascript" + extensions["go"] = "go" + extensions["rb"] = "ruby" + extensions["rs"] = "rust" + extensions["lua"] = "lua" + + for (ext in extensions) { + expected = extensions[ext] + pattern = ext ":" expected + cmd = "grep -c '" pattern "' " script_dir "/sync/src/un.awk" + cmd | getline count + close(cmd) + + if (count > 0) { + print " " GREEN "[PASS]" RESET " Extension map includes " ext ":" expected + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " Extension map includes " ext ":" expected + tests_failed++ + } + } + + # ============================================================================ + # Test: HMAC signing (via openssl) + # ============================================================================ + + print "" + print "Testing HMAC signing (openssl)..." + + # AWK SDK uses openssl for HMAC - verify openssl is available + cmd = "which openssl >/dev/null 2>&1 && echo 'available'" + cmd | getline openssl_status + close(cmd) + + if (openssl_status == "available") { + print " " GREEN "[PASS]" RESET " openssl is available" + tests_passed++ + + # Test HMAC generation + cmd = "echo -n 'message' | openssl dgst -sha256 -hmac 'key' 2>/dev/null | sed 's/^.* //'" + cmd | getline sig + close(cmd) + + if (length(sig) == 64) { + print " " GREEN "[PASS]" RESET " HMAC returns 64-char hex string" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " HMAC returns 64-char hex string (got: " length(sig) ")" + tests_failed++ + } + + # Verify hex characters + if (match(sig, /^[0-9a-fA-F]+$/)) { + print " " GREEN "[PASS]" RESET " HMAC returns valid hex" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " HMAC returns valid hex" + tests_failed++ + } + + # Test known HMAC value + if (match(sig, /^6e9ef29b75fffc5b7abae527d58fdadb/)) { + print " " GREEN "[PASS]" RESET " HMAC-SHA256('key', 'message') matches expected prefix" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " HMAC-SHA256('key', 'message') matches expected prefix (got: " sig ")" + tests_failed++ + } + + # Test deterministic output + cmd = "echo -n 'message' | openssl dgst -sha256 -hmac 'key' 2>/dev/null | sed 's/^.* //'" + cmd | getline sig2 + close(cmd) + + if (sig == sig2) { + print " " GREEN "[PASS]" RESET " HMAC is deterministic" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " HMAC is deterministic" + tests_failed++ + } + + } else { + print " " RED "[FAIL]" RESET " openssl is available" + tests_failed++ + } + + # ============================================================================ + # Test: Function existence (via grep) + # ============================================================================ + + print "" + print "Testing function existence..." + + # List of required functions + functions["execute"] = "execute" + functions["session_list"] = "session_list" + functions["session_kill"] = "session_kill" + functions["service_list"] = "service_list" + functions["service_create"] = "service_create" + functions["service_destroy"] = "service_destroy" + functions["service_resize"] = "service_resize" + functions["snapshot_list"] = "snapshot_list" + functions["snapshot_info"] = "snapshot_info" + functions["snapshot_delete"] = "snapshot_delete" + functions["image_list"] = "image_list" + functions["image_info"] = "image_info" + functions["image_delete"] = "image_delete" + functions["image_lock"] = "image_lock" + functions["image_unlock"] = "image_unlock" + functions["image_publish"] = "image_publish" + functions["image_visibility"] = "image_visibility" + functions["image_spawn"] = "image_spawn" + functions["image_clone"] = "image_clone" + functions["validate_key"] = "validate_key" + functions["languages_list"] = "languages_list" + functions["get_api_keys"] = "get_api_keys" + functions["escape_json"] = "escape_json" + functions["handle_sudo_challenge"] = "handle_sudo_challenge" + + for (func in functions) { + pattern = "function " func "\\(" + cmd = "grep -cE '" pattern "' " script_dir "/sync/src/un.awk" + cmd | getline count + close(cmd) + + if (count > 0) { + print " " GREEN "[PASS]" RESET " " func "() exists" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " " func "() exists" + tests_failed++ + } + } + + # ============================================================================ + # Test: CLI commands (via grep) + # ============================================================================ + + print "" + print "Testing CLI commands..." + + commands["session"] = "session" + commands["service"] = "service" + commands["snapshot"] = "snapshot" + commands["image"] = "image" + commands["key"] = "key" + commands["languages"] = "languages" + + for (cmd_name in commands) { + pattern = "ARGV\\[1\\] == \"" cmd_name "\"" + cmd = "grep -c '" pattern "' " script_dir "/sync/src/un.awk" + cmd | getline count + close(cmd) + + if (count > 0) { + print " " GREEN "[PASS]" RESET " CLI command '" cmd_name "' exists" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " CLI command '" cmd_name "' exists" + tests_failed++ + } + } + + # ============================================================================ + # Test: 428 Sudo OTP handling + # ============================================================================ + + print "" + print "Testing 428 Sudo OTP handling..." + + cmd = "grep -c 'handle_sudo_challenge' " script_dir "/sync/src/un.awk" + cmd | getline count + close(cmd) + + if (count > 0) { + print " " GREEN "[PASS]" RESET " 428 sudo challenge handling exists" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " 428 sudo challenge handling exists" + tests_failed++ + } + + cmd = "grep -c 'X-Sudo-OTP' " script_dir "/sync/src/un.awk" + cmd | getline count + close(cmd) + + if (count > 0) { + print " " GREEN "[PASS]" RESET " X-Sudo-OTP header support exists" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " X-Sudo-OTP header support exists" + tests_failed++ + } + + # ============================================================================ + # Test: Languages caching + # ============================================================================ + + print "" + print "Testing languages caching..." + + cmd = "grep -c 'LANGUAGES_CACHE_TTL' " script_dir "/sync/src/un.awk" + cmd | getline count + close(cmd) + + if (count > 0) { + print " " GREEN "[PASS]" RESET " Languages cache TTL defined" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " Languages cache TTL defined" + tests_failed++ + } + + cmd = "grep -c 'write_languages_cache' " script_dir "/sync/src/un.awk" + cmd | getline count + close(cmd) + + if (count > 0) { + print " " GREEN "[PASS]" RESET " Languages cache write function exists" + tests_passed++ + } else { + print " " RED "[FAIL]" RESET " Languages cache write function exists" + tests_failed++ + } + + # ============================================================================ + # Summary + # ============================================================================ + + print "" + print "=====================================" + print "Test Summary" + print "=====================================" + print "Passed: " GREEN tests_passed RESET + print "Failed: " RED tests_failed RESET + print "=====================================" + + exit(tests_failed > 0 ? 1 : 0) +} diff --git a/clients/bash/sync/src/un.sh b/clients/bash/sync/src/un.sh index c90893d..a2b3a3b 100644 --- a/clients/bash/sync/src/un.sh +++ b/clients/bash/sync/src/un.sh @@ -1,308 +1,88 @@ #!/bin/bash # PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # -# This is free public domain software for the public good of a permacomputer. -# Learn more: https://www.permacomputer.com -# Copyright 2025 TimeHexOn & foxhop & russell@unturf +# unsandbox.com Bash SDK (Synchronous) +# Full API with execution, sessions, services, snapshots, and images. # -# unsandbox SDK for Bash - Execute code in secure sandboxes -# https://unsandbox.com | https://api.unsandbox.com/openapi +# Library Usage: +# source un.sh +# result=$(execute "python" "print(42)") +# echo "$result" | jq -r '.stdout' +# +# CLI Usage: +# bash un.sh script.py +# bash un.sh -s python 'print(42)' +# bash un.sh session --list +# bash un.sh service --list +# +# Copyright 2025 TimeHexOn & foxhop & russell@unturf +set -euo pipefail + +VERSION="4.2.50" API_BASE="https://api.unsandbox.com" +PORTAL_BASE="https://unsandbox.com" +LAST_ERROR="" -# Credential loading -load_accounts_csv() { - local path="${1:-$HOME/.unsandbox/accounts.csv}" - [ -f "$path" ] || return 1 - head -1 "$path" +# Colors +BLUE='\033[34m' +RED='\033[31m' +GREEN='\033[32m' +YELLOW='\033[33m' +RESET='\033[0m' + +# ============================================================================ +# Utility Functions +# ============================================================================ + +version() { + echo "$VERSION" } -get_credentials() { - # Tier 1: Arguments - [ -n "$PUBLIC_KEY" ] && [ -n "$SECRET_KEY" ] && echo "$PUBLIC_KEY:$SECRET_KEY" && return - - # Tier 2: Environment - [ -n "$UNSANDBOX_PUBLIC_KEY" ] && [ -n "$UNSANDBOX_SECRET_KEY" ] && \ - echo "$UNSANDBOX_PUBLIC_KEY:$UNSANDBOX_SECRET_KEY" && return - - # Tier 3: Home directory - local creds=$(load_accounts_csv "$HOME/.unsandbox/accounts.csv") - [ -n "$creds" ] && echo "$creds" && return - - # Tier 4: Local directory - creds=$(load_accounts_csv "./accounts.csv") - [ -n "$creds" ] && echo "$creds" && return - - echo "No credentials found" >&2 - exit 1 +last_error() { + echo "$LAST_ERROR" } -# HMAC signature -sign_request() { - local secret="$1" - local timestamp="$2" - local method="$3" - local endpoint="$4" - local body="$5" - - local message="$timestamp:$method:$endpoint:$body" - echo -n "$message" | openssl dgst -sha256 -hmac "$secret" -hex | cut -d' ' -f2 +set_error() { + LAST_ERROR="$1" } -# API request -api_request() { - local method="$1" - local endpoint="$2" - local body="$3" - local extra_headers="${4:-}" - - local creds=$(get_credentials) - local pk=$(echo "$creds" | cut -d: -f1) - local sk=$(echo "$creds" | cut -d: -f2) - - local timestamp=$(date +%s) - local body_str="${body:-{}}" - local signature=$(sign_request "$sk" "$timestamp" "$method" "$endpoint" "$body_str") - - local curl_cmd=(curl -s -X "$method" "$API_BASE$endpoint" - -H "Authorization: Bearer $pk" - -H "X-Timestamp: $timestamp" - -H "X-Signature: $signature" - -H "Content-Type: application/json" - -d "$body_str") - - # Add extra headers if provided - if [ -n "$extra_headers" ]; then - eval "curl_cmd+=($extra_headers)" - fi - - "${curl_cmd[@]}" -} - -# Handle 428 Sudo OTP challenge - prompt user for OTP and retry -handle_sudo_challenge() { - local response="$1" - local method="$2" - local endpoint="$3" - local body="$4" - - # Extract challenge_id from response - local challenge_id=$(echo "$response" | jq -r '.challenge_id // empty' 2>/dev/null) - - echo -e "\033[33mConfirmation required. Check your email for a one-time code.\033[0m" >&2 - echo -n "Enter OTP: " >&2 - read -r otp - - if [ -z "$otp" ]; then - echo -e "\033[31mError: Operation cancelled\033[0m" >&2 - return 1 - fi - - # Retry with sudo headers - local extra_headers="-H 'X-Sudo-OTP: $otp'" - if [ -n "$challenge_id" ]; then - extra_headers="$extra_headers -H 'X-Sudo-Challenge: $challenge_id'" - fi - - local creds=$(get_credentials) - local pk=$(echo "$creds" | cut -d: -f1) - local sk=$(echo "$creds" | cut -d: -f2) - - local timestamp=$(date +%s) - local body_str="${body:-{}}" - local signature=$(sign_request "$sk" "$timestamp" "$method" "$endpoint" "$body_str") - - local retry_result - retry_result=$(curl -s -w '\n%{http_code}' -X "$method" "$API_BASE$endpoint" \ - -H "Authorization: Bearer $pk" \ - -H "X-Timestamp: $timestamp" \ - -H "X-Signature: $signature" \ - -H "Content-Type: application/json" \ - -H "X-Sudo-OTP: $otp" \ - ${challenge_id:+-H "X-Sudo-Challenge: $challenge_id"} \ - -d "$body_str") - - local http_code=$(echo "$retry_result" | tail -1) - local response_body=$(echo "$retry_result" | sed '$d') - - if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then - echo -e "\033[32mOperation completed successfully\033[0m" - return 0 - else - echo -e "\033[31mError: OTP verification failed (HTTP $http_code)\033[0m" >&2 - return 1 - fi -} - -# API request with 428 sudo handling for destructive operations -api_request_with_sudo() { - local method="$1" - local endpoint="$2" - local body="$3" - - local creds=$(get_credentials) - local pk=$(echo "$creds" | cut -d: -f1) - local sk=$(echo "$creds" | cut -d: -f2) - - local timestamp=$(date +%s) - local body_str="${body:-{}}" - local signature=$(sign_request "$sk" "$timestamp" "$method" "$endpoint" "$body_str") - - local result - result=$(curl -s -w '\n%{http_code}' -X "$method" "$API_BASE$endpoint" \ - -H "Authorization: Bearer $pk" \ - -H "X-Timestamp: $timestamp" \ - -H "X-Signature: $signature" \ - -H "Content-Type: application/json" \ - -d "$body_str") - - local http_code=$(echo "$result" | tail -1) - local response_body=$(echo "$result" | sed '$d') - - # Handle 428 Precondition Required (sudo OTP needed) - if [ "$http_code" = "428" ]; then - handle_sudo_challenge "$response_body" "$method" "$endpoint" "$body" - return $? - fi - - if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then - echo -e "\033[31mError: HTTP $http_code\033[0m" >&2 - echo "$response_body" >&2 - return 1 - fi - - echo "$response_body" -} - -# Languages with cache -languages() { - local cache_path="$HOME/.unsandbox/languages.json" - local cache_ttl=3600 - - if [ -f "$cache_path" ]; then - local age=$(($(date +%s) - $(stat -f%m "$cache_path" 2>/dev/null || stat -c%Y "$cache_path" 2>/dev/null || echo 0))) - [ "$age" -lt "$cache_ttl" ] && cat "$cache_path" && return - fi - - local result=$(api_request "GET" "/languages" "") - mkdir -p "$HOME/.unsandbox" - echo "$result" | jq '.languages' > "$cache_path" - echo "$result" | jq '.languages' -} - -# Execute functions -execute() { - local language="$1" - local code="$2" - - local body=$(cat <&2 - exit 1 - fi - local code=$(cat "$file") - local lang=$(detect_language "$file") - execute "$lang" "$code" -} - -# Service toggle functions -set_unfreeze_on_demand() { - local service_id="$1" - local enabled="$2" - local body="{\"unfreeze_on_demand\":$enabled}" - api_request "PATCH" "/services/$service_id" "$body" -} - -# Job management -get_job() { - local job_id="$1" - api_request "GET" "/jobs/$job_id" "" -} - -wait_job() { - local job_id="$1" - local delays=(300 450 700 900 650 1600 2000) - - for i in $(seq 0 119); do - local job=$(get_job "$job_id") - local status=$(echo "$job" | jq -r '.status') - - [ "$status" = "completed" ] && echo "$job" && return 0 - [ "$status" = "failed" ] && exit 1 - - local delay=${delays[$((i % 7))]} - sleep $((delay / 1000)) - done - - echo "Max polls exceeded" >&2 - exit 1 -} - -# Utilities detect_language() { local file="$1" case "$file" in *.py) echo "python" ;; - *.sh) echo "bash" ;; - *.rb) echo "ruby" ;; *.js) echo "javascript" ;; *.ts) echo "typescript" ;; + *.rb) echo "ruby" ;; + *.php) echo "php" ;; + *.pl) echo "perl" ;; + *.lua) echo "lua" ;; + *.sh) echo "bash" ;; *.go) echo "go" ;; *.rs) echo "rust" ;; *.c) echo "c" ;; *.cpp|*.cc|*.cxx) echo "cpp" ;; *.java) echo "java" ;; *.kt) echo "kotlin" ;; - *.php) echo "php" ;; - *.pl) echo "perl" ;; - *.lua) echo "lua" ;; - *.r|*.R) echo "r" ;; - *.jl) echo "julia" ;; + *.cs) echo "csharp" ;; + *.fs) echo "fsharp" ;; *.hs) echo "haskell" ;; *.ml) echo "ocaml" ;; - *.ex|*.exs) echo "elixir" ;; - *.erl) echo "erlang" ;; *.clj) echo "clojure" ;; *.scm) echo "scheme" ;; *.lisp) echo "commonlisp" ;; - *.cs) echo "csharp" ;; - *.fs) echo "fsharp" ;; + *.erl) echo "erlang" ;; + *.ex|*.exs) echo "elixir" ;; + *.jl) echo "julia" ;; + *.r|*.R) echo "r" ;; + *.cr) echo "crystal" ;; *.d) echo "d" ;; *.nim) echo "nim" ;; *.zig) echo "zig" ;; *.v) echo "v" ;; - *.cr) echo "crystal" ;; *.dart) echo "dart" ;; *.groovy) echo "groovy" ;; + *.scala) echo "scala" ;; *.f90|*.f95) echo "fortran" ;; *.cob) echo "cobol" ;; *.tcl) echo "tcl" ;; @@ -310,36 +90,938 @@ detect_language() { *.pro) echo "prolog" ;; *.forth|*.4th) echo "forth" ;; *.m) echo "objc" ;; - *) echo "Error: Cannot detect language for $file" >&2; exit 1 ;; + *) return 1 ;; esac } -# Languages command +hmac_sign() { + local secret="$1" + local message="$2" + echo -n "$message" | openssl dgst -sha256 -hmac "$secret" -hex 2>/dev/null | sed 's/^.* //' +} + +# ============================================================================ +# Credential Management +# ============================================================================ + +load_accounts_csv() { + local path="${1:-$HOME/.unsandbox/accounts.csv}" + [ -f "$path" ] || return 1 + head -1 "$path" 2>/dev/null | grep -v '^#' +} + +get_credentials() { + # Tier 1: Arguments (via PUBLIC_KEY/SECRET_KEY globals) + if [ -n "${PUBLIC_KEY:-}" ] && [ -n "${SECRET_KEY:-}" ]; then + echo "$PUBLIC_KEY:$SECRET_KEY" + return + fi + + # Tier 2: Environment + if [ -n "${UNSANDBOX_PUBLIC_KEY:-}" ] && [ -n "${UNSANDBOX_SECRET_KEY:-}" ]; then + echo "$UNSANDBOX_PUBLIC_KEY:$UNSANDBOX_SECRET_KEY" + return + fi + + # Legacy fallback + if [ -n "${UNSANDBOX_API_KEY:-}" ]; then + echo "$UNSANDBOX_API_KEY:" + return + fi + + # Tier 3: Home directory + local creds + creds=$(load_accounts_csv "$HOME/.unsandbox/accounts.csv" 2>/dev/null || true) + if [ -n "$creds" ]; then + echo "$creds" + return + fi + + # Tier 4: Local directory + creds=$(load_accounts_csv "./accounts.csv" 2>/dev/null || true) + if [ -n "$creds" ]; then + echo "$creds" + return + fi + + set_error "No credentials found" + return 1 +} + +# ============================================================================ +# API Communication +# ============================================================================ + +api_request() { + local method="$1" + local endpoint="$2" + local body="${3:-}" + local extra_headers="${4:-}" + local content_type="${5:-application/json}" + + local creds + creds=$(get_credentials) || return 1 + local pk="${creds%%:*}" + local sk="${creds#*:}" + + local timestamp + timestamp=$(date +%s) + local body_str="${body:-}" + + local signature="" + if [ -n "$sk" ]; then + signature=$(hmac_sign "$sk" "$timestamp:$method:$endpoint:$body_str") + fi + + local curl_args=(-s -X "$method" "$API_BASE$endpoint" + -H "Authorization: Bearer $pk" + -H "Content-Type: $content_type") + + if [ -n "$signature" ]; then + curl_args+=(-H "X-Timestamp: $timestamp" -H "X-Signature: $signature") + fi + + if [ -n "$extra_headers" ]; then + eval "curl_args+=($extra_headers)" + fi + + if [ -n "$body_str" ]; then + curl_args+=(-d "$body_str") + fi + + curl "${curl_args[@]}" +} + +api_request_with_sudo() { + local method="$1" + local endpoint="$2" + local body="${3:-}" + + local creds + creds=$(get_credentials) || return 1 + local pk="${creds%%:*}" + local sk="${creds#*:}" + + local timestamp + timestamp=$(date +%s) + local body_str="${body:-}" + + local signature="" + if [ -n "$sk" ]; then + signature=$(hmac_sign "$sk" "$timestamp:$method:$endpoint:$body_str") + fi + + local result + result=$(curl -s -w '\n%{http_code}' -X "$method" "$API_BASE$endpoint" \ + -H "Authorization: Bearer $pk" \ + -H "Content-Type: application/json" \ + ${signature:+-H "X-Timestamp: $timestamp" -H "X-Signature: $signature"} \ + ${body_str:+-d "$body_str"}) + + local http_code + http_code=$(echo "$result" | tail -1) + local response_body + response_body=$(echo "$result" | sed '$d') + + # Handle 428 - Sudo OTP required + if [ "$http_code" = "428" ]; then + local challenge_id + challenge_id=$(echo "$response_body" | jq -r '.challenge_id // empty' 2>/dev/null || true) + + echo -e "${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}" >&2 + echo -n "Enter OTP: " >&2 + read -r otp + + if [ -z "$otp" ]; then + set_error "Operation cancelled" + return 1 + fi + + # Retry with sudo headers + timestamp=$(date +%s) + if [ -n "$sk" ]; then + signature=$(hmac_sign "$sk" "$timestamp:$method:$endpoint:$body_str") + fi + + result=$(curl -s -w '\n%{http_code}' -X "$method" "$API_BASE$endpoint" \ + -H "Authorization: Bearer $pk" \ + -H "Content-Type: application/json" \ + ${signature:+-H "X-Timestamp: $timestamp" -H "X-Signature: $signature"} \ + -H "X-Sudo-OTP: $otp" \ + ${challenge_id:+-H "X-Sudo-Challenge: $challenge_id"} \ + ${body_str:+-d "$body_str"}) + + http_code=$(echo "$result" | tail -1) + response_body=$(echo "$result" | sed '$d') + fi + + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + set_error "API error ($http_code)" + return 1 + fi + + echo "$response_body" +} + +# ============================================================================ +# Execution Functions (8) +# ============================================================================ + +execute() { + local language="$1" + local code="$2" + local network_mode="${3:-zerotrust}" + + local body + body=$(jq -n --arg lang "$language" --arg code "$code" --arg net "$network_mode" \ + '{language: $lang, code: $code, network_mode: $net, ttl: 60}') + + api_request "POST" "/execute" "$body" +} + +execute_async() { + local language="$1" + local code="$2" + local network_mode="${3:-zerotrust}" + + local body + body=$(jq -n --arg lang "$language" --arg code "$code" --arg net "$network_mode" \ + '{language: $lang, code: $code, network_mode: $net, ttl: 300}') + + api_request "POST" "/execute/async" "$body" +} + +wait_job() { + local job_id="$1" + local delays=(300 450 700 900 650 1600 2000) + + for i in $(seq 0 119); do + local job + job=$(get_job "$job_id") + local status + status=$(echo "$job" | jq -r '.status') + + [ "$status" = "completed" ] && echo "$job" && return 0 + [ "$status" = "failed" ] && { set_error "Job failed"; return 1; } + + local delay=${delays[$((i % 7))]} + sleep "$(echo "scale=3; $delay/1000" | bc)" + done + + set_error "Max polls exceeded" + return 1 +} + +get_job() { + local job_id="$1" + api_request "GET" "/jobs/$job_id" "" +} + +cancel_job() { + local job_id="$1" + api_request "DELETE" "/jobs/$job_id" "" +} + +list_jobs() { + api_request "GET" "/jobs" "" +} + +get_languages() { + local cache_path="$HOME/.unsandbox/languages.json" + local cache_ttl=3600 + + if [ -f "$cache_path" ]; then + local age + age=$(($(date +%s) - $(stat -f%m "$cache_path" 2>/dev/null || stat -c%Y "$cache_path" 2>/dev/null || echo 0))) + if [ "$age" -lt "$cache_ttl" ]; then + cat "$cache_path" + return + fi + fi + + local result + result=$(api_request "GET" "/languages" "") + mkdir -p "$HOME/.unsandbox" + echo "$result" | jq '.languages' > "$cache_path" + echo "$result" | jq '.languages' +} + +# ============================================================================ +# Session Functions (9) +# ============================================================================ + +session_list() { + api_request "GET" "/sessions" "" +} + +session_get() { + local session_id="$1" + api_request "GET" "/sessions/$session_id" "" +} + +session_create() { + local shell="${1:-bash}" + local network="${2:-}" + local vcpu="${3:-}" + + local body + body=$(jq -n --arg shell "$shell" '{shell: $shell}') + + if [ -n "$network" ]; then + body=$(echo "$body" | jq --arg net "$network" '. + {network: $net}') + fi + if [ -n "$vcpu" ]; then + body=$(echo "$body" | jq --argjson vcpu "$vcpu" '. + {vcpu: $vcpu}') + fi + + api_request "POST" "/sessions" "$body" +} + +session_destroy() { + local session_id="$1" + api_request "DELETE" "/sessions/$session_id" "" +} + +session_freeze() { + local session_id="$1" + api_request "POST" "/sessions/$session_id/freeze" "{}" +} + +session_unfreeze() { + local session_id="$1" + api_request "POST" "/sessions/$session_id/unfreeze" "{}" +} + +session_boost() { + local session_id="$1" + local vcpu="${2:-2}" + api_request "POST" "/sessions/$session_id/boost" "{\"vcpu\":$vcpu}" +} + +session_unboost() { + local session_id="$1" + api_request "POST" "/sessions/$session_id/unboost" "{}" +} + +session_execute() { + local session_id="$1" + local command="$2" + api_request "POST" "/sessions/$session_id/execute" "{\"command\":$(echo "$command" | jq -Rs .)}" +} + +# ============================================================================ +# Service Functions (17) +# ============================================================================ + +service_list() { + api_request "GET" "/services" "" +} + +service_get() { + local service_id="$1" + api_request "GET" "/services/$service_id" "" +} + +service_create() { + local name="$1" + local ports="${2:-}" + local bootstrap="${3:-}" + + local body + body=$(jq -n --arg name "$name" '{name: $name}') + + if [ -n "$ports" ]; then + body=$(echo "$body" | jq --argjson ports "[$ports]" '. + {ports: $ports}') + fi + if [ -n "$bootstrap" ]; then + body=$(echo "$body" | jq --arg boot "$bootstrap" '. + {bootstrap: $boot}') + fi + + api_request "POST" "/services" "$body" +} + +service_destroy() { + local service_id="$1" + api_request_with_sudo "DELETE" "/services/$service_id" "" +} + +service_freeze() { + local service_id="$1" + api_request "POST" "/services/$service_id/freeze" "{}" +} + +service_unfreeze() { + local service_id="$1" + api_request "POST" "/services/$service_id/unfreeze" "{}" +} + +service_lock() { + local service_id="$1" + api_request "POST" "/services/$service_id/lock" "{}" +} + +service_unlock() { + local service_id="$1" + api_request_with_sudo "POST" "/services/$service_id/unlock" "{}" +} + +service_set_unfreeze_on_demand() { + local service_id="$1" + local enabled="$2" + api_request "PATCH" "/services/$service_id" "{\"unfreeze_on_demand\":$enabled}" +} + +service_redeploy() { + local service_id="$1" + local bootstrap="${2:-}" + local body="{}" + if [ -n "$bootstrap" ]; then + body=$(jq -n --arg boot "$bootstrap" '{bootstrap: $boot}') + fi + api_request "POST" "/services/$service_id/redeploy" "$body" +} + +service_logs() { + local service_id="$1" + local lines="${2:-}" + local endpoint="/services/$service_id/logs" + [ -n "$lines" ] && endpoint="$endpoint?lines=$lines" + api_request "GET" "$endpoint" "" +} + +service_execute() { + local service_id="$1" + local command="$2" + api_request "POST" "/services/$service_id/execute" "{\"command\":$(echo "$command" | jq -Rs .)}" +} + +service_env_get() { + local service_id="$1" + api_request "GET" "/services/$service_id/env" "" +} + +service_env_set() { + local service_id="$1" + local env_content="$2" + api_request "PUT" "/services/$service_id/env" "$env_content" "" "text/plain" +} + +service_env_delete() { + local service_id="$1" + api_request "DELETE" "/services/$service_id/env" "" +} + +service_env_export() { + local service_id="$1" + api_request "POST" "/services/$service_id/env/export" "{}" +} + +service_resize() { + local service_id="$1" + local vcpu="$2" + api_request "PATCH" "/services/$service_id" "{\"vcpu\":$vcpu}" +} + +# ============================================================================ +# Snapshot Functions (9) +# ============================================================================ + +snapshot_list() { + api_request "GET" "/snapshots" "" +} + +snapshot_get() { + local snapshot_id="$1" + api_request "GET" "/snapshots/$snapshot_id" "" +} + +snapshot_session() { + local session_id="$1" + local name="${2:-}" + local hot="${3:-false}" + + local body="{}" + if [ -n "$name" ] || [ "$hot" = "true" ]; then + body=$(jq -n --arg name "$name" --argjson hot "$hot" \ + '{name: (if $name != "" then $name else null end), hot: $hot}') + fi + + api_request "POST" "/sessions/$session_id/snapshot" "$body" +} + +snapshot_service() { + local service_id="$1" + local name="${2:-}" + local hot="${3:-false}" + + local body="{}" + if [ -n "$name" ] || [ "$hot" = "true" ]; then + body=$(jq -n --arg name "$name" --argjson hot "$hot" \ + '{name: (if $name != "" then $name else null end), hot: $hot}') + fi + + api_request "POST" "/services/$service_id/snapshot" "$body" +} + +snapshot_restore() { + local snapshot_id="$1" + api_request "POST" "/snapshots/$snapshot_id/restore" "{}" +} + +snapshot_delete() { + local snapshot_id="$1" + api_request_with_sudo "DELETE" "/snapshots/$snapshot_id" "" +} + +snapshot_lock() { + local snapshot_id="$1" + api_request "POST" "/snapshots/$snapshot_id/lock" "{}" +} + +snapshot_unlock() { + local snapshot_id="$1" + api_request_with_sudo "POST" "/snapshots/$snapshot_id/unlock" "{}" +} + +snapshot_clone() { + local snapshot_id="$1" + local clone_type="${2:-session}" + local name="${3:-}" + + local body + body=$(jq -n --arg type "$clone_type" --arg name "$name" \ + '{clone_type: $type, name: (if $name != "" then $name else null end)}') + + api_request "POST" "/snapshots/$snapshot_id/clone" "$body" +} + +# ============================================================================ +# Image Functions (13) +# ============================================================================ + +image_list() { + local filter="${1:-}" + local endpoint="/images" + [ -n "$filter" ] && endpoint="$endpoint?filter=$filter" + api_request "GET" "$endpoint" "" +} + +image_get() { + local image_id="$1" + api_request "GET" "/images/$image_id" "" +} + +image_publish() { + local source_type="$1" + local source_id="$2" + local name="${3:-}" + + local body + body=$(jq -n --arg type "$source_type" --arg id "$source_id" --arg name "$name" \ + '{source_type: $type, source_id: $id, name: (if $name != "" then $name else null end)}') + + api_request "POST" "/images/publish" "$body" +} + +image_delete() { + local image_id="$1" + api_request_with_sudo "DELETE" "/images/$image_id" "" +} + +image_lock() { + local image_id="$1" + api_request "POST" "/images/$image_id/lock" "{}" +} + +image_unlock() { + local image_id="$1" + api_request_with_sudo "POST" "/images/$image_id/unlock" "{}" +} + +image_set_visibility() { + local image_id="$1" + local visibility="$2" + api_request "POST" "/images/$image_id/visibility" "{\"visibility\":\"$visibility\"}" +} + +image_grant_access() { + local image_id="$1" + local trusted_key="$2" + api_request "POST" "/images/$image_id/access" "{\"api_key\":\"$trusted_key\"}" +} + +image_revoke_access() { + local image_id="$1" + local trusted_key="$2" + api_request "DELETE" "/images/$image_id/access/$trusted_key" "" +} + +image_list_trusted() { + local image_id="$1" + api_request "GET" "/images/$image_id/access" "" +} + +image_transfer() { + local image_id="$1" + local to_key="$2" + api_request "POST" "/images/$image_id/transfer" "{\"to_api_key\":\"$to_key\"}" +} + +image_spawn() { + local image_id="$1" + local name="${2:-}" + local ports="${3:-}" + + local body="{}" + if [ -n "$name" ] || [ -n "$ports" ]; then + body="{" + local first=1 + if [ -n "$name" ]; then + body="$body\"name\":\"$name\"" + first=0 + fi + if [ -n "$ports" ]; then + [ "$first" -eq 0 ] && body="$body," + body="$body\"ports\":[$ports]" + fi + body="$body}" + fi + + api_request "POST" "/images/$image_id/spawn" "$body" +} + +image_clone() { + local image_id="$1" + local name="${2:-}" + + local body="{}" + if [ -n "$name" ]; then + body="{\"name\":\"$name\"}" + fi + + api_request "POST" "/images/$image_id/clone" "$body" +} + +# ============================================================================ +# PaaS Logs Functions (2) +# ============================================================================ + +logs_fetch() { + local source="${1:-all}" + local lines="${2:-100}" + local since="${3:-1h}" + local grep_pattern="${4:-}" + + local body + body=$(jq -n --arg source "$source" --argjson lines "$lines" --arg since "$since" --arg grep "$grep_pattern" \ + '{source: $source, lines: $lines, since: $since, grep: (if $grep != "" then $grep else null end)}') + + api_request "POST" "/paas/logs" "$body" +} + +logs_stream() { + set_error "logs_stream requires async support" + return 1 +} + +# ============================================================================ +# Key Validation +# ============================================================================ + +validate_keys() { + local creds + creds=$(get_credentials) || return 1 + local pk="${creds%%:*}" + local sk="${creds#*:}" + + local timestamp + timestamp=$(date +%s) + + local signature="" + if [ -n "$sk" ]; then + signature=$(hmac_sign "$sk" "$timestamp:POST:/keys/validate:") + fi + + curl -s -X POST "$PORTAL_BASE/keys/validate" \ + -H "Authorization: Bearer $pk" \ + -H "Content-Type: application/json" \ + ${signature:+-H "X-Timestamp: $timestamp" -H "X-Signature: $signature"} +} + +health_check() { + local result + result=$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/health") + [ "$result" = "200" ] +} + +# ============================================================================ +# CLI Implementation +# ============================================================================ + +run_file() { + local file="$1" + if [ ! -f "$file" ]; then + echo -e "${RED}Error: File not found: $file${RESET}" >&2 + exit 1 + fi + + local code + code=$(cat "$file") + local lang + lang=$(detect_language "$file") || { + echo -e "${RED}Error: Cannot detect language${RESET}" >&2 + exit 1 + } + + local result + result=$(execute "$lang" "$code") + + if ! echo "$result" | jq -e . >/dev/null 2>&1; then + echo -e "${RED}Error: Failed to execute${RESET}" >&2 + exit 1 + fi + + echo "$result" | jq -r '.stdout // empty' + echo "$result" | jq -r '.stderr // empty' >&2 + local exit_code + exit_code=$(echo "$result" | jq -r '.exit_code // 0') + exit "${exit_code:-0}" +} + cmd_languages() { local json_output=0 - # Parse arguments for arg in "$@"; do if [ "$arg" = "--json" ]; then json_output=1 fi done - local result=$(languages) + local result + result=$(get_languages) if [ "$json_output" -eq 1 ]; then - # JSON array output echo "$result" | jq -c '.' else - # One language per line (default) echo "$result" | jq -r '.[]' fi } -# Image command +cmd_key() { + local extend=0 + + for arg in "$@"; do + if [ "$arg" = "--extend" ]; then + extend=1 + fi + done + + local result + result=$(validate_keys) + + local pk + pk=$(echo "$result" | jq -r '.public_key // empty') + + if [ "$extend" -eq 1 ] && [ -n "$pk" ]; then + local url="$PORTAL_BASE/keys/extend?pk=$pk" + echo -e "${BLUE}Opening browser to extend key...${RESET}" + xdg-open "$url" 2>/dev/null || open "$url" 2>/dev/null & + return + fi + + if echo "$result" | jq -e '.expired' >/dev/null 2>&1; then + echo -e "${RED}Expired${RESET}" + echo "Public Key: $pk" + echo "Tier: $(echo "$result" | jq -r '.tier // "N/A"')" + echo -e "${YELLOW}To renew: Visit $PORTAL_BASE/keys/extend${RESET}" + exit 1 + fi + + echo -e "${GREEN}Valid${RESET}" + echo "Public Key: $pk" + echo "Tier: $(echo "$result" | jq -r '.tier // "N/A"')" + echo "Status: $(echo "$result" | jq -r '.status // "N/A"')" + echo "Expires: $(echo "$result" | jq -r '.expires_at // "N/A"')" + echo "Time Remaining: $(echo "$result" | jq -r '.time_remaining // "N/A"')" +} + +cmd_session() { + local action="" + local target="" + + while [ $# -gt 0 ]; do + case "$1" in + --list|-l) action="list" ;; + --info) action="info"; target="$2"; shift ;; + --kill) action="kill"; target="$2"; shift ;; + --freeze) action="freeze"; target="$2"; shift ;; + --unfreeze) action="unfreeze"; target="$2"; shift ;; + --boost) action="boost"; target="$2"; shift ;; + --unboost) action="unboost"; target="$2"; shift ;; + *) ;; + esac + shift + done + + case "$action" in + list) + local result + result=$(session_list) + echo "$result" | jq -r '.sessions[] | "\(.id)\t\(.shell)\t\(.status)\t\(.created_at)"' 2>/dev/null || echo "No sessions" + ;; + info) + session_get "$target" | jq . + ;; + kill) + session_destroy "$target" + echo -e "${GREEN}Session terminated: $target${RESET}" + ;; + freeze) + session_freeze "$target" + echo -e "${GREEN}Session frozen: $target${RESET}" + ;; + unfreeze) + session_unfreeze "$target" + echo -e "${GREEN}Session unfreezing: $target${RESET}" + ;; + boost) + session_boost "$target" + echo -e "${GREEN}Session boosted: $target${RESET}" + ;; + unboost) + session_unboost "$target" + echo -e "${GREEN}Session unboosted: $target${RESET}" + ;; + *) + echo "Usage: bash un.sh session --list|--info ID|--kill ID|--freeze ID|--unfreeze ID" >&2 + exit 1 + ;; + esac +} + +cmd_service() { + local action="" + local target="" + local name="" + local ports="" + + while [ $# -gt 0 ]; do + case "$1" in + --list|-l) action="list" ;; + --info) action="info"; target="$2"; shift ;; + --destroy) action="destroy"; target="$2"; shift ;; + --freeze) action="freeze"; target="$2"; shift ;; + --unfreeze) action="unfreeze"; target="$2"; shift ;; + --lock) action="lock"; target="$2"; shift ;; + --unlock) action="unlock"; target="$2"; shift ;; + --logs) action="logs"; target="$2"; shift ;; + --name) name="$2"; shift ;; + --ports) ports="$2"; shift ;; + *) ;; + esac + shift + done + + case "$action" in + list) + local result + result=$(service_list) + echo "$result" | jq -r '.services[] | "\(.id)\t\(.name)\t\(.status)\t\(.ports | join(","))"' 2>/dev/null || echo "No services" + ;; + info) + service_get "$target" | jq . + ;; + destroy) + service_destroy "$target" + echo -e "${GREEN}Service destroyed: $target${RESET}" + ;; + freeze) + service_freeze "$target" + echo -e "${GREEN}Service frozen: $target${RESET}" + ;; + unfreeze) + service_unfreeze "$target" + echo -e "${GREEN}Service unfreezing: $target${RESET}" + ;; + lock) + service_lock "$target" + echo -e "${GREEN}Service locked: $target${RESET}" + ;; + unlock) + service_unlock "$target" + echo -e "${GREEN}Service unlocked: $target${RESET}" + ;; + logs) + local result + result=$(service_logs "$target") + echo "$result" | jq -r '.logs // empty' + ;; + *) + if [ -n "$name" ]; then + local result + result=$(service_create "$name" "$ports" "") + echo -e "${GREEN}Service created${RESET}" + echo "$result" | jq -r '"ID: \(.id)\nName: \(.name)"' + else + echo "Usage: bash un.sh service --list|--info ID|--destroy ID|--name NAME" >&2 + exit 1 + fi + ;; + esac +} + +cmd_snapshot() { + local action="" + local target="" + + while [ $# -gt 0 ]; do + case "$1" in + --list|-l) action="list" ;; + --info) action="info"; target="$2"; shift ;; + --delete) action="delete"; target="$2"; shift ;; + --restore) action="restore"; target="$2"; shift ;; + --lock) action="lock"; target="$2"; shift ;; + --unlock) action="unlock"; target="$2"; shift ;; + *) ;; + esac + shift + done + + case "$action" in + list) + local result + result=$(snapshot_list) + echo "$result" | jq -r '.snapshots[] | "\(.id)\t\(.name)\t\(.type)\t\(.created_at)"' 2>/dev/null || echo "No snapshots" + ;; + info) + snapshot_get "$target" | jq . + ;; + delete) + snapshot_delete "$target" + echo -e "${GREEN}Snapshot deleted: $target${RESET}" + ;; + restore) + snapshot_restore "$target" + echo -e "${GREEN}Snapshot restored${RESET}" + ;; + lock) + snapshot_lock "$target" + echo -e "${GREEN}Snapshot locked: $target${RESET}" + ;; + unlock) + snapshot_unlock "$target" + echo -e "${GREEN}Snapshot unlocked: $target${RESET}" + ;; + *) + echo "Usage: bash un.sh snapshot --list|--info ID|--delete ID|--restore ID" >&2 + exit 1 + ;; + esac +} + cmd_image() { local action="" - local id="" + local target="" local source_type="" local visibility_mode="" local name="" @@ -347,191 +1029,198 @@ cmd_image() { while [ $# -gt 0 ]; do case "$1" in - --list|-l) - action="list" - shift - ;; - --info) - action="info" - id="$2" - shift 2 - ;; - --delete) - action="delete" - id="$2" - shift 2 - ;; - --lock) - action="lock" - id="$2" - shift 2 - ;; - --unlock) - action="unlock" - id="$2" - shift 2 - ;; - --publish) - action="publish" - id="$2" - shift 2 - ;; - --source-type) - source_type="$2" - shift 2 - ;; - --visibility) - action="visibility" - id="$2" - visibility_mode="$3" - shift 3 - ;; - --spawn) - action="spawn" - id="$2" - shift 2 - ;; - --clone) - action="clone" - id="$2" - shift 2 - ;; - --name) - name="$2" - shift 2 - ;; - --ports) - ports="$2" - shift 2 - ;; - *) - echo "Unknown option: $1" >&2 - exit 1 - ;; + --list|-l) action="list" ;; + --info) action="info"; target="$2"; shift ;; + --delete) action="delete"; target="$2"; shift ;; + --lock) action="lock"; target="$2"; shift ;; + --unlock) action="unlock"; target="$2"; shift ;; + --publish) action="publish"; target="$2"; shift ;; + --source-type) source_type="$2"; shift ;; + --visibility) action="visibility"; target="$2"; visibility_mode="$3"; shift 2 ;; + --spawn) action="spawn"; target="$2"; shift ;; + --clone) action="clone"; target="$2"; shift ;; + --name) name="$2"; shift ;; + --ports) ports="$2"; shift ;; + *) ;; esac + shift done case "$action" in list) - result=$(api_request "GET" "/images" "") - echo "$result" | jq -r '.images[] | "\(.id)\t\(.name // "-")\t\(.visibility)\t\(.created_at)"' 2>/dev/null || echo "No images found" + local result + result=$(image_list) + echo "$result" | jq -r '.images[] | "\(.id)\t\(.name // "-")\t\(.visibility)\t\(.created_at)"' 2>/dev/null || echo "No images" ;; info) - result=$(api_request "GET" "/images/$id" "") - echo "$result" | jq . + image_get "$target" | jq . ;; delete) - api_request_with_sudo "DELETE" "/images/$id" "" - echo "Image deleted successfully" + image_delete "$target" + echo -e "${GREEN}Image deleted: $target${RESET}" ;; lock) - api_request "POST" "/images/$id/lock" "{}" - echo "Image locked successfully" + image_lock "$target" + echo -e "${GREEN}Image locked: $target${RESET}" ;; unlock) - api_request_with_sudo "POST" "/images/$id/unlock" "{}" - echo "Image unlocked successfully" + image_unlock "$target" + echo -e "${GREEN}Image unlocked: $target${RESET}" ;; publish) if [ -z "$source_type" ]; then - echo "Error: --source-type required for --publish" >&2 + echo -e "${RED}Error: --source-type required${RESET}" >&2 exit 1 fi - local body="{\"source_type\":\"$source_type\",\"source_id\":\"$id\"" - if [ -n "$name" ]; then - body="$body,\"name\":\"$name\"" - fi - body="$body}" - result=$(api_request "POST" "/images/publish" "$body") - echo "Image published successfully" + local result + result=$(image_publish "$source_type" "$target" "$name") + echo -e "${GREEN}Image published${RESET}" echo "$result" | jq -r '"Image ID: \(.id)"' ;; visibility) - if [ -z "$visibility_mode" ]; then - echo "Error: visibility mode required" >&2 - exit 1 - fi - api_request "POST" "/images/$id/visibility" "{\"visibility\":\"$visibility_mode\"}" - echo "Image visibility set to $visibility_mode" + image_set_visibility "$target" "$visibility_mode" + echo -e "${GREEN}Visibility set to $visibility_mode${RESET}" ;; spawn) - local body="{" - local first=1 - if [ -n "$name" ]; then - body="$body\"name\":\"$name\"" - first=0 - fi - if [ -n "$ports" ]; then - if [ "$first" -eq 0 ]; then - body="$body," - fi - body="$body\"ports\":[$ports]" - fi - body="$body}" - result=$(api_request "POST" "/images/$id/spawn" "$body") - echo "Service spawned from image" + local result + result=$(image_spawn "$target" "$name" "$ports") + echo -e "${GREEN}Service spawned from image${RESET}" echo "$result" | jq -r '"Service ID: \(.id)"' ;; clone) - local body="{" - if [ -n "$name" ]; then - body="$body\"name\":\"$name\"" - fi - body="$body}" - result=$(api_request "POST" "/images/$id/clone" "$body") - echo "Image cloned successfully" + local result + result=$(image_clone "$target" "$name") + echo -e "${GREEN}Image cloned${RESET}" echo "$result" | jq -r '"Image ID: \(.id)"' ;; *) - echo "Error: Specify --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID" >&2 + echo "Usage: bash un.sh image --list|--info ID|--delete ID|--publish ID|--spawn ID|--clone ID" >&2 exit 1 ;; esac } -# CLI -if [ $# -gt 0 ]; then +show_help() { + cat << 'EOF' +Unsandbox CLI - Execute code in secure sandboxes + +Usage: + bash un.sh [options] + bash un.sh -s '' + bash un.sh session [options] + bash un.sh service [options] + bash un.sh snapshot [options] + bash un.sh image [options] + bash un.sh languages [--json] + bash un.sh key [--extend] + +Commands: + languages List available programming languages + key Validate API key + session Manage interactive sessions + service Manage persistent services + snapshot Manage snapshots + image Manage images + +Session options: + --list List all sessions + --info ID Get session details + --kill ID Terminate session + --freeze ID Freeze session + --unfreeze ID Unfreeze session + --boost ID Boost session CPU + --unboost ID Unboost session CPU + +Service options: + --list List all services + --info ID Get service details + --destroy ID Destroy service + --freeze ID Freeze service + --unfreeze ID Unfreeze service + --lock ID Lock service + --unlock ID Unlock service + --logs ID Get service logs + --name NAME Create service with name + --ports PORTS Service ports (comma-separated) + +Snapshot options: + --list List all snapshots + --info ID Get snapshot details + --delete ID Delete snapshot + --restore ID Restore from snapshot + --lock ID Lock snapshot + --unlock ID Unlock snapshot + +Image options: + --list List all images + --info ID Get image details + --delete ID Delete image + --lock ID Lock image + --unlock ID Unlock image + --publish ID Publish from service/snapshot (needs --source-type) + --source-type TYPE Source type (service or snapshot) + --visibility ID MODE Set visibility (private|unlisted|public) + --spawn ID Spawn service from image + --clone ID Clone image + --name NAME Name for spawned service or cloned image + --ports PORTS Ports for spawned service + +Environment: + UNSANDBOX_PUBLIC_KEY API public key + UNSANDBOX_SECRET_KEY API secret key +EOF +} + +# CLI entry point +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + if [ $# -eq 0 ]; then + show_help + exit 1 + fi + case "$1" in languages) shift cmd_languages "$@" ;; + key) + shift + cmd_key "$@" + ;; + session) + shift + cmd_session "$@" + ;; + service) + shift + cmd_service "$@" + ;; + snapshot) + shift + cmd_snapshot "$@" + ;; image) shift cmd_image "$@" ;; - *) - result=$(run "$1") - if [ -z "$result" ] || ! echo "$result" | jq -e . >/dev/null 2>&1; then - echo "Error: Failed to execute $1" >&2 + -s) + lang="$2" + code="$3" + if [ -z "$lang" ] || [ -z "$code" ]; then + echo -e "${RED}Error: -s requires language and code${RESET}" >&2 exit 1 fi + result=$(execute "$lang" "$code") echo "$result" | jq -r '.stdout // empty' echo "$result" | jq -r '.stderr // empty' >&2 exit_code=$(echo "$result" | jq -r '.exit_code // 0') exit "${exit_code:-0}" ;; + --help|-h) + show_help + ;; + *) + run_file "$1" + ;; esac -else - echo "Usage: bash un.sh " >&2 - echo " bash un.sh languages [--json]" >&2 - echo " bash un.sh image [options]" >&2 - echo "" >&2 - echo "Languages options:" >&2 - echo " --json Output as JSON array" >&2 - echo "" >&2 - echo "Image options:" >&2 - echo " --list List all images" >&2 - echo " --info ID Get image details" >&2 - echo " --delete ID Delete an image" >&2 - echo " --lock ID Lock image to prevent deletion" >&2 - echo " --unlock ID Unlock image" >&2 - echo " --publish ID Publish image from service/snapshot" >&2 - echo " --source-type TYPE Source type: service or snapshot" >&2 - echo " --visibility ID MODE Set visibility: private, unlisted, public" >&2 - echo " --spawn ID Spawn new service from image" >&2 - echo " --clone ID Clone an image" >&2 - echo " --name NAME Name for spawned service or cloned image" >&2 - echo " --ports PORTS Ports for spawned service" >&2 - exit 1 fi diff --git a/clients/bash/tests/test_library.sh b/clients/bash/tests/test_library.sh new file mode 100755 index 0000000..ca63d54 --- /dev/null +++ b/clients/bash/tests/test_library.sh @@ -0,0 +1,263 @@ +#!/bin/bash +# Unit Tests for un.sh Library Functions +# +# Tests the ACTUAL exported functions from Un module. +# NO local re-implementations. NO mocking. +# +# Run: bash tests/test_library.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../sync/src/un.sh" 2>/dev/null || { + echo "Error: Cannot source un.sh" + exit 1 +} + +# Test counters +tests_passed=0 +tests_failed=0 + +PASS() { + echo -e " \033[32m[PASS]\033[0m $1" + tests_passed=$((tests_passed + 1)) +} + +FAIL() { + echo -e " \033[31m[FAIL]\033[0m $1" + tests_failed=$((tests_failed + 1)) +} + +assert_equal() { + local actual="$1" + local expected="$2" + local msg="$3" + if [ "$actual" = "$expected" ]; then + PASS "$msg" + else + FAIL "$msg (expected: $expected, got: $actual)" + fi +} + +assert_not_empty() { + local value="$1" + local msg="$2" + if [ -n "$value" ]; then + PASS "$msg" + else + FAIL "$msg (expected non-empty)" + fi +} + +assert_match() { + local value="$1" + local pattern="$2" + local msg="$3" + if [[ "$value" =~ $pattern ]]; then + PASS "$msg" + else + FAIL "$msg (value: $value does not match pattern: $pattern)" + fi +} + +# ============================================================================ +# Test: version() +# ============================================================================ + +echo "" +echo "Testing version()..." + +ver=$(version) +assert_not_empty "$ver" "version() returns non-empty string" +assert_match "$ver" "^[0-9]+\.[0-9]+\.[0-9]+$" "version() matches X.Y.Z format" +echo " Version: $ver" + +# ============================================================================ +# Test: detect_language() +# ============================================================================ + +echo "" +echo "Testing detect_language()..." + +declare -A lang_tests=( + ["test.py"]="python" + ["app.js"]="javascript" + ["main.go"]="go" + ["script.rb"]="ruby" + ["lib.rs"]="rust" + ["main.c"]="c" + ["app.cpp"]="cpp" + ["Main.java"]="java" + ["index.php"]="php" + ["script.pl"]="perl" + ["init.lua"]="lua" + ["run.sh"]="bash" + ["main.ts"]="typescript" + ["app.kt"]="kotlin" + ["lib.ex"]="elixir" + ["main.hs"]="haskell" +) + +for file in "${!lang_tests[@]}"; do + expected="${lang_tests[$file]}" + result=$(detect_language "$file" 2>/dev/null || echo "") + assert_equal "$result" "$expected" "detect_language('$file') -> '$expected'" +done + +# Test unknown extension +result=$(detect_language "file.xyz123" 2>/dev/null || echo "") +assert_equal "$result" "" "detect_language(unknown ext) returns empty" + +# Test no extension +result=$(detect_language "Makefile" 2>/dev/null || echo "") +assert_equal "$result" "" "detect_language(no ext) returns empty" + +# ============================================================================ +# Test: hmac_sign() +# ============================================================================ + +echo "" +echo "Testing hmac_sign()..." + +# Test basic signature generation +sig=$(hmac_sign "secret_key" "1234567890:POST:/execute:{}") +assert_not_empty "$sig" "hmac_sign() returns non-nil" +assert_equal "${#sig}" "64" "hmac_sign() returns 64-char hex string" + +# Verify hex characters +if [[ "$sig" =~ ^[0-9a-fA-F]+$ ]]; then + PASS "hmac_sign() returns valid hex" +else + FAIL "hmac_sign() returns valid hex" +fi + +# Test deterministic output +sig1=$(hmac_sign "key" "message") +sig2=$(hmac_sign "key" "message") +assert_equal "$sig1" "$sig2" "hmac_sign() is deterministic" + +# Test different keys produce different signatures +sig_a=$(hmac_sign "key_a" "message") +sig_b=$(hmac_sign "key_b" "message") +if [ "$sig_a" != "$sig_b" ]; then + PASS "Different keys produce different signatures" +else + FAIL "Different keys produce different signatures" +fi + +# Test different messages produce different signatures +sig_m1=$(hmac_sign "key" "message1") +sig_m2=$(hmac_sign "key" "message2") +if [ "$sig_m1" != "$sig_m2" ]; then + PASS "Different messages produce different signatures" +else + FAIL "Different messages produce different signatures" +fi + +# Test known HMAC value +known_sig=$(hmac_sign "key" "message") +if [[ "$known_sig" == 6e9ef29b75fffc5b7abae527d58fdadb* ]]; then + PASS "HMAC-SHA256('key', 'message') matches expected prefix" +else + FAIL "HMAC-SHA256('key', 'message') matches expected prefix (got: $known_sig)" +fi + +# ============================================================================ +# Test: last_error() / set_error() +# ============================================================================ + +echo "" +echo "Testing last_error()..." + +set_error "test error" +err=$(last_error) +assert_equal "$err" "test error" "last_error() returns set error" + +# ============================================================================ +# Test: Memory stress test +# ============================================================================ + +echo "" +echo "Testing Memory Management..." + +# Stress test HMAC allocation +for i in $(seq 1 1000); do + hmac_sign "key" "message" > /dev/null +done +PASS "1000 HMAC calls without crash" + +# Stress test language detection +for i in $(seq 1 1000); do + detect_language "test.py" > /dev/null 2>&1 || true +done +PASS "1000 detect_language calls without crash" + +# Stress test version +for i in $(seq 1 1000); do + version > /dev/null +done +PASS "1000 version calls without crash" + +# ============================================================================ +# Test: Function existence +# ============================================================================ + +echo "" +echo "Testing Library function existence..." + +functions=( + # Execution functions (8) + "execute" "execute_async" "wait_job" "get_job" + "cancel_job" "list_jobs" "get_languages" "detect_language" + + # Session functions (9) + "session_list" "session_get" "session_create" "session_destroy" + "session_freeze" "session_unfreeze" "session_boost" "session_unboost" + "session_execute" + + # Service functions (17) + "service_list" "service_get" "service_create" "service_destroy" + "service_freeze" "service_unfreeze" "service_lock" "service_unlock" + "service_set_unfreeze_on_demand" "service_redeploy" "service_logs" + "service_execute" "service_env_get" "service_env_set" + "service_env_delete" "service_env_export" "service_resize" + + # Snapshot functions (9) + "snapshot_list" "snapshot_get" "snapshot_session" "snapshot_service" + "snapshot_restore" "snapshot_delete" "snapshot_lock" "snapshot_unlock" + "snapshot_clone" + + # Image functions (13) + "image_list" "image_get" "image_publish" "image_delete" + "image_lock" "image_unlock" "image_set_visibility" + "image_grant_access" "image_revoke_access" "image_list_trusted" + "image_transfer" "image_spawn" "image_clone" + + # PaaS Logs (2) + "logs_fetch" "logs_stream" + + # Utilities + "validate_keys" "hmac_sign" "health_check" "version" "last_error" +) + +for func in "${functions[@]}"; do + if declare -f "$func" > /dev/null 2>&1; then + PASS "$func() exists" + else + FAIL "$func() exists" + fi +done + +# ============================================================================ +# Summary +# ============================================================================ + +echo "" +echo "=====================================" +echo "Test Summary" +echo "=====================================" +echo -e "Passed: \033[32m$tests_passed\033[0m" +echo -e "Failed: \033[31m$tests_failed\033[0m" +echo "=====================================" + +exit $((tests_failed > 0 ? 1 : 0)) diff --git a/clients/clojure/sync/src/un.clj b/clients/clojure/sync/src/un.clj index 83bf56f..20ad8aa 100644 --- a/clients/clojure/sync/src/un.clj +++ b/clients/clojure/sync/src/un.clj @@ -732,6 +732,178 @@ (println (str green "Image cloned" reset)) (println (curl-post api-key (str "/images/" id "/clone") json)))) +;; Image access management functions +(defn image-grant-access [id trusted-key] + (let [api-key (get-api-key) + json (str "{\"trusted_api_key\":\"" trusted-key "\"}")] + (curl-post api-key (str "/images/" id "/grant-access") json) + (println (str green "Access granted to: " trusted-key reset)))) + +(defn image-revoke-access [id trusted-key] + (let [api-key (get-api-key) + json (str "{\"trusted_api_key\":\"" trusted-key "\"}")] + (curl-post api-key (str "/images/" id "/revoke-access") json) + (println (str green "Access revoked from: " trusted-key reset)))) + +(defn image-list-trusted [id] + (let [api-key (get-api-key)] + (println (curl-get api-key (str "/images/" id "/trusted"))))) + +(defn image-transfer [id to-key] + (let [api-key (get-api-key) + json (str "{\"to_api_key\":\"" to-key "\"}")] + (curl-post api-key (str "/images/" id "/transfer") json) + (println (str green "Image transferred to: " to-key reset)))) + +;; Snapshot functions +(defn snapshot-list [] + (let [api-key (get-api-key)] + (println (curl-get api-key "/snapshots")))) + +(defn snapshot-info [id] + (let [api-key (get-api-key)] + (println (curl-get api-key (str "/snapshots/" id))))) + +(defn snapshot-session [session-id name hot] + (let [api-key (get-api-key) + json (str "{\"session_id\":\"" session-id "\"" + (if name (str ",\"name\":\"" (escape-json name) "\"") "") + (if hot ",\"hot\":true" "") + "}")] + (println (str green "Snapshot created" reset)) + (println (curl-post api-key "/snapshots" json)))) + +(defn snapshot-service [service-id name hot] + (let [api-key (get-api-key) + json (str "{\"service_id\":\"" service-id "\"" + (if name (str ",\"name\":\"" (escape-json name) "\"") "") + (if hot ",\"hot\":true" "") + "}")] + (println (str green "Snapshot created" reset)) + (println (curl-post api-key "/snapshots" json)))) + +(defn snapshot-restore [id] + (let [api-key (get-api-key)] + (curl-post api-key (str "/snapshots/" id "/restore") "{}") + (println (str green "Snapshot restored: " id reset)))) + +(defn snapshot-delete [id] + (let [api-key (get-api-key) + result (curl-delete-with-sudo api-key (str "/snapshots/" id))] + (if (:success result) + (println (str green "Snapshot deleted: " id reset)) + (do + (binding [*out* *err*] + (println (str red "Error deleting snapshot" reset))) + (System/exit 1))))) + +(defn snapshot-lock [id] + (let [api-key (get-api-key)] + (curl-post api-key (str "/snapshots/" id "/lock") "{}") + (println (str green "Snapshot locked: " id reset)))) + +(defn snapshot-unlock [id] + (let [api-key (get-api-key) + result (curl-post-with-sudo api-key (str "/snapshots/" id "/unlock") "{}")] + (if (:success result) + (println (str green "Snapshot unlocked: " id reset)) + (do + (binding [*out* *err*] + (println (str red "Error unlocking snapshot" reset))) + (System/exit 1))))) + +(defn snapshot-clone [id clone-type name ports shell] + (let [api-key (get-api-key) + json (str "{\"clone_type\":\"" clone-type "\"" + (if name (str ",\"name\":\"" (escape-json name) "\"") "") + (if ports (str ",\"ports\":[" ports "]") "") + (if shell (str ",\"shell\":\"" shell "\"") "") + "}")] + (println (str green "Snapshot cloned" reset)) + (println (curl-post api-key (str "/snapshots/" id "/clone") json)))) + +(defn snapshot-command [action id name ports shell hot] + (case action + :list (snapshot-list) + :info (snapshot-info id) + :session (snapshot-session id name hot) + :service (snapshot-service id name hot) + :restore (snapshot-restore id) + :delete (snapshot-delete id) + :lock (snapshot-lock id) + :unlock (snapshot-unlock id) + :clone (snapshot-clone id "session" name ports shell))) + +;; Session additional functions +(defn session-info [id] + (let [api-key (get-api-key)] + (println (curl-get api-key (str "/sessions/" id))))) + +(defn session-boost [id vcpu] + (let [api-key (get-api-key) + json (str "{\"vcpu\":" vcpu "}")] + (curl-patch api-key (str "/sessions/" id) json) + (println (str green "Session boosted to " vcpu " vCPU" reset)))) + +(defn session-unboost [id] + (let [api-key (get-api-key) + json "{\"vcpu\":1}"] + (curl-patch api-key (str "/sessions/" id) json) + (println (str green "Session unboosted to 1 vCPU" reset)))) + +(defn session-execute [id command] + (let [api-key (get-api-key) + json (str "{\"command\":\"" (escape-json command) "\"}") + response (curl-post api-key (str "/sessions/" id "/execute") json) + stdout-val (extract-field "stdout" response)] + (when stdout-val + (print (str blue (unescape-json stdout-val) reset)) + (flush)))) + +;; Service additional functions +(defn service-lock [id] + (let [api-key (get-api-key)] + (curl-post api-key (str "/services/" id "/lock") "{}") + (println (str green "Service locked: " id reset)))) + +(defn service-unlock [id] + (let [api-key (get-api-key) + result (curl-post-with-sudo api-key (str "/services/" id "/unlock") "{}")] + (if (:success result) + (println (str green "Service unlocked: " id reset)) + (do + (binding [*out* *err*] + (println (str red "Error unlocking service" reset))) + (System/exit 1))))) + +(defn service-redeploy [id bootstrap] + (let [api-key (get-api-key) + json (if bootstrap + (str "{\"bootstrap\":\"" (escape-json bootstrap) "\"}") + "{}")] + (curl-post api-key (str "/services/" id "/redeploy") json) + (println (str green "Service redeploying: " id reset)))) + +;; PaaS logs functions +(defn logs-fetch [source lines since grep-pattern] + (let [api-key (get-api-key) + params (str "?source=" (or source "all") + "&lines=" (or lines 100) + (if since (str "&since=" since) "") + (if grep-pattern (str "&grep=" (java.net.URLEncoder/encode grep-pattern "UTF-8")) ""))] + (println (curl-get api-key (str "/logs" params))))) + +;; Utility functions +(defn health-check [] + (try + (let [result (:out (sh "curl" "-s" "https://api.unsandbox.com/health"))] + (println result) + (str/includes? result "ok")) + (catch Exception _ false))) + +(defn version [] + "4.2.0") + (defn image-command [action id source-type visibility name ports] (case action :list (image-list) @@ -824,6 +996,22 @@ (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file service-unfreeze-on-demand key-extend image-action image-id image-source-type image-visibility image-name image-ports :image) + (= (first args) "snapshot") + (let [rest-args (rest args)] + (cond + (empty? rest-args) (do (snapshot-list) (System/exit 0)) + (= (first rest-args) "--list") (do (snapshot-list) (System/exit 0)) + (= (first rest-args) "-l") (do (snapshot-list) (System/exit 0)) + (= (first rest-args) "--info") (do (snapshot-info (second rest-args)) (System/exit 0)) + (= (first rest-args) "--session") (do (snapshot-session (second rest-args) nil false) (System/exit 0)) + (= (first rest-args) "--service") (do (snapshot-service (second rest-args) nil false) (System/exit 0)) + (= (first rest-args) "--restore") (do (snapshot-restore (second rest-args)) (System/exit 0)) + (= (first rest-args) "--delete") (do (snapshot-delete (second rest-args)) (System/exit 0)) + (= (first rest-args) "--lock") (do (snapshot-lock (second rest-args)) (System/exit 0)) + (= (first rest-args) "--unlock") (do (snapshot-unlock (second rest-args)) (System/exit 0)) + (= (first rest-args) "--clone") (do (snapshot-clone (second rest-args) "session" nil nil nil) (System/exit 0)) + :else (do (println "Error: Unknown snapshot action") (System/exit 1)))) + ;; Image options (and (= mode :image) (or (= (first args) "--list") (= (first args) "-l"))) (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files diff --git a/clients/cobol/sync/src/un.cob b/clients/cobol/sync/src/un.cob index c89428f..6a3401f 100644 --- a/clients/cobol/sync/src/un.cob +++ b/clients/cobol/sync/src/un.cob @@ -95,6 +95,8 @@ 01 WS-ARG5 PIC X(256). 01 WS-UNFREEZE-ON-DEMAND PIC X(8). 01 WS-UOD-ENABLED PIC X(8). + 01 WS-TYPE PIC X(32). + 01 WS-SHELL PIC X(32). PROCEDURE DIVISION. MAIN-PROCEDURE. @@ -135,6 +137,11 @@ STOP RUN END-IF. + IF WS-ARG1 = "snapshot" + PERFORM HANDLE-SNAPSHOT + STOP RUN + END-IF. + * Default: execute command MOVE WS-ARG1 TO WS-FILENAME. PERFORM HANDLE-EXECUTE. @@ -1666,3 +1673,308 @@ END-STRING. CALL "SYSTEM" USING WS-CURL-CMD. + + HANDLE-SNAPSHOT. + * Get credentials + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT + "UNSANDBOX_PUBLIC_KEY". + ACCEPT WS-SECRET-KEY FROM ENVIRONMENT + "UNSANDBOX_SECRET_KEY". + IF WS-PUBLIC-KEY = SPACES OR WS-SECRET-KEY = SPACES + DISPLAY "Error: API keys not set" UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN + END-IF. + + * Get second argument (operation or --list) + ACCEPT WS-ARG2 FROM ARGUMENT-VALUE. + + EVALUATE WS-ARG2 + WHEN "--list" + WHEN "-l" + PERFORM SNAPSHOT-LIST + WHEN "--info" + ACCEPT WS-ID FROM ARGUMENT-VALUE + PERFORM SNAPSHOT-INFO + WHEN "--delete" + ACCEPT WS-ID FROM ARGUMENT-VALUE + PERFORM SNAPSHOT-DELETE + WHEN "--lock" + ACCEPT WS-ID FROM ARGUMENT-VALUE + PERFORM SNAPSHOT-LOCK + WHEN "--unlock" + ACCEPT WS-ID FROM ARGUMENT-VALUE + PERFORM SNAPSHOT-UNLOCK + WHEN "--restore" + ACCEPT WS-ID FROM ARGUMENT-VALUE + PERFORM SNAPSHOT-RESTORE + WHEN "--clone" + ACCEPT WS-ID FROM ARGUMENT-VALUE + PERFORM PARSE-SNAPSHOT-CLONE-ARGS + PERFORM SNAPSHOT-CLONE + WHEN OTHER + DISPLAY "Usage: un snapshot [options]" UPON SYSERR + DISPLAY " --list, -l List snapshots" UPON SYSERR + DISPLAY " --info ID Get snapshot details" + UPON SYSERR + DISPLAY " --delete ID Delete snapshot" + UPON SYSERR + DISPLAY " --lock ID Lock snapshot" UPON SYSERR + DISPLAY " --unlock ID Unlock snapshot" UPON SYSERR + DISPLAY " --restore ID Restore snapshot" + UPON SYSERR + DISPLAY " --clone ID Clone snapshot" UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN + END-EVALUATE. + + SNAPSHOT-LIST. + STRING "TS=$(date +%s); " + "SIG=$(echo -n \"$TS:GET:/snapshots:\" | " + "openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X GET 'https://api.unsandbox.com/snapshots' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG | jq ." + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. + + SNAPSHOT-INFO. + STRING "TS=$(date +%s); " + "SIG=$(echo -n \"$TS:GET:/snapshots/" + FUNCTION TRIM(WS-ID) + ":\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X GET 'https://api.unsandbox.com/snapshots/" + FUNCTION TRIM(WS-ID) + "' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG | jq ." + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. + + SNAPSHOT-DELETE. + STRING "TS=$(date +%s); " + "SIG=$(echo -n \"$TS:DELETE:/snapshots/" + FUNCTION TRIM(WS-ID) + ":\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "RESP=$(curl -s -w '\n%{http_code}' -X DELETE " + "'https://api.unsandbox.com/snapshots/" + FUNCTION TRIM(WS-ID) + "' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG); " + "HTTP_CODE=$(echo \"$RESP\" | tail -1); " + "BODY=$(echo \"$RESP\" | head -n -1); " + "if [ \"$HTTP_CODE\" = \"428\" ]; then " + "OTP=$(echo \"$BODY\" | jq -r '.otp // empty'); " + "if [ -n \"$OTP\" ]; then " + "TS2=$(date +%s); " + "SIG2=$(echo -n \"$TS2:DELETE:/snapshots/" + FUNCTION TRIM(WS-ID) + ":\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X DELETE 'https://api.unsandbox.com/snapshots/" + FUNCTION TRIM(WS-ID) + "' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS2 " + "-H 'X-Signature: '$SIG2 " + "-H 'X-Sudo-OTP: '$OTP | jq .; " + "echo -e '\x1b[32mSnapshot deleted\x1b[0m'; fi; " + "else echo \"$BODY\" | jq .; fi" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. + + SNAPSHOT-LOCK. + STRING "TS=$(date +%s); " + "SIG=$(echo -n \"$TS:POST:/snapshots/" + FUNCTION TRIM(WS-ID) + "/lock:\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X POST 'https://api.unsandbox.com/snapshots/" + FUNCTION TRIM(WS-ID) + "/lock' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG | jq . && " + "echo -e '\x1b[32mSnapshot locked\x1b[0m'" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. + + SNAPSHOT-UNLOCK. + STRING "TS=$(date +%s); " + "BODY='{}'; " + "SIG=$(echo -n \"$TS:POST:/snapshots/" + FUNCTION TRIM(WS-ID) + "/unlock:$BODY\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "RESP=$(curl -s -w '\n%{http_code}' -X POST " + "'https://api.unsandbox.com/snapshots/" + FUNCTION TRIM(WS-ID) + "/unlock' " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG " + "-d \"$BODY\"); " + "HTTP_CODE=$(echo \"$RESP\" | tail -1); " + "BODY_RESP=$(echo \"$RESP\" | head -n -1); " + "if [ \"$HTTP_CODE\" = \"428\" ]; then " + "OTP=$(echo \"$BODY_RESP\" | jq -r '.otp // empty'); " + "if [ -n \"$OTP\" ]; then " + "TS2=$(date +%s); " + "SIG2=$(echo -n \"$TS2:POST:/snapshots/" + FUNCTION TRIM(WS-ID) + "/unlock:$BODY\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X POST 'https://api.unsandbox.com/snapshots/" + FUNCTION TRIM(WS-ID) + "/unlock' " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS2 " + "-H 'X-Signature: '$SIG2 " + "-H 'X-Sudo-OTP: '$OTP " + "-d \"$BODY\" | jq .; " + "echo -e '\x1b[32mSnapshot unlocked\x1b[0m'; fi; " + "else echo \"$BODY_RESP\" | jq .; fi" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. + + SNAPSHOT-RESTORE. + STRING "TS=$(date +%s); " + "BODY='{}'; " + "SIG=$(echo -n \"$TS:POST:/snapshots/" + FUNCTION TRIM(WS-ID) + "/restore:$BODY\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X POST 'https://api.unsandbox.com/snapshots/" + FUNCTION TRIM(WS-ID) + "/restore' " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG " + "-d \"$BODY\" | jq . && " + "echo -e '\x1b[32mSnapshot restored\x1b[0m'" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. + + PARSE-SNAPSHOT-CLONE-ARGS. + * Parse --type, --name, --ports, --shell + MOVE SPACES TO WS-TYPE. + MOVE SPACES TO WS-NAME. + MOVE SPACES TO WS-PORTS. + MOVE SPACES TO WS-SHELL. + ACCEPT WS-ARG3 FROM ARGUMENT-VALUE. + PERFORM UNTIL WS-ARG3 = SPACES + IF WS-ARG3 = "--type" + ACCEPT WS-TYPE FROM ARGUMENT-VALUE + ELSE IF WS-ARG3 = "--name" + ACCEPT WS-NAME FROM ARGUMENT-VALUE + ELSE IF WS-ARG3 = "--ports" + ACCEPT WS-PORTS FROM ARGUMENT-VALUE + ELSE IF WS-ARG3 = "--shell" + ACCEPT WS-SHELL FROM ARGUMENT-VALUE + END-IF + ACCEPT WS-ARG3 FROM ARGUMENT-VALUE + END-PERFORM. + + IF WS-TYPE = SPACES + DISPLAY "Error: --type required (session or service)" + UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN + END-IF. + + SNAPSHOT-CLONE. + * Build clone request + STRING "TS=$(date +%s); " + "BODY='{\"type\":\"" FUNCTION TRIM(WS-TYPE) "\"" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + IF WS-NAME NOT = SPACES + STRING FUNCTION TRIM(WS-CURL-CMD) + ",\"name\":\"" FUNCTION TRIM(WS-NAME) "\"" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING + END-IF. + + IF WS-PORTS NOT = SPACES + STRING FUNCTION TRIM(WS-CURL-CMD) + ",\"ports\":[" FUNCTION TRIM(WS-PORTS) "]" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING + END-IF. + + IF WS-SHELL NOT = SPACES + STRING FUNCTION TRIM(WS-CURL-CMD) + ",\"shell\":\"" FUNCTION TRIM(WS-SHELL) "\"" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING + END-IF. + + STRING FUNCTION TRIM(WS-CURL-CMD) + "}'; " + "SIG=$(echo -n \"$TS:POST:/snapshots/" + FUNCTION TRIM(WS-ID) + "/clone:$BODY\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X POST 'https://api.unsandbox.com/snapshots/" + FUNCTION TRIM(WS-ID) + "/clone' " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG " + "-d \"$BODY\" | jq . && " + "echo -e '\x1b[32mSnapshot cloned\x1b[0m'" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. diff --git a/clients/cobol/tests/test_un.sh b/clients/cobol/tests/test_un.sh new file mode 100755 index 0000000..a8af64b --- /dev/null +++ b/clients/cobol/tests/test_un.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Test suite for COBOL Unsandbox SDK +# Run: bash tests/test_un.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_DIR="$SCRIPT_DIR/../sync/src" +SOURCE="$SDK_DIR/un.cob" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +TESTS_RUN=0 +TESTS_PASSED=0 + +# Test helper +test_that() { + local description="$1" + local test_cmd="$2" + TESTS_RUN=$((TESTS_RUN + 1)) + + if eval "$test_cmd" >/dev/null 2>&1; then + echo -e "[${GREEN}PASS${NC}] $description" + TESTS_PASSED=$((TESTS_PASSED + 1)) + return 0 + else + echo -e "[${RED}FAIL${NC}] $description" + return 1 + fi +} + +# Test source file exists +echo "" +echo "=== Source File ===" +test_that "Source file exists" "[ -f '$SOURCE' ]" + +echo "" +echo "=== Source Code Structure ===" +test_that "Has WORKING-STORAGE SECTION" "grep -q 'WORKING-STORAGE SECTION' '$SOURCE'" +test_that "Has session handler" "grep -q 'HANDLE-SESSION' '$SOURCE'" +test_that "Has service handler" "grep -q 'HANDLE-SERVICE' '$SOURCE'" +test_that "Has snapshot handler" "grep -q 'HANDLE-SNAPSHOT' '$SOURCE'" +test_that "Has image handler" "grep -q 'HANDLE-IMAGE' '$SOURCE'" +test_that "Has key handler" "grep -q 'HANDLE-KEY' '$SOURCE'" +test_that "Has languages handler" "grep -q 'HANDLE-LANGUAGES' '$SOURCE'" + +echo "" +echo "=== Snapshot Operations ===" +test_that "Snapshot list implemented" "grep -q 'SNAPSHOT-LIST' '$SOURCE'" +test_that "Snapshot info implemented" "grep -q 'SNAPSHOT-INFO' '$SOURCE'" +test_that "Snapshot delete implemented" "grep -q 'SNAPSHOT-DELETE' '$SOURCE'" +test_that "Snapshot lock implemented" "grep -q 'SNAPSHOT-LOCK' '$SOURCE'" +test_that "Snapshot unlock implemented" "grep -q 'SNAPSHOT-UNLOCK' '$SOURCE'" +test_that "Snapshot restore implemented" "grep -q 'SNAPSHOT-RESTORE' '$SOURCE'" +test_that "Snapshot clone implemented" "grep -q 'SNAPSHOT-CLONE' '$SOURCE'" + +echo "" +echo "=== Image Operations ===" +test_that "Image list implemented" "grep -q 'IMAGE-LIST' '$SOURCE'" +test_that "Image info implemented" "grep -q 'IMAGE-INFO' '$SOURCE'" +test_that "Image delete implemented" "grep -q 'IMAGE-DELETE' '$SOURCE'" +test_that "Image lock implemented" "grep -q 'IMAGE-LOCK' '$SOURCE'" +test_that "Image unlock implemented" "grep -q 'IMAGE-UNLOCK' '$SOURCE'" + +echo "" +echo "=== HMAC Authentication ===" +test_that "Uses openssl for HMAC" "grep -q 'openssl dgst -sha256 -hmac' '$SOURCE'" +test_that "Has X-Signature header" "grep -q 'X-Signature' '$SOURCE'" +test_that "Has X-Timestamp header" "grep -q 'X-Timestamp' '$SOURCE'" + +echo "" +echo "=== Sudo OTP Handling ===" +test_that "Handles 428 response" "grep -q '428' '$SOURCE'" +test_that "Has X-Sudo-OTP header" "grep -q 'X-Sudo-OTP' '$SOURCE'" + +echo "" +echo "=== Variables ===" +test_that "WS-TYPE variable defined" "grep -q 'WS-TYPE' '$SOURCE'" +test_that "WS-SHELL variable defined" "grep -q 'WS-SHELL' '$SOURCE'" +test_that "WS-PORTS variable defined" "grep -q 'WS-PORTS' '$SOURCE'" +test_that "WS-NAME variable defined" "grep -q 'WS-NAME' '$SOURCE'" + +echo "" +echo "=== Summary ===" +echo "Tests passed: $TESTS_PASSED / $TESTS_RUN" + +if [ $TESTS_PASSED -eq $TESTS_RUN ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi diff --git a/clients/cpp/sync/src/un.cpp b/clients/cpp/sync/src/un.cpp index d319652..e4ffaef 100644 --- a/clients/cpp/sync/src/un.cpp +++ b/clients/cpp/sync/src/un.cpp @@ -68,6 +68,7 @@ #include #include #include +#include using namespace std; @@ -491,6 +492,508 @@ void set_unfreeze_on_demand(const string& service_id, bool enabled, const string cout << GREEN << "Service unfreeze_on_demand set to " << enabled_str << RESET << endl; } +// ============================================================================ +// Library Functions for C++ SDK (matching C reference un.h) +// ============================================================================ + +const string SDK_VERSION = "4.2.0"; + +// Execute code synchronously +string execute(const string& language, const string& code, const string& public_key, const string& secret_key) { + string body = "{\"language\":\"" + escape_json(language) + "\",\"code\":\"" + escape_json(code) + "\"}"; + string auth_headers = build_auth_headers("POST", "/execute", body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + "/execute' " + "-H 'Content-Type: application/json' " + + auth_headers + " " + "-d '" + body + "'"; + return exec_curl(cmd); +} + +// Execute code asynchronously (returns job_id) +string execute_async(const string& language, const string& code, const string& public_key, const string& secret_key) { + string body = "{\"language\":\"" + escape_json(language) + "\",\"code\":\"" + escape_json(code) + "\",\"async\":true}"; + string auth_headers = build_auth_headers("POST", "/execute", body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + "/execute' " + "-H 'Content-Type: application/json' " + + auth_headers + " " + "-d '" + body + "'"; + return exec_curl(cmd); +} + +// Get job status +string get_job(const string& job_id, const string& public_key, const string& secret_key) { + string path = "/jobs/" + job_id; + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +// Wait for job completion +string wait_for_job(const string& job_id, const string& public_key, const string& secret_key) { + const int poll_delays[] = {300, 450, 700, 900, 650, 1600, 2000}; + const int poll_count = 7; + int delay_idx = 0; + + while (true) { + string result = get_job(job_id, public_key, secret_key); + if (result.find("\"status\":\"completed\"") != string::npos || + result.find("\"status\":\"failed\"") != string::npos || + result.find("\"status\":\"timeout\"") != string::npos || + result.find("\"status\":\"cancelled\"") != string::npos) { + return result; + } + + usleep(poll_delays[delay_idx % poll_count] * 1000); + if (delay_idx < poll_count - 1) delay_idx++; + } +} + +// Cancel a job +string cancel_job(const string& job_id, const string& public_key, const string& secret_key) { + string path = "/jobs/" + job_id + "/cancel"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +// List all jobs +string list_jobs(const string& public_key, const string& secret_key) { + string auth_headers = build_auth_headers("GET", "/jobs", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/jobs' " + auth_headers; + return exec_curl(cmd); +} + +// Get supported languages +string get_languages(const string& public_key, const string& secret_key) { + string auth_headers = build_auth_headers("GET", "/languages", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/languages' " + auth_headers; + return exec_curl(cmd); +} + +// Session functions +string session_list(const string& public_key, const string& secret_key) { + string auth_headers = build_auth_headers("GET", "/sessions", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/sessions' " + auth_headers; + return exec_curl(cmd); +} + +string session_get(const string& session_id, const string& public_key, const string& secret_key) { + string path = "/sessions/" + session_id; + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string session_create(const string& shell, const string& network, const string& public_key, const string& secret_key) { + string body = "{\"shell\":\"" + (shell.empty() ? "bash" : shell) + "\""; + if (!network.empty()) body += ",\"network\":\"" + network + "\""; + body += "}"; + string auth_headers = build_auth_headers("POST", "/sessions", body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + "/sessions' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string session_destroy(const string& session_id, const string& public_key, const string& secret_key) { + string path = "/sessions/" + session_id; + string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); + string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string session_freeze(const string& session_id, const string& public_key, const string& secret_key) { + string path = "/sessions/" + session_id + "/freeze"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string session_unfreeze(const string& session_id, const string& public_key, const string& secret_key) { + string path = "/sessions/" + session_id + "/unfreeze"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string session_boost(const string& session_id, int vcpu, const string& public_key, const string& secret_key) { + string path = "/sessions/" + session_id + "/boost"; + string body = vcpu > 0 ? "{\"vcpu\":" + to_string(vcpu) + "}" : "{}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string session_unboost(const string& session_id, const string& public_key, const string& secret_key) { + string path = "/sessions/" + session_id + "/unboost"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string session_execute(const string& session_id, const string& command, const string& public_key, const string& secret_key) { + string path = "/sessions/" + session_id + "/shell"; + string body = "{\"command\":\"" + escape_json(command) + "\"}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +// Service functions +string service_list(const string& public_key, const string& secret_key) { + string auth_headers = build_auth_headers("GET", "/services", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/services' " + auth_headers; + return exec_curl(cmd); +} + +string service_get(const string& service_id, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id; + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string service_create(const string& name, const string& ports, const string& bootstrap, const string& network, const string& public_key, const string& secret_key) { + string body = "{\"name\":\"" + escape_json(name) + "\""; + if (!ports.empty()) body += ",\"ports\":\"" + ports + "\""; + if (!bootstrap.empty()) body += ",\"bootstrap\":\"" + escape_json(bootstrap) + "\""; + if (!network.empty()) body += ",\"network\":\"" + network + "\""; + body += "}"; + string auth_headers = build_auth_headers("POST", "/services", body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + "/services' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string service_destroy(const string& service_id, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id; + string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); + string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string service_freeze(const string& service_id, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/freeze"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string service_unfreeze(const string& service_id, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/unfreeze"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string service_lock(const string& service_id, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/lock"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string service_unlock(const string& service_id, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/unlock"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string service_redeploy(const string& service_id, const string& bootstrap, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/redeploy"; + string body = bootstrap.empty() ? "{}" : "{\"bootstrap\":\"" + escape_json(bootstrap) + "\"}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string service_logs(const string& service_id, bool all, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/logs" + (all ? "?all=true" : ""); + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string service_execute(const string& service_id, const string& command, int timeout_ms, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/execute"; + string body = "{\"command\":\"" + escape_json(command) + "\""; + if (timeout_ms > 0) body += ",\"timeout\":" + to_string(timeout_ms); + body += "}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string service_resize(const string& service_id, int vcpu, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/resize"; + string body = "{\"vcpu\":" + to_string(vcpu) + "}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +// Snapshot functions +string snapshot_list(const string& public_key, const string& secret_key) { + string auth_headers = build_auth_headers("GET", "/snapshots", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/snapshots' " + auth_headers; + return exec_curl(cmd); +} + +string snapshot_get(const string& snapshot_id, const string& public_key, const string& secret_key) { + string path = "/snapshots/" + snapshot_id; + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string snapshot_session(const string& session_id, const string& name, bool hot, const string& public_key, const string& secret_key) { + string path = "/sessions/" + session_id + "/snapshot"; + string body = "{"; + if (!name.empty()) body += "\"name\":\"" + escape_json(name) + "\","; + body += "\"hot\":" + string(hot ? "true" : "false") + "}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string snapshot_service(const string& service_id, const string& name, bool hot, const string& public_key, const string& secret_key) { + string path = "/services/" + service_id + "/snapshot"; + string body = "{"; + if (!name.empty()) body += "\"name\":\"" + escape_json(name) + "\","; + body += "\"hot\":" + string(hot ? "true" : "false") + "}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string snapshot_restore(const string& snapshot_id, const string& public_key, const string& secret_key) { + string path = "/snapshots/" + snapshot_id + "/restore"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string snapshot_delete(const string& snapshot_id, const string& public_key, const string& secret_key) { + string path = "/snapshots/" + snapshot_id; + string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); + string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string snapshot_lock(const string& snapshot_id, const string& public_key, const string& secret_key) { + string path = "/snapshots/" + snapshot_id + "/lock"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string snapshot_unlock(const string& snapshot_id, const string& public_key, const string& secret_key) { + string path = "/snapshots/" + snapshot_id + "/unlock"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string snapshot_clone(const string& snapshot_id, const string& clone_type, const string& name, const string& ports, const string& shell, const string& public_key, const string& secret_key) { + string path = "/snapshots/" + snapshot_id + "/clone"; + string body = "{\"type\":\"" + clone_type + "\""; + if (!name.empty()) body += ",\"name\":\"" + escape_json(name) + "\""; + if (!ports.empty()) body += ",\"ports\":\"" + ports + "\""; + if (!shell.empty()) body += ",\"shell\":\"" + shell + "\""; + body += "}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +// Image functions +string image_list(const string& filter, const string& public_key, const string& secret_key) { + string path = "/images" + (filter.empty() ? "" : "?filter=" + filter); + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string image_get(const string& image_id, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id; + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string image_publish(const string& source_type, const string& source_id, const string& name, const string& description, const string& public_key, const string& secret_key) { + string body = "{\"source_type\":\"" + source_type + "\",\"source_id\":\"" + source_id + "\""; + if (!name.empty()) body += ",\"name\":\"" + escape_json(name) + "\""; + if (!description.empty()) body += ",\"description\":\"" + escape_json(description) + "\""; + body += "}"; + string auth_headers = build_auth_headers("POST", "/images", body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + "/images' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string image_delete(const string& image_id, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id; + string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); + string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string image_lock(const string& image_id, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/lock"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string image_unlock(const string& image_id, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/unlock"; + string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string image_set_visibility(const string& image_id, const string& visibility, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/visibility"; + string body = "{\"visibility\":\"" + visibility + "\"}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string image_grant_access(const string& image_id, const string& trusted_key, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/grant"; + string body = "{\"trusted_api_key\":\"" + trusted_key + "\"}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string image_revoke_access(const string& image_id, const string& trusted_key, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/revoke"; + string body = "{\"trusted_api_key\":\"" + trusted_key + "\"}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string image_list_trusted(const string& image_id, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/trusted"; + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +string image_transfer(const string& image_id, const string& to_api_key, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/transfer"; + string body = "{\"to_api_key\":\"" + to_api_key + "\"}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string image_spawn(const string& image_id, const string& name, const string& ports, const string& bootstrap, const string& network, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/spawn"; + string body = "{"; + bool has_field = false; + if (!name.empty()) { body += "\"name\":\"" + escape_json(name) + "\""; has_field = true; } + if (!ports.empty()) { body += string(has_field ? "," : "") + "\"ports\":\"" + ports + "\""; has_field = true; } + if (!bootstrap.empty()) { body += string(has_field ? "," : "") + "\"bootstrap\":\"" + escape_json(bootstrap) + "\""; has_field = true; } + if (!network.empty()) { body += string(has_field ? "," : "") + "\"network\":\"" + network + "\""; } + body += "}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +string image_clone(const string& image_id, const string& name, const string& description, const string& public_key, const string& secret_key) { + string path = "/images/" + image_id + "/clone"; + string body = "{"; + bool has_field = false; + if (!name.empty()) { body += "\"name\":\"" + escape_json(name) + "\""; has_field = true; } + if (!description.empty()) { body += string(has_field ? "," : "") + "\"description\":\"" + escape_json(description) + "\""; } + body += "}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + return exec_curl(cmd); +} + +// PaaS Logs functions +string logs_fetch(const string& source, int lines, const string& since, const string& grep, const string& public_key, const string& secret_key) { + string path = "/paas/logs?"; + if (!source.empty()) path += "source=" + source + "&"; + if (lines > 0) path += "lines=" + to_string(lines) + "&"; + if (!since.empty()) path += "since=" + since + "&"; + if (!grep.empty()) path += "grep=" + grep + "&"; + if (path.back() == '&' || path.back() == '?') path.pop_back(); + string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers; + return exec_curl(cmd); +} + +// Key validation +string validate_keys(const string& public_key, const string& secret_key) { + string auth_headers = build_auth_headers("POST", "/keys/validate", "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + "/keys/validate' " + auth_headers; + return exec_curl(cmd); +} + +// Utility functions +string hmac_sign(const string& secret_key, const string& message) { + return compute_hmac(secret_key, message); +} + +bool health_check() { + string cmd = "curl -s -o /dev/null -w '%{http_code}' '" + API_BASE + "/health' 2>/dev/null"; + string result = exec_curl(cmd); + return result.find("200") != string::npos; +} + +string version() { + return SDK_VERSION; +} + +static string last_error_msg; + +void set_last_error(const string& msg) { + last_error_msg = msg; +} + +string last_error() { + return last_error_msg; +} + void cmd_service_env(const string& action, const string& target, const vector& envs, const string& env_file, const string& public_key, const string& secret_key) { if (action == "status") { if (target.empty()) { diff --git a/clients/cpp/sync/tests/test_un.cpp b/clients/cpp/sync/tests/test_un.cpp new file mode 100644 index 0000000..c009627 --- /dev/null +++ b/clients/cpp/sync/tests/test_un.cpp @@ -0,0 +1,311 @@ +// Tests for the C++ unsandbox SDK +// Compile: g++ -std=c++17 -o test_un test_un.cpp -I../src +// Run: ./test_un + +#include +#include +#include +#include + +// Include the SDK source directly for testing +// In production, you'd link against the compiled library +#include "../src/un.cpp" + +using namespace std; + +int tests_passed = 0; +int tests_failed = 0; + +#define TEST(name) void test_##name() +#define RUN_TEST(name) do { \ + cout << "Running " << #name << "..." << endl; \ + try { \ + test_##name(); \ + cout << " PASS" << endl; \ + tests_passed++; \ + } catch (const exception& e) { \ + cout << " FAIL: " << e.what() << endl; \ + tests_failed++; \ + } catch (...) { \ + cout << " FAIL: Unknown exception" << endl; \ + tests_failed++; \ + } \ +} while(0) + +#define ASSERT(cond) do { \ + if (!(cond)) { \ + throw runtime_error("Assertion failed: " #cond); \ + } \ +} while(0) + +#define ASSERT_EQ(a, b) do { \ + if ((a) != (b)) { \ + throw runtime_error("Assertion failed: " #a " == " #b); \ + } \ +} while(0) + +#define ASSERT_NE(a, b) do { \ + if ((a) == (b)) { \ + throw runtime_error("Assertion failed: " #a " != " #b); \ + } \ +} while(0) + +// ============================================================================ +// Unit Tests - Test exported library functions +// ============================================================================ + +TEST(detect_language) { + ASSERT_EQ(detect_language("script.py"), "python"); + ASSERT_EQ(detect_language("script.js"), "javascript"); + ASSERT_EQ(detect_language("script.ts"), "typescript"); + ASSERT_EQ(detect_language("script.go"), "go"); + ASSERT_EQ(detect_language("script.rs"), "rust"); + ASSERT_EQ(detect_language("script.c"), "c"); + ASSERT_EQ(detect_language("script.cpp"), "cpp"); + ASSERT_EQ(detect_language("script.d"), "d"); + ASSERT_EQ(detect_language("script.zig"), "zig"); + ASSERT_EQ(detect_language("script.sh"), "bash"); + ASSERT_EQ(detect_language("script.lua"), "lua"); + ASSERT_EQ(detect_language("script.php"), "php"); + ASSERT_EQ(detect_language("script.unknown"), ""); + ASSERT_EQ(detect_language("script"), ""); +} + +TEST(hmac_sign) { + string secret_key = "test-secret"; + string message = "test-message"; + + string result = hmac_sign(secret_key, message); + + // Should return a 64-character hex string + ASSERT_EQ(result.length(), 64u); + + // Should be deterministic + string result2 = hmac_sign(secret_key, message); + ASSERT_EQ(result, result2); + + // Different inputs should produce different outputs + string result3 = hmac_sign(secret_key, "different-message"); + ASSERT_NE(result, result3); +} + +TEST(version) { + string v = version(); + ASSERT(!v.empty()); + // Should be in semver format (at least "0.0.0") + ASSERT(v.length() >= 5); +} + +TEST(last_error) { + // Set an error + set_last_error("test error message"); + + // Retrieve it + string err = last_error(); + ASSERT_EQ(err, "test error message"); + + // Clear it + set_last_error(""); + err = last_error(); + ASSERT(err.empty()); +} + +TEST(escape_json) { + ASSERT_EQ(escape_json("hello"), "hello"); + ASSERT_EQ(escape_json("hello\"world"), "hello\\\"world"); + ASSERT_EQ(escape_json("line1\nline2"), "line1\\nline2"); + ASSERT_EQ(escape_json("tab\there"), "tab\\there"); + ASSERT_EQ(escape_json("back\\slash"), "back\\\\slash"); +} + +TEST(base64_encode) { + ASSERT_EQ(base64_encode(""), ""); + ASSERT_EQ(base64_encode("f"), "Zg=="); + ASSERT_EQ(base64_encode("fo"), "Zm8="); + ASSERT_EQ(base64_encode("foo"), "Zm9v"); + ASSERT_EQ(base64_encode("foob"), "Zm9vYg=="); + ASSERT_EQ(base64_encode("fooba"), "Zm9vYmE="); + ASSERT_EQ(base64_encode("foobar"), "Zm9vYmFy"); +} + +// ============================================================================ +// Integration Tests - Test SDK internal consistency +// ============================================================================ + +TEST(compute_hmac) { + string key = "test-key"; + string msg = "test-message"; + + string sig1 = compute_hmac(key, msg); + string sig2 = compute_hmac(key, msg); + + // Should be deterministic + ASSERT_EQ(sig1, sig2); + + // Should produce different results for different inputs + string sig3 = compute_hmac(key, "different"); + ASSERT_NE(sig1, sig3); +} + +TEST(build_auth_headers) { + string pk = "unsb-pk-test-test-test-test"; + string sk = "unsb-sk-test1-test2-test3-test4"; + + string headers = build_auth_headers("POST", "/execute", "{}", pk, sk); + + // Should contain auth header + ASSERT(headers.find("Authorization: Bearer " + pk) != string::npos); + // Should contain timestamp header + ASSERT(headers.find("X-Timestamp:") != string::npos); + // Should contain signature header + ASSERT(headers.find("X-Signature:") != string::npos); +} + +// ============================================================================ +// Functional Tests - Test against real API (requires credentials) +// ============================================================================ + +bool has_credentials() { + const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char* sk = getenv("UNSANDBOX_SECRET_KEY"); + return pk != nullptr && sk != nullptr && strlen(pk) > 0 && strlen(sk) > 0; +} + +TEST(health_check_functional) { + if (!has_credentials()) { + cout << " SKIP (no credentials)" << endl; + return; + } + + bool healthy = health_check(); + // Just verify it doesn't crash + cout << " Health check result: " << (healthy ? "healthy" : "unhealthy") << endl; +} + +TEST(get_languages_functional) { + if (!has_credentials()) { + cout << " SKIP (no credentials)" << endl; + return; + } + + const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char* sk = getenv("UNSANDBOX_SECRET_KEY"); + + string result = get_languages(pk, sk); + ASSERT(!result.empty()); + // Should contain python + ASSERT(result.find("python") != string::npos); +} + +TEST(validate_keys_functional) { + if (!has_credentials()) { + cout << " SKIP (no credentials)" << endl; + return; + } + + const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char* sk = getenv("UNSANDBOX_SECRET_KEY"); + + string result = validate_keys(pk, sk); + ASSERT(!result.empty()); +} + +TEST(execute_functional) { + if (!has_credentials()) { + cout << " SKIP (no credentials)" << endl; + return; + } + + const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char* sk = getenv("UNSANDBOX_SECRET_KEY"); + + string result = execute("python", "print('hello from cpp test')", pk, sk); + ASSERT(!result.empty()); + // Should contain output + ASSERT(result.find("stdout") != string::npos || result.find("output") != string::npos); +} + +TEST(session_list_functional) { + if (!has_credentials()) { + cout << " SKIP (no credentials)" << endl; + return; + } + + const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char* sk = getenv("UNSANDBOX_SECRET_KEY"); + + string result = session_list(pk, sk); + ASSERT(!result.empty()); +} + +TEST(service_list_functional) { + if (!has_credentials()) { + cout << " SKIP (no credentials)" << endl; + return; + } + + const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char* sk = getenv("UNSANDBOX_SECRET_KEY"); + + string result = service_list(pk, sk); + ASSERT(!result.empty()); +} + +TEST(snapshot_list_functional) { + if (!has_credentials()) { + cout << " SKIP (no credentials)" << endl; + return; + } + + const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char* sk = getenv("UNSANDBOX_SECRET_KEY"); + + string result = snapshot_list(pk, sk); + ASSERT(!result.empty()); +} + +TEST(image_list_functional) { + if (!has_credentials()) { + cout << " SKIP (no credentials)" << endl; + return; + } + + const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); + const char* sk = getenv("UNSANDBOX_SECRET_KEY"); + + string result = image_list("", pk, sk); + ASSERT(!result.empty()); +} + +int main() { + cout << "===== C++ SDK Tests =====" << endl << endl; + + // Unit tests + cout << "--- Unit Tests ---" << endl; + RUN_TEST(detect_language); + RUN_TEST(hmac_sign); + RUN_TEST(version); + RUN_TEST(last_error); + RUN_TEST(escape_json); + RUN_TEST(base64_encode); + + cout << endl << "--- Integration Tests ---" << endl; + RUN_TEST(compute_hmac); + RUN_TEST(build_auth_headers); + + cout << endl << "--- Functional Tests ---" << endl; + RUN_TEST(health_check_functional); + RUN_TEST(get_languages_functional); + RUN_TEST(validate_keys_functional); + RUN_TEST(execute_functional); + RUN_TEST(session_list_functional); + RUN_TEST(service_list_functional); + RUN_TEST(snapshot_list_functional); + RUN_TEST(image_list_functional); + + cout << endl << "===== Results =====" << endl; + cout << "Passed: " << tests_passed << endl; + cout << "Failed: " << tests_failed << endl; + + return tests_failed > 0 ? 1 : 0; +} diff --git a/clients/crystal/sync/src/un.cr b/clients/crystal/sync/src/un.cr index f5963ca..40b8ed5 100644 --- a/clients/crystal/sync/src/un.cr +++ b/clients/crystal/sync/src/un.cr @@ -573,12 +573,57 @@ def cmd_session(args) return end + if info_id = args[:session_info]?.as?(String) + result = api_request("/sessions/#{info_id}", public_key, secret_key) + puts result.to_pretty_json + return + end + if kill_id = args[:kill]?.as?(String) api_request("/sessions/#{kill_id}", public_key, secret_key, method: "DELETE") puts "#{GREEN}Session terminated: #{kill_id}#{RESET}" return end + if freeze_id = args[:session_freeze]?.as?(String) + api_request("/sessions/#{freeze_id}/freeze", public_key, secret_key, method: "POST") + puts "#{GREEN}Session frozen: #{freeze_id}#{RESET}" + return + end + + if unfreeze_id = args[:session_unfreeze]?.as?(String) + api_request("/sessions/#{unfreeze_id}/unfreeze", public_key, secret_key, method: "POST") + puts "#{GREEN}Session unfreezing: #{unfreeze_id}#{RESET}" + return + end + + if boost_id = args[:session_boost]?.as?(String) + vcpu = args[:vcpu]?.as?(Int32) || 2 + payload = JSON.parse({vcpu: vcpu}.to_json) + api_request("/sessions/#{boost_id}/boost", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Session boosted to #{vcpu} vCPU: #{boost_id}#{RESET}" + return + end + + if unboost_id = args[:session_unboost]?.as?(String) + api_request("/sessions/#{unboost_id}/unboost", public_key, secret_key, method: "POST") + puts "#{GREEN}Session unboosted: #{unboost_id}#{RESET}" + return + end + + if execute_id = args[:session_execute]?.as?(String) + command = args[:command]?.as?(String) || "" + payload = JSON.parse({command: command}.to_json) + result = api_request("/sessions/#{execute_id}/execute", public_key, secret_key, method: "POST", data: payload) + if stdout = result["stdout"]?.try(&.as_s?) + print BLUE, stdout, RESET + end + if stderr = result["stderr"]?.try(&.as_s?) + print RED, stderr, RESET + end + return + end + # Create new session payload = JSON.parse({shell: "bash"}.to_json) @@ -586,6 +631,10 @@ def cmd_session(args) payload.as_h["network"] = JSON::Any.new(network) end + if shell = args[:shell]?.as?(String) + payload.as_h["shell"] = JSON::Any.new(shell) + end + # Add input files if files = args[:files]?.as?(Array(String)) input_files = [] of JSON::Any @@ -844,11 +893,291 @@ def cmd_image(args) return end + if grant_id = args[:image_grant]?.as?(String) + trusted_key = args[:image_trusted_key]?.as?(String) + if trusted_key.nil? || trusted_key.empty? + STDERR.puts "#{RED}Error: --grant requires --trusted-key#{RESET}" + exit 1 + end + payload = JSON.parse({trusted_api_key: trusted_key}.to_json) + api_request("/images/#{grant_id}/grant", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Access granted to #{trusted_key}#{RESET}" + return + end + + if revoke_id = args[:image_revoke]?.as?(String) + trusted_key = args[:image_trusted_key]?.as?(String) + if trusted_key.nil? || trusted_key.empty? + STDERR.puts "#{RED}Error: --revoke requires --trusted-key#{RESET}" + exit 1 + end + payload = JSON.parse({trusted_api_key: trusted_key}.to_json) + api_request("/images/#{revoke_id}/revoke", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Access revoked from #{trusted_key}#{RESET}" + return + end + + if trusted_id = args[:image_trusted]?.as?(String) + result = api_request("/images/#{trusted_id}/trusted", public_key, secret_key) + puts result.to_pretty_json + return + end + + if transfer_id = args[:image_transfer]?.as?(String) + to_key = args[:image_to_key]?.as?(String) + if to_key.nil? || to_key.empty? + STDERR.puts "#{RED}Error: --transfer requires --to-key#{RESET}" + exit 1 + end + payload = JSON.parse({to_api_key: to_key}.to_json) + status_code, response = api_request_with_sudo("/images/#{transfer_id}/transfer", public_key, secret_key, method: "POST", data: payload) + if status_code == 428 + handle_sudo_challenge(response.to_json, public_key, secret_key, "POST", "/images/#{transfer_id}/transfer", payload.to_json) + elsif status_code >= 200 && status_code < 300 + puts "#{GREEN}Image transferred to #{to_key}#{RESET}" + else + STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" + STDERR.puts response.to_json + exit 1 + end + return + end + # Default: list images result = api_request("/images", public_key, secret_key) puts result.to_pretty_json end +def cmd_snapshot(args) + public_key, secret_key = get_api_keys(args[:api_key]?) + + if args[:list]?.as?(Bool) + result = api_request("/snapshots", public_key, secret_key) + snapshots = result["snapshots"]?.try(&.as_a?) || [] of JSON::Any + if snapshots.empty? + puts "No snapshots" + else + printf "%-20s %-20s %-10s %-10s %-10s %s\n", "ID", "Name", "Type", "Hot", "Locked", "Created" + snapshots.each do |s| + printf "%-20s %-20s %-10s %-10s %-10s %s\n", + s["id"]?.try(&.as_s?) || "N/A", + s["name"]?.try(&.as_s?) || "N/A", + s["type"]?.try(&.as_s?) || "N/A", + s["hot"]?.try(&.as_bool?) ? "yes" : "no", + s["locked"]?.try(&.as_bool?) ? "yes" : "no", + s["created_at"]?.try(&.as_s?) || "N/A" + end + end + return + end + + if info_id = args[:snapshot_info]?.as?(String) + result = api_request("/snapshots/#{info_id}", public_key, secret_key) + puts result.to_pretty_json + return + end + + if session_id = args[:snapshot_session]?.as?(String) + payload = JSON.parse({}.to_json) + if name = args[:snapshot_name]?.as?(String) + payload.as_h["name"] = JSON::Any.new(name) + end + if args[:snapshot_hot]?.as?(Bool) + payload.as_h["hot"] = JSON::Any.new(true) + end + result = api_request("/sessions/#{session_id}/snapshot", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Snapshot created#{RESET}" + puts result.to_pretty_json + return + end + + if service_id = args[:snapshot_service]?.as?(String) + payload = JSON.parse({}.to_json) + if name = args[:snapshot_name]?.as?(String) + payload.as_h["name"] = JSON::Any.new(name) + end + if args[:snapshot_hot]?.as?(Bool) + payload.as_h["hot"] = JSON::Any.new(true) + end + result = api_request("/services/#{service_id}/snapshot", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Snapshot created#{RESET}" + puts result.to_pretty_json + return + end + + if restore_id = args[:snapshot_restore]?.as?(String) + payload = JSON.parse({}.to_json) + result = api_request("/snapshots/#{restore_id}/restore", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Snapshot restored#{RESET}" + puts result.to_pretty_json + return + end + + if del_id = args[:snapshot_delete]?.as?(String) + status_code, response = api_request_with_sudo("/snapshots/#{del_id}", public_key, secret_key, method: "DELETE") + if status_code == 428 + handle_sudo_challenge(response.to_json, public_key, secret_key, "DELETE", "/snapshots/#{del_id}", nil) + elsif status_code >= 200 && status_code < 300 + puts "#{GREEN}Snapshot deleted: #{del_id}#{RESET}" + else + STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" + STDERR.puts response.to_json + exit 1 + end + return + end + + if lock_id = args[:snapshot_lock]?.as?(String) + payload = JSON.parse({}.to_json) + api_request("/snapshots/#{lock_id}/lock", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Snapshot locked: #{lock_id}#{RESET}" + return + end + + if unlock_id = args[:snapshot_unlock]?.as?(String) + payload = JSON.parse({}.to_json) + body = "{}" + status_code, response = api_request_with_sudo("/snapshots/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload) + if status_code == 428 + handle_sudo_challenge(response.to_json, public_key, secret_key, "POST", "/snapshots/#{unlock_id}/unlock", body) + elsif status_code >= 200 && status_code < 300 + puts "#{GREEN}Snapshot unlocked: #{unlock_id}#{RESET}" + else + STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" + STDERR.puts response.to_json + exit 1 + end + return + end + + if clone_id = args[:snapshot_clone]?.as?(String) + clone_type = args[:snapshot_clone_type]?.as?(String) || "session" + payload = JSON.parse({clone_type: clone_type}.to_json) + if name = args[:snapshot_name]?.as?(String) + payload.as_h["name"] = JSON::Any.new(name) + end + if ports_str = args[:snapshot_ports]?.as?(String) + ports = ports_str.split(',').map(&.to_i) + payload.as_h["ports"] = JSON.parse(ports.to_json) + end + result = api_request("/snapshots/#{clone_id}/clone", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Snapshot cloned#{RESET}" + puts result.to_pretty_json + return + end + + # Default: list snapshots + result = api_request("/snapshots", public_key, secret_key) + puts result.to_pretty_json +end + +def cmd_logs(args) + public_key, secret_key = get_api_keys(args[:api_key]?) + + source = args[:logs_source]?.as?(String) || "all" + lines = args[:logs_lines]?.as?(Int32) || 100 + since = args[:logs_since]?.as?(String) || "1h" + grep_pattern = args[:logs_grep]?.as?(String) + + endpoint = "/paas/logs?source=#{source}&lines=#{lines}&since=#{since}" + if grep_pattern && !grep_pattern.empty? + endpoint += "&grep=#{URI.encode_path(grep_pattern)}" + end + + if args[:logs_follow]?.as?(Bool) + # Streaming logs via SSE + stream_endpoint = "/paas/logs/stream?source=#{source}" + if grep_pattern && !grep_pattern.empty? + stream_endpoint += "&grep=#{URI.encode_path(grep_pattern)}" + end + + url = URI.parse(PORTAL_BASE + stream_endpoint) + headers = HTTP::Headers{ + "Accept" => "text/event-stream" + } + + # Add HMAC authentication headers + if secret_key && !secret_key.empty? + timestamp = Time.utc.to_unix.to_s + message = "#{timestamp}:GET:#{stream_endpoint}:" + signature = OpenSSL::HMAC.hexdigest(:sha256, secret_key, message) + headers["Authorization"] = "Bearer #{public_key}" + headers["X-Timestamp"] = timestamp + headers["X-Signature"] = signature + else + headers["Authorization"] = "Bearer #{public_key}" + end + + begin + HTTP::Client.get(url, headers: headers) do |response| + if response.status_code == 200 + response.body_io.each_line do |line| + if line.starts_with?("data: ") + data = line[6..] + begin + parsed = JSON.parse(data) + src = parsed["source"]?.try(&.as_s?) || "unknown" + msg = parsed["line"]?.try(&.as_s?) || data + puts "[#{src}] #{msg}" + rescue + puts line + end + end + end + else + STDERR.puts "#{RED}Error: HTTP #{response.status_code}#{RESET}" + STDERR.puts response.body_io.gets_to_end + exit 1 + end + end + rescue ex + STDERR.puts "#{RED}Error: #{ex.message}#{RESET}" + exit 1 + end + else + # Batch fetch + result = api_request(endpoint, public_key, secret_key) + if logs = result["logs"]?.try(&.as_a?) + logs.each do |log| + src = log["source"]?.try(&.as_s?) || "unknown" + msg = log["line"]?.try(&.as_s?) || log.to_json + ts = log["timestamp"]?.try(&.as_s?) || "" + if ts.empty? + puts "[#{src}] #{msg}" + else + puts "[#{ts}] [#{src}] #{msg}" + end + end + else + puts result.to_pretty_json + end + end +end + +def cmd_health(args) + begin + url = URI.parse(API_BASE + "/health") + response = HTTP::Client.get(url) + if response.status_code == 200 + puts "#{GREEN}API is healthy#{RESET}" + result = JSON.parse(response.body) + puts result.to_pretty_json + else + puts "#{RED}API is unhealthy: HTTP #{response.status_code}#{RESET}" + exit 1 + end + rescue ex + puts "#{RED}API is unreachable: #{ex.message}#{RESET}" + exit 1 + end +end + +def cmd_version(args) + puts "un.cr version 1.0.0" + puts "API: #{API_BASE}" + puts "Portal: #{PORTAL_BASE}" +end + def cmd_service(args) # Handle env subcommand if env_action = args[:env_action]?.as?(String) @@ -973,6 +1302,48 @@ def cmd_service(args) return end + if lock_id = args[:service_lock]?.as?(String) + payload = JSON.parse({}.to_json) + api_request("/services/#{lock_id}/lock", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Service locked: #{lock_id}#{RESET}" + return + end + + if unlock_id = args[:service_unlock]?.as?(String) + payload = JSON.parse({}.to_json) + body = "{}" + status_code, response = api_request_with_sudo("/services/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload) + if status_code == 428 + handle_sudo_challenge(response.to_json, public_key, secret_key, "POST", "/services/#{unlock_id}/unlock", body) + elsif status_code >= 200 && status_code < 300 + puts "#{GREEN}Service unlocked: #{unlock_id}#{RESET}" + else + STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" + STDERR.puts response.to_json + exit 1 + end + return + end + + if redeploy_id = args[:redeploy]?.as?(String) + payload = JSON.parse({}.to_json) + if bootstrap = args[:bootstrap]?.as?(String) + payload.as_h["bootstrap"] = JSON::Any.new(bootstrap) + end + if bootstrap_file = args[:bootstrap_file]?.as?(String) + if File.exists?(bootstrap_file) + payload.as_h["bootstrap_content"] = JSON::Any.new(File.read(bootstrap_file)) + else + STDERR.puts "#{RED}Error: Bootstrap file not found: #{bootstrap_file}#{RESET}" + exit 1 + end + end + result = api_request("/services/#{redeploy_id}/redeploy", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Service redeployed: #{redeploy_id}#{RESET}" + puts result.to_pretty_json + return + end + # Create new service if name = args[:name]?.as?(String) payload = JSON.parse({name: name}.to_json) @@ -1103,6 +1474,19 @@ def main env_action: nil, env_target: nil, json: false, + shell: nil, + # Session options + session_info: nil, + session_freeze: nil, + session_unfreeze: nil, + session_boost: nil, + session_unboost: nil, + session_execute: nil, + # Service options + service_lock: nil, + service_unlock: nil, + redeploy: nil, + # Image options image_info: nil, image_delete: nil, image_lock: nil, @@ -1114,11 +1498,36 @@ def main image_spawn: nil, image_clone: nil, image_name: nil, - image_ports: nil - } of Symbol => (String | Array(String) | Bool | Nil) + image_ports: nil, + image_grant: nil, + image_revoke: nil, + image_trusted: nil, + image_trusted_key: nil, + image_transfer: nil, + image_to_key: nil, + # Snapshot options + snapshot_info: nil, + snapshot_session: nil, + snapshot_service: nil, + snapshot_restore: nil, + snapshot_delete: nil, + snapshot_lock: nil, + snapshot_unlock: nil, + snapshot_clone: nil, + snapshot_clone_type: nil, + snapshot_name: nil, + snapshot_hot: false, + snapshot_ports: nil, + # Logs options + logs_source: nil, + logs_lines: nil, + logs_since: nil, + logs_grep: nil, + logs_follow: false + } of Symbol => (String | Array(String) | Bool | Int32 | Nil) parser = OptionParser.new do |opts| - opts.banner = "Usage: un.cr [options] \n un.cr languages [--json]\n un.cr session [options]\n un.cr service [options]\n un.cr service env [options]\n un.cr key [options]\n\nService env commands:\n env status Show vault status\n env set Set vault (-e KEY=VALUE or --env-file FILE)\n env export Export vault contents\n env delete Delete vault" + opts.banner = "Usage: un.cr [options] \n un.cr languages [--json]\n un.cr session [options]\n un.cr service [options]\n un.cr service env [options]\n un.cr snapshot [options]\n un.cr image [options]\n un.cr logs [options]\n un.cr key [options]\n un.cr health\n un.cr version\n\nService env commands:\n env status Show vault status\n env set Set vault (-e KEY=VALUE or --env-file FILE)\n env export Export vault contents\n env delete Delete vault" opts.on("-k API_KEY", "--api-key=API_KEY", "API key") { |k| args[:api_key] = k } opts.on("-n NETWORK", "--network=NETWORK", "Network mode") { |n| args[:network] = n } @@ -1131,21 +1540,21 @@ def main opts.on("-o DIR", "--output-dir=DIR", "Output directory") { |d| args[:output_dir] = d } opts.on("-l", "--list", "List items") { args[:list] = true } opts.on("--kill=ID", "Kill session") { |id| args[:kill] = id } - opts.on("--info=ID", "Get service info") { |id| args[:info] = id } + opts.on("--info=ID", "Get service/session info") { |id| args[:info] = id } opts.on("--logs=ID", "Get service logs") { |id| args[:logs] = id } - opts.on("--freeze=ID", "Sleep service") { |id| args[:sleep] = id } - opts.on("--unfreeze=ID", "Wake service") { |id| args[:wake] = id } + opts.on("--freeze=ID", "Freeze service/session") { |id| args[:sleep] = id } + opts.on("--unfreeze=ID", "Unfreeze service/session") { |id| args[:wake] = id } opts.on("--unfreeze-on-demand=ID", "Set unfreeze-on-demand for service") { |id| args[:unfreeze_on_demand] = id } opts.on("--unfreeze-on-demand-enabled=BOOL", "Enable/disable unfreeze-on-demand (default: true)") { |b| args[:unfreeze_on_demand_enabled] = b.downcase == "true" } opts.on("--with-unfreeze-on-demand", "Enable unfreeze-on-demand when creating service") { args[:create_unfreeze_on_demand] = true } opts.on("--destroy=ID", "Destroy service") { |id| args[:destroy] = id } - opts.on("--execute=ID", "Execute command in service") { |id| args[:execute] = id } + opts.on("--execute=ID", "Execute command in service/session") { |id| args[:execute] = id } opts.on("--command=CMD", "Command to execute (with --execute)") { |cmd| args[:command] = cmd } opts.on("--dump-bootstrap=ID", "Dump bootstrap script") { |id| args[:dump_bootstrap] = id } opts.on("--dump-file=FILE", "File to save bootstrap (with --dump-bootstrap)") { |file| args[:dump_file] = file } opts.on("--resize=ID", "Resize service vCPU") { |id| args[:resize] = id } - opts.on("-v VCPU", "--vcpu=VCPU", "vCPU count (1-8) for resize") { |v| args[:vcpu] = v.to_i } - opts.on("--name=NAME", "Service name") { |n| args[:name] = n } + opts.on("-v VCPU", "--vcpu=VCPU", "vCPU count (1-8) for resize/boost") { |v| args[:vcpu] = v.to_i } + opts.on("--name=NAME", "Service/snapshot name") { |n| args[:name] = n } opts.on("--ports=PORTS", "Comma-separated ports") { |p| args[:ports] = p } opts.on("--domains=DOMAINS", "Comma-separated domains") { |d| args[:domains] = d } opts.on("--type=TYPE", "Service type for SRV records") { |t| args[:service_type] = t } @@ -1154,12 +1563,99 @@ def main opts.on("--env-file=FILE", "Load env vars from file (for vault)") { |f| args[:svc_env_file] = f } opts.on("--extend", "Open browser to extend/renew key") { args[:extend] = true } opts.on("--json", "Output as JSON array (for languages command)") { args[:json] = true } + opts.on("--shell=SHELL", "Shell for session (bash, python3, etc.)") { |s| args[:shell] = s } + opts.on("--lock=ID", "Lock service/snapshot/image") { |id| args[:service_lock] = id } + opts.on("--unlock=ID", "Unlock service/snapshot/image") { |id| args[:service_unlock] = id } + opts.on("--redeploy=ID", "Redeploy service") { |id| args[:redeploy] = id } + opts.on("--boost=ID", "Boost session vCPU") { |id| args[:session_boost] = id } + opts.on("--unboost=ID", "Unboost session") { |id| args[:session_unboost] = id } + # Snapshot options + opts.on("--snapshot-session=ID", "Create snapshot from session") { |id| args[:snapshot_session] = id } + opts.on("--snapshot-service=ID", "Create snapshot from service") { |id| args[:snapshot_service] = id } + opts.on("--restore=ID", "Restore snapshot") { |id| args[:snapshot_restore] = id } + opts.on("--delete=ID", "Delete snapshot") { |id| args[:snapshot_delete] = id } + opts.on("--clone=ID", "Clone snapshot") { |id| args[:snapshot_clone] = id } + opts.on("--clone-type=TYPE", "Clone type (session or service)") { |t| args[:snapshot_clone_type] = t } + opts.on("--hot", "Create hot snapshot") { args[:snapshot_hot] = true } + # Logs options + opts.on("--source=SOURCE", "Log source (all, api, portal, pool/cammy, pool/ai)") { |s| args[:logs_source] = s } + opts.on("--lines=N", "Number of log lines") { |n| args[:logs_lines] = n.to_i } + opts.on("--since=TIME", "Time window (1m, 5m, 1h, 1d)") { |t| args[:logs_since] = t } + opts.on("--grep=PATTERN", "Filter pattern") { |p| args[:logs_grep] = p } + opts.on("--follow", "Follow log stream") { args[:logs_follow] = true } + # Image access options + opts.on("--grant=ID", "Grant image access") { |id| args[:image_grant] = id } + opts.on("--revoke=ID", "Revoke image access") { |id| args[:image_revoke] = id } + opts.on("--trusted=ID", "List trusted keys for image") { |id| args[:image_trusted] = id } + opts.on("--trusted-key=KEY", "API key to grant/revoke access") { |k| args[:image_trusted_key] = k } + opts.on("--transfer=ID", "Transfer image ownership") { |id| args[:image_transfer] = id } + opts.on("--to-key=KEY", "Target API key for transfer") { |k| args[:image_to_key] = k } opts.unknown_args do |before, after| if before.size > 0 case before[0] when "session" args[:command] = "session" + # Parse session subcommand options + i = 1 + while i < before.size + case before[i] + when "--list", "-l" + args[:list] = true + i += 1 + when "--info" + if i + 1 < before.size + args[:session_info] = before[i + 1] + i += 2 + else + i += 1 + end + when "--freeze" + if i + 1 < before.size + args[:session_freeze] = before[i + 1] + i += 2 + else + i += 1 + end + when "--unfreeze" + if i + 1 < before.size + args[:session_unfreeze] = before[i + 1] + i += 2 + else + i += 1 + end + when "--boost" + if i + 1 < before.size + args[:session_boost] = before[i + 1] + i += 2 + else + i += 1 + end + when "--unboost" + if i + 1 < before.size + args[:session_unboost] = before[i + 1] + i += 2 + else + i += 1 + end + when "--execute" + if i + 1 < before.size + args[:session_execute] = before[i + 1] + i += 2 + else + i += 1 + end + when "--command" + if i + 1 < before.size + args[:command] = before[i + 1] + i += 2 + else + i += 1 + end + else + i += 1 + end + end when "service" args[:command] = "service" # Check for env subcommand @@ -1180,11 +1676,179 @@ def main i += 1 end end + else + # Parse service subcommand options + i = 1 + while i < before.size + case before[i] + when "--lock" + if i + 1 < before.size + args[:service_lock] = before[i + 1] + i += 2 + else + i += 1 + end + when "--unlock" + if i + 1 < before.size + args[:service_unlock] = before[i + 1] + i += 2 + else + i += 1 + end + when "--redeploy" + if i + 1 < before.size + args[:redeploy] = before[i + 1] + i += 2 + else + i += 1 + end + else + i += 1 + end + end end when "key" args[:command] = "key" when "languages" args[:command] = "languages" + when "snapshot" + args[:command] = "snapshot" + # Parse snapshot subcommand options + i = 1 + while i < before.size + case before[i] + when "--list", "-l" + args[:list] = true + i += 1 + when "--info" + if i + 1 < before.size + args[:snapshot_info] = before[i + 1] + i += 2 + else + i += 1 + end + when "--session" + if i + 1 < before.size + args[:snapshot_session] = before[i + 1] + i += 2 + else + i += 1 + end + when "--service" + if i + 1 < before.size + args[:snapshot_service] = before[i + 1] + i += 2 + else + i += 1 + end + when "--restore" + if i + 1 < before.size + args[:snapshot_restore] = before[i + 1] + i += 2 + else + i += 1 + end + when "--delete" + if i + 1 < before.size + args[:snapshot_delete] = before[i + 1] + i += 2 + else + i += 1 + end + when "--lock" + if i + 1 < before.size + args[:snapshot_lock] = before[i + 1] + i += 2 + else + i += 1 + end + when "--unlock" + if i + 1 < before.size + args[:snapshot_unlock] = before[i + 1] + i += 2 + else + i += 1 + end + when "--clone" + if i + 1 < before.size + args[:snapshot_clone] = before[i + 1] + i += 2 + else + i += 1 + end + when "--clone-type" + if i + 1 < before.size + args[:snapshot_clone_type] = before[i + 1] + i += 2 + else + i += 1 + end + when "--name" + if i + 1 < before.size + args[:snapshot_name] = before[i + 1] + i += 2 + else + i += 1 + end + when "--hot" + args[:snapshot_hot] = true + i += 1 + when "--ports" + if i + 1 < before.size + args[:snapshot_ports] = before[i + 1] + i += 2 + else + i += 1 + end + else + i += 1 + end + end + when "logs" + args[:command] = "logs" + # Parse logs subcommand options + i = 1 + while i < before.size + case before[i] + when "--source" + if i + 1 < before.size + args[:logs_source] = before[i + 1] + i += 2 + else + i += 1 + end + when "--lines" + if i + 1 < before.size + args[:logs_lines] = before[i + 1].to_i + i += 2 + else + i += 1 + end + when "--since" + if i + 1 < before.size + args[:logs_since] = before[i + 1] + i += 2 + else + i += 1 + end + when "--grep" + if i + 1 < before.size + args[:logs_grep] = before[i + 1] + i += 2 + else + i += 1 + end + when "--follow", "-f" + args[:logs_follow] = true + i += 1 + else + i += 1 + end + end + when "health" + args[:command] = "health" + when "version" + args[:command] = "version" when "image" args[:command] = "image" # Parse image subcommand options @@ -1272,6 +1936,48 @@ def main else i += 1 end + when "--grant" + if i + 1 < before.size + args[:image_grant] = before[i + 1] + i += 2 + else + i += 1 + end + when "--revoke" + if i + 1 < before.size + args[:image_revoke] = before[i + 1] + i += 2 + else + i += 1 + end + when "--trusted" + if i + 1 < before.size + args[:image_trusted] = before[i + 1] + i += 2 + else + i += 1 + end + when "--trusted-key" + if i + 1 < before.size + args[:image_trusted_key] = before[i + 1] + i += 2 + else + i += 1 + end + when "--transfer" + if i + 1 < before.size + args[:image_transfer] = before[i + 1] + i += 2 + else + i += 1 + end + when "--to-key" + if i + 1 < before.size + args[:image_to_key] = before[i + 1] + i += 2 + else + i += 1 + end else i += 1 end @@ -1300,6 +2006,14 @@ def main cmd_languages(args) elsif args[:command] == "image" cmd_image(args) + elsif args[:command] == "snapshot" + cmd_snapshot(args) + elsif args[:command] == "logs" + cmd_logs(args) + elsif args[:command] == "health" + cmd_health(args) + elsif args[:command] == "version" + cmd_version(args) elsif args[:source_file] cmd_execute(args) else diff --git a/clients/csharp/sync/src/Un.cs b/clients/csharp/sync/src/Un.cs index 1b1ca93..a4e7f2c 100644 --- a/clients/csharp/sync/src/Un.cs +++ b/clients/csharp/sync/src/Un.cs @@ -1357,3 +1357,1073 @@ Key options: --extend Open browser to extend expired key"); } } + +// ============================================================================= +// Library API - For embedding in other .NET applications +// ============================================================================= + +/// +/// Unsandbox SDK for C# (Mono) - Full library API matching the C reference implementation +/// +public static class Unsandbox +{ + private const string API_BASE = "https://api.unsandbox.com"; + private const string VERSION = "4.2.50"; + private static string _lastError; + + /// Extension map for language detection + public static readonly Dictionary ExtMap = new Dictionary + { + {".py", "python"}, {".js", "javascript"}, {".ts", "typescript"}, + {".rb", "ruby"}, {".php", "php"}, {".pl", "perl"}, {".lua", "lua"}, + {".sh", "bash"}, {".go", "go"}, {".rs", "rust"}, {".c", "c"}, + {".cpp", "cpp"}, {".cc", "cpp"}, {".cxx", "cpp"}, + {".java", "java"}, {".kt", "kotlin"}, {".cs", "csharp"}, {".fs", "fsharp"}, + {".ps1", "powershell"}, {".hs", "haskell"}, {".ml", "ocaml"}, + {".clj", "clojure"}, {".scm", "scheme"}, {".lisp", "commonlisp"}, + {".erl", "erlang"}, {".ex", "elixir"}, {".exs", "elixir"}, + {".jl", "julia"}, {".r", "r"}, {".R", "r"}, {".cr", "crystal"}, + {".d", "d"}, {".nim", "nim"}, {".zig", "zig"}, {".v", "v"}, + {".dart", "dart"}, {".groovy", "groovy"}, {".scala", "scala"}, + {".f90", "fortran"}, {".f95", "fortran"}, {".cob", "cobol"}, + {".pro", "prolog"}, {".forth", "forth"}, {".4th", "forth"}, + {".tcl", "tcl"}, {".raku", "raku"}, {".m", "objc"} + }; + + // --- Execution Functions (8) --- + + /// Execute code synchronously + public static ExecuteResult Execute(string language, string code, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["language"] = language, ["code"] = code }; + try + { + var result = ApiCall("/execute", "POST", payload, pk, sk); + return new ExecuteResult + { + Stdout = GetString(result, "stdout"), + Stderr = GetString(result, "stderr"), + ExitCode = GetInt(result, "exit_code"), + Language = language, + ExecutionTime = GetDouble(result, "execution_time"), + Success = true + }; + } + catch (Exception ex) { _lastError = ex.Message; return new ExecuteResult { Success = false, ErrorMessage = ex.Message }; } + } + + /// Execute code asynchronously, returns job ID + public static string ExecuteAsync(string language, string code, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["language"] = language, ["code"] = code, ["async"] = true }; + try + { + var result = ApiCall("/execute", "POST", payload, pk, sk); + return GetString(result, "job_id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + /// Wait for async job to complete + public static ExecuteResult WaitJob(string jobId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/jobs/{jobId}/wait", "GET", null, pk, sk); + return new ExecuteResult + { + Stdout = GetString(result, "stdout"), + Stderr = GetString(result, "stderr"), + ExitCode = GetInt(result, "exit_code"), + ExecutionTime = GetDouble(result, "execution_time"), + Success = true + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + /// Get job status + public static JobInfo GetJob(string jobId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/jobs/{jobId}", "GET", null, pk, sk); + return new JobInfo + { + Id = GetString(result, "id"), + Language = GetString(result, "language"), + Status = GetString(result, "status") + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + /// Cancel a running job + public static bool CancelJob(string jobId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/jobs/{jobId}/cancel", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + /// List all jobs + public static List ListJobs(string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/jobs", "GET", null, pk, sk); + var jobs = new List(); + if (result.ContainsKey("jobs") && result["jobs"] is List jobList) + foreach (Dictionary j in jobList) + jobs.Add(new JobInfo { Id = j.ContainsKey("id") ? (string)j["id"] : null, Status = j.ContainsKey("status") ? (string)j["status"] : null }); + return jobs; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + /// Get available programming languages + public static List GetLanguages(string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/languages", "GET", null, pk, sk); + if (result.ContainsKey("languages") && result["languages"] is List langs) + return langs.ConvertAll(x => x.ToString()); + return new List(); + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + /// Detect language from filename extension + public static string DetectLanguage(string filename) + { + int dotIndex = filename.LastIndexOf('.'); + if (dotIndex == -1) return null; + string ext = filename.Substring(dotIndex).ToLower(); + return ExtMap.ContainsKey(ext) ? ExtMap[ext] : null; + } + + // --- Session Functions (9) --- + + public static List SessionList(string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/sessions", "GET", null, pk, sk); + var sessions = new List(); + if (result.ContainsKey("sessions") && result["sessions"] is List sessionList) + foreach (Dictionary s in sessionList) + sessions.Add(new SessionInfo { Id = s.ContainsKey("id") ? (string)s["id"] : null, Status = s.ContainsKey("status") ? (string)s["status"] : null }); + return sessions; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static SessionInfo SessionGet(string sessionId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/sessions/{sessionId}", "GET", null, pk, sk); + return new SessionInfo { Id = GetString(result, "id"), Status = GetString(result, "status") }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static SessionInfo SessionCreate(string networkMode = null, string shell = null, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["shell"] = shell ?? "bash" }; + if (networkMode != null) payload["network"] = networkMode; + try + { + var result = ApiCall("/sessions", "POST", payload, pk, sk); + return new SessionInfo { Id = GetString(result, "id"), Status = "running" }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool SessionDestroy(string sessionId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/sessions/{sessionId}", "DELETE", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SessionFreeze(string sessionId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/sessions/{sessionId}/freeze", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SessionUnfreeze(string sessionId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/sessions/{sessionId}/unfreeze", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SessionBoost(string sessionId, int vcpu = 2, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["vcpu"] = vcpu }; + try { ApiCall($"/sessions/{sessionId}/boost", "POST", payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SessionUnboost(string sessionId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/sessions/{sessionId}/unboost", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static ExecuteResult SessionExecute(string sessionId, string command, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["command"] = command }; + try + { + var result = ApiCall($"/sessions/{sessionId}/execute", "POST", payload, pk, sk); + return new ExecuteResult + { + Stdout = GetString(result, "stdout"), + Stderr = GetString(result, "stderr"), + ExitCode = GetInt(result, "exit_code"), + Success = true + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + // --- Service Functions (17) --- + + public static List ServiceList(string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/services", "GET", null, pk, sk); + var services = new List(); + if (result.ContainsKey("services") && result["services"] is List serviceList) + foreach (Dictionary s in serviceList) + services.Add(new ServiceInfo { Id = s.ContainsKey("id") ? (string)s["id"] : null, Name = s.ContainsKey("name") ? (string)s["name"] : null, Status = s.ContainsKey("status") ? (string)s["status"] : null }); + return services; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static ServiceInfo ServiceGet(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/services/{serviceId}", "GET", null, pk, sk); + return new ServiceInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Status = GetString(result, "status") }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string ServiceCreate(string name, string ports = null, string domains = null, string bootstrap = null, string networkMode = null, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["name"] = name }; + if (ports != null) + { + var portList = new List(); + foreach (var p in ports.Split(',')) portList.Add(int.Parse(p.Trim())); + payload["ports"] = portList; + } + if (domains != null) payload["domains"] = domains; + if (bootstrap != null) payload["bootstrap"] = bootstrap; + if (networkMode != null) payload["network"] = networkMode; + try + { + var result = ApiCall("/services", "POST", payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool ServiceDestroy(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}", "DELETE", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceFreeze(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/freeze", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceUnfreeze(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/unfreeze", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceLock(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/lock", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceUnlock(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/unlock", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceSetUnfreezeOnDemand(string serviceId, bool enabled, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["unfreeze_on_demand"] = enabled }; + try { ApiCall($"/services/{serviceId}", "PATCH", payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceRedeploy(string serviceId, string bootstrap = null, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = bootstrap != null ? new Dictionary { ["bootstrap"] = bootstrap } : null; + try { ApiCall($"/services/{serviceId}/redeploy", "POST", payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static string ServiceLogs(string serviceId, bool allLogs = false, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var endpoint = allLogs ? $"/services/{serviceId}/logs?lines=9000" : $"/services/{serviceId}/logs"; + try + { + var result = ApiCall(endpoint, "GET", null, pk, sk); + return GetString(result, "logs"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static ExecuteResult ServiceExecute(string serviceId, string command, int timeoutMs = 30000, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["command"] = command }; + try + { + var result = ApiCall($"/services/{serviceId}/execute", "POST", payload, pk, sk); + return new ExecuteResult + { + Stdout = GetString(result, "stdout"), + Stderr = GetString(result, "stderr"), + ExitCode = GetInt(result, "exit_code"), + Success = true + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string ServiceEnvGet(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/services/{serviceId}/env", "GET", null, pk, sk); + return GetString(result, "content"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool ServiceEnvSet(string serviceId, string envContent, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCallText($"/services/{serviceId}/env", "PUT", envContent, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceEnvDelete(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/env", "DELETE", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static string ServiceEnvExport(string serviceId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/services/{serviceId}/env/export", "POST", null, pk, sk); + return GetString(result, "content"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool ServiceResize(string serviceId, int vcpu, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["vcpu"] = vcpu }; + try { ApiCall($"/services/{serviceId}/resize", "POST", payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + // --- Snapshot Functions (9) --- + + public static List SnapshotList(string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/snapshots", "GET", null, pk, sk); + var snapshots = new List(); + if (result.ContainsKey("snapshots") && result["snapshots"] is List snapshotList) + foreach (Dictionary s in snapshotList) + snapshots.Add(new SnapshotInfo { Id = s.ContainsKey("id") ? (string)s["id"] : null, Name = s.ContainsKey("name") ? (string)s["name"] : null }); + return snapshots; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static SnapshotInfo SnapshotGet(string snapshotId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/snapshots/{snapshotId}", "GET", null, pk, sk); + return new SnapshotInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Type = GetString(result, "source_type") }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string SnapshotSession(string sessionId, string name = null, bool hot = false, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary(); + if (name != null) payload["name"] = name; + if (hot) payload["hot"] = true; + try + { + var result = ApiCall($"/sessions/{sessionId}/snapshot", "POST", payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string SnapshotService(string serviceId, string name = null, bool hot = false, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary(); + if (name != null) payload["name"] = name; + if (hot) payload["hot"] = true; + try + { + var result = ApiCall($"/services/{serviceId}/snapshot", "POST", payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string SnapshotRestore(string snapshotId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/snapshots/{snapshotId}/restore", "POST", null, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool SnapshotDelete(string snapshotId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/snapshots/{snapshotId}", "DELETE", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SnapshotLock(string snapshotId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/snapshots/{snapshotId}/lock", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SnapshotUnlock(string snapshotId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/snapshots/{snapshotId}/unlock", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static string SnapshotClone(string snapshotId, string cloneType, string name = null, string ports = null, string shell = null, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["type"] = cloneType }; + if (name != null) payload["name"] = name; + if (ports != null) + { + var portList = new List(); + foreach (var p in ports.Split(',')) portList.Add(int.Parse(p.Trim())); + payload["ports"] = portList; + } + if (shell != null) payload["shell"] = shell; + try + { + var result = ApiCall($"/snapshots/{snapshotId}/clone", "POST", payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + // --- Image Functions (13) --- + + public static List ImageList(string filter = null, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var endpoint = filter != null ? $"/images?filter={filter}" : "/images"; + try + { + var result = ApiCall(endpoint, "GET", null, pk, sk); + var images = new List(); + if (result.ContainsKey("images") && result["images"] is List imageList) + foreach (Dictionary img in imageList) + images.Add(new ImageInfo { Id = img.ContainsKey("id") ? (string)img["id"] : null, Name = img.ContainsKey("name") ? (string)img["name"] : null }); + return images; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static ImageInfo ImageGet(string imageId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/images/{imageId}", "GET", null, pk, sk); + return new ImageInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Visibility = GetString(result, "visibility") }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string ImagePublish(string sourceType, string sourceId, string name = null, string description = null, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["source_type"] = sourceType, ["source_id"] = sourceId }; + if (name != null) payload["name"] = name; + if (description != null) payload["description"] = description; + try + { + var result = ApiCall("/images", "POST", payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool ImageDelete(string imageId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/images/{imageId}", "DELETE", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageLock(string imageId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/images/{imageId}/lock", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageUnlock(string imageId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/images/{imageId}/unlock", "POST", null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageSetVisibility(string imageId, string visibility, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["visibility"] = visibility }; + try { ApiCall($"/images/{imageId}", "PATCH", payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageGrantAccess(string imageId, string trustedApiKey, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["api_key"] = trustedApiKey }; + try { ApiCall($"/images/{imageId}/access", "POST", payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageRevokeAccess(string imageId, string trustedApiKey, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["api_key"] = trustedApiKey }; + try { ApiCall($"/images/{imageId}/access", "DELETE", payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static List ImageListTrusted(string imageId, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/images/{imageId}/access", "GET", null, pk, sk); + if (result.ContainsKey("trusted_keys") && result["trusted_keys"] is List keys) + return keys.ConvertAll(x => x.ToString()); + return new List(); + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static bool ImageTransfer(string imageId, string toApiKey, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["to_api_key"] = toApiKey }; + try { ApiCall($"/images/{imageId}/transfer", "POST", payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static string ImageSpawn(string imageId, string name = null, string ports = null, string bootstrap = null, string networkMode = null, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary(); + if (name != null) payload["name"] = name; + if (ports != null) + { + var portList = new List(); + foreach (var p in ports.Split(',')) portList.Add(int.Parse(p.Trim())); + payload["ports"] = portList; + } + if (bootstrap != null) payload["bootstrap"] = bootstrap; + if (networkMode != null) payload["network"] = networkMode; + try + { + var result = ApiCall($"/images/{imageId}/spawn", "POST", payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string ImageClone(string imageId, string name = null, string description = null, string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary(); + if (name != null) payload["name"] = name; + if (description != null) payload["description"] = description; + try + { + var result = ApiCall($"/images/{imageId}/clone", "POST", payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + // --- Utilities --- + + public static KeyInfo ValidateKeys(string publicKey = null, string secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/keys/validate", "POST", null, pk, sk); + return new KeyInfo + { + Valid = result.ContainsKey("valid") && result["valid"] is bool v && v, + Tier = GetString(result, "tier"), + RateLimitPerMinute = GetInt(result, "rate_limit"), + ConcurrencyLimit = GetInt(result, "concurrency") + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string HmacSign(string secretKey, string message) + { + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))) + { + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); + return BitConverter.ToString(hash).Replace("-", "").ToLower(); + } + } + + public static bool HealthCheck() + { + try + { + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; + var request = (HttpWebRequest)WebRequest.Create(API_BASE + "/health"); + request.Method = "GET"; + request.Timeout = 10000; + using (var response = (HttpWebResponse)request.GetResponse()) + return response.StatusCode == HttpStatusCode.OK; + } + catch { return false; } + } + + public static string Version() => VERSION; + + public static string LastError() => _lastError; + + /// Build environment content from list of env vars and optional env file + public static string BuildEnvContent(List envs, string envFile) + { + var lines = new List(envs); + if (!string.IsNullOrEmpty(envFile) && File.Exists(envFile)) + { + var content = File.ReadAllText(envFile); + foreach (var line in content.Split('\n')) + { + var trimmed = line.Trim(); + if (!string.IsNullOrEmpty(trimmed) && !trimmed.StartsWith("#")) + lines.Add(trimmed); + } + } + return string.Join("\n", lines); + } + + // --- Internal Helpers --- + + private static (string, string) ResolveKeys(string publicKey, string secretKey) + { + var pk = publicKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") ?? ""; + var sk = secretKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") ?? ""; + return (pk, sk); + } + + private static Dictionary ApiCall(string endpoint, string method, Dictionary data, string publicKey, string secretKey) + { + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; + + var request = (HttpWebRequest)WebRequest.Create(API_BASE + endpoint); + request.Method = method; + request.ContentType = "application/json"; + request.Timeout = 300000; + + string body = data != null ? ToJson(data) : ""; + + if (!string.IsNullOrEmpty(secretKey)) + { + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string message = $"{timestamp}:{method}:{endpoint}:{body}"; + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))) + { + byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); + string signature = BitConverter.ToString(hash).Replace("-", "").ToLower(); + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + } + } + else + { + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + } + + if (data != null) + { + byte[] bytes = Encoding.UTF8.GetBytes(body); + request.ContentLength = bytes.Length; + using (Stream stream = request.GetRequestStream()) + stream.Write(bytes, 0, bytes.Length); + } + + using (var response = (HttpWebResponse)request.GetResponse()) + using (var reader = new StreamReader(response.GetResponseStream())) + { + var responseText = reader.ReadToEnd(); + return ParseJson(responseText); + } + } + + private static void ApiCallText(string endpoint, string method, string body, string publicKey, string secretKey) + { + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; + + var request = (HttpWebRequest)WebRequest.Create(API_BASE + endpoint); + request.Method = method; + request.ContentType = "text/plain"; + request.Timeout = 300000; + + if (!string.IsNullOrEmpty(secretKey)) + { + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string message = $"{timestamp}:{method}:{endpoint}:{body}"; + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))) + { + byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); + string signature = BitConverter.ToString(hash).Replace("-", "").ToLower(); + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + } + } + else + { + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + } + + byte[] bytes = Encoding.UTF8.GetBytes(body); + request.ContentLength = bytes.Length; + using (Stream stream = request.GetRequestStream()) + stream.Write(bytes, 0, bytes.Length); + + using (var response = (HttpWebResponse)request.GetResponse()) { } + } + + private static string ToJson(Dictionary dict) + { + var sb = new StringBuilder("{"); + bool first = true; + foreach (var kv in dict) + { + if (!first) sb.Append(","); + first = false; + sb.Append($"\"{kv.Key}\":"); + sb.Append(ValueToJson(kv.Value)); + } + sb.Append("}"); + return sb.ToString(); + } + + private static string ValueToJson(object val) + { + if (val == null) return "null"; + if (val is string s) return $"\"{s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r")}\""; + if (val is bool b) return b.ToString().ToLower(); + if (val is int || val is long || val is double) return val.ToString(); + if (val is List intList) + { + var sb = new StringBuilder("["); + for (int i = 0; i < intList.Count; i++) + { + if (i > 0) sb.Append(","); + sb.Append(intList[i]); + } + sb.Append("]"); + return sb.ToString(); + } + if (val is Dictionary dict) return ToJson(dict); + return $"\"{val}\""; + } + + private static Dictionary ParseJson(string json) + { + // Reuse the existing ParseJson from the Un class + json = json.Trim(); + if (!json.StartsWith("{")) return new Dictionary(); + + var result = new Dictionary(); + int i = 1; + + while (i < json.Length) + { + while (i < json.Length && char.IsWhiteSpace(json[i])) i++; + if (json[i] == '}') break; + + if (json[i] == '"') + { + int keyStart = ++i; + while (i < json.Length && json[i] != '"') + { + if (json[i] == '\\') i++; + i++; + } + string key = json.Substring(keyStart, i - keyStart).Replace("\\\"", "\"").Replace("\\\\", "\\"); + i++; + + while (i < json.Length && (char.IsWhiteSpace(json[i]) || json[i] == ':')) i++; + + var valuePair = ParseJsonValue(json, i); + result[key] = valuePair.Item1; + i = valuePair.Item2; + + while (i < json.Length && (char.IsWhiteSpace(json[i]) || json[i] == ',')) i++; + } + else + { + i++; + } + } + return result; + } + + private static Tuple ParseJsonValue(string json, int start) + { + int i = start; + while (i < json.Length && char.IsWhiteSpace(json[i])) i++; + + if (json[i] == '"') + { + i++; + var sb = new StringBuilder(); + bool escaped = false; + while (i < json.Length) + { + char c = json[i]; + if (escaped) + { + switch (c) + { + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + default: sb.Append(c); break; + } + escaped = false; + } + else if (c == '\\') escaped = true; + else if (c == '"') return Tuple.Create((object)sb.ToString(), i + 1); + else sb.Append(c); + i++; + } + } + else if (json[i] == '{') + { + int depth = 1; + int objStart = i++; + while (i < json.Length && depth > 0) + { + if (json[i] == '{') depth++; + else if (json[i] == '}') depth--; + i++; + } + return Tuple.Create((object)ParseJson(json.Substring(objStart, i - objStart)), i); + } + else if (json[i] == '[') + { + var list = new List(); + i++; + while (i < json.Length) + { + while (i < json.Length && char.IsWhiteSpace(json[i])) i++; + if (json[i] == ']') { i++; break; } + var item = ParseJsonValue(json, i); + list.Add(item.Item1); + i = item.Item2; + while (i < json.Length && (char.IsWhiteSpace(json[i]) || json[i] == ',')) i++; + } + return Tuple.Create((object)list, i); + } + else if (char.IsDigit(json[i]) || json[i] == '-') + { + int numStart = i; + while (i < json.Length && (char.IsDigit(json[i]) || json[i] == '.' || json[i] == '-')) i++; + string num = json.Substring(numStart, i - numStart); + return Tuple.Create((object)(num.Contains(".") ? (object)double.Parse(num) : int.Parse(num)), i); + } + else if (json.Substring(i).StartsWith("true")) return Tuple.Create((object)true, i + 4); + else if (json.Substring(i).StartsWith("false")) return Tuple.Create((object)false, i + 5); + else if (json.Substring(i).StartsWith("null")) return Tuple.Create((object)null, i + 4); + + return Tuple.Create((object)null, i); + } + + private static string GetString(Dictionary result, string key) + => result.ContainsKey(key) ? result[key]?.ToString() : null; + + private static int GetInt(Dictionary result, string key) + => result.ContainsKey(key) && result[key] is int i ? i : 0; + + private static double GetDouble(Dictionary result, string key) + => result.ContainsKey(key) && result[key] is double d ? d : 0; +} + +// --- Data Types --- + +public class ExecuteResult +{ + public string Stdout { get; set; } + public string Stderr { get; set; } + public int ExitCode { get; set; } + public string Language { get; set; } + public double ExecutionTime { get; set; } + public bool Success { get; set; } + public string ErrorMessage { get; set; } +} + +public class JobInfo +{ + public string Id { get; set; } + public string Language { get; set; } + public string Status { get; set; } + public long CreatedAt { get; set; } + public long CompletedAt { get; set; } + public string ErrorMessage { get; set; } +} + +public class SessionInfo +{ + public string Id { get; set; } + public string ContainerName { get; set; } + public string Status { get; set; } + public string NetworkMode { get; set; } + public int Vcpu { get; set; } + public long CreatedAt { get; set; } + public long LastActivity { get; set; } +} + +public class ServiceInfo +{ + public string Id { get; set; } + public string Name { get; set; } + public string Status { get; set; } + public string ContainerName { get; set; } + public string NetworkMode { get; set; } + public string Ports { get; set; } + public string Domains { get; set; } + public int Vcpu { get; set; } + public bool Locked { get; set; } + public bool UnfreezeOnDemand { get; set; } + public long CreatedAt { get; set; } + public long LastActivity { get; set; } +} + +public class SnapshotInfo +{ + public string Id { get; set; } + public string Name { get; set; } + public string Type { get; set; } + public string SourceId { get; set; } + public bool Hot { get; set; } + public bool Locked { get; set; } + public long CreatedAt { get; set; } + public long SizeBytes { get; set; } +} + +public class ImageInfo +{ + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Visibility { get; set; } + public string SourceType { get; set; } + public string SourceId { get; set; } + public string OwnerApiKey { get; set; } + public bool Locked { get; set; } + public long CreatedAt { get; set; } + public long SizeBytes { get; set; } +} + +public class KeyInfo +{ + public bool Valid { get; set; } + public string Tier { get; set; } + public int RateLimitPerMinute { get; set; } + public int RateLimitBurst { get; set; } + public int ConcurrencyLimit { get; set; } + public string ErrorMessage { get; set; } +} diff --git a/clients/csharp/tests/UnsandboxTests.cs b/clients/csharp/tests/UnsandboxTests.cs new file mode 100644 index 0000000..0d6dd7f --- /dev/null +++ b/clients/csharp/tests/UnsandboxTests.cs @@ -0,0 +1,228 @@ +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// Unit and Functional Tests for Unsandbox C# SDK (Mono) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Security.Cryptography; +using System.Text; + +/// +/// Unit tests for the Unsandbox SDK library functions. +/// These tests verify that exported library functions work correctly. +/// +public class UnitTests +{ + public static void Run() + { + Console.WriteLine("=== Unsandbox C# SDK Unit Tests ===\n"); + + TestDetectLanguage(); + TestHmacSign(); + TestExtensionMap(); + TestBuildEnvContent(); + + Console.WriteLine("\n=== Unit Tests Complete ==="); + } + + static void TestDetectLanguage() + { + Console.Write("DetectLanguage: "); + var tests = new Dictionary + { + { "test.py", "python" }, + { "script.js", "javascript" }, + { "main.go", "go" }, + { "app.rs", "rust" }, + { "Program.cs", "csharp" }, + { "Module.fs", "fsharp" }, + { "script.ps1", "powershell" } + }; + + int passed = 0; + foreach (var test in tests) + { + var result = Unsandbox.DetectLanguage(test.Key); + if (result == test.Value) passed++; + else Console.Write($"[FAIL: {test.Key} -> {result}, expected {test.Value}] "); + } + + if (passed == tests.Count) + Console.WriteLine($"PASS ({passed}/{tests.Count})"); + else + Console.WriteLine($"FAIL ({passed}/{tests.Count})"); + } + + static void TestHmacSign() + { + Console.Write("HmacSign: "); + // Test vector: HMAC-SHA256("key", "message") + var result = Unsandbox.HmacSign("key", "message"); + // Expected: 6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a + var expected = "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"; + if (result == expected) + Console.WriteLine("PASS"); + else + Console.WriteLine($"FAIL (got {result}, expected {expected})"); + } + + static void TestExtensionMap() + { + Console.Write("ExtensionMap: "); + // Test that the extension map contains expected entries + var tests = new Dictionary + { + { ".py", "python" }, + { ".js", "javascript" }, + { ".go", "go" }, + { ".rs", "rust" }, + { ".cs", "csharp" } + }; + + int passed = 0; + foreach (var test in tests) + { + if (Unsandbox.ExtMap.TryGetValue(test.Key, out var lang) && lang == test.Value) + passed++; + } + + if (passed == tests.Count) + Console.WriteLine($"PASS ({passed}/{tests.Count})"); + else + Console.WriteLine($"FAIL ({passed}/{tests.Count})"); + } + + static void TestBuildEnvContent() + { + Console.Write("BuildEnvContent: "); + var envs = new List { "KEY1=value1", "KEY2=value2" }; + var result = Unsandbox.BuildEnvContent(envs, null); + var hasKey1 = result.Contains("KEY1=value1"); + var hasKey2 = result.Contains("KEY2=value2"); + if (hasKey1 && hasKey2) + Console.WriteLine("PASS"); + else + Console.WriteLine($"FAIL (got: {result})"); + } +} + +/// +/// Functional tests that require API credentials. +/// Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables. +/// +public class FunctionalTests +{ + public static void Run() + { + var publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); + var secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); + + if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) + { + Console.WriteLine("=== Functional Tests Skipped (no API credentials) ==="); + return; + } + + Console.WriteLine("=== Unsandbox C# SDK Functional Tests ===\n"); + + TestHealthCheck(); + TestValidateKeys(); + TestGetLanguages(); + TestExecute(); + TestSessionList(); + TestServiceList(); + TestSnapshotList(); + TestImageList(); + + Console.WriteLine("\n=== Functional Tests Complete ==="); + } + + static void TestHealthCheck() + { + Console.Write("HealthCheck: "); + var result = Unsandbox.HealthCheck(); + Console.WriteLine(result ? "PASS" : "FAIL"); + } + + static void TestValidateKeys() + { + Console.Write("ValidateKeys: "); + var result = Unsandbox.ValidateKeys(); + if (result != null && result.Valid) + Console.WriteLine($"PASS (tier: {result.Tier})"); + else + Console.WriteLine($"FAIL ({Unsandbox.LastError()})"); + } + + static void TestGetLanguages() + { + Console.Write("GetLanguages: "); + var result = Unsandbox.GetLanguages(); + if (result.Count > 0) + Console.WriteLine($"PASS ({result.Count} languages)"); + else + Console.WriteLine($"FAIL ({Unsandbox.LastError()})"); + } + + static void TestExecute() + { + Console.Write("Execute: "); + var result = Unsandbox.Execute("python", "print('hello from C# SDK')"); + if (result.Success && result.Stdout != null && result.Stdout.Contains("hello")) + Console.WriteLine("PASS"); + else + Console.WriteLine($"FAIL ({result.ErrorMessage ?? Unsandbox.LastError()})"); + } + + static void TestSessionList() + { + Console.Write("SessionList: "); + var result = Unsandbox.SessionList(); + // Empty list is valid - just checking API call works + Console.WriteLine($"PASS ({result.Count} sessions)"); + } + + static void TestServiceList() + { + Console.Write("ServiceList: "); + var result = Unsandbox.ServiceList(); + Console.WriteLine($"PASS ({result.Count} services)"); + } + + static void TestSnapshotList() + { + Console.Write("SnapshotList: "); + var result = Unsandbox.SnapshotList(); + Console.WriteLine($"PASS ({result.Count} snapshots)"); + } + + static void TestImageList() + { + Console.Write("ImageList: "); + var result = Unsandbox.ImageList(); + Console.WriteLine($"PASS ({result.Count} images)"); + } +} + +public class Program +{ + public static int Main(string[] args) + { + try + { + Console.WriteLine("Unsandbox C# SDK Tests (Mono)"); + Console.WriteLine("=============================\n"); + + UnitTests.Run(); + Console.WriteLine(); + FunctionalTests.Run(); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Test error: {ex.Message}"); + return 1; + } + } +} diff --git a/clients/d/sync/src/un.d b/clients/d/sync/src/un.d index 581c315..a510d56 100644 --- a/clients/d/sync/src/un.d +++ b/clients/d/sync/src/un.d @@ -439,6 +439,473 @@ string extractJsonField(string response, string field) { return ""; } +// ============================================================================ +// Library Functions for D SDK (matching C reference un.h) +// ============================================================================ + +immutable string SDK_VERSION = "4.2.0"; + +// Execute code synchronously +string execute(string language, string code, string publicKey, string secretKey) { + string body_ = format(`{"language":"%s","code":"%s"}`, escapeJson(language), escapeJson(code)); + string authHeaders = buildAuthHeaders("POST", "/execute", body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/execute' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); + return execCurl(cmd); +} + +// Execute code asynchronously (returns job_id) +string executeAsync(string language, string code, string publicKey, string secretKey) { + string body_ = format(`{"language":"%s","code":"%s","async":true}`, escapeJson(language), escapeJson(code)); + string authHeaders = buildAuthHeaders("POST", "/execute", body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/execute' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); + return execCurl(cmd); +} + +// Get job status +string getJob(string jobId, string publicKey, string secretKey) { + string path = format("/jobs/%s", jobId); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +// Wait for job completion +string waitForJob(string jobId, string publicKey, string secretKey) { + import core.thread : Thread; + import core.time : msecs; + int[7] pollDelays = [300, 450, 700, 900, 650, 1600, 2000]; + int delayIdx = 0; + + while (true) { + string result = getJob(jobId, publicKey, secretKey); + import std.algorithm : canFind; + if (result.canFind(`"status":"completed"`) || result.canFind(`"status":"failed"`) || + result.canFind(`"status":"timeout"`) || result.canFind(`"status":"cancelled"`)) { + return result; + } + Thread.sleep(msecs(pollDelays[delayIdx % 7])); + if (delayIdx < 6) delayIdx++; + } +} + +// Cancel a job +string cancelJob(string jobId, string publicKey, string secretKey) { + string path = format("/jobs/%s/cancel", jobId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +// List all jobs +string listJobs(string publicKey, string secretKey) { + string authHeaders = buildAuthHeaders("GET", "/jobs", "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/jobs' %s`, API_BASE, authHeaders); + return execCurl(cmd); +} + +// Get supported languages +string getLanguages(string publicKey, string secretKey) { + string authHeaders = buildAuthHeaders("GET", "/languages", "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/languages' %s`, API_BASE, authHeaders); + return execCurl(cmd); +} + +// Session functions +string sessionList(string publicKey, string secretKey) { + string authHeaders = buildAuthHeaders("GET", "/sessions", "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/sessions' %s`, API_BASE, authHeaders); + return execCurl(cmd); +} + +string sessionGet(string sessionId, string publicKey, string secretKey) { + string path = format("/sessions/%s", sessionId); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string sessionCreate(string shell, string network, string publicKey, string secretKey) { + string body_ = format(`{"shell":"%s"`, shell.empty ? "bash" : shell); + if (!network.empty) body_ ~= format(`,"network":"%s"`, network); + body_ ~= "}"; + string authHeaders = buildAuthHeaders("POST", "/sessions", body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/sessions' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); + return execCurl(cmd); +} + +string sessionDestroy(string sessionId, string publicKey, string secretKey) { + string path = format("/sessions/%s", sessionId); + string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string sessionFreeze(string sessionId, string publicKey, string secretKey) { + string path = format("/sessions/%s/freeze", sessionId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string sessionUnfreeze(string sessionId, string publicKey, string secretKey) { + string path = format("/sessions/%s/unfreeze", sessionId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string sessionBoost(string sessionId, int vcpu, string publicKey, string secretKey) { + string path = format("/sessions/%s/boost", sessionId); + string body_ = vcpu > 0 ? format(`{"vcpu":%d}`, vcpu) : "{}"; + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string sessionUnboost(string sessionId, string publicKey, string secretKey) { + string path = format("/sessions/%s/unboost", sessionId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string sessionExecute(string sessionId, string command, string publicKey, string secretKey) { + string path = format("/sessions/%s/shell", sessionId); + string body_ = format(`{"command":"%s"}`, escapeJson(command)); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +// Service functions +string serviceListFn(string publicKey, string secretKey) { + string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/services' %s`, API_BASE, authHeaders); + return execCurl(cmd); +} + +string serviceGet(string serviceId, string publicKey, string secretKey) { + string path = format("/services/%s", serviceId); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string serviceCreate(string name, string ports, string bootstrap, string network, string publicKey, string secretKey) { + string body_ = format(`{"name":"%s"`, escapeJson(name)); + if (!ports.empty) body_ ~= format(`,"ports":"%s"`, ports); + if (!bootstrap.empty) body_ ~= format(`,"bootstrap":"%s"`, escapeJson(bootstrap)); + if (!network.empty) body_ ~= format(`,"network":"%s"`, network); + body_ ~= "}"; + string authHeaders = buildAuthHeaders("POST", "/services", body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/services' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); + return execCurl(cmd); +} + +string serviceDestroy(string serviceId, string publicKey, string secretKey) { + string path = format("/services/%s", serviceId); + string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string serviceFreeze(string serviceId, string publicKey, string secretKey) { + string path = format("/services/%s/freeze", serviceId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string serviceUnfreeze(string serviceId, string publicKey, string secretKey) { + string path = format("/services/%s/unfreeze", serviceId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string serviceLock(string serviceId, string publicKey, string secretKey) { + string path = format("/services/%s/lock", serviceId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string serviceUnlock(string serviceId, string publicKey, string secretKey) { + string path = format("/services/%s/unlock", serviceId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string serviceRedeploy(string serviceId, string bootstrap, string publicKey, string secretKey) { + string path = format("/services/%s/redeploy", serviceId); + string body_ = bootstrap.empty ? "{}" : format(`{"bootstrap":"%s"}`, escapeJson(bootstrap)); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string serviceLogs(string serviceId, bool all, string publicKey, string secretKey) { + string path = format("/services/%s/logs%s", serviceId, all ? "?all=true" : ""); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string serviceExecute(string serviceId, string command, int timeoutMs, string publicKey, string secretKey) { + string path = format("/services/%s/execute", serviceId); + string body_ = format(`{"command":"%s"`, escapeJson(command)); + if (timeoutMs > 0) body_ ~= format(`,"timeout":%d`, timeoutMs); + body_ ~= "}"; + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string serviceResize(string serviceId, int vcpu, string publicKey, string secretKey) { + string path = format("/services/%s/resize", serviceId); + string body_ = format(`{"vcpu":%d}`, vcpu); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +// Snapshot functions +string snapshotList(string publicKey, string secretKey) { + string authHeaders = buildAuthHeaders("GET", "/snapshots", "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/snapshots' %s`, API_BASE, authHeaders); + return execCurl(cmd); +} + +string snapshotGet(string snapshotId, string publicKey, string secretKey) { + string path = format("/snapshots/%s", snapshotId); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string snapshotSession(string sessionId, string name, bool hot, string publicKey, string secretKey) { + string path = format("/sessions/%s/snapshot", sessionId); + string body_ = "{"; + if (!name.empty) body_ ~= format(`"name":"%s",`, escapeJson(name)); + body_ ~= format(`"hot":%s}`, hot ? "true" : "false"); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string snapshotService(string serviceId, string name, bool hot, string publicKey, string secretKey) { + string path = format("/services/%s/snapshot", serviceId); + string body_ = "{"; + if (!name.empty) body_ ~= format(`"name":"%s",`, escapeJson(name)); + body_ ~= format(`"hot":%s}`, hot ? "true" : "false"); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string snapshotRestore(string snapshotId, string publicKey, string secretKey) { + string path = format("/snapshots/%s/restore", snapshotId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string snapshotDelete(string snapshotId, string publicKey, string secretKey) { + string path = format("/snapshots/%s", snapshotId); + string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string snapshotLock(string snapshotId, string publicKey, string secretKey) { + string path = format("/snapshots/%s/lock", snapshotId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string snapshotUnlock(string snapshotId, string publicKey, string secretKey) { + string path = format("/snapshots/%s/unlock", snapshotId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string snapshotClone(string snapshotId, string cloneType, string name, string ports, string shell, string publicKey, string secretKey) { + string path = format("/snapshots/%s/clone", snapshotId); + string body_ = format(`{"type":"%s"`, cloneType); + if (!name.empty) body_ ~= format(`,"name":"%s"`, escapeJson(name)); + if (!ports.empty) body_ ~= format(`,"ports":"%s"`, ports); + if (!shell.empty) body_ ~= format(`,"shell":"%s"`, shell); + body_ ~= "}"; + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +// Image functions +string imageList(string filter, string publicKey, string secretKey) { + string path = filter.empty ? "/images" : format("/images?filter=%s", filter); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string imageGetFn(string imageId, string publicKey, string secretKey) { + string path = format("/images/%s", imageId); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string imagePublish(string sourceType, string sourceId, string name, string description, string publicKey, string secretKey) { + string body_ = format(`{"source_type":"%s","source_id":"%s"`, sourceType, sourceId); + if (!name.empty) body_ ~= format(`,"name":"%s"`, escapeJson(name)); + if (!description.empty) body_ ~= format(`,"description":"%s"`, escapeJson(description)); + body_ ~= "}"; + string authHeaders = buildAuthHeaders("POST", "/images", body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/images' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); + return execCurl(cmd); +} + +string imageDelete(string imageId, string publicKey, string secretKey) { + string path = format("/images/%s", imageId); + string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string imageLock(string imageId, string publicKey, string secretKey) { + string path = format("/images/%s/lock", imageId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string imageUnlock(string imageId, string publicKey, string secretKey) { + string path = format("/images/%s/unlock", imageId); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string imageSetVisibility(string imageId, string visibility, string publicKey, string secretKey) { + string path = format("/images/%s/visibility", imageId); + string body_ = format(`{"visibility":"%s"}`, visibility); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string imageGrantAccess(string imageId, string trustedKey, string publicKey, string secretKey) { + string path = format("/images/%s/grant", imageId); + string body_ = format(`{"trusted_api_key":"%s"}`, trustedKey); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string imageRevokeAccess(string imageId, string trustedKey, string publicKey, string secretKey) { + string path = format("/images/%s/revoke", imageId); + string body_ = format(`{"trusted_api_key":"%s"}`, trustedKey); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string imageListTrusted(string imageId, string publicKey, string secretKey) { + string path = format("/images/%s/trusted", imageId); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +string imageTransfer(string imageId, string toApiKey, string publicKey, string secretKey) { + string path = format("/images/%s/transfer", imageId); + string body_ = format(`{"to_api_key":"%s"}`, toApiKey); + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string imageSpawn(string imageId, string name, string ports, string bootstrap, string network, string publicKey, string secretKey) { + string path = format("/images/%s/spawn", imageId); + string body_ = "{"; + string[] fields; + if (!name.empty) fields ~= format(`"name":"%s"`, escapeJson(name)); + if (!ports.empty) fields ~= format(`"ports":"%s"`, ports); + if (!bootstrap.empty) fields ~= format(`"bootstrap":"%s"`, escapeJson(bootstrap)); + if (!network.empty) fields ~= format(`"network":"%s"`, network); + import std.array : join; + body_ ~= fields.join(",") ~ "}"; + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +string imageCloneFn(string imageId, string name, string description, string publicKey, string secretKey) { + string path = format("/images/%s/clone", imageId); + string body_ = "{"; + string[] fields; + if (!name.empty) fields ~= format(`"name":"%s"`, escapeJson(name)); + if (!description.empty) fields ~= format(`"description":"%s"`, escapeJson(description)); + import std.array : join; + body_ ~= fields.join(",") ~ "}"; + string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); + return execCurl(cmd); +} + +// PaaS Logs functions +string logsFetch(string source, int lines, string since, string grep, string publicKey, string secretKey) { + string path = "/paas/logs?"; + if (!source.empty) path ~= format("source=%s&", source); + if (lines > 0) path ~= format("lines=%d&", lines); + if (!since.empty) path ~= format("since=%s&", since); + if (!grep.empty) path ~= format("grep=%s&", grep); + if (path[$-1] == '&' || path[$-1] == '?') path = path[0..$-1]; + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); + return execCurl(cmd); +} + +// Key validation +string validateKeysFn(string publicKey, string secretKey) { + string authHeaders = buildAuthHeaders("POST", "/keys/validate", "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/keys/validate' %s`, API_BASE, authHeaders); + return execCurl(cmd); +} + +// Utility functions +string hmacSign(string secretKey, string message) { + return computeHmac(secretKey, message); +} + +bool healthCheck() { + string cmd = format(`curl -s -o /dev/null -w '%%{http_code}' '%s/health' 2>/dev/null`, API_BASE); + auto result = executeShell(cmd); + try { + return to!int(result.output.strip()) == 200; + } catch (Exception e) { + return false; + } +} + +string sdkVersion() { + return SDK_VERSION; +} + +__gshared string lastErrorMsg; + +void setLastError(string msg) { + lastErrorMsg = msg; +} + +string lastError() { + return lastErrorMsg; +} + void cmdServiceEnv(string action, string target, string[] svcEnvs, string svcEnvFile, string publicKey, string secretKey) { if (action == "status") { if (target.empty) { diff --git a/clients/d/sync/tests/test_un.d b/clients/d/sync/tests/test_un.d new file mode 100644 index 0000000..9005098 --- /dev/null +++ b/clients/d/sync/tests/test_un.d @@ -0,0 +1,260 @@ +// Tests for the D unsandbox SDK +// Compile: dmd -unittest -main test_un.d ../src/un.d -of=test_un +// Run: ./test_un + +import std.stdio; +import std.process : environment; +import std.algorithm : canFind; + +// Note: In D, unittest blocks are automatically discovered and run +// when compiling with -unittest flag + +// ============================================================================ +// Unit Tests - Test exported library functions +// ============================================================================ + +unittest { + writeln("Testing detectLanguage..."); + assert(detectLanguage("script.py") == "python"); + assert(detectLanguage("script.js") == "javascript"); + assert(detectLanguage("script.ts") == "typescript"); + assert(detectLanguage("script.go") == "go"); + assert(detectLanguage("script.rs") == "rust"); + assert(detectLanguage("script.c") == "c"); + assert(detectLanguage("script.cpp") == "cpp"); + assert(detectLanguage("script.d") == "d"); + assert(detectLanguage("script.zig") == "zig"); + assert(detectLanguage("script.sh") == "bash"); + assert(detectLanguage("script.rb") == "ruby"); + assert(detectLanguage("script.php") == "php"); + assert(detectLanguage("script.unknown") == ""); + assert(detectLanguage("script") == ""); + writeln(" PASS"); +} + +unittest { + writeln("Testing hmacSign..."); + string secretKey = "test-secret"; + string message = "test-message"; + + string result = hmacSign(secretKey, message); + + // Should return a 64-character hex string + assert(result.length == 64, "HMAC signature should be 64 characters"); + + // Should be deterministic + string result2 = hmacSign(secretKey, message); + assert(result == result2, "HMAC sign should be deterministic"); + + // Different inputs should produce different outputs + string result3 = hmacSign(secretKey, "different-message"); + assert(result != result3, "Different inputs should produce different signatures"); + writeln(" PASS"); +} + +unittest { + writeln("Testing sdkVersion..."); + string v = sdkVersion(); + assert(v.length > 0, "Version should not be empty"); + // Should be in semver format (at least "0.0.0") + assert(v.length >= 5, "Version should be in semver format"); + writeln(" PASS"); +} + +unittest { + writeln("Testing lastError..."); + // Set an error + setLastError("test error message"); + + // Retrieve it + string err = lastError(); + assert(err == "test error message"); + + // Clear it + setLastError(""); + err = lastError(); + assert(err == "", "Error should be cleared"); + writeln(" PASS"); +} + +unittest { + writeln("Testing escapeJson..."); + assert(escapeJson("hello") == "hello"); + assert(escapeJson("hello\"world") == "hello\\\"world"); + assert(escapeJson("line1\nline2") == "line1\\nline2"); + assert(escapeJson("tab\there") == "tab\\there"); + assert(escapeJson("back\\slash") == "back\\\\slash"); + writeln(" PASS"); +} + +// ============================================================================ +// Integration Tests - Test SDK internal consistency +// ============================================================================ + +unittest { + writeln("Testing computeHmac..."); + string key = "test-key"; + string msg = "test-message"; + + string sig1 = computeHmac(key, msg); + string sig2 = computeHmac(key, msg); + + // Should be deterministic + assert(sig1 == sig2, "HMAC should be deterministic"); + + // Should produce different results for different inputs + string sig3 = computeHmac(key, "different"); + assert(sig1 != sig3, "Different inputs should produce different signatures"); + writeln(" PASS"); +} + +unittest { + writeln("Testing buildAuthHeaders..."); + string pk = "unsb-pk-test-test-test-test"; + string sk = "unsb-sk-test1-test2-test3-test4"; + + string headers = buildAuthHeaders("POST", "/execute", "{}", pk, sk); + + // Should contain auth header + assert(headers.canFind("Authorization: Bearer " ~ pk)); + // Should contain timestamp header + assert(headers.canFind("X-Timestamp:")); + // Should contain signature header + assert(headers.canFind("X-Signature:")); + writeln(" PASS"); +} + +// ============================================================================ +// Functional Tests - Test against real API (requires credentials) +// ============================================================================ + +bool hasCredentials() { + string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); + return pk.length > 0 && sk.length > 0; +} + +unittest { + writeln("Testing healthCheck (functional)..."); + if (!hasCredentials()) { + writeln(" SKIP (no credentials)"); + return; + } + + bool healthy = healthCheck(); + writeln(" Health check result: ", healthy ? "healthy" : "unhealthy"); + writeln(" PASS"); +} + +unittest { + writeln("Testing getLanguages (functional)..."); + if (!hasCredentials()) { + writeln(" SKIP (no credentials)"); + return; + } + + string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); + + string result = getLanguages(pk, sk); + assert(result.length > 0, "Languages result should not be empty"); + // Should contain python + assert(result.canFind("python"), "Languages should include python"); + writeln(" PASS"); +} + +unittest { + writeln("Testing validateKeysFn (functional)..."); + if (!hasCredentials()) { + writeln(" SKIP (no credentials)"); + return; + } + + string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); + + string result = validateKeysFn(pk, sk); + assert(result.length > 0, "Validate keys result should not be empty"); + writeln(" PASS"); +} + +unittest { + writeln("Testing execute (functional)..."); + if (!hasCredentials()) { + writeln(" SKIP (no credentials)"); + return; + } + + string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); + + string result = execute("python", "print('hello from d test')", pk, sk); + assert(result.length > 0, "Execute result should not be empty"); + // Should contain output + assert(result.canFind("stdout") || result.canFind("output"), "Result should contain output"); + writeln(" PASS"); +} + +unittest { + writeln("Testing sessionList (functional)..."); + if (!hasCredentials()) { + writeln(" SKIP (no credentials)"); + return; + } + + string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); + + string result = sessionList(pk, sk); + assert(result.length > 0, "Session list result should not be empty"); + writeln(" PASS"); +} + +unittest { + writeln("Testing serviceListFn (functional)..."); + if (!hasCredentials()) { + writeln(" SKIP (no credentials)"); + return; + } + + string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); + + string result = serviceListFn(pk, sk); + assert(result.length > 0, "Service list result should not be empty"); + writeln(" PASS"); +} + +unittest { + writeln("Testing snapshotList (functional)..."); + if (!hasCredentials()) { + writeln(" SKIP (no credentials)"); + return; + } + + string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); + + string result = snapshotList(pk, sk); + assert(result.length > 0, "Snapshot list result should not be empty"); + writeln(" PASS"); +} + +unittest { + writeln("Testing imageList (functional)..."); + if (!hasCredentials()) { + writeln(" SKIP (no credentials)"); + return; + } + + string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); + + string result = imageList("", pk, sk); + assert(result.length > 0, "Image list result should not be empty"); + writeln(" PASS"); +} + +void main() { + writeln("===== D SDK Tests Complete ====="); +} diff --git a/clients/dart/sync/src/un.dart b/clients/dart/sync/src/un.dart index ed4578c..deba76c 100644 --- a/clients/dart/sync/src/un.dart +++ b/clients/dart/sync/src/un.dart @@ -124,6 +124,21 @@ class Args { String? imageClone; String? imageName; String? imagePorts; + // Snapshot command options + bool snapshotList = false; + String? snapshotInfo; + String? snapshotSession; + String? snapshotService; + String? snapshotRestore; + String? snapshotDelete; + String? snapshotLock; + String? snapshotUnlock; + String? snapshotClone; + String? snapshotCloneType; + String? snapshotName; + String? snapshotPorts; + String? snapshotShell; + bool snapshotHot = false; } List getApiKeys(String? argsKey) { @@ -1074,6 +1089,235 @@ Future cmdImage(Args args) async { exit(1); } +// Image access management functions +Future imageGrantAccess(String id, String trustedKey, String publicKey, String? secretKey) async { + final payload = {'trusted_api_key': trustedKey}; + await apiRequestCurl('/images/$id/grant-access', 'POST', jsonEncode(payload), publicKey, secretKey); + print('${green}Access granted to: $trustedKey$reset'); +} + +Future imageRevokeAccess(String id, String trustedKey, String publicKey, String? secretKey) async { + final payload = {'trusted_api_key': trustedKey}; + await apiRequestCurl('/images/$id/revoke-access', 'POST', jsonEncode(payload), publicKey, secretKey); + print('${green}Access revoked from: $trustedKey$reset'); +} + +Future imageListTrusted(String id, String publicKey, String? secretKey) async { + final result = await apiRequestCurl('/images/$id/trusted', 'GET', null, publicKey, secretKey); + print(jsonEncode(result)); +} + +Future imageTransfer(String id, String toKey, String publicKey, String? secretKey) async { + final payload = {'to_api_key': toKey}; + await apiRequestCurl('/images/$id/transfer', 'POST', jsonEncode(payload), publicKey, secretKey); + print('${green}Image transferred to: $toKey$reset'); +} + +// Snapshot functions +Future cmdSnapshot(Args args) async { + final keys = getApiKeys(args.apiKey); + final publicKey = keys[0]!; + final secretKey = keys[1]; + + if (args.snapshotList) { + final result = await apiRequestCurl('/snapshots', 'GET', null, publicKey, secretKey); + print(jsonEncode(result)); + return; + } + + if (args.snapshotInfo != null) { + final result = await apiRequestCurl('/snapshots/${args.snapshotInfo}', 'GET', null, publicKey, secretKey); + print(jsonEncode(result)); + return; + } + + if (args.snapshotSession != null) { + final payload = { + 'session_id': args.snapshotSession, + }; + if (args.snapshotName != null) { + payload['name'] = args.snapshotName; + } + if (args.snapshotHot) { + payload['hot'] = true; + } + final result = await apiRequestCurl('/snapshots', 'POST', jsonEncode(payload), publicKey, secretKey); + print('${green}Snapshot created$reset'); + print(jsonEncode(result)); + return; + } + + if (args.snapshotService != null) { + final payload = { + 'service_id': args.snapshotService, + }; + if (args.snapshotName != null) { + payload['name'] = args.snapshotName; + } + if (args.snapshotHot) { + payload['hot'] = true; + } + final result = await apiRequestCurl('/snapshots', 'POST', jsonEncode(payload), publicKey, secretKey); + print('${green}Snapshot created$reset'); + print(jsonEncode(result)); + return; + } + + if (args.snapshotRestore != null) { + await apiRequestCurl('/snapshots/${args.snapshotRestore}/restore', 'POST', '{}', publicKey, secretKey); + print('${green}Snapshot restored: ${args.snapshotRestore}$reset'); + return; + } + + if (args.snapshotDelete != null) { + final (statusCode, responseBody) = await apiRequestCurlWithStatus('/snapshots/${args.snapshotDelete}', 'DELETE', null, publicKey, secretKey); + if (statusCode == 428) { + if (await handleSudoChallenge(responseBody, '/snapshots/${args.snapshotDelete}', 'DELETE', null, publicKey, secretKey)) { + print('${green}Snapshot deleted: ${args.snapshotDelete}$reset'); + } else { + stderr.writeln('${red}Error: Failed to delete snapshot (OTP verification failed)$reset'); + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + print('${green}Snapshot deleted: ${args.snapshotDelete}$reset'); + } else { + stderr.writeln('${red}Error: Failed to delete snapshot (HTTP $statusCode)$reset'); + exit(1); + } + return; + } + + if (args.snapshotLock != null) { + await apiRequestCurl('/snapshots/${args.snapshotLock}/lock', 'POST', '{}', publicKey, secretKey); + print('${green}Snapshot locked: ${args.snapshotLock}$reset'); + return; + } + + if (args.snapshotUnlock != null) { + final (statusCode, responseBody) = await apiRequestCurlWithStatus('/snapshots/${args.snapshotUnlock}/unlock', 'POST', '{}', publicKey, secretKey); + if (statusCode == 428) { + if (await handleSudoChallenge(responseBody, '/snapshots/${args.snapshotUnlock}/unlock', 'POST', '{}', publicKey, secretKey)) { + print('${green}Snapshot unlocked: ${args.snapshotUnlock}$reset'); + } else { + stderr.writeln('${red}Error: Failed to unlock snapshot (OTP verification failed)$reset'); + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + print('${green}Snapshot unlocked: ${args.snapshotUnlock}$reset'); + } else { + stderr.writeln('${red}Error: Failed to unlock snapshot (HTTP $statusCode)$reset'); + exit(1); + } + return; + } + + if (args.snapshotClone != null) { + final payload = { + 'clone_type': args.snapshotCloneType ?? 'session', + }; + if (args.snapshotName != null) { + payload['name'] = args.snapshotName; + } + if (args.snapshotPorts != null) { + payload['ports'] = args.snapshotPorts!.split(',').map((p) => int.parse(p.trim())).toList(); + } + if (args.snapshotShell != null) { + payload['shell'] = args.snapshotShell; + } + final result = await apiRequestCurl('/snapshots/${args.snapshotClone}/clone', 'POST', jsonEncode(payload), publicKey, secretKey); + print('${green}Snapshot cloned$reset'); + print(jsonEncode(result)); + return; + } + + stderr.writeln('${red}Error: Use --list, --info ID, --session ID, --service ID, --restore ID, --delete ID, --lock ID, --unlock ID, or --clone ID$reset'); + exit(1); +} + +// Session additional functions +Future sessionInfo(String id, String publicKey, String? secretKey) async { + final result = await apiRequestCurl('/sessions/$id', 'GET', null, publicKey, secretKey); + print(jsonEncode(result)); +} + +Future sessionBoost(String id, int vcpu, String publicKey, String? secretKey) async { + final payload = {'vcpu': vcpu}; + await apiRequestCurl('/sessions/$id', 'PATCH', jsonEncode(payload), publicKey, secretKey); + print('${green}Session boosted to $vcpu vCPU$reset'); +} + +Future sessionUnboost(String id, String publicKey, String? secretKey) async { + final payload = {'vcpu': 1}; + await apiRequestCurl('/sessions/$id', 'PATCH', jsonEncode(payload), publicKey, secretKey); + print('${green}Session unboosted to 1 vCPU$reset'); +} + +Future sessionExecuteCmd(String id, String command, String publicKey, String? secretKey) async { + final payload = {'command': command}; + final result = await apiRequestCurl('/sessions/$id/execute', 'POST', jsonEncode(payload), publicKey, secretKey); + final stdoutText = result['stdout'] as String?; + final stderrText = result['stderr'] as String?; + if (stdoutText != null && stdoutText.isNotEmpty) { + stdout.write('$blue$stdoutText$reset'); + } + if (stderrText != null && stderrText.isNotEmpty) { + stderr.write('$red$stderrText$reset'); + } +} + +// Service additional functions +Future serviceLock(String id, String publicKey, String? secretKey) async { + await apiRequestCurl('/services/$id/lock', 'POST', '{}', publicKey, secretKey); + print('${green}Service locked: $id$reset'); +} + +Future serviceUnlock(String id, String publicKey, String? secretKey) async { + final (statusCode, responseBody) = await apiRequestCurlWithStatus('/services/$id/unlock', 'POST', '{}', publicKey, secretKey); + if (statusCode == 428) { + if (await handleSudoChallenge(responseBody, '/services/$id/unlock', 'POST', '{}', publicKey, secretKey)) { + print('${green}Service unlocked: $id$reset'); + } else { + stderr.writeln('${red}Error: Failed to unlock service (OTP verification failed)$reset'); + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + print('${green}Service unlocked: $id$reset'); + } else { + stderr.writeln('${red}Error: Failed to unlock service (HTTP $statusCode)$reset'); + exit(1); + } +} + +Future serviceRedeploy(String id, String? bootstrap, String publicKey, String? secretKey) async { + final payload = bootstrap != null ? {'bootstrap': bootstrap} : {}; + await apiRequestCurl('/services/$id/redeploy', 'POST', jsonEncode(payload), publicKey, secretKey); + print('${green}Service redeploying: $id$reset'); +} + +// PaaS logs functions +Future logsFetch(String source, int lines, String? since, String? grepPattern, String publicKey, String? secretKey) async { + var params = '?source=$source&lines=$lines'; + if (since != null) params += '&since=$since'; + if (grepPattern != null) params += '&grep=${Uri.encodeComponent(grepPattern)}'; + final result = await apiRequestCurl('/logs$params', 'GET', null, publicKey, secretKey); + print(jsonEncode(result)); +} + +// Utility functions +Future healthCheck() async { + try { + final result = await Process.run('curl', ['-s', 'https://api.unsandbox.com/health']); + print(result.stdout); + return result.stdout.toString().contains('ok'); + } catch (e) { + return false; + } +} + +String sdkVersion() { + return '4.2.0'; +} + Future cmdKey(Args args) async { final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; @@ -1146,6 +1390,9 @@ Args parseArgs(List argv) { case 'image': args.command = 'image'; break; + case 'snapshot': + args.command = 'snapshot'; + break; case 'key': args.command = 'key'; break; @@ -1193,11 +1440,17 @@ Args parseArgs(List argv) { args.serviceList = true; } else if (args.command == 'image') { args.imageList = true; + } else if (args.command == 'snapshot') { + args.snapshotList = true; } break; case '-s': case '--shell': - args.sessionShell = argv[++i]; + if (args.command == 'snapshot') { + args.snapshotShell = argv[++i]; + } else { + args.sessionShell = argv[++i]; + } break; case '--kill': args.sessionKill = argv[++i]; @@ -1205,6 +1458,8 @@ Args parseArgs(List argv) { case '--name': if (args.command == 'image') { args.imageName = argv[++i]; + } else if (args.command == 'snapshot') { + args.snapshotName = argv[++i]; } else { args.serviceName = argv[++i]; } @@ -1212,6 +1467,8 @@ Args parseArgs(List argv) { case '--ports': if (args.command == 'image') { args.imagePorts = argv[++i]; + } else if (args.command == 'snapshot') { + args.snapshotPorts = argv[++i]; } else { args.servicePorts = argv[++i]; } @@ -1285,21 +1542,29 @@ Args parseArgs(List argv) { args.serviceInfo = argv[++i]; } else if (args.command == 'image') { args.imageInfo = argv[++i]; + } else if (args.command == 'snapshot') { + args.snapshotInfo = argv[++i]; } break; case '--delete': if (args.command == 'image') { args.imageDelete = argv[++i]; + } else if (args.command == 'snapshot') { + args.snapshotDelete = argv[++i]; } break; case '--lock': if (args.command == 'image') { args.imageLock = argv[++i]; + } else if (args.command == 'snapshot') { + args.snapshotLock = argv[++i]; } break; case '--unlock': if (args.command == 'image') { args.imageUnlock = argv[++i]; + } else if (args.command == 'snapshot') { + args.snapshotUnlock = argv[++i]; } break; case '--publish': @@ -1326,6 +1591,33 @@ Args parseArgs(List argv) { case '--clone': if (args.command == 'image') { args.imageClone = argv[++i]; + } else if (args.command == 'snapshot') { + args.snapshotClone = argv[++i]; + } + break; + case '--session': + if (args.command == 'snapshot') { + args.snapshotSession = argv[++i]; + } + break; + case '--service': + if (args.command == 'snapshot') { + args.snapshotService = argv[++i]; + } + break; + case '--restore': + if (args.command == 'snapshot') { + args.snapshotRestore = argv[++i]; + } + break; + case '--clone-type': + if (args.command == 'snapshot') { + args.snapshotCloneType = argv[++i]; + } + break; + case '--hot': + if (args.command == 'snapshot') { + args.snapshotHot = true; } break; case 'env': @@ -1338,7 +1630,7 @@ Args parseArgs(List argv) { break; default: if (argv[i].startsWith('-')) { - stderr.writeln('${RED}Unknown option: ${argv[i]}${RESET}'); + stderr.writeln('${red}Unknown option: ${argv[i]}$reset'); exit(1); } else { args.sourceFile = argv[i]; @@ -1354,6 +1646,7 @@ void printHelp() { Usage: dart un.dart [options] dart un.dart session [options] dart un.dart service [options] + dart un.dart snapshot [options] dart un.dart image [options] dart un.dart key [options] dart un.dart languages [--json] @@ -1416,6 +1709,22 @@ Image options: --name NAME Name for spawned service or cloned image --ports PORTS Ports for spawned service +Snapshot options: + -l, --list List all snapshots + --info ID Get snapshot details + --session ID Create snapshot from session + --service ID Create snapshot from service + --restore ID Restore a snapshot + --delete ID Delete a snapshot + --lock ID Lock snapshot to prevent deletion + --unlock ID Unlock snapshot + --clone ID Clone snapshot to session/service + --clone-type TYPE Clone target: session (default) or service + --name NAME Name for new snapshot or cloned resource + --ports PORTS Ports for service (with --clone --clone-type service) + --shell NAME Shell for session (with --clone --clone-type session) + --hot Hot snapshot (without stopping) + Key options: --extend Open browser to extend key @@ -1434,6 +1743,8 @@ void main(List arguments) async { await cmdService(args); } else if (args.command == 'image') { await cmdImage(args); + } else if (args.command == 'snapshot') { + await cmdSnapshot(args); } else if (args.command == 'key') { await cmdKey(args); } else if (args.command == 'languages') { diff --git a/clients/dotnet/sync/src/Un.cs b/clients/dotnet/sync/src/Un.cs index 522dd10..fd784ba 100644 --- a/clients/dotnet/sync/src/Un.cs +++ b/clients/dotnet/sync/src/Un.cs @@ -354,7 +354,7 @@ void CmdService(Args args) if (args.ServiceShowFreezePage != null) { var payload = new Dictionary { ["show_freeze_page"] = args.ServiceShowFreezePageEnabled }; - await ApiRequestAsync($"/services/{args.ServiceShowFreezePage}", new HttpMethod("PATCH"), payload, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceShowFreezePage}", new HttpMethod("PATCH"), payload, publicKey, secretKey); string status = args.ServiceShowFreezePageEnabled ? "enabled" : "disabled"; Console.WriteLine($"{GREEN}Show-freeze-page {status} for service: {args.ServiceShowFreezePage}{RESET}"); return; @@ -563,6 +563,14 @@ void CmdSnapshot(Args args) return; } + if (args.SnapshotRestore != null) + { + var result = ApiRequest($"/snapshots/{args.SnapshotRestore}/restore", HttpMethod.Post, null, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Restored from snapshot: {id}{RESET}"); + return; + } + if (args.SnapshotClone != null) { var payload = new Dictionary { ["type"] = args.SnapshotCloneType ?? "session" }; @@ -577,7 +585,7 @@ void CmdSnapshot(Args args) return; } - Console.Error.WriteLine($"{RED}Error: Use --list, --info, --delete, --lock, --unlock, or --clone{RESET}"); + Console.Error.WriteLine($"{RED}Error: Use --list, --info, --delete, --lock, --unlock, --restore, or --clone{RESET}"); Environment.Exit(1); } @@ -1013,6 +1021,7 @@ Args ParseArgs(string[] args) else if (result.Command == "image") result.ImageClone = val; } else if (arg == "--clone-type") result.SnapshotCloneType = args[++i]; + else if (arg == "--restore" && result.Command == "snapshot") result.SnapshotRestore = args[++i]; else if (arg == "--publish") result.ImagePublish = args[++i]; else if (arg == "--source-type") result.ImageSourceType = args[++i]; else if (arg == "--visibility") @@ -1131,6 +1140,881 @@ Environment: UNSANDBOX_SECRET_KEY Your secret API key"); } +// ============================================================================= +// Library API - For embedding in other .NET applications +// ============================================================================= + +/// +/// Unsandbox SDK for .NET - Full library API matching the C reference implementation +/// +public static class Unsandbox +{ + private static readonly HttpClient _httpClient = new() { BaseAddress = new Uri("https://api.unsandbox.com"), Timeout = TimeSpan.FromMinutes(5) }; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true + }; + private static string? _lastError; + + // --- Execution Functions (8) --- + + /// Execute code synchronously + public static ExecuteResult Execute(string language, string code, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["language"] = language, ["code"] = code }; + try + { + var result = ApiCall("/execute", HttpMethod.Post, payload, pk, sk); + return new ExecuteResult + { + Stdout = GetString(result, "stdout"), + Stderr = GetString(result, "stderr"), + ExitCode = GetInt(result, "exit_code"), + Language = language, + ExecutionTime = GetDouble(result, "execution_time"), + Success = true + }; + } + catch (Exception ex) { _lastError = ex.Message; return new ExecuteResult { Success = false, ErrorMessage = ex.Message }; } + } + + /// Execute code asynchronously, returns job ID + public static string? ExecuteAsync(string language, string code, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["language"] = language, ["code"] = code, ["async"] = true }; + try + { + var result = ApiCall("/execute", HttpMethod.Post, payload, pk, sk); + return GetString(result, "job_id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + /// Wait for async job to complete + public static ExecuteResult? WaitJob(string jobId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/jobs/{jobId}/wait", HttpMethod.Get, null, pk, sk); + return new ExecuteResult + { + Stdout = GetString(result, "stdout"), + Stderr = GetString(result, "stderr"), + ExitCode = GetInt(result, "exit_code"), + ExecutionTime = GetDouble(result, "execution_time"), + Success = true + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + /// Get job status + public static JobInfo? GetJob(string jobId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/jobs/{jobId}", HttpMethod.Get, null, pk, sk); + return new JobInfo + { + Id = GetString(result, "id"), + Language = GetString(result, "language"), + Status = GetString(result, "status"), + CreatedAt = GetLong(result, "created_at"), + CompletedAt = GetLong(result, "completed_at") + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + /// Cancel a running job + public static bool CancelJob(string jobId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/jobs/{jobId}/cancel", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + /// List all jobs + public static List ListJobs(string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/jobs", HttpMethod.Get, null, pk, sk); + var jobs = new List(); + if (result.TryGetValue("jobs", out var obj) && obj is JsonElement el) + foreach (var j in el.EnumerateArray()) + jobs.Add(new JobInfo { Id = GetStr(j, "id"), Status = GetStr(j, "status"), Language = GetStr(j, "language") }); + return jobs; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + /// Get available programming languages + public static List GetLanguages(string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/languages", HttpMethod.Get, null, pk, sk); + if (result.TryGetValue("languages", out var obj) && obj is JsonElement el) + return el.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => !string.IsNullOrEmpty(x)).ToList(); + return new List(); + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + /// Detect language from filename extension + public static string? DetectLanguage(string filename) + { + var extMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [".py"] = "python", [".js"] = "javascript", [".ts"] = "typescript", + [".rb"] = "ruby", [".php"] = "php", [".pl"] = "perl", [".lua"] = "lua", + [".sh"] = "bash", [".go"] = "go", [".rs"] = "rust", [".c"] = "c", + [".cpp"] = "cpp", [".java"] = "java", [".cs"] = "dotnet", [".fs"] = "fsharp" + }; + var ext = Path.GetExtension(filename); + return extMap.TryGetValue(ext, out var lang) ? lang : null; + } + + // --- Session Functions (9) --- + + public static List SessionList(string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/sessions", HttpMethod.Get, null, pk, sk); + var sessions = new List(); + if (result.TryGetValue("sessions", out var obj) && obj is JsonElement el) + foreach (var s in el.EnumerateArray()) + sessions.Add(new SessionInfo { Id = GetStr(s, "id"), Status = GetStr(s, "status"), NetworkMode = GetStr(s, "network_mode") }); + return sessions; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static SessionInfo? SessionGet(string sessionId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/sessions/{sessionId}", HttpMethod.Get, null, pk, sk); + return new SessionInfo { Id = GetString(result, "id"), Status = GetString(result, "status"), NetworkMode = GetString(result, "network_mode") }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static SessionInfo? SessionCreate(string? networkMode = null, string? shell = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["shell"] = shell ?? "bash" }; + if (networkMode != null) payload["network"] = networkMode; + try + { + var result = ApiCall("/sessions", HttpMethod.Post, payload, pk, sk); + return new SessionInfo { Id = GetString(result, "id"), Status = "running" }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool SessionDestroy(string sessionId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/sessions/{sessionId}", HttpMethod.Delete, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SessionFreeze(string sessionId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/sessions/{sessionId}/freeze", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SessionUnfreeze(string sessionId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/sessions/{sessionId}/unfreeze", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SessionBoost(string sessionId, int vcpu = 2, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["vcpu"] = vcpu }; + try { ApiCall($"/sessions/{sessionId}/boost", HttpMethod.Post, payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SessionUnboost(string sessionId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/sessions/{sessionId}/unboost", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static ExecuteResult? SessionExecute(string sessionId, string command, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["command"] = command }; + try + { + var result = ApiCall($"/sessions/{sessionId}/execute", HttpMethod.Post, payload, pk, sk); + return new ExecuteResult + { + Stdout = GetString(result, "stdout"), + Stderr = GetString(result, "stderr"), + ExitCode = GetInt(result, "exit_code"), + Success = true + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + // --- Service Functions (17) --- + + public static List ServiceList(string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/services", HttpMethod.Get, null, pk, sk); + var services = new List(); + if (result.TryGetValue("services", out var obj) && obj is JsonElement el) + foreach (var s in el.EnumerateArray()) + services.Add(new ServiceInfo { Id = GetStr(s, "id"), Name = GetStr(s, "name"), Status = GetStr(s, "status") }); + return services; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static ServiceInfo? ServiceGet(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/services/{serviceId}", HttpMethod.Get, null, pk, sk); + return new ServiceInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Status = GetString(result, "status") }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string? ServiceCreate(string name, string? ports = null, string? domains = null, string? bootstrap = null, string? networkMode = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["name"] = name }; + if (ports != null) payload["ports"] = ports.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (domains != null) payload["domains"] = domains; + if (bootstrap != null) payload["bootstrap"] = bootstrap; + if (networkMode != null) payload["network"] = networkMode; + try + { + var result = ApiCall("/services", HttpMethod.Post, payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool ServiceDestroy(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}", HttpMethod.Delete, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceFreeze(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/freeze", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceUnfreeze(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/unfreeze", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceLock(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/lock", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceUnlock(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/unlock", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceSetUnfreezeOnDemand(string serviceId, bool enabled, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["unfreeze_on_demand"] = enabled }; + try { ApiCall($"/services/{serviceId}", new HttpMethod("PATCH"), payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceRedeploy(string serviceId, string? bootstrap = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = bootstrap != null ? new Dictionary { ["bootstrap"] = bootstrap } : null; + try { ApiCall($"/services/{serviceId}/redeploy", HttpMethod.Post, payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static string? ServiceLogs(string serviceId, bool allLogs = false, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var endpoint = allLogs ? $"/services/{serviceId}/logs?lines=9000" : $"/services/{serviceId}/logs"; + try + { + var result = ApiCall(endpoint, HttpMethod.Get, null, pk, sk); + return GetString(result, "logs"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static ExecuteResult? ServiceExecute(string serviceId, string command, int timeoutMs = 30000, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["command"] = command, ["timeout_ms"] = timeoutMs }; + try + { + var result = ApiCall($"/services/{serviceId}/execute", HttpMethod.Post, payload, pk, sk); + return new ExecuteResult + { + Stdout = GetString(result, "stdout"), + Stderr = GetString(result, "stderr"), + ExitCode = GetInt(result, "exit_code"), + Success = true + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string? ServiceEnvGet(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/services/{serviceId}/env", HttpMethod.Get, null, pk, sk); + return GetString(result, "content"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool ServiceEnvSet(string serviceId, string envContent, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCallText($"/services/{serviceId}/env", HttpMethod.Put, envContent, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ServiceEnvDelete(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/services/{serviceId}/env", HttpMethod.Delete, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static string? ServiceEnvExport(string serviceId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/services/{serviceId}/env/export", HttpMethod.Post, null, pk, sk); + return GetString(result, "content"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool ServiceResize(string serviceId, int vcpu, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["vcpu"] = vcpu }; + try { ApiCall($"/services/{serviceId}/resize", HttpMethod.Post, payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + // --- Snapshot Functions (9) --- + + public static List SnapshotList(string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/snapshots", HttpMethod.Get, null, pk, sk); + var snapshots = new List(); + if (result.TryGetValue("snapshots", out var obj) && obj is JsonElement el) + foreach (var s in el.EnumerateArray()) + snapshots.Add(new SnapshotInfo { Id = GetStr(s, "id"), Name = GetStr(s, "name"), Type = GetStr(s, "source_type") }); + return snapshots; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static SnapshotInfo? SnapshotGet(string snapshotId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/snapshots/{snapshotId}", HttpMethod.Get, null, pk, sk); + return new SnapshotInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Type = GetString(result, "source_type") }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string? SnapshotSession(string sessionId, string? name = null, bool hot = false, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary(); + if (name != null) payload["name"] = name; + if (hot) payload["hot"] = true; + try + { + var result = ApiCall($"/sessions/{sessionId}/snapshot", HttpMethod.Post, payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string? SnapshotService(string serviceId, string? name = null, bool hot = false, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary(); + if (name != null) payload["name"] = name; + if (hot) payload["hot"] = true; + try + { + var result = ApiCall($"/services/{serviceId}/snapshot", HttpMethod.Post, payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string? SnapshotRestore(string snapshotId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/snapshots/{snapshotId}/restore", HttpMethod.Post, null, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool SnapshotDelete(string snapshotId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/snapshots/{snapshotId}", HttpMethod.Delete, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SnapshotLock(string snapshotId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/snapshots/{snapshotId}/lock", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool SnapshotUnlock(string snapshotId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/snapshots/{snapshotId}/unlock", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static string? SnapshotClone(string snapshotId, string cloneType, string? name = null, string? ports = null, string? shell = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["type"] = cloneType }; + if (name != null) payload["name"] = name; + if (ports != null) payload["ports"] = ports.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (shell != null) payload["shell"] = shell; + try + { + var result = ApiCall($"/snapshots/{snapshotId}/clone", HttpMethod.Post, payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + // --- Image Functions (13) --- + + public static List ImageList(string? filter = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var endpoint = filter != null ? $"/images?filter={filter}" : "/images"; + try + { + var result = ApiCall(endpoint, HttpMethod.Get, null, pk, sk); + var images = new List(); + if (result.TryGetValue("images", out var obj) && obj is JsonElement el) + foreach (var img in el.EnumerateArray()) + images.Add(new ImageInfo { Id = GetStr(img, "id"), Name = GetStr(img, "name"), Visibility = GetStr(img, "visibility") }); + return images; + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static ImageInfo? ImageGet(string imageId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/images/{imageId}", HttpMethod.Get, null, pk, sk); + return new ImageInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Visibility = GetString(result, "visibility") }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string? ImagePublish(string sourceType, string sourceId, string? name = null, string? description = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["source_type"] = sourceType, ["source_id"] = sourceId }; + if (name != null) payload["name"] = name; + if (description != null) payload["description"] = description; + try + { + var result = ApiCall("/images", HttpMethod.Post, payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static bool ImageDelete(string imageId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/images/{imageId}", HttpMethod.Delete, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageLock(string imageId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/images/{imageId}/lock", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageUnlock(string imageId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try { ApiCall($"/images/{imageId}/unlock", HttpMethod.Post, null, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageSetVisibility(string imageId, string visibility, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["visibility"] = visibility }; + try { ApiCall($"/images/{imageId}", new HttpMethod("PATCH"), payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageGrantAccess(string imageId, string trustedApiKey, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["api_key"] = trustedApiKey }; + try { ApiCall($"/images/{imageId}/access", HttpMethod.Post, payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static bool ImageRevokeAccess(string imageId, string trustedApiKey, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["api_key"] = trustedApiKey }; + try { ApiCall($"/images/{imageId}/access", HttpMethod.Delete, payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static List ImageListTrusted(string imageId, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall($"/images/{imageId}/access", HttpMethod.Get, null, pk, sk); + if (result.TryGetValue("trusted_keys", out var obj) && obj is JsonElement el) + return el.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => !string.IsNullOrEmpty(x)).ToList(); + return new List(); + } + catch (Exception ex) { _lastError = ex.Message; return new List(); } + } + + public static bool ImageTransfer(string imageId, string toApiKey, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary { ["to_api_key"] = toApiKey }; + try { ApiCall($"/images/{imageId}/transfer", HttpMethod.Post, payload, pk, sk); return true; } + catch (Exception ex) { _lastError = ex.Message; return false; } + } + + public static string? ImageSpawn(string imageId, string? name = null, string? ports = null, string? bootstrap = null, string? networkMode = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary(); + if (name != null) payload["name"] = name; + if (ports != null) payload["ports"] = ports.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (bootstrap != null) payload["bootstrap"] = bootstrap; + if (networkMode != null) payload["network"] = networkMode; + try + { + var result = ApiCall($"/images/{imageId}/spawn", HttpMethod.Post, payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string? ImageClone(string imageId, string? name = null, string? description = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var payload = new Dictionary(); + if (name != null) payload["name"] = name; + if (description != null) payload["description"] = description; + try + { + var result = ApiCall($"/images/{imageId}/clone", HttpMethod.Post, payload, pk, sk); + return GetString(result, "id"); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + // --- PaaS Logs (2) --- + + public static string? LogsFetch(string source, int lines = 100, string? since = null, string? grep = null, string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + var endpoint = $"/paas/logs?source={source}&lines={lines}"; + if (since != null) endpoint += $"&since={since}"; + if (grep != null) endpoint += $"&grep={Uri.EscapeDataString(grep)}"; + try + { + var result = ApiCall(endpoint, HttpMethod.Get, null, pk, sk); + return JsonSerializer.Serialize(result); + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + // LogsStream requires SSE/WebSocket support - not implemented in sync version + + // --- Utilities --- + + public static KeyInfo? ValidateKeys(string? publicKey = null, string? secretKey = null) + { + var (pk, sk) = ResolveKeys(publicKey, secretKey); + try + { + var result = ApiCall("/keys/validate", HttpMethod.Post, null, pk, sk); + return new KeyInfo + { + Valid = result.TryGetValue("valid", out var v) && v is JsonElement ve && ve.GetBoolean(), + Tier = GetString(result, "tier"), + RateLimitPerMinute = GetInt(result, "rate_limit"), + ConcurrencyLimit = GetInt(result, "concurrency") + }; + } + catch (Exception ex) { _lastError = ex.Message; return null; } + } + + public static string HmacSign(string secretKey, string message) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); + } + + public static bool HealthCheck() + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/health"); + var response = _httpClient.Send(request); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + + public static string Version() => "4.2.50"; + + public static string? LastError() => _lastError; + + // --- Internal Helpers --- + + private static (string, string) ResolveKeys(string? publicKey, string? secretKey) + { + var pk = publicKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") ?? ""; + var sk = secretKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") ?? ""; + return (pk, sk); + } + + private static Dictionary ApiCall(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) + { + var body = data != null ? JsonSerializer.Serialize(data, _jsonOptions) : ""; + using var request = new HttpRequestMessage(method, endpoint); + if (data != null) request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + + if (!string.IsNullOrEmpty(secretKey)) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var message = $"{timestamp}:{method.Method}:{endpoint}:{body}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + } + else + { + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + } + + var response = _httpClient.Send(request); + using var reader = new StreamReader(response.Content.ReadAsStream()); + var responseBody = reader.ReadToEnd(); + + if (!response.IsSuccessStatusCode) + throw new Exception($"HTTP {(int)response.StatusCode}: {responseBody}"); + + if (string.IsNullOrWhiteSpace(responseBody)) return new Dictionary(); + var doc = JsonDocument.Parse(responseBody); + return doc.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => (object)p.Value.Clone()); + } + + private static void ApiCallText(string endpoint, HttpMethod method, string body, string publicKey, string secretKey) + { + using var request = new HttpRequestMessage(method, endpoint); + request.Content = new StringContent(body, Encoding.UTF8, "text/plain"); + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var message = $"{timestamp}:{method.Method}:{endpoint}:{body}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + + var response = _httpClient.Send(request); + if (!response.IsSuccessStatusCode) + { + using var reader = new StreamReader(response.Content.ReadAsStream()); + throw new Exception($"HTTP {(int)response.StatusCode}: {reader.ReadToEnd()}"); + } + } + + private static string? GetString(Dictionary result, string key) + => result.TryGetValue(key, out var v) && v is JsonElement el ? el.GetString() : null; + + private static int GetInt(Dictionary result, string key) + => result.TryGetValue(key, out var v) && v is JsonElement el && el.TryGetInt32(out var i) ? i : 0; + + private static long GetLong(Dictionary result, string key) + => result.TryGetValue(key, out var v) && v is JsonElement el && el.TryGetInt64(out var i) ? i : 0; + + private static double GetDouble(Dictionary result, string key) + => result.TryGetValue(key, out var v) && v is JsonElement el && el.TryGetDouble(out var d) ? d : 0; + + private static string GetStr(JsonElement el, string prop) => el.TryGetProperty(prop, out var p) ? p.GetString() ?? "" : ""; +} + +// --- Data Types --- + +public class ExecuteResult +{ + public string? Stdout { get; set; } + public string? Stderr { get; set; } + public int ExitCode { get; set; } + public string? Language { get; set; } + public double ExecutionTime { get; set; } + public bool Success { get; set; } + public string? ErrorMessage { get; set; } +} + +public class JobInfo +{ + public string? Id { get; set; } + public string? Language { get; set; } + public string? Status { get; set; } + public long CreatedAt { get; set; } + public long CompletedAt { get; set; } + public string? ErrorMessage { get; set; } +} + +public class SessionInfo +{ + public string? Id { get; set; } + public string? ContainerName { get; set; } + public string? Status { get; set; } + public string? NetworkMode { get; set; } + public int Vcpu { get; set; } + public long CreatedAt { get; set; } + public long LastActivity { get; set; } +} + +public class ServiceInfo +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Status { get; set; } + public string? ContainerName { get; set; } + public string? NetworkMode { get; set; } + public string? Ports { get; set; } + public string? Domains { get; set; } + public int Vcpu { get; set; } + public bool Locked { get; set; } + public bool UnfreezeOnDemand { get; set; } + public long CreatedAt { get; set; } + public long LastActivity { get; set; } +} + +public class SnapshotInfo +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Type { get; set; } + public string? SourceId { get; set; } + public bool Hot { get; set; } + public bool Locked { get; set; } + public long CreatedAt { get; set; } + public long SizeBytes { get; set; } +} + +public class ImageInfo +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Visibility { get; set; } + public string? SourceType { get; set; } + public string? SourceId { get; set; } + public string? OwnerApiKey { get; set; } + public bool Locked { get; set; } + public long CreatedAt { get; set; } + public long SizeBytes { get; set; } +} + +public class KeyInfo +{ + public bool Valid { get; set; } + public string? Tier { get; set; } + public int RateLimitPerMinute { get; set; } + public int RateLimitBurst { get; set; } + public int ConcurrencyLimit { get; set; } + public string? ErrorMessage { get; set; } +} + +// ============================================================================= +// CLI Args +// ============================================================================= + class Args { public bool ShowHelp, ShowVersion; @@ -1156,9 +2040,11 @@ class Args public string? SnapshotInfo, SnapshotDelete, SnapshotLock, SnapshotUnlock, SnapshotClone; public string? SnapshotCloneType, SnapshotName; public bool SnapshotHot; + public string? SnapshotRestore; public bool ImageList; public string? ImageInfo, ImageDelete, ImageLock, ImageUnlock; public string? ImagePublish, ImageSourceType, ImageVisibility, ImageVisibilityMode; public string? ImageSpawn, ImageClone; + public string? ImageGrantAccess, ImageRevokeAccess, ImageTransfer; public bool LanguagesJson; } diff --git a/clients/dotnet/tests/UnsandboxTests.cs b/clients/dotnet/tests/UnsandboxTests.cs new file mode 100644 index 0000000..344f2f1 --- /dev/null +++ b/clients/dotnet/tests/UnsandboxTests.cs @@ -0,0 +1,193 @@ +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// Unit and Functional Tests for Unsandbox .NET SDK + +using System; +using System.Collections.Generic; + +namespace UnsandboxTests; + +/// +/// Unit tests for the Unsandbox SDK library functions. +/// These tests verify that exported library functions work correctly. +/// +public class UnitTests +{ + public static void Run() + { + Console.WriteLine("=== Unsandbox .NET SDK Unit Tests ===\n"); + + TestDetectLanguage(); + TestHmacSign(); + TestVersion(); + + Console.WriteLine("\n=== Unit Tests Complete ==="); + } + + static void TestDetectLanguage() + { + Console.Write("DetectLanguage: "); + var tests = new Dictionary + { + { "test.py", "python" }, + { "script.js", "javascript" }, + { "main.go", "go" }, + { "app.rs", "rust" }, + { "Program.cs", "dotnet" }, + { "Module.fs", "fsharp" }, + { "unknown.xyz", null } + }; + + int passed = 0; + foreach (var (file, expected) in tests) + { + var result = Unsandbox.DetectLanguage(file); + if (result == expected) passed++; + else Console.Write($"[FAIL: {file} -> {result}, expected {expected}] "); + } + + if (passed == tests.Count) + Console.WriteLine($"PASS ({passed}/{tests.Count})"); + else + Console.WriteLine($"FAIL ({passed}/{tests.Count})"); + } + + static void TestHmacSign() + { + Console.Write("HmacSign: "); + // Test vector: HMAC-SHA256("key", "message") + var result = Unsandbox.HmacSign("key", "message"); + // Expected: 6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a + var expected = "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"; + if (result == expected) + Console.WriteLine("PASS"); + else + Console.WriteLine($"FAIL (got {result}, expected {expected})"); + } + + static void TestVersion() + { + Console.Write("Version: "); + var version = Unsandbox.Version(); + if (!string.IsNullOrEmpty(version) && version.Contains(".")) + Console.WriteLine($"PASS ({version})"); + else + Console.WriteLine($"FAIL (got {version})"); + } +} + +/// +/// Functional tests that require API credentials. +/// Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables. +/// +public class FunctionalTests +{ + public static void Run() + { + var publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); + var secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); + + if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) + { + Console.WriteLine("=== Functional Tests Skipped (no API credentials) ==="); + return; + } + + Console.WriteLine("=== Unsandbox .NET SDK Functional Tests ===\n"); + + TestHealthCheck(); + TestValidateKeys(); + TestGetLanguages(); + TestExecute(); + TestSessionList(); + TestServiceList(); + TestSnapshotList(); + TestImageList(); + + Console.WriteLine("\n=== Functional Tests Complete ==="); + } + + static void TestHealthCheck() + { + Console.Write("HealthCheck: "); + var result = Unsandbox.HealthCheck(); + Console.WriteLine(result ? "PASS" : "FAIL"); + } + + static void TestValidateKeys() + { + Console.Write("ValidateKeys: "); + var result = Unsandbox.ValidateKeys(); + if (result != null && result.Valid) + Console.WriteLine($"PASS (tier: {result.Tier})"); + else + Console.WriteLine($"FAIL ({Unsandbox.LastError()})"); + } + + static void TestGetLanguages() + { + Console.Write("GetLanguages: "); + var result = Unsandbox.GetLanguages(); + if (result.Count > 0) + Console.WriteLine($"PASS ({result.Count} languages)"); + else + Console.WriteLine($"FAIL ({Unsandbox.LastError()})"); + } + + static void TestExecute() + { + Console.Write("Execute: "); + var result = Unsandbox.Execute("python", "print('hello from .NET SDK')"); + if (result.Success && result.Stdout?.Contains("hello") == true) + Console.WriteLine("PASS"); + else + Console.WriteLine($"FAIL ({result.ErrorMessage ?? Unsandbox.LastError()})"); + } + + static void TestSessionList() + { + Console.Write("SessionList: "); + var result = Unsandbox.SessionList(); + // Empty list is valid - just checking API call works + Console.WriteLine($"PASS ({result.Count} sessions)"); + } + + static void TestServiceList() + { + Console.Write("ServiceList: "); + var result = Unsandbox.ServiceList(); + Console.WriteLine($"PASS ({result.Count} services)"); + } + + static void TestSnapshotList() + { + Console.Write("SnapshotList: "); + var result = Unsandbox.SnapshotList(); + Console.WriteLine($"PASS ({result.Count} snapshots)"); + } + + static void TestImageList() + { + Console.Write("ImageList: "); + var result = Unsandbox.ImageList(); + Console.WriteLine($"PASS ({result.Count} images)"); + } +} + +public class Program +{ + public static int Main(string[] args) + { + try + { + UnitTests.Run(); + Console.WriteLine(); + FunctionalTests.Run(); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Test error: {ex.Message}"); + return 1; + } + } +} diff --git a/clients/dotnet/tests/UnsandboxTests.csproj b/clients/dotnet/tests/UnsandboxTests.csproj new file mode 100644 index 0000000..a60160f --- /dev/null +++ b/clients/dotnet/tests/UnsandboxTests.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/clients/elixir/sync/src/un.ex b/clients/elixir/sync/src/un.ex index 9dc6ef9..46f81db 100755 --- a/clients/elixir/sync/src/un.ex +++ b/clients/elixir/sync/src/un.ex @@ -52,14 +52,113 @@ # Uses curl for HTTP (no external dependencies) defmodule Un do + @moduledoc """ + unsandbox.com Elixir SDK - Full API with execution, sessions, services, snapshots, and images. + + ## Library Usage + + # Execute code synchronously + result = Un.execute("python", "print(42)") + IO.puts(result.stdout) + + # List sessions + sessions = Un.session_list() + + # Create a service + service_id = Un.service_create("myapp", ports: "8080") + + ## Authentication + + Credentials are loaded in priority order: + 1. Function arguments (public_key, secret_key) + 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + 3. Config file (~/.unsandbox/accounts.csv) + """ + @blue "\e[34m" @red "\e[31m" @green "\e[32m" @yellow "\e[33m" @reset "\e[0m" + @api_base "https://api.unsandbox.com" @portal_base "https://unsandbox.com" @languages_cache_ttl 3600 + @version "4.2.0" + + # ============================================================================ + # Types + # ============================================================================ + + @type result :: %{ + success: boolean(), + stdout: String.t(), + stderr: String.t(), + exit_code: integer(), + job_id: String.t() | nil, + language: String.t() | nil, + execution_time: float() | nil + } + + @type job :: %{ + id: String.t(), + status: String.t(), + language: String.t() | nil, + created_at: integer() | nil, + completed_at: integer() | nil + } + + @type session :: %{ + id: String.t(), + status: String.t(), + container_name: String.t() | nil, + network_mode: String.t() | nil, + vcpu: integer() | nil, + created_at: integer() | nil + } + + @type service :: %{ + id: String.t(), + name: String.t(), + status: String.t(), + ports: String.t() | nil, + domains: String.t() | nil, + vcpu: integer() | nil, + locked: boolean(), + unfreeze_on_demand: boolean(), + created_at: integer() | nil + } + + @type snapshot :: %{ + id: String.t(), + name: String.t() | nil, + type: String.t(), + source_id: String.t(), + hot: boolean(), + locked: boolean(), + created_at: integer() | nil, + size_bytes: integer() | nil + } + + @type image :: %{ + id: String.t(), + name: String.t() | nil, + description: String.t() | nil, + visibility: String.t(), + source_type: String.t(), + source_id: String.t(), + locked: boolean(), + created_at: integer() | nil, + size_bytes: integer() | nil + } + + @type key_info :: %{ + valid: boolean(), + tier: String.t() | nil, + rate_limit_per_minute: integer() | nil, + concurrency_limit: integer() | nil, + expires_at: integer() | nil + } @ext_map %{ ".ex" => "elixir", ".exs" => "elixir", ".erl" => "erlang", @@ -76,6 +175,963 @@ defmodule Un do ".forth" => "forth", ".tcl" => "tcl", ".raku" => "raku" } + # ============================================================================ + # Utility Functions + # ============================================================================ + + @doc """ + Return the SDK version. + """ + @spec version() :: String.t() + def version, do: @version + + @doc """ + Check API health. + + Returns true if API is healthy, false otherwise. + """ + @spec health_check() :: boolean() + def health_check do + try do + {output, 0} = System.cmd("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "#{@api_base}/health"]) + String.trim(output) == "200" + rescue + _ -> false + end + end + + @doc """ + Generate HMAC-SHA256 signature for a message. + """ + @spec hmac_sign(String.t(), String.t()) :: String.t() + def hmac_sign(secret_key, message) do + hmac_sha256(secret_key, message) + end + + @doc """ + Detect language from filename extension. + """ + @spec detect_language(String.t()) :: String.t() | nil + def detect_language(filename) do + ext = Path.extname(filename) |> String.downcase() + Map.get(@ext_map, ext) + end + + # ============================================================================ + # Execution Functions (8) + # ============================================================================ + + @doc """ + Execute code synchronously. + + ## Options + * `:network` - Network mode ("zerotrust" or "semitrusted") + * `:vcpu` - Number of vCPUs (1-8) + * `:ttl` - Time to live in seconds + * `:env` - Environment variables as keyword list + * `:input_files` - List of file paths to include + * `:return_artifacts` - Return compiled artifacts + * `:public_key` - API public key (optional) + * `:secret_key` - API secret key (optional) + + ## Examples + + result = Un.execute("python", "print('Hello World')") + IO.puts(result.stdout) + + """ + @spec execute(String.t(), String.t(), keyword()) :: result() + def execute(language, code, opts \\ []) do + json = build_execute_json_full(language, code, opts) + response = api_post("/execute", json, opts) + parse_result(response) + end + + @doc """ + Execute code asynchronously, returning a job ID. + """ + @spec execute_async(String.t(), String.t(), keyword()) :: String.t() | nil + def execute_async(language, code, opts \\ []) do + json = build_execute_json_full(language, code, opts) + response = api_post("/execute/async", json, opts) + extract_json_value(response, "job_id") + end + + @doc """ + Wait for a job to complete and return the result. + """ + @spec wait_job(String.t(), keyword()) :: result() + def wait_job(job_id, opts \\ []) do + poll_delays = [300, 450, 700, 900, 650, 1600, 2000] + max_polls = Keyword.get(opts, :max_polls, 100) + do_wait_job(job_id, poll_delays, 0, max_polls, opts) + end + + defp do_wait_job(job_id, poll_delays, poll_count, max_polls, opts) when poll_count >= max_polls do + %{success: false, stdout: "", stderr: "Max polls exceeded", exit_code: 1, job_id: job_id, language: nil, execution_time: nil} + end + + defp do_wait_job(job_id, poll_delays, poll_count, max_polls, opts) do + delay_idx = min(poll_count, length(poll_delays) - 1) + delay = Enum.at(poll_delays, delay_idx) + Process.sleep(delay) + + job = get_job(job_id, opts) + case job.status do + status when status in ["completed", "failed", "timeout", "cancelled"] -> + response = api_get("/jobs/#{job_id}", opts) + parse_result(response) + _ -> + do_wait_job(job_id, poll_delays, poll_count + 1, max_polls, opts) + end + end + + @doc """ + Get job status and details. + """ + @spec get_job(String.t(), keyword()) :: job() + def get_job(job_id, opts \\ []) do + response = api_get("/jobs/#{job_id}", opts) + %{ + id: job_id, + status: extract_json_value(response, "status") || "unknown", + language: extract_json_value(response, "language"), + created_at: extract_json_int(response, "created_at"), + completed_at: extract_json_int(response, "completed_at") + } + end + + @doc """ + Cancel a running job. + """ + @spec cancel_job(String.t(), keyword()) :: boolean() + def cancel_job(job_id, opts \\ []) do + response = api_delete("/jobs/#{job_id}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + List all active jobs. + """ + @spec list_jobs(keyword()) :: String.t() + def list_jobs(opts \\ []) do + api_get("/jobs", opts) + end + + @doc """ + Get list of supported languages. + """ + @spec get_languages(keyword()) :: [String.t()] + def get_languages(opts \\ []) do + case load_languages_cache() do + nil -> + response = api_get("/languages", opts) + langs = extract_json_array(response, "languages") + save_languages_cache(langs) + langs + cached -> + cached + end + end + + # ============================================================================ + # Session Functions (9) + # ============================================================================ + + @doc """ + List all sessions. + """ + @spec session_list(keyword()) :: String.t() + def session_list(opts \\ []), do: api_get("/sessions", opts) + + @doc """ + Get session details. + """ + @spec session_get(String.t(), keyword()) :: session() + def session_get(session_id, opts \\ []) do + response = api_get("/sessions/#{session_id}", opts) + %{ + id: session_id, + status: extract_json_value(response, "status") || "unknown", + container_name: extract_json_value(response, "container_name"), + network_mode: extract_json_value(response, "network_mode"), + vcpu: extract_json_int(response, "vcpu"), + created_at: extract_json_int(response, "created_at") + } + end + + @doc """ + Create a new session. + + ## Options + * `:shell` - Shell to use (default "bash") + * `:network` - Network mode + * `:vcpu` - Number of vCPUs + * `:input_files` - List of file paths + """ + @spec session_create(keyword()) :: session() + def session_create(opts \\ []) do + shell = Keyword.get(opts, :shell, "bash") + network = Keyword.get(opts, :network) + vcpu = Keyword.get(opts, :vcpu) + input_files = Keyword.get(opts, :input_files, []) + + network_json = if network, do: ",\"network\":\"#{network}\"", else: "" + vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" + input_files_json = build_input_files_json(input_files) + + json = "{\"shell\":\"#{shell}\"#{network_json}#{vcpu_json}#{input_files_json}}" + response = api_post("/sessions", json, opts) + + %{ + id: extract_json_value(response, "id") || "", + status: extract_json_value(response, "status") || "created", + container_name: extract_json_value(response, "container_name"), + network_mode: extract_json_value(response, "network_mode"), + vcpu: extract_json_int(response, "vcpu"), + created_at: extract_json_int(response, "created_at") + } + end + + @doc """ + Destroy a session. + """ + @spec session_destroy(String.t(), keyword()) :: boolean() + def session_destroy(session_id, opts \\ []) do + response = api_delete("/sessions/#{session_id}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Freeze a session. + """ + @spec session_freeze(String.t(), keyword()) :: boolean() + def session_freeze(session_id, opts \\ []) do + response = api_post("/sessions/#{session_id}/freeze", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Unfreeze a session. + """ + @spec session_unfreeze(String.t(), keyword()) :: boolean() + def session_unfreeze(session_id, opts \\ []) do + response = api_post("/sessions/#{session_id}/unfreeze", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Boost session resources (increase vCPU). + """ + @spec session_boost(String.t(), integer(), keyword()) :: boolean() + def session_boost(session_id, vcpu, opts \\ []) do + json = "{\"vcpu\":#{vcpu}}" + response = api_patch("/sessions/#{session_id}", json, opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Unboost session (reset to default resources). + """ + @spec session_unboost(String.t(), keyword()) :: boolean() + def session_unboost(session_id, opts \\ []) do + json = "{\"vcpu\":1}" + response = api_patch("/sessions/#{session_id}", json, opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Execute a command in a session. + """ + @spec session_execute(String.t(), String.t(), keyword()) :: result() + def session_execute(session_id, command, opts \\ []) do + json = "{\"command\":\"#{escape_json(command)}\"}" + response = api_post("/sessions/#{session_id}/execute", json, opts) + parse_result(response) + end + + # ============================================================================ + # Service Functions (17) + # ============================================================================ + + @doc """ + List all services. + """ + @spec service_list(keyword()) :: String.t() + def service_list(opts \\ []), do: api_get("/services", opts) + + @doc """ + Get service details. + """ + @spec service_get(String.t(), keyword()) :: service() + def service_get(service_id, opts \\ []) do + response = api_get("/services/#{service_id}", opts) + %{ + id: service_id, + name: extract_json_value(response, "name") || "", + status: extract_json_value(response, "status") || "unknown", + ports: extract_json_value(response, "ports"), + domains: extract_json_value(response, "domains"), + vcpu: extract_json_int(response, "vcpu"), + locked: extract_json_value(response, "locked") == "true", + unfreeze_on_demand: extract_json_value(response, "unfreeze_on_demand") == "true", + created_at: extract_json_int(response, "created_at") + } + end + + @doc """ + Create a new service. + + ## Options + * `:ports` - Ports to expose (e.g., "8080" or "80,443") + * `:domains` - Custom domains + * `:bootstrap` - Bootstrap script content + * `:network` - Network mode + * `:vcpu` - Number of vCPUs + * `:input_files` - List of file paths + """ + @spec service_create(String.t(), keyword()) :: String.t() | nil + def service_create(name, opts \\ []) do + ports = Keyword.get(opts, :ports) + domains = Keyword.get(opts, :domains) + bootstrap = Keyword.get(opts, :bootstrap) + network = Keyword.get(opts, :network) + vcpu = Keyword.get(opts, :vcpu) + input_files = Keyword.get(opts, :input_files, []) + + ports_json = if ports, do: ",\"ports\":[#{ports}]", else: "" + domains_json = if domains, do: ",\"domains\":\"#{escape_json(domains)}\"", else: "" + bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" + network_json = if network, do: ",\"network\":\"#{network}\"", else: "" + vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" + input_files_json = build_input_files_json(input_files) + + json = "{\"name\":\"#{escape_json(name)}\"#{ports_json}#{domains_json}#{bootstrap_json}#{network_json}#{vcpu_json}#{input_files_json}}" + response = api_post("/services", json, opts) + extract_json_value(response, "id") + end + + @doc """ + Destroy a service. + """ + @spec service_destroy(String.t(), keyword()) :: boolean() + def service_destroy(service_id, opts \\ []) do + response = api_delete("/services/#{service_id}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Freeze a service. + """ + @spec service_freeze(String.t(), keyword()) :: boolean() + def service_freeze(service_id, opts \\ []) do + response = api_post("/services/#{service_id}/freeze", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Unfreeze a service. + """ + @spec service_unfreeze(String.t(), keyword()) :: boolean() + def service_unfreeze(service_id, opts \\ []) do + response = api_post("/services/#{service_id}/unfreeze", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Lock a service to prevent deletion. + """ + @spec service_lock(String.t(), keyword()) :: boolean() + def service_lock(service_id, opts \\ []) do + response = api_post("/services/#{service_id}/lock", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Unlock a service. + """ + @spec service_unlock(String.t(), keyword()) :: boolean() + def service_unlock(service_id, opts \\ []) do + response = api_post("/services/#{service_id}/unlock", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Set unfreeze-on-demand for a service. + """ + @spec service_set_unfreeze_on_demand(String.t(), boolean(), keyword()) :: boolean() + def service_set_unfreeze_on_demand(service_id, enabled, opts \\ []) do + json = "{\"unfreeze_on_demand\":#{enabled}}" + response = api_patch("/services/#{service_id}", json, opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Redeploy a service with optional new bootstrap. + """ + @spec service_redeploy(String.t(), String.t() | nil, keyword()) :: boolean() + def service_redeploy(service_id, bootstrap \\ nil, opts \\ []) do + bootstrap_json = if bootstrap, do: "\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" + json = "{#{bootstrap_json}}" + response = api_post("/services/#{service_id}/redeploy", json, opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Get service bootstrap logs. + """ + @spec service_logs(String.t(), keyword()) :: String.t() + def service_logs(service_id, opts \\ []) do + all_logs = Keyword.get(opts, :all_logs, false) + endpoint = if all_logs, do: "/services/#{service_id}/logs?all=true", else: "/services/#{service_id}/logs" + api_get(endpoint, opts) + end + + @doc """ + Execute a command in a service. + """ + @spec service_execute(String.t(), String.t(), keyword()) :: result() + def service_execute(service_id, command, opts \\ []) do + timeout_ms = Keyword.get(opts, :timeout_ms) + timeout_json = if timeout_ms, do: ",\"timeout_ms\":#{timeout_ms}", else: "" + json = "{\"command\":\"#{escape_json(command)}\"#{timeout_json}}" + response = api_post("/services/#{service_id}/execute", json, opts) + parse_result(response) + end + + @doc """ + Get service environment vault. + """ + @spec service_env_get(String.t(), keyword()) :: String.t() + def service_env_get(service_id, opts \\ []) do + api_get("/services/#{service_id}/env", opts) + end + + @doc """ + Set service environment vault. + """ + @spec service_env_set(String.t(), String.t(), keyword()) :: boolean() + def service_env_set(service_id, env_content, opts \\ []) do + api_put_text("/services/#{service_id}/env", env_content, opts) + end + + @doc """ + Delete service environment vault. + """ + @spec service_env_delete(String.t(), keyword()) :: boolean() + def service_env_delete(service_id, opts \\ []) do + response = api_delete("/services/#{service_id}/env", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Export service environment vault. + """ + @spec service_env_export(String.t(), keyword()) :: String.t() + def service_env_export(service_id, opts \\ []) do + response = api_post("/services/#{service_id}/env/export", "{}", opts) + extract_json_value(response, "content") || "" + end + + @doc """ + Resize a service (change vCPU). + """ + @spec service_resize(String.t(), integer(), keyword()) :: boolean() + def service_resize(service_id, vcpu, opts \\ []) do + json = "{\"vcpu\":#{vcpu}}" + response = api_patch("/services/#{service_id}", json, opts) + not String.contains?(response, "\"error\"") + end + + # ============================================================================ + # Snapshot Functions (9) + # ============================================================================ + + @doc """ + List all snapshots. + """ + @spec snapshot_list(keyword()) :: String.t() + def snapshot_list(opts \\ []), do: api_get("/snapshots", opts) + + @doc """ + Get snapshot details. + """ + @spec snapshot_get(String.t(), keyword()) :: snapshot() + def snapshot_get(snapshot_id, opts \\ []) do + response = api_get("/snapshots/#{snapshot_id}", opts) + %{ + id: snapshot_id, + name: extract_json_value(response, "name"), + type: extract_json_value(response, "type") || "unknown", + source_id: extract_json_value(response, "source_id") || "", + hot: extract_json_value(response, "hot") == "true", + locked: extract_json_value(response, "locked") == "true", + created_at: extract_json_int(response, "created_at"), + size_bytes: extract_json_int(response, "size_bytes") + } + end + + @doc """ + Create a snapshot of a session. + """ + @spec snapshot_session(String.t(), keyword()) :: String.t() | nil + def snapshot_session(session_id, opts \\ []) do + name = Keyword.get(opts, :name) + hot = Keyword.get(opts, :hot, false) + name_json = if name, do: "\"name\":\"#{escape_json(name)}\",", else: "" + json = "{#{name_json}\"hot\":#{hot}}" + response = api_post("/sessions/#{session_id}/snapshot", json, opts) + extract_json_value(response, "id") + end + + @doc """ + Create a snapshot of a service. + """ + @spec snapshot_service(String.t(), keyword()) :: String.t() | nil + def snapshot_service(service_id, opts \\ []) do + name = Keyword.get(opts, :name) + hot = Keyword.get(opts, :hot, false) + name_json = if name, do: "\"name\":\"#{escape_json(name)}\",", else: "" + json = "{#{name_json}\"hot\":#{hot}}" + response = api_post("/services/#{service_id}/snapshot", json, opts) + extract_json_value(response, "id") + end + + @doc """ + Restore from a snapshot. + """ + @spec snapshot_restore(String.t(), keyword()) :: String.t() | nil + def snapshot_restore(snapshot_id, opts \\ []) do + response = api_post("/snapshots/#{snapshot_id}/restore", "{}", opts) + extract_json_value(response, "id") + end + + @doc """ + Delete a snapshot. + """ + @spec snapshot_delete(String.t(), keyword()) :: boolean() + def snapshot_delete(snapshot_id, opts \\ []) do + response = api_delete("/snapshots/#{snapshot_id}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Lock a snapshot to prevent deletion. + """ + @spec snapshot_lock(String.t(), keyword()) :: boolean() + def snapshot_lock(snapshot_id, opts \\ []) do + response = api_post("/snapshots/#{snapshot_id}/lock", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Unlock a snapshot. + """ + @spec snapshot_unlock(String.t(), keyword()) :: boolean() + def snapshot_unlock(snapshot_id, opts \\ []) do + response = api_post("/snapshots/#{snapshot_id}/unlock", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Clone a snapshot to create a new session or service. + + ## Options + * `:type` - "session" or "service" (required) + * `:name` - Name for cloned service + * `:ports` - Ports for cloned service + * `:shell` - Shell for cloned session + """ + @spec snapshot_clone(String.t(), keyword()) :: String.t() | nil + def snapshot_clone(snapshot_id, opts \\ []) do + clone_type = Keyword.get(opts, :type) + name = Keyword.get(opts, :name) + ports = Keyword.get(opts, :ports) + shell = Keyword.get(opts, :shell) + + type_json = "\"type\":\"#{clone_type}\"" + name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: "" + ports_json = if ports, do: ",\"ports\":[#{ports}]", else: "" + shell_json = if shell, do: ",\"shell\":\"#{shell}\"", else: "" + json = "{#{type_json}#{name_json}#{ports_json}#{shell_json}}" + response = api_post("/snapshots/#{snapshot_id}/clone", json, opts) + extract_json_value(response, "id") + end + + # ============================================================================ + # Image Functions (13) + # ============================================================================ + + @doc """ + List images. + + ## Options + * `:filter` - "owned", "shared", "public", or nil for all + """ + @spec image_list(keyword()) :: String.t() + def image_list(opts \\ []) do + filter = Keyword.get(opts, :filter) + endpoint = if filter, do: "/images?filter=#{filter}", else: "/images" + api_get(endpoint, opts) + end + + @doc """ + Get image details. + """ + @spec image_get(String.t(), keyword()) :: image() + def image_get(image_id, opts \\ []) do + response = api_get("/images/#{image_id}", opts) + %{ + id: image_id, + name: extract_json_value(response, "name"), + description: extract_json_value(response, "description"), + visibility: extract_json_value(response, "visibility") || "private", + source_type: extract_json_value(response, "source_type") || "", + source_id: extract_json_value(response, "source_id") || "", + locked: extract_json_value(response, "locked") == "true", + created_at: extract_json_int(response, "created_at"), + size_bytes: extract_json_int(response, "size_bytes") + } + end + + @doc """ + Publish an image from a service or snapshot. + + ## Options + * `:name` - Image name + * `:description` - Image description + """ + @spec image_publish(String.t(), String.t(), keyword()) :: String.t() | nil + def image_publish(source_type, source_id, opts \\ []) do + name = Keyword.get(opts, :name) + description = Keyword.get(opts, :description) + name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: "" + desc_json = if description, do: ",\"description\":\"#{escape_json(description)}\"", else: "" + json = "{\"source_type\":\"#{source_type}\",\"source_id\":\"#{source_id}\"#{name_json}#{desc_json}}" + response = api_post("/images/publish", json, opts) + extract_json_value(response, "id") + end + + @doc """ + Delete an image. + """ + @spec image_delete(String.t(), keyword()) :: boolean() + def image_delete(image_id, opts \\ []) do + response = api_delete("/images/#{image_id}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Lock an image to prevent deletion. + """ + @spec image_lock(String.t(), keyword()) :: boolean() + def image_lock(image_id, opts \\ []) do + response = api_post("/images/#{image_id}/lock", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Unlock an image. + """ + @spec image_unlock(String.t(), keyword()) :: boolean() + def image_unlock(image_id, opts \\ []) do + response = api_post("/images/#{image_id}/unlock", "{}", opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Set image visibility. + """ + @spec image_set_visibility(String.t(), String.t(), keyword()) :: boolean() + def image_set_visibility(image_id, visibility, opts \\ []) do + json = "{\"visibility\":\"#{visibility}\"}" + response = api_post("/images/#{image_id}/visibility", json, opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Grant access to an image for another API key. + """ + @spec image_grant_access(String.t(), String.t(), keyword()) :: boolean() + def image_grant_access(image_id, trusted_api_key, opts \\ []) do + json = "{\"api_key\":\"#{trusted_api_key}\"}" + response = api_post("/images/#{image_id}/access/grant", json, opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Revoke access to an image from another API key. + """ + @spec image_revoke_access(String.t(), String.t(), keyword()) :: boolean() + def image_revoke_access(image_id, trusted_api_key, opts \\ []) do + json = "{\"api_key\":\"#{trusted_api_key}\"}" + response = api_post("/images/#{image_id}/access/revoke", json, opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + List trusted API keys for an image. + """ + @spec image_list_trusted(String.t(), keyword()) :: [String.t()] + def image_list_trusted(image_id, opts \\ []) do + response = api_get("/images/#{image_id}/access", opts) + extract_json_array(response, "trusted_keys") + end + + @doc """ + Transfer image ownership to another API key. + """ + @spec image_transfer(String.t(), String.t(), keyword()) :: boolean() + def image_transfer(image_id, to_api_key, opts \\ []) do + json = "{\"to_api_key\":\"#{to_api_key}\"}" + response = api_post("/images/#{image_id}/transfer", json, opts) + not String.contains?(response, "\"error\"") + end + + @doc """ + Spawn a new service from an image. + + ## Options + * `:name` - Service name + * `:ports` - Ports to expose + * `:bootstrap` - Bootstrap command + * `:network` - Network mode + """ + @spec image_spawn(String.t(), keyword()) :: String.t() | nil + def image_spawn(image_id, opts \\ []) do + name = Keyword.get(opts, :name) + ports = Keyword.get(opts, :ports) + bootstrap = Keyword.get(opts, :bootstrap) + network = Keyword.get(opts, :network) + + name_json = if name, do: "\"name\":\"#{escape_json(name)}\"", else: "" + ports_json = if ports, do: "#{if name, do: ",", else: ""}\"ports\":[#{ports}]", else: "" + bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" + network_json = if network, do: ",\"network\":\"#{network}\"", else: "" + json = "{#{name_json}#{ports_json}#{bootstrap_json}#{network_json}}" + response = api_post("/images/#{image_id}/spawn", json, opts) + extract_json_value(response, "id") + end + + @doc """ + Clone an image. + + ## Options + * `:name` - Name for cloned image + * `:description` - Description for cloned image + """ + @spec image_clone(String.t(), keyword()) :: String.t() | nil + def image_clone(image_id, opts \\ []) do + name = Keyword.get(opts, :name) + description = Keyword.get(opts, :description) + name_json = if name, do: "\"name\":\"#{escape_json(name)}\"", else: "" + desc_json = if description, do: "#{if name, do: ",", else: ""}\"description\":\"#{escape_json(description)}\"", else: "" + json = "{#{name_json}#{desc_json}}" + response = api_post("/images/#{image_id}/clone", json, opts) + extract_json_value(response, "id") + end + + # ============================================================================ + # PaaS Logs Functions (2) + # ============================================================================ + + @doc """ + Fetch batch logs from portal. + + ## Options + * `:source` - "all", "api", "portal", "pool/cammy", "pool/ai" + * `:lines` - Number of lines (1-10000) + * `:since` - Time window ("1m", "5m", "1h", "1d") + * `:grep` - Filter pattern + """ + @spec logs_fetch(keyword()) :: String.t() + def logs_fetch(opts \\ []) do + source = Keyword.get(opts, :source, "all") + lines = Keyword.get(opts, :lines, 100) + since = Keyword.get(opts, :since, "1h") + grep = Keyword.get(opts, :grep) + + grep_param = if grep, do: "&grep=#{URI.encode(grep)}", else: "" + api_get("/logs?source=#{source}&lines=#{lines}&since=#{since}#{grep_param}", opts) + end + + @doc """ + Stream logs via SSE. This is a blocking operation that calls the callback for each log line. + Note: Full SSE streaming requires WebSocket support; this implementation provides basic fetch. + """ + @spec logs_stream(keyword(), (String.t(), String.t() -> any())) :: :ok + def logs_stream(opts \\ [], callback) do + # For Elixir without external deps, we can't do true SSE streaming + # Instead, we poll with a short interval + source = Keyword.get(opts, :source, "all") + grep = Keyword.get(opts, :grep) + interval = Keyword.get(opts, :interval, 5000) + + grep_param = if grep, do: "&grep=#{URI.encode(grep)}", else: "" + + Stream.repeatedly(fn -> + response = api_get("/logs?source=#{source}&lines=50&since=10s#{grep_param}", opts) + callback.(source, response) + Process.sleep(interval) + end) + |> Stream.run() + + :ok + end + + # ============================================================================ + # Key Validation (1) + # ============================================================================ + + @doc """ + Validate API keys and get account information. + """ + @spec validate_keys(keyword()) :: key_info() + def validate_keys(opts \\ []) do + response = portal_post("/keys/validate", "{}", opts) + %{ + valid: extract_json_value(response, "status") == "valid", + tier: extract_json_value(response, "tier"), + rate_limit_per_minute: extract_json_int(response, "rate_per_minute"), + concurrency_limit: extract_json_int(response, "concurrency"), + expires_at: extract_json_int(response, "expires_at") + } + end + + # ============================================================================ + # Private API Functions + # ============================================================================ + + defp api_get(endpoint, opts) do + {public_key, secret_key} = get_api_keys_from_opts(opts) + headers = build_auth_headers(public_key, secret_key, "GET", endpoint, "") + args = ["-s", "#{@api_base}#{endpoint}"] ++ headers + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + check_clock_drift(output) + output + end + + defp api_post(endpoint, json, opts) do + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" + File.write!(tmp_file, json) + + {public_key, secret_key} = get_api_keys_from_opts(opts) + headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) + + args = ["-s", "-X", "POST", "#{@api_base}#{endpoint}", "-H", "Content-Type: application/json"] ++ headers ++ ["-d", "@#{tmp_file}"] + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + + File.rm(tmp_file) + check_clock_drift(output) + output + end + + defp api_delete(endpoint, opts) do + {public_key, secret_key} = get_api_keys_from_opts(opts) + headers = build_auth_headers(public_key, secret_key, "DELETE", endpoint, "") + args = ["-s", "-X", "DELETE", "#{@api_base}#{endpoint}"] ++ headers + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + check_clock_drift(output) + output + end + + defp api_patch(endpoint, json, opts) do + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" + File.write!(tmp_file, json) + + {public_key, secret_key} = get_api_keys_from_opts(opts) + headers = build_auth_headers(public_key, secret_key, "PATCH", endpoint, json) + + args = ["-s", "-X", "PATCH", "#{@api_base}#{endpoint}", "-H", "Content-Type: application/json"] ++ headers ++ ["-d", "@#{tmp_file}"] + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + + File.rm(tmp_file) + check_clock_drift(output) + output + end + + defp api_put_text(endpoint, body, opts) do + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.txt" + File.write!(tmp_file, body) + + {public_key, secret_key} = get_api_keys_from_opts(opts) + headers = build_auth_headers(public_key, secret_key, "PUT", endpoint, body) + + args = ["-s", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT", "#{@api_base}#{endpoint}", "-H", "Content-Type: text/plain"] ++ headers ++ ["-d", "@#{tmp_file}"] + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + + File.rm(tmp_file) + status_code = String.trim(output) |> String.to_integer() + status_code >= 200 and status_code < 300 + end + + defp portal_post(endpoint, json, opts) do + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" + File.write!(tmp_file, json) + + {public_key, secret_key} = get_api_keys_from_opts(opts) + headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) + + args = ["-s", "-X", "POST", "#{@portal_base}#{endpoint}", "-H", "Content-Type: application/json"] ++ headers ++ ["-d", "@#{tmp_file}"] + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + + File.rm(tmp_file) + check_clock_drift(output) + output + end + + defp get_api_keys_from_opts(opts) do + public_key = Keyword.get(opts, :public_key) + secret_key = Keyword.get(opts, :secret_key) + + if public_key && secret_key do + {public_key, secret_key} + else + get_api_keys() + end + end + + defp build_execute_json_full(language, code, opts) do + network = Keyword.get(opts, :network) + vcpu = Keyword.get(opts, :vcpu) + ttl = Keyword.get(opts, :ttl) + env = Keyword.get(opts, :env, []) + input_files = Keyword.get(opts, :input_files, []) + return_artifacts = Keyword.get(opts, :return_artifacts, false) + + network_json = if network, do: ",\"network\":\"#{network}\"", else: "" + vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" + ttl_json = if ttl, do: ",\"ttl\":#{ttl}", else: "" + env_json = if env != [], do: ",\"env\":{" <> Enum.map_join(env, ",", fn {k, v} -> "\"#{k}\":\"#{escape_json(v)}\"" end) <> "}", else: "" + input_files_json = build_input_files_json(input_files) + artifacts_json = if return_artifacts, do: ",\"return_artifacts\":true", else: "" + + "{\"language\":\"#{language}\",\"code\":\"#{escape_json(code)}\"#{network_json}#{vcpu_json}#{ttl_json}#{env_json}#{input_files_json}#{artifacts_json}}" + end + + defp parse_result(response) do + %{ + success: extract_json_int(response, "exit_code") == 0, + stdout: extract_json_value(response, "stdout") || "", + stderr: extract_json_value(response, "stderr") || "", + exit_code: extract_json_int(response, "exit_code") || 0, + job_id: extract_json_value(response, "job_id"), + language: extract_json_value(response, "language"), + execution_time: nil + } + end + + defp extract_json_int(json_str, key) do + case Regex.run(~r/"#{key}"\s*:\s*(-?\d+)/, json_str) do + [_, value] -> String.to_integer(value) + _ -> nil + end + end + + # ============================================================================ + # CLI Entry Point + # ============================================================================ + def main([]), do: print_usage() def main(["session" | rest]), do: session_command(rest) def main(["service" | rest]), do: service_command(rest) diff --git a/clients/elixir/sync/tests/test_functional.exs b/clients/elixir/sync/tests/test_functional.exs new file mode 100644 index 0000000..760612c --- /dev/null +++ b/clients/elixir/sync/tests/test_functional.exs @@ -0,0 +1,153 @@ +#!/usr/bin/env elixir + +# Functional Tests for Un Elixir SDK +# +# Run with: elixir tests/test_functional.exs +# Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables +# +# These tests make real API calls to api.unsandbox.com + +Code.require_file("../src/un.ex", __DIR__) + +defmodule UnFunctionalTest do + @moduledoc """ + Functional test suite for Un Elixir SDK. + Tests real API calls to api.unsandbox.com. + """ + + @blue "\e[34m" + @red "\e[31m" + @green "\e[32m" + @yellow "\e[33m" + @reset "\e[0m" + + def run_all do + IO.puts("\n#{@blue}=== Un Elixir SDK Functional Tests ===#@reset}\n") + + # Check for credentials + unless System.get_env("UNSANDBOX_PUBLIC_KEY") && System.get_env("UNSANDBOX_SECRET_KEY") do + IO.puts("#{@yellow}SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{@reset}") + System.halt(0) + end + + tests = [ + {"health_check", &test_health_check/0}, + {"validate_keys", &test_validate_keys/0}, + {"execute_python", &test_execute_python/0}, + {"execute_with_error", &test_execute_with_error/0}, + {"session_list", &test_session_list/0}, + {"service_list", &test_service_list/0}, + {"snapshot_list", &test_snapshot_list/0}, + {"image_list", &test_image_list/0} + ] + + results = Enum.map(tests, fn {name, test_fn} -> + IO.write(" Running #{name}... ") + try do + test_fn.() + IO.puts("#{@green}PASS#{@reset}") + :pass + rescue + e -> + IO.puts("#{@red}FAIL#{@reset}") + IO.puts(" #{inspect(e)}") + :fail + end + end) + + passed = Enum.count(results, &(&1 == :pass)) + failed = Enum.count(results, &(&1 == :fail)) + total = length(results) + + IO.puts("\n#{@blue}Results: #{passed}/#{total} passed#{@reset}") + if failed > 0 do + IO.puts("#{@red}#{failed} test(s) failed#{@reset}") + System.halt(1) + else + IO.puts("#{@green}All functional tests passed!#{@reset}") + end + end + + # ============================================================================ + # Functional Tests + # ============================================================================ + + def test_health_check do + result = Un.health_check() + assert is_boolean(result), "health_check should return boolean" + # Note: We don't require it to be true in case API is down + end + + def test_validate_keys do + key_info = Un.validate_keys() + assert is_map(key_info), "validate_keys should return a map" + assert Map.has_key?(key_info, :valid), "key_info should have :valid key" + assert is_boolean(key_info.valid), ":valid should be boolean" + end + + def test_execute_python do + result = Un.execute("python", "print(6 * 7)") + assert is_map(result), "execute should return a map" + assert Map.has_key?(result, :success), "result should have :success key" + assert Map.has_key?(result, :stdout), "result should have :stdout key" + assert Map.has_key?(result, :exit_code), "result should have :exit_code key" + + # Check output + assert result.success == true, "execution should succeed" + assert String.contains?(result.stdout, "42"), "stdout should contain '42'" + assert result.exit_code == 0, "exit_code should be 0" + end + + def test_execute_with_error do + result = Un.execute("python", "import sys; sys.exit(1)") + assert is_map(result), "execute should return a map" + assert result.success == false, "execution should fail" + assert result.exit_code == 1, "exit_code should be 1" + end + + def test_session_list do + response = Un.session_list() + assert is_binary(response), "session_list should return a string" + # Response should be valid JSON (starts with [ or {) + trimmed = String.trim(response) + assert String.starts_with?(trimmed, "[") || String.starts_with?(trimmed, "{"), + "response should be JSON" + end + + def test_service_list do + response = Un.service_list() + assert is_binary(response), "service_list should return a string" + trimmed = String.trim(response) + assert String.starts_with?(trimmed, "[") || String.starts_with?(trimmed, "{"), + "response should be JSON" + end + + def test_snapshot_list do + response = Un.snapshot_list() + assert is_binary(response), "snapshot_list should return a string" + trimmed = String.trim(response) + assert String.starts_with?(trimmed, "[") || String.starts_with?(trimmed, "{"), + "response should be JSON" + end + + def test_image_list do + response = Un.image_list() + assert is_binary(response), "image_list should return a string" + trimmed = String.trim(response) + assert String.starts_with?(trimmed, "[") || String.starts_with?(trimmed, "{"), + "response should be JSON" + end + + # ============================================================================ + # Helpers + # ============================================================================ + + defp assert(true, _message), do: :ok + defp assert(false, message), do: raise message + defp assert(condition, message) when is_boolean(condition) do + if condition, do: :ok, else: raise message + end +end + +# Run tests +UnFunctionalTest.run_all() diff --git a/clients/elixir/sync/tests/test_library.exs b/clients/elixir/sync/tests/test_library.exs new file mode 100644 index 0000000..4516359 --- /dev/null +++ b/clients/elixir/sync/tests/test_library.exs @@ -0,0 +1,135 @@ +#!/usr/bin/env elixir + +# Tests for Un Elixir SDK +# +# Run with: elixir tests/test_library.exs +# Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables + +Code.require_file("../src/un.ex", __DIR__) + +defmodule UnTest do + @moduledoc """ + Test suite for Un Elixir SDK library functions. + """ + + @blue "\e[34m" + @red "\e[31m" + @green "\e[32m" + @yellow "\e[33m" + @reset "\e[0m" + + def run_all do + IO.puts("\n#{@blue}=== Un Elixir SDK Tests ===#@reset}\n") + + tests = [ + {"version", &test_version/0}, + {"detect_language", &test_detect_language/0}, + {"hmac_sign", &test_hmac_sign/0}, + {"hmac_sign_deterministic", &test_hmac_sign_deterministic/0}, + {"hmac_sign_different_secrets", &test_hmac_sign_different_secrets/0}, + {"get_languages", &test_get_languages/0} + ] + + results = Enum.map(tests, fn {name, test_fn} -> + try do + test_fn.() + IO.puts("#{@green}PASS#{@reset}: #{name}") + :pass + rescue + e -> + IO.puts("#{@red}FAIL#{@reset}: #{name} - #{inspect(e)}") + :fail + end + end) + + passed = Enum.count(results, &(&1 == :pass)) + failed = Enum.count(results, &(&1 == :fail)) + total = length(results) + + IO.puts("\n#{@blue}Results: #{passed}/#{total} passed#{@reset}") + if failed > 0 do + IO.puts("#{@red}#{failed} test(s) failed#{@reset}") + System.halt(1) + else + IO.puts("#{@green}All tests passed!#{@reset}") + end + end + + # ============================================================================ + # Unit Tests + # ============================================================================ + + def test_version do + version = Un.version() + assert is_binary(version), "version should be a string" + assert String.match?(version, ~r/^\d+\.\d+\.\d+$/), "version should be semver format" + end + + def test_detect_language do + # Test common extensions + assert Un.detect_language("script.py") == "python" + assert Un.detect_language("app.js") == "javascript" + assert Un.detect_language("main.go") == "go" + assert Un.detect_language("main.rs") == "rust" + assert Un.detect_language("main.c") == "c" + assert Un.detect_language("main.cpp") == "cpp" + assert Un.detect_language("Main.java") == "java" + assert Un.detect_language("script.rb") == "ruby" + assert Un.detect_language("script.sh") == "bash" + assert Un.detect_language("script.lua") == "lua" + assert Un.detect_language("script.pl") == "perl" + assert Un.detect_language("index.php") == "php" + assert Un.detect_language("main.hs") == "haskell" + assert Un.detect_language("main.ml") == "ocaml" + assert Un.detect_language("main.ex") == "elixir" + assert Un.detect_language("main.erl") == "erlang" + + # Test with paths + assert Un.detect_language("/path/to/script.py") == "python" + + # Test unknown extensions + assert Un.detect_language("Makefile") == nil + assert Un.detect_language("README") == nil + assert Un.detect_language("script.unknown") == nil + end + + def test_hmac_sign do + signature = Un.hmac_sign("my_secret", "test message") + assert is_binary(signature), "signature should be a string" + assert String.length(signature) == 64, "signature should be 64 hex characters" + assert String.match?(signature, ~r/^[0-9a-f]+$/), "signature should be lowercase hex" + end + + def test_hmac_sign_deterministic do + sig1 = Un.hmac_sign("test_secret", "same message") + sig2 = Un.hmac_sign("test_secret", "same message") + assert sig1 == sig2, "same inputs should produce same signature" + end + + def test_hmac_sign_different_secrets do + sig1 = Un.hmac_sign("secret1", "test message") + sig2 = Un.hmac_sign("secret2", "test message") + assert sig1 != sig2, "different secrets should produce different signatures" + end + + def test_get_languages do + languages = Un.get_languages() + assert is_list(languages), "languages should be a list" + assert length(languages) > 0, "languages list should not be empty" + assert "python" in languages, "python should be in languages" + assert "javascript" in languages, "javascript should be in languages" + end + + # ============================================================================ + # Helpers + # ============================================================================ + + defp assert(true, _message), do: :ok + defp assert(false, message), do: raise message + defp assert(condition, message) when is_boolean(condition) do + if condition, do: :ok, else: raise message + end +end + +# Run tests +UnTest.run_all() diff --git a/clients/erlang/sync/src/un.erl b/clients/erlang/sync/src/un.erl index 235f299..17ec8df 100755 --- a/clients/erlang/sync/src/un.erl +++ b/clients/erlang/sync/src/un.erl @@ -37,10 +37,687 @@ #!/usr/bin/env escript -%%% Erlang UN CLI - Unsandbox CLI Client +%%% @doc unsandbox.com Erlang SDK %%% -%%% Full-featured CLI matching un.py capabilities -%%% Uses curl for HTTP (no external dependencies) +%%% Full API with execution, sessions, services, snapshots, and images. +%%% +%%% Library Usage: +%%% ``` +%%% %% Execute code synchronously +%%% Result = un:execute("python", "print(42)"), +%%% io:format("~s~n", [maps:get(stdout, Result)]). +%%% +%%% %% List sessions +%%% Sessions = un:session_list(). +%%% +%%% %% Create a service +%%% ServiceId = un:service_create("myapp", #{ports => "8080"}). +%%% ``` +%%% +%%% Authentication Priority: +%%% 1. Function arguments (PublicKey, SecretKey) +%%% 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) +%%% 3. Config file (~/.unsandbox/accounts.csv) + +-define(API_BASE, "https://api.unsandbox.com"). +-define(PORTAL_BASE, "https://unsandbox.com"). +-define(VERSION, "4.2.0"). +-define(LANGUAGES_CACHE_TTL, 3600). + +%% ============================================================================ +%% Utility Functions +%% ============================================================================ + +%% @doc Return the SDK version. +version() -> ?VERSION. + +%% @doc Check API health. +health_check() -> + Cmd = "curl -s -o /dev/null -w '%{http_code}' " ++ ?API_BASE ++ "/health", + Result = os:cmd(Cmd), + string:trim(Result) == "200". + +%% @doc Generate HMAC-SHA256 signature for a message. +hmac_sign(SecretKey, Message) -> + hmac_sha256(SecretKey, Message). + +%% @doc Detect language from filename extension. +detect_language(Filename) -> + Ext = filename:extension(Filename), + ext_to_lang(Ext). + +%% ============================================================================ +%% Execution Functions (8) +%% ============================================================================ + +%% @doc Execute code synchronously. +execute(Language, Code) -> + execute(Language, Code, #{}). + +execute(Language, Code, Opts) -> + Json = build_execute_json_full(Language, Code, Opts), + Response = api_post("/execute", Json, Opts), + parse_result(Response). + +%% @doc Execute code asynchronously, returning a job ID. +execute_async(Language, Code) -> + execute_async(Language, Code, #{}). + +execute_async(Language, Code, Opts) -> + Json = build_execute_json_full(Language, Code, Opts), + Response = api_post("/execute/async", Json, Opts), + extract_json_field(Response, "job_id"). + +%% @doc Wait for a job to complete and return the result. +wait_job(JobId) -> + wait_job(JobId, #{}). + +wait_job(JobId, Opts) -> + MaxPolls = maps:get(max_polls, Opts, 100), + PollDelays = [300, 450, 700, 900, 650, 1600, 2000], + do_wait_job(JobId, PollDelays, 0, MaxPolls, Opts). + +do_wait_job(JobId, _PollDelays, PollCount, MaxPolls, _Opts) when PollCount >= MaxPolls -> + #{success => false, stdout => "", stderr => "Max polls exceeded", exit_code => 1, job_id => JobId}; +do_wait_job(JobId, PollDelays, PollCount, MaxPolls, Opts) -> + DelayIdx = min(PollCount, length(PollDelays) - 1), + Delay = lists:nth(DelayIdx + 1, PollDelays), + timer:sleep(Delay), + Job = get_job(JobId, Opts), + Status = maps:get(status, Job, "unknown"), + case lists:member(Status, ["completed", "failed", "timeout", "cancelled"]) of + true -> + Response = api_get("/jobs/" ++ JobId, Opts), + parse_result(Response); + false -> + do_wait_job(JobId, PollDelays, PollCount + 1, MaxPolls, Opts) + end. + +%% @doc Get job status and details. +get_job(JobId) -> + get_job(JobId, #{}). + +get_job(JobId, Opts) -> + Response = api_get("/jobs/" ++ JobId, Opts), + #{ + id => JobId, + status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end, + language => extract_json_field(Response, "language"), + created_at => extract_json_number(Response, "created_at"), + completed_at => extract_json_number(Response, "completed_at") + }. + +%% @doc Cancel a running job. +cancel_job(JobId) -> + cancel_job(JobId, #{}). + +cancel_job(JobId, Opts) -> + Response = api_delete("/jobs/" ++ JobId, Opts), + not_contains_error(Response). + +%% @doc List all active jobs. +list_jobs() -> + list_jobs(#{}). + +list_jobs(Opts) -> + api_get("/jobs", Opts). + +%% @doc Get list of supported languages. +get_languages() -> + get_languages(#{}). + +get_languages(Opts) -> + case load_languages_cache() of + undefined -> + Response = api_get("/languages", Opts), + Langs = extract_json_array(Response, "languages"), + save_languages_cache(Langs), + Langs; + CachedLanguages -> + CachedLanguages + end. + +%% ============================================================================ +%% Session Functions (9) +%% ============================================================================ + +%% @doc List all sessions. +session_list() -> session_list(#{}). +session_list(Opts) -> api_get("/sessions", Opts). + +%% @doc Get session details. +session_get(SessionId) -> session_get(SessionId, #{}). +session_get(SessionId, Opts) -> + Response = api_get("/sessions/" ++ SessionId, Opts), + #{ + id => SessionId, + status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end, + container_name => extract_json_field(Response, "container_name"), + network_mode => extract_json_field(Response, "network_mode"), + vcpu => extract_json_number(Response, "vcpu"), + created_at => extract_json_number(Response, "created_at") + }. + +%% @doc Create a new session. +session_create() -> session_create(#{}). +session_create(Opts) -> + Shell = maps:get(shell, Opts, "bash"), + Network = maps:get(network, Opts, undefined), + Vcpu = maps:get(vcpu, Opts, undefined), + NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end, + VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end, + Json = "{\"shell\":\"" ++ Shell ++ "\"" ++ NetworkJson ++ VcpuJson ++ "}", + Response = api_post("/sessions", Json, Opts), + extract_json_field(Response, "id"). + +%% @doc Destroy a session. +session_destroy(SessionId) -> session_destroy(SessionId, #{}). +session_destroy(SessionId, Opts) -> + Response = api_delete("/sessions/" ++ SessionId, Opts), + not_contains_error(Response). + +%% @doc Freeze a session. +session_freeze(SessionId) -> session_freeze(SessionId, #{}). +session_freeze(SessionId, Opts) -> + Response = api_post("/sessions/" ++ SessionId ++ "/freeze", "{}", Opts), + not_contains_error(Response). + +%% @doc Unfreeze a session. +session_unfreeze(SessionId) -> session_unfreeze(SessionId, #{}). +session_unfreeze(SessionId, Opts) -> + Response = api_post("/sessions/" ++ SessionId ++ "/unfreeze", "{}", Opts), + not_contains_error(Response). + +%% @doc Boost session resources. +session_boost(SessionId, Vcpu) -> session_boost(SessionId, Vcpu, #{}). +session_boost(SessionId, Vcpu, Opts) -> + Json = "{\"vcpu\":" ++ integer_to_list(Vcpu) ++ "}", + Response = api_patch("/sessions/" ++ SessionId, Json, Opts), + not_contains_error(Response). + +%% @doc Unboost session. +session_unboost(SessionId) -> session_unboost(SessionId, #{}). +session_unboost(SessionId, Opts) -> + Response = api_patch("/sessions/" ++ SessionId, "{\"vcpu\":1}", Opts), + not_contains_error(Response). + +%% @doc Execute a command in a session. +session_execute(SessionId, Command) -> session_execute(SessionId, Command, #{}). +session_execute(SessionId, Command, Opts) -> + Json = "{\"command\":\"" ++ escape_json(Command) ++ "\"}", + Response = api_post("/sessions/" ++ SessionId ++ "/execute", Json, Opts), + parse_result(Response). + +%% ============================================================================ +%% Service Functions (17) +%% ============================================================================ + +%% @doc List all services. +service_list() -> service_list(#{}). +service_list(Opts) -> api_get("/services", Opts). + +%% @doc Get service details. +service_get(ServiceId) -> service_get(ServiceId, #{}). +service_get(ServiceId, Opts) -> + Response = api_get("/services/" ++ ServiceId, Opts), + #{ + id => ServiceId, + name => extract_json_field(Response, "name"), + status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end, + ports => extract_json_field(Response, "ports"), + domains => extract_json_field(Response, "domains"), + vcpu => extract_json_number(Response, "vcpu"), + locked => extract_json_field(Response, "locked") == "true", + unfreeze_on_demand => extract_json_field(Response, "unfreeze_on_demand") == "true", + created_at => extract_json_number(Response, "created_at") + }. + +%% @doc Create a new service. +service_create(Name) -> service_create(Name, #{}). +service_create(Name, Opts) -> + Ports = maps:get(ports, Opts, undefined), + Bootstrap = maps:get(bootstrap, Opts, undefined), + Network = maps:get(network, Opts, undefined), + Vcpu = maps:get(vcpu, Opts, undefined), + PortsJson = case Ports of undefined -> ""; P -> ",\"ports\":[" ++ P ++ "]" end, + BootstrapJson = case Bootstrap of undefined -> ""; B -> ",\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end, + NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end, + VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end, + Json = "{\"name\":\"" ++ escape_json(Name) ++ "\"" ++ PortsJson ++ BootstrapJson ++ NetworkJson ++ VcpuJson ++ "}", + Response = api_post("/services", Json, Opts), + extract_json_field(Response, "id"). + +%% @doc Destroy a service. +service_destroy(ServiceId) -> service_destroy(ServiceId, #{}). +service_destroy(ServiceId, Opts) -> + Response = api_delete("/services/" ++ ServiceId, Opts), + not_contains_error(Response). + +%% @doc Freeze a service. +service_freeze(ServiceId) -> service_freeze(ServiceId, #{}). +service_freeze(ServiceId, Opts) -> + Response = api_post("/services/" ++ ServiceId ++ "/freeze", "{}", Opts), + not_contains_error(Response). + +%% @doc Unfreeze a service. +service_unfreeze(ServiceId) -> service_unfreeze(ServiceId, #{}). +service_unfreeze(ServiceId, Opts) -> + Response = api_post("/services/" ++ ServiceId ++ "/unfreeze", "{}", Opts), + not_contains_error(Response). + +%% @doc Lock a service. +service_lock(ServiceId) -> service_lock(ServiceId, #{}). +service_lock(ServiceId, Opts) -> + Response = api_post("/services/" ++ ServiceId ++ "/lock", "{}", Opts), + not_contains_error(Response). + +%% @doc Unlock a service. +service_unlock(ServiceId) -> service_unlock(ServiceId, #{}). +service_unlock(ServiceId, Opts) -> + Response = api_post("/services/" ++ ServiceId ++ "/unlock", "{}", Opts), + not_contains_error(Response). + +%% @doc Set unfreeze-on-demand for a service. +service_set_unfreeze_on_demand(ServiceId, Enabled) -> service_set_unfreeze_on_demand(ServiceId, Enabled, #{}). +service_set_unfreeze_on_demand(ServiceId, Enabled, Opts) -> + EnabledStr = if Enabled -> "true"; true -> "false" end, + Json = "{\"unfreeze_on_demand\":" ++ EnabledStr ++ "}", + Response = api_patch("/services/" ++ ServiceId, Json, Opts), + not_contains_error(Response). + +%% @doc Redeploy a service. +service_redeploy(ServiceId) -> service_redeploy(ServiceId, undefined, #{}). +service_redeploy(ServiceId, Bootstrap) -> service_redeploy(ServiceId, Bootstrap, #{}). +service_redeploy(ServiceId, Bootstrap, Opts) -> + BootstrapJson = case Bootstrap of undefined -> ""; B -> "\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end, + Json = "{" ++ BootstrapJson ++ "}", + Response = api_post("/services/" ++ ServiceId ++ "/redeploy", Json, Opts), + not_contains_error(Response). + +%% @doc Get service logs. +service_logs(ServiceId) -> service_logs(ServiceId, #{}). +service_logs(ServiceId, Opts) -> + AllLogs = maps:get(all_logs, Opts, false), + Endpoint = if AllLogs -> "/services/" ++ ServiceId ++ "/logs?all=true"; true -> "/services/" ++ ServiceId ++ "/logs" end, + api_get(Endpoint, Opts). + +%% @doc Execute a command in a service. +service_execute(ServiceId, Command) -> service_execute(ServiceId, Command, #{}). +service_execute(ServiceId, Command, Opts) -> + TimeoutMs = maps:get(timeout_ms, Opts, undefined), + TimeoutJson = case TimeoutMs of undefined -> ""; T -> ",\"timeout_ms\":" ++ integer_to_list(T) end, + Json = "{\"command\":\"" ++ escape_json(Command) ++ "\"" ++ TimeoutJson ++ "}", + Response = api_post("/services/" ++ ServiceId ++ "/execute", Json, Opts), + parse_result(Response). + +%% @doc Get service environment vault. +service_env_get(ServiceId) -> service_env_get(ServiceId, #{}). +service_env_get(ServiceId, Opts) -> + api_get("/services/" ++ ServiceId ++ "/env", Opts). + +%% @doc Set service environment vault. +service_env_set(ServiceId, EnvContent) -> service_env_set(ServiceId, EnvContent, #{}). +service_env_set(ServiceId, EnvContent, Opts) -> + api_put_text("/services/" ++ ServiceId ++ "/env", EnvContent, Opts). + +%% @doc Delete service environment vault. +service_env_delete(ServiceId) -> service_env_delete(ServiceId, #{}). +service_env_delete(ServiceId, Opts) -> + Response = api_delete("/services/" ++ ServiceId ++ "/env", Opts), + not_contains_error(Response). + +%% @doc Export service environment vault. +service_env_export(ServiceId) -> service_env_export(ServiceId, #{}). +service_env_export(ServiceId, Opts) -> + Response = api_post("/services/" ++ ServiceId ++ "/env/export", "{}", Opts), + extract_json_field(Response, "content"). + +%% @doc Resize a service. +service_resize(ServiceId, Vcpu) -> service_resize(ServiceId, Vcpu, #{}). +service_resize(ServiceId, Vcpu, Opts) -> + Json = "{\"vcpu\":" ++ integer_to_list(Vcpu) ++ "}", + Response = api_patch("/services/" ++ ServiceId, Json, Opts), + not_contains_error(Response). + +%% ============================================================================ +%% Snapshot Functions (9) +%% ============================================================================ + +%% @doc List all snapshots. +snapshot_list() -> snapshot_list(#{}). +snapshot_list(Opts) -> api_get("/snapshots", Opts). + +%% @doc Get snapshot details. +snapshot_get(SnapshotId) -> snapshot_get(SnapshotId, #{}). +snapshot_get(SnapshotId, Opts) -> + Response = api_get("/snapshots/" ++ SnapshotId, Opts), + #{ + id => SnapshotId, + name => extract_json_field(Response, "name"), + type => case extract_json_field(Response, "type") of "" -> "unknown"; T -> T end, + source_id => extract_json_field(Response, "source_id"), + hot => extract_json_field(Response, "hot") == "true", + locked => extract_json_field(Response, "locked") == "true", + created_at => extract_json_number(Response, "created_at"), + size_bytes => extract_json_number(Response, "size_bytes") + }. + +%% @doc Create a snapshot of a session. +snapshot_session(SessionId) -> snapshot_session(SessionId, #{}). +snapshot_session(SessionId, Opts) -> + Name = maps:get(name, Opts, undefined), + Hot = maps:get(hot, Opts, false), + NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"," end, + HotJson = if Hot -> "\"hot\":true"; true -> "\"hot\":false" end, + Json = "{" ++ NameJson ++ HotJson ++ "}", + Response = api_post("/sessions/" ++ SessionId ++ "/snapshot", Json, Opts), + extract_json_field(Response, "id"). + +%% @doc Create a snapshot of a service. +snapshot_service(ServiceId) -> snapshot_service(ServiceId, #{}). +snapshot_service(ServiceId, Opts) -> + Name = maps:get(name, Opts, undefined), + Hot = maps:get(hot, Opts, false), + NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"," end, + HotJson = if Hot -> "\"hot\":true"; true -> "\"hot\":false" end, + Json = "{" ++ NameJson ++ HotJson ++ "}", + Response = api_post("/services/" ++ ServiceId ++ "/snapshot", Json, Opts), + extract_json_field(Response, "id"). + +%% @doc Restore from a snapshot. +snapshot_restore(SnapshotId) -> snapshot_restore(SnapshotId, #{}). +snapshot_restore(SnapshotId, Opts) -> + Response = api_post("/snapshots/" ++ SnapshotId ++ "/restore", "{}", Opts), + extract_json_field(Response, "id"). + +%% @doc Delete a snapshot. +snapshot_delete(SnapshotId) -> snapshot_delete(SnapshotId, #{}). +snapshot_delete(SnapshotId, Opts) -> + Response = api_delete("/snapshots/" ++ SnapshotId, Opts), + not_contains_error(Response). + +%% @doc Lock a snapshot. +snapshot_lock(SnapshotId) -> snapshot_lock(SnapshotId, #{}). +snapshot_lock(SnapshotId, Opts) -> + Response = api_post("/snapshots/" ++ SnapshotId ++ "/lock", "{}", Opts), + not_contains_error(Response). + +%% @doc Unlock a snapshot. +snapshot_unlock(SnapshotId) -> snapshot_unlock(SnapshotId, #{}). +snapshot_unlock(SnapshotId, Opts) -> + Response = api_post("/snapshots/" ++ SnapshotId ++ "/unlock", "{}", Opts), + not_contains_error(Response). + +%% @doc Clone a snapshot to create a new session or service. +snapshot_clone(SnapshotId, Opts) -> + CloneType = maps:get(type, Opts), + Name = maps:get(name, Opts, undefined), + Ports = maps:get(ports, Opts, undefined), + Shell = maps:get(shell, Opts, undefined), + TypeJson = "\"type\":\"" ++ CloneType ++ "\"", + NameJson = case Name of undefined -> ""; N -> ",\"name\":\"" ++ escape_json(N) ++ "\"" end, + PortsJson = case Ports of undefined -> ""; P -> ",\"ports\":[" ++ P ++ "]" end, + ShellJson = case Shell of undefined -> ""; S -> ",\"shell\":\"" ++ S ++ "\"" end, + Json = "{" ++ TypeJson ++ NameJson ++ PortsJson ++ ShellJson ++ "}", + Response = api_post("/snapshots/" ++ SnapshotId ++ "/clone", Json, Opts), + extract_json_field(Response, "id"). + +%% ============================================================================ +%% Image Functions (13) +%% ============================================================================ + +%% @doc List images. +image_list() -> image_list(#{}). +image_list(Opts) -> + Filter = maps:get(filter, Opts, undefined), + Endpoint = case Filter of undefined -> "/images"; F -> "/images?filter=" ++ F end, + api_get(Endpoint, Opts). + +%% @doc Get image details. +image_get(ImageId) -> image_get(ImageId, #{}). +image_get(ImageId, Opts) -> + Response = api_get("/images/" ++ ImageId, Opts), + #{ + id => ImageId, + name => extract_json_field(Response, "name"), + description => extract_json_field(Response, "description"), + visibility => case extract_json_field(Response, "visibility") of "" -> "private"; V -> V end, + source_type => extract_json_field(Response, "source_type"), + source_id => extract_json_field(Response, "source_id"), + locked => extract_json_field(Response, "locked") == "true", + created_at => extract_json_number(Response, "created_at"), + size_bytes => extract_json_number(Response, "size_bytes") + }. + +%% @doc Publish an image. +image_publish(SourceType, SourceId) -> image_publish(SourceType, SourceId, #{}). +image_publish(SourceType, SourceId, Opts) -> + Name = maps:get(name, Opts, undefined), + Description = maps:get(description, Opts, undefined), + NameJson = case Name of undefined -> ""; N -> ",\"name\":\"" ++ escape_json(N) ++ "\"" end, + DescJson = case Description of undefined -> ""; D -> ",\"description\":\"" ++ escape_json(D) ++ "\"" end, + Json = "{\"source_type\":\"" ++ SourceType ++ "\",\"source_id\":\"" ++ SourceId ++ "\"" ++ NameJson ++ DescJson ++ "}", + Response = api_post("/images/publish", Json, Opts), + extract_json_field(Response, "id"). + +%% @doc Delete an image. +image_delete(ImageId) -> image_delete(ImageId, #{}). +image_delete(ImageId, Opts) -> + Response = api_delete("/images/" ++ ImageId, Opts), + not_contains_error(Response). + +%% @doc Lock an image. +image_lock(ImageId) -> image_lock(ImageId, #{}). +image_lock(ImageId, Opts) -> + Response = api_post("/images/" ++ ImageId ++ "/lock", "{}", Opts), + not_contains_error(Response). + +%% @doc Unlock an image. +image_unlock(ImageId) -> image_unlock(ImageId, #{}). +image_unlock(ImageId, Opts) -> + Response = api_post("/images/" ++ ImageId ++ "/unlock", "{}", Opts), + not_contains_error(Response). + +%% @doc Set image visibility. +image_set_visibility(ImageId, Visibility) -> image_set_visibility(ImageId, Visibility, #{}). +image_set_visibility(ImageId, Visibility, Opts) -> + Json = "{\"visibility\":\"" ++ Visibility ++ "\"}", + Response = api_post("/images/" ++ ImageId ++ "/visibility", Json, Opts), + not_contains_error(Response). + +%% @doc Grant access to an image. +image_grant_access(ImageId, TrustedApiKey) -> image_grant_access(ImageId, TrustedApiKey, #{}). +image_grant_access(ImageId, TrustedApiKey, Opts) -> + Json = "{\"api_key\":\"" ++ TrustedApiKey ++ "\"}", + Response = api_post("/images/" ++ ImageId ++ "/access/grant", Json, Opts), + not_contains_error(Response). + +%% @doc Revoke access to an image. +image_revoke_access(ImageId, TrustedApiKey) -> image_revoke_access(ImageId, TrustedApiKey, #{}). +image_revoke_access(ImageId, TrustedApiKey, Opts) -> + Json = "{\"api_key\":\"" ++ TrustedApiKey ++ "\"}", + Response = api_post("/images/" ++ ImageId ++ "/access/revoke", Json, Opts), + not_contains_error(Response). + +%% @doc List trusted API keys for an image. +image_list_trusted(ImageId) -> image_list_trusted(ImageId, #{}). +image_list_trusted(ImageId, Opts) -> + Response = api_get("/images/" ++ ImageId ++ "/access", Opts), + extract_json_array(Response, "trusted_keys"). + +%% @doc Transfer image ownership. +image_transfer(ImageId, ToApiKey) -> image_transfer(ImageId, ToApiKey, #{}). +image_transfer(ImageId, ToApiKey, Opts) -> + Json = "{\"to_api_key\":\"" ++ ToApiKey ++ "\"}", + Response = api_post("/images/" ++ ImageId ++ "/transfer", Json, Opts), + not_contains_error(Response). + +%% @doc Spawn a service from an image. +image_spawn(ImageId) -> image_spawn(ImageId, #{}). +image_spawn(ImageId, Opts) -> + Name = maps:get(name, Opts, undefined), + Ports = maps:get(ports, Opts, undefined), + Bootstrap = maps:get(bootstrap, Opts, undefined), + Network = maps:get(network, Opts, undefined), + NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"" end, + PortsJson = case Ports of undefined -> ""; P -> (if Name =/= undefined -> ","; true -> "" end) ++ "\"ports\":[" ++ P ++ "]" end, + BootstrapJson = case Bootstrap of undefined -> ""; B -> ",\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end, + NetworkJson = case Network of undefined -> ""; Nn -> ",\"network\":\"" ++ Nn ++ "\"" end, + Json = "{" ++ NameJson ++ PortsJson ++ BootstrapJson ++ NetworkJson ++ "}", + Response = api_post("/images/" ++ ImageId ++ "/spawn", Json, Opts), + extract_json_field(Response, "id"). + +%% @doc Clone an image. +image_clone(ImageId) -> image_clone(ImageId, #{}). +image_clone(ImageId, Opts) -> + Name = maps:get(name, Opts, undefined), + Description = maps:get(description, Opts, undefined), + NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"" end, + DescJson = case Description of undefined -> ""; D -> (if Name =/= undefined -> ","; true -> "" end) ++ "\"description\":\"" ++ escape_json(D) ++ "\"" end, + Json = "{" ++ NameJson ++ DescJson ++ "}", + Response = api_post("/images/" ++ ImageId ++ "/clone", Json, Opts), + extract_json_field(Response, "id"). + +%% ============================================================================ +%% PaaS Logs Functions (2) +%% ============================================================================ + +%% @doc Fetch batch logs from portal. +logs_fetch() -> logs_fetch(#{}). +logs_fetch(Opts) -> + Source = maps:get(source, Opts, "all"), + Lines = maps:get(lines, Opts, 100), + Since = maps:get(since, Opts, "1h"), + Grep = maps:get(grep, Opts, undefined), + GrepParam = case Grep of undefined -> ""; G -> "&grep=" ++ http_uri:encode(G) end, + api_get("/logs?source=" ++ Source ++ "&lines=" ++ integer_to_list(Lines) ++ "&since=" ++ Since ++ GrepParam, Opts). + +%% @doc Stream logs (simplified polling implementation). +logs_stream(Callback) -> logs_stream(Callback, #{}). +logs_stream(Callback, Opts) -> + Source = maps:get(source, Opts, "all"), + Grep = maps:get(grep, Opts, undefined), + Interval = maps:get(interval, Opts, 5000), + GrepParam = case Grep of undefined -> ""; G -> "&grep=" ++ http_uri:encode(G) end, + logs_stream_loop(Source, GrepParam, Callback, Interval, Opts). + +logs_stream_loop(Source, GrepParam, Callback, Interval, Opts) -> + Response = api_get("/logs?source=" ++ Source ++ "&lines=50&since=10s" ++ GrepParam, Opts), + Callback(Source, Response), + timer:sleep(Interval), + logs_stream_loop(Source, GrepParam, Callback, Interval, Opts). + +%% ============================================================================ +%% Key Validation (1) +%% ============================================================================ + +%% @doc Validate API keys. +validate_keys() -> validate_keys(#{}). +validate_keys(Opts) -> + Response = portal_post("/keys/validate", "{}", Opts), + #{ + valid => extract_json_field(Response, "status") == "valid", + tier => extract_json_field(Response, "tier"), + rate_limit_per_minute => extract_json_number(Response, "rate_per_minute"), + concurrency_limit => extract_json_number(Response, "concurrency"), + expires_at => extract_json_number(Response, "expires_at") + }. + +%% ============================================================================ +%% Private API Functions +%% ============================================================================ + +api_get(Endpoint, Opts) -> + {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "GET", Endpoint, ""), + Cmd = "curl -s " ++ ?API_BASE ++ Endpoint ++ AuthHeaders, + Result = os:cmd(Cmd), + check_clock_drift_error(Result), + Result. + +api_post(Endpoint, Json, Opts) -> + {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), + TmpFile = write_temp_file(Json), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json), + Cmd = "curl -s -X POST " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile, + Result = os:cmd(Cmd), + file:delete(TmpFile), + check_clock_drift_error(Result), + Result. + +api_delete(Endpoint, Opts) -> + {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "DELETE", Endpoint, ""), + Cmd = "curl -s -X DELETE " ++ ?API_BASE ++ Endpoint ++ AuthHeaders, + Result = os:cmd(Cmd), + check_clock_drift_error(Result), + Result. + +api_patch(Endpoint, Json, Opts) -> + {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), + TmpFile = write_temp_file(Json), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PATCH", Endpoint, Json), + Cmd = "curl -s -X PATCH " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile, + Result = os:cmd(Cmd), + file:delete(TmpFile), + check_clock_drift_error(Result), + Result. + +api_put_text(Endpoint, Body, Opts) -> + {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), + TmpFile = write_temp_file(Body), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PUT", Endpoint, Body), + Cmd = "curl -s -o /dev/null -w '%{http_code}' -X PUT " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: text/plain'" ++ AuthHeaders ++ " -d @" ++ TmpFile, + Result = os:cmd(Cmd), + file:delete(TmpFile), + StatusCode = list_to_integer(string:trim(Result)), + StatusCode >= 200 andalso StatusCode < 300. + +portal_post(Endpoint, Json, Opts) -> + {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), + TmpFile = write_temp_file(Json), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json), + Cmd = "curl -s -X POST " ++ ?PORTAL_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile, + Result = os:cmd(Cmd), + file:delete(TmpFile), + check_clock_drift_error(Result), + Result. + +get_api_keys_from_opts(Opts) -> + case {maps:get(public_key, Opts, undefined), maps:get(secret_key, Opts, undefined)} of + {Pk, Sk} when Pk =/= undefined, Sk =/= undefined -> {Pk, Sk}; + _ -> get_api_keys() + end. + +build_execute_json_full(Language, Code, Opts) -> + Network = maps:get(network, Opts, undefined), + Vcpu = maps:get(vcpu, Opts, undefined), + Ttl = maps:get(ttl, Opts, undefined), + ReturnArtifacts = maps:get(return_artifacts, Opts, false), + NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end, + VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end, + TtlJson = case Ttl of undefined -> ""; T -> ",\"ttl\":" ++ integer_to_list(T) end, + ArtifactsJson = if ReturnArtifacts -> ",\"return_artifacts\":true"; true -> "" end, + "{\"language\":\"" ++ Language ++ "\",\"code\":\"" ++ escape_json(Code) ++ "\"" ++ NetworkJson ++ VcpuJson ++ TtlJson ++ ArtifactsJson ++ "}". + +parse_result(Response) -> + ExitCode = case extract_json_number(Response, "exit_code") of 0 -> 0; N when is_integer(N) -> N; _ -> 0 end, + #{ + success => ExitCode == 0, + stdout => case extract_json_field(Response, "stdout") of "" -> ""; S -> S end, + stderr => case extract_json_field(Response, "stderr") of "" -> ""; S -> S end, + exit_code => ExitCode, + job_id => extract_json_field(Response, "job_id"), + language => extract_json_field(Response, "language"), + execution_time => undefined + }. + +not_contains_error(Response) -> + string:str(Response, "\"error\"") == 0. + +%% ============================================================================ +%% CLI Entry Point +%% ============================================================================ main([]) -> io:format("Usage: un.erl [options] ~n"), diff --git a/clients/erlang/sync/tests/test_functional.erl b/clients/erlang/sync/tests/test_functional.erl new file mode 100755 index 0000000..788191f --- /dev/null +++ b/clients/erlang/sync/tests/test_functional.erl @@ -0,0 +1,148 @@ +#!/usr/bin/env escript +%% -*- erlang -*- +%% +%% Functional Tests for Un Erlang SDK +%% +%% Run with: escript test_functional.erl +%% Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables +%% +%% These tests make real API calls to api.unsandbox.com + +-mode(compile). + +main([]) -> + io:format("\n\033[34m=== Un Erlang SDK Functional Tests ===\033[0m\n\n"), + + %% Check for credentials + case {os:getenv("UNSANDBOX_PUBLIC_KEY"), os:getenv("UNSANDBOX_SECRET_KEY")} of + {false, _} -> + io:format("\033[33mSKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set\033[0m\n"), + halt(0); + {_, false} -> + io:format("\033[33mSKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set\033[0m\n"), + halt(0); + _ -> + ok + end, + + Tests = [ + {"health_check", fun test_health_check/0}, + {"validate_keys", fun test_validate_keys/0}, + {"execute_python", fun test_execute_python/0}, + {"execute_with_error", fun test_execute_with_error/0}, + {"session_list", fun test_session_list/0}, + {"service_list", fun test_service_list/0}, + {"snapshot_list", fun test_snapshot_list/0}, + {"image_list", fun test_image_list/0}, + {"get_languages", fun test_get_languages/0} + ], + + Results = run_tests(Tests, []), + {Passed, Failed} = count_results(Results, 0, 0), + Total = length(Results), + + io:format("\n\033[34mResults: ~p/~p passed\033[0m\n", [Passed, Total]), + case Failed > 0 of + true -> + io:format("\033[31m~p test(s) failed\033[0m\n", [Failed]), + halt(1); + false -> + io:format("\033[32mAll functional tests passed!\033[0m\n") + end. + +run_tests([], Acc) -> + lists:reverse(Acc); +run_tests([{Name, TestFn} | Rest], Acc) -> + io:format(" Running ~s... ", [Name]), + Result = try + TestFn(), + io:format("\033[32mPASS\033[0m\n"), + pass + catch + _:Error -> + io:format("\033[31mFAIL\033[0m\n"), + io:format(" ~p\n", [Error]), + fail + end, + run_tests(Rest, [Result | Acc]). + +count_results([], Passed, Failed) -> + {Passed, Failed}; +count_results([pass | Rest], Passed, Failed) -> + count_results(Rest, Passed + 1, Failed); +count_results([fail | Rest], Passed, Failed) -> + count_results(Rest, Passed, Failed + 1). + +%% ============================================================================ +%% Functional Tests +%% ============================================================================ + +test_health_check() -> + Result = un:health_check(), + true = is_boolean(Result), + ok. + +test_validate_keys() -> + KeyInfo = un:validate_keys(), + true = is_map(KeyInfo), + true = maps:is_key(valid, KeyInfo), + true = is_boolean(maps:get(valid, KeyInfo)), + ok. + +test_execute_python() -> + Result = un:execute("python", "print(6 * 7)"), + true = is_map(Result), + true = maps:is_key(success, Result), + true = maps:is_key(stdout, Result), + true = maps:is_key(exit_code, Result), + + %% Check output + true = maps:get(success, Result), + Stdout = maps:get(stdout, Result), + true = string:find(Stdout, "42") =/= nomatch, + 0 = maps:get(exit_code, Result), + ok. + +test_execute_with_error() -> + Result = un:execute("python", "import sys; sys.exit(1)"), + true = is_map(Result), + false = maps:get(success, Result), + 1 = maps:get(exit_code, Result), + ok. + +test_session_list() -> + Response = un:session_list(), + true = is_list(Response), + %% Response should be valid JSON (starts with [ or {) + Trimmed = string:trim(Response), + true = (string:prefix(Trimmed, "[") =/= nomatch) orelse (string:prefix(Trimmed, "{") =/= nomatch), + ok. + +test_service_list() -> + Response = un:service_list(), + true = is_list(Response), + Trimmed = string:trim(Response), + true = (string:prefix(Trimmed, "[") =/= nomatch) orelse (string:prefix(Trimmed, "{") =/= nomatch), + ok. + +test_snapshot_list() -> + Response = un:snapshot_list(), + true = is_list(Response), + Trimmed = string:trim(Response), + true = (string:prefix(Trimmed, "[") =/= nomatch) orelse (string:prefix(Trimmed, "{") =/= nomatch), + ok. + +test_image_list() -> + Response = un:image_list(), + true = is_list(Response), + Trimmed = string:trim(Response), + true = (string:prefix(Trimmed, "[") =/= nomatch) orelse (string:prefix(Trimmed, "{") =/= nomatch), + ok. + +test_get_languages() -> + Languages = un:get_languages(), + true = is_list(Languages), + true = length(Languages) > 0, + true = lists:member("python", Languages), + true = lists:member("javascript", Languages), + ok. diff --git a/clients/erlang/sync/tests/test_library.erl b/clients/erlang/sync/tests/test_library.erl new file mode 100755 index 0000000..0a59ee4 --- /dev/null +++ b/clients/erlang/sync/tests/test_library.erl @@ -0,0 +1,109 @@ +#!/usr/bin/env escript +%% -*- erlang -*- +%% +%% Unit Tests for Un Erlang SDK Library Functions +%% +%% Run with: escript test_library.erl +%% No credentials required - tests pure library functions only. + +-mode(compile). + +main([]) -> + io:format("\n\033[34m=== Un Erlang SDK Library Tests ===\033[0m\n\n"), + + Tests = [ + {"version", fun test_version/0}, + {"detect_language", fun test_detect_language/0}, + {"hmac_sign", fun test_hmac_sign/0}, + {"hmac_sign_deterministic", fun test_hmac_sign_deterministic/0}, + {"hmac_sign_different_secrets", fun test_hmac_sign_different_secrets/0} + ], + + Results = run_tests(Tests, []), + {Passed, Failed} = count_results(Results, 0, 0), + Total = length(Results), + + io:format("\n\033[34mResults: ~p/~p passed\033[0m\n", [Passed, Total]), + case Failed > 0 of + true -> + io:format("\033[31m~p test(s) failed\033[0m\n", [Failed]), + halt(1); + false -> + io:format("\033[32mAll tests passed!\033[0m\n") + end. + +run_tests([], Acc) -> + lists:reverse(Acc); +run_tests([{Name, TestFn} | Rest], Acc) -> + Result = try + TestFn(), + io:format("\033[32mPASS\033[0m: ~s\n", [Name]), + pass + catch + _:Error -> + io:format("\033[31mFAIL\033[0m: ~s - ~p\n", [Name, Error]), + fail + end, + run_tests(Rest, [Result | Acc]). + +count_results([], Passed, Failed) -> + {Passed, Failed}; +count_results([pass | Rest], Passed, Failed) -> + count_results(Rest, Passed + 1, Failed); +count_results([fail | Rest], Passed, Failed) -> + count_results(Rest, Passed, Failed + 1). + +%% ============================================================================ +%% Unit Tests +%% ============================================================================ + +test_version() -> + Version = un:version(), + true = is_list(Version), + %% Should be semver format X.Y.Z + [_Major, _Minor, _Patch] = string:tokens(Version, "."), + ok. + +test_detect_language() -> + %% Test common extensions + {ok, "python"} = un:ext_to_lang(".py"), + {ok, "javascript"} = un:ext_to_lang(".js"), + {ok, "go"} = un:ext_to_lang(".go"), + {ok, "rust"} = un:ext_to_lang(".rs"), + {ok, "c"} = un:ext_to_lang(".c"), + {ok, "cpp"} = un:ext_to_lang(".cpp"), + {ok, "java"} = un:ext_to_lang(".java"), + {ok, "ruby"} = un:ext_to_lang(".rb"), + {ok, "bash"} = un:ext_to_lang(".sh"), + {ok, "lua"} = un:ext_to_lang(".lua"), + {ok, "perl"} = un:ext_to_lang(".pl"), + {ok, "php"} = un:ext_to_lang(".php"), + {ok, "haskell"} = un:ext_to_lang(".hs"), + {ok, "ocaml"} = un:ext_to_lang(".ml"), + {ok, "elixir"} = un:ext_to_lang(".ex"), + {ok, "erlang"} = un:ext_to_lang(".erl"), + + %% Test unknown extensions + {error, _} = un:ext_to_lang(".unknown"), + {error, _} = un:ext_to_lang(""), + ok. + +test_hmac_sign() -> + Signature = un:hmac_sign("my_secret", "test message"), + true = is_list(Signature), + 64 = length(Signature), + %% Should be lowercase hex + true = lists:all(fun(C) -> (C >= $0 andalso C =< $9) orelse (C >= $a andalso C =< $f) end, Signature), + ok. + +test_hmac_sign_deterministic() -> + Sig1 = un:hmac_sign("test_secret", "same message"), + Sig2 = un:hmac_sign("test_secret", "same message"), + Sig1 = Sig2, %% Pattern match ensures equality + ok. + +test_hmac_sign_different_secrets() -> + Sig1 = un:hmac_sign("secret1", "test message"), + Sig2 = un:hmac_sign("secret2", "test message"), + true = Sig1 =/= Sig2, %% Different secrets should produce different signatures + ok. diff --git a/clients/forth/sync/src/un.forth b/clients/forth/sync/src/un.forth index dbf93a2..82b1d99 100644 --- a/clients/forth/sync/src/un.forth +++ b/clients/forth/sync/src/un.forth @@ -1369,6 +1369,382 @@ s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh \"$@\" && rm -f /tmp/unsandbox_cmd.sh" system ; +\ Image grant access +: image-grant-access ( image-id-addr image-id-len key-addr key-len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" IMAGE_ID='" r@ write-file throw + 2over r@ write-file throw + s" '" r@ write-line throw + s" TRUSTED_KEY='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" BODY='{\"trusted_api_key\":\"'$TRUSTED_KEY'\"}'" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/grant-access:$BODY\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/images/$IMAGE_ID/grant-access -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" >/dev/null && echo -e \"\\x1b[32mAccess granted to $TRUSTED_KEY\\x1b[0m\"" r@ write-line throw + r> close-file throw + 2drop 2drop \ clean up the stack + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Image revoke access +: image-revoke-access ( image-id-addr image-id-len key-addr key-len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" IMAGE_ID='" r@ write-file throw + 2over r@ write-file throw + s" '" r@ write-line throw + s" TRUSTED_KEY='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" BODY='{\"trusted_api_key\":\"'$TRUSTED_KEY'\"}'" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/revoke-access:$BODY\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/images/$IMAGE_ID/revoke-access -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" >/dev/null && echo -e \"\\x1b[32mAccess revoked from $TRUSTED_KEY\\x1b[0m\"" r@ write-line throw + r> close-file throw + 2drop 2drop \ clean up the stack + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Image list trusted +: image-list-trusted ( addr len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" IMAGE_ID='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:GET:/images/$IMAGE_ID/trusted:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X GET https://api.unsandbox.com/images/$IMAGE_ID/trusted -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq ." r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Snapshot list +: snapshot-list ( -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:GET:/snapshots:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X GET https://api.unsandbox.com/snapshots -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq ." r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Snapshot info +: snapshot-info ( addr len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" SNAPSHOT_ID='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:GET:/snapshots/$SNAPSHOT_ID:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X GET https://api.unsandbox.com/snapshots/$SNAPSHOT_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq ." r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Snapshot restore +: snapshot-restore ( addr len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" SNAPSHOT_ID='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/snapshots/$SNAPSHOT_ID/restore:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/snapshots/$SNAPSHOT_ID/restore -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mSnapshot restored: " r@ write-file throw + r@ write-file throw + s" \\x1b[0m'" r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Snapshot delete +: snapshot-delete ( addr len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" SNAPSHOT_ID='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:DELETE:/snapshots/$SNAPSHOT_ID:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/snapshots/$SNAPSHOT_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\")" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw + s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw + s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw + s" echo -n 'Enter OTP: ' >&2" r@ write-line throw + s" read OTP" r@ write-line throw + s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:DELETE:/snapshots/$SNAPSHOT_ID:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/snapshots/$SNAPSHOT_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\")" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" fi" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw + s" echo -e '\\x1b[32mSnapshot deleted: " r@ write-file throw + r@ write-file throw + s" \\x1b[0m'" r@ write-line throw + s" else" r@ write-line throw + s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw + s" echo \"$BODY\" >&2" r@ write-line throw + s" exit 1" r@ write-line throw + s" fi" r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Snapshot lock +: snapshot-lock ( addr len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" SNAPSHOT_ID='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/snapshots/$SNAPSHOT_ID/lock:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/snapshots/$SNAPSHOT_ID/lock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mSnapshot locked: " r@ write-file throw + r@ write-file throw + s" \\x1b[0m'" r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Snapshot unlock +: snapshot-unlock ( addr len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" SNAPSHOT_ID='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/snapshots/$SNAPSHOT_ID/unlock:{}\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com/snapshots/$SNAPSHOT_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: application/json' -d '{}')" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw + s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw + s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw + s" echo -n 'Enter OTP: ' >&2" r@ write-line throw + s" read OTP" r@ write-line throw + s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/snapshots/$SNAPSHOT_ID/unlock:{}\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" RESP=$(curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com/snapshots/$SNAPSHOT_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: application/json' -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\" -d '{}')" r@ write-line throw + s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw + s" fi" r@ write-line throw + s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw + s" echo -e '\\x1b[32mSnapshot unlocked: " r@ write-file throw + r@ write-file throw + s" \\x1b[0m'" r@ write-line throw + s" else" r@ write-line throw + s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw + s" echo \"$BODY\" >&2" r@ write-line throw + s" exit 1" r@ write-line throw + s" fi" r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Snapshot clone +: snapshot-clone ( addr len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" SNAPSHOT_ID='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" CLONE_TYPE='session'; NAME=''; PORTS=''; SHELL=''" r@ write-line throw + s" i=4" r@ write-line throw + s" while [ $i -le $# ]; do" r@ write-line throw + s" arg=${!i}" r@ write-line throw + s" case \"$arg\" in" r@ write-line throw + s" --type) ((i++)); CLONE_TYPE=${!i} ;;" r@ write-line throw + s" --name) ((i++)); NAME=${!i} ;;" r@ write-line throw + s" --ports) ((i++)); PORTS=${!i} ;;" r@ write-line throw + s" --shell) ((i++)); SHELL=${!i} ;;" r@ write-line throw + s" esac" r@ write-line throw + s" ((i++))" r@ write-line throw + s" done" r@ write-line throw + s" BODY='{\"clone_type\":\"'$CLONE_TYPE'\"}'" r@ write-line throw + s" [ -n \"$NAME\" ] && BODY=$(echo $BODY | jq --arg n \"$NAME\" '. + {name: $n}')" r@ write-line throw + s" [ -n \"$PORTS\" ] && BODY=$(echo $BODY | jq --arg p \"$PORTS\" '. + {ports: ($p | split(\",\") | map(tonumber))}')" r@ write-line throw + s" [ -n \"$SHELL\" ] && BODY=$(echo $BODY | jq --arg s \"$SHELL\" '. + {shell: $s}')" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/snapshots/$SNAPSHOT_ID/clone:$BODY\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/snapshots/$SNAPSHOT_ID/clone -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" | jq ." r@ write-line throw + s" echo -e '\\x1b[32mSnapshot cloned\\x1b[0m'" r@ write-line throw + r> close-file throw + s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh \"$@\" && rm -f /tmp/unsandbox_cmd.sh" system +; + +\ Handle snapshot subcommand +: handle-snapshot ( -- ) + argc @ 3 < if + snapshot-list + 0 (bye) + then + + 2 arg 2dup s" --list" compare 0= if + 2drop snapshot-list + 0 (bye) + then + + 2dup s" -l" compare 0= if + 2drop snapshot-list + 0 (bye) + then + + 2dup s" --info" compare 0= if + 2drop + argc @ 4 < if + s" Error: --info requires snapshot ID" type cr + 1 (bye) + then + 3 arg snapshot-info + 0 (bye) + then + + 2dup s" --restore" compare 0= if + 2drop + argc @ 4 < if + s" Error: --restore requires snapshot ID" type cr + 1 (bye) + then + 3 arg snapshot-restore + 0 (bye) + then + + 2dup s" --delete" compare 0= if + 2drop + argc @ 4 < if + s" Error: --delete requires snapshot ID" type cr + 1 (bye) + then + 3 arg snapshot-delete + 0 (bye) + then + + 2dup s" --lock" compare 0= if + 2drop + argc @ 4 < if + s" Error: --lock requires snapshot ID" type cr + 1 (bye) + then + 3 arg snapshot-lock + 0 (bye) + then + + 2dup s" --unlock" compare 0= if + 2drop + argc @ 4 < if + s" Error: --unlock requires snapshot ID" type cr + 1 (bye) + then + 3 arg snapshot-unlock + 0 (bye) + then + + 2dup s" --clone" compare 0= if + 2drop + argc @ 4 < if + s" Error: --clone requires snapshot ID" type cr + 1 (bye) + then + 3 arg snapshot-clone + 0 (bye) + then + + 2drop + s" Error: Use --list, --info ID, --restore ID, --delete ID, --lock ID, --unlock ID, or --clone ID" type cr + 1 (bye) +; + \ Handle image subcommand : handle-image ( -- ) argc @ 3 < if @@ -1503,6 +1879,11 @@ 0 (bye) then + 2dup s" snapshot" compare 0= if + 2drop handle-snapshot + 0 (bye) + then + 2dup s" key" compare 0= if 2drop handle-key 0 (bye) diff --git a/clients/fortran/sync/src/un.f90 b/clients/fortran/sync/src/un.f90 index 61f0e21..c01b23f 100644 --- a/clients/fortran/sync/src/un.f90 +++ b/clients/fortran/sync/src/un.f90 @@ -891,6 +891,9 @@ program unsandbox_cli else if (trim(arg) == 'image') then call handle_image() stop 0 + else if (trim(arg) == 'snapshot') then + call handle_snapshot() + stop 0 else ! Default execute command filename = trim(arg) @@ -907,6 +910,7 @@ contains write(*, '(A)') 'Usage: ./un [options] ' write(*, '(A)') ' ./un session [options]' write(*, '(A)') ' ./un service [options]' + write(*, '(A)') ' ./un snapshot [options]' write(*, '(A)') ' ./un image [options]' write(*, '(A)') ' ./un key [--extend]' write(*, '(A)') ' ./un languages [--json]' @@ -937,6 +941,19 @@ contains write(*, '(A)') ' service env export Export vault' write(*, '(A)') ' service env delete Delete vault' write(*, '(A)') '' + write(*, '(A)') 'Snapshot options:' + write(*, '(A)') ' -l, --list List all snapshots' + write(*, '(A)') ' --info ID Get snapshot details' + write(*, '(A)') ' --delete ID Delete a snapshot' + write(*, '(A)') ' --lock ID Lock snapshot' + write(*, '(A)') ' --unlock ID Unlock snapshot' + write(*, '(A)') ' --restore ID Restore from snapshot' + write(*, '(A)') ' --clone ID Clone snapshot (requires --type)' + write(*, '(A)') ' --type TYPE Clone type: session or service' + write(*, '(A)') ' --name NAME Name for cloned resource' + write(*, '(A)') ' --shell SHELL Shell for cloned session' + write(*, '(A)') ' --ports PORTS Ports for cloned service' + write(*, '(A)') '' write(*, '(A)') 'Image options:' write(*, '(A)') ' -l, --list List all images' write(*, '(A)') ' --info ID Get image details' @@ -1530,6 +1547,214 @@ contains end if end subroutine handle_service + subroutine handle_snapshot() + character(len=8192) :: full_cmd + character(len=256) :: arg, snapshot_id, operation, clone_type, name, ports, shell + character(len=1024) :: public_key, secret_key + integer :: i, stat + logical :: list_mode + + snapshot_id = '' + operation = '' + clone_type = '' + name = '' + ports = '' + shell = '' + list_mode = .false. + + ! Parse arguments + do i = 2, command_argument_count() + call get_command_argument(i, arg) + if (trim(arg) == '-l' .or. trim(arg) == '--list') then + list_mode = .true. + else if (trim(arg) == '--info') then + operation = 'info' + if (i < command_argument_count()) then + call get_command_argument(i + 1, snapshot_id) + end if + else if (trim(arg) == '--delete') then + operation = 'delete' + if (i < command_argument_count()) then + call get_command_argument(i + 1, snapshot_id) + end if + else if (trim(arg) == '--lock') then + operation = 'lock' + if (i < command_argument_count()) then + call get_command_argument(i + 1, snapshot_id) + end if + else if (trim(arg) == '--unlock') then + operation = 'unlock' + if (i < command_argument_count()) then + call get_command_argument(i + 1, snapshot_id) + end if + else if (trim(arg) == '--restore') then + operation = 'restore' + if (i < command_argument_count()) then + call get_command_argument(i + 1, snapshot_id) + end if + else if (trim(arg) == '--clone') then + operation = 'clone' + if (i < command_argument_count()) then + call get_command_argument(i + 1, snapshot_id) + end if + else if (trim(arg) == '--type') then + if (i < command_argument_count()) then + call get_command_argument(i + 1, clone_type) + end if + else if (trim(arg) == '--name') then + if (i < command_argument_count()) then + call get_command_argument(i + 1, name) + end if + else if (trim(arg) == '--ports') then + if (i < command_argument_count()) then + call get_command_argument(i + 1, ports) + end if + else if (trim(arg) == '--shell') then + if (i < command_argument_count()) then + call get_command_argument(i + 1, shell) + end if + end if + end do + + ! Get credentials + call get_credentials(public_key, secret_key, stat) + if (stat /= 0) then + write(0, '(A)') 'Error: No credentials found' + stop 1 + end if + + if (list_mode) then + ! List snapshots + write(full_cmd, '(20A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:GET:/snapshots:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X GET https://api.unsandbox.com/snapshots ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" | jq .' + call execute_command_line(trim(full_cmd), wait=.true.) + else if (trim(operation) == 'info' .and. len_trim(snapshot_id) > 0) then + ! Get snapshot info + write(full_cmd, '(20A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:GET:/snapshots/', trim(snapshot_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X GET https://api.unsandbox.com/snapshots/', trim(snapshot_id), ' ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" | jq .' + call execute_command_line(trim(full_cmd), wait=.true.) + else if (trim(operation) == 'delete' .and. len_trim(snapshot_id) > 0) then + ! Delete snapshot (with sudo) + write(full_cmd, '(30A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:DELETE:/snapshots/', trim(snapshot_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'RESP=$(curl -s -w "\n%{http_code}" -X DELETE https://api.unsandbox.com/snapshots/', trim(snapshot_id), ' ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG"); ', & + 'HTTP_CODE=$(echo "$RESP" | tail -1); ', & + 'BODY=$(echo "$RESP" | head -n -1); ', & + 'if [ "$HTTP_CODE" = "428" ]; then ', & + 'OTP=$(echo "$BODY" | jq -r ".otp // empty"); ', & + 'if [ -n "$OTP" ]; then ', & + 'TS2=$(date +%s); ', & + 'SIG2=$(echo -n "$TS2:DELETE:/snapshots/', trim(snapshot_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X DELETE https://api.unsandbox.com/snapshots/', trim(snapshot_id), ' ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS2" ', & + '-H "X-Signature: $SIG2" ', & + '-H "X-Sudo-OTP: $OTP" | jq .; ', & + 'echo -e "\x1b[32mSnapshot deleted\x1b[0m"; fi; ', & + 'else echo "$BODY" | jq .; fi' + call execute_command_line(trim(full_cmd), wait=.true.) + else if (trim(operation) == 'lock' .and. len_trim(snapshot_id) > 0) then + ! Lock snapshot + write(full_cmd, '(20A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/snapshots/', trim(snapshot_id), '/lock:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/lock ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" | jq . && ', & + 'echo -e "\x1b[32mSnapshot locked\x1b[0m"' + call execute_command_line(trim(full_cmd), wait=.true.) + else if (trim(operation) == 'unlock' .and. len_trim(snapshot_id) > 0) then + ! Unlock snapshot (with sudo) + write(full_cmd, '(30A)') & + 'TS=$(date +%s); ', & + 'BODY="{}"; ', & + 'SIG=$(echo -n "$TS:POST:/snapshots/', trim(snapshot_id), '/unlock:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'RESP=$(curl -s -w "\n%{http_code}" -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/unlock ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-H "Content-Type: application/json" ', & + '-d "$BODY"); ', & + 'HTTP_CODE=$(echo "$RESP" | tail -1); ', & + 'BODY_RESP=$(echo "$RESP" | head -n -1); ', & + 'if [ "$HTTP_CODE" = "428" ]; then ', & + 'OTP=$(echo "$BODY_RESP" | jq -r ".otp // empty"); ', & + 'if [ -n "$OTP" ]; then ', & + 'TS2=$(date +%s); ', & + 'SIG2=$(echo -n "$TS2:POST:/snapshots/', trim(snapshot_id), '/unlock:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/unlock ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS2" ', & + '-H "X-Signature: $SIG2" ', & + '-H "X-Sudo-OTP: $OTP" ', & + '-H "Content-Type: application/json" ', & + '-d "$BODY" | jq .; ', & + 'echo -e "\x1b[32mSnapshot unlocked\x1b[0m"; fi; ', & + 'else echo "$BODY_RESP" | jq .; fi' + call execute_command_line(trim(full_cmd), wait=.true.) + else if (trim(operation) == 'restore' .and. len_trim(snapshot_id) > 0) then + ! Restore snapshot + write(full_cmd, '(20A)') & + 'TS=$(date +%s); ', & + 'BODY="{}"; ', & + 'SIG=$(echo -n "$TS:POST:/snapshots/', trim(snapshot_id), '/restore:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/restore ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-H "Content-Type: application/json" ', & + '-d "$BODY" | jq . && ', & + 'echo -e "\x1b[32mSnapshot restored\x1b[0m"' + call execute_command_line(trim(full_cmd), wait=.true.) + else if (trim(operation) == 'clone' .and. len_trim(snapshot_id) > 0) then + ! Clone snapshot + if (len_trim(clone_type) == 0) then + write(0, '(A)') 'Error: --type required for --clone (session or service)' + stop 1 + end if + write(full_cmd, '(30A)') & + 'TS=$(date +%s); ', & + 'BODY=''{"type":"', trim(clone_type), '"' + if (len_trim(name) > 0) then + write(full_cmd, '(A,A)') trim(full_cmd), ',"name":"' // trim(name) // '"' + end if + if (len_trim(ports) > 0) then + write(full_cmd, '(A,A)') trim(full_cmd), ',"ports":[' // trim(ports) // ']' + end if + if (len_trim(shell) > 0) then + write(full_cmd, '(A,A)') trim(full_cmd), ',"shell":"' // trim(shell) // '"' + end if + write(full_cmd, '(A,20A)') trim(full_cmd), '}''; ', & + 'SIG=$(echo -n "$TS:POST:/snapshots/', trim(snapshot_id), '/clone:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/clone ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-H "Content-Type: application/json" ', & + '-d "$BODY" | jq . && ', & + 'echo -e "\x1b[32mSnapshot cloned\x1b[0m"' + call execute_command_line(trim(full_cmd), wait=.true.) + else + write(0, '(A)') 'Error: Use --list, --info, --delete, --lock, --unlock, --restore, or --clone' + stop 1 + end if + end subroutine handle_snapshot + subroutine handle_image() character(len=8192) :: full_cmd character(len=256) :: arg, image_id, operation, source_type, name, ports, visibility_mode diff --git a/clients/fortran/tests/test_un.sh b/clients/fortran/tests/test_un.sh new file mode 100755 index 0000000..dbbc09d --- /dev/null +++ b/clients/fortran/tests/test_un.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Test suite for Fortran Unsandbox SDK +# Run: bash tests/test_un.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_DIR="$SCRIPT_DIR/../sync/src" +SOURCE="$SDK_DIR/un.f90" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +TESTS_RUN=0 +TESTS_PASSED=0 + +# Test helper +test_that() { + local description="$1" + local test_cmd="$2" + TESTS_RUN=$((TESTS_RUN + 1)) + + if eval "$test_cmd" >/dev/null 2>&1; then + echo -e "[${GREEN}PASS${NC}] $description" + TESTS_PASSED=$((TESTS_PASSED + 1)) + return 0 + else + echo -e "[${RED}FAIL${NC}] $description" + return 1 + fi +} + +echo "" +echo "=== Source File ===" +test_that "Source file exists" "[ -f '$SOURCE' ]" + +echo "" +echo "=== Command Handlers ===" +test_that "Session handler defined" "grep -q 'subroutine handle_session' '$SOURCE'" +test_that "Service handler defined" "grep -q 'subroutine handle_service' '$SOURCE'" +test_that "Snapshot handler defined" "grep -q 'subroutine handle_snapshot' '$SOURCE'" +test_that "Image handler defined" "grep -q 'subroutine handle_image' '$SOURCE'" +test_that "Key handler defined" "grep -q 'subroutine handle_key' '$SOURCE'" +test_that "Languages handler defined" "grep -q 'subroutine handle_languages' '$SOURCE'" + +echo "" +echo "=== Command Dispatch ===" +test_that "Session dispatch" "grep -q \"trim(arg) == 'session'\" '$SOURCE'" +test_that "Service dispatch" "grep -q \"trim(arg) == 'service'\" '$SOURCE'" +test_that "Snapshot dispatch" "grep -q \"trim(arg) == 'snapshot'\" '$SOURCE'" +test_that "Image dispatch" "grep -q \"trim(arg) == 'image'\" '$SOURCE'" +test_that "Key dispatch" "grep -q \"trim(arg) == 'key'\" '$SOURCE'" +test_that "Languages dispatch" "grep -q \"trim(arg) == 'languages'\" '$SOURCE'" + +echo "" +echo "=== Snapshot Operations ===" +test_that "Snapshot --list" "grep -q \"operation = 'list'\" '$SOURCE' || grep -q 'list_mode' '$SOURCE'" +test_that "Snapshot --info" "grep -q \"operation = 'info'\" '$SOURCE'" +test_that "Snapshot --delete" "grep -q \"operation = 'delete'\" '$SOURCE'" +test_that "Snapshot --lock" "grep -q \"operation = 'lock'\" '$SOURCE'" +test_that "Snapshot --unlock" "grep -q \"operation = 'unlock'\" '$SOURCE'" +test_that "Snapshot --restore" "grep -q \"operation = 'restore'\" '$SOURCE'" +test_that "Snapshot --clone" "grep -q \"operation = 'clone'\" '$SOURCE'" + +echo "" +echo "=== Help Text ===" +test_that "Help shows snapshot" "grep -q 'snapshot' '$SOURCE'" +test_that "Help shows --list" "grep -q '\\-\\-list' '$SOURCE'" +test_that "Help shows --info" "grep -q '\\-\\-info' '$SOURCE'" +test_that "Help shows --restore" "grep -q '\\-\\-restore' '$SOURCE'" +test_that "Help shows --clone" "grep -q '\\-\\-clone' '$SOURCE'" + +echo "" +echo "=== HMAC Authentication ===" +test_that "Uses openssl for HMAC" "grep -q 'openssl dgst -sha256 -hmac' '$SOURCE'" +test_that "Has X-Signature header" "grep -q 'X-Signature' '$SOURCE'" +test_that "Has X-Timestamp header" "grep -q 'X-Timestamp' '$SOURCE'" + +echo "" +echo "=== Sudo OTP Handling ===" +test_that "Handles 428 response" "grep -q '428' '$SOURCE'" +test_that "Has X-Sudo-OTP header" "grep -q 'X-Sudo-OTP' '$SOURCE'" + +echo "" +echo "=== Module Structure ===" +test_that "Has unsandbox_sdk module" "grep -q 'module unsandbox_sdk' '$SOURCE'" +test_that "Has unsandbox_client type" "grep -q 'type :: unsandbox_client' '$SOURCE'" +test_that "Has execution_result type" "grep -q 'type :: execution_result' '$SOURCE'" + +echo "" +echo "=== Summary ===" +echo "Tests passed: $TESTS_PASSED / $TESTS_RUN" + +if [ $TESTS_PASSED -eq $TESTS_RUN ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi diff --git a/clients/fsharp/sync/src/un.fs b/clients/fsharp/sync/src/un.fs index c196c60..58dcbdb 100644 --- a/clients/fsharp/sync/src/un.fs +++ b/clients/fsharp/sync/src/un.fs @@ -740,14 +740,14 @@ let openBrowser (url: string) = eprintfn "%sError opening browser: %s%s" red ex.Message reset let cmdKey (args: Args) = - let apiKey = getApiKey args.ApiKey + let (publicKey, secretKey) = getApiKeys args.ApiKey ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls let request = WebRequest.Create(portalBase + "/keys/validate") :?> HttpWebRequest request.Method <- "POST" request.ContentType <- "application/json" - request.Headers.Add("Authorization", sprintf "Bearer %s" apiKey) + request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) request.Timeout <- 30000 try @@ -756,7 +756,7 @@ let cmdKey (args: Args) = let responseText = reader.ReadToEnd() let result = parseJson responseText - let publicKey = match result.TryFind "public_key" with | Some v -> v.ToString() | None -> "N/A" + let resultPublicKey = match result.TryFind "public_key" with | Some v -> v.ToString() | None -> "N/A" let tier = match result.TryFind "tier" with | Some v -> v.ToString() | None -> "N/A" let status = match result.TryFind "status" with | Some v -> v.ToString() | None -> "N/A" let expiresAt = match result.TryFind "expires_at" with | Some v -> v.ToString() | None -> "N/A" @@ -766,20 +766,20 @@ let cmdKey (args: Args) = let concurrency = match result.TryFind "concurrency" with | Some v -> v.ToString() | None -> "N/A" let expired = match result.TryFind "expired" with | Some v -> v.ToString() = "True" | None -> false - if args.KeyExtend && publicKey <> "N/A" then - let extendUrl = sprintf "%s/keys/extend?pk=%s" portalBase publicKey + if args.KeyExtend && resultPublicKey <> "N/A" then + let extendUrl = sprintf "%s/keys/extend?pk=%s" portalBase resultPublicKey printfn "%sOpening browser to extend key...%s" blue reset openBrowser extendUrl elif expired then printfn "%sExpired%s" red reset - printfn "Public Key: %s" publicKey + printfn "Public Key: %s" resultPublicKey printfn "Tier: %s" tier printfn "Expired: %s" expiresAt printfn "%sTo renew: Visit https://unsandbox.com/keys/extend%s" yellow reset exit 1 else printfn "%sValid%s" green reset - printfn "Public Key: %s" publicKey + printfn "Public Key: %s" resultPublicKey printfn "Tier: %s" tier printfn "Status: %s" status printfn "Expires: %s" expiresAt diff --git a/clients/fsharp/tests/UnsandboxTests.fs b/clients/fsharp/tests/UnsandboxTests.fs new file mode 100644 index 0000000..622cb06 --- /dev/null +++ b/clients/fsharp/tests/UnsandboxTests.fs @@ -0,0 +1,265 @@ +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// Unit and Functional Tests for Unsandbox F# SDK + +open System +open System.Collections.Generic +open System.Security.Cryptography +open System.Text + +// Source the main module (when running as script) +// For compiled tests, include un.fs in the project + +/// Unit tests for the Unsandbox SDK library functions. +module UnitTests = + let run () = + printfn "=== Unsandbox F# SDK Unit Tests ===\n" + + testDetectLanguage () + testHmacSign () + testExtensionMap () + + printfn "\n=== Unit Tests Complete ===" + + and testDetectLanguage () = + printf "DetectLanguage: " + let tests = [ + ("test.py", "python") + ("script.js", "javascript") + ("main.go", "go") + ("app.rs", "rust") + ("Program.cs", "csharp") + ("Module.fs", "fsharp") + ] + + let mutable passed = 0 + for (filename, expected) in tests do + let ext = filename.Substring(filename.LastIndexOf('.')) + match Map.tryFind ext extMap with + | Some lang when lang = expected -> passed <- passed + 1 + | Some lang -> printf "[FAIL: %s -> %s, expected %s] " filename lang expected + | None -> printf "[FAIL: %s -> None, expected %s] " filename expected + + if passed = tests.Length then + printfn "PASS (%d/%d)" passed tests.Length + else + printfn "FAIL (%d/%d)" passed tests.Length + + and testHmacSign () = + printf "HmacSign: " + // Test vector: HMAC-SHA256("key", "message") + use hmac = new HMACSHA256(Encoding.UTF8.GetBytes("key")) + let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes("message")) + let signature = BitConverter.ToString(hash).Replace("-", "").ToLower() + let expected = "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a" + if signature = expected then + printfn "PASS" + else + printfn "FAIL (got %s, expected %s)" signature expected + + and testExtensionMap () = + printf "ExtensionMap: " + let tests = [ + (".py", "python") + (".js", "javascript") + (".go", "go") + (".rs", "rust") + (".fs", "fsharp") + ] + + let mutable passed = 0 + for (ext, expected) in tests do + match Map.tryFind ext extMap with + | Some lang when lang = expected -> passed <- passed + 1 + | _ -> () + + if passed = tests.Length then + printfn "PASS (%d/%d)" passed tests.Length + else + printfn "FAIL (%d/%d)" passed tests.Length + +/// Functional tests that require API credentials. +module FunctionalTests = + let run () = + let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") + let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") + + if String.IsNullOrEmpty(publicKey) || String.IsNullOrEmpty(secretKey) then + printfn "=== Functional Tests Skipped (no API credentials) ===" + else + printfn "=== Unsandbox F# SDK Functional Tests ===\n" + + testValidateKeys () + testGetLanguages () + testExecute () + testSessionList () + testServiceList () + testSnapshotList () + testImageList () + + printfn "\n=== Functional Tests Complete ===" + + and testValidateKeys () = + printf "ValidateKeys: " + try + let result = apiRequest "/keys/validate" "POST" None publicKey secretKey + match result.TryFind "valid" with + | Some v when v.ToString() = "True" -> + let tier = match result.TryFind "tier" with | Some t -> t.ToString() | None -> "N/A" + printfn "PASS (tier: %s)" tier + | _ -> printfn "FAIL" + with ex -> + printfn "FAIL (%s)" ex.Message + + and testGetLanguages () = + printf "GetLanguages: " + try + let result = apiRequest "/languages" "GET" None publicKey secretKey + match result.TryFind "languages" with + | Some langs -> printfn "PASS (languages received)" + | None -> printfn "FAIL (no languages in response)" + with ex -> + printfn "FAIL (%s)" ex.Message + + and testExecute () = + printf "Execute: " + try + let payload = [("language", box "python"); ("code", box "print('hello from F# SDK')")] + let result = apiRequest "/execute" "POST" (Some payload) publicKey secretKey + match result.TryFind "stdout" with + | Some stdout when stdout.ToString().Contains("hello") -> printfn "PASS" + | _ -> printfn "FAIL (no expected output)" + with ex -> + printfn "FAIL (%s)" ex.Message + + and testSessionList () = + printf "SessionList: " + try + let result = apiRequest "/sessions" "GET" None publicKey secretKey + printfn "PASS (sessions endpoint responded)" + with ex -> + printfn "FAIL (%s)" ex.Message + + and testServiceList () = + printf "ServiceList: " + try + let result = apiRequest "/services" "GET" None publicKey secretKey + printfn "PASS (services endpoint responded)" + with ex -> + printfn "FAIL (%s)" ex.Message + + and testSnapshotList () = + printf "SnapshotList: " + try + let result = apiRequest "/snapshots" "GET" None publicKey secretKey + printfn "PASS (snapshots endpoint responded)" + with ex -> + printfn "FAIL (%s)" ex.Message + + and testImageList () = + printf "ImageList: " + try + let result = apiRequest "/images" "GET" None publicKey secretKey + printfn "PASS (images endpoint responded)" + with ex -> + printfn "FAIL (%s)" ex.Message + + // Get the API keys from environment + and publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") + and secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") + +// Extension map (duplicated here for standalone testing) +let extMap = + Map.ofList [ + (".py", "python"); (".js", "javascript"); (".ts", "typescript") + (".rb", "ruby"); (".php", "php"); (".pl", "perl"); (".lua", "lua") + (".sh", "bash"); (".go", "go"); (".rs", "rust"); (".c", "c") + (".cpp", "cpp"); (".cc", "cpp"); (".cxx", "cpp") + (".java", "java"); (".kt", "kotlin"); (".cs", "csharp"); (".fs", "fsharp") + (".hs", "haskell"); (".ml", "ocaml"); (".clj", "clojure"); (".scm", "scheme") + (".lisp", "commonlisp"); (".erl", "erlang"); (".ex", "elixir"); (".exs", "elixir") + (".jl", "julia"); (".r", "r"); (".R", "r"); (".cr", "crystal") + (".d", "d"); (".nim", "nim"); (".zig", "zig"); (".v", "v") + (".dart", "dart"); (".groovy", "groovy"); (".scala", "scala") + (".f90", "fortran"); (".f95", "fortran"); (".cob", "cobol") + (".pro", "prolog"); (".forth", "forth"); (".4th", "forth") + (".tcl", "tcl"); (".raku", "raku"); (".m", "objc") + ] + +// API request function (simplified for testing) +open System.Net +open System.IO + +let apiBase = "https://api.unsandbox.com" + +let toJson (obj: obj) = + match obj with + | :? string as s -> sprintf "\"%s\"" (s.Replace("\\", "\\\\").Replace("\"", "\\\"")) + | :? int as i -> i.ToString() + | :? bool as b -> b.ToString().ToLower() + | :? (string * obj) list as lst -> + let entries = lst |> List.map (fun (k, v) -> sprintf "\"%s\":%s" k (toJson v)) |> String.concat "," + sprintf "{%s}" entries + | _ -> sprintf "\"%s\"" (obj.ToString()) + +let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = + ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls + + let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest + request.Method <- method + request.ContentType <- "application/json" + request.Timeout <- 300000 + + let body = match data with | Some d -> toJson (box d) | None -> "" + + if not (String.IsNullOrEmpty(secretKey)) then + let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + let message = sprintf "%d:%s:%s:%s" timestamp method endpoint body + + use hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)) + let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)) + let signature = BitConverter.ToString(hash).Replace("-", "").ToLower() + + request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) + request.Headers.Add("X-Timestamp", timestamp.ToString()) + request.Headers.Add("X-Signature", signature) + else + request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) + + match data with + | Some d -> + let bytes = Encoding.UTF8.GetBytes(body) + request.ContentLength <- int64 bytes.Length + use stream = request.GetRequestStream() + stream.Write(bytes, 0, bytes.Length) + | None -> () + + use response = request.GetResponse() :?> HttpWebResponse + use reader = new StreamReader(response.GetResponseStream()) + let responseText = reader.ReadToEnd() + + // Simple JSON parsing - return as string map + Map.empty + |> fun m -> if responseText.Contains("\"valid\"") then Map.add "valid" (box true) m else m + |> fun m -> if responseText.Contains("\"tier\"") then Map.add "tier" (box "unknown") m else m + |> fun m -> if responseText.Contains("\"languages\"") then Map.add "languages" (box []) m else m + |> fun m -> if responseText.Contains("\"stdout\"") then + let start = responseText.IndexOf("\"stdout\":\"") + 10 + let endIdx = responseText.IndexOf("\"", start) + if start > 10 && endIdx > start then + Map.add "stdout" (box (responseText.Substring(start, endIdx - start))) m + else m + else m + +[] +let main argv = + try + printfn "Unsandbox F# SDK Tests" + printfn "======================\n" + + UnitTests.run () + printfn "" + FunctionalTests.run () + 0 + with ex -> + eprintfn "Test error: %s" ex.Message + 1 diff --git a/clients/go/sync/src/un.go b/clients/go/sync/src/un.go index 4b858a5..e59922f 100644 --- a/clients/go/sync/src/un.go +++ b/clients/go/sync/src/un.go @@ -1096,6 +1096,20 @@ func ExecuteInService(creds *Credentials, serviceID, command string) (map[string return makeRequest("POST", fmt.Sprintf("/services/%s/execute", serviceID), creds, data) } +// ResizeService changes the vCPU count for a running service. +// +// Args: +// +// creds: API credentials +// serviceID: Service ID +// vcpu: New vCPU count (1-8) +func ResizeService(creds *Credentials, serviceID string, vcpu int) (map[string]interface{}, error) { + data := map[string]interface{}{ + "vcpu": vcpu, + } + return makeRequest("POST", fmt.Sprintf("/services/%s/resize", serviceID), creds, data) +} + // ============================================================================ // Additional Snapshot Operations // ============================================================================ @@ -1480,6 +1494,190 @@ func CloneImage(creds *Credentials, imageID string, opts *CloneImageOptions) (ma return makeRequest("POST", fmt.Sprintf("/images/%s/clone", imageID), creds, data) } +// ============================================================================ +// PaaS Logs API +// ============================================================================ + +// LogsFetchOptions contains options for fetching logs. +type LogsFetchOptions struct { + Lines int // Number of lines (1-10000) + Since string // Time window ("1m", "5m", "1h", "1d") + Grep string // Optional filter pattern +} + +// LogsFetch fetches batch logs from the portal. +// +// Args: +// +// creds: API credentials +// source: Log source ("all", "api", "portal", "pool/cammy", "pool/ai") +// opts: Fetch options (can be nil for defaults) +// +// Returns: +// +// JSON response with log entries +func LogsFetch(creds *Credentials, source string, opts *LogsFetchOptions) (map[string]interface{}, error) { + path := "/paas/logs" + params := []string{} + + if source != "" { + params = append(params, fmt.Sprintf("source=%s", source)) + } + + if opts != nil { + if opts.Lines > 0 { + params = append(params, fmt.Sprintf("lines=%d", opts.Lines)) + } + if opts.Since != "" { + params = append(params, fmt.Sprintf("since=%s", opts.Since)) + } + if opts.Grep != "" { + params = append(params, fmt.Sprintf("grep=%s", opts.Grep)) + } + } + + if len(params) > 0 { + path = path + "?" + strings.Join(params, "&") + } + + return makeRequest("GET", path, creds, nil) +} + +// LogCallback is called for each log line received during streaming. +type LogCallback func(source, line string) + +// LogsStream streams logs via Server-Sent Events. +// This function blocks until the stream is closed or an error occurs. +// +// Args: +// +// creds: API credentials +// 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 +// +// Returns: +// +// nil on clean shutdown, error on failure +func LogsStream(creds *Credentials, source, grep string, callback LogCallback) error { + path := "/paas/logs/stream" + params := []string{} + + if source != "" { + params = append(params, fmt.Sprintf("source=%s", source)) + } + if grep != "" { + params = append(params, fmt.Sprintf("grep=%s", grep)) + } + + if len(params) > 0 { + path = path + "?" + strings.Join(params, "&") + } + + url := APIBase + path + timestamp := time.Now().Unix() + message := fmt.Sprintf("%d:GET:%s:", timestamp, path) + mac := hmac.New(sha256.New, []byte(creds.SecretKey)) + mac.Write([]byte(message)) + signature := hex.EncodeToString(mac.Sum(nil)) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+creds.PublicKey) + req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp)) + req.Header.Set("X-Signature", signature) + req.Header.Set("Accept", "text/event-stream") + + client := &http.Client{Timeout: 0} // No timeout for streaming + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("stream error (HTTP %d): %s", resp.StatusCode, string(body)) + } + + reader := bufio.NewReader(resp.Body) + currentSource := source + + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + return nil // Clean shutdown + } + return err + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Parse SSE format + if strings.HasPrefix(line, "event:") { + // New source from event type + currentSource = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + } else if strings.HasPrefix(line, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if callback != nil && data != "" { + callback(currentSource, data) + } + } + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +// SDKVersion is the version of this SDK. +const SDKVersion = "4.2.0" + +// HmacSign computes an HMAC-SHA256 signature for the given message using the secret key. +// Returns the signature as a lowercase hex string. +func HmacSign(secretKey, message string) string { + mac := hmac.New(sha256.New, []byte(secretKey)) + mac.Write([]byte(message)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// HealthCheck checks if the API is reachable and responding. +// Returns true if healthy, false otherwise. +func HealthCheck() bool { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(APIBase + "/health") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 +} + +// Version returns the SDK version string. +func Version() string { + return SDKVersion +} + +// lastError holds the most recent error message for thread-safe access. +var lastError string + +// SetLastError sets the last error message (internal use). +func SetLastError(msg string) { + lastError = msg +} + +// LastError returns the most recent error message from the SDK. +func LastError() string { + return lastError +} + // ============================================================================ // CLI Implementation // ============================================================================ diff --git a/clients/go/sync/tests/un_test.go b/clients/go/sync/tests/un_test.go new file mode 100644 index 0000000..0bd06fb --- /dev/null +++ b/clients/go/sync/tests/un_test.go @@ -0,0 +1,363 @@ +// Tests for the Go unsandbox SDK +// Run with: go test -v ./tests/ +package un + +import ( + "os" + "testing" +) + +// ============================================================================ +// Unit Tests - Test exported library functions +// ============================================================================ + +func TestDetectLanguage(t *testing.T) { + tests := []struct { + filename string + expected string + }{ + {"script.py", "python"}, + {"script.js", "javascript"}, + {"script.ts", "typescript"}, + {"script.rb", "ruby"}, + {"script.go", "go"}, + {"script.rs", "rust"}, + {"script.c", "c"}, + {"script.cpp", "cpp"}, + {"script.d", "d"}, + {"script.zig", "zig"}, + {"script.sh", "bash"}, + {"script.lua", "lua"}, + {"script.php", "php"}, + {"script.unknown", ""}, + {"script", ""}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + result := DetectLanguage(tt.filename) + if result != tt.expected { + t.Errorf("DetectLanguage(%q) = %q, want %q", tt.filename, result, tt.expected) + } + }) + } +} + +func TestHmacSign(t *testing.T) { + // Test with known values + secretKey := "test-secret" + message := "test-message" + + result := HmacSign(secretKey, message) + + // Should return a 64-character hex string + if len(result) != 64 { + t.Errorf("HmacSign returned %d characters, want 64", len(result)) + } + + // Should be deterministic + result2 := HmacSign(secretKey, message) + if result != result2 { + t.Error("HmacSign is not deterministic") + } + + // Different inputs should produce different outputs + result3 := HmacSign(secretKey, "different-message") + if result == result3 { + t.Error("HmacSign returned same result for different inputs") + } +} + +func TestVersion(t *testing.T) { + version := Version() + if version == "" { + t.Error("Version() returned empty string") + } + // Should be in semver format + if len(version) < 5 { // At minimum "0.0.0" + t.Errorf("Version() = %q, expected semver format", version) + } +} + +func TestLastError(t *testing.T) { + // Set an error + SetLastError("test error message") + + // Retrieve it + err := LastError() + if err != "test error message" { + t.Errorf("LastError() = %q, want %q", err, "test error message") + } + + // Clear it + SetLastError("") + err = LastError() + if err != "" { + t.Errorf("LastError() after clear = %q, want empty", err) + } +} + +func TestCredentialsNew(t *testing.T) { + pk := "unsb-pk-test-test-test-test" + sk := "unsb-sk-test1-test2-test3-test4" + + creds := &Credentials{ + PublicKey: pk, + SecretKey: sk, + } + + if creds.PublicKey != pk { + t.Errorf("PublicKey = %q, want %q", creds.PublicKey, pk) + } + if creds.SecretKey != sk { + t.Errorf("SecretKey = %q, want %q", creds.SecretKey, sk) + } +} + +// ============================================================================ +// Integration Tests - Test SDK internal consistency +// ============================================================================ + +func TestSignRequest(t *testing.T) { + secretKey := "test-secret-key" + timestamp := int64(1704067200) // 2024-01-01 00:00:00 UTC + method := "POST" + path := "/execute" + body := `{"language":"python","code":"print(1)"}` + + signature := signRequest(secretKey, timestamp, method, path, []byte(body)) + + // Should return a 64-character hex string + if len(signature) != 64 { + t.Errorf("signRequest returned %d characters, want 64", len(signature)) + } + + // Should be deterministic + signature2 := signRequest(secretKey, timestamp, method, path, []byte(body)) + if signature != signature2 { + t.Error("signRequest is not deterministic") + } + + // Different timestamps should produce different signatures + signature3 := signRequest(secretKey, timestamp+1, method, path, []byte(body)) + if signature == signature3 { + t.Error("signRequest returned same result for different timestamps") + } +} + +func TestResolveCredentialsFromEnv(t *testing.T) { + // Save original env vars + origPK := os.Getenv("UNSANDBOX_PUBLIC_KEY") + origSK := os.Getenv("UNSANDBOX_SECRET_KEY") + + // Set test env vars + testPK := "unsb-pk-test-test-test-test" + testSK := "unsb-sk-test1-test2-test3-test4" + os.Setenv("UNSANDBOX_PUBLIC_KEY", testPK) + os.Setenv("UNSANDBOX_SECRET_KEY", testSK) + + // Test + creds, err := ResolveCredentials("", "") + if err != nil { + t.Fatalf("ResolveCredentials failed: %v", err) + } + if creds.PublicKey != testPK { + t.Errorf("PublicKey = %q, want %q", creds.PublicKey, testPK) + } + if creds.SecretKey != testSK { + t.Errorf("SecretKey = %q, want %q", creds.SecretKey, testSK) + } + + // Restore original env vars + if origPK != "" { + os.Setenv("UNSANDBOX_PUBLIC_KEY", origPK) + } else { + os.Unsetenv("UNSANDBOX_PUBLIC_KEY") + } + if origSK != "" { + os.Setenv("UNSANDBOX_SECRET_KEY", origSK) + } else { + os.Unsetenv("UNSANDBOX_SECRET_KEY") + } +} + +func TestResolveCredentialsFromArgs(t *testing.T) { + testPK := "unsb-pk-arg1-arg2-arg3-arg4" + testSK := "unsb-sk-arg11-arg22-arg33-arg44" + + creds, err := ResolveCredentials(testPK, testSK) + if err != nil { + t.Fatalf("ResolveCredentials failed: %v", err) + } + if creds.PublicKey != testPK { + t.Errorf("PublicKey = %q, want %q", creds.PublicKey, testPK) + } + if creds.SecretKey != testSK { + t.Errorf("SecretKey = %q, want %q", creds.SecretKey, testSK) + } +} + +// ============================================================================ +// Functional Tests - Test against real API (requires credentials) +// ============================================================================ + +func getTestCredentials(t *testing.T) *Credentials { + creds, err := ResolveCredentials("", "") + if err != nil { + t.Skip("No credentials available for functional tests") + } + return creds +} + +func TestHealthCheck(t *testing.T) { + if testing.Short() { + t.Skip("Skipping functional test in short mode") + } + + healthy := HealthCheck() + if !healthy { + t.Log("API health check returned unhealthy (API may be unreachable)") + } +} + +func TestGetLanguages(t *testing.T) { + if testing.Short() { + t.Skip("Skipping functional test in short mode") + } + + creds := getTestCredentials(t) + languages, err := GetLanguages(creds) + if err != nil { + t.Fatalf("GetLanguages failed: %v", err) + } + + if len(languages) == 0 { + t.Error("GetLanguages returned empty list") + } + + // Should include common languages + hasPython := false + hasJavascript := false + for _, lang := range languages { + if lang == "python" { + hasPython = true + } + if lang == "javascript" { + hasJavascript = true + } + } + + if !hasPython { + t.Error("GetLanguages missing 'python'") + } + if !hasJavascript { + t.Error("GetLanguages missing 'javascript'") + } +} + +func TestValidateKeys(t *testing.T) { + if testing.Short() { + t.Skip("Skipping functional test in short mode") + } + + creds := getTestCredentials(t) + result, err := ValidateKeys(creds) + if err != nil { + t.Fatalf("ValidateKeys failed: %v", err) + } + + if result == nil { + t.Error("ValidateKeys returned nil") + } +} + +func TestExecuteCode(t *testing.T) { + if testing.Short() { + t.Skip("Skipping functional test in short mode") + } + + creds := getTestCredentials(t) + result, err := ExecuteCode(creds, "python", "print('hello from go test')") + if err != nil { + t.Fatalf("ExecuteCode failed: %v", err) + } + + if result == nil { + t.Error("ExecuteCode returned nil") + } + + // Check for stdout in result + if stdout, ok := result["stdout"].(string); ok { + if stdout == "" { + t.Error("ExecuteCode returned empty stdout") + } + } +} + +func TestListSessions(t *testing.T) { + if testing.Short() { + t.Skip("Skipping functional test in short mode") + } + + creds := getTestCredentials(t) + sessions, err := ListSessions(creds) + if err != nil { + t.Fatalf("ListSessions failed: %v", err) + } + + // Should return a list (possibly empty) + if sessions == nil { + t.Error("ListSessions returned nil") + } +} + +func TestListServices(t *testing.T) { + if testing.Short() { + t.Skip("Skipping functional test in short mode") + } + + creds := getTestCredentials(t) + services, err := ListServices(creds) + if err != nil { + t.Fatalf("ListServices failed: %v", err) + } + + // Should return a list (possibly empty) + if services == nil { + t.Error("ListServices returned nil") + } +} + +func TestListSnapshots(t *testing.T) { + if testing.Short() { + t.Skip("Skipping functional test in short mode") + } + + creds := getTestCredentials(t) + snapshots, err := ListSnapshots(creds) + if err != nil { + t.Fatalf("ListSnapshots failed: %v", err) + } + + // Should return a list (possibly empty) + if snapshots == nil { + t.Error("ListSnapshots returned nil") + } +} + +func TestListImages(t *testing.T) { + if testing.Short() { + t.Skip("Skipping functional test in short mode") + } + + creds := getTestCredentials(t) + images, err := ListImages(creds, "") + if err != nil { + t.Fatalf("ListImages failed: %v", err) + } + + // Should return a list (possibly empty) + if images == nil { + t.Error("ListImages returned nil") + } +} diff --git a/clients/groovy/sync/src/un.groovy b/clients/groovy/sync/src/un.groovy index 65a1fdb..a4a2156 100644 --- a/clients/groovy/sync/src/un.groovy +++ b/clients/groovy/sync/src/un.groovy @@ -922,6 +922,597 @@ def languages(Map options = [:]) { return result } +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Get SDK version string. + */ +def version() { + return "4.2.0" +} + +/** + * Check API health status. + */ +def healthCheck() { + try { + def url = new URL("${API_BASE}/health") + def connection = url.openConnection() as java.net.HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + return connection.responseCode == 200 + } catch (Exception e) { + return false + } +} + +/** + * Generate HMAC-SHA256 signature. + */ +def hmacSign(String secretKey, String message) { + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + return mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() +} + +// ============================================================================ +// Session Functions +// ============================================================================ + +/** + * List all sessions. + */ +def sessionList(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/sessions', 'GET', null, publicKey, secretKey) + return result.sessions ?: [] +} + +/** + * Get session details. + */ +def sessionGet(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}", 'GET', null, publicKey, secretKey) +} + +/** + * Create a new session. + */ +def sessionCreate(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [ + network_mode: options.networkMode ?: 'zerotrust', + shell: options.shell ?: 'bash' + ] + if (options.vcpu) payload.vcpu = options.vcpu + return apiRequest('/sessions', 'POST', payload, publicKey, secretKey) +} + +/** + * Destroy a session. + */ +def sessionDestroy(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * Freeze a session. + */ +def sessionFreeze(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/freeze", 'POST', null, publicKey, secretKey) +} + +/** + * Unfreeze a session. + */ +def sessionUnfreeze(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/unfreeze", 'POST', null, publicKey, secretKey) +} + +/** + * Boost a session. + */ +def sessionBoost(String sessionId, int vcpu = 2, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/boost", 'POST', [vcpu: vcpu], publicKey, secretKey) +} + +/** + * Unboost a session. + */ +def sessionUnboost(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/unboost", 'POST', null, publicKey, secretKey) +} + +/** + * Execute command in a session. + */ +def sessionExecute(String sessionId, String command, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/shell", 'POST', [command: command], publicKey, secretKey) +} + +// ============================================================================ +// Service Functions +// ============================================================================ + +/** + * List all services. + */ +def serviceList(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/services', 'GET', null, publicKey, secretKey) + return result.services ?: [] +} + +/** + * Get service details. + */ +def serviceGet(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}", 'GET', null, publicKey, secretKey) +} + +/** + * Create a new service. + */ +def serviceCreate(String name, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [name: name] + if (options.ports) payload.ports = options.ports.split(',').collect { it.trim().toInteger() } + if (options.domains) payload.domains = options.domains + if (options.bootstrap) payload.bootstrap = options.bootstrap + if (options.networkMode) payload.network_mode = options.networkMode + def result = apiRequest('/services', 'POST', payload, publicKey, secretKey) + return result.id +} + +/** + * Destroy a service. + */ +def serviceDestroy(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/services/${serviceId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * Freeze a service. + */ +def serviceFreeze(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/freeze", 'POST', null, publicKey, secretKey) +} + +/** + * Unfreeze a service. + */ +def serviceUnfreeze(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/unfreeze", 'POST', null, publicKey, secretKey) +} + +/** + * Lock a service. + */ +def serviceLock(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/lock", 'POST', null, publicKey, secretKey) +} + +/** + * Unlock a service. + */ +def serviceUnlock(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/services/${serviceId}/unlock", 'POST', null, publicKey, secretKey) +} + +/** + * Set unfreeze on demand for a service. + */ +def serviceSetUnfreezeOnDemand(String serviceId, boolean enabled, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequestPatch("/services/${serviceId}", [unfreeze_on_demand: enabled], publicKey, secretKey) +} + +/** + * Redeploy a service. + */ +def serviceRedeploy(String serviceId, String bootstrap = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = bootstrap ? [bootstrap: bootstrap] : [:] + return apiRequest("/services/${serviceId}/redeploy", 'POST', payload, publicKey, secretKey) +} + +/** + * Get service logs. + */ +def serviceLogs(String serviceId, boolean allLogs = false, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def path = allLogs ? "/services/${serviceId}/logs?all=true" : "/services/${serviceId}/logs" + def result = apiRequest(path, 'GET', null, publicKey, secretKey) + return result.logs +} + +/** + * Execute command in a service. + */ +def serviceExecute(String serviceId, String command, int timeoutMs = 0, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [command: command] + if (timeoutMs > 0) payload.timeout = timeoutMs + return apiRequest("/services/${serviceId}/execute", 'POST', payload, publicKey, secretKey) +} + +/** + * Get service environment vault status. + */ +def serviceEnvGet(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/env", 'GET', null, publicKey, secretKey) +} + +/** + * Set service environment vault. + */ +def serviceEnvSet(String serviceId, String envContent, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequestText("/services/${serviceId}/env", 'PUT', envContent, publicKey, secretKey) +} + +/** + * Delete service environment vault. + */ +def serviceEnvDelete(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/env", 'DELETE', null, publicKey, secretKey) +} + +/** + * Export service environment vault. + */ +def serviceEnvExport(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/env/export", 'POST', [:], publicKey, secretKey) +} + +/** + * Resize a service. + */ +def serviceResize(String serviceId, int vcpu, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequestPatch("/services/${serviceId}", [vcpu: vcpu], publicKey, secretKey) +} + +// ============================================================================ +// Snapshot Functions +// ============================================================================ + +/** + * List all snapshots. + */ +def snapshotList(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/snapshots', 'GET', null, publicKey, secretKey) + return result.snapshots ?: [] +} + +/** + * Get snapshot details. + */ +def snapshotGet(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/snapshots/${snapshotId}", 'GET', null, publicKey, secretKey) +} + +/** + * Create snapshot from session. + */ +def snapshotSession(String sessionId, String name = null, boolean hot = false, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [session_id: sessionId, hot: hot] + if (name) payload.name = name + def result = apiRequest('/snapshots', 'POST', payload, publicKey, secretKey) + return result.snapshot_id +} + +/** + * Create snapshot from service. + */ +def snapshotService(String serviceId, String name = null, boolean hot = false, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [service_id: serviceId, hot: hot] + if (name) payload.name = name + def result = apiRequest('/snapshots', 'POST', payload, publicKey, secretKey) + return result.snapshot_id +} + +/** + * Restore a snapshot. + */ +def snapshotRestore(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/snapshots/${snapshotId}/restore", 'POST', [:], publicKey, secretKey) +} + +/** + * Delete a snapshot. + */ +def snapshotDelete(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/snapshots/${snapshotId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * Lock a snapshot. + */ +def snapshotLock(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/snapshots/${snapshotId}/lock", 'POST', null, publicKey, secretKey) +} + +/** + * Unlock a snapshot. + */ +def snapshotUnlock(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/snapshots/${snapshotId}/unlock", 'POST', null, publicKey, secretKey) +} + +/** + * Clone a snapshot. + */ +def snapshotClone(String snapshotId, String cloneType, String name = null, String ports = null, String shell = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [type: cloneType] + if (name) payload.name = name + if (ports) payload.ports = ports.split(',').collect { it.trim().toInteger() } + if (shell) payload.shell = shell + def result = apiRequest("/snapshots/${snapshotId}/clone", 'POST', payload, publicKey, secretKey) + return result.session_id ?: result.service_id +} + +// ============================================================================ +// Image Functions +// ============================================================================ + +/** + * List all images. + */ +def imageList(String filter = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def path = filter ? "/images/${filter}" : '/images' + def result = apiRequest(path, 'GET', null, publicKey, secretKey) + return result.images ?: [] +} + +/** + * Get image details. + */ +def imageGet(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}", 'GET', null, publicKey, secretKey) +} + +/** + * Publish an image. + */ +def imagePublish(String sourceType, String sourceId, String name = null, String description = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [source_type: sourceType, source_id: sourceId] + if (name) payload.name = name + if (description) payload.description = description + def result = apiRequest('/images', 'POST', payload, publicKey, secretKey) + return result.image_id +} + +/** + * Delete an image. + */ +def imageDelete(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/images/${imageId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * Lock an image. + */ +def imageLock(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/lock", 'POST', null, publicKey, secretKey) +} + +/** + * Unlock an image. + */ +def imageUnlock(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/images/${imageId}/unlock", 'POST', null, publicKey, secretKey) +} + +/** + * Set image visibility. + */ +def imageSetVisibility(String imageId, String visibility, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/visibility", 'POST', [visibility: visibility], publicKey, secretKey) +} + +/** + * Grant access to an image. + */ +def imageGrantAccess(String imageId, String trustedApiKey, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/grant", 'POST', [trusted_api_key: trustedApiKey], publicKey, secretKey) +} + +/** + * Revoke access to an image. + */ +def imageRevokeAccess(String imageId, String trustedApiKey, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/revoke", 'POST', [trusted_api_key: trustedApiKey], publicKey, secretKey) +} + +/** + * List trusted keys for an image. + */ +def imageListTrusted(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest("/images/${imageId}/trusted", 'GET', null, publicKey, secretKey) + return result.trusted ?: [] +} + +/** + * Transfer image ownership. + */ +def imageTransfer(String imageId, String toApiKey, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/transfer", 'POST', [to_api_key: toApiKey], publicKey, secretKey) +} + +/** + * Spawn a service from an image. + */ +def imageSpawn(String imageId, String name = null, String ports = null, String bootstrap = null, String networkMode = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [:] + if (name) payload.name = name + if (ports) payload.ports = ports.split(',').collect { it.trim().toInteger() } + if (bootstrap) payload.bootstrap = bootstrap + if (networkMode) payload.network_mode = networkMode + def result = apiRequest("/images/${imageId}/spawn", 'POST', payload, publicKey, secretKey) + return result.service_id +} + +/** + * Clone an image. + */ +def imageClone(String imageId, String name = null, String description = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [:] + if (name) payload.name = name + if (description) payload.description = description + def result = apiRequest("/images/${imageId}/clone", 'POST', payload, publicKey, secretKey) + return result.image_id +} + +// ============================================================================ +// PaaS Logs Functions +// ============================================================================ + +/** + * Fetch batch logs. + */ +def logsFetch(String source = 'all', int lines = 100, String since = null, String grep = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def params = ["source=${source}", "lines=${lines}"] + if (since) params << "since=${since}" + if (grep) params << "grep=${URLEncoder.encode(grep, 'UTF-8')}" + return apiRequest("/paas/logs?${params.join('&')}", 'GET', null, publicKey, secretKey) +} + +/** + * Callback interface for log streaming. + */ +interface LogCallback { + void onLogLine(String source, String line) +} + +/** + * Stream logs via SSE. Blocks until interrupted or server closes. + * + * @param source Log source ('all', 'api', 'portal', 'pool/cammy', 'pool/ai') + * @param grep Optional filter pattern + * @param callback Callback for each log line + * @param options Optional parameters (publicKey, secretKey) + * @return true on clean shutdown, false on error + */ +def logsStream(String source = 'all', String grep = null, LogCallback callback, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + + def path = "/paas/logs/stream?source=${source ?: 'all'}" + if (grep) { + path += "&grep=${URLEncoder.encode(grep, 'UTF-8')}" + } + + def timestamp = (System.currentTimeMillis() / 1000) as long + def signature = signRequest(secretKey, timestamp, 'GET', path, '') + + def url = new URL("${API_BASE}${path}") + def connection = url.openConnection() as java.net.HttpURLConnection + + connection.requestMethod = 'GET' + connection.setRequestProperty('Authorization', "Bearer ${publicKey}") + connection.setRequestProperty('X-Timestamp', timestamp.toString()) + connection.setRequestProperty('X-Signature', signature) + connection.setRequestProperty('Accept', 'text/event-stream') + connection.connectTimeout = 30000 + connection.readTimeout = 0 // No timeout for streaming + + if (connection.responseCode != 200) { + return false + } + + try { + def reader = new BufferedReader(new InputStreamReader(connection.inputStream, 'UTF-8')) + def currentSource = source ?: 'all' + def line + + while ((line = reader.readLine()) != null) { + if (line.startsWith('data: ')) { + def data = line.substring(6) + if (callback) { + callback.onLogLine(currentSource, data) + } + } else if (line.startsWith('event: ')) { + currentSource = line.substring(7) + } + } + return true + } catch (Exception e) { + return false + } +} + +/** + * Validate API keys. + */ +def validateKeys(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + + def timestamp = (System.currentTimeMillis() / 1000) as long + def message = "${timestamp}:POST:/keys/validate:{}" + def signature = signRequest(secretKey, timestamp, 'POST', '/keys/validate', '{}') + + def url = new URL("${PORTAL_BASE}/keys/validate") + def connection = url.openConnection() as java.net.HttpURLConnection + + connection.requestMethod = 'POST' + connection.setRequestProperty('Authorization', "Bearer ${publicKey}") + connection.setRequestProperty('X-Timestamp', timestamp.toString()) + connection.setRequestProperty('X-Signature', signature) + connection.setRequestProperty('Content-Type', 'application/json') + connection.connectTimeout = 30000 + connection.readTimeout = 30000 + connection.doOutput = true + connection.outputStream.withWriter { it.write('{}') } + + if (connection.responseCode !in 200..299) { + throw new APIError("HTTP ${connection.responseCode}") + } + + return new JsonSlurper().parseText(connection.inputStream.text) +} + /** * Detect programming language from file extension or shebang. * diff --git a/clients/groovy/sync/tests/UnTest.groovy b/clients/groovy/sync/tests/UnTest.groovy new file mode 100644 index 0000000..6e51a16 --- /dev/null +++ b/clients/groovy/sync/tests/UnTest.groovy @@ -0,0 +1,453 @@ +#!/usr/bin/env groovy +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// Unit tests for Un SDK - Groovy Synchronous client + +import groovy.test.GroovyTestCase + +/** + * Test suite for the Unsandbox Groovy SDK. + * + * Run with: groovy UnTest.groovy + * + * Integration tests require UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY + * environment variables to be set. + */ +class UnTest extends GroovyTestCase { + + // Load the SDK + static { + def sdkPath = new File(UnTest.class.protectionDomain.codeSource.location.path).parentFile.parentFile + evaluate(new File(sdkPath, 'src/un.groovy')) + } + + // ======================================================================== + // Language Detection Tests + // ======================================================================== + + void testDetectPython() { + assertEquals("python", detectLanguage("script.py")) + assertEquals("python", detectLanguage("path/to/script.py")) + } + + void testDetectJavaScript() { + assertEquals("javascript", detectLanguage("app.js")) + } + + void testDetectTypeScript() { + assertEquals("typescript", detectLanguage("app.ts")) + } + + void testDetectGo() { + assertEquals("go", detectLanguage("main.go")) + } + + void testDetectRust() { + assertEquals("rust", detectLanguage("lib.rs")) + } + + void testDetectJava() { + assertEquals("java", detectLanguage("Main.java")) + } + + void testDetectKotlin() { + assertEquals("kotlin", detectLanguage("Main.kt")) + } + + void testDetectGroovy() { + assertEquals("groovy", detectLanguage("script.groovy")) + } + + void testDetectCpp() { + assertEquals("cpp", detectLanguage("main.cpp")) + } + + void testDetectC() { + assertEquals("c", detectLanguage("main.c")) + } + + void testDetectRuby() { + assertEquals("ruby", detectLanguage("script.rb")) + } + + void testDetectPhp() { + assertEquals("php", detectLanguage("index.php")) + } + + void testDetectUnknown() { + assertNull(detectLanguage("file.unknown")) + assertNull(detectLanguage("noextension")) + } + + // ======================================================================== + // Utility Function Tests + // ======================================================================== + + void testVersionString() { + def ver = version() + assertNotNull(ver) + assertTrue("Version should be in X.Y.Z format", ver ==~ /\d+\.\d+\.\d+/) + } + + void testHmacSignature() { + def signature = hmacSign("secret", "message") + assertNotNull(signature) + assertEquals("HMAC-SHA256 should produce 64 hex chars", 64, signature.length()) + assertTrue("Signature should be lowercase hex", signature ==~ /[0-9a-f]+/) + } + + void testHmacConsistent() { + def sig1 = hmacSign("key", "data") + def sig2 = hmacSign("key", "data") + assertEquals("Same inputs should produce same signature", sig1, sig2) + } + + void testHmacDifferent() { + def sig1 = hmacSign("key1", "data") + def sig2 = hmacSign("key2", "data") + assertFalse("Different keys should produce different signatures", sig1 == sig2) + } + + void testHmacKnownValue() { + // HMAC-SHA256("key", "The quick brown fox jumps over the lazy dog") + // Known value from various implementations + def signature = hmacSign("key", "The quick brown fox jumps over the lazy dog") + assertEquals("f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", signature) + } + + // ======================================================================== + // Health Check Tests + // ======================================================================== + + void testHealthCheckReturnsBoolean() { + def healthy = healthCheck() + // We just verify it returns a boolean without throwing + assertTrue(healthy instanceof Boolean) + } + + // ======================================================================== + // Exception Tests + // ======================================================================== + + void testUnsandboxError() { + def error = new UnsandboxError("Test message") + assertEquals("Test message", error.message) + } + + void testAuthenticationError() { + def error = new AuthenticationError("Auth failed") + assertEquals("Auth failed", error.message) + assertTrue(error instanceof UnsandboxError) + } + + void testExecutionError() { + def error = new ExecutionError("Exec failed", 1, "stderr output") + assertEquals("Exec failed", error.message) + assertEquals(1, error.exitCode) + assertEquals("stderr output", error.stderr) + } + + void testAPIError() { + def error = new APIError("API failed", 500, '{"error": "internal"}') + assertEquals("API failed", error.message) + assertEquals(500, error.statusCode) + assertEquals('{"error": "internal"}', error.response) + } + + void testTimeoutError() { + def error = new TimeoutError("Operation timed out") + assertEquals("Operation timed out", error.message) + assertTrue(error instanceof UnsandboxError) + } + + void testSudoChallengeError() { + def error = new SudoChallengeError("challenge-123", '{"challenge_id": "challenge-123"}') + assertEquals("challenge-123", error.challengeId) + assertEquals('{"challenge_id": "challenge-123"}', error.responseBody) + } + + // ======================================================================== + // Extension Map Tests + // ======================================================================== + + void testExtensionMapComplete() { + // Verify the EXT_MAP has all expected extensions + assertNotNull(EXT_MAP['.py']) + assertNotNull(EXT_MAP['.js']) + assertNotNull(EXT_MAP['.ts']) + assertNotNull(EXT_MAP['.go']) + assertNotNull(EXT_MAP['.rs']) + assertNotNull(EXT_MAP['.java']) + assertNotNull(EXT_MAP['.kt']) + assertNotNull(EXT_MAP['.groovy']) + assertNotNull(EXT_MAP['.rb']) + assertNotNull(EXT_MAP['.php']) + assertNotNull(EXT_MAP['.c']) + assertNotNull(EXT_MAP['.cpp']) + assertNotNull(EXT_MAP['.sh']) + assertNotNull(EXT_MAP['.lua']) + assertNotNull(EXT_MAP['.pl']) + } + + // ======================================================================== + // Integration Tests (requires credentials) + // ======================================================================== + + void testExecutePythonCode() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def result = execute("python", 'print("Hello, World!")', [ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(result) + assertTrue(result.stdout?.contains("Hello, World!") ?: false) + } + + void testExecuteJavaScriptCode() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def result = execute("javascript", 'console.log("Hello from JS")', [ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(result) + assertTrue(result.stdout?.contains("Hello from JS") ?: false) + } + + void testGetLanguages() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def result = languages([ + publicKey: publicKey, + secretKey: secretKey, + forceRefresh: true + ]) + + assertNotNull(result) + assertNotNull(result.languages) + assertTrue(result.languages.size() > 0) + assertTrue(result.languages.contains("python")) + assertTrue(result.languages.contains("javascript")) + } + + void testListJobs() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def jobs = listJobs([ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(jobs) + // Jobs list can be empty if no jobs are running + } + + void testValidateKeys() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def result = validateKeys([ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(result) + } + + void testListSessions() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def sessions = sessionList([ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(sessions) + } + + void testListServices() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def services = serviceList([ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(services) + } + + void testListSnapshots() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def snapshots = snapshotList([ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(snapshots) + } + + void testListImages() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def images = imageList(null, [ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(images) + } + + void testAsyncExecution() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def job = executeAsync("python", 'print("Async test")', [ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(job) + assertNotNull(job.job_id) + + // Wait for completion + def result = wait(job.job_id, [ + publicKey: publicKey, + secretKey: secretKey, + maxPolls: 30 + ]) + + assertNotNull(result) + assertTrue(result.stdout?.contains("Async test") ?: (result.result?.stdout?.contains("Async test") ?: false)) + } + + void testLogsFetch() { + def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (!publicKey || !secretKey) { + println "Skipping integration test - credentials not set" + return + } + + def result = logsFetch('all', 10, null, null, [ + publicKey: publicKey, + secretKey: secretKey + ]) + + assertNotNull(result) + } + + void testLogCallbackInterface() { + // Verify LogCallback interface exists and can be implemented + def callback = { source, line -> + assertNotNull(source) + assertNotNull(line) + } as LogCallback + + assertNotNull(callback) + } + + // ======================================================================== + // Run all tests + // ======================================================================== + + static void main(String[] args) { + println "Running Unsandbox Groovy SDK Tests..." + println "=" * 60 + + def test = new UnTest() + def methods = UnTest.class.declaredMethods.findAll { + it.name.startsWith('test') && it.parameterCount == 0 + } + + int passed = 0 + int failed = 0 + int skipped = 0 + + methods.each { method -> + print "Testing ${method.name}... " + try { + method.invoke(test) + println "PASS" + passed++ + } catch (Exception e) { + def cause = e.cause ?: e + if (cause.message?.contains("Skipping")) { + println "SKIP" + skipped++ + } else { + println "FAIL: ${cause.message}" + failed++ + } + } + } + + println "=" * 60 + println "Results: ${passed} passed, ${failed} failed, ${skipped} skipped" + + if (failed > 0) { + System.exit(1) + } + } +} diff --git a/clients/haskell/sync/src/un.hs b/clients/haskell/sync/src/un.hs index 79b417e..d17d0b5 100644 --- a/clients/haskell/sync/src/un.hs +++ b/clients/haskell/sync/src/un.hs @@ -345,6 +345,499 @@ parseExecute args = let (k, v) = span (/= '=') kv in (k, drop 1 v) +-- ============================================================================ +-- Library API +-- ============================================================================ + +-- SDK Version +sdkVersion :: String +sdkVersion = "4.2.0" + +-- | Return the SDK version +version :: String +version = sdkVersion + +-- | Check API health +healthCheck :: IO Bool +healthCheck = do + (exitCode, stdout, _) <- readProcessWithExitCode "curl" + ["-s", "-o", "/dev/null", "-w", "%{http_code}", apiBase ++ "/health"] "" + return $ filter isDigit stdout == "200" + +-- | Generate HMAC-SHA256 signature for a message +hmacSign :: String -> String -> String +hmacSign = hmacSha256 + +-- | Detect language from filename extension +detectLanguage :: String -> Maybe String +detectLanguage filename = extToLang (takeExtension filename) + +-- | Get list of supported languages (list of strings) +getLanguages :: IO [String] +getLanguages = do + cached <- loadLanguagesCache + case cached of + Just languages -> return languages + Nothing -> do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/languages") + let languages = maybe [] id (extractJsonArray stdout "languages") + when (not (null languages)) $ saveLanguagesCache languages + return languages + +-- | Execute code synchronously +execute :: String -> String -> IO (Either String (Bool, String, String, Int)) +execute language code = do + apiKey <- getApiKey + let json = "{\"language\":\"" ++ escapeJSON language ++ "\",\"code\":\"" ++ escapeJSON code ++ "\"}" + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/execute") json + case exitCode of + ExitSuccess -> + let success = case extractJsonString stdout "exit_code" of + Just "0" -> True + _ -> False + stdoutVal = maybe "" id (extractJsonString stdout "stdout") + stderrVal = maybe "" id (extractJsonString stdout "stderr") + exitCodeVal = case extractJsonString stdout "exit_code" of + Just s -> read (filter isDigit s) :: Int + _ -> 0 + in return $ Right (success, stdoutVal, stderrVal, exitCodeVal) + _ -> return $ Left "Execution failed" + +-- | Execute code asynchronously, returning a job ID +executeAsync :: String -> String -> IO (Maybe String) +executeAsync language code = do + apiKey <- getApiKey + let json = "{\"language\":\"" ++ escapeJSON language ++ "\",\"code\":\"" ++ escapeJSON code ++ "\"}" + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/execute/async") json + case exitCode of + ExitSuccess -> return $ extractJsonString stdout "job_id" + _ -> return Nothing + +-- | Get job status +getJob :: String -> IO (Maybe (String, String)) +getJob jobId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlGet apiKey (apiBase ++ "/jobs/" ++ jobId) + case exitCode of + ExitSuccess -> + let status = maybe "unknown" id (extractJsonString stdout "status") + language = maybe "" id (extractJsonString stdout "language") + in return $ Just (status, language) + _ -> return Nothing + +-- | Wait for job completion +waitJob :: String -> IO (Either String (Bool, String, String, Int)) +waitJob jobId = waitJobLoop jobId 0 100 + where + pollDelays = [300, 450, 700, 900, 650, 1600, 2000] + terminalStates = ["completed", "failed", "timeout", "cancelled"] + + waitJobLoop jid pollCount maxPolls + | pollCount >= maxPolls = return $ Left "Max polls exceeded" + | otherwise = do + let delayIdx = min pollCount (length pollDelays - 1) + let delayMs = pollDelays !! delayIdx + threadDelay (delayMs * 1000) -- threadDelay takes microseconds + + result <- getJob jid + case result of + Just (status, _) | status `elem` terminalStates -> do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/jobs/" ++ jid) + let success = case extractJsonString stdout "exit_code" of + Just "0" -> True + _ -> False + stdoutVal = maybe "" id (extractJsonString stdout "stdout") + stderrVal = maybe "" id (extractJsonString stdout "stderr") + exitCodeVal = case extractJsonString stdout "exit_code" of + Just s -> read (filter isDigit s) :: Int + _ -> 1 + return $ Right (success, stdoutVal, stderrVal, exitCodeVal) + _ -> waitJobLoop jid (pollCount + 1) maxPolls + +-- | Cancel a running job +cancelJob :: String -> IO Bool +cancelJob jobId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlDelete apiKey (apiBase ++ "/jobs/" ++ jobId) + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | List all active jobs +listJobs :: IO String +listJobs = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/jobs") + return stdout + +-- | List all sessions +sessionList :: IO String +sessionList = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/sessions") + return stdout + +-- | Get session details +sessionGet :: String -> IO String +sessionGet sessionId = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/sessions/" ++ sessionId) + return stdout + +-- | Create a new session +sessionCreate :: Maybe String -> Maybe String -> IO (Maybe String) +sessionCreate shell network = do + apiKey <- getApiKey + let shellVal = maybe "bash" id shell + let networkJson = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") network + let json = "{\"shell\":\"" ++ shellVal ++ "\"" ++ networkJson ++ "}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions") json + return $ extractJsonString stdout "id" + +-- | Destroy a session +sessionDestroy :: String -> IO Bool +sessionDestroy sessionId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlDelete apiKey (apiBase ++ "/sessions/" ++ sessionId) + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Freeze a session +sessionFreeze :: String -> IO Bool +sessionFreeze sessionId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions/" ++ sessionId ++ "/freeze") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Unfreeze a session +sessionUnfreeze :: String -> IO Bool +sessionUnfreeze sessionId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions/" ++ sessionId ++ "/unfreeze") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Boost session resources +sessionBoost :: String -> Int -> IO Bool +sessionBoost sessionId vcpu = do + apiKey <- getApiKey + let json = "{\"vcpu\":" ++ show vcpu ++ "}" + (exitCode, stdout, _) <- curlPatch apiKey (apiBase ++ "/sessions/" ++ sessionId) json + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Unboost session +sessionUnboost :: String -> IO Bool +sessionUnboost sessionId = sessionBoost sessionId 1 + +-- | Execute a command in a session +sessionExecute :: String -> String -> IO String +sessionExecute sessionId command = do + apiKey <- getApiKey + let json = "{\"command\":\"" ++ escapeJSON command ++ "\"}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions/" ++ sessionId ++ "/execute") json + return stdout + +-- | List all services +serviceList :: IO String +serviceList = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/services") + return stdout + +-- | Get service details +serviceGet :: String -> IO String +serviceGet serviceId = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/services/" ++ serviceId) + return stdout + +-- | Create a new service +serviceCreate :: String -> Maybe String -> Maybe String -> Maybe String -> IO (Maybe String) +serviceCreate name ports bootstrap network = do + apiKey <- getApiKey + let portsJson = maybe "" (\p -> ",\"ports\":[" ++ p ++ "]") ports + let bootstrapJson = maybe "" (\b -> ",\"bootstrap\":\"" ++ escapeJSON b ++ "\"") bootstrap + let networkJson = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") network + let json = "{\"name\":\"" ++ escapeJSON name ++ "\"" ++ portsJson ++ bootstrapJson ++ networkJson ++ "}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/services") json + return $ extractJsonString stdout "id" + +-- | Destroy a service +serviceDestroy :: String -> IO Bool +serviceDestroy serviceId = do + result <- curlDeleteWithSudo "" (apiBase ++ "/services/" ++ serviceId) + case result of + SudoSuccess _ -> return True + _ -> return False + +-- | Freeze a service +serviceFreeze :: String -> IO Bool +serviceFreeze serviceId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/freeze") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Unfreeze a service +serviceUnfreeze :: String -> IO Bool +serviceUnfreeze serviceId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/unfreeze") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Lock a service +serviceLock :: String -> IO Bool +serviceLock serviceId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/lock") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Unlock a service +serviceUnlock :: String -> IO Bool +serviceUnlock serviceId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/unlock") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Set unfreeze-on-demand for a service +serviceSetUnfreezeOnDemand :: String -> Bool -> IO Bool +serviceSetUnfreezeOnDemand serviceId enabled = do + apiKey <- getApiKey + let enabledStr = if enabled then "true" else "false" + let json = "{\"unfreeze_on_demand\":" ++ enabledStr ++ "}" + (exitCode, stdout, _) <- curlPatch apiKey (apiBase ++ "/services/" ++ serviceId) json + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Redeploy a service +serviceRedeploy :: String -> Maybe String -> IO Bool +serviceRedeploy serviceId bootstrap = do + apiKey <- getApiKey + let bootstrapJson = maybe "" (\b -> "\"bootstrap\":\"" ++ escapeJSON b ++ "\"") bootstrap + let json = "{" ++ bootstrapJson ++ "}" + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/redeploy") json + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Get service logs +serviceLogs :: String -> Bool -> IO String +serviceLogs serviceId allLogs = do + apiKey <- getApiKey + let endpoint = if allLogs + then "/services/" ++ serviceId ++ "/logs?all=true" + else "/services/" ++ serviceId ++ "/logs" + (_, stdout, _) <- curlGet apiKey (apiBase ++ endpoint) + return stdout + +-- | Execute a command in a service +serviceExecute :: String -> String -> IO String +serviceExecute serviceId command = do + apiKey <- getApiKey + let json = "{\"command\":\"" ++ escapeJSON command ++ "\"}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/execute") json + return stdout + +-- | Resize a service +serviceResize :: String -> Int -> IO Bool +serviceResize serviceId vcpu = do + apiKey <- getApiKey + let json = "{\"vcpu\":" ++ show vcpu ++ "}" + (exitCode, stdout, _) <- curlPatch apiKey (apiBase ++ "/services/" ++ serviceId) json + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | List all snapshots +snapshotList :: IO String +snapshotList = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/snapshots") + return stdout + +-- | Get snapshot details +snapshotGet :: String -> IO String +snapshotGet snapshotId = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/snapshots/" ++ snapshotId) + return stdout + +-- | Create a snapshot of a session +snapshotSession :: String -> Maybe String -> Bool -> IO (Maybe String) +snapshotSession sessionId name hot = do + apiKey <- getApiKey + let nameJson = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\",") name + let hotJson = if hot then "\"hot\":true" else "\"hot\":false" + let json = "{" ++ nameJson ++ hotJson ++ "}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions/" ++ sessionId ++ "/snapshot") json + return $ extractJsonString stdout "id" + +-- | Create a snapshot of a service +snapshotService :: String -> Maybe String -> Bool -> IO (Maybe String) +snapshotService serviceId name hot = do + apiKey <- getApiKey + let nameJson = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\",") name + let hotJson = if hot then "\"hot\":true" else "\"hot\":false" + let json = "{" ++ nameJson ++ hotJson ++ "}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/snapshot") json + return $ extractJsonString stdout "id" + +-- | Restore from a snapshot +snapshotRestore :: String -> IO (Maybe String) +snapshotRestore snapshotId = do + apiKey <- getApiKey + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/snapshots/" ++ snapshotId ++ "/restore") "{}" + return $ extractJsonString stdout "id" + +-- | Delete a snapshot +snapshotDelete :: String -> IO Bool +snapshotDelete snapshotId = do + result <- curlDeleteWithSudo "" (apiBase ++ "/snapshots/" ++ snapshotId) + case result of + SudoSuccess _ -> return True + _ -> return False + +-- | Lock a snapshot +snapshotLock :: String -> IO Bool +snapshotLock snapshotId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/snapshots/" ++ snapshotId ++ "/lock") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Unlock a snapshot +snapshotUnlock :: String -> IO Bool +snapshotUnlock snapshotId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/snapshots/" ++ snapshotId ++ "/unlock") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Clone a snapshot to create a new session or service +snapshotClone :: String -> String -> Maybe String -> Maybe String -> Maybe String -> IO (Maybe String) +snapshotClone snapshotId cloneType name ports shell = do + apiKey <- getApiKey + let typeJson = "\"type\":\"" ++ cloneType ++ "\"" + let nameJson = maybe "" (\n -> ",\"name\":\"" ++ escapeJSON n ++ "\"") name + let portsJson = maybe "" (\p -> ",\"ports\":[" ++ p ++ "]") ports + let shellJson = maybe "" (\s -> ",\"shell\":\"" ++ s ++ "\"") shell + let json = "{" ++ typeJson ++ nameJson ++ portsJson ++ shellJson ++ "}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/snapshots/" ++ snapshotId ++ "/clone") json + return $ extractJsonString stdout "id" + +-- | List images +imageList :: Maybe String -> IO String +imageList filter' = do + apiKey <- getApiKey + let endpoint = maybe "/images" (\f -> "/images?filter=" ++ f) filter' + (_, stdout, _) <- curlGet apiKey (apiBase ++ endpoint) + return stdout + +-- | Get image details +imageGet :: String -> IO String +imageGet imageId = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/images/" ++ imageId) + return stdout + +-- | Publish an image +imagePublish :: String -> String -> Maybe String -> Maybe String -> IO (Maybe String) +imagePublish sourceType sourceId name description = do + apiKey <- getApiKey + let nameJson = maybe "" (\n -> ",\"name\":\"" ++ escapeJSON n ++ "\"") name + let descJson = maybe "" (\d -> ",\"description\":\"" ++ escapeJSON d ++ "\"") description + let json = "{\"source_type\":\"" ++ sourceType ++ "\",\"source_id\":\"" ++ sourceId ++ "\"" ++ nameJson ++ descJson ++ "}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/images/publish") json + return $ extractJsonString stdout "id" + +-- | Delete an image +imageDelete :: String -> IO Bool +imageDelete imageId = do + result <- curlDeleteWithSudo "" (apiBase ++ "/images/" ++ imageId) + case result of + SudoSuccess _ -> return True + _ -> return False + +-- | Lock an image +imageLock :: String -> IO Bool +imageLock imageId = do + apiKey <- getApiKey + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/lock") "{}" + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Unlock an image +imageUnlock :: String -> IO Bool +imageUnlock imageId = do + result <- curlPostWithSudo "" (apiBase ++ "/images/" ++ imageId ++ "/unlock") "{}" + case result of + SudoSuccess _ -> return True + _ -> return False + +-- | Set image visibility +imageSetVisibility :: String -> String -> IO Bool +imageSetVisibility imageId visibility = do + apiKey <- getApiKey + let json = "{\"visibility\":\"" ++ visibility ++ "\"}" + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/visibility") json + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Grant access to an image +imageGrantAccess :: String -> String -> IO Bool +imageGrantAccess imageId trustedApiKey = do + apiKey <- getApiKey + let json = "{\"api_key\":\"" ++ trustedApiKey ++ "\"}" + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/access/grant") json + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Revoke access to an image +imageRevokeAccess :: String -> String -> IO Bool +imageRevokeAccess imageId trustedApiKey = do + apiKey <- getApiKey + let json = "{\"api_key\":\"" ++ trustedApiKey ++ "\"}" + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/access/revoke") json + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | List trusted API keys for an image +imageListTrusted :: String -> IO String +imageListTrusted imageId = do + apiKey <- getApiKey + (_, stdout, _) <- curlGet apiKey (apiBase ++ "/images/" ++ imageId ++ "/access") + return stdout + +-- | Transfer image ownership +imageTransfer :: String -> String -> IO Bool +imageTransfer imageId toApiKey = do + apiKey <- getApiKey + let json = "{\"to_api_key\":\"" ++ toApiKey ++ "\"}" + (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/transfer") json + return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) + +-- | Spawn a service from an image +imageSpawn :: String -> Maybe String -> Maybe String -> Maybe String -> Maybe String -> IO (Maybe String) +imageSpawn imageId name ports bootstrap network = do + apiKey <- getApiKey + let nameJson = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\"") name + let portsJson = maybe "" (\p -> (if null nameJson then "" else ",") ++ "\"ports\":[" ++ p ++ "]") ports + let bootstrapJson = maybe "" (\b -> ",\"bootstrap\":\"" ++ escapeJSON b ++ "\"") bootstrap + let networkJson = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") network + let json = "{" ++ nameJson ++ portsJson ++ bootstrapJson ++ networkJson ++ "}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/spawn") json + return $ extractJsonString stdout "id" + +-- | Clone an image +imageClone :: String -> Maybe String -> Maybe String -> IO (Maybe String) +imageClone imageId name description = do + apiKey <- getApiKey + let nameJson = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\"") name + let descJson = maybe "" (\d -> (if null nameJson then "" else ",") ++ "\"description\":\"" ++ escapeJSON d ++ "\"") description + let json = "{" ++ nameJson ++ descJson ++ "}" + (_, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/clone") json + return $ extractJsonString stdout "id" + +-- | Validate API keys +validateKeys :: IO String +validateKeys = do + apiKey <- getApiKey + (_, stdout, _) <- curlPostPortal apiKey (portalBase ++ "/keys/validate") "{}" + return stdout + +-- Helper for threadDelay (microseconds) +threadDelay :: Int -> IO () +threadDelay us = do + let ms = us `div` 1000 + _ <- readProcessWithExitCode "sleep" [show (fromIntegral ms / 1000.0 :: Double)] "" + return () + -- Main main :: IO () main = do diff --git a/clients/haskell/sync/tests/test_functional.hs b/clients/haskell/sync/tests/test_functional.hs new file mode 100755 index 0000000..8b16013 --- /dev/null +++ b/clients/haskell/sync/tests/test_functional.hs @@ -0,0 +1,182 @@ +#!/usr/bin/env runhaskell + +{- +Functional Tests for Un Haskell SDK + +Run with: runhaskell test_functional.hs +Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables + +These tests make real API calls to api.unsandbox.com +-} + +import System.Exit (exitFailure, exitSuccess) +import System.Environment (lookupEnv) +import System.Process (readProcessWithExitCode) +import Data.List (isPrefixOf, isInfixOf) +import Data.Char (isDigit) + +-- ANSI colors +blue, red, green, yellow, reset :: String +blue = "\x1b[34m" +red = "\x1b[31m" +green = "\x1b[32m" +yellow = "\x1b[33m" +reset = "\x1b[0m" + +-- API constants +apiBase :: String +apiBase = "https://api.unsandbox.com" + +portalBase :: String +portalBase = "https://unsandbox.com" + +-- Import HMAC from crypto library +import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as BSC +import Crypto.Hash.SHA256 (hmac) +import Text.Printf (printf) +import Data.Time.Clock.POSIX (getPOSIXTime) + +hmacSha256 :: String -> String -> String +hmacSha256 secret message = + let secretBS = BSC.pack secret + messageBS = BSC.pack message + mac = hmac secretBS messageBS + in concatMap (printf "%02x") (BS.unpack mac) + +main :: IO () +main = do + putStrLn $ "\n" ++ blue ++ "=== Un Haskell SDK Functional Tests ===" ++ reset ++ "\n" + + -- Check for credentials + publicKey <- lookupEnv "UNSANDBOX_PUBLIC_KEY" + secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY" + + case (publicKey, secretKey) of + (Just pk, Just sk) -> do + results <- sequence + [ runTest "health_check" (testHealthCheck pk sk) + , runTest "validate_keys" (testValidateKeys pk sk) + , runTest "execute_python" (testExecutePython pk sk) + , runTest "execute_with_error" (testExecuteWithError pk sk) + , runTest "session_list" (testSessionList pk sk) + , runTest "service_list" (testServiceList pk sk) + , runTest "snapshot_list" (testSnapshotList pk sk) + , runTest "image_list" (testImageList pk sk) + ] + + let passed = length $ filter id results + let failed = length $ filter not results + let total = length results + + putStrLn $ "\n" ++ blue ++ "Results: " ++ show passed ++ "/" ++ show total ++ " passed" ++ reset + + if failed > 0 + then do + putStrLn $ red ++ show failed ++ " test(s) failed" ++ reset + exitFailure + else do + putStrLn $ green ++ "All functional tests passed!" ++ reset + exitSuccess + + _ -> do + putStrLn $ yellow ++ "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" ++ reset + exitSuccess + +runTest :: String -> IO Bool -> IO Bool +runTest name test = do + putStr $ " Running " ++ name ++ "... " + result <- test + if result + then putStrLn $ green ++ "PASS" ++ reset + else putStrLn $ red ++ "FAIL" ++ reset + return result + +-- Build auth headers +buildAuthHeaders :: String -> String -> String -> String -> String -> IO [String] +buildAuthHeaders publicKey secretKey method path body = do + now <- getPOSIXTime + let timestamp = show (floor now :: Integer) + let message = timestamp ++ ":" ++ method ++ ":" ++ path ++ ":" ++ body + let signature = hmacSha256 secretKey message + return [ "-H", "Authorization: Bearer " ++ publicKey + , "-H", "X-Timestamp: " ++ timestamp + , "-H", "X-Signature: " ++ signature + ] + +-- HTTP helpers +curlGet :: String -> String -> String -> IO String +curlGet publicKey secretKey endpoint = do + authHeaders <- buildAuthHeaders publicKey secretKey "GET" endpoint "" + (_, stdout, _) <- readProcessWithExitCode "curl" + (["-s", apiBase ++ endpoint] ++ authHeaders) "" + return stdout + +curlPost :: String -> String -> String -> String -> IO String +curlPost publicKey secretKey endpoint json = do + authHeaders <- buildAuthHeaders publicKey secretKey "POST" endpoint json + (_, stdout, _) <- readProcessWithExitCode "curl" + (["-s", "-X", "POST", apiBase ++ endpoint, "-H", "Content-Type: application/json"] ++ authHeaders ++ ["-d", json]) "" + return stdout + +curlPostPortal :: String -> String -> String -> String -> IO String +curlPostPortal publicKey secretKey endpoint json = do + authHeaders <- buildAuthHeaders publicKey secretKey "POST" endpoint json + (_, stdout, _) <- readProcessWithExitCode "curl" + (["-s", "-X", "POST", portalBase ++ endpoint, "-H", "Content-Type: application/json"] ++ authHeaders ++ ["-d", json]) "" + return stdout + +-- ============================================================================ +-- Functional Tests +-- ============================================================================ + +testHealthCheck :: String -> String -> IO Bool +testHealthCheck _ _ = do + (_, stdout, _) <- readProcessWithExitCode "curl" + ["-s", "-o", "/dev/null", "-w", "%{http_code}", apiBase ++ "/health"] "" + return $ filter isDigit stdout == "200" + +testValidateKeys :: String -> String -> IO Bool +testValidateKeys publicKey secretKey = do + response <- curlPostPortal publicKey secretKey "/keys/validate" "{}" + -- Check response is JSON with expected fields + return $ "{" `isPrefixOf` response && ("\"valid\"" `isInfixOf` response || "\"status\"" `isInfixOf` response) + +testExecutePython :: String -> String -> IO Bool +testExecutePython publicKey secretKey = do + let json = "{\"language\":\"python\",\"code\":\"print(6 * 7)\"}" + response <- curlPost publicKey secretKey "/execute" json + -- Check output contains 42 + return $ "42" `isInfixOf` response + +testExecuteWithError :: String -> String -> IO Bool +testExecuteWithError publicKey secretKey = do + let json = "{\"language\":\"python\",\"code\":\"import sys; sys.exit(1)\"}" + response <- curlPost publicKey secretKey "/execute" json + -- Check exit_code is 1 + return $ "\"exit_code\":1" `isInfixOf` response || "\"exit_code\": 1" `isInfixOf` response + +testSessionList :: String -> String -> IO Bool +testSessionList publicKey secretKey = do + response <- curlGet publicKey secretKey "/sessions" + -- Response should be JSON array or object + let trimmed = dropWhile (== ' ') response + return $ "[" `isPrefixOf` trimmed || "{" `isPrefixOf` trimmed + +testServiceList :: String -> String -> IO Bool +testServiceList publicKey secretKey = do + response <- curlGet publicKey secretKey "/services" + let trimmed = dropWhile (== ' ') response + return $ "[" `isPrefixOf` trimmed || "{" `isPrefixOf` trimmed + +testSnapshotList :: String -> String -> IO Bool +testSnapshotList publicKey secretKey = do + response <- curlGet publicKey secretKey "/snapshots" + let trimmed = dropWhile (== ' ') response + return $ "[" `isPrefixOf` trimmed || "{" `isPrefixOf` trimmed + +testImageList :: String -> String -> IO Bool +testImageList publicKey secretKey = do + response <- curlGet publicKey secretKey "/images" + let trimmed = dropWhile (== ' ') response + return $ "[" `isPrefixOf` trimmed || "{" `isPrefixOf` trimmed diff --git a/clients/haskell/sync/tests/test_library.hs b/clients/haskell/sync/tests/test_library.hs new file mode 100755 index 0000000..39270e6 --- /dev/null +++ b/clients/haskell/sync/tests/test_library.hs @@ -0,0 +1,161 @@ +#!/usr/bin/env runhaskell + +{- +Unit Tests for Un Haskell SDK Library Functions + +Run with: runhaskell test_library.hs +No credentials required - tests pure library functions only. +-} + +import System.Exit (exitFailure, exitSuccess) +import Data.Char (isHexDigit) +import Data.List (isPrefixOf) + +-- Import from parent src directory +-- In a real scenario, this would be properly imported + +-- ANSI colors +blue, red, green, yellow, reset :: String +blue = "\x1b[34m" +red = "\x1b[31m" +green = "\x1b[32m" +yellow = "\x1b[33m" +reset = "\x1b[0m" + +-- Inline implementation for testing (matches un.hs) +import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as BSC +import Crypto.Hash.SHA256 (hmac) +import Text.Printf (printf) +import System.FilePath (takeExtension) + +hmacSha256 :: String -> String -> String +hmacSha256 secret message = + let secretBS = BSC.pack secret + messageBS = BSC.pack message + mac = hmac secretBS messageBS + in concatMap (printf "%02x") (BS.unpack mac) + +extToLang :: String -> Maybe String +extToLang ext = lookup ext extMap + where + extMap = [ (".hs", "haskell"), (".ml", "ocaml"), (".clj", "clojure") + , (".scm", "scheme"), (".lisp", "commonlisp"), (".erl", "erlang") + , (".ex", "elixir"), (".exs", "elixir"), (".py", "python") + , (".js", "javascript"), (".ts", "typescript"), (".rb", "ruby") + , (".go", "go"), (".rs", "rust"), (".c", "c"), (".cpp", "cpp") + , (".cc", "cpp"), (".cxx", "cpp"), (".java", "java") + , (".kt", "kotlin"), (".cs", "csharp"), (".fs", "fsharp") + , (".jl", "julia"), (".r", "r"), (".cr", "crystal") + , (".d", "d"), (".nim", "nim"), (".zig", "zig"), (".v", "v") + , (".dart", "dart"), (".groovy", "groovy"), (".scala", "scala") + , (".sh", "bash"), (".pl", "perl"), (".lua", "lua"), (".php", "php") + ] + +sdkVersion :: String +sdkVersion = "4.2.0" + +detectLanguage :: String -> Maybe String +detectLanguage filename = extToLang (takeExtension filename) + +hmacSign :: String -> String -> String +hmacSign = hmacSha256 + +main :: IO () +main = do + putStrLn $ "\n" ++ blue ++ "=== Un Haskell SDK Library Tests ===" ++ reset ++ "\n" + + results <- sequence + [ runTest "version" testVersion + , runTest "detect_language" testDetectLanguage + , runTest "hmac_sign" testHmacSign + , runTest "hmac_sign_deterministic" testHmacSignDeterministic + , runTest "hmac_sign_different_secrets" testHmacSignDifferentSecrets + ] + + let passed = length $ filter id results + let failed = length $ filter not results + let total = length results + + putStrLn $ "\n" ++ blue ++ "Results: " ++ show passed ++ "/" ++ show total ++ " passed" ++ reset + + if failed > 0 + then do + putStrLn $ red ++ show failed ++ " test(s) failed" ++ reset + exitFailure + else do + putStrLn $ green ++ "All tests passed!" ++ reset + exitSuccess + +runTest :: String -> IO Bool -> IO Bool +runTest name test = do + result <- test + if result + then putStrLn $ green ++ "PASS" ++ reset ++ ": " ++ name + else putStrLn $ red ++ "FAIL" ++ reset ++ ": " ++ name + return result + +-- ============================================================================ +-- Unit Tests +-- ============================================================================ + +testVersion :: IO Bool +testVersion = do + let version = sdkVersion + -- Check it's non-empty + if null version + then return False + else do + -- Check it's semver format (contains dots) + let parts = words $ map (\c -> if c == '.' then ' ' else c) version + return $ length parts == 3 + +testDetectLanguage :: IO Bool +testDetectLanguage = do + -- Test common extensions + let tests = + [ (detectLanguage "script.py" == Just "python", "python") + , (detectLanguage "app.js" == Just "javascript", "javascript") + , (detectLanguage "main.go" == Just "go", "go") + , (detectLanguage "main.rs" == Just "rust", "rust") + , (detectLanguage "main.c" == Just "c", "c") + , (detectLanguage "main.cpp" == Just "cpp", "cpp") + , (detectLanguage "Main.java" == Just "java", "java") + , (detectLanguage "script.rb" == Just "ruby", "ruby") + , (detectLanguage "script.sh" == Just "bash", "bash") + , (detectLanguage "script.lua" == Just "lua", "lua") + , (detectLanguage "script.pl" == Just "perl", "perl") + , (detectLanguage "index.php" == Just "php", "php") + , (detectLanguage "main.hs" == Just "haskell", "haskell") + , (detectLanguage "main.ml" == Just "ocaml", "ocaml") + , (detectLanguage "main.ex" == Just "elixir", "elixir") + , (detectLanguage "main.erl" == Just "erlang", "erlang") + -- Test with paths + , (detectLanguage "/path/to/script.py" == Just "python", "path/python") + -- Test unknown extensions + , (detectLanguage "Makefile" == Nothing, "Makefile") + , (detectLanguage "README" == Nothing, "README") + , (detectLanguage "script.unknown" == Nothing, "unknown") + ] + return $ all fst tests + +testHmacSign :: IO Bool +testHmacSign = do + let signature = hmacSign "my_secret" "test message" + -- Should be 64 hex characters + let is64Hex = length signature == 64 && all isHexDigit signature + -- Should be lowercase + let isLowercase = all (\c -> not (c >= 'A' && c <= 'F')) signature + return $ is64Hex && isLowercase + +testHmacSignDeterministic :: IO Bool +testHmacSignDeterministic = do + let sig1 = hmacSign "test_secret" "same message" + let sig2 = hmacSign "test_secret" "same message" + return $ sig1 == sig2 + +testHmacSignDifferentSecrets :: IO Bool +testHmacSignDifferentSecrets = do + let sig1 = hmacSign "secret1" "test message" + let sig2 = hmacSign "secret2" "test message" + return $ sig1 /= sig2 diff --git a/clients/java/sync/src/Un.java b/clients/java/sync/src/Un.java index 956158a..4984ca3 100644 --- a/clients/java/sync/src/Un.java +++ b/clients/java/sync/src/Un.java @@ -2393,6 +2393,123 @@ public class Un { return makeRequest("POST", "/images/" + imageId + "/clone", creds[0], creds[1], data); } + // ======================================================================== + // PaaS Logs API (2) + // ======================================================================== + + /** + * Fetch batch logs from portal. + * + * @param source Log source: "all", "api", "portal", "pool/cammy", "pool/ai" + * @param lines Number of lines (1-10000) + * @param since Time window: "1m", "5m", "1h", "1d" + * @param grep Optional filter pattern (null for no filter) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing logs + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map logsFetch( + String source, + int lines, + String since, + String grep, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + StringBuilder path = new StringBuilder("/paas/logs?source="); + path.append(source != null ? source : "all"); + path.append("&lines=").append(lines > 0 ? lines : 100); + if (since != null && !since.isEmpty()) { + path.append("&since=").append(since); + } + if (grep != null && !grep.isEmpty()) { + path.append("&grep=").append(java.net.URLEncoder.encode(grep, "UTF-8")); + } + return makeRequest("GET", path.toString(), creds[0], creds[1], null); + } + + /** + * Interface for receiving streamed log lines. + */ + public interface LogCallback { + /** + * Called for each log line received. + * + * @param source The log source (e.g., "api", "portal") + * @param line The log line content + */ + void onLogLine(String source, String line); + } + + /** + * Stream logs via SSE. Blocks until interrupted or server closes connection. + * + * @param source Log source: "all", "api", "portal", "pool/cammy", "pool/ai" + * @param grep Optional filter pattern (null for no filter) + * @param callback Callback for each log line received + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return true on clean shutdown, false on error + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + */ + public static boolean logsStream( + String source, + String grep, + LogCallback callback, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + StringBuilder path = new StringBuilder("/paas/logs/stream?source="); + path.append(source != null ? source : "all"); + if (grep != null && !grep.isEmpty()) { + path.append("&grep=").append(java.net.URLEncoder.encode(grep, "UTF-8")); + } + + String url = API_BASE + path.toString(); + long timestamp = System.currentTimeMillis() / 1000; + String signature = signRequest(creds[1], timestamp, "GET", path.toString(), null); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(30000); + conn.setReadTimeout(0); // No timeout for streaming + + conn.setRequestProperty("Authorization", "Bearer " + creds[0]); + conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp)); + conn.setRequestProperty("X-Signature", signature); + conn.setRequestProperty("Accept", "text/event-stream"); + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + return false; + } + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + String currentSource = source != null ? source : "all"; + while ((line = reader.readLine()) != null) { + if (line.startsWith("data: ")) { + String data = line.substring(6); + if (callback != null) { + callback.onLogLine(currentSource, data); + } + } else if (line.startsWith("event: ")) { + currentSource = line.substring(7); + } + } + return true; + } catch (Exception e) { + return false; + } + } + // ======================================================================== // Key Validation API // ======================================================================== @@ -2415,6 +2532,106 @@ public class Un { return makeRequest("POST", "/keys/validate", creds[0], creds[1], new LinkedHashMap<>()); } + // ======================================================================== + // Utility Functions + // ======================================================================== + + /** + * Get SDK version string. + * + * @return Version string (e.g., "4.2.0") + */ + public static String version() { + return "4.2.0"; + } + + /** + * Check API health status. + * + * @return true if API is healthy, false otherwise + */ + public static boolean healthCheck() { + try { + HttpURLConnection conn = (HttpURLConnection) new URL(API_BASE + "/health").openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + return conn.getResponseCode() == 200; + } catch (Exception e) { + return false; + } + } + + /** + * Generate HMAC-SHA256 signature. + * + * @param secretKey Secret key for HMAC + * @param message Message to sign + * @return Lowercase hex-encoded signature + */ + public static String hmacSign(String secretKey, String message) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec( + secretKey.getBytes(StandardCharsets.UTF_8), + "HmacSHA256" + ); + mac.init(secretKeySpec); + byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } catch (Exception e) { + return null; + } + } + + /** + * Get details of a specific snapshot. + * + * @param snapshotId Snapshot ID to retrieve + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Snapshot details map + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map getSnapshot( + String snapshotId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/snapshots/" + snapshotId, creds[0], creds[1], null); + } + + /** + * Resize a service (change vCPU allocation). + * + * @param serviceId Service ID to resize + * @param vcpu New vCPU count (1-8) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with resize confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map resizeService( + String serviceId, + int vcpu, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("vcpu", vcpu); + return makeRequestWithMethod("PATCH", "/services/" + serviceId, creds[0], creds[1], data); + } + // ======================================================================== // Image Generation API // ======================================================================== diff --git a/clients/java/sync/test/UnTest.java b/clients/java/sync/test/UnTest.java index a6cc64c..9498694 100644 --- a/clients/java/sync/test/UnTest.java +++ b/clients/java/sync/test/UnTest.java @@ -6,6 +6,7 @@ * - HMAC-SHA256 signature generation * - Credential resolution logic * - Language detection + * - Utility functions * * To run tests: * mvn test @@ -80,6 +81,18 @@ public class UnTest { assertEquals("cpp", Un.detectLanguage("main.cxx")); } + @Test + @DisplayName("Should detect Kotlin from .kt extension") + void detectKotlin() { + assertEquals("kotlin", Un.detectLanguage("Main.kt")); + } + + @Test + @DisplayName("Should detect Groovy from .groovy extension") + void detectGroovy() { + assertEquals("groovy", Un.detectLanguage("script.groovy")); + } + @Test @DisplayName("Should return null for unknown extension") void detectUnknown() { @@ -94,6 +107,44 @@ public class UnTest { } } + @Nested + @DisplayName("Utility Function Tests") + class UtilityTests { + + @Test + @DisplayName("Version should return a valid version string") + void versionString() { + String version = Un.version(); + assertNotNull(version); + assertTrue(version.matches("\\d+\\.\\d+\\.\\d+"), "Version should be in X.Y.Z format"); + } + + @Test + @DisplayName("HMAC sign should produce valid hex signature") + void hmacSignature() { + String signature = Un.hmacSign("secret", "message"); + assertNotNull(signature); + assertEquals(64, signature.length(), "HMAC-SHA256 should produce 64 hex chars"); + assertTrue(signature.matches("[0-9a-f]+"), "Signature should be lowercase hex"); + } + + @Test + @DisplayName("HMAC sign should be consistent") + void hmacConsistent() { + String sig1 = Un.hmacSign("key", "data"); + String sig2 = Un.hmacSign("key", "data"); + assertEquals(sig1, sig2, "Same inputs should produce same signature"); + } + + @Test + @DisplayName("HMAC sign should differ with different inputs") + void hmacDifferent() { + String sig1 = Un.hmacSign("key1", "data"); + String sig2 = Un.hmacSign("key2", "data"); + assertNotEquals(sig1, sig2, "Different keys should produce different signatures"); + } + } + @Nested @DisplayName("Credential Exception Tests") class CredentialExceptionTests { @@ -120,6 +171,22 @@ public class UnTest { } } + @Nested + @DisplayName("Sudo Challenge Exception Tests") + class SudoChallengeExceptionTests { + + @Test + @DisplayName("SudoChallengeException should contain challenge ID") + void sudoChallengeDetails() { + Un.SudoChallengeException ex = new Un.SudoChallengeException( + "challenge-123", + "{\"challenge_id\": \"challenge-123\"}" + ); + assertEquals("challenge-123", ex.getChallengeId()); + assertEquals("{\"challenge_id\": \"challenge-123\"}", ex.getResponseBody()); + } + } + @Nested @DisplayName("Integration Tests (requires credentials)") @EnabledIfEnvironmentVariable(named = "UNSANDBOX_PUBLIC_KEY", matches = ".+") @@ -193,5 +260,63 @@ public class UnTest { assertEquals("completed", result.get("status")); assertTrue(result.get("stdout").toString().contains("Async test")); } + + @Test + @DisplayName("Should list jobs") + void listJobs() throws IOException { + List> jobs = Un.listJobs(publicKey, secretKey); + assertNotNull(jobs); + // Jobs list can be empty if no jobs are running + } + + @Test + @DisplayName("Should validate keys successfully") + void validateKeys() throws IOException { + Map result = Un.validateKeys(publicKey, secretKey); + assertNotNull(result); + // The response should contain validation info + } + + @Test + @DisplayName("Should list sessions") + void listSessions() throws IOException { + List> sessions = Un.listSessions(publicKey, secretKey); + assertNotNull(sessions); + } + + @Test + @DisplayName("Should list services") + void listServices() throws IOException { + List> services = Un.listServices(publicKey, secretKey); + assertNotNull(services); + } + + @Test + @DisplayName("Should list snapshots") + void listSnapshots() throws IOException { + List> snapshots = Un.listSnapshots(publicKey, secretKey); + assertNotNull(snapshots); + } + + @Test + @DisplayName("Should list images") + void listImages() throws IOException { + List> images = Un.listImages(null, publicKey, secretKey); + assertNotNull(images); + } + } + + @Nested + @DisplayName("Health Check Tests") + class HealthCheckTests { + + @Test + @DisplayName("Health check should return boolean") + void healthCheckReturnsBoolean() { + boolean healthy = Un.healthCheck(); + // We just verify it returns without throwing + // The actual result depends on network connectivity + assertTrue(healthy || !healthy); + } } } diff --git a/clients/javascript/sync/jest.config.js b/clients/javascript/sync/jest.config.js new file mode 100644 index 0000000..3b5e34d --- /dev/null +++ b/clients/javascript/sync/jest.config.js @@ -0,0 +1,7 @@ +export default { + testEnvironment: 'node', + transform: {}, + testMatch: ['**/tests/**/*.test.js'], + moduleFileExtensions: ['js', 'mjs'], + verbose: true, +}; diff --git a/clients/javascript/sync/package.json b/clients/javascript/sync/package.json index 5cddd9c..0afbe5c 100644 --- a/clients/javascript/sync/package.json +++ b/clients/javascript/sync/package.json @@ -5,9 +5,12 @@ "type": "module", "main": "src/un.js", "scripts": { - "test": "echo 'No tests configured yet'" + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" }, "keywords": ["unsandbox", "code-execution", "sandbox", "api"], "author": "unsandbox.com", - "license": "Unlicense" + "license": "Unlicense", + "devDependencies": { + "jest": "^29.0.0" + } } diff --git a/clients/javascript/sync/src/un.js b/clients/javascript/sync/src/un.js index 81051e6..093eca3 100644 --- a/clients/javascript/sync/src/un.js +++ b/clients/javascript/sync/src/un.js @@ -933,7 +933,7 @@ async function serviceSnapshot(serviceId, publicKey, secretKey, name, hot = fals } /** - * List all snapshots (NEW). + * List all snapshots. * * Returns: Promise (list of snapshot dicts) */ @@ -944,7 +944,20 @@ async function listSnapshots(publicKey, secretKey) { } /** - * Restore a snapshot (NEW). + * Get details of a specific snapshot. + * + * Args: + * snapshotId: Snapshot ID to get details for + * + * Returns: Promise (snapshot details with id, name, type, source_id, etc.) + */ +async function getSnapshot(snapshotId, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('GET', `/snapshots/${snapshotId}`, publicKey, secretKey); +} + +/** + * Restore a snapshot. * * Returns: Promise (response with restored resource info) */ @@ -1410,6 +1423,20 @@ async function executeInService(serviceId, command, timeout = 30000, publicKey, return response; } +/** + * Resize a service's vCPU allocation. + * + * Args: + * serviceId: Service ID to resize + * vcpu: Number of vCPUs (1-8 typically) + * + * Returns: Promise (updated service info) + */ +async function resizeService(serviceId, vcpu, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + return makeRequest('PATCH', `/services/${serviceId}`, publicKey, secretKey, { vcpu }); +} + // ============================================================================ // Additional Snapshot Functions // ============================================================================ @@ -1753,9 +1780,198 @@ async function image(prompt, options = {}) { return makeRequest('POST', '/image', pk, sk, payload); } +// ============================================================================ +// PaaS Logs Functions +// ============================================================================ + +let _lastError = null; + +/** + * Fetch batch logs from the PaaS platform. + * + * Args: + * source: Log source - "all", "api", "portal", "pool/cammy", "pool/ai" + * lines: Number of lines to fetch (1-10000) + * since: Time window - "1m", "5m", "1h", "1d" + * grep: Optional filter pattern + * publicKey: API public key + * secretKey: API secret key + * + * Returns: Promise (log entries) + */ +async function logsFetch(source = 'all', lines = 100, since = '5m', grep = null, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + let urlPath = `/logs?source=${source}&lines=${lines}&since=${since}`; + if (grep) urlPath += `&grep=${encodeURIComponent(grep)}`; + return makeRequest('GET', urlPath, publicKey, secretKey); +} + +/** + * Stream logs via Server-Sent Events. + * + * Args: + * source: Log source - "all", "api", "portal", "pool/cammy", "pool/ai" + * grep: Optional filter pattern + * callback: Function called for each log line (signature: callback(source, line)) + * publicKey: API public key + * secretKey: API secret key + * + * Returns: Promise (blocks until interrupted or server closes) + */ +async function logsStream(source = 'all', grep = null, callback = null, publicKey, secretKey) { + [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); + + let urlPath = `/logs/stream?source=${source}`; + if (grep) urlPath += `&grep=${encodeURIComponent(grep)}`; + + const timestamp = Math.floor(Date.now() / 1000); + const signature = await signRequest(secretKey, timestamp, 'GET', urlPath, null); + + const url = `${API_BASE}${urlPath}`; + const headers = { + 'Authorization': `Bearer ${publicKey}`, + 'X-Timestamp': timestamp.toString(), + 'X-Signature': signature, + 'Accept': 'text/event-stream', + }; + + // Node.js SSE streaming + if (IS_NODE) { + const https = await import('https'); + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const options = { + hostname: urlObj.hostname, + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers, + }; + + const req = https.default.request(options, (res) => { + res.on('data', (chunk) => { + const lines = chunk.toString().split('\n'); + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.substring(6); + try { + const entry = JSON.parse(data); + if (callback) { + callback(entry.source || source, entry.line || data); + } else { + console.log(`[${entry.source || source}] ${entry.line || data}`); + } + } catch (e) { + if (callback) { + callback(source, data); + } else { + console.log(`[${source}] ${data}`); + } + } + } + } + }); + res.on('end', resolve); + res.on('error', reject); + }); + + req.on('error', reject); + req.end(); + }); + } + + // Browser EventSource not directly supported with custom headers + throw new Error('logsStream is only supported in Node.js'); +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +const SDK_VERSION = '4.2.0'; + +/** + * Get the SDK version string. + * + * Returns: string (e.g., "4.2.0") + */ +function sdkVersion() { + return SDK_VERSION; +} + +/** + * Check if the API is healthy and responding. + * + * Returns: Promise + */ +async function healthCheck() { + try { + if (IS_NODE) { + const https = await import('https'); + return new Promise((resolve) => { + const req = https.default.get(`${API_BASE}/health`, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', () => { + _lastError = 'Health check failed: network error'; + resolve(false); + }); + req.setTimeout(10000, () => { + _lastError = 'Health check failed: timeout'; + resolve(false); + }); + }); + } else { + const response = await fetch(`${API_BASE}/health`); + return response.status === 200; + } + } catch (e) { + _lastError = `Health check failed: ${e.message}`; + return false; + } +} + +/** + * Get the last error message. + * + * Returns: string|null + */ +function lastError() { + return _lastError; +} + +/** + * Sign a message using HMAC-SHA256. + * + * This is the underlying signing function used for request authentication. + * Exposed for testing and debugging purposes. + * + * Args: + * secretKey: The secret key for signing + * message: The message to sign + * + * Returns: Promise (64-character lowercase hex string) + */ +async function hmacSign(secretKey, message) { + if (IS_NODE) { + return crypto.createHmac('sha256', secretKey).update(message).digest('hex'); + } else { + // Browser Web Crypto API + const encoder = new TextEncoder(); + const keyData = encoder.encode(secretKey); + const msgData = encoder.encode(message); + const cryptoKey = await window.crypto.subtle.importKey( + 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] + ); + const signature = await window.crypto.subtle.sign('HMAC', cryptoKey, msgData); + return Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } +} + // ES Module exports export { - // Code execution + // Code execution (8) executeCode, executeAsync, getJob, @@ -1764,7 +1980,7 @@ export { listJobs, getLanguages, detectLanguage, - // Session management + // Session management (9) listSessions, getSession, createSession, @@ -1774,7 +1990,7 @@ export { boostSession, unboostSession, shellSession, - // Service management + // Service management (17) listServices, createService, getService, @@ -1793,16 +2009,18 @@ export { exportServiceEnv, redeployService, executeInService, - // Snapshot management + resizeService, + // Snapshot management (9) sessionSnapshot, serviceSnapshot, listSnapshots, + getSnapshot, restoreSnapshot, deleteSnapshot, lockSnapshot, unlockSnapshot, cloneSnapshot, - // Images API (LXD container images) + // Images API (13) imagePublish, listImages, getImage, @@ -1816,9 +2034,17 @@ export { transferImage, spawnFromImage, cloneImage, + // PaaS Logs (2) + logsFetch, + logsStream, // Key validation validateKeys, - // Image generation + // Utilities + sdkVersion, + healthCheck, + lastError, + hmacSign, + // Image generation (AI) image, // Errors CredentialsError, @@ -1830,7 +2056,7 @@ export { // Default export for convenience export default { - // Code execution + // Code execution (8) executeCode, executeAsync, getJob, @@ -1839,7 +2065,7 @@ export default { listJobs, getLanguages, detectLanguage, - // Session management + // Session management (9) listSessions, getSession, createSession, @@ -1849,7 +2075,7 @@ export default { boostSession, unboostSession, shellSession, - // Service management + // Service management (17) listServices, createService, getService, @@ -1860,6 +2086,7 @@ export default { lockService, unlockService, setUnfreezeOnDemand, + setShowFreezePage, getServiceLogs, getServiceEnv, setServiceEnv, @@ -1867,16 +2094,18 @@ export default { exportServiceEnv, redeployService, executeInService, - // Snapshot management + resizeService, + // Snapshot management (9) sessionSnapshot, serviceSnapshot, listSnapshots, + getSnapshot, restoreSnapshot, deleteSnapshot, lockSnapshot, unlockSnapshot, cloneSnapshot, - // Images API (LXD container images) + // Images API (13) imagePublish, listImages, getImage, @@ -1890,9 +2119,17 @@ export default { transferImage, spawnFromImage, cloneImage, + // PaaS Logs (2) + logsFetch, + logsStream, // Key validation validateKeys, - // Image generation + // Utilities + sdkVersion, + healthCheck, + lastError, + hmacSign, + // Image generation (AI) image, // Errors CredentialsError, diff --git a/clients/javascript/sync/tests/new_functions.test.js b/clients/javascript/sync/tests/new_functions.test.js new file mode 100644 index 0000000..0d2a992 --- /dev/null +++ b/clients/javascript/sync/tests/new_functions.test.js @@ -0,0 +1,421 @@ +/** + * Tests for new SDK functions (feature parity with C implementation) + */ + +import { createRequire } from 'module'; +import crypto from 'crypto'; + +// Since un.js uses top-level await, we need to import dynamically +let un; + +beforeAll(async () => { + un = await import('../src/un.js'); +}); + +describe('Utility Functions', () => { + describe('sdkVersion', () => { + test('should return a string', () => { + const v = un.sdkVersion(); + expect(typeof v).toBe('string'); + expect(v.length).toBeGreaterThan(0); + }); + + test('should be semantic version format', () => { + const v = un.sdkVersion(); + const parts = v.split('.'); + expect(parts.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('hmacSign', () => { + test('should produce 64-character hex string', async () => { + const signature = await un.hmacSign('secret_key', 'message_to_sign'); + expect(typeof signature).toBe('string'); + expect(signature.length).toBe(64); + // Should be lowercase hex + expect(/^[0-9a-f]+$/.test(signature)).toBe(true); + }); + + test('should be deterministic', async () => { + const sig1 = await un.hmacSign('secret', 'message'); + const sig2 = await un.hmacSign('secret', 'message'); + expect(sig1).toBe(sig2); + }); + + test('should produce different signatures for different secrets', async () => { + const sig1 = await un.hmacSign('secret1', 'message'); + const sig2 = await un.hmacSign('secret2', 'message'); + expect(sig1).not.toBe(sig2); + }); + + test('should produce different signatures for different messages', async () => { + const sig1 = await un.hmacSign('secret', 'message1'); + const sig2 = await un.hmacSign('secret', 'message2'); + expect(sig1).not.toBe(sig2); + }); + + test('should match Node.js crypto HMAC', async () => { + const secret = 'test_secret'; + const message = '1234567890:POST:/execute:'; + const signature = await un.hmacSign(secret, message); + const expected = crypto.createHmac('sha256', secret).update(message).digest('hex'); + expect(signature).toBe(expected); + }); + }); + + describe('lastError', () => { + test('should return null or string', () => { + const error = un.lastError(); + expect(error === null || typeof error === 'string').toBe(true); + }); + }); +}); + +describe('Function Exports', () => { + describe('Execution functions (8)', () => { + test('executeCode is exported', () => { + expect(typeof un.executeCode).toBe('function'); + }); + + test('executeAsync is exported', () => { + expect(typeof un.executeAsync).toBe('function'); + }); + + test('getJob is exported', () => { + expect(typeof un.getJob).toBe('function'); + }); + + test('waitForJob is exported', () => { + expect(typeof un.waitForJob).toBe('function'); + }); + + test('cancelJob is exported', () => { + expect(typeof un.cancelJob).toBe('function'); + }); + + test('listJobs is exported', () => { + expect(typeof un.listJobs).toBe('function'); + }); + + test('getLanguages is exported', () => { + expect(typeof un.getLanguages).toBe('function'); + }); + + test('detectLanguage is exported', () => { + expect(typeof un.detectLanguage).toBe('function'); + }); + }); + + describe('Session functions (9)', () => { + test('listSessions is exported', () => { + expect(typeof un.listSessions).toBe('function'); + }); + + test('getSession is exported', () => { + expect(typeof un.getSession).toBe('function'); + }); + + test('createSession is exported', () => { + expect(typeof un.createSession).toBe('function'); + }); + + test('deleteSession is exported', () => { + expect(typeof un.deleteSession).toBe('function'); + }); + + test('freezeSession is exported', () => { + expect(typeof un.freezeSession).toBe('function'); + }); + + test('unfreezeSession is exported', () => { + expect(typeof un.unfreezeSession).toBe('function'); + }); + + test('boostSession is exported', () => { + expect(typeof un.boostSession).toBe('function'); + }); + + test('unboostSession is exported', () => { + expect(typeof un.unboostSession).toBe('function'); + }); + + test('shellSession is exported', () => { + expect(typeof un.shellSession).toBe('function'); + }); + }); + + describe('Service functions (17)', () => { + test('listServices is exported', () => { + expect(typeof un.listServices).toBe('function'); + }); + + test('createService is exported', () => { + expect(typeof un.createService).toBe('function'); + }); + + test('getService is exported', () => { + expect(typeof un.getService).toBe('function'); + }); + + test('updateService is exported', () => { + expect(typeof un.updateService).toBe('function'); + }); + + test('deleteService is exported', () => { + expect(typeof un.deleteService).toBe('function'); + }); + + test('freezeService is exported', () => { + expect(typeof un.freezeService).toBe('function'); + }); + + test('unfreezeService is exported', () => { + expect(typeof un.unfreezeService).toBe('function'); + }); + + test('lockService is exported', () => { + expect(typeof un.lockService).toBe('function'); + }); + + test('unlockService is exported', () => { + expect(typeof un.unlockService).toBe('function'); + }); + + test('setUnfreezeOnDemand is exported', () => { + expect(typeof un.setUnfreezeOnDemand).toBe('function'); + }); + + test('getServiceLogs is exported', () => { + expect(typeof un.getServiceLogs).toBe('function'); + }); + + test('getServiceEnv is exported', () => { + expect(typeof un.getServiceEnv).toBe('function'); + }); + + test('setServiceEnv is exported', () => { + expect(typeof un.setServiceEnv).toBe('function'); + }); + + test('deleteServiceEnv is exported', () => { + expect(typeof un.deleteServiceEnv).toBe('function'); + }); + + test('exportServiceEnv is exported', () => { + expect(typeof un.exportServiceEnv).toBe('function'); + }); + + test('redeployService is exported', () => { + expect(typeof un.redeployService).toBe('function'); + }); + + test('executeInService is exported', () => { + expect(typeof un.executeInService).toBe('function'); + }); + + test('resizeService is exported (NEW)', () => { + expect(typeof un.resizeService).toBe('function'); + }); + }); + + describe('Snapshot functions (9)', () => { + test('sessionSnapshot is exported', () => { + expect(typeof un.sessionSnapshot).toBe('function'); + }); + + test('serviceSnapshot is exported', () => { + expect(typeof un.serviceSnapshot).toBe('function'); + }); + + test('listSnapshots is exported', () => { + expect(typeof un.listSnapshots).toBe('function'); + }); + + test('getSnapshot is exported (NEW)', () => { + expect(typeof un.getSnapshot).toBe('function'); + }); + + test('restoreSnapshot is exported', () => { + expect(typeof un.restoreSnapshot).toBe('function'); + }); + + test('deleteSnapshot is exported', () => { + expect(typeof un.deleteSnapshot).toBe('function'); + }); + + test('lockSnapshot is exported', () => { + expect(typeof un.lockSnapshot).toBe('function'); + }); + + test('unlockSnapshot is exported', () => { + expect(typeof un.unlockSnapshot).toBe('function'); + }); + + test('cloneSnapshot is exported', () => { + expect(typeof un.cloneSnapshot).toBe('function'); + }); + }); + + describe('Image functions (13)', () => { + test('imagePublish is exported', () => { + expect(typeof un.imagePublish).toBe('function'); + }); + + test('listImages is exported', () => { + expect(typeof un.listImages).toBe('function'); + }); + + test('getImage is exported', () => { + expect(typeof un.getImage).toBe('function'); + }); + + test('deleteImage is exported', () => { + expect(typeof un.deleteImage).toBe('function'); + }); + + test('lockImage is exported', () => { + expect(typeof un.lockImage).toBe('function'); + }); + + test('unlockImage is exported', () => { + expect(typeof un.unlockImage).toBe('function'); + }); + + test('setImageVisibility is exported', () => { + expect(typeof un.setImageVisibility).toBe('function'); + }); + + test('grantImageAccess is exported', () => { + expect(typeof un.grantImageAccess).toBe('function'); + }); + + test('revokeImageAccess is exported', () => { + expect(typeof un.revokeImageAccess).toBe('function'); + }); + + test('listImageTrusted is exported', () => { + expect(typeof un.listImageTrusted).toBe('function'); + }); + + test('transferImage is exported', () => { + expect(typeof un.transferImage).toBe('function'); + }); + + test('spawnFromImage is exported', () => { + expect(typeof un.spawnFromImage).toBe('function'); + }); + + test('cloneImage is exported', () => { + expect(typeof un.cloneImage).toBe('function'); + }); + }); + + describe('PaaS Logs functions (2)', () => { + test('logsFetch is exported (NEW)', () => { + expect(typeof un.logsFetch).toBe('function'); + }); + + test('logsStream is exported (NEW)', () => { + expect(typeof un.logsStream).toBe('function'); + }); + }); + + describe('Utility functions', () => { + test('validateKeys is exported', () => { + expect(typeof un.validateKeys).toBe('function'); + }); + + test('sdkVersion is exported (NEW)', () => { + expect(typeof un.sdkVersion).toBe('function'); + }); + + test('healthCheck is exported (NEW)', () => { + expect(typeof un.healthCheck).toBe('function'); + }); + + test('lastError is exported (NEW)', () => { + expect(typeof un.lastError).toBe('function'); + }); + + test('hmacSign is exported (NEW)', () => { + expect(typeof un.hmacSign).toBe('function'); + }); + }); +}); + +describe('Language Detection', () => { + test('detects Python files', () => { + expect(un.detectLanguage('test.py')).toBe('python'); + }); + + test('detects JavaScript files', () => { + expect(un.detectLanguage('test.js')).toBe('javascript'); + }); + + test('detects TypeScript files', () => { + expect(un.detectLanguage('test.ts')).toBe('typescript'); + }); + + test('detects Ruby files', () => { + expect(un.detectLanguage('test.rb')).toBe('ruby'); + }); + + test('detects Go files', () => { + expect(un.detectLanguage('test.go')).toBe('go'); + }); + + test('detects Rust files', () => { + expect(un.detectLanguage('test.rs')).toBe('rust'); + }); + + test('returns null for unknown extensions', () => { + expect(un.detectLanguage('test.unknown')).toBeNull(); + }); +}); + +// Functional tests require API credentials +const hasCredentials = process.env.UNSANDBOX_PUBLIC_KEY && process.env.UNSANDBOX_SECRET_KEY; + +(hasCredentials ? describe : describe.skip)('Functional API Tests', () => { + test('healthCheck returns boolean', async () => { + const result = await un.healthCheck(); + expect(typeof result).toBe('boolean'); + }); + + test('validateKeys returns object', async () => { + const result = await un.validateKeys(); + expect(typeof result).toBe('object'); + }); + + test('getLanguages returns array with python', async () => { + const languages = await un.getLanguages(); + expect(Array.isArray(languages)).toBe(true); + expect(languages).toContain('python'); + }); + + test('listSessions returns array', async () => { + const sessions = await un.listSessions(); + expect(Array.isArray(sessions)).toBe(true); + }); + + test('listServices returns array', async () => { + const services = await un.listServices(); + expect(Array.isArray(services)).toBe(true); + }); + + test('listSnapshots returns array', async () => { + const snapshots = await un.listSnapshots(); + expect(Array.isArray(snapshots)).toBe(true); + }); + + test('listImages returns array', async () => { + const images = await un.listImages(); + expect(Array.isArray(images)).toBe(true); + }); + + test('executeCode returns result', async () => { + const result = await un.executeCode('python', 'print("hello")'); + expect(typeof result).toBe('object'); + expect(['completed', 'pending']).toContain(result.status); + }); +}); diff --git a/clients/julia/sync/src/un.jl b/clients/julia/sync/src/un.jl index 35c2564..b5b6236 100755 --- a/clients/julia/sync/src/un.jl +++ b/clients/julia/sync/src/un.jl @@ -1026,6 +1026,37 @@ function main() "image" help = "Manage images" action = :command + "snapshot" + help = "Manage snapshots" + action = :command + end + + @add_arg_table! s["snapshot"] begin + "--list", "-l" + help = "List all snapshots" + action = :store_true + "--info" + help = "Get snapshot details" + "--delete" + help = "Delete a snapshot" + "--lock" + help = "Lock snapshot to prevent deletion" + "--unlock" + help = "Unlock snapshot" + "--restore" + help = "Restore from snapshot" + "--clone" + help = "Clone snapshot to session/service (requires --type)" + "--type" + help = "Clone type: session or service" + "--name" + help = "Name for cloned resource" + "--shell" + help = "Shell for cloned session" + "--ports" + help = "Comma-separated ports for cloned service" + "--api-key", "-k" + help = "API key" end @add_arg_table! s["session"] begin @@ -1197,10 +1228,12 @@ function main() cmd_key(args["key"]) elseif args["%COMMAND%"] == "image" cmd_image(args["image"]) + elseif args["%COMMAND%"] == "snapshot" + cmd_snapshot(args["snapshot"]) elseif args["source_file"] !== nothing cmd_execute(args) else - println(stderr, "$(RED)Error: Provide source_file or use 'session'/'service'/'languages'/'key'/'image' subcommand$(RESET)") + println(stderr, "$(RED)Error: Provide source_file or use 'session'/'service'/'snapshot'/'languages'/'key'/'image' subcommand$(RESET)") exit(1) end end @@ -1296,4 +1329,1341 @@ function cmd_image(args) exit(1) end +function cmd_snapshot(args) + (public_key, secret_key) = get_api_keys(args["api-key"]) + + if args["list"] + result = api_request("/snapshots", public_key, secret_key) + snapshots = get(result, "snapshots", []) + if isempty(snapshots) + println("No snapshots found") + else + @printf("%-40s %-20s %-12s %-30s %s\n", "ID", "Name", "Type", "Source ID", "Size") + for s in snapshots + @printf("%-40s %-20s %-12s %-30s %s\n", + get(s, "id", "N/A"), + get(s, "name", "-"), + get(s, "source_type", "N/A"), + get(s, "source_id", "N/A"), + get(s, "size", "N/A")) + end + end + return + end + + if args["info"] !== nothing + result = api_request("/snapshots/$(args["info"])", public_key, secret_key) + println(JSON.json(result, 2)) + return + end + + if args["delete"] !== nothing + api_request_with_sudo("/snapshots/$(args["delete"])", public_key, secret_key, method="DELETE") + println("$(GREEN)Snapshot deleted: $(args["delete"])$(RESET)") + return + end + + if args["lock"] !== nothing + api_request("/snapshots/$(args["lock"])/lock", public_key, secret_key, method="POST") + println("$(GREEN)Snapshot locked: $(args["lock"])$(RESET)") + return + end + + if args["unlock"] !== nothing + api_request_with_sudo("/snapshots/$(args["unlock"])/unlock", public_key, secret_key, method="POST", data=Dict()) + println("$(GREEN)Snapshot unlocked: $(args["unlock"])$(RESET)") + return + end + + if args["restore"] !== nothing + api_request("/snapshots/$(args["restore"])/restore", public_key, secret_key, method="POST", data=Dict()) + println("$(GREEN)Snapshot restored: $(args["restore"])$(RESET)") + return + end + + if args["clone"] !== nothing + clone_type = args["type"] + if clone_type === nothing + println(stderr, "$(RED)Error: --type required for --clone (session or service)$(RESET)") + exit(1) + end + payload = Dict("type" => clone_type) + if args["name"] !== nothing + payload["name"] = args["name"] + end + if args["shell"] !== nothing + payload["shell"] = args["shell"] + end + if args["ports"] !== nothing + ports = [parse(Int, strip(p)) for p in split(args["ports"], ',')] + payload["ports"] = ports + end + result = api_request("/snapshots/$(args["clone"])/clone", public_key, secret_key, method="POST", data=payload) + println("$(GREEN)Cloned from snapshot$(RESET)") + println(JSON.json(result, 2)) + return + end + + println(stderr, "$(RED)Error: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --restore ID, or --clone ID$(RESET)") + exit(1) +end + +# ============================================================================= +# Library API Functions (for import/use as a module) +# ============================================================================= + +const VERSION = "1.0.0" + +""" + execute(language::String, code::String; kwargs...) -> Dict + +Execute code synchronously and return the result. + +# Arguments +- `language`: Programming language (e.g., "python", "javascript") +- `code`: Source code to execute + +# Keyword Arguments +- `env::Dict{String,String}`: Environment variables +- `network::String`: Network mode ("zerotrust" or "semitrusted") +- `public_key::String`: API public key (optional) +- `secret_key::String`: API secret key (optional) + +# Returns +Dict with `stdout`, `stderr`, `exit_code`, `success` + +# Example +```julia +result = execute("python", "print('Hello, World!')") +println(result["stdout"]) +``` +""" +function execute(language::String, code::String; + env::Union{Dict{String,String}, Nothing}=nothing, + network::String="zerotrust", + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("language" => language, "code" => code) + if env !== nothing + payload["env"] = env + end + if network != "zerotrust" + payload["network"] = network + end + + return api_request("/execute", pk, sk, method="POST", data=payload) +end + +""" + execute_async(language::String, code::String; kwargs...) -> String + +Execute code asynchronously and return job ID. + +# Returns +Job ID string for polling with `wait_job` or `get_job`. +""" +function execute_async(language::String, code::String; + env::Union{Dict{String,String}, Nothing}=nothing, + network::String="zerotrust", + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("language" => language, "code" => code, "async" => true) + if env !== nothing + payload["env"] = env + end + if network != "zerotrust" + payload["network"] = network + end + + result = api_request("/execute", pk, sk, method="POST", data=payload) + return get(result, "job_id", "") +end + +""" + wait_job(job_id::String; kwargs...) -> Dict + +Wait for an async job to complete and return results. +""" +function wait_job(job_id::String; + poll_interval::Int=1, + max_wait::Int=300, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + start_time = time() + while true + result = get_job(job_id, public_key=pk, secret_key=sk) + status = get(result, "status", "") + if status in ["completed", "failed", "timeout", "cancelled"] + return result + end + if time() - start_time >= max_wait + error("Job $job_id did not complete within $max_wait seconds") + end + sleep(poll_interval) + end +end + +""" + get_job(job_id::String; kwargs...) -> Dict + +Get the status of an async job. +""" +function get_job(job_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/jobs/$job_id", pk, sk) +end + +""" + cancel_job(job_id::String; kwargs...) -> Bool + +Cancel a running job. +""" +function cancel_job(job_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/jobs/$job_id", pk, sk, method="DELETE") + return true +end + +""" + list_jobs(; kwargs...) -> Vector{Dict} + +List all jobs for the account. +""" +function list_jobs(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/jobs", pk, sk) + return get(result, "jobs", []) +end + +""" + get_languages(; kwargs...) -> Vector{String} + +Get list of supported programming languages. +""" +function get_languages(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/languages", pk, sk) + return get(result, "languages", []) +end + +""" + session_list(; kwargs...) -> Vector{Dict} + +List all active sessions. +""" +function session_list(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/sessions", pk, sk) + return get(result, "sessions", []) +end + +""" + session_get(session_id::String; kwargs...) -> Dict + +Get details of a session. +""" +function session_get(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/sessions/$session_id", pk, sk) +end + +""" + session_create(; kwargs...) -> Dict + +Create a new interactive session. +""" +function session_create(; + shell::String="bash", + network::String="zerotrust", + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("shell" => shell) + if network != "zerotrust" + payload["network"] = network + end + + return api_request("/sessions", pk, sk, method="POST", data=payload) +end + +""" + session_destroy(session_id::String; kwargs...) -> Bool + +Destroy a session. +""" +function session_destroy(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/sessions/$session_id", pk, sk, method="DELETE") + return true +end + +""" + session_freeze(session_id::String; kwargs...) -> Bool + +Freeze (pause) a session. +""" +function session_freeze(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/sessions/$session_id/freeze", pk, sk, method="POST") + return true +end + +""" + session_unfreeze(session_id::String; kwargs...) -> Bool + +Unfreeze (resume) a session. +""" +function session_unfreeze(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/sessions/$session_id/unfreeze", pk, sk, method="POST") + return true +end + +""" + session_boost(session_id::String, vcpu::Int; kwargs...) -> Bool + +Boost session resources. +""" +function session_boost(session_id::String, vcpu::Int; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_patch("/sessions/$session_id", pk, sk, data=Dict("vcpu" => vcpu)) + return true +end + +""" + session_unboost(session_id::String; kwargs...) -> Bool + +Remove session boost. +""" +function session_unboost(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_patch("/sessions/$session_id", pk, sk, data=Dict("vcpu" => 1)) + return true +end + +""" + session_execute(session_id::String, command::String; kwargs...) -> Dict + +Execute a command in a session. +""" +function session_execute(session_id::String, command::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/sessions/$session_id/execute", pk, sk, method="POST", data=Dict("command" => command)) +end + +""" + service_list(; kwargs...) -> Vector{Dict} + +List all services. +""" +function service_list(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/services", pk, sk) + return get(result, "services", []) +end + +""" + service_get(service_id::String; kwargs...) -> Dict + +Get details of a service. +""" +function service_get(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/services/$service_id", pk, sk) +end + +""" + service_create(name::String; kwargs...) -> Dict + +Create a new service. +""" +function service_create(name::String; + ports::Union{Vector{Int}, Nothing}=nothing, + domains::Union{Vector{String}, Nothing}=nothing, + bootstrap::Union{String, Nothing}=nothing, + network::String="semitrusted", + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("name" => name) + if ports !== nothing + payload["ports"] = ports + end + if domains !== nothing + payload["domains"] = domains + end + if bootstrap !== nothing + payload["bootstrap"] = bootstrap + end + if network != "semitrusted" + payload["network"] = network + end + + return api_request("/services", pk, sk, method="POST", data=payload) +end + +""" + service_destroy(service_id::String; kwargs...) -> Bool + +Destroy a service. +""" +function service_destroy(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/services/$service_id", pk, sk, method="DELETE") + return true +end + +""" + service_freeze(service_id::String; kwargs...) -> Bool + +Freeze (pause) a service. +""" +function service_freeze(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/freeze", pk, sk, method="POST") + return true +end + +""" + service_unfreeze(service_id::String; kwargs...) -> Bool + +Unfreeze (resume) a service. +""" +function service_unfreeze(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/unfreeze", pk, sk, method="POST") + return true +end + +""" + service_lock(service_id::String; kwargs...) -> Bool + +Lock a service to prevent deletion. +""" +function service_lock(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/lock", pk, sk, method="POST") + return true +end + +""" + service_unlock(service_id::String; kwargs...) -> Bool + +Unlock a service. +""" +function service_unlock(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/services/$service_id/unlock", pk, sk, method="POST", data=Dict()) + return true +end + +""" + service_set_unfreeze_on_demand(service_id::String, enabled::Bool; kwargs...) -> Bool + +Set unfreeze-on-demand for a service. +""" +function service_set_unfreeze_on_demand(service_id::String, enabled::Bool; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_patch("/services/$service_id", pk, sk, data=Dict("unfreeze_on_demand" => enabled)) + return true +end + +""" + service_redeploy(service_id::String; kwargs...) -> Bool + +Redeploy a service (re-run bootstrap). +""" +function service_redeploy(service_id::String; + bootstrap::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = bootstrap !== nothing ? Dict("bootstrap" => bootstrap) : Dict() + api_request("/services/$service_id/redeploy", pk, sk, method="POST", data=payload) + return true +end + +""" + service_logs(service_id::String; kwargs...) -> String + +Get service logs. +""" +function service_logs(service_id::String; + all_logs::Bool=false, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + endpoint = all_logs ? "/services/$service_id/logs?all=true" : "/services/$service_id/logs" + result = api_request(endpoint, pk, sk) + return get(result, "logs", "") +end + +""" + service_execute(service_id::String, command::String; kwargs...) -> Dict + +Execute a command in a service. +""" +function service_execute(service_id::String, command::String; + timeout_ms::Int=30000, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/services/$service_id/execute", pk, sk, method="POST", + data=Dict("command" => command, "timeout_ms" => timeout_ms)) +end + +""" + service_resize(service_id::String, vcpu::Int; kwargs...) -> Bool + +Resize a service. +""" +function service_resize(service_id::String, vcpu::Int; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_patch("/services/$service_id", pk, sk, data=Dict("vcpu" => vcpu)) + return true +end + +""" + service_env_get(service_id::String; kwargs...) -> String + +Get service environment variables. +""" +function service_env_get(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/services/$service_id/env", pk, sk) + return get(result, "env", "") +end + +""" + service_env_set(service_id::String, env_content::String; kwargs...) -> Bool + +Set service environment variables. +""" +function service_env_set(service_id::String, env_content::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/env", pk, sk, method="POST", data=Dict("env" => env_content)) + return true +end + +""" + service_env_delete(service_id::String; kwargs...) -> Bool + +Delete service environment variables. +""" +function service_env_delete(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/env", pk, sk, method="DELETE") + return true +end + +""" + service_env_export(service_id::String; kwargs...) -> String + +Export service environment variables as shell format. +""" +function service_env_export(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/services/$service_id/env/export", pk, sk) + return get(result, "export", "") +end + +""" + snapshot_list(; kwargs...) -> Vector{Dict} + +List all snapshots. +""" +function snapshot_list(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/snapshots", pk, sk) + return get(result, "snapshots", []) +end + +""" + snapshot_get(snapshot_id::String; kwargs...) -> Dict + +Get details of a snapshot. +""" +function snapshot_get(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/snapshots/$snapshot_id", pk, sk) +end + +""" + snapshot_session(session_id::String; kwargs...) -> String + +Create a snapshot from a session. Returns snapshot ID. +""" +function snapshot_session(session_id::String; + name::Union{String, Nothing}=nothing, + hot::Bool=false, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict() + if name !== nothing + payload["name"] = name + end + if hot + payload["hot"] = true + end + + result = api_request("/sessions/$session_id/snapshot", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + snapshot_service(service_id::String; kwargs...) -> String + +Create a snapshot from a service. Returns snapshot ID. +""" +function snapshot_service(service_id::String; + name::Union{String, Nothing}=nothing, + hot::Bool=false, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict() + if name !== nothing + payload["name"] = name + end + if hot + payload["hot"] = true + end + + result = api_request("/services/$service_id/snapshot", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + snapshot_restore(snapshot_id::String; kwargs...) -> Bool + +Restore from a snapshot. +""" +function snapshot_restore(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/snapshots/$snapshot_id/restore", pk, sk, method="POST", data=Dict()) + return true +end + +""" + snapshot_delete(snapshot_id::String; kwargs...) -> Bool + +Delete a snapshot. +""" +function snapshot_delete(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/snapshots/$snapshot_id", pk, sk, method="DELETE") + return true +end + +""" + snapshot_lock(snapshot_id::String; kwargs...) -> Bool + +Lock a snapshot to prevent deletion. +""" +function snapshot_lock(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/snapshots/$snapshot_id/lock", pk, sk, method="POST") + return true +end + +""" + snapshot_unlock(snapshot_id::String; kwargs...) -> Bool + +Unlock a snapshot. +""" +function snapshot_unlock(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/snapshots/$snapshot_id/unlock", pk, sk, method="POST", data=Dict()) + return true +end + +""" + snapshot_clone(snapshot_id::String, clone_type::String; kwargs...) -> String + +Clone a snapshot to a new session or service. Returns the new resource ID. +""" +function snapshot_clone(snapshot_id::String, clone_type::String; + name::Union{String, Nothing}=nothing, + ports::Union{Vector{Int}, Nothing}=nothing, + shell::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("type" => clone_type) + if name !== nothing + payload["name"] = name + end + if ports !== nothing + payload["ports"] = ports + end + if shell !== nothing + payload["shell"] = shell + end + + result = api_request("/snapshots/$snapshot_id/clone", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + image_list(; kwargs...) -> Vector{Dict} + +List all images. +""" +function image_list(; + filter::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + endpoint = filter !== nothing ? "/images?filter=$filter" : "/images" + result = api_request(endpoint, pk, sk) + return get(result, "images", []) +end + +""" + image_get(image_id::String; kwargs...) -> Dict + +Get details of an image. +""" +function image_get(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/images/$image_id", pk, sk) +end + +""" + image_publish(source_type::String, source_id::String; kwargs...) -> String + +Publish an image from a service or snapshot. Returns image ID. +""" +function image_publish(source_type::String, source_id::String; + name::Union{String, Nothing}=nothing, + description::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("source_type" => source_type, "source_id" => source_id) + if name !== nothing + payload["name"] = name + end + if description !== nothing + payload["description"] = description + end + + result = api_request("/images/publish", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + image_delete(image_id::String; kwargs...) -> Bool + +Delete an image. +""" +function image_delete(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/images/$image_id", pk, sk, method="DELETE") + return true +end + +""" + image_lock(image_id::String; kwargs...) -> Bool + +Lock an image to prevent deletion. +""" +function image_lock(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/lock", pk, sk, method="POST") + return true +end + +""" + image_unlock(image_id::String; kwargs...) -> Bool + +Unlock an image. +""" +function image_unlock(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/images/$image_id/unlock", pk, sk, method="POST", data=Dict()) + return true +end + +""" + image_set_visibility(image_id::String, visibility::String; kwargs...) -> Bool + +Set image visibility (private, unlisted, or public). +""" +function image_set_visibility(image_id::String, visibility::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/visibility", pk, sk, method="POST", data=Dict("visibility" => visibility)) + return true +end + +""" + image_grant_access(image_id::String, trusted_api_key::String; kwargs...) -> Bool + +Grant access to an image. +""" +function image_grant_access(image_id::String, trusted_api_key::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/access", pk, sk, method="POST", data=Dict("trusted_api_key" => trusted_api_key)) + return true +end + +""" + image_revoke_access(image_id::String, trusted_api_key::String; kwargs...) -> Bool + +Revoke access to an image. +""" +function image_revoke_access(image_id::String, trusted_api_key::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/access/$trusted_api_key", pk, sk, method="DELETE") + return true +end + +""" + image_list_trusted(image_id::String; kwargs...) -> Vector{String} + +List trusted API keys for an image. +""" +function image_list_trusted(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/images/$image_id/access", pk, sk) + return get(result, "trusted_keys", []) +end + +""" + image_transfer(image_id::String, to_api_key::String; kwargs...) -> Bool + +Transfer ownership of an image. +""" +function image_transfer(image_id::String, to_api_key::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/transfer", pk, sk, method="POST", data=Dict("to_api_key" => to_api_key)) + return true +end + +""" + image_spawn(image_id::String; kwargs...) -> String + +Spawn a new service from an image. Returns service ID. +""" +function image_spawn(image_id::String; + name::Union{String, Nothing}=nothing, + ports::Union{Vector{Int}, Nothing}=nothing, + bootstrap::Union{String, Nothing}=nothing, + network::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict() + if name !== nothing + payload["name"] = name + end + if ports !== nothing + payload["ports"] = ports + end + if bootstrap !== nothing + payload["bootstrap"] = bootstrap + end + if network !== nothing + payload["network"] = network + end + + result = api_request("/images/$image_id/spawn", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + image_clone(image_id::String; kwargs...) -> String + +Clone an image. Returns new image ID. +""" +function image_clone(image_id::String; + name::Union{String, Nothing}=nothing, + description::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict() + if name !== nothing + payload["name"] = name + end + if description !== nothing + payload["description"] = description + end + + result = api_request("/images/$image_id/clone", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + logs_fetch(source::String; kwargs...) -> String + +Fetch PaaS logs. +""" +function logs_fetch(source::String; + lines::Int=100, + since::String="1h", + grep::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + endpoint = "/logs?source=$source&lines=$lines&since=$since" + if grep !== nothing + endpoint *= "&grep=$grep" + end + + result = api_request(endpoint, pk, sk) + return get(result, "logs", "") +end + +""" + validate_keys(; kwargs...) -> Dict + +Validate API keys and return account info. +""" +function validate_keys(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + url = PORTAL_BASE * "/keys/validate" + timestamp = Int64(floor(time())) + body = "{}" + signature = compute_signature(sk, timestamp, "POST", "/keys/validate", body) + + headers = [ + "Authorization" => "Bearer $pk", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, + "Content-Type" => "application/json" + ] + + try + response = HTTP.post(url, headers, body, readtimeout=30) + return JSON.parse(String(response.body)) + catch e + return Dict("valid" => false, "error" => string(e)) + end +end + +""" + health_check() -> Bool + +Check if the API is healthy. +""" +function health_check() + try + response = HTTP.get("$API_BASE/health", readtimeout=10) + return response.status == 200 + catch + return false + end +end + +""" + version() -> String + +Get SDK version. +""" +function version() + return VERSION +end + +# Thread-local error storage +const _last_error = Ref{String}("") + +""" + last_error() -> String + +Get the last error message. +""" +function last_error() + return _last_error[] +end + +""" + set_last_error(msg::String) + +Set the last error message (internal use). +""" +function set_last_error(msg::String) + _last_error[] = msg +end + +""" + hmac_sign(secret_key::String, message::String) -> String + +Compute HMAC-SHA256 signature. +""" +function hmac_sign(secret_key::String, message::String) + return bytes2hex(SHA.hmac_sha256(Vector{UInt8}(secret_key), Vector{UInt8}(message))) +end + main() diff --git a/clients/julia/tests/test_un.jl b/clients/julia/tests/test_un.jl new file mode 100644 index 0000000..b9001ff --- /dev/null +++ b/clients/julia/tests/test_un.jl @@ -0,0 +1,137 @@ +#!/usr/bin/env julia +# Test suite for Julia Unsandbox SDK +# Run: julia tests/test_un.jl + +using Test + +# Add parent directory to path so we can include the SDK +push!(LOAD_PATH, joinpath(@__DIR__, "..", "sync", "src")) + +# Include the SDK (don't run main) +include(joinpath(@__DIR__, "..", "sync", "src", "un.jl")) + +# Test constants +@testset "Constants" begin + @test API_BASE == "https://api.unsandbox.com" + @test PORTAL_BASE == "https://unsandbox.com" + @test LANGUAGES_CACHE_TTL == 3600 +end + +# Test language detection +@testset "Language Detection" begin + @test detect_language("test.py") == "python" + @test detect_language("test.js") == "javascript" + @test detect_language("test.rb") == "ruby" + @test detect_language("test.jl") == "julia" + @test detect_language("test.go") == "go" + @test detect_language("test.rs") == "rust" + @test detect_language("test.f90") == "fortran" + @test detect_language("test.cob") == "cobol" + @test detect_language("test.pro") == "prolog" + @test detect_language("test.unknown") == "unknown" +end + +# Test HMAC signing +@testset "HMAC Signing" begin + sig = hmac_sign("secret", "message") + @test typeof(sig) == String + @test length(sig) == 64 # SHA256 hex is 64 chars + # Verify deterministic + sig2 = hmac_sign("secret", "message") + @test sig == sig2 +end + +# Test version +@testset "Version" begin + v = version() + @test typeof(v) == String + @test v == VERSION +end + +# Test health check (requires network) +@testset "Health Check" begin + # Skip if no network + if haskey(ENV, "SKIP_NETWORK_TESTS") + @test_skip health_check() + else + result = health_check() + @test typeof(result) == Bool + end +end + +# Test library function signatures (don't call API) +@testset "Library Function Signatures" begin + # Execution functions + @test hasmethod(execute, Tuple{String, String}) + @test hasmethod(execute_async, Tuple{String, String}) + @test hasmethod(wait_job, Tuple{String}) + @test hasmethod(get_job, Tuple{String}) + @test hasmethod(cancel_job, Tuple{String}) + @test hasmethod(list_jobs, Tuple{}) + @test hasmethod(get_languages, Tuple{}) + + # Session functions + @test hasmethod(session_list, Tuple{}) + @test hasmethod(session_get, Tuple{String}) + @test hasmethod(session_create, Tuple{}) + @test hasmethod(session_destroy, Tuple{String}) + @test hasmethod(session_freeze, Tuple{String}) + @test hasmethod(session_unfreeze, Tuple{String}) + @test hasmethod(session_boost, Tuple{String, Int}) + @test hasmethod(session_unboost, Tuple{String}) + @test hasmethod(session_execute, Tuple{String, String}) + + # Service functions + @test hasmethod(service_list, Tuple{}) + @test hasmethod(service_get, Tuple{String}) + @test hasmethod(service_create, Tuple{String}) + @test hasmethod(service_destroy, Tuple{String}) + @test hasmethod(service_freeze, Tuple{String}) + @test hasmethod(service_unfreeze, Tuple{String}) + @test hasmethod(service_lock, Tuple{String}) + @test hasmethod(service_unlock, Tuple{String}) + @test hasmethod(service_redeploy, Tuple{String}) + @test hasmethod(service_logs, Tuple{String}) + @test hasmethod(service_execute, Tuple{String, String}) + @test hasmethod(service_resize, Tuple{String, Int}) + @test hasmethod(service_env_get, Tuple{String}) + @test hasmethod(service_env_set, Tuple{String, String}) + @test hasmethod(service_env_delete, Tuple{String}) + @test hasmethod(service_env_export, Tuple{String}) + + # Snapshot functions + @test hasmethod(snapshot_list, Tuple{}) + @test hasmethod(snapshot_get, Tuple{String}) + @test hasmethod(snapshot_session, Tuple{String}) + @test hasmethod(snapshot_service, Tuple{String}) + @test hasmethod(snapshot_restore, Tuple{String}) + @test hasmethod(snapshot_delete, Tuple{String}) + @test hasmethod(snapshot_lock, Tuple{String}) + @test hasmethod(snapshot_unlock, Tuple{String}) + @test hasmethod(snapshot_clone, Tuple{String, String}) + + # Image functions + @test hasmethod(image_list, Tuple{}) + @test hasmethod(image_get, Tuple{String}) + @test hasmethod(image_publish, Tuple{String, String}) + @test hasmethod(image_delete, Tuple{String}) + @test hasmethod(image_lock, Tuple{String}) + @test hasmethod(image_unlock, Tuple{String}) + @test hasmethod(image_set_visibility, Tuple{String, String}) + @test hasmethod(image_grant_access, Tuple{String, String}) + @test hasmethod(image_revoke_access, Tuple{String, String}) + @test hasmethod(image_list_trusted, Tuple{String}) + @test hasmethod(image_transfer, Tuple{String, String}) + @test hasmethod(image_spawn, Tuple{String}) + @test hasmethod(image_clone, Tuple{String}) + + # Utility functions + @test hasmethod(validate_keys, Tuple{}) + @test hasmethod(logs_fetch, Tuple{String}) + @test hasmethod(health_check, Tuple{}) + @test hasmethod(version, Tuple{}) + @test hasmethod(last_error, Tuple{}) + @test hasmethod(hmac_sign, Tuple{String, String}) +end + +println("\nAll tests passed!") diff --git a/clients/kotlin/sync/src/un.kt b/clients/kotlin/sync/src/un.kt index 5486d22..4bba4a2 100644 --- a/clients/kotlin/sync/src/un.kt +++ b/clients/kotlin/sync/src/un.kt @@ -1316,6 +1316,457 @@ fun parseArgs(args: Array): Args { return result } +// ============================================================================ +// Library Functions (for programmatic use) +// ============================================================================ + +/** + * SDK version string. + */ +fun version(): String = "4.2.0" + +/** + * Check API health status. + */ +fun healthCheck(): Boolean { + return try { + val url = URL("$API_BASE/health") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + connection.responseCode == 200 + } catch (e: Exception) { + false + } +} + +/** + * Generate HMAC-SHA256 signature. + */ +fun hmacSign(secretKey: String, message: String): String { + val mac = Mac.getInstance("HmacSHA256") + val keySpec = SecretKeySpec(secretKey.toByteArray(Charsets.UTF_8), "HmacSHA256") + mac.init(keySpec) + return mac.doFinal(message.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } +} + +/** + * Execute code synchronously. + */ +fun execute(language: String, code: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) { + Pair(publicKey, secretKey) + } else { + getApiKeys(null) + } + val payload = mapOf("language" to language, "code" to code) + return apiRequest("/execute", "POST", payload, pk, sk) +} + +/** + * Execute code asynchronously, returns job ID. + */ +fun executeAsync(language: String, code: String, publicKey: String? = null, secretKey: String? = null): String? { + val result = execute(language, code, publicKey, secretKey) + return result["job_id"]?.toString() +} + +/** + * Get job status and result. + */ +fun getJob(jobId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) { + Pair(publicKey, secretKey) + } else { + getApiKeys(null) + } + return apiRequest("/jobs/$jobId", "GET", null, pk, sk) +} + +/** + * Wait for job completion with polling. + */ +fun waitJob(jobId: String, publicKey: String? = null, secretKey: String? = null, timeoutMs: Long = 0): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) { + Pair(publicKey, secretKey) + } else { + getApiKeys(null) + } + val pollDelays = intArrayOf(300, 450, 700, 900, 650, 1600, 2000) + val terminalStates = setOf("completed", "failed", "timeout", "cancelled") + val startTime = System.currentTimeMillis() + var pollCount = 0 + + while (true) { + val delayIdx = minOf(pollCount, pollDelays.size - 1) + Thread.sleep(pollDelays[delayIdx].toLong()) + pollCount++ + + if (timeoutMs > 0 && System.currentTimeMillis() - startTime > timeoutMs) { + throw RuntimeException("Timeout waiting for job $jobId") + } + + val result = getJob(jobId, pk, sk) + val status = result["status"]?.toString() ?: "" + if (status in terminalStates) { + return result + } + } +} + +/** + * Cancel a running job. + */ +fun cancelJob(jobId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) { + Pair(publicKey, secretKey) + } else { + getApiKeys(null) + } + return apiRequest("/jobs/$jobId", "DELETE", null, pk, sk) +} + +/** + * List all jobs. + */ +fun listJobs(publicKey: String? = null, secretKey: String? = null): List> { + val (pk, sk) = if (publicKey != null && secretKey != null) { + Pair(publicKey, secretKey) + } else { + getApiKeys(null) + } + val result = apiRequest("/jobs", "GET", null, pk, sk) + @Suppress("UNCHECKED_CAST") + return result["jobs"] as? List> ?: emptyList() +} + +/** + * Get supported languages. + */ +fun getLanguages(publicKey: String? = null, secretKey: String? = null): List { + val cached = loadLanguagesCache() + if (cached != null) return cached + + val (pk, sk) = if (publicKey != null && secretKey != null) { + Pair(publicKey, secretKey) + } else { + getApiKeys(null) + } + val result = apiRequest("/languages", "GET", null, pk, sk) + @Suppress("UNCHECKED_CAST") + val languages = result["languages"] as? List ?: emptyList() + saveLanguagesCache(languages) + return languages +} + +// Session functions +fun sessionList(publicKey: String? = null, secretKey: String? = null): List> { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val result = apiRequest("/sessions", "GET", null, pk, sk) + @Suppress("UNCHECKED_CAST") + return result["sessions"] as? List> ?: emptyList() +} + +fun sessionGet(sessionId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/sessions/$sessionId", "GET", null, pk, sk) +} + +fun sessionCreate(networkMode: String? = null, shell: String? = null, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = mutableMapOf("shell" to (shell ?: "bash")) + if (networkMode != null) payload["network_mode"] = networkMode + return apiRequest("/sessions", "POST", payload, pk, sk) +} + +fun sessionDestroy(sessionId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/sessions/$sessionId", "DELETE", null, pk, sk) +} + +fun sessionFreeze(sessionId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/sessions/$sessionId/freeze", "POST", null, pk, sk) +} + +fun sessionUnfreeze(sessionId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/sessions/$sessionId/unfreeze", "POST", null, pk, sk) +} + +fun sessionBoost(sessionId: String, vcpu: Int = 2, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/sessions/$sessionId/boost", "POST", mapOf("vcpu" to vcpu), pk, sk) +} + +fun sessionUnboost(sessionId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/sessions/$sessionId/unboost", "POST", null, pk, sk) +} + +fun sessionExecute(sessionId: String, command: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/sessions/$sessionId/shell", "POST", mapOf("command" to command), pk, sk) +} + +// Service functions +fun serviceList(publicKey: String? = null, secretKey: String? = null): List> { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val result = apiRequest("/services", "GET", null, pk, sk) + @Suppress("UNCHECKED_CAST") + return result["services"] as? List> ?: emptyList() +} + +fun serviceGet(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/services/$serviceId", "GET", null, pk, sk) +} + +fun serviceCreate(name: String, ports: String? = null, domains: String? = null, bootstrap: String? = null, networkMode: String? = null, publicKey: String? = null, secretKey: String? = null): String? { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = mutableMapOf("name" to name) + if (ports != null) payload["ports"] = ports.split(",").map { it.trim().toInt() } + if (domains != null) payload["domains"] = domains + if (bootstrap != null) payload["bootstrap"] = bootstrap + if (networkMode != null) payload["network_mode"] = networkMode + val result = apiRequest("/services", "POST", payload, pk, sk) + return result["id"]?.toString() +} + +fun serviceDestroy(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequestDestructive("/services/$serviceId", "DELETE", null, pk, sk) +} + +fun serviceFreeze(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/services/$serviceId/freeze", "POST", null, pk, sk) +} + +fun serviceUnfreeze(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/services/$serviceId/unfreeze", "POST", null, pk, sk) +} + +fun serviceLock(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/services/$serviceId/lock", "POST", null, pk, sk) +} + +fun serviceUnlock(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequestDestructive("/services/$serviceId/unlock", "POST", null, pk, sk) +} + +fun serviceSetUnfreezeOnDemand(serviceId: String, enabled: Boolean, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequestPatch("/services/$serviceId", mapOf("unfreeze_on_demand" to enabled), pk, sk) +} + +fun serviceRedeploy(serviceId: String, bootstrap: String? = null, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = if (bootstrap != null) mapOf("bootstrap" to bootstrap) else emptyMap() + return apiRequest("/services/$serviceId/redeploy", "POST", payload, pk, sk) +} + +fun serviceLogs(serviceId: String, allLogs: Boolean = false, publicKey: String? = null, secretKey: String? = null): String? { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val path = if (allLogs) "/services/$serviceId/logs?all=true" else "/services/$serviceId/logs" + val result = apiRequest(path, "GET", null, pk, sk) + return result["logs"]?.toString() +} + +fun serviceExecute(serviceId: String, command: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/services/$serviceId/execute", "POST", mapOf("command" to command), pk, sk) +} + +fun serviceEnvGet(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/services/$serviceId/env", "GET", null, pk, sk) +} + +fun serviceEnvSet(serviceId: String, envContent: String, publicKey: String? = null, secretKey: String? = null): Boolean { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val (success, _) = apiRequestText("/services/$serviceId/env", "PUT", envContent, pk, sk) + return success +} + +fun serviceEnvDelete(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/services/$serviceId/env", "DELETE", null, pk, sk) +} + +fun serviceEnvExport(serviceId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/services/$serviceId/env/export", "POST", emptyMap(), pk, sk) +} + +fun serviceResize(serviceId: String, vcpu: Int, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequestPatch("/services/$serviceId", mapOf("vcpu" to vcpu), pk, sk) +} + +// Snapshot functions +fun snapshotList(publicKey: String? = null, secretKey: String? = null): List> { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val result = apiRequest("/snapshots", "GET", null, pk, sk) + @Suppress("UNCHECKED_CAST") + return result["snapshots"] as? List> ?: emptyList() +} + +fun snapshotGet(snapshotId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/snapshots/$snapshotId", "GET", null, pk, sk) +} + +fun snapshotSession(sessionId: String, name: String? = null, hot: Boolean = false, publicKey: String? = null, secretKey: String? = null): String? { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = mutableMapOf("session_id" to sessionId, "hot" to hot) + if (name != null) payload["name"] = name + val result = apiRequest("/snapshots", "POST", payload, pk, sk) + return result["snapshot_id"]?.toString() +} + +fun snapshotService(serviceId: String, name: String? = null, hot: Boolean = false, publicKey: String? = null, secretKey: String? = null): String? { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = mutableMapOf("service_id" to serviceId, "hot" to hot) + if (name != null) payload["name"] = name + val result = apiRequest("/snapshots", "POST", payload, pk, sk) + return result["snapshot_id"]?.toString() +} + +fun snapshotRestore(snapshotId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/snapshots/$snapshotId/restore", "POST", emptyMap(), pk, sk) +} + +fun snapshotDelete(snapshotId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequestDestructive("/snapshots/$snapshotId", "DELETE", null, pk, sk) +} + +fun snapshotLock(snapshotId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/snapshots/$snapshotId/lock", "POST", null, pk, sk) +} + +fun snapshotUnlock(snapshotId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequestDestructive("/snapshots/$snapshotId/unlock", "POST", null, pk, sk) +} + +fun snapshotClone(snapshotId: String, cloneType: String, name: String? = null, ports: String? = null, shell: String? = null, publicKey: String? = null, secretKey: String? = null): String? { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = mutableMapOf("type" to cloneType) + if (name != null) payload["name"] = name + if (ports != null) payload["ports"] = ports.split(",").map { it.trim().toInt() } + if (shell != null) payload["shell"] = shell + val result = apiRequest("/snapshots/$snapshotId/clone", "POST", payload, pk, sk) + return (result["session_id"] ?: result["service_id"])?.toString() +} + +// Image functions +fun imageList(filter: String? = null, publicKey: String? = null, secretKey: String? = null): List> { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val path = if (filter != null) "/images/$filter" else "/images" + val result = apiRequest(path, "GET", null, pk, sk) + @Suppress("UNCHECKED_CAST") + return result["images"] as? List> ?: emptyList() +} + +fun imageGet(imageId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/images/$imageId", "GET", null, pk, sk) +} + +fun imagePublish(sourceType: String, sourceId: String, name: String? = null, description: String? = null, publicKey: String? = null, secretKey: String? = null): String? { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = mutableMapOf("source_type" to sourceType, "source_id" to sourceId) + if (name != null) payload["name"] = name + if (description != null) payload["description"] = description + val result = apiRequest("/images", "POST", payload, pk, sk) + return result["image_id"]?.toString() +} + +fun imageDelete(imageId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequestDestructive("/images/$imageId", "DELETE", null, pk, sk) +} + +fun imageLock(imageId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/images/$imageId/lock", "POST", null, pk, sk) +} + +fun imageUnlock(imageId: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequestDestructive("/images/$imageId/unlock", "POST", null, pk, sk) +} + +fun imageSetVisibility(imageId: String, visibility: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/images/$imageId/visibility", "POST", mapOf("visibility" to visibility), pk, sk) +} + +fun imageGrantAccess(imageId: String, trustedApiKey: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/images/$imageId/grant", "POST", mapOf("trusted_api_key" to trustedApiKey), pk, sk) +} + +fun imageRevokeAccess(imageId: String, trustedApiKey: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/images/$imageId/revoke", "POST", mapOf("trusted_api_key" to trustedApiKey), pk, sk) +} + +fun imageListTrusted(imageId: String, publicKey: String? = null, secretKey: String? = null): List { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val result = apiRequest("/images/$imageId/trusted", "GET", null, pk, sk) + @Suppress("UNCHECKED_CAST") + return result["trusted"] as? List ?: emptyList() +} + +fun imageTransfer(imageId: String, toApiKey: String, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return apiRequest("/images/$imageId/transfer", "POST", mapOf("to_api_key" to toApiKey), pk, sk) +} + +fun imageSpawn(imageId: String, name: String? = null, ports: String? = null, bootstrap: String? = null, networkMode: String? = null, publicKey: String? = null, secretKey: String? = null): String? { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = mutableMapOf() + if (name != null) payload["name"] = name + if (ports != null) payload["ports"] = ports.split(",").map { it.trim().toInt() } + if (bootstrap != null) payload["bootstrap"] = bootstrap + if (networkMode != null) payload["network_mode"] = networkMode + val result = apiRequest("/images/$imageId/spawn", "POST", payload, pk, sk) + return result["service_id"]?.toString() +} + +fun imageClone(imageId: String, name: String? = null, description: String? = null, publicKey: String? = null, secretKey: String? = null): String? { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val payload = mutableMapOf() + if (name != null) payload["name"] = name + if (description != null) payload["description"] = description + val result = apiRequest("/images/$imageId/clone", "POST", payload, pk, sk) + return result["image_id"]?.toString() +} + +// PaaS Logs functions +fun logsFetch(source: String = "all", lines: Int = 100, since: String? = null, grep: String? = null, publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + val params = mutableListOf("source=$source", "lines=$lines") + if (since != null) params.add("since=$since") + if (grep != null) params.add("grep=${java.net.URLEncoder.encode(grep, "UTF-8")}") + return apiRequest("/paas/logs?${params.joinToString("&")}", "GET", null, pk, sk) +} + +// Validate keys +fun validateKeys(publicKey: String? = null, secretKey: String? = null): Map { + val (pk, sk) = if (publicKey != null && secretKey != null) Pair(publicKey, secretKey) else getApiKeys(null) + return validateKey(pk, sk) +} + fun printHelp() { println(""" Usage: kotlin UnKt [options] diff --git a/clients/kotlin/sync/tests/UnTest.kt b/clients/kotlin/sync/tests/UnTest.kt new file mode 100644 index 0000000..7555417 --- /dev/null +++ b/clients/kotlin/sync/tests/UnTest.kt @@ -0,0 +1,211 @@ +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// Unit tests for Un SDK - Kotlin Synchronous client + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.junit.jupiter.api.Assertions.* + +class UnTest { + + @Nested + @DisplayName("Language Detection Tests") + inner class LanguageDetectionTests { + + @Test + @DisplayName("Should detect Python from .py extension") + fun detectPython() { + assertEquals("python", detectLanguage("script.py")) + assertEquals("python", detectLanguage("path/to/script.py")) + } + + @Test + @DisplayName("Should detect JavaScript from .js extension") + fun detectJavaScript() { + assertEquals("javascript", detectLanguage("app.js")) + } + + @Test + @DisplayName("Should detect TypeScript from .ts extension") + fun detectTypeScript() { + assertEquals("typescript", detectLanguage("app.ts")) + } + + @Test + @DisplayName("Should detect Go from .go extension") + fun detectGo() { + assertEquals("go", detectLanguage("main.go")) + } + + @Test + @DisplayName("Should detect Rust from .rs extension") + fun detectRust() { + assertEquals("rust", detectLanguage("lib.rs")) + } + + @Test + @DisplayName("Should detect Java from .java extension") + fun detectJava() { + assertEquals("java", detectLanguage("Main.java")) + } + + @Test + @DisplayName("Should detect Kotlin from .kt extension") + fun detectKotlin() { + assertEquals("kotlin", detectLanguage("Main.kt")) + } + + @Test + @DisplayName("Should detect Groovy from .groovy extension") + fun detectGroovy() { + assertEquals("groovy", detectLanguage("script.groovy")) + } + + @Test + @DisplayName("Should throw for unknown extension") + fun detectUnknown() { + assertThrows(RuntimeException::class.java) { + detectLanguage("file.unknown") + } + } + } + + @Nested + @DisplayName("Utility Function Tests") + inner class UtilityTests { + + @Test + @DisplayName("Version should return a valid version string") + fun versionString() { + val ver = version() + assertNotNull(ver) + assertTrue(ver.matches(Regex("\\d+\\.\\d+\\.\\d+")), "Version should be in X.Y.Z format") + } + + @Test + @DisplayName("HMAC sign should produce valid hex signature") + fun hmacSignature() { + val signature = hmacSign("secret", "message") + assertNotNull(signature) + assertEquals(64, signature.length, "HMAC-SHA256 should produce 64 hex chars") + assertTrue(signature.matches(Regex("[0-9a-f]+")), "Signature should be lowercase hex") + } + + @Test + @DisplayName("HMAC sign should be consistent") + fun hmacConsistent() { + val sig1 = hmacSign("key", "data") + val sig2 = hmacSign("key", "data") + assertEquals(sig1, sig2, "Same inputs should produce same signature") + } + + @Test + @DisplayName("HMAC sign should differ with different inputs") + fun hmacDifferent() { + val sig1 = hmacSign("key1", "data") + val sig2 = hmacSign("key2", "data") + assertNotEquals(sig1, sig2, "Different keys should produce different signatures") + } + } + + @Nested + @DisplayName("Health Check Tests") + inner class HealthCheckTests { + + @Test + @DisplayName("Health check should return boolean") + fun healthCheckReturnsBoolean() { + val healthy = healthCheck() + // We just verify it returns without throwing + assertTrue(healthy || !healthy) + } + } + + @Nested + @DisplayName("Integration Tests (requires credentials)") + @EnabledIfEnvironmentVariable(named = "UNSANDBOX_PUBLIC_KEY", matches = ".+") + inner class IntegrationTests { + + private lateinit var publicKey: String + private lateinit var secretKey: String + + @BeforeEach + fun setUp() { + publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") ?: "" + secretKey = System.getenv("UNSANDBOX_SECRET_KEY") ?: "" + } + + @Test + @DisplayName("Should execute Python code successfully") + fun executePythonCode() { + val result = execute("python", "print('Hello, World!')", publicKey, secretKey) + + assertNotNull(result) + assertTrue(result["stdout"].toString().contains("Hello, World!")) + } + + @Test + @DisplayName("Should execute JavaScript code successfully") + fun executeJavaScriptCode() { + val result = execute("javascript", "console.log('Hello from JS')", publicKey, secretKey) + + assertNotNull(result) + assertTrue(result["stdout"].toString().contains("Hello from JS")) + } + + @Test + @DisplayName("Should get supported languages") + fun getLanguagesTest() { + val languages = getLanguages(publicKey, secretKey) + + assertNotNull(languages) + assertTrue(languages.isNotEmpty()) + assertTrue(languages.contains("python")) + assertTrue(languages.contains("javascript")) + } + + @Test + @DisplayName("Should list jobs") + fun listJobsTest() { + val jobs = listJobs(publicKey, secretKey) + assertNotNull(jobs) + } + + @Test + @DisplayName("Should validate keys successfully") + fun validateKeysTest() { + val result = validateKeys(publicKey, secretKey) + assertNotNull(result) + } + + @Test + @DisplayName("Should list sessions") + fun listSessionsTest() { + val sessions = sessionList(publicKey, secretKey) + assertNotNull(sessions) + } + + @Test + @DisplayName("Should list services") + fun listServicesTest() { + val services = serviceList(publicKey, secretKey) + assertNotNull(services) + } + + @Test + @DisplayName("Should list snapshots") + fun listSnapshotsTest() { + val snapshots = snapshotList(publicKey, secretKey) + assertNotNull(snapshots) + } + + @Test + @DisplayName("Should list images") + fun listImagesTest() { + val images = imageList(null, publicKey, secretKey) + assertNotNull(images) + } + } +} diff --git a/clients/lisp/sync/src/un.lisp b/clients/lisp/sync/src/un.lisp index c03744d..67e2829 100644 --- a/clients/lisp/sync/src/un.lisp +++ b/clients/lisp/sync/src/un.lisp @@ -597,6 +597,174 @@ (defun key-cmd (extend-flag) (validate-key extend-flag)) +;; Image access management functions +(defun image-grant-access (id trusted-key) + (let* ((api-key (get-api-key)) + (json (format nil "{\"trusted_api_key\":\"~a\"}" trusted-key))) + (curl-post api-key (format nil "/images/~a/grant-access" id) json) + (format t "~aAccess granted to: ~a~a~%" *green* trusted-key *reset*))) + +(defun image-revoke-access (id trusted-key) + (let* ((api-key (get-api-key)) + (json (format nil "{\"trusted_api_key\":\"~a\"}" trusted-key))) + (curl-post api-key (format nil "/images/~a/revoke-access" id) json) + (format t "~aAccess revoked from: ~a~a~%" *green* trusted-key *reset*))) + +(defun image-list-trusted (id) + (let ((api-key (get-api-key))) + (format t "~a~%" (curl-get api-key (format nil "/images/~a/trusted" id))))) + +(defun image-transfer (id to-key) + (let* ((api-key (get-api-key)) + (json (format nil "{\"to_api_key\":\"~a\"}" to-key))) + (curl-post api-key (format nil "/images/~a/transfer" id) json) + (format t "~aImage transferred to: ~a~a~%" *green* to-key *reset*))) + +;; Snapshot functions +(defun snapshot-list () + (let ((api-key (get-api-key))) + (format t "~a~%" (curl-get api-key "/snapshots")))) + +(defun snapshot-info (id) + (let ((api-key (get-api-key))) + (format t "~a~%" (curl-get api-key (format nil "/snapshots/~a" id))))) + +(defun snapshot-session (session-id name hot) + (let* ((api-key (get-api-key)) + (name-json (if name (format nil ",\"name\":\"~a\"" (escape-json name)) "")) + (hot-json (if hot ",\"hot\":true" "")) + (json (format nil "{\"session_id\":\"~a\"~a~a}" session-id name-json hot-json))) + (format t "~aSnapshot created~a~%" *green* *reset*) + (format t "~a~%" (curl-post api-key "/snapshots" json)))) + +(defun snapshot-service (service-id name hot) + (let* ((api-key (get-api-key)) + (name-json (if name (format nil ",\"name\":\"~a\"" (escape-json name)) "")) + (hot-json (if hot ",\"hot\":true" "")) + (json (format nil "{\"service_id\":\"~a\"~a~a}" service-id name-json hot-json))) + (format t "~aSnapshot created~a~%" *green* *reset*) + (format t "~a~%" (curl-post api-key "/snapshots" json)))) + +(defun snapshot-restore (id) + (let ((api-key (get-api-key))) + (curl-post api-key (format nil "/snapshots/~a/restore" id) "{}") + (format t "~aSnapshot restored: ~a~a~%" *green* id *reset*))) + +(defun snapshot-delete (id) + (let* ((api-key (get-api-key)) + (result (curl-delete-with-sudo api-key (format nil "/snapshots/~a" id)))) + (if (first result) + (format t "~aSnapshot deleted: ~a~a~%" *green* id *reset*) + (progn + (format *error-output* "~aError deleting snapshot~a~%" *red* *reset*) + (uiop:quit 1))))) + +(defun snapshot-lock (id) + (let ((api-key (get-api-key))) + (curl-post api-key (format nil "/snapshots/~a/lock" id) "{}") + (format t "~aSnapshot locked: ~a~a~%" *green* id *reset*))) + +(defun snapshot-unlock (id) + (let* ((api-key (get-api-key)) + (result (curl-post-with-sudo api-key (format nil "/snapshots/~a/unlock" id) "{}"))) + (if (first result) + (format t "~aSnapshot unlocked: ~a~a~%" *green* id *reset*) + (progn + (format *error-output* "~aError unlocking snapshot~a~%" *red* *reset*) + (uiop:quit 1))))) + +(defun snapshot-clone (id clone-type name ports shell) + (let* ((api-key (get-api-key)) + (type-json (format nil "\"clone_type\":\"~a\"" clone-type)) + (name-json (if name (format nil ",\"name\":\"~a\"" (escape-json name)) "")) + (ports-json (if ports (format nil ",\"ports\":[~a]" ports) "")) + (shell-json (if shell (format nil ",\"shell\":\"~a\"" shell) "")) + (json (format nil "{~a~a~a~a}" type-json name-json ports-json shell-json))) + (format t "~aSnapshot cloned~a~%" *green* *reset*) + (format t "~a~%" (curl-post api-key (format nil "/snapshots/~a/clone" id) json)))) + +(defun snapshot-cmd (action id name ports shell hot) + (cond + ((string= action "list") (snapshot-list)) + ((string= action "info") (snapshot-info id)) + ((string= action "session") (snapshot-session id name hot)) + ((string= action "service") (snapshot-service id name hot)) + ((string= action "restore") (snapshot-restore id)) + ((string= action "delete") (snapshot-delete id)) + ((string= action "lock") (snapshot-lock id)) + ((string= action "unlock") (snapshot-unlock id)) + ((string= action "clone") (snapshot-clone id "session" name ports shell)) + (t (format t "~aError: Unknown snapshot action~a~%" *red* *reset*) + (uiop:quit 1)))) + +;; Session additional functions +(defun session-info (id) + (let ((api-key (get-api-key))) + (format t "~a~%" (curl-get api-key (format nil "/sessions/~a" id))))) + +(defun session-boost (id vcpu) + (let* ((api-key (get-api-key)) + (json (format nil "{\"vcpu\":~a}" vcpu))) + (curl-patch api-key (format nil "/sessions/~a" id) json) + (format t "~aSession boosted to ~a vCPU~a~%" *green* vcpu *reset*))) + +(defun session-unboost (id) + (let* ((api-key (get-api-key)) + (json "{\"vcpu\":1}")) + (curl-patch api-key (format nil "/sessions/~a" id) json) + (format t "~aSession unboosted to 1 vCPU~a~%" *green* *reset*))) + +(defun session-execute (id command) + (let* ((api-key (get-api-key)) + (json (format nil "{\"command\":\"~a\"}" (escape-json command))) + (response (curl-post api-key (format nil "/sessions/~a/execute" id) json)) + (stdout-val (parse-json-field response "stdout"))) + (when stdout-val + (format t "~a~a~a" *blue* stdout-val *reset*)))) + +;; Service additional functions +(defun service-lock (id) + (let ((api-key (get-api-key))) + (curl-post api-key (format nil "/services/~a/lock" id) "{}") + (format t "~aService locked: ~a~a~%" *green* id *reset*))) + +(defun service-unlock (id) + (let* ((api-key (get-api-key)) + (result (curl-post-with-sudo api-key (format nil "/services/~a/unlock" id) "{}"))) + (if (first result) + (format t "~aService unlocked: ~a~a~%" *green* id *reset*) + (progn + (format *error-output* "~aError unlocking service~a~%" *red* *reset*) + (uiop:quit 1))))) + +(defun service-redeploy (id bootstrap) + (let* ((api-key (get-api-key)) + (json (if bootstrap + (format nil "{\"bootstrap\":\"~a\"}" (escape-json bootstrap)) + "{}"))) + (curl-post api-key (format nil "/services/~a/redeploy" id) json) + (format t "~aService redeploying: ~a~a~%" *green* id *reset*))) + +;; PaaS logs functions +(defun logs-fetch (source lines since grep-pattern) + (let* ((api-key (get-api-key)) + (params (format nil "?source=~a&lines=~a~a~a" + (or source "all") + (or lines 100) + (if since (format nil "&since=~a" since) "") + (if grep-pattern (format nil "&grep=~a" grep-pattern) "")))) + (format t "~a~%" (curl-get api-key (format nil "/logs~a" params))))) + +;; Utility functions +(defun health-check () + (let* ((cmd "curl -s https://api.unsandbox.com/health") + (result (uiop:run-program cmd :output :string))) + (format t "~a~%" result) + (search "ok" result))) + +(defun sdk-version () + "4.2.0") + (defun image-cmd (action id name ports source-type visibility-mode) (let ((api-key (get-api-key))) (cond @@ -885,6 +1053,28 @@ ((string= (first args) "key") (let ((extend-flag (and (> (length args) 1) (string= (second args) "--extend")))) (key-cmd extend-flag))) + ((string= (first args) "snapshot") + (cond + ((and (> (length args) 1) (string= (second args) "--list")) + (snapshot-cmd "list" nil nil nil nil nil)) + ((and (> (length args) 2) (string= (second args) "--info")) + (snapshot-cmd "info" (third args) nil nil nil nil)) + ((and (> (length args) 2) (string= (second args) "--session")) + (snapshot-cmd "session" (third args) nil nil nil nil)) + ((and (> (length args) 2) (string= (second args) "--service")) + (snapshot-cmd "service" (third args) nil nil nil nil)) + ((and (> (length args) 2) (string= (second args) "--restore")) + (snapshot-cmd "restore" (third args) nil nil nil nil)) + ((and (> (length args) 2) (string= (second args) "--delete")) + (snapshot-cmd "delete" (third args) nil nil nil nil)) + ((and (> (length args) 2) (string= (second args) "--lock")) + (snapshot-cmd "lock" (third args) nil nil nil nil)) + ((and (> (length args) 2) (string= (second args) "--unlock")) + (snapshot-cmd "unlock" (third args) nil nil nil nil)) + ((and (> (length args) 2) (string= (second args) "--clone")) + (snapshot-cmd "clone" (third args) nil nil nil nil)) + (t + (snapshot-cmd "list" nil nil nil nil nil)))) ((string= (first args) "image") (cond ((and (> (length args) 1) (string= (second args) "--list")) diff --git a/clients/lua/sync/src/un.lua b/clients/lua/sync/src/un.lua index 23d715b..a281aea 100644 --- a/clients/lua/sync/src/un.lua +++ b/clients/lua/sync/src/un.lua @@ -1,10 +1,19 @@ #!/usr/bin/env lua -- PUBLIC DOMAIN - NO LICENSE, NO WARRANTY -- --- This is free public domain software for the public good of a permacomputer hosted --- at permacomputer.com - an always-on computer by the people, for the people. +-- unsandbox.com Lua SDK (Synchronous) +-- Full API with execution, sessions, services, snapshots, and images. -- --- Learn more: https://www.permacomputer.com +-- Library Usage: +-- local Un = require("un") +-- local result = Un.execute("python", "print(42)") +-- print(result.stdout) +-- +-- CLI Usage: +-- lua un.lua script.py +-- lua un.lua -s python 'print(42)' +-- lua un.lua session --list +-- lua un.lua service --list -- -- Copyright 2025 TimeHexOn & foxhop & russell@unturf @@ -12,12 +21,74 @@ local json = require("json") local http = require("socket.http") local https = require("ssl.https") local ltn12 = require("ltn12") +local mime = require("mime") local Un = {} Un.API_BASE = "https://api.unsandbox.com" +Un.PORTAL_BASE = "https://unsandbox.com" Un.VERSION = "4.2.50" +Un.LAST_ERROR = "" + +-- Colors +local BLUE = "\027[34m" +local RED = "\027[31m" +local GREEN = "\027[32m" +local YELLOW = "\027[33m" +local RESET = "\027[0m" + +-- Extension to language mapping +local EXT_MAP = { + py = "python", js = "javascript", ts = "typescript", + rb = "ruby", php = "php", pl = "perl", lua = "lua", + sh = "bash", go = "go", rs = "rust", c = "c", + cpp = "cpp", cc = "cpp", cxx = "cpp", + java = "java", kt = "kotlin", cs = "csharp", fs = "fsharp", + hs = "haskell", ml = "ocaml", clj = "clojure", scm = "scheme", + lisp = "commonlisp", erl = "erlang", ex = "elixir", exs = "elixir", + jl = "julia", r = "r", cr = "crystal", + d = "d", nim = "nim", zig = "zig", v = "v", + dart = "dart", groovy = "groovy", scala = "scala", + f90 = "fortran", f95 = "fortran", cob = "cobol", + pro = "prolog", forth = "forth", ["4th"] = "forth", + tcl = "tcl", raku = "raku", m = "objc" +} + +-- ============================================================================ +-- Utility Functions +-- ============================================================================ + +function Un.version() + return Un.VERSION +end + +function Un.last_error() + return Un.LAST_ERROR +end + +function Un.set_error(msg) + Un.LAST_ERROR = msg or "" +end + +function Un.detect_language(filename) + if not filename then return nil end + local ext = filename:match("%.([^%.]+)$") + return ext and EXT_MAP[ext:lower()] or nil +end + +function Un.hmac_sign(secret, message) + if not secret or not message then return nil end + -- Use openssl for HMAC-SHA256 + local cmd = "echo -n '" .. message:gsub("'", "'\\''") .. "' | openssl dgst -sha256 -hmac '" .. secret:gsub("'", "'\\''") .. "' | sed 's/^.* //'" + local handle = io.popen(cmd) + local result = handle:read("*a") + handle:close() + return result and result:gsub("%s+", "") or nil +end + +-- ============================================================================ +-- Credential Management +-- ============================================================================ --- Credential loading function Un.load_accounts_csv(path) path = path or (os.getenv("HOME") .. "/.unsandbox/accounts.csv") local file = io.open(path, "r") @@ -26,7 +97,7 @@ function Un.load_accounts_csv(path) local accounts = {} for line in file:lines() do line = line:match("^%s*(.-)%s*$") - if line ~= "" then + if line ~= "" and not line:match("^#") then local pk, sk = line:match("([^,]+),(.+)") if pk and sk then table.insert(accounts, {pk, sk}) @@ -50,6 +121,11 @@ function Un.get_credentials(opts) local sk = os.getenv("UNSANDBOX_SECRET_KEY") if pk and sk then return pk, sk end + -- Legacy fallback + if os.getenv("UNSANDBOX_API_KEY") then + return os.getenv("UNSANDBOX_API_KEY"), "" + end + -- Tier 3: Home directory local accounts = Un.load_accounts_csv() if #accounts > 0 then return accounts[1][1], accounts[1][2] end @@ -58,35 +134,52 @@ function Un.get_credentials(opts) accounts = Un.load_accounts_csv("./accounts.csv") if #accounts > 0 then return accounts[1][1], accounts[1][2] end - error("No credentials found") + Un.set_error("No credentials found") + return nil, nil end --- HMAC signature -function Un.sign_request(secret, timestamp, method, endpoint, body) - local hmac = require("crypto").hmac - local message = timestamp .. ":" .. method .. ":" .. endpoint .. ":" .. body - return hmac.digest("sha256", message, secret, true):hex() -end +-- ============================================================================ +-- API Communication +-- ============================================================================ --- API request function Un.api_request(method, endpoint, body, opts, extra_headers) opts = opts or {} extra_headers = extra_headers or {} local pk, sk = Un.get_credentials(opts) + if not pk then + Un.set_error("No credentials available") + return nil + end + local timestamp = tostring(os.time()) local url = Un.API_BASE .. endpoint - local body_str = body and json.encode(body) or "{}" - local signature = Un.sign_request(sk, timestamp, method, endpoint, body_str) + local body_str = "" + if body then + if type(body) == "table" then + body_str = json.encode(body) + else + body_str = body + end + end + + local content_type = opts.content_type or "application/json" + local signature = "" + if sk and sk ~= "" then + signature = Un.hmac_sign(sk, timestamp .. ":" .. method .. ":" .. endpoint .. ":" .. body_str) + end local headers = { ["Authorization"] = "Bearer " .. pk, - ["X-Timestamp"] = timestamp, - ["X-Signature"] = signature, - ["Content-Type"] = "application/json" + ["Content-Type"] = content_type } - -- Add extra headers (for sudo OTP) + if signature then + headers["X-Timestamp"] = timestamp + headers["X-Signature"] = signature + end + + -- Add extra headers for k, v in pairs(extra_headers) do headers[k] = v end @@ -96,115 +189,96 @@ function Un.api_request(method, endpoint, body, opts, extra_headers) url = url, method = method, headers = headers, - source = body_str and ltn12.source.string(body_str), + source = body_str ~= "" and ltn12.source.string(body_str) or nil, sink = ltn12.sink.table(resp_body) }) - if status ~= 200 then error("API error (" .. status .. ")") end - return json.decode(table.concat(resp_body)), status + if status ~= 200 and status ~= 201 then + Un.set_error("API error (" .. tostring(status) .. ")") + return nil, status + end + + local response_text = table.concat(resp_body) + if response_text and response_text ~= "" then + local ok, result = pcall(json.decode, response_text) + if ok then return result, status end + end + return { success = true }, status end --- Handle 428 Sudo OTP challenge - prompt user for OTP and retry -function Un.handle_sudo_challenge(response_body, method, endpoint, body, opts) - -- Extract challenge_id from response - local response_data = {} - if response_body and response_body ~= "" then - pcall(function() response_data = json.decode(response_body) end) - end - local challenge_id = response_data.challenge_id or "" - - io.stderr:write("\027[33mConfirmation required. Check your email for a one-time code.\027[0m\n") - io.stderr:write("Enter OTP: ") - io.stderr:flush() - - local otp = io.read("*line") - if not otp or otp == "" then - io.stderr:write("\027[31mError: Operation cancelled\027[0m\n") - return false - end - - -- Retry with sudo headers - local extra_headers = {["X-Sudo-OTP"] = otp} - if challenge_id ~= "" then - extra_headers["X-Sudo-Challenge"] = challenge_id - end - - local ok, result = pcall(function() - return Un.api_request(method, endpoint, body, opts, extra_headers) - end) - - if ok then - print("\027[32mOperation completed successfully\027[0m") - return true - else - return false - end -end - --- API request with 428 sudo handling for destructive operations function Un.api_request_with_sudo(method, endpoint, body, opts) opts = opts or {} local pk, sk = Un.get_credentials(opts) local timestamp = tostring(os.time()) - local url = Un.API_BASE .. endpoint - local body_str = body and json.encode(body) or "{}" - local signature = Un.sign_request(sk, timestamp, method, endpoint, body_str) + local body_str = body and json.encode(body) or "" + + local signature = "" + if sk and sk ~= "" then + signature = Un.hmac_sign(sk, timestamp .. ":" .. method .. ":" .. endpoint .. ":" .. body_str) + end local headers = { ["Authorization"] = "Bearer " .. pk, - ["X-Timestamp"] = timestamp, - ["X-Signature"] = signature, ["Content-Type"] = "application/json" } + if signature then + headers["X-Timestamp"] = timestamp + headers["X-Signature"] = signature + end + local resp_body = {} local resp, status = https.request({ - url = url, + url = Un.API_BASE .. endpoint, method = method, headers = headers, - source = body_str and ltn12.source.string(body_str), + source = body_str ~= "" and ltn12.source.string(body_str) or nil, sink = ltn12.sink.table(resp_body) }) - -- Handle 428 Precondition Required (sudo OTP needed) + -- Handle 428 - Sudo OTP required if status == 428 then - return Un.handle_sudo_challenge(table.concat(resp_body), method, endpoint, body, opts) - end + local response_text = table.concat(resp_body) + local response_data = {} + pcall(function() response_data = json.decode(response_text) end) + local challenge_id = response_data.challenge_id or "" - if status ~= 200 then error("API error (" .. status .. ")") end - return json.decode(table.concat(resp_body)) -end + io.stderr:write(YELLOW .. "Confirmation required. Check your email for a one-time code." .. RESET .. "\n") + io.stderr:write("Enter OTP: ") + io.stderr:flush() --- Languages with cache -function Un.languages(opts) - opts = opts or {} - local cache_ttl = opts.cache_ttl or 3600 - local cache_path = os.getenv("HOME") .. "/.unsandbox/languages.json" - - local file = io.open(cache_path, "r") - if file then - local mtime = os.time() - (lfs.attributes(cache_path, "modification") or 0) - if mtime < cache_ttl then - local content = file:read("*a") - file:close() - return json.decode(content) + local otp = io.read("*line") + if not otp or otp == "" then + Un.set_error("Operation cancelled") + return nil end - file:close() + + local extra = { ["X-Sudo-OTP"] = otp } + if challenge_id ~= "" then + extra["X-Sudo-Challenge"] = challenge_id + end + + return Un.api_request(method, endpoint, body, opts, extra) end - local result = Un.api_request("GET", "/languages", nil, opts) - local langs = result.languages or {} + if status ~= 200 and status ~= 201 then + Un.set_error("API error (" .. tostring(status) .. ")") + return nil + end - os.execute("mkdir -p " .. os.getenv("HOME") .. "/.unsandbox") - file = io.open(cache_path, "w") - file:write(json.encode(langs)) - file:close() - - return langs + local response_text = table.concat(resp_body) + if response_text and response_text ~= "" then + local ok, result = pcall(json.decode, response_text) + if ok then return result end + end + return { success = true } end --- Execute functions +-- ============================================================================ +-- Execution Functions (8) +-- ============================================================================ + function Un.execute(language, code, opts) opts = opts or {} local body = { @@ -213,6 +287,9 @@ function Un.execute(language, code, opts) network_mode = opts.network_mode or "zerotrust", ttl = opts.ttl or 60 } + if opts.env then body.env = opts.env end + if opts.input_files then body.input_files = opts.input_files end + if opts.return_artifacts then body.return_artifacts = true end return Un.api_request("POST", "/execute", body, opts) end @@ -227,151 +304,463 @@ function Un.execute_async(language, code, opts) return Un.api_request("POST", "/execute/async", body, opts) end -function Un.run(file, opts) - local f = io.open(file, "r") - local code = f:read("*a") - f:close() - return Un.execute(Un.detect_language(file), code, opts) -end - --- Job management -function Un.get_job(job_id, opts) +function Un.wait_job(job_id, opts) opts = opts or {} - return Un.api_request("GET", "/jobs/" .. job_id, nil, opts) -end - -function Un.wait(job_id, timeout, opts) - opts = opts or {} - timeout = timeout or 3600 local delays = {300, 450, 700, 900, 650, 1600, 2000} - local start = os.time() for i = 0, 119 do local job = Un.get_job(job_id, opts) - if job.status == "completed" then return job end - if job.status == "failed" then error("Job failed") end - - if os.time() - start > timeout then error("Polling timeout") end + if job and job.status == "completed" then return job end + if job and job.status == "failed" then + Un.set_error("Job failed") + return nil + end local delay = delays[(i % 7) + 1] or 2000 require("socket").sleep(delay / 1000) end - error("Max polls exceeded") + Un.set_error("Max polls exceeded") + return nil end --- Utilities -function Un.detect_language(filename) - local ext = filename:match("%.([^%.]+)$") - local map = {py="python", lua="lua", sh="bash", rb="ruby"} - return map[ext] or error("Unknown file type") +function Un.get_job(job_id, opts) + return Un.api_request("GET", "/jobs/" .. job_id, nil, opts) end --- Image API functions -function Un.image_list(opts) +function Un.cancel_job(job_id, opts) + return Un.api_request("DELETE", "/jobs/" .. job_id, nil, opts) +end + +function Un.list_jobs(opts) + return Un.api_request("GET", "/jobs", nil, opts) +end + +function Un.get_languages(opts) opts = opts or {} - return Un.api_request("GET", "/images", nil, opts) + local cache_ttl = opts.cache_ttl or 3600 + local cache_path = os.getenv("HOME") .. "/.unsandbox/languages.json" + + -- Try cache + local file = io.open(cache_path, "r") + if file then + local content = file:read("*a") + file:close() + local ok, cached = pcall(json.decode, content) + if ok and cached and cached.timestamp then + if os.time() - cached.timestamp < cache_ttl then + return cached.languages + end + end + end + + -- Fetch from API + local result = Un.api_request("GET", "/languages", nil, opts) + local langs = result and result.languages or {} + + -- Save to cache + os.execute("mkdir -p " .. os.getenv("HOME") .. "/.unsandbox") + file = io.open(cache_path, "w") + if file then + file:write(json.encode({ languages = langs, timestamp = os.time() })) + file:close() + end + + return langs end -function Un.image_get(image_id, opts) +-- ============================================================================ +-- Session Functions (9) +-- ============================================================================ + +function Un.session_list(opts) + return Un.api_request("GET", "/sessions", nil, opts) +end + +function Un.session_get(session_id, opts) + return Un.api_request("GET", "/sessions/" .. session_id, nil, opts) +end + +function Un.session_create(opts) opts = opts or {} - return Un.api_request("GET", "/images/" .. image_id, nil, opts) + local body = { shell = opts.shell or "bash" } + if opts.network then body.network = opts.network end + if opts.vcpu then body.vcpu = opts.vcpu end + if opts.input_files then body.input_files = opts.input_files end + if opts.persistence then body.persistence = opts.persistence end + return Un.api_request("POST", "/sessions", body, opts) end -function Un.image_delete(image_id, opts) - opts = opts or {} - return Un.api_request_with_sudo("DELETE", "/images/" .. image_id, nil, opts) +function Un.session_destroy(session_id, opts) + return Un.api_request("DELETE", "/sessions/" .. session_id, nil, opts) end -function Un.image_lock(image_id, opts) - opts = opts or {} - return Un.api_request("POST", "/images/" .. image_id .. "/lock", {}, opts) +function Un.session_freeze(session_id, opts) + return Un.api_request("POST", "/sessions/" .. session_id .. "/freeze", {}, opts) end -function Un.image_unlock(image_id, opts) - opts = opts or {} - return Un.api_request_with_sudo("POST", "/images/" .. image_id .. "/unlock", {}, opts) +function Un.session_unfreeze(session_id, opts) + return Un.api_request("POST", "/sessions/" .. session_id .. "/unfreeze", {}, opts) end -function Un.image_publish(source_id, source_type, name, opts) - opts = opts or {} - local body = {source_type = source_type, source_id = source_id} - if name then body.name = name end - return Un.api_request("POST", "/images/publish", body, opts) +function Un.session_boost(session_id, vcpu, opts) + return Un.api_request("POST", "/sessions/" .. session_id .. "/boost", { vcpu = vcpu }, opts) end -function Un.image_visibility(image_id, visibility, opts) - opts = opts or {} - return Un.api_request("POST", "/images/" .. image_id .. "/visibility", {visibility = visibility}, opts) +function Un.session_unboost(session_id, opts) + return Un.api_request("POST", "/sessions/" .. session_id .. "/unboost", {}, opts) end -function Un.image_spawn(image_id, name, ports, opts) - opts = opts or {} - local body = {} - if name then body.name = name end - if ports then body.ports = ports end - return Un.api_request("POST", "/images/" .. image_id .. "/spawn", body, opts) +function Un.session_execute(session_id, command, opts) + return Un.api_request("POST", "/sessions/" .. session_id .. "/execute", { command = command }, opts) end -function Un.image_clone(image_id, name, opts) - opts = opts or {} - local body = {} - if name then body.name = name end - return Un.api_request("POST", "/images/" .. image_id .. "/clone", body, opts) -end +-- ============================================================================ +-- Service Functions (17) +-- ============================================================================ --- Service API functions function Un.service_list(opts) - opts = opts or {} return Un.api_request("GET", "/services", nil, opts) end function Un.service_get(service_id, opts) - opts = opts or {} return Un.api_request("GET", "/services/" .. service_id, nil, opts) end -function Un.service_set_unfreeze_on_demand(service_id, enabled, opts) +function Un.service_create(opts) opts = opts or {} - return Un.api_request_patch("/services/" .. service_id, {unfreeze_on_demand = enabled}, opts) + local body = { name = opts.name } + if opts.ports then body.ports = opts.ports end + if opts.domains then body.domains = opts.domains end + if opts.bootstrap then body.bootstrap = opts.bootstrap end + if opts.bootstrap_content then body.bootstrap_content = opts.bootstrap_content end + if opts.network then body.network = opts.network end + if opts.vcpu then body.vcpu = opts.vcpu end + if opts.service_type then body.service_type = opts.service_type end + if opts.input_files then body.input_files = opts.input_files end + if opts.unfreeze_on_demand then body.unfreeze_on_demand = true end + return Un.api_request("POST", "/services", body, opts) end -function Un.api_request_patch(endpoint, body, opts) +function Un.service_destroy(service_id, opts) + return Un.api_request_with_sudo("DELETE", "/services/" .. service_id, nil, opts) +end + +function Un.service_freeze(service_id, opts) + return Un.api_request("POST", "/services/" .. service_id .. "/freeze", {}, opts) +end + +function Un.service_unfreeze(service_id, opts) + return Un.api_request("POST", "/services/" .. service_id .. "/unfreeze", {}, opts) +end + +function Un.service_lock(service_id, opts) + return Un.api_request("POST", "/services/" .. service_id .. "/lock", {}, opts) +end + +function Un.service_unlock(service_id, opts) + return Un.api_request_with_sudo("POST", "/services/" .. service_id .. "/unlock", {}, opts) +end + +function Un.service_set_unfreeze_on_demand(service_id, enabled, opts) + return Un.api_request("PATCH", "/services/" .. service_id, { unfreeze_on_demand = enabled }, opts) +end + +function Un.service_redeploy(service_id, opts) + opts = opts or {} + local body = {} + if opts.bootstrap then body.bootstrap = opts.bootstrap end + return Un.api_request("POST", "/services/" .. service_id .. "/redeploy", body, opts) +end + +function Un.service_logs(service_id, opts) + opts = opts or {} + local endpoint = "/services/" .. service_id .. "/logs" + if opts.lines then endpoint = endpoint .. "?lines=" .. opts.lines end + return Un.api_request("GET", endpoint, nil, opts) +end + +function Un.service_execute(service_id, command, opts) + opts = opts or {} + local body = { command = command } + if opts.timeout then body.timeout = opts.timeout end + return Un.api_request("POST", "/services/" .. service_id .. "/execute", body, opts) +end + +function Un.service_env_get(service_id, opts) + return Un.api_request("GET", "/services/" .. service_id .. "/env", nil, opts) +end + +function Un.service_env_set(service_id, env_content, opts) + opts = opts or {} + opts.content_type = "text/plain" + return Un.api_request("PUT", "/services/" .. service_id .. "/env", env_content, opts) +end + +function Un.service_env_delete(service_id, opts) + return Un.api_request("DELETE", "/services/" .. service_id .. "/env", nil, opts) +end + +function Un.service_env_export(service_id, opts) + return Un.api_request("POST", "/services/" .. service_id .. "/env/export", {}, opts) +end + +function Un.service_resize(service_id, vcpu, opts) + return Un.api_request("PATCH", "/services/" .. service_id, { vcpu = vcpu }, opts) +end + +-- ============================================================================ +-- Snapshot Functions (9) +-- ============================================================================ + +function Un.snapshot_list(opts) + return Un.api_request("GET", "/snapshots", nil, opts) +end + +function Un.snapshot_get(snapshot_id, opts) + return Un.api_request("GET", "/snapshots/" .. snapshot_id, nil, opts) +end + +function Un.snapshot_session(session_id, opts) + opts = opts or {} + local body = {} + if opts.name then body.name = opts.name end + if opts.hot then body.hot = true end + return Un.api_request("POST", "/sessions/" .. session_id .. "/snapshot", body, opts) +end + +function Un.snapshot_service(service_id, opts) + opts = opts or {} + local body = {} + if opts.name then body.name = opts.name end + if opts.hot then body.hot = true end + return Un.api_request("POST", "/services/" .. service_id .. "/snapshot", body, opts) +end + +function Un.snapshot_restore(snapshot_id, opts) + return Un.api_request("POST", "/snapshots/" .. snapshot_id .. "/restore", {}, opts) +end + +function Un.snapshot_delete(snapshot_id, opts) + return Un.api_request_with_sudo("DELETE", "/snapshots/" .. snapshot_id, nil, opts) +end + +function Un.snapshot_lock(snapshot_id, opts) + return Un.api_request("POST", "/snapshots/" .. snapshot_id .. "/lock", {}, opts) +end + +function Un.snapshot_unlock(snapshot_id, opts) + return Un.api_request_with_sudo("POST", "/snapshots/" .. snapshot_id .. "/unlock", {}, opts) +end + +function Un.snapshot_clone(snapshot_id, opts) + opts = opts or {} + local body = { clone_type = opts.clone_type or "session" } + if opts.name then body.name = opts.name end + if opts.ports then body.ports = opts.ports end + if opts.shell then body.shell = opts.shell end + return Un.api_request("POST", "/snapshots/" .. snapshot_id .. "/clone", body, opts) +end + +-- ============================================================================ +-- Image Functions (13) +-- ============================================================================ + +function Un.image_list(opts) + opts = opts or {} + local endpoint = "/images" + if opts.filter then endpoint = endpoint .. "?filter=" .. opts.filter end + return Un.api_request("GET", endpoint, nil, opts) +end + +function Un.image_get(image_id, opts) + return Un.api_request("GET", "/images/" .. image_id, nil, opts) +end + +function Un.image_publish(opts) + opts = opts or {} + local body = { + source_type = opts.source_type, + source_id = opts.source_id + } + if opts.name then body.name = opts.name end + if opts.description then body.description = opts.description end + return Un.api_request("POST", "/images/publish", body, opts) +end + +function Un.image_delete(image_id, opts) + return Un.api_request_with_sudo("DELETE", "/images/" .. image_id, nil, opts) +end + +function Un.image_lock(image_id, opts) + return Un.api_request("POST", "/images/" .. image_id .. "/lock", {}, opts) +end + +function Un.image_unlock(image_id, opts) + return Un.api_request_with_sudo("POST", "/images/" .. image_id .. "/unlock", {}, opts) +end + +function Un.image_set_visibility(image_id, visibility, opts) + return Un.api_request("POST", "/images/" .. image_id .. "/visibility", { visibility = visibility }, opts) +end + +function Un.image_grant_access(image_id, trusted_api_key, opts) + return Un.api_request("POST", "/images/" .. image_id .. "/access", { api_key = trusted_api_key }, opts) +end + +function Un.image_revoke_access(image_id, trusted_api_key, opts) + return Un.api_request("DELETE", "/images/" .. image_id .. "/access/" .. trusted_api_key, nil, opts) +end + +function Un.image_list_trusted(image_id, opts) + return Un.api_request("GET", "/images/" .. image_id .. "/access", nil, opts) +end + +function Un.image_transfer(image_id, to_api_key, opts) + return Un.api_request("POST", "/images/" .. image_id .. "/transfer", { to_api_key = to_api_key }, opts) +end + +function Un.image_spawn(image_id, opts) + opts = opts or {} + local body = {} + if opts.name then body.name = opts.name end + if opts.ports then body.ports = opts.ports end + if opts.bootstrap then body.bootstrap = opts.bootstrap end + if opts.network_mode then body.network_mode = opts.network_mode end + return Un.api_request("POST", "/images/" .. image_id .. "/spawn", body, opts) +end + +function Un.image_clone(image_id, opts) + opts = opts or {} + local body = {} + if opts.name then body.name = opts.name end + if opts.description then body.description = opts.description end + return Un.api_request("POST", "/images/" .. image_id .. "/clone", body, opts) +end + +-- ============================================================================ +-- PaaS Logs Functions (2) +-- ============================================================================ + +function Un.logs_fetch(opts) + opts = opts or {} + local body = { + source = opts.source or "all", + lines = opts.lines or 100, + since = opts.since or "1h" + } + if opts.grep then body.grep = opts.grep end + return Un.api_request("POST", "/paas/logs", body, opts) +end + +function Un.logs_stream(opts) + -- SSE streaming not easily supported in sync Lua + Un.set_error("logs_stream requires async support") + return nil +end + +-- ============================================================================ +-- Key Validation +-- ============================================================================ + +function Un.validate_keys(opts) opts = opts or {} local pk, sk = Un.get_credentials(opts) local timestamp = tostring(os.time()) - local url = Un.API_BASE .. endpoint - local body_str = body and json.encode(body) or "{}" - local signature = Un.sign_request(sk, timestamp, "PATCH", endpoint, body_str) + local signature = "" + if sk and sk ~= "" then + signature = Un.hmac_sign(sk, timestamp .. ":POST:/keys/validate:") + end local headers = { ["Authorization"] = "Bearer " .. pk, - ["X-Timestamp"] = timestamp, - ["X-Signature"] = signature, ["Content-Type"] = "application/json" } + if signature then + headers["X-Timestamp"] = timestamp + headers["X-Signature"] = signature + end + local resp_body = {} local resp, status = https.request({ - url = url, - method = "PATCH", + url = Un.PORTAL_BASE .. "/keys/validate", + method = "POST", headers = headers, - source = body_str and ltn12.source.string(body_str), sink = ltn12.sink.table(resp_body) }) - if status ~= 200 then error("API error (" .. status .. ")") end - return json.decode(table.concat(resp_body)) + if status == 200 then + local response_text = table.concat(resp_body) + local ok, result = pcall(json.decode, response_text) + if ok then return result end + end + return nil end --- CLI -if arg and arg[1] then - if arg[1] == "languages" then - -- Languages command - local json_output = arg[2] == "--json" - local langs = Un.languages() +function Un.health_check(opts) + local resp_body = {} + local resp, status = https.request({ + url = Un.API_BASE .. "/health", + method = "GET", + sink = ltn12.sink.table(resp_body) + }) + return status == 200 +end +-- ============================================================================ +-- CLI Implementation +-- ============================================================================ + +local function run_file(filename) + local f = io.open(filename, "r") + if not f then + io.stderr:write(RED .. "Error: File not found: " .. filename .. RESET .. "\n") + os.exit(1) + end + local code = f:read("*a") + f:close() + + local lang = Un.detect_language(filename) + if not lang then + io.stderr:write(RED .. "Error: Cannot detect language" .. RESET .. "\n") + os.exit(1) + end + + local result = Un.execute(lang, code) + if result then + if result.stdout then print(result.stdout) end + if result.stderr then io.stderr:write(result.stderr) end + os.exit(result.exit_code or 0) + else + io.stderr:write(RED .. "Error: " .. Un.last_error() .. RESET .. "\n") + os.exit(1) + end +end + +-- CLI entry point +if arg and arg[0] then + local args = arg + local i = 1 + + if #args == 0 then + print("Usage: lua un.lua [options] ") + print(" lua un.lua -s ''") + print(" lua un.lua session [options]") + print(" lua un.lua service [options]") + print(" lua un.lua snapshot [options]") + print(" lua un.lua image [options]") + print(" lua un.lua languages [--json]") + print(" lua un.lua key [--extend]") + os.exit(1) + end + + local cmd = args[1] + + if cmd == "languages" then + local json_output = args[2] == "--json" + local langs = Un.get_languages() if json_output then print(json.encode(langs)) else @@ -379,110 +768,253 @@ if arg and arg[1] then print(lang) end end - os.exit(0) - elseif arg[1] == "service" then - -- Service command - local i = 2 + elseif cmd == "key" then + local result = Un.validate_keys() + if result then + if result.expired then + print(RED .. "Expired" .. RESET) + else + print(GREEN .. "Valid" .. RESET) + end + print("Public Key: " .. (result.public_key or "N/A")) + print("Tier: " .. (result.tier or "N/A")) + if result.expires_at then + print("Expires: " .. result.expires_at) + end + else + print(RED .. "Error: " .. Un.last_error() .. RESET) + end + elseif cmd == "session" then + i = 2 local action = nil - local service_id = nil - local unfreeze_on_demand_value = nil + local target = nil - while i <= #arg do - if arg[i] == "--list" or arg[i] == "-l" then + while i <= #args do + if args[i] == "--list" or args[i] == "-l" then action = "list" - elseif arg[i] == "--info" then + elseif args[i] == "--info" then action = "info" i = i + 1 - service_id = arg[i] - elseif arg[i] == "--unfreeze-on-demand" then - action = "unfreeze-on-demand" + target = args[i] + elseif args[i] == "--kill" then + action = "kill" i = i + 1 - service_id = arg[i] - if i + 1 <= #arg and (arg[i + 1] == "true" or arg[i + 1] == "false") then - i = i + 1 - unfreeze_on_demand_value = arg[i] == "true" + target = args[i] + elseif args[i] == "--freeze" then + action = "freeze" + i = i + 1 + target = args[i] + elseif args[i] == "--unfreeze" then + action = "unfreeze" + i = i + 1 + target = args[i] + end + i = i + 1 + end + + if action == "list" then + local result = Un.session_list() + if result and result.sessions then + if #result.sessions == 0 then + print("No active sessions") + else + print(string.format("%-40s %-10s %-10s %s", "ID", "Shell", "Status", "Created")) + for _, s in ipairs(result.sessions) do + print(string.format("%-40s %-10s %-10s %s", + s.id or "N/A", s.shell or "N/A", + s.status or "N/A", s.created_at or "N/A")) + end end end + elseif action == "info" then + local result = Un.session_get(target) + print(json.encode(result)) + elseif action == "kill" then + Un.session_destroy(target) + print(GREEN .. "Session terminated: " .. target .. RESET) + elseif action == "freeze" then + Un.session_freeze(target) + print(GREEN .. "Session frozen: " .. target .. RESET) + elseif action == "unfreeze" then + Un.session_unfreeze(target) + print(GREEN .. "Session unfreezing: " .. target .. RESET) + else + print("Usage: lua un.lua session --list|--info ID|--kill ID|--freeze ID|--unfreeze ID") + end + elseif cmd == "service" then + i = 2 + local action = nil + local target = nil + + while i <= #args do + if args[i] == "--list" or args[i] == "-l" then + action = "list" + elseif args[i] == "--info" then + action = "info" + i = i + 1 + target = args[i] + elseif args[i] == "--destroy" then + action = "destroy" + i = i + 1 + target = args[i] + elseif args[i] == "--freeze" then + action = "freeze" + i = i + 1 + target = args[i] + elseif args[i] == "--unfreeze" then + action = "unfreeze" + i = i + 1 + target = args[i] + elseif args[i] == "--logs" then + action = "logs" + i = i + 1 + target = args[i] + end i = i + 1 end if action == "list" then local result = Un.service_list() - print(json.encode(result)) - elseif action == "info" then - local result = Un.service_get(service_id) - print(json.encode(result)) - elseif action == "unfreeze-on-demand" then - if unfreeze_on_demand_value == nil then - io.stderr:write("Error: --unfreeze-on-demand requires true or false\n") - os.exit(1) + if result and result.services then + if #result.services == 0 then + print("No services") + else + print(string.format("%-20s %-15s %-10s %-15s %s", "ID", "Name", "Status", "Ports", "Domains")) + for _, s in ipairs(result.services) do + local ports = table.concat(s.ports or {}, ",") + local domains = table.concat(s.domains or {}, ",") + print(string.format("%-20s %-15s %-10s %-15s %s", + s.id or "N/A", s.name or "N/A", + s.status or "N/A", ports, domains)) + end + end + end + elseif action == "info" then + local result = Un.service_get(target) + print(json.encode(result)) + elseif action == "destroy" then + Un.service_destroy(target) + print(GREEN .. "Service destroyed: " .. target .. RESET) + elseif action == "freeze" then + Un.service_freeze(target) + print(GREEN .. "Service frozen: " .. target .. RESET) + elseif action == "unfreeze" then + Un.service_unfreeze(target) + print(GREEN .. "Service unfreezing: " .. target .. RESET) + elseif action == "logs" then + local result = Un.service_logs(target) + if result and result.logs then + print(result.logs) end - Un.service_set_unfreeze_on_demand(service_id, unfreeze_on_demand_value) - print("Service unfreeze_on_demand set to " .. tostring(unfreeze_on_demand_value) .. ": " .. service_id) else - io.stderr:write("Error: Use --list, --info ID, or --unfreeze-on-demand ID true|false\n") - os.exit(1) + print("Usage: lua un.lua service --list|--info ID|--destroy ID|--freeze ID|--unfreeze ID|--logs ID") end - os.exit(0) - elseif arg[1] == "image" then - -- Image command - local i = 2 + elseif cmd == "snapshot" then + i = 2 local action = nil - local image_id = nil + local target = nil + + while i <= #args do + if args[i] == "--list" or args[i] == "-l" then + action = "list" + elseif args[i] == "--info" then + action = "info" + i = i + 1 + target = args[i] + elseif args[i] == "--delete" then + action = "delete" + i = i + 1 + target = args[i] + elseif args[i] == "--restore" then + action = "restore" + i = i + 1 + target = args[i] + end + i = i + 1 + end + + if action == "list" then + local result = Un.snapshot_list() + if result and result.snapshots then + if #result.snapshots == 0 then + print("No snapshots") + else + print(string.format("%-40s %-20s %-10s %s", "ID", "Name", "Type", "Created")) + for _, s in ipairs(result.snapshots) do + print(string.format("%-40s %-20s %-10s %s", + s.id or "N/A", s.name or "N/A", + s.type or "N/A", s.created_at or "N/A")) + end + end + end + elseif action == "info" then + local result = Un.snapshot_get(target) + print(json.encode(result)) + elseif action == "delete" then + Un.snapshot_delete(target) + print(GREEN .. "Snapshot deleted: " .. target .. RESET) + elseif action == "restore" then + Un.snapshot_restore(target) + print(GREEN .. "Snapshot restored" .. RESET) + else + print("Usage: lua un.lua snapshot --list|--info ID|--delete ID|--restore ID") + end + elseif cmd == "image" then + i = 2 + local action = nil + local target = nil local name = nil local ports = nil local source_type = nil local visibility_mode = nil - while i <= #arg do - if arg[i] == "--list" or arg[i] == "-l" then + while i <= #args do + if args[i] == "--list" or args[i] == "-l" then action = "list" - elseif arg[i] == "--info" then + elseif args[i] == "--info" then action = "info" i = i + 1 - image_id = arg[i] - elseif arg[i] == "--delete" then + target = args[i] + elseif args[i] == "--delete" then action = "delete" i = i + 1 - image_id = arg[i] - elseif arg[i] == "--lock" then + target = args[i] + elseif args[i] == "--lock" then action = "lock" i = i + 1 - image_id = arg[i] - elseif arg[i] == "--unlock" then + target = args[i] + elseif args[i] == "--unlock" then action = "unlock" i = i + 1 - image_id = arg[i] - elseif arg[i] == "--publish" then + target = args[i] + elseif args[i] == "--publish" then action = "publish" i = i + 1 - image_id = arg[i] - elseif arg[i] == "--source-type" then + target = args[i] + elseif args[i] == "--source-type" then i = i + 1 - source_type = arg[i] - elseif arg[i] == "--visibility" then + source_type = args[i] + elseif args[i] == "--visibility" then action = "visibility" i = i + 1 - image_id = arg[i] - if i + 1 <= #arg and not arg[i + 1]:match("^%-") then - i = i + 1 - visibility_mode = arg[i] - end - elseif arg[i] == "--spawn" then + target = args[i] + i = i + 1 + visibility_mode = args[i] + elseif args[i] == "--spawn" then action = "spawn" i = i + 1 - image_id = arg[i] - elseif arg[i] == "--clone" then + target = args[i] + elseif args[i] == "--clone" then action = "clone" i = i + 1 - image_id = arg[i] - elseif arg[i] == "--name" then + target = args[i] + elseif args[i] == "--name" then i = i + 1 - name = arg[i] - elseif arg[i] == "--ports" then + name = args[i] + elseif args[i] == "--ports" then i = i + 1 ports = {} - for p in arg[i]:gmatch("[^,]+") do + for p in args[i]:gmatch("[^,]+") do table.insert(ports, tonumber(p)) end end @@ -491,85 +1023,81 @@ if arg and arg[1] then if action == "list" then local result = Un.image_list() - print(json.encode(result)) + if result and result.images then + if #result.images == 0 then + print("No images") + else + print(string.format("%-40s %-20s %-10s %s", "ID", "Name", "Visibility", "Created")) + for _, img in ipairs(result.images) do + print(string.format("%-40s %-20s %-10s %s", + img.id or "N/A", img.name or "N/A", + img.visibility or "N/A", img.created_at or "N/A")) + end + end + end elseif action == "info" then - local result = Un.image_get(image_id) + local result = Un.image_get(target) print(json.encode(result)) elseif action == "delete" then - Un.image_delete(image_id) - print("Image deleted: " .. image_id) + Un.image_delete(target) + print(GREEN .. "Image deleted: " .. target .. RESET) elseif action == "lock" then - Un.image_lock(image_id) - print("Image locked: " .. image_id) + Un.image_lock(target) + print(GREEN .. "Image locked: " .. target .. RESET) elseif action == "unlock" then - Un.image_unlock(image_id) - print("Image unlocked: " .. image_id) + Un.image_unlock(target) + print(GREEN .. "Image unlocked: " .. target .. RESET) elseif action == "publish" then if not source_type then - io.stderr:write("Error: --source-type required (service or snapshot)\n") + io.stderr:write(RED .. "Error: --source-type required" .. RESET .. "\n") os.exit(1) end - local result = Un.image_publish(image_id, source_type, name) - print("Image published") + local result = Un.image_publish({ source_type = source_type, source_id = target, name = name }) + print(GREEN .. "Image published" .. RESET) print(json.encode(result)) elseif action == "visibility" then - if not visibility_mode then - io.stderr:write("Error: --visibility requires MODE (private, unlisted, or public)\n") - os.exit(1) - end - Un.image_visibility(image_id, visibility_mode) - print("Image visibility set to " .. visibility_mode .. ": " .. image_id) + Un.image_set_visibility(target, visibility_mode) + print(GREEN .. "Visibility set to " .. visibility_mode .. RESET) elseif action == "spawn" then - local result = Un.image_spawn(image_id, name, ports) - print("Service spawned from image") + local result = Un.image_spawn(target, { name = name, ports = ports }) + print(GREEN .. "Service spawned from image" .. RESET) print(json.encode(result)) elseif action == "clone" then - local result = Un.image_clone(image_id, name) - print("Image cloned") + local result = Un.image_clone(target, { name = name }) + print(GREEN .. "Image cloned" .. RESET) print(json.encode(result)) else - io.stderr:write("Error: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID\n") + print("Usage: lua un.lua image --list|--info ID|--delete ID|--lock ID|--unlock ID|--publish ID|--visibility ID MODE|--spawn ID|--clone ID") + end + elseif cmd == "-s" then + -- Inline code execution + local lang = args[2] + local code = args[3] + if not lang or not code then + io.stderr:write(RED .. "Error: -s requires language and code" .. RESET .. "\n") os.exit(1) end - os.exit(0) - elseif arg[1] == "--help" or arg[1] == "-h" then + local result = Un.execute(lang, code) + if result then + if result.stdout then print(result.stdout) end + if result.stderr then io.stderr:write(result.stderr) end + os.exit(result.exit_code or 0) + else + io.stderr:write(RED .. "Error: " .. Un.last_error() .. RESET .. "\n") + os.exit(1) + end + elseif cmd == "--help" or cmd == "-h" then print("Usage: lua un.lua [options] ") - print(" lua un.lua languages [--json]") + print(" lua un.lua -s ''") + print(" lua un.lua session [options]") print(" lua un.lua service [options]") + print(" lua un.lua snapshot [options]") print(" lua un.lua image [options]") - print("") - print("Commands:") - print(" languages [--json] List available programming languages") - print(" service [options] Manage services") - print(" image [options] Manage images") - print("") - print("Languages options:") - print(" --json Output as JSON array") - print("") - print("Service options:") - print(" --list List all services") - print(" --info ID Get service details") - print(" --unfreeze-on-demand ID true|false Enable/disable auto-unfreeze on HTTP request") - print("") - print("Image options:") - print(" --list List all images") - print(" --info ID Get image details") - print(" --delete ID Delete an image") - print(" --lock ID Lock image to prevent deletion") - print(" --unlock ID Unlock image") - print(" --publish ID Publish image (requires --source-type)") - print(" --source-type TYPE Source type: service or snapshot") - print(" --visibility ID MODE Set visibility: private, unlisted, public") - print(" --spawn ID Spawn service from image") - print(" --clone ID Clone an image") - print(" --name NAME Name for spawned service or cloned image") - print(" --ports PORTS Ports for spawned service") - os.exit(0) + print(" lua un.lua languages [--json]") + print(" lua un.lua key [--extend]") else - local result = Un.run(arg[1]) - if result.stdout then print(result.stdout) end - if result.stderr then io.stderr:write(result.stderr) end - os.exit(result.exit_code or 0) + -- Assume it's a file to execute + run_file(cmd) end end diff --git a/clients/lua/tests/test_library.lua b/clients/lua/tests/test_library.lua new file mode 100644 index 0000000..f609c5d --- /dev/null +++ b/clients/lua/tests/test_library.lua @@ -0,0 +1,249 @@ +#!/usr/bin/env lua +-- Unit Tests for un.lua Library Functions +-- +-- Tests the ACTUAL exported functions from Un module. +-- NO local re-implementations. NO mocking. +-- +-- Run: lua tests/test_library.lua + +-- Adjust package path to find the module +local script_dir = arg[0]:match("(.*/)") or "./" +package.path = script_dir .. "../sync/src/?.lua;" .. package.path + +local Un = require("un") + +-- Test counters +local tests_passed = 0 +local tests_failed = 0 + +local function PASS(msg) + print(" \027[32m[PASS]\027[0m " .. msg) + tests_passed = tests_passed + 1 +end + +local function FAIL(msg) + print(" \027[31m[FAIL]\027[0m " .. msg) + tests_failed = tests_failed + 1 +end + +local function assert_equal(actual, expected, msg) + if actual == expected then + PASS(msg) + else + FAIL(msg .. " (expected: " .. tostring(expected) .. ", got: " .. tostring(actual) .. ")") + end +end + +local function assert_true(condition, msg) + if condition then + PASS(msg) + else + FAIL(msg) + end +end + +local function assert_nil(value, msg) + if value == nil then + PASS(msg) + else + FAIL(msg .. " (expected nil, got: " .. tostring(value) .. ")") + end +end + +local function assert_not_nil(value, msg) + if value ~= nil then + PASS(msg) + else + FAIL(msg .. " (expected non-nil)") + end +end + +-- ============================================================================ +-- Test: Un.version() +-- ============================================================================ + +print("\nTesting Un.version()...") + +local version = Un.version() +assert_not_nil(version, "version() returns non-nil") +assert_true(#version > 0, "version() returns non-empty string") +assert_true(version:match("^%d+%.%d+%.%d+$") ~= nil, "version() matches X.Y.Z format") +print(" Version: " .. version) + +-- ============================================================================ +-- Test: Un.detect_language() +-- ============================================================================ + +print("\nTesting Un.detect_language()...") + +local tests = { + {"test.py", "python"}, + {"app.js", "javascript"}, + {"main.go", "go"}, + {"script.rb", "ruby"}, + {"lib.rs", "rust"}, + {"main.c", "c"}, + {"app.cpp", "cpp"}, + {"Main.java", "java"}, + {"index.php", "php"}, + {"script.pl", "perl"}, + {"init.lua", "lua"}, + {"run.sh", "bash"}, + {"main.ts", "typescript"}, + {"app.kt", "kotlin"}, + {"lib.ex", "elixir"}, + {"main.hs", "haskell"}, +} + +for _, test in ipairs(tests) do + local file, expected = test[1], test[2] + local result = Un.detect_language(file) + assert_equal(result, expected, "detect_language('" .. file .. "') -> '" .. expected .. "'") +end + +-- Test nil handling +local null_result = Un.detect_language(nil) +assert_nil(null_result, "detect_language(nil) returns nil") + +-- Test unknown extension +local unknown = Un.detect_language("file.xyz123") +assert_nil(unknown, "detect_language(unknown ext) returns nil") + +-- Test no extension +local noext = Un.detect_language("Makefile") +assert_nil(noext, "detect_language(no ext) returns nil") + +-- ============================================================================ +-- Test: Un.hmac_sign() +-- ============================================================================ + +print("\nTesting Un.hmac_sign()...") + +-- Test basic signature generation +local sig = Un.hmac_sign("secret_key", "1234567890:POST:/execute:{}") +assert_not_nil(sig, "hmac_sign() returns non-nil") +assert_equal(#sig, 64, "hmac_sign() returns 64-char hex string") + +-- Verify hex characters +assert_true(sig:match("^[0-9a-fA-F]+$") ~= nil, "hmac_sign() returns valid hex") + +-- Test deterministic output +local sig1 = Un.hmac_sign("key", "message") +local sig2 = Un.hmac_sign("key", "message") +assert_equal(sig1, sig2, "hmac_sign() is deterministic") + +-- Test different keys produce different signatures +local sig_a = Un.hmac_sign("key_a", "message") +local sig_b = Un.hmac_sign("key_b", "message") +assert_true(sig_a ~= sig_b, "Different keys produce different signatures") + +-- Test different messages produce different signatures +local sig_m1 = Un.hmac_sign("key", "message1") +local sig_m2 = Un.hmac_sign("key", "message2") +assert_true(sig_m1 ~= sig_m2, "Different messages produce different signatures") + +-- Test nil handling +local null_key = Un.hmac_sign(nil, "message") +assert_nil(null_key, "hmac_sign(nil, msg) returns nil") + +local null_msg = Un.hmac_sign("key", nil) +assert_nil(null_msg, "hmac_sign(key, nil) returns nil") + +-- Test known HMAC value +local known_sig = Un.hmac_sign("key", "message") +assert_true(known_sig:sub(1, 32) == "6e9ef29b75fffc5b7abae527d58fdadb", + "HMAC-SHA256('key', 'message') matches expected prefix") + +-- ============================================================================ +-- Test: Un.last_error() +-- ============================================================================ + +print("\nTesting Un.last_error()...") + +local error_msg = Un.last_error() +assert_not_nil(error_msg, "last_error() returns non-nil") + +Un.set_error("test error") +assert_equal(Un.last_error(), "test error", "last_error() returns set error") + +-- ============================================================================ +-- Test: Memory stress test +-- ============================================================================ + +print("\nTesting Memory Management...") + +-- Stress test HMAC allocation +for i = 0, 999 do + Un.hmac_sign("key", "message") +end +PASS("1000 HMAC calls without crash") + +-- Stress test language detection +for i = 0, 999 do + Un.detect_language("test.py") +end +PASS("1000 detect_language calls without crash") + +-- Stress test version +for i = 0, 999 do + Un.version() +end +PASS("1000 version calls without crash") + +-- ============================================================================ +-- Test: Function existence +-- ============================================================================ + +print("\nTesting Library function existence...") + +local functions = { + -- Execution functions (8) + "execute", "execute_async", "wait_job", "get_job", + "cancel_job", "list_jobs", "get_languages", "detect_language", + + -- Session functions (9) + "session_list", "session_get", "session_create", "session_destroy", + "session_freeze", "session_unfreeze", "session_boost", "session_unboost", + "session_execute", + + -- Service functions (17) + "service_list", "service_get", "service_create", "service_destroy", + "service_freeze", "service_unfreeze", "service_lock", "service_unlock", + "service_set_unfreeze_on_demand", "service_redeploy", "service_logs", + "service_execute", "service_env_get", "service_env_set", + "service_env_delete", "service_env_export", "service_resize", + + -- Snapshot functions (9) + "snapshot_list", "snapshot_get", "snapshot_session", "snapshot_service", + "snapshot_restore", "snapshot_delete", "snapshot_lock", "snapshot_unlock", + "snapshot_clone", + + -- Image functions (13) + "image_list", "image_get", "image_publish", "image_delete", + "image_lock", "image_unlock", "image_set_visibility", + "image_grant_access", "image_revoke_access", "image_list_trusted", + "image_transfer", "image_spawn", "image_clone", + + -- PaaS Logs (2) + "logs_fetch", "logs_stream", + + -- Utilities + "validate_keys", "hmac_sign", "health_check", "version", "last_error" +} + +for _, func_name in ipairs(functions) do + assert_true(type(Un[func_name]) == "function", "Un." .. func_name .. " exists") +end + +-- ============================================================================ +-- Summary +-- ============================================================================ + +print("\n=====================================") +print("Test Summary") +print("=====================================") +print("Passed: \027[32m" .. tests_passed .. "\027[0m") +print("Failed: \027[31m" .. tests_failed .. "\027[0m") +print("=====================================") + +os.exit(tests_failed > 0 and 1 or 0) diff --git a/clients/nim/sync/src/un.nim b/clients/nim/sync/src/un.nim index 27c2686..80da4a1 100644 --- a/clients/nim/sync/src/un.nim +++ b/clients/nim/sync/src/un.nim @@ -451,13 +451,20 @@ proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: let cmd = fmt"""curl -s -X POST '{API_BASE}/execute' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" echo execCurl(cmd) -proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, screen: bool, inputFiles: seq[string], publicKey: string, secretKey: string) = +proc cmdSession(list: bool, kill, info, freeze, unfreeze, boost, unboost, execute, command, shell, network: string, vcpu: int, tmux, screen: bool, inputFiles: seq[string], publicKey: string, secretKey: string) = if list: let authHeaders = buildAuthHeaders("GET", "/sessions", "", publicKey, secretKey) let cmd = fmt"""curl -s -X GET '{API_BASE}/sessions' {authHeaders}""" echo execCurl(cmd) return + if info != "": + let path = fmt"/sessions/{info}" + let authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/sessions/{info}' {authHeaders}""" + echo execCurl(cmd) + return + if kill != "": let path = fmt"/sessions/{kill}" let authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey) @@ -466,6 +473,74 @@ proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, scree echo GREEN & "Session terminated: " & kill & RESET return + if freeze != "": + let path = fmt"/sessions/{freeze}/freeze" + let authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions/{freeze}/freeze' {authHeaders}""" + discard execCurl(cmd) + echo GREEN & "Session frozen: " & freeze & RESET + return + + if unfreeze != "": + let path = fmt"/sessions/{unfreeze}/unfreeze" + let authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions/{unfreeze}/unfreeze' {authHeaders}""" + discard execCurl(cmd) + echo GREEN & "Session unfreezing: " & unfreeze & RESET + return + + if boost != "": + let boostVcpu = if vcpu > 0: vcpu else: 2 + let json = fmt"""{{"vcpu":{boostVcpu}}}""" + let path = fmt"/sessions/{boost}/boost" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions/{boost}/boost' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + discard execCurl(cmd) + echo GREEN & "Session boosted to " & $boostVcpu & " vCPU: " & boost & RESET + return + + if unboost != "": + let path = fmt"/sessions/{unboost}/unboost" + let authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions/{unboost}/unboost' {authHeaders}""" + discard execCurl(cmd) + echo GREEN & "Session unboosted: " & unboost & RESET + return + + if execute != "": + let json = fmt"""{"command":"{escapeJson(command)}"}""" + let path = fmt"/sessions/{execute}/execute" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions/{execute}/execute' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + let result = execCurl(cmd) + + let stdoutStart = result.find("\"stdout\":\"") + if stdoutStart >= 0: + let start = stdoutStart + 10 + var endPos = start + while endPos < result.len: + if result[endPos] == '"' and (endPos == 0 or result[endPos-1] != '\\'): + break + inc endPos + if endPos > start: + var output = result[start..= 0: + let start = stderrStart + 10 + var endPos = start + while endPos < result.len: + if result[endPos] == '"' and (endPos == 0 or result[endPos-1] != '\\'): + break + inc endPos + if endPos > start: + var errout = result[start.. 0: json.add(fmt""","vcpu":{vcpu}""") @@ -492,7 +567,7 @@ proc setServiceUnfreezeOnDemand(serviceId: string, enabled: bool, publicKey: str except: return false -proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, resize: string, resizeVcpu: int, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, unfreezeOnDemand: bool, setUnfreezeOnDemand, setUnfreezeOnDemandEnabled: string, inputFiles: seq[string], svcEnvs: seq[string], svcEnvFile, envAction, envTarget: string, publicKey: string, secretKey: string) = +proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, resize: string, resizeVcpu: int, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, unfreezeOnDemand: bool, setUnfreezeOnDemand, setUnfreezeOnDemandEnabled, lock, unlock, redeploy: string, inputFiles: seq[string], svcEnvs: seq[string], svcEnvFile, envAction, envTarget: string, publicKey: string, secretKey: string) = # Handle env subcommand if envAction != "": cmdServiceEnv(envAction, envTarget, svcEnvs, svcEnvFile, publicKey, secretKey) @@ -577,6 +652,47 @@ proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list quit(1) return + if lock != "": + let path = fmt"/services/{lock}/lock" + let authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{lock}/lock' {authHeaders}""" + discard execCurl(cmd) + echo GREEN & "Service locked: " & lock & RESET + return + + if unlock != "": + let path = fmt"/services/{unlock}/unlock" + let status = execCurlPostWithSudo(path, "{}", publicKey, secretKey) + if status >= 200 and status < 300: + echo GREEN & "Service unlocked: " & unlock & RESET + elif status != 428: + stderr.writeLine(RED & "Error: Failed to unlock service" & RESET) + quit(1) + return + + if redeploy != "": + var json = "{" + var hasContent = false + if bootstrap != "": + json.add(fmt""""bootstrap":"{escapeJson(bootstrap)}"""") + hasContent = true + if bootstrapFile != "": + if fileExists(bootstrapFile): + let bootCode = readFile(bootstrapFile) + if hasContent: json.add(",") + json.add(fmt""""bootstrap_content":"{escapeJson(bootCode)}"""") + else: + stderr.writeLine(RED & "Error: Bootstrap file not found: " & bootstrapFile & RESET) + quit(1) + json.add("}") + let path = fmt"/services/{redeploy}/redeploy" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{redeploy}/redeploy' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + let response = execCurl(cmd) + echo GREEN & "Service redeployed: " & redeploy & RESET + echo response + return + if execute != "": let json = fmt"""{"command":"{escapeJson(command)}"}""" let path = fmt"/services/{execute}/execute" @@ -823,7 +939,144 @@ proc cmdLanguages(jsonOutput: bool, publicKey: string, secretKey: string) = else: inc pos -proc cmdImage(list: bool, infoId, deleteId, lockId, unlockId, publishId, sourceType, visibilityId, visibilityMode, spawnId, cloneId, name, ports, publicKey, secretKey: string) = +proc cmdSnapshot(list: bool, infoId, sessionId, serviceId, restoreId, deleteId, lockId, unlockId, cloneId, cloneType, name, ports: string, hot: bool, publicKey, secretKey: string) = + if list: + let authHeaders = buildAuthHeaders("GET", "/snapshots", "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/snapshots' {authHeaders}""" + echo execCurl(cmd) + return + + if infoId != "": + let path = fmt"/snapshots/{infoId}" + let authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/snapshots/{infoId}' {authHeaders}""" + echo execCurl(cmd) + return + + if sessionId != "": + var json = "{" + var hasContent = false + if name != "": + json.add(fmt""""name":"{name}"""") + hasContent = true + if hot: + if hasContent: json.add(",") + json.add(""""hot":true""") + json.add("}") + let path = fmt"/sessions/{sessionId}/snapshot" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions/{sessionId}/snapshot' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + let response = execCurl(cmd) + echo GREEN & "Snapshot created" & RESET + echo response + return + + if serviceId != "": + var json = "{" + var hasContent = false + if name != "": + json.add(fmt""""name":"{name}"""") + hasContent = true + if hot: + if hasContent: json.add(",") + json.add(""""hot":true""") + json.add("}") + let path = fmt"/services/{serviceId}/snapshot" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{serviceId}/snapshot' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + let response = execCurl(cmd) + echo GREEN & "Snapshot created" & RESET + echo response + return + + if restoreId != "": + let path = fmt"/snapshots/{restoreId}/restore" + let authHeaders = buildAuthHeaders("POST", path, "{}", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/snapshots/{restoreId}/restore' -H 'Content-Type: application/json' {authHeaders} -d '{{}}'""" + let response = execCurl(cmd) + echo GREEN & "Snapshot restored" & RESET + echo response + return + + if deleteId != "": + let path = fmt"/snapshots/{deleteId}" + let status = execCurlDeleteWithSudo(path, publicKey, secretKey) + if status >= 200 and status < 300: + echo GREEN & "Snapshot deleted: " & deleteId & RESET + elif status != 428: + stderr.writeLine(RED & "Error: Failed to delete snapshot" & RESET) + quit(1) + return + + if lockId != "": + let path = fmt"/snapshots/{lockId}/lock" + let authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/snapshots/{lockId}/lock' {authHeaders}""" + discard execCurl(cmd) + echo GREEN & "Snapshot locked: " & lockId & RESET + return + + if unlockId != "": + let path = fmt"/snapshots/{unlockId}/unlock" + let status = execCurlPostWithSudo(path, "{}", publicKey, secretKey) + if status >= 200 and status < 300: + echo GREEN & "Snapshot unlocked: " & unlockId & RESET + elif status != 428: + stderr.writeLine(RED & "Error: Failed to unlock snapshot" & RESET) + quit(1) + return + + if cloneId != "": + var json = fmt"""{{"clone_type":"{if cloneType != "": cloneType else: "session"}""""" + if name != "": json.add(fmt""","name":"{name}"""") + if ports != "": json.add(fmt""","ports":[{ports}]""") + json.add("}") + let path = fmt"/snapshots/{cloneId}/clone" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/snapshots/{cloneId}/clone' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + let response = execCurl(cmd) + echo GREEN & "Snapshot cloned" & RESET + echo response + return + + stderr.writeLine(RED & "Error: Use --list, --info, --session, --service, --restore, --delete, --lock, --unlock, or --clone" & RESET) + quit(1) + +proc cmdLogs(source: string, lines: int, since, grep: string, follow: bool, publicKey, secretKey: string) = + let sourceParam = if source != "": source else: "all" + let linesParam = if lines > 0: lines else: 100 + let sinceParam = if since != "": since else: "1h" + + if follow: + var endpoint = fmt"/paas/logs/stream?source={sourceParam}" + if grep != "": endpoint.add(fmt"&grep={grep}") + let authHeaders = buildAuthHeaders("GET", endpoint, "", publicKey, secretKey) + let cmd = fmt"""curl -s -N -X GET '{PORTAL_BASE}{endpoint}' -H 'Accept: text/event-stream' {authHeaders}""" + # Stream logs - this will block + let output = execProcess(cmd) + echo output + else: + var endpoint = fmt"/paas/logs?source={sourceParam}&lines={linesParam}&since={sinceParam}" + if grep != "": endpoint.add(fmt"&grep={grep}") + let authHeaders = buildAuthHeaders("GET", endpoint, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{PORTAL_BASE}{endpoint}' {authHeaders}""" + echo execCurl(cmd) + +proc cmdHealth() = + let cmd = fmt"""curl -s -X GET '{API_BASE}/health'""" + let response = execProcess(cmd) + if response.contains("\"status\":\"healthy\"") or response.contains("\"ok\":true"): + echo GREEN & "API is healthy" & RESET + else: + echo RED & "API may be unhealthy" & RESET + echo response + +proc cmdVersion() = + echo "un.nim version 1.0.0" + echo "API: " & API_BASE + echo "Portal: " & PORTAL_BASE + +proc cmdImage(list: bool, infoId, deleteId, lockId, unlockId, publishId, sourceType, visibilityId, visibilityMode, spawnId, cloneId, name, ports, grantId, revokeId, trustedId, trustedKey, transferId, toKey, publicKey, secretKey: string) = if list: let authHeaders = buildAuthHeaders("GET", "/images", "", publicKey, secretKey) let cmd = fmt"""curl -s -X GET '{API_BASE}/images' {authHeaders}""" @@ -919,7 +1172,52 @@ proc cmdImage(list: bool, infoId, deleteId, lockId, unlockId, publishId, sourceT echo response return - stderr.writeLine(RED & "Error: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, or --clone" & RESET) + if grantId != "": + if trustedKey == "": + stderr.writeLine(RED & "Error: --grant requires --trusted-key" & RESET) + quit(1) + let json = fmt"""{{"trusted_api_key":"{trustedKey}"}}""" + let path = fmt"/images/{grantId}/grant" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/images/{grantId}/grant' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + discard execCurl(cmd) + echo GREEN & "Access granted to " & trustedKey & RESET + return + + if revokeId != "": + if trustedKey == "": + stderr.writeLine(RED & "Error: --revoke requires --trusted-key" & RESET) + quit(1) + let json = fmt"""{{"trusted_api_key":"{trustedKey}"}}""" + let path = fmt"/images/{revokeId}/revoke" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/images/{revokeId}/revoke' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + discard execCurl(cmd) + echo GREEN & "Access revoked from " & trustedKey & RESET + return + + if trustedId != "": + let path = fmt"/images/{trustedId}/trusted" + let authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/images/{trustedId}/trusted' {authHeaders}""" + echo execCurl(cmd) + return + + if transferId != "": + if toKey == "": + stderr.writeLine(RED & "Error: --transfer requires --to-key" & RESET) + quit(1) + let json = fmt"""{{"to_api_key":"{toKey}"}}""" + let path = fmt"/images/{transferId}/transfer" + let status = execCurlPostWithSudo(path, json, publicKey, secretKey) + if status >= 200 and status < 300: + echo GREEN & "Image transferred to " & toKey & RESET + elif status != 428: + stderr.writeLine(RED & "Error: Failed to transfer image" & RESET) + quit(1) + return + + stderr.writeLine(RED & "Error: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, --clone, --grant, --revoke, --trusted, or --transfer" & RESET) quit(1) proc main() = @@ -985,6 +1283,7 @@ proc main() = var list = false var infoId, deleteId, lockId, unlockId, publishId, sourceType = "" var visibilityId, visibilityMode, spawnId, cloneId, name, ports = "" + var grantId, revokeId, trustedId, trustedKey, transferId, toKey = "" var i = 1 while i < args.len: case args[i] @@ -1003,10 +1302,69 @@ proc main() = of "--clone": cloneId = args[i+1]; inc i of "--name": name = args[i+1]; inc i of "--ports": ports = args[i+1]; inc i + of "--grant": grantId = args[i+1]; inc i + of "--revoke": revokeId = args[i+1]; inc i + of "--trusted": trustedId = args[i+1]; inc i + of "--trusted-key": trustedKey = args[i+1]; inc i + of "--transfer": transferId = args[i+1]; inc i + of "--to-key": toKey = args[i+1]; inc i of "-k": publicKey = args[i+1]; inc i else: discard inc i - cmdImage(list, infoId, deleteId, lockId, unlockId, publishId, sourceType, visibilityId, visibilityMode, spawnId, cloneId, name, ports, publicKey, secretKey) + cmdImage(list, infoId, deleteId, lockId, unlockId, publishId, sourceType, visibilityId, visibilityMode, spawnId, cloneId, name, ports, grantId, revokeId, trustedId, trustedKey, transferId, toKey, publicKey, secretKey) + return + + if args[0] == "snapshot": + var list = false + var infoId, sessionId, serviceId, restoreId, deleteId, lockId, unlockId = "" + var cloneId, cloneType, name, ports = "" + var hot = false + var i = 1 + while i < args.len: + case args[i] + of "--list", "-l": list = true + of "--info": infoId = args[i+1]; inc i + of "--session": sessionId = args[i+1]; inc i + of "--service": serviceId = args[i+1]; inc i + of "--restore": restoreId = args[i+1]; inc i + of "--delete": deleteId = args[i+1]; inc i + of "--lock": lockId = args[i+1]; inc i + of "--unlock": unlockId = args[i+1]; inc i + of "--clone": cloneId = args[i+1]; inc i + of "--clone-type": cloneType = args[i+1]; inc i + of "--name": name = args[i+1]; inc i + of "--ports": ports = args[i+1]; inc i + of "--hot": hot = true + of "-k": publicKey = args[i+1]; inc i + else: discard + inc i + cmdSnapshot(list, infoId, sessionId, serviceId, restoreId, deleteId, lockId, unlockId, cloneId, cloneType, name, ports, hot, publicKey, secretKey) + return + + if args[0] == "logs": + var source, since, grep = "" + var lines = 0 + var follow = false + var i = 1 + while i < args.len: + case args[i] + of "--source": source = args[i+1]; inc i + of "--lines": lines = parseInt(args[i+1]); inc i + of "--since": since = args[i+1]; inc i + of "--grep": grep = args[i+1]; inc i + of "--follow", "-f": follow = true + of "-k": publicKey = args[i+1]; inc i + else: discard + inc i + cmdLogs(source, lines, since, grep, follow, publicKey, secretKey) + return + + if args[0] == "health": + cmdHealth() + return + + if args[0] == "version": + cmdVersion() return if args[0] == "key": @@ -1022,7 +1380,7 @@ proc main() = if args[0] == "session": var list = false - var kill, shell, network = "" + var kill, info, freeze, unfreeze, boost, unboost, execute, command, shell, network = "" var vcpu = 0 var tmux, screen = false var inputFiles: seq[string] = @[] @@ -1031,6 +1389,13 @@ proc main() = case args[i] of "--list": list = true of "--kill": kill = args[i+1]; inc i + of "--info": info = args[i+1]; inc i + of "--freeze": freeze = args[i+1]; inc i + of "--unfreeze": unfreeze = args[i+1]; inc i + of "--boost": boost = args[i+1]; inc i + of "--unboost": unboost = args[i+1]; inc i + of "--execute": execute = args[i+1]; inc i + of "--command": command = args[i+1]; inc i of "--shell": shell = args[i+1]; inc i of "-n": network = args[i+1]; inc i of "-v": vcpu = parseInt(args[i+1]); inc i @@ -1047,13 +1412,14 @@ proc main() = inc i else: discard inc i - cmdSession(list, kill, shell, network, vcpu, tmux, screen, inputFiles, publicKey, secretKey) + cmdSession(list, kill, info, freeze, unfreeze, boost, unboost, execute, command, shell, network, vcpu, tmux, screen, inputFiles, publicKey, secretKey) return if args[0] == "service": var name, ports, bootstrap, bootstrapFile, serviceType = "" var list = false var info, logs, tail, sleep, wake, destroy, resize, execute, command, dumpBootstrap, dumpFile, network = "" + var lock, unlock, redeploy = "" var vcpu = 0 var resizeVcpu = 0 var unfreezeOnDemand = false @@ -1078,7 +1444,7 @@ proc main() = of "-k": publicKey = args[i+1]; inc i else: discard inc i - cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, network, vcpu, unfreezeOnDemand, setUnfreezeOnDemand, setUnfreezeOnDemandEnabled, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey) + cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, network, vcpu, unfreezeOnDemand, setUnfreezeOnDemand, setUnfreezeOnDemandEnabled, lock, unlock, redeploy, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey) return while i < args.len: @@ -1101,6 +1467,9 @@ proc main() = of "--command": command = args[i+1]; inc i of "--dump-bootstrap": dumpBootstrap = args[i+1]; inc i of "--dump-file": dumpFile = args[i+1]; inc i + of "--lock": lock = args[i+1]; inc i + of "--unlock": unlock = args[i+1]; inc i + of "--redeploy": redeploy = args[i+1]; inc i of "-n": network = args[i+1]; inc i of "-v": vcpu = parseInt(args[i+1]); inc i of "-k": publicKey = args[i+1]; inc i @@ -1121,7 +1490,7 @@ proc main() = inc i else: discard inc i - cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, network, vcpu, unfreezeOnDemand, setUnfreezeOnDemand, setUnfreezeOnDemandEnabled, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey) + cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, network, vcpu, unfreezeOnDemand, setUnfreezeOnDemand, setUnfreezeOnDemandEnabled, lock, unlock, redeploy, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey) return # Execute mode diff --git a/clients/objective-c/sync/src/un.m b/clients/objective-c/sync/src/un.m index 64ecb48..c7b77d4 100644 --- a/clients/objective-c/sync/src/un.m +++ b/clients/objective-c/sync/src/un.m @@ -1674,6 +1674,12 @@ void cmdImage(NSArray* args) { NSString* cloneId = nil; NSString* name = nil; NSString* ports = nil; + NSString* grantId = nil; + NSString* revokeId = nil; + NSString* trustedId = nil; + NSString* trustedKey = nil; + NSString* transferId = nil; + NSString* toKey = nil; for (NSUInteger i = 0; i < [args count]; i++) { NSString* arg = args[i]; @@ -1702,6 +1708,18 @@ void cmdImage(NSArray* args) { name = args[++i]; } else if ([arg isEqualToString:@"--ports"] && i + 1 < [args count]) { ports = args[++i]; + } else if ([arg isEqualToString:@"--grant"] && i + 1 < [args count]) { + grantId = args[++i]; + } else if ([arg isEqualToString:@"--revoke"] && i + 1 < [args count]) { + revokeId = args[++i]; + } else if ([arg isEqualToString:@"--trusted"] && i + 1 < [args count]) { + trustedId = args[++i]; + } else if ([arg isEqualToString:@"--trusted-key"] && i + 1 < [args count]) { + trustedKey = args[++i]; + } else if ([arg isEqualToString:@"--transfer"] && i + 1 < [args count]) { + transferId = args[++i]; + } else if ([arg isEqualToString:@"--to-key"] && i + 1 < [args count]) { + toKey = args[++i]; } } @@ -1831,11 +1849,443 @@ void cmdImage(NSArray* args) { return; } - fprintf(stderr, "%sError: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, or --clone%s\n", + if (grantId) { + if (!trustedKey) { + fprintf(stderr, "%sError: --grant requires --trusted-key%s\n", [RED UTF8String], [RESET UTF8String]); + exit(1); + } + NSString* endpoint = [NSString stringWithFormat:@"/images/%@/grant", grantId]; + NSDictionary* payload = @{@"trusted_api_key": trustedKey}; + apiRequestCLI(endpoint, @"POST", payload, publicKey, secretKey); + printf("%sAccess granted to %s%s\n", [GREEN UTF8String], [trustedKey UTF8String], [RESET UTF8String]); + return; + } + + if (revokeId) { + if (!trustedKey) { + fprintf(stderr, "%sError: --revoke requires --trusted-key%s\n", [RED UTF8String], [RESET UTF8String]); + exit(1); + } + NSString* endpoint = [NSString stringWithFormat:@"/images/%@/revoke", revokeId]; + NSDictionary* payload = @{@"trusted_api_key": trustedKey}; + apiRequestCLI(endpoint, @"POST", payload, publicKey, secretKey); + printf("%sAccess revoked from %s%s\n", [GREEN UTF8String], [trustedKey UTF8String], [RESET UTF8String]); + return; + } + + if (trustedId) { + NSString* endpoint = [NSString stringWithFormat:@"/images/%@/trusted", trustedId]; + NSDictionary* result = apiRequestCLI(endpoint, @"GET", nil, publicKey, secretKey); + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%s\n", [jsonString UTF8String]); + return; + } + + if (transferId) { + if (!toKey) { + fprintf(stderr, "%sError: --transfer requires --to-key%s\n", [RED UTF8String], [RESET UTF8String]); + exit(1); + } + NSString* endpoint = [NSString stringWithFormat:@"/images/%@/transfer", transferId]; + NSDictionary* payload = @{@"to_api_key": toKey}; + NSInteger statusCode = 0; + NSDictionary* response = apiRequestWithStatusCLI(endpoint, @"POST", payload, publicKey, secretKey, &statusCode); + + if (statusCode == 428) { + NSData* bodyData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; + NSString* bodyStr = [[NSString alloc] initWithData:bodyData encoding:NSUTF8StringEncoding]; + if (handleSudoChallenge(response, @"POST", endpoint, bodyStr, publicKey, secretKey)) { + printf("%sImage transferred to %s%s\n", [GREEN UTF8String], [toKey UTF8String], [RESET UTF8String]); + } else { + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + printf("%sImage transferred to %s%s\n", [GREEN UTF8String], [toKey UTF8String], [RESET UTF8String]); + } else { + fprintf(stderr, "%sError: HTTP %ld%s\n", [RED UTF8String], (long)statusCode, [RESET UTF8String]); + exit(1); + } + return; + } + + fprintf(stderr, "%sError: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, --clone, --grant, --revoke, --trusted, or --transfer%s\n", [RED UTF8String], [RESET UTF8String]); exit(1); } +// ============================================================================ +// Snapshot Command +// ============================================================================ + +void cmdSnapshot(NSArray* args) { + NSString* publicKey, *secretKey; + UNGetApiKeysCLI(&publicKey, &secretKey); + + BOOL listMode = NO; + NSString* infoId = nil; + NSString* sessionId = nil; + NSString* serviceId = nil; + NSString* restoreId = nil; + NSString* deleteId = nil; + NSString* lockId = nil; + NSString* unlockId = nil; + NSString* cloneId = nil; + NSString* cloneType = nil; + NSString* name = nil; + NSString* ports = nil; + BOOL hot = NO; + + for (NSUInteger i = 0; i < [args count]; i++) { + NSString* arg = args[i]; + if ([arg isEqualToString:@"--list"] || [arg isEqualToString:@"-l"]) { + listMode = YES; + } else if ([arg isEqualToString:@"--info"] && i + 1 < [args count]) { + infoId = args[++i]; + } else if ([arg isEqualToString:@"--session"] && i + 1 < [args count]) { + sessionId = args[++i]; + } else if ([arg isEqualToString:@"--service"] && i + 1 < [args count]) { + serviceId = args[++i]; + } else if ([arg isEqualToString:@"--restore"] && i + 1 < [args count]) { + restoreId = args[++i]; + } else if ([arg isEqualToString:@"--delete"] && i + 1 < [args count]) { + deleteId = args[++i]; + } else if ([arg isEqualToString:@"--lock"] && i + 1 < [args count]) { + lockId = args[++i]; + } else if ([arg isEqualToString:@"--unlock"] && i + 1 < [args count]) { + unlockId = args[++i]; + } else if ([arg isEqualToString:@"--clone"] && i + 1 < [args count]) { + cloneId = args[++i]; + } else if ([arg isEqualToString:@"--clone-type"] && i + 1 < [args count]) { + cloneType = args[++i]; + } else if ([arg isEqualToString:@"--name"] && i + 1 < [args count]) { + name = args[++i]; + } else if ([arg isEqualToString:@"--ports"] && i + 1 < [args count]) { + ports = args[++i]; + } else if ([arg isEqualToString:@"--hot"]) { + hot = YES; + } + } + + if (listMode) { + NSDictionary* result = apiRequestCLI(@"/snapshots", @"GET", nil, publicKey, secretKey); + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%s\n", [jsonString UTF8String]); + return; + } + + if (infoId) { + NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@", infoId]; + NSDictionary* result = apiRequestCLI(endpoint, @"GET", nil, publicKey, secretKey); + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%s\n", [jsonString UTF8String]); + return; + } + + if (sessionId) { + NSString* endpoint = [NSString stringWithFormat:@"/sessions/%@/snapshot", sessionId]; + NSMutableDictionary* payload = [NSMutableDictionary dictionary]; + if (name) payload[@"name"] = name; + if (hot) payload[@"hot"] = @YES; + + NSDictionary* result = apiRequestCLI(endpoint, @"POST", payload, publicKey, secretKey); + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%sSnapshot created%s\n", [GREEN UTF8String], [RESET UTF8String]); + printf("%s\n", [jsonString UTF8String]); + return; + } + + if (serviceId) { + NSString* endpoint = [NSString stringWithFormat:@"/services/%@/snapshot", serviceId]; + NSMutableDictionary* payload = [NSMutableDictionary dictionary]; + if (name) payload[@"name"] = name; + if (hot) payload[@"hot"] = @YES; + + NSDictionary* result = apiRequestCLI(endpoint, @"POST", payload, publicKey, secretKey); + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%sSnapshot created%s\n", [GREEN UTF8String], [RESET UTF8String]); + printf("%s\n", [jsonString UTF8String]); + return; + } + + if (restoreId) { + NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/restore", restoreId]; + NSDictionary* result = apiRequestCLI(endpoint, @"POST", @{}, publicKey, secretKey); + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%sSnapshot restored%s\n", [GREEN UTF8String], [RESET UTF8String]); + printf("%s\n", [jsonString UTF8String]); + return; + } + + if (deleteId) { + NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@", deleteId]; + NSInteger statusCode = 0; + NSDictionary* response = apiRequestWithStatusCLI(endpoint, @"DELETE", nil, publicKey, secretKey, &statusCode); + + if (statusCode == 428) { + if (handleSudoChallenge(response, @"DELETE", endpoint, nil, publicKey, secretKey)) { + printf("%sSnapshot deleted: %s%s\n", [GREEN UTF8String], [deleteId UTF8String], [RESET UTF8String]); + } else { + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + printf("%sSnapshot deleted: %s%s\n", [GREEN UTF8String], [deleteId UTF8String], [RESET UTF8String]); + } else { + fprintf(stderr, "%sError: HTTP %ld%s\n", [RED UTF8String], (long)statusCode, [RESET UTF8String]); + exit(1); + } + return; + } + + if (lockId) { + NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/lock", lockId]; + apiRequestCLI(endpoint, @"POST", nil, publicKey, secretKey); + printf("%sSnapshot locked: %s%s\n", [GREEN UTF8String], [lockId UTF8String], [RESET UTF8String]); + return; + } + + if (unlockId) { + NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/unlock", unlockId]; + NSInteger statusCode = 0; + NSDictionary* response = apiRequestWithStatusCLI(endpoint, @"POST", @{}, publicKey, secretKey, &statusCode); + + if (statusCode == 428) { + if (handleSudoChallenge(response, @"POST", endpoint, @"{}", publicKey, secretKey)) { + printf("%sSnapshot unlocked: %s%s\n", [GREEN UTF8String], [unlockId UTF8String], [RESET UTF8String]); + } else { + exit(1); + } + } else if (statusCode >= 200 && statusCode < 300) { + printf("%sSnapshot unlocked: %s%s\n", [GREEN UTF8String], [unlockId UTF8String], [RESET UTF8String]); + } else { + fprintf(stderr, "%sError: HTTP %ld%s\n", [RED UTF8String], (long)statusCode, [RESET UTF8String]); + exit(1); + } + return; + } + + if (cloneId) { + NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/clone", cloneId]; + NSMutableDictionary* payload = [NSMutableDictionary dictionary]; + payload[@"clone_type"] = cloneType ?: @"session"; + if (name) payload[@"name"] = name; + if (ports) { + NSArray* portStrings = [ports componentsSeparatedByString:@","]; + NSMutableArray* portNumbers = [NSMutableArray array]; + for (NSString* p in portStrings) { + [portNumbers addObject:@([p intValue])]; + } + payload[@"ports"] = portNumbers; + } + + NSDictionary* result = apiRequestCLI(endpoint, @"POST", payload, publicKey, secretKey); + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%sSnapshot cloned%s\n", [GREEN UTF8String], [RESET UTF8String]); + printf("%s\n", [jsonString UTF8String]); + return; + } + + fprintf(stderr, "%sError: Use --list, --info, --session, --service, --restore, --delete, --lock, --unlock, or --clone%s\n", + [RED UTF8String], [RESET UTF8String]); + exit(1); +} + +// ============================================================================ +// Logs Command +// ============================================================================ + +void cmdLogs(NSArray* args) { + NSString* publicKey, *secretKey; + UNGetApiKeysCLI(&publicKey, &secretKey); + + NSString* source = @"all"; + NSInteger lines = 100; + NSString* since = @"1h"; + NSString* grepPattern = nil; + BOOL follow = NO; + + for (NSUInteger i = 0; i < [args count]; i++) { + NSString* arg = args[i]; + if (([arg isEqualToString:@"--source"] || [arg isEqualToString:@"-s"]) && i + 1 < [args count]) { + source = args[++i]; + } else if (([arg isEqualToString:@"--lines"] || [arg isEqualToString:@"-n"]) && i + 1 < [args count]) { + lines = [args[++i] integerValue]; + } else if ([arg isEqualToString:@"--since"] && i + 1 < [args count]) { + since = args[++i]; + } else if (([arg isEqualToString:@"--grep"] || [arg isEqualToString:@"-g"]) && i + 1 < [args count]) { + grepPattern = args[++i]; + } else if ([arg isEqualToString:@"--follow"] || [arg isEqualToString:@"-f"]) { + follow = YES; + } + } + + if (follow) { + // Streaming logs via SSE + NSMutableString* endpoint = [NSMutableString stringWithFormat:@"/paas/logs/stream?source=%@", source]; + if (grepPattern) { + NSString* encoded = [grepPattern stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + [endpoint appendFormat:@"&grep=%@", encoded]; + } + + NSString* urlStr = [NSString stringWithFormat:@"%@%@", UN_PORTAL_BASE, endpoint]; + NSURL* url = [NSURL URLWithString:urlStr]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = @"GET"; + [request setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"]; + + // Add HMAC authentication headers + if (secretKey && [secretKey length] > 0) { + NSString* timestamp = [NSString stringWithFormat:@"%ld", (long)[[NSDate date] timeIntervalSince1970]]; + NSString* message = [NSString stringWithFormat:@"%@:GET:%@:", timestamp, endpoint]; + NSString* signature = hmacSign(secretKey, message); + [request setValue:[NSString stringWithFormat:@"Bearer %@", publicKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:timestamp forHTTPHeaderField:@"X-Timestamp"]; + [request setValue:signature forHTTPHeaderField:@"X-Signature"]; + } else { + [request setValue:[NSString stringWithFormat:@"Bearer %@", publicKey] forHTTPHeaderField:@"Authorization"]; + } + + printf("Streaming logs (Ctrl+C to stop)...\n"); + + // Use synchronous download for SSE - will block + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block NSData* responseData = nil; + + NSURLSessionDataTask* task = [[NSURLSession sharedSession] dataTaskWithRequest:request + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + responseData = data; + dispatch_semaphore_signal(semaphore); + }]; + [task resume]; + + // For SSE, we need to handle streaming differently - just show we attempted + // Real SSE would need a custom NSURLSession delegate + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + if (responseData) { + NSString* output = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; + printf("%s\n", [output UTF8String]); + } + return; + } + + // Fetch logs + NSMutableString* endpoint = [NSMutableString stringWithFormat:@"/paas/logs?source=%@&lines=%ld&since=%@", + source, (long)lines, since]; + if (grepPattern) { + NSString* encoded = [grepPattern stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + [endpoint appendFormat:@"&grep=%@", encoded]; + } + + // Call Portal API for logs + NSString* urlStr = [NSString stringWithFormat:@"%@%@", UN_PORTAL_BASE, endpoint]; + NSURL* url = [NSURL URLWithString:urlStr]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = @"GET"; + [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + + // Add HMAC authentication headers + if (secretKey && [secretKey length] > 0) { + NSString* timestamp = [NSString stringWithFormat:@"%ld", (long)[[NSDate date] timeIntervalSince1970]]; + NSString* message = [NSString stringWithFormat:@"%@:GET:%@:", timestamp, endpoint]; + NSString* signature = hmacSign(secretKey, message); + [request setValue:[NSString stringWithFormat:@"Bearer %@", publicKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:timestamp forHTTPHeaderField:@"X-Timestamp"]; + [request setValue:signature forHTTPHeaderField:@"X-Signature"]; + } else { + [request setValue:[NSString stringWithFormat:@"Bearer %@", publicKey] forHTTPHeaderField:@"Authorization"]; + } + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block NSDictionary* responseData = nil; + __block NSInteger statusCode = 0; + + NSURLSessionDataTask* task = [[NSURLSession sharedSession] dataTaskWithRequest:request + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response; + statusCode = httpResponse.statusCode; + if (data) { + responseData = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + } + dispatch_semaphore_signal(semaphore); + }]; + [task resume]; + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + if (statusCode == 200 && responseData) { + NSArray* logLines = responseData[@"logs"]; + if (logLines && [logLines isKindOfClass:[NSArray class]]) { + for (NSDictionary* entry in logLines) { + NSString* src = entry[@"source"] ?: @"unknown"; + NSString* msg = entry[@"line"] ?: @""; + printf("[%s] %s\n", [src UTF8String], [msg UTF8String]); + } + } else { + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:responseData options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%s\n", [jsonString UTF8String]); + } + } else { + fprintf(stderr, "%sError: HTTP %ld%s\n", [RED UTF8String], (long)statusCode, [RESET UTF8String]); + exit(1); + } +} + +// ============================================================================ +// Health Command +// ============================================================================ + +void cmdHealth(void) { + NSURL* url = [NSURL URLWithString:[NSString stringWithFormat:@"%@/health", UN_API_BASE]]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = @"GET"; + request.timeoutInterval = 10; + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + __block NSDictionary* responseData = nil; + __block NSInteger statusCode = 0; + + NSURLSessionDataTask* task = [[NSURLSession sharedSession] dataTaskWithRequest:request + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response; + statusCode = httpResponse.statusCode; + if (data) { + responseData = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + } + dispatch_semaphore_signal(semaphore); + }]; + [task resume]; + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + if (statusCode == 200) { + printf("%sAPI is healthy%s\n", [GREEN UTF8String], [RESET UTF8String]); + } else { + printf("%sAPI may be unhealthy%s\n", [RED UTF8String], [RESET UTF8String]); + } + + if (responseData) { + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:responseData options:NSJSONWritingPrettyPrinted error:nil]; + NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + printf("%s\n", [jsonString UTF8String]); + } +} + +// ============================================================================ +// Version Command +// ============================================================================ + +void cmdVersion(void) { + printf("un.m version 1.0.0\n"); + printf("API: %s\n", [UN_API_BASE UTF8String]); + printf("Portal: %s\n", [UN_PORTAL_BASE UTF8String]); +} + void cmdLanguages(BOOL jsonOutput) { NSString* publicKey, *secretKey; UNGetApiKeysCLI(&publicKey, &secretKey); @@ -1951,6 +2401,14 @@ int main(int argc, const char* argv[]) { cmdLanguages(jsonOutput); } else if ([firstArg isEqualToString:@"key"]) { cmdKey([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]); + } else if ([firstArg isEqualToString:@"snapshot"]) { + cmdSnapshot([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]); + } else if ([firstArg isEqualToString:@"logs"]) { + cmdLogs([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]); + } else if ([firstArg isEqualToString:@"health"]) { + cmdHealth(); + } else if ([firstArg isEqualToString:@"version"] || [firstArg isEqualToString:@"--version"]) { + cmdVersion(); } else { cmdExecute(args); } diff --git a/clients/ocaml/sync/src/un.ml b/clients/ocaml/sync/src/un.ml index e316b33..3baaf4a 100755 --- a/clients/ocaml/sync/src/un.ml +++ b/clients/ocaml/sync/src/un.ml @@ -995,6 +995,397 @@ module Client = struct languages ~public_key:client.public_key ~secret_key:client.secret_key () end +(* ============================================================================ + Library API - Sessions (9) + ============================================================================ *) + +(** SDK version *) +let sdk_version = "4.2.0" + +(** Return the SDK version *) +let version () = sdk_version + +(** Check API health *) +let health_check () = + let cmd = Printf.sprintf "curl -s -o /dev/null -w '%%{http_code}' %s/health" api_base in + let ic = Unix.open_process_in cmd in + let status = try input_line ic with End_of_file -> "0" in + let _ = Unix.close_process_in ic in + String.trim status = "200" + +(** Generate HMAC-SHA256 signature *) +let hmac_sign secret message = hmac_sha256 secret message + +(** Detect language from filename *) +let detect_language filename = + let ext = get_extension filename in + ext_to_lang ext + +(** List all sessions *) +let session_list ?public_key ?secret_key () = + api_get ?public_key ?secret_key "/sessions" + +(** Get session details *) +let session_get ?public_key ?secret_key session_id = + api_get ?public_key ?secret_key (Printf.sprintf "/sessions/%s" session_id) + +(** Create a new session *) +let session_create ?public_key ?secret_key ?(shell="bash") ?network ?vcpu () = + let network_json = match network with Some n -> Printf.sprintf ",\"network\":\"%s\"" n | None -> "" in + let vcpu_json = match vcpu with Some v -> Printf.sprintf ",\"vcpu\":%d" v | None -> "" in + let json = Printf.sprintf "{\"shell\":\"%s\"%s%s}" shell network_json vcpu_json in + let response = api_post ?public_key ?secret_key "/sessions" json in + extract_json_value response "id" + +(** Destroy a session *) +let session_destroy ?public_key ?secret_key session_id = + let response = api_delete ?public_key ?secret_key (Printf.sprintf "/sessions/%s" session_id) in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Freeze a session *) +let session_freeze ?public_key ?secret_key session_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/sessions/%s/freeze" session_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Unfreeze a session *) +let session_unfreeze ?public_key ?secret_key session_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/sessions/%s/unfreeze" session_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Boost session resources *) +let session_boost ?public_key ?secret_key session_id vcpu = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let json = Printf.sprintf "{\"vcpu\":%d}" vcpu in + let endpoint = Printf.sprintf "/sessions/%s" session_id in + let auth_headers = build_auth_headers pk sk "PATCH" endpoint json in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc json; + close_out oc; + let cmd = Printf.sprintf "curl -s -X PATCH %s%s -H 'Content-Type: application/json'%s -d @%s" + api_base endpoint auth_headers tmp_file in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let response = read_all "" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Unboost session *) +let session_unboost ?public_key ?secret_key session_id = + session_boost ?public_key ?secret_key session_id 1 + +(** Execute a command in a session *) +let session_execute ?public_key ?secret_key session_id command = + let json = Printf.sprintf "{\"command\":\"%s\"}" (escape_json command) in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/sessions/%s/execute" session_id) json in + let stdout_val = match extract_json_value response "stdout" with Some s -> unescape_json s | None -> "" in + let stderr_val = match extract_json_value response "stderr" with Some s -> unescape_json s | None -> "" in + let exit_code = match extract_json_int response "exit_code" with Some i -> i | None -> 0 in + { success = (exit_code = 0); stdout = stdout_val; stderr = stderr_val; exit_code; job_id = None } + +(* ============================================================================ + Library API - Services (17) + ============================================================================ *) + +(** List all services *) +let service_list ?public_key ?secret_key () = + api_get ?public_key ?secret_key "/services" + +(** Get service details *) +let service_get ?public_key ?secret_key service_id = + api_get ?public_key ?secret_key (Printf.sprintf "/services/%s" service_id) + +(** Create a new service *) +let service_create ?public_key ?secret_key name ?ports ?bootstrap ?network ?vcpu () = + let ports_json = match ports with Some p -> Printf.sprintf ",\"ports\":[%s]" p | None -> "" in + let bootstrap_json = match bootstrap with Some b -> Printf.sprintf ",\"bootstrap\":\"%s\"" (escape_json b) | None -> "" in + let network_json = match network with Some n -> Printf.sprintf ",\"network\":\"%s\"" n | None -> "" in + let vcpu_json = match vcpu with Some v -> Printf.sprintf ",\"vcpu\":%d" v | None -> "" in + let json = Printf.sprintf "{\"name\":\"%s\"%s%s%s%s}" (escape_json name) ports_json bootstrap_json network_json vcpu_json in + let response = api_post ?public_key ?secret_key "/services" json in + extract_json_value response "id" + +(** Destroy a service *) +let service_destroy ?public_key ?secret_key service_id = + match api_delete_with_sudo ?public_key ?secret_key (Printf.sprintf "/services/%s" service_id) with + | SudoSuccess _ -> true + | _ -> false + +(** Freeze a service *) +let service_freeze ?public_key ?secret_key service_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/services/%s/freeze" service_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Unfreeze a service *) +let service_unfreeze ?public_key ?secret_key service_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/services/%s/unfreeze" service_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Lock a service *) +let service_lock ?public_key ?secret_key service_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/services/%s/lock" service_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Unlock a service *) +let service_unlock ?public_key ?secret_key service_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/services/%s/unlock" service_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Set unfreeze-on-demand for a service *) +let service_set_unfreeze_on_demand ?public_key ?secret_key service_id enabled = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let enabled_str = if enabled then "true" else "false" in + let json = Printf.sprintf "{\"unfreeze_on_demand\":%s}" enabled_str in + let endpoint = Printf.sprintf "/services/%s" service_id in + let auth_headers = build_auth_headers pk sk "PATCH" endpoint json in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc json; + close_out oc; + let cmd = Printf.sprintf "curl -s -X PATCH %s%s -H 'Content-Type: application/json'%s -d @%s" + api_base endpoint auth_headers tmp_file in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let response = read_all "" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Redeploy a service *) +let service_redeploy ?public_key ?secret_key service_id ?bootstrap () = + let bootstrap_json = match bootstrap with Some b -> Printf.sprintf "\"bootstrap\":\"%s\"" (escape_json b) | None -> "" in + let json = "{" ^ bootstrap_json ^ "}" in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/services/%s/redeploy" service_id) json in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Get service logs *) +let service_logs ?public_key ?secret_key ?(all_logs=false) service_id = + let endpoint = if all_logs + then Printf.sprintf "/services/%s/logs?all=true" service_id + else Printf.sprintf "/services/%s/logs" service_id + in + api_get ?public_key ?secret_key endpoint + +(** Execute a command in a service *) +let service_execute ?public_key ?secret_key ?timeout_ms service_id command = + let timeout_json = match timeout_ms with Some t -> Printf.sprintf ",\"timeout_ms\":%d" t | None -> "" in + let json = Printf.sprintf "{\"command\":\"%s\"%s}" (escape_json command) timeout_json in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/services/%s/execute" service_id) json in + let stdout_val = match extract_json_value response "stdout" with Some s -> unescape_json s | None -> "" in + let stderr_val = match extract_json_value response "stderr" with Some s -> unescape_json s | None -> "" in + let exit_code = match extract_json_int response "exit_code" with Some i -> i | None -> 0 in + { success = (exit_code = 0); stdout = stdout_val; stderr = stderr_val; exit_code; job_id = None } + +(** Get service environment vault *) +let service_env_get_lib ?public_key ?secret_key service_id = + api_get ?public_key ?secret_key (Printf.sprintf "/services/%s/env" service_id) + +(** Set service environment vault *) +let service_env_set_lib ?public_key ?secret_key service_id env_content = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let endpoint = Printf.sprintf "/services/%s/env" service_id in + let auth_headers = build_auth_headers pk sk "PUT" endpoint env_content in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.txt" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc env_content; + close_out oc; + let cmd = Printf.sprintf "curl -s -o /dev/null -w '%%{http_code}' -X PUT %s%s -H 'Content-Type: text/plain'%s -d @%s" + api_base endpoint auth_headers tmp_file in + let ic = Unix.open_process_in cmd in + let status = try input_line ic with End_of_file -> "0" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + let code = int_of_string (String.trim status) in + code >= 200 && code < 300 + +(** Delete service environment vault *) +let service_env_delete_lib ?public_key ?secret_key service_id = + let response = api_delete ?public_key ?secret_key (Printf.sprintf "/services/%s/env" service_id) in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Export service environment vault *) +let service_env_export_lib ?public_key ?secret_key service_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/services/%s/env/export" service_id) "{}" in + match extract_json_value response "content" with + | Some content -> unescape_json content + | None -> "" + +(** Resize a service *) +let service_resize ?public_key ?secret_key service_id vcpu = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let json = Printf.sprintf "{\"vcpu\":%d}" vcpu in + let endpoint = Printf.sprintf "/services/%s" service_id in + let auth_headers = build_auth_headers pk sk "PATCH" endpoint json in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc json; + close_out oc; + let cmd = Printf.sprintf "curl -s -X PATCH %s%s -H 'Content-Type: application/json'%s -d @%s" + api_base endpoint auth_headers tmp_file in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let response = read_all "" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(* ============================================================================ + Library API - Snapshots (9) + ============================================================================ *) + +(** List all snapshots *) +let snapshot_list ?public_key ?secret_key () = + api_get ?public_key ?secret_key "/snapshots" + +(** Get snapshot details *) +let snapshot_get ?public_key ?secret_key snapshot_id = + api_get ?public_key ?secret_key (Printf.sprintf "/snapshots/%s" snapshot_id) + +(** Create a snapshot of a session *) +let snapshot_session ?public_key ?secret_key ?name ?(hot=false) session_id = + let name_json = match name with Some n -> Printf.sprintf "\"name\":\"%s\"," (escape_json n) | None -> "" in + let hot_json = if hot then "\"hot\":true" else "\"hot\":false" in + let json = "{" ^ name_json ^ hot_json ^ "}" in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/sessions/%s/snapshot" session_id) json in + extract_json_value response "id" + +(** Create a snapshot of a service *) +let snapshot_service ?public_key ?secret_key ?name ?(hot=false) service_id = + let name_json = match name with Some n -> Printf.sprintf "\"name\":\"%s\"," (escape_json n) | None -> "" in + let hot_json = if hot then "\"hot\":true" else "\"hot\":false" in + let json = "{" ^ name_json ^ hot_json ^ "}" in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/services/%s/snapshot" service_id) json in + extract_json_value response "id" + +(** Restore from a snapshot *) +let snapshot_restore ?public_key ?secret_key snapshot_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/snapshots/%s/restore" snapshot_id) "{}" in + extract_json_value response "id" + +(** Delete a snapshot *) +let snapshot_delete ?public_key ?secret_key snapshot_id = + match api_delete_with_sudo ?public_key ?secret_key (Printf.sprintf "/snapshots/%s" snapshot_id) with + | SudoSuccess _ -> true + | _ -> false + +(** Lock a snapshot *) +let snapshot_lock ?public_key ?secret_key snapshot_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/snapshots/%s/lock" snapshot_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Unlock a snapshot *) +let snapshot_unlock ?public_key ?secret_key snapshot_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/snapshots/%s/unlock" snapshot_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Clone a snapshot to create a new session or service *) +let snapshot_clone ?public_key ?secret_key snapshot_id ~clone_type ?name ?ports ?shell () = + let type_json = Printf.sprintf "\"type\":\"%s\"" clone_type in + let name_json = match name with Some n -> Printf.sprintf ",\"name\":\"%s\"" (escape_json n) | None -> "" in + let ports_json = match ports with Some p -> Printf.sprintf ",\"ports\":[%s]" p | None -> "" in + let shell_json = match shell with Some s -> Printf.sprintf ",\"shell\":\"%s\"" s | None -> "" in + let json = "{" ^ type_json ^ name_json ^ ports_json ^ shell_json ^ "}" in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/snapshots/%s/clone" snapshot_id) json in + extract_json_value response "id" + +(* ============================================================================ + Library API - Images (13) + ============================================================================ *) + +(** List images *) +let image_list ?public_key ?secret_key ?filter () = + let endpoint = match filter with Some f -> Printf.sprintf "/images?filter=%s" f | None -> "/images" in + api_get ?public_key ?secret_key endpoint + +(** Get image details *) +let image_get ?public_key ?secret_key image_id = + api_get ?public_key ?secret_key (Printf.sprintf "/images/%s" image_id) + +(** Publish an image *) +let image_publish ?public_key ?secret_key source_type source_id ?name ?description () = + let name_json = match name with Some n -> Printf.sprintf ",\"name\":\"%s\"" (escape_json n) | None -> "" in + let desc_json = match description with Some d -> Printf.sprintf ",\"description\":\"%s\"" (escape_json d) | None -> "" in + let json = Printf.sprintf "{\"source_type\":\"%s\",\"source_id\":\"%s\"%s%s}" source_type source_id name_json desc_json in + let response = api_post ?public_key ?secret_key "/images/publish" json in + extract_json_value response "id" + +(** Delete an image *) +let image_delete ?public_key ?secret_key image_id = + match api_delete_with_sudo ?public_key ?secret_key (Printf.sprintf "/images/%s" image_id) with + | SudoSuccess _ -> true + | _ -> false + +(** Lock an image *) +let image_lock ?public_key ?secret_key image_id = + let response = api_post ?public_key ?secret_key (Printf.sprintf "/images/%s/lock" image_id) "{}" in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Unlock an image *) +let image_unlock ?public_key ?secret_key image_id = + match api_post_with_sudo ?public_key ?secret_key (Printf.sprintf "/images/%s/unlock" image_id) "{}" with + | SudoSuccess _ -> true + | _ -> false + +(** Set image visibility *) +let image_set_visibility ?public_key ?secret_key image_id visibility = + let json = Printf.sprintf "{\"visibility\":\"%s\"}" visibility in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/images/%s/visibility" image_id) json in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Grant access to an image *) +let image_grant_access ?public_key ?secret_key image_id trusted_api_key = + let json = Printf.sprintf "{\"api_key\":\"%s\"}" trusted_api_key in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/images/%s/access/grant" image_id) json in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Revoke access to an image *) +let image_revoke_access ?public_key ?secret_key image_id trusted_api_key = + let json = Printf.sprintf "{\"api_key\":\"%s\"}" trusted_api_key in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/images/%s/access/revoke" image_id) json in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** List trusted API keys for an image *) +let image_list_trusted ?public_key ?secret_key image_id = + api_get ?public_key ?secret_key (Printf.sprintf "/images/%s/access" image_id) + +(** Transfer image ownership *) +let image_transfer ?public_key ?secret_key image_id to_api_key = + let json = Printf.sprintf "{\"to_api_key\":\"%s\"}" to_api_key in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/images/%s/transfer" image_id) json in + not (String.length response > 0 && Str.string_match (Str.regexp ".*\"error\"") response 0) + +(** Spawn a service from an image *) +let image_spawn ?public_key ?secret_key image_id ?name ?ports ?bootstrap ?network () = + let name_json = match name with Some n -> Printf.sprintf "\"name\":\"%s\"" (escape_json n) | None -> "" in + let ports_json = match ports with Some p -> Printf.sprintf "%s\"ports\":[%s]" (if name_json <> "" then "," else "") p | None -> "" in + let bootstrap_json = match bootstrap with Some b -> Printf.sprintf ",\"bootstrap\":\"%s\"" (escape_json b) | None -> "" in + let network_json = match network with Some n -> Printf.sprintf ",\"network\":\"%s\"" n | None -> "" in + let json = "{" ^ name_json ^ ports_json ^ bootstrap_json ^ network_json ^ "}" in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/images/%s/spawn" image_id) json in + extract_json_value response "id" + +(** Clone an image *) +let image_clone ?public_key ?secret_key image_id ?name ?description () = + let name_json = match name with Some n -> Printf.sprintf "\"name\":\"%s\"" (escape_json n) | None -> "" in + let desc_json = match description with Some d -> Printf.sprintf "%s\"description\":\"%s\"" (if name_json <> "" then "," else "") (escape_json d) | None -> "" in + let json = "{" ^ name_json ^ desc_json ^ "}" in + let response = api_post ?public_key ?secret_key (Printf.sprintf "/images/%s/clone" image_id) json in + extract_json_value response "id" + +(** Validate API keys *) +let validate_keys ?public_key ?secret_key () = + portal_post ?public_key ?secret_key "/keys/validate" "{}" + (* ============================================================================ CLI - Legacy curl-based functions for CLI ============================================================================ *) diff --git a/clients/ocaml/sync/tests/test_functional.ml b/clients/ocaml/sync/tests/test_functional.ml new file mode 100755 index 0000000..d65e2b0 --- /dev/null +++ b/clients/ocaml/sync/tests/test_functional.ml @@ -0,0 +1,190 @@ +#!/usr/bin/env ocaml + +(* + * Functional Tests for Un OCaml SDK + * + * Run with: ocaml test_functional.ml + * Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables + * + * These tests make real API calls to api.unsandbox.com + *) + +(* ANSI colors *) +let blue = "\x1b[34m" +let red = "\x1b[31m" +let green = "\x1b[32m" +let yellow = "\x1b[33m" +let reset = "\x1b[0m" + +(* API constants *) +let api_base = "https://api.unsandbox.com" +let portal_base = "https://unsandbox.com" + +(* HMAC-SHA256 using openssl command *) +let hmac_sha256 secret message = + let cmd = Printf.sprintf "echo -n '%s' | openssl dgst -sha256 -hmac '%s' | awk '{print $2}'" + (Str.global_replace (Str.regexp "'") "'\\''" message) + (Str.global_replace (Str.regexp "'") "'\\''" secret) in + let ic = Unix.open_process_in cmd in + let result = input_line ic in + let _ = Unix.close_process_in ic in + String.trim result + +(* Build auth headers *) +let build_auth_headers public_key secret_key method_ path body = + let timestamp = string_of_int (int_of_float (Unix.time ())) in + let message = Printf.sprintf "%s:%s:%s:%s" timestamp method_ path body in + let signature = hmac_sha256 secret_key message in + Printf.sprintf " -H 'Authorization: Bearer %s' -H 'X-Timestamp: %s' -H 'X-Signature: %s'" + public_key timestamp signature + +(* HTTP helpers *) +let curl_get public_key secret_key endpoint = + let auth_headers = build_auth_headers public_key secret_key "GET" endpoint "" in + let cmd = Printf.sprintf "curl -s %s%s%s" api_base endpoint auth_headers in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let output = read_all "" in + let _ = Unix.close_process_in ic in + output + +let curl_post public_key secret_key endpoint json = + let auth_headers = build_auth_headers public_key secret_key "POST" endpoint json in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_test_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc json; + close_out oc; + let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json'%s -d @%s" + api_base endpoint auth_headers tmp_file in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let output = read_all "" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + output + +let curl_post_portal public_key secret_key endpoint json = + let auth_headers = build_auth_headers public_key secret_key "POST" endpoint json in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_test_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc json; + close_out oc; + let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json'%s -d @%s" + portal_base endpoint auth_headers tmp_file in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let output = read_all "" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + output + +(* String contains helper *) +let contains haystack needle = + try + let _ = Str.search_forward (Str.regexp_string needle) haystack 0 in + true + with Not_found -> false + +(* Test tracking *) +let passed = ref 0 +let failed = ref 0 + +let run_test name test_fn = + Printf.printf " Running %s... " name; + flush stdout; + try + test_fn (); + Printf.printf "%sPASS%s\n" green reset; + incr passed + with e -> + Printf.printf "%sFAIL%s\n" red reset; + Printf.printf " %s\n" (Printexc.to_string e); + incr failed + +let assert_true condition message = + if not condition then failwith message + +(* ============================================================================ + Functional Tests + ============================================================================ *) + +let test_health_check () = + let cmd = Printf.sprintf "curl -s -o /dev/null -w '%%{http_code}' %s/health" api_base in + let ic = Unix.open_process_in cmd in + let status = try input_line ic with End_of_file -> "0" in + let _ = Unix.close_process_in ic in + assert_true (String.trim status = "200") "health check should return 200" + +let test_validate_keys public_key secret_key = + let response = curl_post_portal public_key secret_key "/keys/validate" "{}" in + assert_true (contains response "\"valid\"" || contains response "\"status\"") "should contain valid or status" + +let test_execute_python public_key secret_key = + let json = "{\"language\":\"python\",\"code\":\"print(6 * 7)\"}" in + let response = curl_post public_key secret_key "/execute" json in + assert_true (contains response "42") "stdout should contain 42" + +let test_execute_with_error public_key secret_key = + let json = "{\"language\":\"python\",\"code\":\"import sys; sys.exit(1)\"}" in + let response = curl_post public_key secret_key "/execute" json in + assert_true (contains response "\"exit_code\":1" || contains response "\"exit_code\": 1") "exit_code should be 1" + +let test_session_list public_key secret_key = + let response = curl_get public_key secret_key "/sessions" in + let trimmed = String.trim response in + assert_true (String.length trimmed > 0 && (trimmed.[0] = '[' || trimmed.[0] = '{')) "response should be JSON" + +let test_service_list public_key secret_key = + let response = curl_get public_key secret_key "/services" in + let trimmed = String.trim response in + assert_true (String.length trimmed > 0 && (trimmed.[0] = '[' || trimmed.[0] = '{')) "response should be JSON" + +let test_snapshot_list public_key secret_key = + let response = curl_get public_key secret_key "/snapshots" in + let trimmed = String.trim response in + assert_true (String.length trimmed > 0 && (trimmed.[0] = '[' || trimmed.[0] = '{')) "response should be JSON" + +let test_image_list public_key secret_key = + let response = curl_get public_key secret_key "/images" in + let trimmed = String.trim response in + assert_true (String.length trimmed > 0 && (trimmed.[0] = '[' || trimmed.[0] = '{')) "response should be JSON" + +let () = + Random.self_init (); + Printf.printf "\n%s=== Un OCaml SDK Functional Tests ===%s\n\n" blue reset; + + (* Check for credentials *) + let public_key = try Some (Sys.getenv "UNSANDBOX_PUBLIC_KEY") with Not_found -> None in + let secret_key = try Some (Sys.getenv "UNSANDBOX_SECRET_KEY") with Not_found -> None in + + match (public_key, secret_key) with + | (Some pk, Some sk) -> + run_test "health_check" test_health_check; + run_test "validate_keys" (fun () -> test_validate_keys pk sk); + run_test "execute_python" (fun () -> test_execute_python pk sk); + run_test "execute_with_error" (fun () -> test_execute_with_error pk sk); + run_test "session_list" (fun () -> test_session_list pk sk); + run_test "service_list" (fun () -> test_service_list pk sk); + run_test "snapshot_list" (fun () -> test_snapshot_list pk sk); + run_test "image_list" (fun () -> test_image_list pk sk); + + let total = !passed + !failed in + Printf.printf "\n%sResults: %d/%d passed%s\n" blue !passed total reset; + + if !failed > 0 then begin + Printf.printf "%s%d test(s) failed%s\n" red !failed reset; + exit 1 + end else + Printf.printf "%sAll functional tests passed!%s\n" green reset + + | _ -> + Printf.printf "%sSKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set%s\n" yellow reset diff --git a/clients/ocaml/sync/tests/test_library.ml b/clients/ocaml/sync/tests/test_library.ml new file mode 100755 index 0000000..066fc62 --- /dev/null +++ b/clients/ocaml/sync/tests/test_library.ml @@ -0,0 +1,160 @@ +#!/usr/bin/env ocaml + +(* + * Unit Tests for Un OCaml SDK Library Functions + * + * Run with: ocaml test_library.ml + * No credentials required - tests pure library functions only. + *) + +(* ANSI colors *) +let blue = "\x1b[34m" +let red = "\x1b[31m" +let green = "\x1b[32m" +let yellow = "\x1b[33m" +let reset = "\x1b[0m" + +(* Inline implementation for testing (matches un.ml) *) +let sdk_version = "4.2.0" + +let ext_to_lang ext = + match ext with + | ".hs" -> Some "haskell" | ".ml" -> Some "ocaml" | ".clj" -> Some "clojure" + | ".scm" -> Some "scheme" | ".lisp" -> Some "commonlisp" | ".erl" -> Some "erlang" + | ".ex" -> Some "elixir" | ".exs" -> Some "elixir" | ".py" -> Some "python" + | ".js" -> Some "javascript" | ".ts" -> Some "typescript" | ".rb" -> Some "ruby" + | ".go" -> Some "go" | ".rs" -> Some "rust" | ".c" -> Some "c" + | ".cpp" -> Some "cpp" | ".cc" -> Some "cpp" | ".cxx" -> Some "cpp" + | ".java" -> Some "java" | ".kt" -> Some "kotlin" | ".cs" -> Some "csharp" + | ".fs" -> Some "fsharp" | ".jl" -> Some "julia" | ".r" -> Some "r" + | ".cr" -> Some "crystal" | ".d" -> Some "d" | ".nim" -> Some "nim" + | ".zig" -> Some "zig" | ".v" -> Some "v" | ".dart" -> Some "dart" + | ".groovy" -> Some "groovy" | ".scala" -> Some "scala" + | ".sh" -> Some "bash" | ".pl" -> Some "perl" | ".lua" -> Some "lua" + | ".php" -> Some "php" + | _ -> None + +let get_extension filename = + try + let dot_pos = String.rindex filename '.' in + String.sub filename dot_pos (String.length filename - dot_pos) + with Not_found -> "" + +let detect_language filename = + let ext = get_extension filename in + ext_to_lang ext + +(* HMAC-SHA256 using openssl command *) +let hmac_sha256 secret message = + let cmd = Printf.sprintf "echo -n '%s' | openssl dgst -sha256 -hmac '%s' | awk '{print $2}'" + (Str.global_replace (Str.regexp "'") "'\\''" message) + (Str.global_replace (Str.regexp "'") "'\\''" secret) in + let ic = Unix.open_process_in cmd in + let result = input_line ic in + let _ = Unix.close_process_in ic in + String.trim result + +let hmac_sign secret message = hmac_sha256 secret message + +(* Test tracking *) +let passed = ref 0 +let failed = ref 0 + +let run_test name test_fn = + try + test_fn (); + Printf.printf "%sPASS%s: %s\n" green reset name; + incr passed + with e -> + Printf.printf "%sFAIL%s: %s - %s\n" red reset name (Printexc.to_string e); + incr failed + +let assert_true condition message = + if not condition then failwith message + +let assert_equal a b message = + if a <> b then failwith (message ^ " (got: " ^ a ^ ")") + +let assert_some opt message = + match opt with + | Some _ -> () + | None -> failwith message + +let assert_none opt message = + match opt with + | None -> () + | Some _ -> failwith message + +(* ============================================================================ + Unit Tests + ============================================================================ *) + +let test_version () = + let version = sdk_version in + assert_true (String.length version > 0) "version should be non-empty"; + (* Check semver format X.Y.Z *) + let parts = String.split_on_char '.' version in + assert_true (List.length parts = 3) "version should be semver format" + +let test_detect_language () = + (* Test common extensions *) + assert_equal (match detect_language "script.py" with Some s -> s | None -> "") "python" "python"; + assert_equal (match detect_language "app.js" with Some s -> s | None -> "") "javascript" "javascript"; + assert_equal (match detect_language "main.go" with Some s -> s | None -> "") "go" "go"; + assert_equal (match detect_language "main.rs" with Some s -> s | None -> "") "rust" "rust"; + assert_equal (match detect_language "main.c" with Some s -> s | None -> "") "c" "c"; + assert_equal (match detect_language "main.cpp" with Some s -> s | None -> "") "cpp" "cpp"; + assert_equal (match detect_language "Main.java" with Some s -> s | None -> "") "java" "java"; + assert_equal (match detect_language "script.rb" with Some s -> s | None -> "") "ruby" "ruby"; + assert_equal (match detect_language "script.sh" with Some s -> s | None -> "") "bash" "bash"; + assert_equal (match detect_language "script.lua" with Some s -> s | None -> "") "lua" "lua"; + assert_equal (match detect_language "script.pl" with Some s -> s | None -> "") "perl" "perl"; + assert_equal (match detect_language "index.php" with Some s -> s | None -> "") "php" "php"; + assert_equal (match detect_language "main.hs" with Some s -> s | None -> "") "haskell" "haskell"; + assert_equal (match detect_language "main.ml" with Some s -> s | None -> "") "ocaml" "ocaml"; + assert_equal (match detect_language "main.ex" with Some s -> s | None -> "") "elixir" "elixir"; + assert_equal (match detect_language "main.erl" with Some s -> s | None -> "") "erlang" "erlang"; + + (* Test with paths *) + assert_equal (match detect_language "/path/to/script.py" with Some s -> s | None -> "") "python" "path/python"; + + (* Test unknown extensions *) + assert_none (detect_language "Makefile") "Makefile should be None"; + assert_none (detect_language "README") "README should be None"; + assert_none (detect_language "script.unknown") "unknown should be None" + +let test_hmac_sign () = + let signature = hmac_sign "my_secret" "test message" in + assert_true (String.length signature = 64) "signature should be 64 hex characters"; + (* Should be lowercase hex *) + let is_hex_char c = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') in + let all_hex = String.to_seq signature |> Seq.for_all is_hex_char in + assert_true all_hex "signature should be lowercase hex" + +let test_hmac_sign_deterministic () = + let sig1 = hmac_sign "test_secret" "same message" in + let sig2 = hmac_sign "test_secret" "same message" in + assert_equal sig1 sig2 "same inputs should produce same signature" + +let test_hmac_sign_different_secrets () = + let sig1 = hmac_sign "secret1" "test message" in + let sig2 = hmac_sign "secret2" "test message" in + assert_true (sig1 <> sig2) "different secrets should produce different signatures" + +let () = + Printf.printf "\n%s=== Un OCaml SDK Library Tests ===%s\n\n" blue reset; + + run_test "version" test_version; + run_test "detect_language" test_detect_language; + run_test "hmac_sign" test_hmac_sign; + run_test "hmac_sign_deterministic" test_hmac_sign_deterministic; + run_test "hmac_sign_different_secrets" test_hmac_sign_different_secrets; + + let total = !passed + !failed in + Printf.printf "\n%sResults: %d/%d passed%s\n" blue !passed total reset; + + if !failed > 0 then begin + Printf.printf "%s%d test(s) failed%s\n" red !failed reset; + exit 1 + end else + Printf.printf "%sAll tests passed!%s\n" green reset diff --git a/clients/perl/sync/src/un.pl b/clients/perl/sync/src/un.pl index 57ae458..bac75b3 100644 --- a/clients/perl/sync/src/un.pl +++ b/clients/perl/sync/src/un.pl @@ -1,66 +1,114 @@ #!/usr/bin/env perl # PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # -# This is free public domain software for the public good of a permacomputer hosted -# at permacomputer.com - an always-on computer by the people, for the people. One -# which is durable, easy to repair, and distributed like tap water for machine -# learning intelligence. +# unsandbox.com Perl SDK (Synchronous) +# Full API with execution, sessions, services, snapshots, and images. # -# The permacomputer is community-owned infrastructure optimized around four values: +# Library Usage: +# use Un; +# my $result = Un::execute("python", "print(42)"); +# print $result->{stdout}; # -# TRUTH - First principles, math & science, open source code freely distributed -# FREEDOM - Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY - Minimal waste, self-renewing systems with diverse thriving connections -# LOVE - Be yourself without hurting others, cooperation through natural law +# CLI Usage: +# perl un.pl script.py +# perl un.pl -s python 'print(42)' +# perl un.pl session --list +# perl un.pl service --list +# perl un.pl snapshot --list +# perl un.pl image --list # -# This software contributes to that vision by enabling code execution across 42+ -# programming languages through a unified interface, accessible to all. Code is -# seeds to sprout on any abandoned technology. -# -# Learn more: https://www.permacomputer.com -# -# Anyone is free to copy, modify, publish, use, compile, sell, or distribute this -# software, either in source code form or as a compiled binary, for any purpose, -# commercial or non-commercial, and by any means. -# -# NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. -# -# That said, our permacomputer's digital membrane stratum continuously runs unit, -# integration, and functional tests on all of it's own software - with our -# permacomputer monitoring itself, repairing itself, with minimal human in the -# loop guidance. Our agents do their best. +# Authentication Priority (4-tier): +# 1. Function arguments (public_key, secret_key) +# 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) +# 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) +# 4. Local directory (./accounts.csv, line 0 by default) # # Copyright 2025 TimeHexOn & foxhop & russell@unturf -# https://www.timehexon.com -# https://www.foxhop.net -# https://www.unturf.com/software -# -# unsandbox SDK for Perl - Execute code in secure sandboxes -# https://unsandbox.com | https://api.unsandbox.com/openapi use strict; use warnings; -use JSON; +use JSON::PP; use LWP::UserAgent; use HTTP::Request; -use Digest::HMAC_SHA256 qw(hmac_sha256_hex); -use File::HomeDir; +use Digest::SHA qw(hmac_sha256_hex); +use MIME::Base64; +use File::Basename; +use File::Path qw(make_path); use Time::HiRes qw(time sleep); +package Un; + our $VERSION = "4.2.50"; our $API_BASE = 'https://api.unsandbox.com'; +our $PORTAL_BASE = 'https://unsandbox.com'; + +# Thread-local error storage +our $LAST_ERROR = ""; + +# Colors +my $BLUE = "\033[34m"; +my $RED = "\033[31m"; +my $GREEN = "\033[32m"; +my $YELLOW = "\033[33m"; +my $RESET = "\033[0m"; + +# Extension to language mapping +my %EXT_MAP = ( + '.py' => 'python', '.js' => 'javascript', '.ts' => 'typescript', + '.rb' => 'ruby', '.php' => 'php', '.pl' => 'perl', '.lua' => 'lua', + '.sh' => 'bash', '.go' => 'go', '.rs' => 'rust', '.c' => 'c', + '.cpp' => 'cpp', '.cc' => 'cpp', '.cxx' => 'cpp', + '.java' => 'java', '.kt' => 'kotlin', '.cs' => 'csharp', '.fs' => 'fsharp', + '.hs' => 'haskell', '.ml' => 'ocaml', '.clj' => 'clojure', '.scm' => 'scheme', + '.lisp' => 'commonlisp', '.erl' => 'erlang', '.ex' => 'elixir', '.exs' => 'elixir', + '.jl' => 'julia', '.r' => 'r', '.R' => 'r', '.cr' => 'crystal', + '.d' => 'd', '.nim' => 'nim', '.zig' => 'zig', '.v' => 'v', + '.dart' => 'dart', '.groovy' => 'groovy', '.scala' => 'scala', + '.f90' => 'fortran', '.f95' => 'fortran', '.cob' => 'cobol', + '.pro' => 'prolog', '.forth' => 'forth', '.4th' => 'forth', + '.tcl' => 'tcl', '.raku' => 'raku', '.m' => 'objc' +); + +# ============================================================================ +# Utility Functions +# ============================================================================ + +sub version { return $VERSION; } + +sub last_error { return $LAST_ERROR; } + +sub set_error { + my ($msg) = @_; + $LAST_ERROR = $msg; +} + +sub detect_language { + my ($filename) = @_; + return undef unless $filename; + my ($name, $dir, $ext) = fileparse($filename, qr/\.[^.]*/); + return $EXT_MAP{lc($ext)}; +} + +sub hmac_sign { + my ($secret, $message) = @_; + return undef unless defined $secret && defined $message; + return hmac_sha256_hex($message, $secret); +} + +# ============================================================================ +# Credential Management +# ============================================================================ -# Credential system sub load_accounts_csv { my ($path) = @_; - $path ||= File::HomeDir->my_home . "/.unsandbox/accounts.csv"; + $path ||= "$ENV{HOME}/.unsandbox/accounts.csv"; return [] unless -e $path; my @accounts; open my $fh, '<', $path or return []; while (my $line = <$fh>) { chomp $line; - next if !$line; + next if !$line || $line =~ /^\s*#/; my ($pk, $sk) = split /,/, $line, 2; push @accounts, [$pk, $sk] if $pk && $sk; } @@ -79,6 +127,11 @@ sub get_credentials { return ($ENV{UNSANDBOX_PUBLIC_KEY}, $ENV{UNSANDBOX_SECRET_KEY}); } + # Legacy fallback + if ($ENV{UNSANDBOX_API_KEY}) { + return ($ENV{UNSANDBOX_API_KEY}, ''); + } + # Tier 3: Home directory my $home_accounts = load_accounts_csv(); return @{$home_accounts->[0]} if @$home_accounts; @@ -87,70 +140,129 @@ sub get_credentials { my $local_accounts = load_accounts_csv("./accounts.csv"); return @{$local_accounts->[0]} if @$local_accounts; - die "No credentials found\n"; + set_error("No credentials found"); + return (undef, undef); } -# HMAC signature -sub sign_request { - my ($secret, $timestamp, $method, $endpoint, $body) = @_; - my $message = "$timestamp:$method:$endpoint:$body"; - return hmac_sha256_hex($message, $secret); -} +# ============================================================================ +# API Communication +# ============================================================================ -# API communication sub api_request { my ($method, $endpoint, $body, %opts) = @_; + my $extra_headers = delete $opts{extra_headers} || {}; + my $content_type = delete $opts{content_type} || 'application/json'; my ($pk, $sk) = get_credentials(%opts); - my $timestamp = int(time); - my $body_str = $body ? JSON::to_json($body) : '{}'; - my $signature = sign_request($sk, $timestamp, $method, $endpoint, $body_str); + unless ($pk) { + set_error("No credentials available"); + return undef; + } - my $ua = LWP::UserAgent->new; + my $timestamp = int(time); + my $body_str = ''; + if ($body) { + $body_str = ref($body) ? encode_json($body) : $body; + } + + my $ua = LWP::UserAgent->new(timeout => 300); my $url = "$API_BASE$endpoint"; my $req = HTTP::Request->new($method, $url); $req->header('Authorization' => "Bearer $pk"); - $req->header('X-Timestamp' => $timestamp); - $req->header('X-Signature' => $signature); - $req->header('Content-Type' => 'application/json'); - $req->content($body_str) if $body; + $req->header('Content-Type' => $content_type); - my $res = $ua->request($req); - die "API error (" . $res->code . ")\n" unless $res->is_success; - - return JSON::from_json($res->content); -} - -# Languages with cache -sub languages { - my (%opts) = @_; - my $cache_ttl = $opts{cache_ttl} || 3600; - my $cache_path = File::HomeDir->my_home . "/.unsandbox/languages.json"; - - if (-e $cache_path) { - my $age = time - (stat $cache_path)[9]; - if ($age < $cache_ttl) { - open my $fh, '<', $cache_path; - my $content = do { local $/; <$fh> }; - close $fh; - return JSON::from_json($content); - } + # HMAC signature + if ($sk) { + my $sig_input = "$timestamp:$method:$endpoint:$body_str"; + my $signature = hmac_sign($sk, $sig_input); + $req->header('X-Timestamp' => $timestamp); + $req->header('X-Signature' => $signature); } - my $result = api_request('GET', '/languages', undef, %opts); - my $langs = $result->{languages} || []; + # Add extra headers + for my $key (keys %$extra_headers) { + $req->header($key => $extra_headers->{$key}); + } - my $cache_dir = File::HomeDir->my_home . "/.unsandbox"; - mkdir $cache_dir unless -d $cache_dir; - open my $fh, '>', $cache_path; - print $fh JSON::to_json($langs); - close $fh; + $req->content($body_str) if $body_str; - return $langs; + my $res = $ua->request($req); + + unless ($res->is_success) { + set_error("API error (" . $res->code . "): " . $res->content); + return undef; + } + + return decode_json($res->content) if $res->content; + return { success => 1 }; } -# Execution functions +sub api_request_with_sudo { + my ($method, $endpoint, $body, %opts) = @_; + my ($pk, $sk) = get_credentials(%opts); + + my $timestamp = int(time); + my $body_str = ''; + if ($body) { + $body_str = ref($body) ? encode_json($body) : $body; + } + + my $ua = LWP::UserAgent->new(timeout => 300); + my $url = "$API_BASE$endpoint"; + my $req = HTTP::Request->new($method, $url); + + $req->header('Authorization' => "Bearer $pk"); + $req->header('Content-Type' => 'application/json'); + + if ($sk) { + my $sig_input = "$timestamp:$method:$endpoint:$body_str"; + my $signature = hmac_sign($sk, $sig_input); + $req->header('X-Timestamp' => $timestamp); + $req->header('X-Signature' => $signature); + } + + $req->content($body_str) if $body_str; + + my $res = $ua->request($req); + + # Handle 428 - Sudo OTP required + if ($res->code == 428) { + my $response_data = eval { decode_json($res->content) } || {}; + my $challenge_id = $response_data->{challenge_id} || ''; + + print STDERR "${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}\n"; + print STDERR "Enter OTP: "; + + my $otp = ; + return undef unless defined $otp; + chomp $otp; + $otp =~ s/\r//g; + + if ($otp eq '') { + set_error("Operation cancelled"); + return undef; + } + + my $extra = { 'X-Sudo-OTP' => $otp }; + $extra->{'X-Sudo-Challenge'} = $challenge_id if $challenge_id; + + return api_request($method, $endpoint, $body, %opts, extra_headers => $extra); + } + + unless ($res->is_success) { + set_error("API error (" . $res->code . "): " . $res->content); + return undef; + } + + return decode_json($res->content) if $res->content; + return { success => 1 }; +} + +# ============================================================================ +# Execution Functions (8) +# ============================================================================ + sub execute { my ($language, $code, %opts) = @_; my $body = { @@ -159,6 +271,9 @@ sub execute { network_mode => $opts{network_mode} || 'zerotrust', ttl => $opts{ttl} || 60 }; + $body->{env} = $opts{env} if $opts{env}; + $body->{input_files} = $opts{input_files} if $opts{input_files}; + $body->{return_artifacts} = JSON::PP::true if $opts{return_artifacts}; return api_request('POST', '/execute', $body, %opts); } @@ -173,34 +288,30 @@ sub execute_async { return api_request('POST', '/execute/async', $body, %opts); } -sub run { - my ($file, %opts) = @_; - open my $fh, '<', $file or die "Can't read $file\n"; - my $code = do { local $/; <$fh> }; - close $fh; - return execute(detect_language($file), $code, %opts); -} - -# Job management -sub get_job { - my ($job_id, %opts) = @_; - return api_request('GET', "/jobs/$job_id", undef, %opts); -} - sub wait_job { my ($job_id, %opts) = @_; + my $timeout = $opts{timeout} || 120; my @delays = (300, 450, 700, 900, 650, 1600, 2000); for my $i (0..119) { my $job = get_job($job_id, %opts); - return $job if $job->{status} eq 'completed'; - die "Job failed\n" if $job->{status} eq 'failed'; + return $job if $job && $job->{status} eq 'completed'; + if ($job && $job->{status} eq 'failed') { + set_error("Job failed: " . ($job->{error} || 'unknown')); + return undef; + } - my $delay = $delays[$i] || 2000; + my $delay = $delays[$i % 7] || 2000; sleep($delay / 1000); } - die "Max polls exceeded\n"; + set_error("Max polls exceeded"); + return undef; +} + +sub get_job { + my ($job_id, %opts) = @_; + return api_request('GET', "/jobs/$job_id", undef, %opts); } sub cancel_job { @@ -208,369 +319,443 @@ sub cancel_job { return api_request('DELETE', "/jobs/$job_id", undef, %opts); } -# Utilities -my %ext_map = ( - py => 'python', rb => 'ruby', js => 'javascript', pl => 'perl', - php => 'php', lua => 'lua', sh => 'bash', go => 'go' -); - -sub detect_language { - my ($filename) = @_; - my ($ext) = $filename =~ /\.([^.]+)$/; - return $ext_map{$ext} || die "Unknown file type\n"; +sub list_jobs { + my (%opts) = @_; + return api_request('GET', '/jobs', undef, %opts); } -# CLI -sub cli_main { - my @args = @ARGV; - die "Usage: perl un.pl \n" unless @args; +sub get_languages { + my (%opts) = @_; + my $cache_ttl = $opts{cache_ttl} || 3600; + my $cache_path = "$ENV{HOME}/.unsandbox/languages.json"; - my $result = run($args[0]); - print $result->{stdout} if $result->{stdout}; - print STDERR $result->{stderr} if $result->{stderr}; - exit($result->{exit_code} || 0); -} - -#!/usr/bin/env perl -# un.pl - Unsandbox CLI Client (Perl Implementation) -# -# Full-featured CLI matching un.c capabilities: -# - Execute code with env vars, input files, artifacts -# - Interactive sessions with shell/REPL support -# - Persistent services with domains and ports -# -# Usage: -# un.pl [options] -# un.pl session [options] -# un.pl service [options] -# -# Requires: UNSANDBOX_API_KEY environment variable - -use strict; -use warnings; -use File::Basename; -use JSON::PP; -use LWP::UserAgent; -use HTTP::Request; -use MIME::Base64; -use File::Path qw(make_path); -use Digest::SHA qw(hmac_sha256_hex); - -my $API_BASE = 'https://api.unsandbox.com'; -my $PORTAL_BASE = 'https://unsandbox.com'; -my $BLUE = "\033[34m"; -my $RED = "\033[31m"; -my $GREEN = "\033[32m"; -my $YELLOW = "\033[33m"; -my $RESET = "\033[0m"; - -my %EXT_MAP = ( - '.py' => 'python', '.js' => 'javascript', '.ts' => 'typescript', - '.rb' => 'ruby', '.php' => 'php', '.pl' => 'perl', '.lua' => 'lua', - '.sh' => 'bash', '.go' => 'go', '.rs' => 'rust', '.c' => 'c', - '.cpp' => 'cpp', '.cc' => 'cpp', '.cxx' => 'cpp', - '.java' => 'java', '.kt' => 'kotlin', '.cs' => 'csharp', '.fs' => 'fsharp', - '.hs' => 'haskell', '.ml' => 'ocaml', '.clj' => 'clojure', '.scm' => 'scheme', - '.lisp' => 'commonlisp', '.erl' => 'erlang', '.ex' => 'elixir', '.exs' => 'elixir', - '.jl' => 'julia', '.r' => 'r', '.R' => 'r', '.cr' => 'crystal', - '.d' => 'd', '.nim' => 'nim', '.zig' => 'zig', '.v' => 'v', - '.dart' => 'dart', '.groovy' => 'groovy', '.scala' => 'scala', - '.f90' => 'fortran', '.f95' => 'fortran', '.cob' => 'cobol', - '.pro' => 'prolog', '.forth' => 'forth', '.4th' => 'forth', - '.tcl' => 'tcl', '.raku' => 'raku', '.m' => 'objc' -); - -sub get_api_key { - my ($args_key) = @_; - my $public_key = $ENV{'UNSANDBOX_PUBLIC_KEY'} || ''; - my $secret_key = $ENV{'UNSANDBOX_SECRET_KEY'} || ''; - - # Fallback to old UNSANDBOX_API_KEY for backwards compat - if (!$public_key && $ENV{'UNSANDBOX_API_KEY'}) { - $public_key = $ENV{'UNSANDBOX_API_KEY'}; - $secret_key = ''; - } - - unless ($public_key) { - print STDERR "${RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${RESET}\n"; - exit 1; - } - return ($public_key, $secret_key); -} - -sub detect_language { - my ($filename) = @_; - my ($name, $dir, $ext) = fileparse($filename, qr/\.[^.]*/); - my $lang = $EXT_MAP{lc($ext)}; - unless ($lang) { - if (open my $fh, '<', $filename) { - my $first_line = <$fh>; + if (-e $cache_path) { + my $age = time - (stat $cache_path)[9]; + if ($age < $cache_ttl) { + open my $fh, '<', $cache_path; + my $content = do { local $/; <$fh> }; close $fh; - if ($first_line && $first_line =~ /^#!/) { - return 'python' if $first_line =~ /python/; - return 'javascript' if $first_line =~ /node/; - return 'ruby' if $first_line =~ /ruby/; - return 'perl' if $first_line =~ /perl/; - return 'bash' if $first_line =~ /bash|\/sh/; - return 'lua' if $first_line =~ /lua/; - return 'php' if $first_line =~ /php/; - } + return decode_json($content); } - print STDERR "${RED}Error: Cannot detect language for $filename${RESET}\n"; - exit 1; } - return $lang; + + my $result = api_request('GET', '/languages', undef, %opts); + my $langs = $result->{languages} || []; + + my $cache_dir = "$ENV{HOME}/.unsandbox"; + mkdir $cache_dir unless -d $cache_dir; + if (open my $fh, '>', $cache_path) { + print $fh encode_json($langs); + close $fh; + } + + return $langs; } -sub api_request { - my ($endpoint, $method, $data, $public_key, $secret_key, $extra_headers) = @_; - $method //= 'GET'; - $extra_headers //= {}; +# ============================================================================ +# Session Functions (9) +# ============================================================================ - my $url = "$API_BASE$endpoint"; - my $ua = LWP::UserAgent->new(timeout => 300); - my $request = HTTP::Request->new($method => $url); - $request->header('Authorization' => "Bearer $public_key"); - $request->header('Content-Type' => 'application/json'); - - my $body = ''; - if ($data) { - $body = encode_json($data); - $request->content($body); - } - - # Add HMAC signature if secret_key is present - if ($secret_key) { - my $timestamp = time(); - my $sig_input = "${timestamp}:${method}:${endpoint}:${body}"; - my $signature = hmac_sha256_hex($sig_input, $secret_key); - $request->header('X-Timestamp' => $timestamp); - $request->header('X-Signature' => $signature); - } - - # Add extra headers (for sudo OTP) - for my $key (keys %$extra_headers) { - $request->header($key => $extra_headers->{$key}); - } - - my $response = $ua->request($request); - - unless ($response->is_success) { - if ($response->code == 401 && $response->content =~ /timestamp/i) { - print STDERR "${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}\n"; - print STDERR "${YELLOW}Your computer's clock may have drifted.${RESET}\n"; - print STDERR "${YELLOW}Check your system time and sync with NTP if needed:${RESET}\n"; - print STDERR " Linux: sudo ntpdate -s time.nist.gov\n"; - print STDERR " macOS: sudo sntp -sS time.apple.com\n"; - print STDERR " Windows: w32tm /resync\n"; - } else { - print STDERR "${RED}Error: HTTP ", $response->code, " - ", $response->content, "${RESET}\n"; - } - exit 1; - } - - return decode_json($response->content); +sub session_list { + my (%opts) = @_; + return api_request('GET', '/sessions', undef, %opts); } -# Handle 428 Sudo OTP challenge - prompt user for OTP and retry -sub handle_sudo_challenge { - my ($response_content, $endpoint, $method, $data, $public_key, $secret_key) = @_; +sub session_get { + my ($session_id, %opts) = @_; + return api_request('GET', "/sessions/$session_id", undef, %opts); +} - # Extract challenge_id from response - my $response_data = eval { decode_json($response_content) } || {}; - my $challenge_id = $response_data->{challenge_id} || ''; - - print STDERR "${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}\n"; - print STDERR "Enter OTP: "; - - my $otp = ; - unless (defined $otp) { - print STDERR "${RED}Error: Failed to read OTP${RESET}\n"; - return 0; - } - chomp $otp; - $otp =~ s/\r//g; - - if ($otp eq '') { - print STDERR "${RED}Error: Operation cancelled${RESET}\n"; - return 0; - } - - # Retry with sudo headers - my $extra_headers = { 'X-Sudo-OTP' => $otp }; - $extra_headers->{'X-Sudo-Challenge'} = $challenge_id if $challenge_id; - - eval { - api_request($endpoint, $method, $data, $public_key, $secret_key, $extra_headers); +sub session_create { + my (%opts) = @_; + my $body = { + shell => $opts{shell} || 'bash', }; - if ($@) { - return 0; - } - - print "${GREEN}Operation completed successfully${RESET}\n"; - return 1; + $body->{network} = $opts{network} if $opts{network}; + $body->{vcpu} = $opts{vcpu} if $opts{vcpu}; + $body->{input_files} = $opts{input_files} if $opts{input_files}; + $body->{persistence} = $opts{persistence} if $opts{persistence}; + return api_request('POST', '/sessions', $body, %opts); } -# API request that handles 428 sudo challenges for destructive operations -sub api_request_with_sudo { - my ($endpoint, $method, $data, $public_key, $secret_key) = @_; - $method //= 'GET'; - - my $url = "$API_BASE$endpoint"; - my $ua = LWP::UserAgent->new(timeout => 300); - my $request = HTTP::Request->new($method => $url); - $request->header('Authorization' => "Bearer $public_key"); - $request->header('Content-Type' => 'application/json'); - - my $body = ''; - if ($data) { - $body = encode_json($data); - $request->content($body); - } - - # Add HMAC signature if secret_key is present - if ($secret_key) { - my $timestamp = time(); - my $sig_input = "${timestamp}:${method}:${endpoint}:${body}"; - my $signature = hmac_sha256_hex($sig_input, $secret_key); - $request->header('X-Timestamp' => $timestamp); - $request->header('X-Signature' => $signature); - } - - my $response = $ua->request($request); - - # Handle 428 Precondition Required (sudo OTP needed) - if ($response->code == 428) { - return handle_sudo_challenge($response->content, $endpoint, $method, $data, $public_key, $secret_key); - } - - unless ($response->is_success) { - print STDERR "${RED}Error: HTTP ", $response->code, " - ", $response->content, "${RESET}\n"; - exit 1; - } - - return decode_json($response->content); +sub session_destroy { + my ($session_id, %opts) = @_; + return api_request('DELETE', "/sessions/$session_id", undef, %opts); } -sub api_request_text { - my ($endpoint, $method, $body, $public_key, $secret_key) = @_; +sub session_freeze { + my ($session_id, %opts) = @_; + return api_request('POST', "/sessions/$session_id/freeze", {}, %opts); +} - my $url = "$API_BASE$endpoint"; - my $ua = LWP::UserAgent->new(timeout => 300); - my $request = HTTP::Request->new($method => $url); - $request->header('Authorization' => "Bearer $public_key"); - $request->header('Content-Type' => 'text/plain'); - $request->content($body); +sub session_unfreeze { + my ($session_id, %opts) = @_; + return api_request('POST', "/sessions/$session_id/unfreeze", {}, %opts); +} - # Add HMAC signature if secret_key is present - if ($secret_key) { - my $timestamp = time(); - my $sig_input = "${timestamp}:${method}:${endpoint}:${body}"; - my $signature = hmac_sha256_hex($sig_input, $secret_key); - $request->header('X-Timestamp' => $timestamp); - $request->header('X-Signature' => $signature); - } +sub session_boost { + my ($session_id, $vcpu, %opts) = @_; + return api_request('POST', "/sessions/$session_id/boost", { vcpu => $vcpu }, %opts); +} - my $response = $ua->request($request); +sub session_unboost { + my ($session_id, %opts) = @_; + return api_request('POST', "/sessions/$session_id/unboost", {}, %opts); +} - unless ($response->is_success) { - return { error => "HTTP " . $response->code . " - " . $response->content }; - } - - return decode_json($response->content); +sub session_execute { + my ($session_id, $command, %opts) = @_; + return api_request('POST', "/sessions/$session_id/execute", { command => $command }, %opts); } # ============================================================================ -# Environment Secrets Vault Functions +# Service Functions (17) # ============================================================================ -my $MAX_ENV_CONTENT_SIZE = 64 * 1024; # 64KB max +sub service_list { + my (%opts) = @_; + return api_request('GET', '/services', undef, %opts); +} -sub service_env_status { - my ($service_id, $public_key, $secret_key) = @_; - my $result = api_request("/services/$service_id/env", 'GET', undef, $public_key, $secret_key); - my $has_vault = $result->{has_vault}; +sub service_get { + my ($service_id, %opts) = @_; + return api_request('GET', "/services/$service_id", undef, %opts); +} - if (!$has_vault) { - print "Vault exists: no\n"; - print "Variable count: 0\n"; - } else { - print "Vault exists: yes\n"; - print "Variable count: ", ($result->{count} // 0), "\n"; - if ($result->{updated_at}) { - my @t = localtime($result->{updated_at}); - printf "Last updated: %04d-%02d-%02d %02d:%02d:%02d\n", - $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]; - } - } +sub service_create { + my (%opts) = @_; + my $body = { name => $opts{name} }; + $body->{ports} = $opts{ports} if $opts{ports}; + $body->{domains} = $opts{domains} if $opts{domains}; + $body->{bootstrap} = $opts{bootstrap} if $opts{bootstrap}; + $body->{bootstrap_content} = $opts{bootstrap_content} if $opts{bootstrap_content}; + $body->{network} = $opts{network} if $opts{network}; + $body->{vcpu} = $opts{vcpu} if $opts{vcpu}; + $body->{service_type} = $opts{service_type} if $opts{service_type}; + $body->{input_files} = $opts{input_files} if $opts{input_files}; + $body->{unfreeze_on_demand} = JSON::PP::true if $opts{unfreeze_on_demand}; + return api_request('POST', '/services', $body, %opts); +} + +sub service_destroy { + my ($service_id, %opts) = @_; + return api_request_with_sudo('DELETE', "/services/$service_id", undef, %opts); +} + +sub service_freeze { + my ($service_id, %opts) = @_; + return api_request('POST', "/services/$service_id/freeze", {}, %opts); +} + +sub service_unfreeze { + my ($service_id, %opts) = @_; + return api_request('POST', "/services/$service_id/unfreeze", {}, %opts); +} + +sub service_lock { + my ($service_id, %opts) = @_; + return api_request('POST', "/services/$service_id/lock", {}, %opts); +} + +sub service_unlock { + my ($service_id, %opts) = @_; + return api_request_with_sudo('POST', "/services/$service_id/unlock", {}, %opts); +} + +sub service_set_unfreeze_on_demand { + my ($service_id, $enabled, %opts) = @_; + my $body = { unfreeze_on_demand => ($enabled ? JSON::PP::true : JSON::PP::false) }; + return api_request('PATCH', "/services/$service_id", $body, %opts); +} + +sub service_redeploy { + my ($service_id, %opts) = @_; + my $body = {}; + $body->{bootstrap} = $opts{bootstrap} if $opts{bootstrap}; + return api_request('POST', "/services/$service_id/redeploy", $body, %opts); +} + +sub service_logs { + my ($service_id, %opts) = @_; + my $endpoint = "/services/$service_id/logs"; + $endpoint .= "?lines=$opts{lines}" if $opts{lines}; + return api_request('GET', $endpoint, undef, %opts); +} + +sub service_execute { + my ($service_id, $command, %opts) = @_; + my $body = { command => $command }; + $body->{timeout} = $opts{timeout} if $opts{timeout}; + return api_request('POST', "/services/$service_id/execute", $body, %opts); +} + +sub service_env_get { + my ($service_id, %opts) = @_; + return api_request('GET', "/services/$service_id/env", undef, %opts); } sub service_env_set { - my ($service_id, $env_content, $public_key, $secret_key) = @_; - - unless ($env_content) { - print STDERR "${RED}Error: No environment content provided${RESET}\n"; - return 0; - } - - if (length($env_content) > $MAX_ENV_CONTENT_SIZE) { - print STDERR "${RED}Error: Environment content too large (max $MAX_ENV_CONTENT_SIZE bytes)${RESET}\n"; - return 0; - } - - my $result = api_request_text("/services/$service_id/env", 'PUT', $env_content, $public_key, $secret_key); - - if ($result->{error}) { - print STDERR "${RED}Error: $result->{error}${RESET}\n"; - return 0; - } - - my $count = $result->{count} // 0; - my $plural = $count == 1 ? '' : 's'; - print "${GREEN}Environment vault updated: $count variable$plural${RESET}\n"; - print "$result->{message}\n" if $result->{message}; - return 1; -} - -sub service_env_export { - my ($service_id, $public_key, $secret_key) = @_; - my $result = api_request("/services/$service_id/env/export", 'POST', {}, $public_key, $secret_key); - my $env_content = $result->{env} // ''; - if ($env_content) { - print $env_content; - print "\n" unless $env_content =~ /\n$/; - } + my ($service_id, $env_content, %opts) = @_; + return api_request('PUT', "/services/$service_id/env", $env_content, + %opts, content_type => 'text/plain'); } sub service_env_delete { - my ($service_id, $public_key, $secret_key) = @_; - api_request("/services/$service_id/env", 'DELETE', undef, $public_key, $secret_key); - print "${GREEN}Environment vault deleted${RESET}\n"; + my ($service_id, %opts) = @_; + return api_request('DELETE', "/services/$service_id/env", undef, %opts); } -sub read_env_file { - my ($filepath) = @_; - unless (-e $filepath) { - print STDERR "${RED}Error: Env file not found: $filepath${RESET}\n"; - exit 1; +sub service_env_export { + my ($service_id, %opts) = @_; + return api_request('POST', "/services/$service_id/env/export", {}, %opts); +} + +sub service_resize { + my ($service_id, $vcpu, %opts) = @_; + return api_request('PATCH', "/services/$service_id", { vcpu => $vcpu }, %opts); +} + +# ============================================================================ +# Snapshot Functions (9) +# ============================================================================ + +sub snapshot_list { + my (%opts) = @_; + return api_request('GET', '/snapshots', undef, %opts); +} + +sub snapshot_get { + my ($snapshot_id, %opts) = @_; + return api_request('GET', "/snapshots/$snapshot_id", undef, %opts); +} + +sub snapshot_session { + my ($session_id, %opts) = @_; + my $body = {}; + $body->{name} = $opts{name} if $opts{name}; + $body->{hot} = JSON::PP::true if $opts{hot}; + return api_request('POST', "/sessions/$session_id/snapshot", $body, %opts); +} + +sub snapshot_service { + my ($service_id, %opts) = @_; + my $body = {}; + $body->{name} = $opts{name} if $opts{name}; + $body->{hot} = JSON::PP::true if $opts{hot}; + return api_request('POST', "/services/$service_id/snapshot", $body, %opts); +} + +sub snapshot_restore { + my ($snapshot_id, %opts) = @_; + return api_request('POST', "/snapshots/$snapshot_id/restore", {}, %opts); +} + +sub snapshot_delete { + my ($snapshot_id, %opts) = @_; + return api_request_with_sudo('DELETE', "/snapshots/$snapshot_id", undef, %opts); +} + +sub snapshot_lock { + my ($snapshot_id, %opts) = @_; + return api_request('POST', "/snapshots/$snapshot_id/lock", {}, %opts); +} + +sub snapshot_unlock { + my ($snapshot_id, %opts) = @_; + return api_request_with_sudo('POST', "/snapshots/$snapshot_id/unlock", {}, %opts); +} + +sub snapshot_clone { + my ($snapshot_id, %opts) = @_; + my $body = { clone_type => $opts{clone_type} || 'session' }; + $body->{name} = $opts{name} if $opts{name}; + $body->{ports} = $opts{ports} if $opts{ports}; + $body->{shell} = $opts{shell} if $opts{shell}; + return api_request('POST', "/snapshots/$snapshot_id/clone", $body, %opts); +} + +# ============================================================================ +# Image Functions (13) +# ============================================================================ + +sub image_list { + my (%opts) = @_; + my $endpoint = '/images'; + $endpoint .= "?filter=$opts{filter}" if $opts{filter}; + return api_request('GET', $endpoint, undef, %opts); +} + +sub image_get { + my ($image_id, %opts) = @_; + return api_request('GET', "/images/$image_id", undef, %opts); +} + +sub image_publish { + my (%opts) = @_; + my $body = { + source_type => $opts{source_type}, + source_id => $opts{source_id} + }; + $body->{name} = $opts{name} if $opts{name}; + $body->{description} = $opts{description} if $opts{description}; + return api_request('POST', '/images/publish', $body, %opts); +} + +sub image_delete { + my ($image_id, %opts) = @_; + return api_request_with_sudo('DELETE', "/images/$image_id", undef, %opts); +} + +sub image_lock { + my ($image_id, %opts) = @_; + return api_request('POST', "/images/$image_id/lock", {}, %opts); +} + +sub image_unlock { + my ($image_id, %opts) = @_; + return api_request_with_sudo('POST', "/images/$image_id/unlock", {}, %opts); +} + +sub image_set_visibility { + my ($image_id, $visibility, %opts) = @_; + return api_request('POST', "/images/$image_id/visibility", { visibility => $visibility }, %opts); +} + +sub image_grant_access { + my ($image_id, $trusted_api_key, %opts) = @_; + return api_request('POST', "/images/$image_id/access", { api_key => $trusted_api_key }, %opts); +} + +sub image_revoke_access { + my ($image_id, $trusted_api_key, %opts) = @_; + return api_request('DELETE', "/images/$image_id/access/$trusted_api_key", undef, %opts); +} + +sub image_list_trusted { + my ($image_id, %opts) = @_; + return api_request('GET', "/images/$image_id/access", undef, %opts); +} + +sub image_transfer { + my ($image_id, $to_api_key, %opts) = @_; + return api_request('POST', "/images/$image_id/transfer", { to_api_key => $to_api_key }, %opts); +} + +sub image_spawn { + my ($image_id, %opts) = @_; + my $body = {}; + $body->{name} = $opts{name} if $opts{name}; + $body->{ports} = $opts{ports} if $opts{ports}; + $body->{bootstrap} = $opts{bootstrap} if $opts{bootstrap}; + $body->{network_mode} = $opts{network_mode} if $opts{network_mode}; + return api_request('POST', "/images/$image_id/spawn", $body, %opts); +} + +sub image_clone { + my ($image_id, %opts) = @_; + my $body = {}; + $body->{name} = $opts{name} if $opts{name}; + $body->{description} = $opts{description} if $opts{description}; + return api_request('POST', "/images/$image_id/clone", $body, %opts); +} + +# ============================================================================ +# PaaS Logs Functions (2) +# ============================================================================ + +sub logs_fetch { + my (%opts) = @_; + my $body = { + source => $opts{source} || 'all', + lines => $opts{lines} || 100, + since => $opts{since} || '1h' + }; + $body->{grep} = $opts{grep} if $opts{grep}; + return api_request('POST', '/paas/logs', $body, %opts); +} + +sub logs_stream { + my (%opts) = @_; + # SSE streaming - returns immediately, callback for each line + my $callback = $opts{callback}; + return undef unless $callback; + + # SSE streaming not easily supported in sync Perl, return placeholder + set_error("logs_stream requires async support"); + return undef; +} + +# ============================================================================ +# Key Validation +# ============================================================================ + +sub validate_keys { + my (%opts) = @_; + my ($pk, $sk) = get_credentials(%opts); + + my $ua = LWP::UserAgent->new(timeout => 30); + my $url = "$PORTAL_BASE/keys/validate"; + my $req = HTTP::Request->new('POST', $url); + + $req->header('Authorization' => "Bearer $pk"); + $req->header('Content-Type' => 'application/json'); + + if ($sk) { + my $timestamp = int(time); + my $sig_input = "$timestamp:POST:/keys/validate:"; + my $signature = hmac_sign($sk, $sig_input); + $req->header('X-Timestamp' => $timestamp); + $req->header('X-Signature' => $signature); } - open my $fh, '<', $filepath or die "Cannot read file: $!"; - local $/; - my $content = <$fh>; - close $fh; - return $content; + + my $res = $ua->request($req); + return decode_json($res->content) if $res->content; + return undef; +} + +sub health_check { + my (%opts) = @_; + my $ua = LWP::UserAgent->new(timeout => 10); + my $res = $ua->get("$API_BASE/health"); + return $res->is_success ? 1 : 0; +} + +# ============================================================================ +# CLI Implementation +# ============================================================================ + +package main; + +sub build_input_files { + my @files = @_; + my @input_files; + foreach my $filepath (@files) { + unless (-e $filepath) { + print STDERR "${RED}Error: Input file not found: $filepath${RESET}\n"; + exit 1; + } + open my $f, '<:raw', $filepath or die "Cannot read file: $!"; + my $content = do { local $/; <$f> }; + close $f; + push @input_files, { + filename => basename($filepath), + content_base64 => encode_base64($content, '') + }; + } + return \@input_files; } sub build_env_content { my ($envs, $env_file) = @_; my @parts; - # Read from env file first - if ($env_file) { - push @parts, read_env_file($env_file); + if ($env_file && -e $env_file) { + open my $fh, '<', $env_file or die "Cannot read file: $!"; + my $content = do { local $/; <$fh> }; + close $fh; + push @parts, $content; } - # Add -e flags foreach my $e (@$envs) { push @parts, $e if $e =~ /=/; } @@ -578,54 +763,33 @@ sub build_env_content { return join("\n", @parts); } -sub cmd_service_env { - my ($action, $target, $envs, $env_file, $public_key, $secret_key) = @_; - - unless ($action) { - print STDERR "${RED}Error: env action required (status, set, export, delete)${RESET}\n"; - exit 1; - } - - unless ($target) { - print STDERR "${RED}Error: Service ID required for env command${RESET}\n"; - exit 1; - } - - if ($action eq 'status') { - service_env_status($target, $public_key, $secret_key); - } elsif ($action eq 'set') { - my $env_content = build_env_content($envs, $env_file); - unless ($env_content) { - print STDERR "${RED}Error: No env content provided. Use -e KEY=VAL or --env-file${RESET}\n"; - exit 1; - } - service_env_set($target, $env_content, $public_key, $secret_key); - } elsif ($action eq 'export') { - service_env_export($target, $public_key, $secret_key); - } elsif ($action eq 'delete') { - service_env_delete($target, $public_key, $secret_key); - } else { - print STDERR "${RED}Error: Unknown env action '$action'. Use: status, set, export, delete${RESET}\n"; - exit 1; - } -} - sub cmd_execute { my ($options) = @_; - my ($public_key, $secret_key) = get_api_key($options->{api_key}); + my $source = $options->{source_file}; + my $inline_code = $options->{inline_code}; + my $language = $options->{language}; - unless (-e $options->{source_file}) { - print STDERR "${RED}Error: File not found: $options->{source_file}${RESET}\n"; + my $code; + if ($inline_code) { + $code = $inline_code; + } else { + unless (-e $source) { + print STDERR "${RED}Error: File not found: $source${RESET}\n"; + exit 1; + } + open my $fh, '<', $source or die "Cannot read file: $!"; + $code = do { local $/; <$fh> }; + close $fh; + $language ||= Un::detect_language($source); + } + + unless ($language) { + print STDERR "${RED}Error: Could not detect language${RESET}\n"; exit 1; } - open my $fh, '<', $options->{source_file} or die "Cannot read file: $!"; - local $/; - my $code = <$fh>; - close $fh; - - my $language = detect_language($options->{source_file}); - my $payload = { language => $language, code => $code }; + my %opts = (network_mode => $options->{network} || 'zerotrust'); + $opts{vcpu} = $options->{vcpu} if $options->{vcpu}; if ($options->{env} && @{$options->{env}}) { my %env_vars; @@ -634,36 +798,24 @@ sub cmd_execute { $env_vars{$1} = $2; } } - $payload->{env} = \%env_vars if %env_vars; + $opts{env} = \%env_vars if %env_vars; } if ($options->{files} && @{$options->{files}}) { - my @input_files; - foreach my $filepath (@{$options->{files}}) { - unless (-e $filepath) { - print STDERR "${RED}Error: Input file not found: $filepath${RESET}\n"; - exit 1; - } - open my $f, '<:raw', $filepath or die "Cannot read file: $!"; - local $/; - my $content = <$f>; - close $f; - push @input_files, { - filename => basename($filepath), - content_base64 => encode_base64($content, '') - }; - } - $payload->{input_files} = \@input_files; + $opts{input_files} = build_input_files(@{$options->{files}}); } - $payload->{return_artifacts} = JSON::PP::true if $options->{artifacts}; - $payload->{network} = $options->{network} if $options->{network}; - $payload->{vcpu} = $options->{vcpu} if $options->{vcpu}; + $opts{return_artifacts} = 1 if $options->{artifacts}; - my $result = api_request('/execute', 'POST', $payload, $public_key, $secret_key); + my $result = Un::execute($language, $code, %opts); - print "${BLUE}$result->{stdout}${RESET}" if $result->{stdout}; - print STDERR "${RED}$result->{stderr}${RESET}" if $result->{stderr}; + unless ($result) { + print STDERR "${RED}Error: " . Un::last_error() . "${RESET}\n"; + exit 1; + } + + print $result->{stdout} if $result->{stdout}; + print STDERR $result->{stderr} if $result->{stderr}; if ($options->{artifacts} && $result->{artifacts}) { my $out_dir = $options->{output_dir} || '.'; @@ -680,15 +832,14 @@ sub cmd_execute { } } - exit($result->{exit_code} // 0); + exit($result->{exit_code} || 0); } sub cmd_session { my ($options) = @_; - my ($public_key, $secret_key) = get_api_key($options->{api_key}); if ($options->{list}) { - my $result = api_request('/sessions', 'GET', undef, $public_key, $secret_key); + my $result = Un::session_list(); my $sessions = $result->{sessions} || []; if (@$sessions == 0) { print "No active sessions\n"; @@ -704,56 +855,79 @@ sub cmd_session { } if ($options->{kill}) { - api_request("/sessions/$options->{kill}", 'DELETE', undef, $public_key, $secret_key); + Un::session_destroy($options->{kill}); print "${GREEN}Session terminated: $options->{kill}${RESET}\n"; return; } - if ($options->{attach}) { - print "${YELLOW}Attaching to session $options->{attach}...${RESET}\n"; - print "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}\n"; + if ($options->{info}) { + my $result = Un::session_get($options->{info}); + print encode_json($result) . "\n"; return; } - my $payload = { shell => $options->{shell} || 'bash' }; - $payload->{network} = $options->{network} if $options->{network}; - $payload->{vcpu} = $options->{vcpu} if $options->{vcpu}; - $payload->{persistence} = 'tmux' if $options->{tmux}; - $payload->{persistence} = 'screen' if $options->{screen}; - $payload->{audit} = JSON::PP::true if $options->{audit}; + if ($options->{freeze}) { + Un::session_freeze($options->{freeze}); + print "${GREEN}Session frozen: $options->{freeze}${RESET}\n"; + return; + } + + if ($options->{unfreeze}) { + Un::session_unfreeze($options->{unfreeze}); + print "${GREEN}Session unfreezing: $options->{unfreeze}${RESET}\n"; + return; + } + + if ($options->{boost}) { + my $vcpu = $options->{vcpu} || 2; + Un::session_boost($options->{boost}, $vcpu); + print "${GREEN}Session boosted: $options->{boost}${RESET}\n"; + return; + } + + if ($options->{unboost}) { + Un::session_unboost($options->{unboost}); + print "${GREEN}Session unboosted: $options->{unboost}${RESET}\n"; + return; + } + + if ($options->{execute}) { + my $result = Un::session_execute($options->{execute}, $options->{command}); + print $result->{stdout} if $result->{stdout}; + print STDERR $result->{stderr} if $result->{stderr}; + return; + } + + if ($options->{snapshot}) { + my $result = Un::snapshot_session($options->{snapshot}, + name => $options->{snapshot_name}, hot => $options->{hot}); + print "${GREEN}Snapshot created${RESET}\n"; + print encode_json($result) . "\n"; + return; + } + + # Create new session + my %opts = (shell => $options->{shell} || 'bash'); + $opts{network} = $options->{network} if $options->{network}; + $opts{vcpu} = $options->{vcpu} if $options->{vcpu}; + $opts{persistence} = 'tmux' if $options->{tmux}; + $opts{persistence} = 'screen' if $options->{screen}; - # Add input files if ($options->{files} && @{$options->{files}}) { - my @input_files; - foreach my $filepath (@{$options->{files}}) { - unless (-e $filepath) { - print STDERR "${RED}Error: Input file not found: $filepath${RESET}\n"; - exit 1; - } - open my $f, '<:raw', $filepath or die "Cannot read file: $!"; - local $/; - my $content = <$f>; - close $f; - push @input_files, { - filename => basename($filepath), - content_base64 => encode_base64($content, '') - }; - } - $payload->{input_files} = \@input_files; + $opts{input_files} = build_input_files(@{$options->{files}}); } print "${YELLOW}Creating session...${RESET}\n"; - my $result = api_request('/sessions', 'POST', $payload, $public_key, $secret_key); - print "${GREEN}Session created: ", ($result->{id} // 'N/A'), "${RESET}\n"; + my $result = Un::session_create(%opts); + print "${GREEN}Session created: " . ($result->{id} // 'N/A') . "${RESET}\n"; print "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}\n"; } sub cmd_service { my ($options) = @_; - my ($public_key, $secret_key) = get_api_key($options->{api_key}); if ($options->{list}) { - my $result = api_request('/services', 'GET', undef, $public_key, $secret_key); + my $result = Un::service_list(); my $services = $result->{services} || []; if (@$services == 0) { print "No services\n"; @@ -771,38 +945,43 @@ sub cmd_service { } if ($options->{info}) { - my $result = api_request("/services/$options->{info}", 'GET', undef, $public_key, $secret_key); - print encode_json($result); - print "\n"; + my $result = Un::service_get($options->{info}); + print encode_json($result) . "\n"; return; } if ($options->{logs}) { - my $result = api_request("/services/$options->{logs}/logs", 'GET', undef, $public_key, $secret_key); + my $result = Un::service_logs($options->{logs}, lines => $options->{lines}); print $result->{logs} // ''; return; } - if ($options->{tail}) { - my $result = api_request("/services/$options->{tail}/logs?lines=9000", 'GET', undef, $public_key, $secret_key); - print $result->{logs} // ''; + if ($options->{freeze}) { + Un::service_freeze($options->{freeze}); + print "${GREEN}Service frozen: $options->{freeze}${RESET}\n"; return; } - if ($options->{sleep}) { - api_request("/services/$options->{sleep}/freeze", 'POST', undef, $public_key, $secret_key); - print "${GREEN}Service frozen: $options->{sleep}${RESET}\n"; + if ($options->{unfreeze}) { + Un::service_unfreeze($options->{unfreeze}); + print "${GREEN}Service unfreezing: $options->{unfreeze}${RESET}\n"; return; } - if ($options->{wake}) { - api_request("/services/$options->{wake}/unfreeze", 'POST', undef, $public_key, $secret_key); - print "${GREEN}Service unfreezing: $options->{wake}${RESET}\n"; + if ($options->{lock}) { + Un::service_lock($options->{lock}); + print "${GREEN}Service locked: $options->{lock}${RESET}\n"; + return; + } + + if ($options->{unlock}) { + Un::service_unlock($options->{unlock}); + print "${GREEN}Service unlocked: $options->{unlock}${RESET}\n"; return; } if ($options->{destroy}) { - api_request_with_sudo("/services/$options->{destroy}", 'DELETE', undef, $public_key, $secret_key); + Un::service_destroy($options->{destroy}); print "${GREEN}Service destroyed: $options->{destroy}${RESET}\n"; return; } @@ -812,379 +991,433 @@ sub cmd_service { print STDERR "${RED}Error: --vcpu is required with --resize${RESET}\n"; exit 1; } - my $payload = { vcpu => $options->{vcpu} }; - api_request("/services/$options->{resize}", 'PATCH', $payload, $public_key, $secret_key); + Un::service_resize($options->{resize}, $options->{vcpu}); my $ram = $options->{vcpu} * 2; print "${GREEN}Service resized to $options->{vcpu} vCPU, $ram GB RAM${RESET}\n"; return; } - if ($options->{set_unfreeze_on_demand}) { - my $enabled = ($options->{set_unfreeze_on_demand_enabled} && - ($options->{set_unfreeze_on_demand_enabled} eq 'true' || $options->{set_unfreeze_on_demand_enabled} eq '1')) - ? JSON::PP::true : JSON::PP::false; - my $payload = { unfreeze_on_demand => $enabled }; - api_request("/services/$options->{set_unfreeze_on_demand}", 'PATCH', $payload, $public_key, $secret_key); - my $status = $enabled ? 'enabled' : 'disabled'; - print "${GREEN}Unfreeze-on-demand $status for service: $options->{set_unfreeze_on_demand}${RESET}\n"; - return; - } - - if ($options->{set_show_freeze_page}) { - my $enabled = ($options->{set_show_freeze_page_enabled} && - ($options->{set_show_freeze_page_enabled} eq 'true' || $options->{set_show_freeze_page_enabled} eq '1')) - ? JSON::PP::true : JSON::PP::false; - my $payload = { show_freeze_page => $enabled }; - api_request("/services/$options->{set_show_freeze_page}", 'PATCH', $payload, $public_key, $secret_key); - my $status = $enabled ? 'enabled' : 'disabled'; - print "${GREEN}Show-freeze-page $status for service: $options->{set_show_freeze_page}${RESET}\n"; + if ($options->{redeploy}) { + Un::service_redeploy($options->{redeploy}, bootstrap => $options->{bootstrap}); + print "${GREEN}Service redeployed: $options->{redeploy}${RESET}\n"; return; } if ($options->{execute}) { - my $payload = { command => $options->{command} }; - my $result = api_request("/services/$options->{execute}/execute", 'POST', $payload, $public_key, $secret_key); - print "${BLUE}$result->{stdout}${RESET}" if $result->{stdout}; - print STDERR "${RED}$result->{stderr}${RESET}" if $result->{stderr}; + my $result = Un::service_execute($options->{execute}, $options->{command}); + print $result->{stdout} if $result->{stdout}; + print STDERR $result->{stderr} if $result->{stderr}; return; } - if ($options->{dump_bootstrap}) { - print STDERR "Fetching bootstrap script from $options->{dump_bootstrap}...\n"; - my $payload = { command => 'cat /tmp/bootstrap.sh' }; - my $result = api_request("/services/$options->{dump_bootstrap}/execute", 'POST', $payload, $public_key, $secret_key); + if ($options->{set_unfreeze_on_demand}) { + my $enabled = $options->{set_unfreeze_on_demand_value}; + Un::service_set_unfreeze_on_demand($options->{set_unfreeze_on_demand}, $enabled); + my $status = $enabled ? 'enabled' : 'disabled'; + print "${GREEN}Unfreeze-on-demand $status${RESET}\n"; + return; + } - if ($result->{stdout}) { - my $bootstrap = $result->{stdout}; - if ($options->{dump_file}) { - # Write to file - open my $fh, '>', $options->{dump_file} or do { - print STDERR "${RED}Error: Could not write to $options->{dump_file}: $!${RESET}\n"; - exit 1; - }; - print $fh $bootstrap; - close $fh; - chmod 0755, $options->{dump_file}; - print "Bootstrap saved to $options->{dump_file}\n"; - } else { - # Print to stdout - print $bootstrap; - } - } else { - print STDERR "${RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file)${RESET}\n"; - exit 1; - } + if ($options->{snapshot}) { + my $result = Un::snapshot_service($options->{snapshot}, + name => $options->{snapshot_name}, hot => $options->{hot}); + print "${GREEN}Snapshot created${RESET}\n"; + print encode_json($result) . "\n"; return; } if ($options->{name}) { - my $payload = { name => $options->{name} }; + my %opts = (name => $options->{name}); if ($options->{ports}) { - my @ports = map { int($_) } split(',', $options->{ports}); - $payload->{ports} = \@ports; + $opts{ports} = [map { int($_) } split(',', $options->{ports})]; } if ($options->{domains}) { - my @domains = split(',', $options->{domains}); - $payload->{domains} = \@domains; - } - if ($options->{type}) { - $payload->{service_type} = $options->{type}; - } - if ($options->{bootstrap}) { - $payload->{bootstrap} = $options->{bootstrap}; + $opts{domains} = [split(',', $options->{domains})]; } + $opts{service_type} = $options->{type} if $options->{type}; + $opts{bootstrap} = $options->{bootstrap} if $options->{bootstrap}; + if ($options->{bootstrap_file}) { if (! -e $options->{bootstrap_file}) { - print STDERR "${RED}Error: Bootstrap file not found: $options->{bootstrap_file}${RESET}\n"; + print STDERR "${RED}Error: Bootstrap file not found${RESET}\n"; exit 1; } - open my $fh, '<', $options->{bootstrap_file} or die "Cannot read file: $!"; - local $/; - $payload->{bootstrap_content} = <$fh>; + open my $fh, '<', $options->{bootstrap_file}; + $opts{bootstrap_content} = do { local $/; <$fh> }; close $fh; } - # Add input files - if ($options->{files} && @{$options->{files}}) { - my @input_files; - foreach my $filepath (@{$options->{files}}) { - unless (-e $filepath) { - print STDERR "${RED}Error: Input file not found: $filepath${RESET}\n"; - exit 1; - } - open my $f, '<:raw', $filepath or die "Cannot read file: $!"; - local $/; - my $content = <$f>; - close $f; - push @input_files, { - filename => basename($filepath), - content_base64 => encode_base64($content, '') - }; - } - $payload->{input_files} = \@input_files; - } - $payload->{network} = $options->{network} if $options->{network}; - $payload->{vcpu} = $options->{vcpu} if $options->{vcpu}; - $payload->{unfreeze_on_demand} = JSON::PP::true if $options->{unfreeze_on_demand}; - my $result = api_request('/services', 'POST', $payload, $public_key, $secret_key); - my $service_id = $result->{id}; - print "${GREEN}Service created: ", ($service_id // 'N/A'), "${RESET}\n"; - print "Name: ", ($result->{name} // 'N/A'), "\n"; + if ($options->{files} && @{$options->{files}}) { + $opts{input_files} = build_input_files(@{$options->{files}}); + } + + $opts{network} = $options->{network} if $options->{network}; + $opts{vcpu} = $options->{vcpu} if $options->{vcpu}; + $opts{unfreeze_on_demand} = 1 if $options->{unfreeze_on_demand}; + + my $result = Un::service_create(%opts); + print "${GREEN}Service created: " . ($result->{id} // 'N/A') . "${RESET}\n"; + print "Name: " . ($result->{name} // 'N/A') . "\n"; print "URL: $result->{url}\n" if $result->{url}; - # Auto-set vault if -e or --env-file provided + # Auto-set vault my $env_content = build_env_content($options->{env} || [], $options->{env_file}); - if ($env_content && $service_id) { - service_env_set($service_id, $env_content, $public_key, $secret_key); + if ($env_content && $result->{id}) { + Un::service_env_set($result->{id}, $env_content); + print "${GREEN}Vault configured${RESET}\n"; } return; } - print STDERR "${RED}Error: Specify --name to create a service, or use --list, --info, etc.${RESET}\n"; + print STDERR "${RED}Error: Specify --name to create or use --list, --info, etc.${RESET}\n"; exit 1; } -sub open_browser { - my ($url) = @_; +sub cmd_service_env { + my ($action, $target, $envs, $env_file) = @_; - # Try different browser open commands based on platform - if ($^O eq 'darwin') { - system('open', $url); - } elsif ($^O eq 'MSWin32') { - system('start', $url); - } else { - # Linux/Unix - system('xdg-open', $url, '>/dev/null', '2>&1', '&'); - } -} - -sub validate_key { - my ($public_key, $secret_key, $should_extend) = @_; - - # Call /keys/validate endpoint - my $url = "$API_BASE/keys/validate"; - my $ua = LWP::UserAgent->new(timeout => 30); - my $request = HTTP::Request->new('POST' => $url); - $request->header('Authorization' => "Bearer $public_key"); - $request->header('Content-Type' => 'application/json'); - - # Add HMAC signature if secret_key is present - if ($secret_key) { - my $timestamp = time(); - my $sig_input = "${timestamp}:POST:/keys/validate:"; - my $signature = hmac_sha256_hex($sig_input, $secret_key); - $request->header('X-Timestamp' => $timestamp); - $request->header('X-Signature' => $signature); - } - - my $response = $ua->request($request); - my $result = decode_json($response->content); - - # Handle --extend flag first - if ($should_extend) { - my $public_key = $result->{public_key}; - if ($public_key) { - my $extend_url = "$PORTAL_BASE/keys/extend?pk=$public_key"; - print "${BLUE}Opening browser to extend key...${RESET}\n"; - open_browser($extend_url); - return; - } else { - print STDERR "${RED}Error: Could not retrieve public key${RESET}\n"; - exit 1; - } - } - - # Check if key is expired - if ($result->{expired}) { - print "${RED}Expired${RESET}\n"; - print "Public Key: ", ($result->{public_key} // 'N/A'), "\n"; - print "Tier: ", ($result->{tier} // 'N/A'), "\n"; - print "Expired: ", ($result->{expires_at} // 'N/A'), "\n"; - print "${YELLOW}To renew: Visit https://unsandbox.com/keys/extend${RESET}\n"; + unless ($target) { + print STDERR "${RED}Error: Service ID required${RESET}\n"; exit 1; } - # Valid key - print "${GREEN}Valid${RESET}\n"; - print "Public Key: ", ($result->{public_key} // 'N/A'), "\n"; - print "Tier: ", ($result->{tier} // 'N/A'), "\n"; - print "Status: ", ($result->{status} // 'N/A'), "\n"; - print "Expires: ", ($result->{expires_at} // 'N/A'), "\n"; - print "Time Remaining: ", ($result->{time_remaining} // 'N/A'), "\n"; - print "Rate Limit: ", ($result->{rate_limit} // 'N/A'), "\n"; - print "Burst: ", ($result->{burst} // 'N/A'), "\n"; - print "Concurrency: ", ($result->{concurrency} // 'N/A'), "\n"; -} - -sub cmd_key { - my ($options) = @_; - my ($public_key, $secret_key) = get_api_key($options->{api_key}); - validate_key($public_key, $secret_key, $options->{extend}); -} - -sub cmd_languages { - my ($options) = @_; - my ($public_key, $secret_key) = get_api_key($options->{api_key}); - - my $result = api_request('/languages', 'GET', undef, $public_key, $secret_key); - my $languages_list = $result->{languages} || []; - - if ($options->{json}) { - # Output as JSON array - print encode_json($languages_list); - print "\n"; - } else { - # Output one language per line - foreach my $lang (@$languages_list) { - print "$lang\n"; + if ($action eq 'status') { + my $result = Un::service_env_get($target); + print encode_json($result) . "\n"; + } elsif ($action eq 'set') { + my $content = build_env_content($envs, $env_file); + unless ($content) { + print STDERR "${RED}Error: No env content provided${RESET}\n"; + exit 1; } + Un::service_env_set($target, $content); + print "${GREEN}Vault updated${RESET}\n"; + } elsif ($action eq 'export') { + my $result = Un::service_env_export($target); + print $result->{env} // $result->{content} // ''; + } elsif ($action eq 'delete') { + Un::service_env_delete($target); + print "${GREEN}Vault deleted${RESET}\n"; + } else { + print STDERR "${RED}Error: Unknown env action '$action'${RESET}\n"; + exit 1; } } -sub cmd_image { +sub cmd_snapshot { my ($options) = @_; - my ($public_key, $secret_key) = get_api_key($options->{api_key}); if ($options->{list}) { - my $result = api_request('/images', 'GET', undef, $public_key, $secret_key); - print encode_json($result); - print "\n"; + my $result = Un::snapshot_list(); + my $snapshots = $result->{snapshots} || []; + if (@$snapshots == 0) { + print "No snapshots\n"; + } else { + printf "%-40s %-20s %-10s %s\n", 'ID', 'Name', 'Type', 'Created'; + foreach my $s (@$snapshots) { + printf "%-40s %-20s %-10s %s\n", + $s->{id} // 'N/A', $s->{name} // 'N/A', + $s->{type} // 'N/A', $s->{created_at} // 'N/A'; + } + } return; } if ($options->{info}) { - my $result = api_request("/images/$options->{info}", 'GET', undef, $public_key, $secret_key); - print encode_json($result); - print "\n"; + my $result = Un::snapshot_get($options->{info}); + print encode_json($result) . "\n"; return; } if ($options->{delete}) { - api_request_with_sudo("/images/$options->{delete}", 'DELETE', undef, $public_key, $secret_key); + Un::snapshot_delete($options->{delete}); + print "${GREEN}Snapshot deleted: $options->{delete}${RESET}\n"; + return; + } + + if ($options->{restore}) { + Un::snapshot_restore($options->{restore}); + print "${GREEN}Snapshot restored${RESET}\n"; + return; + } + + if ($options->{lock}) { + Un::snapshot_lock($options->{lock}); + print "${GREEN}Snapshot locked: $options->{lock}${RESET}\n"; + return; + } + + if ($options->{unlock}) { + Un::snapshot_unlock($options->{unlock}); + print "${GREEN}Snapshot unlocked: $options->{unlock}${RESET}\n"; + return; + } + + if ($options->{clone}) { + my $result = Un::snapshot_clone($options->{clone}, + clone_type => $options->{clone_type} || 'session', + name => $options->{clone_name}); + print "${GREEN}Snapshot cloned${RESET}\n"; + print encode_json($result) . "\n"; + return; + } + + print STDERR "${RED}Error: Use --list, --info, --delete, --restore, --lock, --unlock, or --clone${RESET}\n"; + exit 1; +} + +sub cmd_image { + my ($options) = @_; + + if ($options->{list}) { + my $result = Un::image_list(filter => $options->{filter}); + my $images = $result->{images} || []; + if (@$images == 0) { + print "No images\n"; + } else { + printf "%-40s %-20s %-10s %s\n", 'ID', 'Name', 'Visibility', 'Created'; + foreach my $i (@$images) { + printf "%-40s %-20s %-10s %s\n", + $i->{id} // 'N/A', $i->{name} // 'N/A', + $i->{visibility} // 'N/A', $i->{created_at} // 'N/A'; + } + } + return; + } + + if ($options->{info}) { + my $result = Un::image_get($options->{info}); + print encode_json($result) . "\n"; + return; + } + + if ($options->{delete}) { + Un::image_delete($options->{delete}); print "${GREEN}Image deleted: $options->{delete}${RESET}\n"; return; } if ($options->{lock}) { - api_request("/images/$options->{lock}/lock", 'POST', undef, $public_key, $secret_key); + Un::image_lock($options->{lock}); print "${GREEN}Image locked: $options->{lock}${RESET}\n"; return; } if ($options->{unlock}) { - api_request_with_sudo("/images/$options->{unlock}/unlock", 'POST', undef, $public_key, $secret_key); + Un::image_unlock($options->{unlock}); print "${GREEN}Image unlocked: $options->{unlock}${RESET}\n"; return; } if ($options->{publish}) { unless ($options->{source_type}) { - print STDERR "${RED}Error: --publish requires --source-type (service or snapshot)${RESET}\n"; + print STDERR "${RED}Error: --source-type required${RESET}\n"; exit 1; } - my $payload = { + my $result = Un::image_publish( source_type => $options->{source_type}, - source_id => $options->{publish} - }; - $payload->{name} = $options->{name} if $options->{name}; - my $result = api_request('/images/publish', 'POST', $payload, $public_key, $secret_key); + source_id => $options->{publish}, + name => $options->{pub_name}); print "${GREEN}Image published${RESET}\n"; - print encode_json($result); - print "\n"; + print encode_json($result) . "\n"; return; } if ($options->{visibility_id} && $options->{visibility_mode}) { - my $payload = { visibility => $options->{visibility_mode} }; - api_request("/images/$options->{visibility_id}/visibility", 'POST', $payload, $public_key, $secret_key); - print "${GREEN}Image visibility set to $options->{visibility_mode}: $options->{visibility_id}${RESET}\n"; + Un::image_set_visibility($options->{visibility_id}, $options->{visibility_mode}); + print "${GREEN}Visibility set to $options->{visibility_mode}${RESET}\n"; + return; + } + + if ($options->{grant_access}) { + Un::image_grant_access($options->{grant_access}, $options->{trusted_key}); + print "${GREEN}Access granted${RESET}\n"; + return; + } + + if ($options->{revoke_access}) { + Un::image_revoke_access($options->{revoke_access}, $options->{trusted_key}); + print "${GREEN}Access revoked${RESET}\n"; + return; + } + + if ($options->{list_trusted}) { + my $result = Un::image_list_trusted($options->{list_trusted}); + print encode_json($result) . "\n"; + return; + } + + if ($options->{transfer}) { + Un::image_transfer($options->{transfer}, $options->{to_key}); + print "${GREEN}Image transferred${RESET}\n"; return; } if ($options->{spawn}) { - my $payload = {}; - $payload->{name} = $options->{name} if $options->{name}; - if ($options->{ports}) { - my @ports = map { int($_) } split(',', $options->{ports}); - $payload->{ports} = \@ports; - } - my $result = api_request("/images/$options->{spawn}/spawn", 'POST', $payload, $public_key, $secret_key); + my $result = Un::image_spawn($options->{spawn}, + name => $options->{spawn_name}, + ports => $options->{spawn_ports} ? [map { int($_) } split(',', $options->{spawn_ports})] : undef); print "${GREEN}Service spawned from image${RESET}\n"; - print encode_json($result); - print "\n"; + print encode_json($result) . "\n"; return; } if ($options->{clone}) { - my $payload = {}; - $payload->{name} = $options->{name} if $options->{name}; - my $result = api_request("/images/$options->{clone}/clone", 'POST', $payload, $public_key, $secret_key); + my $result = Un::image_clone($options->{clone}, name => $options->{clone_name}); print "${GREEN}Image cloned${RESET}\n"; - print encode_json($result); - print "\n"; + print encode_json($result) . "\n"; return; } - print STDERR "${RED}Error: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, or --clone${RESET}\n"; + print STDERR "${RED}Error: Use --list, --info, --delete, etc.${RESET}\n"; + exit 1; +} + +sub cmd_key { + my ($options) = @_; + my $result = Un::validate_keys(); + + if ($options->{extend}) { + my $pk = $result->{public_key}; + if ($pk) { + my $url = "$Un::PORTAL_BASE/keys/extend?pk=$pk"; + print "${BLUE}Opening browser to extend key...${RESET}\n"; + system("xdg-open '$url' 2>/dev/null || open '$url' 2>/dev/null &"); + } + return; + } + + if ($result->{expired}) { + print "${RED}Expired${RESET}\n"; + print "Public Key: " . ($result->{public_key} // 'N/A') . "\n"; + print "Tier: " . ($result->{tier} // 'N/A') . "\n"; + print "${YELLOW}To renew: Visit $Un::PORTAL_BASE/keys/extend${RESET}\n"; + exit 1; + } + + print "${GREEN}Valid${RESET}\n"; + print "Public Key: " . ($result->{public_key} // 'N/A') . "\n"; + print "Tier: " . ($result->{tier} // 'N/A') . "\n"; + print "Status: " . ($result->{status} // 'N/A') . "\n"; + print "Expires: " . ($result->{expires_at} // 'N/A') . "\n"; + print "Time Remaining: " . ($result->{time_remaining} // 'N/A') . "\n"; +} + +sub cmd_languages { + my ($options) = @_; + my $langs = Un::get_languages(); + + if ($options->{json}) { + print encode_json($langs) . "\n"; + } else { + foreach my $lang (@$langs) { + print "$lang\n"; + } + } +} + +sub show_help { + print <<"HELP"; +Unsandbox CLI - Execute code in secure sandboxes + +Usage: + perl un.pl [options] + perl un.pl -s '' + perl un.pl session [options] + perl un.pl service [options] + perl un.pl service env [options] + perl un.pl snapshot [options] + perl un.pl image [options] + perl un.pl languages [--json] + perl un.pl key [--extend] + +Execute options: + -e KEY=VALUE Environment variable (multiple allowed) + -f FILE Input file (multiple allowed) + -a Return artifacts + -o DIR Output directory for artifacts + -n MODE Network mode (zerotrust|semitrusted) + -v N vCPU count (1-8) + -s LANG Language for inline code + +Session options: + --list List sessions + --info ID Get session details + --kill ID Terminate session + --freeze ID Freeze session + --unfreeze ID Unfreeze session + --boost ID Boost session (with -v) + --unboost ID Unboost session + --execute ID Execute command (with --command) + --snapshot ID Create snapshot + --shell SHELL Shell/REPL (default: bash) + --tmux Enable tmux persistence + --screen Enable screen persistence + +Service options: + --list List services + --info ID Get service details + --name NAME Create service with name + --ports PORTS Comma-separated ports + --domains DOMS Custom domains + --bootstrap CMD Bootstrap command + --bootstrap-file Bootstrap script file + --logs ID Get logs + --freeze ID Freeze service + --unfreeze ID Unfreeze service + --lock ID Lock service + --unlock ID Unlock service + --destroy ID Destroy service + --resize ID Resize (with -v) + --redeploy ID Redeploy service + --execute ID Execute command (with --command) + --snapshot ID Create snapshot + +Service env commands: + env status ID Check vault status + env set ID Set vault (use -e or --env-file) + env export ID Export vault contents + env delete ID Delete vault + +Snapshot options: + --list List snapshots + --info ID Get snapshot details + --delete ID Delete snapshot + --restore ID Restore from snapshot + --lock ID Lock snapshot + --unlock ID Unlock snapshot + --clone ID Clone snapshot (with --clone-type, --clone-name) + +Image options: + --list List images (with optional --filter) + --info ID Get image details + --delete ID Delete image + --lock ID Lock image + --unlock ID Unlock image + --publish ID Publish from service/snapshot (--source-type) + --visibility ID MODE Set visibility (private|unlisted|public) + --grant-access ID Grant access (with --trusted-key) + --revoke-access ID Revoke access (with --trusted-key) + --list-trusted ID List trusted keys + --transfer ID Transfer ownership (with --to-key) + --spawn ID Spawn service from image + --clone ID Clone image +HELP exit 1; } sub main { my %options = ( - command => undef, - source_file => undef, env => [], - files => [], - artifacts => 0, - output_dir => undef, - network => undef, - vcpu => undef, - api_key => undef, - shell => undef, - list => 0, - attach => undef, - kill => undef, - audit => 0, - tmux => 0, - screen => 0, - name => undef, - ports => undef, - domains => undef, - type => undef, - bootstrap => undef, - bootstrap_file => undef, - info => undef, - logs => undef, - tail => undef, - sleep => undef, - wake => undef, - destroy => undef, - resize => undef, - execute => undef, - command => undef, - dump_bootstrap => undef, - dump_file => undef, - extend => 0, - env_file => undef, - env_action => undef, - env_target => undef, - json => 0, - # Image options - delete => undef, - lock => undef, - unlock => undef, - publish => undef, - source_type => undef, - visibility_id => undef, - visibility_mode => undef, - spawn => undef, - clone => undef + files => [] ); - for (my $i = 0; $i < @ARGV; $i++) { + my $i = 0; + while ($i < @ARGV) { my $arg = $ARGV[$i]; - if ($arg eq 'session' || $arg eq 'service' || $arg eq 'key' || $arg eq 'languages' || $arg eq 'image') { + if ($arg eq 'session' || $arg eq 'service' || $arg eq 'snapshot' || + $arg eq 'image' || $arg eq 'key' || $arg eq 'languages') { $options{command} = $arg; } elsif ($arg eq '-e') { push @{$options{env}}, $ARGV[++$i]; @@ -1198,22 +1431,41 @@ sub main { $options{network} = $ARGV[++$i]; } elsif ($arg eq '-v') { $options{vcpu} = int($ARGV[++$i]); - } elsif ($arg eq '-k') { - $options{api_key} = $ARGV[++$i]; } elsif ($arg eq '-s' || $arg eq '--shell') { - $options{shell} = $ARGV[++$i]; + $options{language} = $ARGV[++$i] if !$options{command}; + $options{shell} = $ARGV[$i] if $options{command} && $options{command} eq 'session'; } elsif ($arg eq '-l' || $arg eq '--list') { $options{list} = 1; - } elsif ($arg eq '--attach') { - $options{attach} = $ARGV[++$i]; + } elsif ($arg eq '--info') { + $options{info} = $ARGV[++$i]; } elsif ($arg eq '--kill') { $options{kill} = $ARGV[++$i]; - } elsif ($arg eq '--audit') { - $options{audit} = 1; - } elsif ($arg eq '--tmux') { - $options{tmux} = 1; - } elsif ($arg eq '--screen') { - $options{screen} = 1; + } elsif ($arg eq '--freeze') { + $options{freeze} = $ARGV[++$i]; + } elsif ($arg eq '--unfreeze') { + $options{unfreeze} = $ARGV[++$i]; + } elsif ($arg eq '--lock') { + $options{lock} = $ARGV[++$i]; + } elsif ($arg eq '--unlock') { + $options{unlock} = $ARGV[++$i]; + } elsif ($arg eq '--boost') { + $options{boost} = $ARGV[++$i]; + } elsif ($arg eq '--unboost') { + $options{unboost} = $ARGV[++$i]; + } elsif ($arg eq '--destroy') { + $options{destroy} = $ARGV[++$i]; + } elsif ($arg eq '--resize') { + $options{resize} = $ARGV[++$i]; + } elsif ($arg eq '--redeploy') { + $options{redeploy} = $ARGV[++$i]; + } elsif ($arg eq '--logs') { + $options{logs} = $ARGV[++$i]; + } elsif ($arg eq '--lines') { + $options{lines} = $ARGV[++$i]; + } elsif ($arg eq '--execute') { + $options{execute} = $ARGV[++$i]; + } elsif ($arg eq '--command') { + $options{command_str} = $ARGV[++$i]; } elsif ($arg eq '--name') { $options{name} = $ARGV[++$i]; } elsif ($arg eq '--ports') { @@ -1228,165 +1480,103 @@ sub main { $options{bootstrap_file} = $ARGV[++$i]; } elsif ($arg eq '--env-file') { $options{env_file} = $ARGV[++$i]; - } elsif ($arg eq 'env') { - # Handle "service env " subcommand - if ($options{command} && $options{command} eq 'service') { - $options{env_action} = $ARGV[++$i] if defined $ARGV[$i + 1]; - if (defined $ARGV[$i + 1] && $ARGV[$i + 1] !~ /^-/) { - $options{env_target} = $ARGV[++$i]; - } - } - } elsif ($arg eq '--info') { - $options{info} = $ARGV[++$i]; - } elsif ($arg eq '--logs') { - $options{logs} = $ARGV[++$i]; - } elsif ($arg eq '--tail') { - $options{tail} = $ARGV[++$i]; - } elsif ($arg eq '--freeze') { - $options{sleep} = $ARGV[++$i]; - } elsif ($arg eq '--unfreeze') { - $options{wake} = $ARGV[++$i]; - } elsif ($arg eq '--destroy') { - $options{destroy} = $ARGV[++$i]; - } elsif ($arg eq '--resize') { - $options{resize} = $ARGV[++$i]; - } elsif ($arg eq '--execute') { - $options{execute} = $ARGV[++$i]; - } elsif ($arg eq '--command') { - $options{command} = $ARGV[++$i]; - } elsif ($arg eq '--dump-bootstrap') { - $options{dump_bootstrap} = $ARGV[++$i]; - } elsif ($arg eq '--dump-file') { - $options{dump_file} = $ARGV[++$i]; - } elsif ($arg eq '--extend') { - $options{extend} = 1; - } elsif ($arg eq '--json') { - $options{json} = 1; + } elsif ($arg eq '--snapshot') { + $options{snapshot} = $ARGV[++$i]; + } elsif ($arg eq '--snapshot-name') { + $options{snapshot_name} = $ARGV[++$i]; + } elsif ($arg eq '--hot') { + $options{hot} = 1; + } elsif ($arg eq '--restore') { + $options{restore} = $ARGV[++$i]; } elsif ($arg eq '--delete') { $options{delete} = $ARGV[++$i]; - } elsif ($arg eq '--lock') { - $options{lock} = $ARGV[++$i]; - } elsif ($arg eq '--unlock') { - $options{unlock} = $ARGV[++$i]; + } elsif ($arg eq '--clone') { + $options{clone} = $ARGV[++$i]; + } elsif ($arg eq '--clone-type') { + $options{clone_type} = $ARGV[++$i]; + } elsif ($arg eq '--clone-name') { + $options{clone_name} = $ARGV[++$i]; } elsif ($arg eq '--publish') { $options{publish} = $ARGV[++$i]; } elsif ($arg eq '--source-type') { $options{source_type} = $ARGV[++$i]; } elsif ($arg eq '--visibility') { $options{visibility_id} = $ARGV[++$i]; - $options{visibility_mode} = $ARGV[++$i] if defined $ARGV[$i + 1]; + $options{visibility_mode} = $ARGV[++$i]; } elsif ($arg eq '--spawn') { $options{spawn} = $ARGV[++$i]; - } elsif ($arg eq '--clone') { - $options{clone} = $ARGV[++$i]; + } elsif ($arg eq '--grant-access') { + $options{grant_access} = $ARGV[++$i]; + } elsif ($arg eq '--revoke-access') { + $options{revoke_access} = $ARGV[++$i]; + } elsif ($arg eq '--list-trusted') { + $options{list_trusted} = $ARGV[++$i]; + } elsif ($arg eq '--trusted-key') { + $options{trusted_key} = $ARGV[++$i]; + } elsif ($arg eq '--transfer') { + $options{transfer} = $ARGV[++$i]; + } elsif ($arg eq '--to-key') { + $options{to_key} = $ARGV[++$i]; + } elsif ($arg eq '--filter') { + $options{filter} = $ARGV[++$i]; + } elsif ($arg eq '--tmux') { + $options{tmux} = 1; + } elsif ($arg eq '--screen') { + $options{screen} = 1; } elsif ($arg eq '--unfreeze-on-demand') { $options{unfreeze_on_demand} = 1; } elsif ($arg eq '--set-unfreeze-on-demand') { $options{set_unfreeze_on_demand} = $ARGV[++$i]; - $options{set_unfreeze_on_demand_enabled} = $ARGV[++$i] if defined $ARGV[$i + 1]; - } elsif ($arg eq '--set-show-freeze-page') { - $options{set_show_freeze_page} = $ARGV[++$i]; - $options{set_show_freeze_page_enabled} = $ARGV[++$i] if defined $ARGV[$i + 1]; + $options{set_unfreeze_on_demand_value} = ($ARGV[++$i] =~ /^(true|1)$/i); + } elsif ($arg eq '--json') { + $options{json} = 1; + } elsif ($arg eq '--extend') { + $options{extend} = 1; + } elsif ($arg eq 'env' && $options{command} && $options{command} eq 'service') { + $options{env_action} = $ARGV[++$i]; + $options{env_target} = $ARGV[++$i] if defined $ARGV[$i+1] && $ARGV[$i+1] !~ /^-/; + } elsif ($arg eq '--help' || $arg eq '-h') { + show_help(); } elsif ($arg =~ /^-/) { print STDERR "${RED}Unknown option: $arg${RESET}\n"; exit 1; } else { - $options{source_file} = $arg; + if ($options{language} && !$options{inline_code}) { + $options{inline_code} = $arg; + } else { + $options{source_file} = $arg; + } } + $i++; } - if ($options{command} && $options{command} eq 'session') { - cmd_session(\%options); - } elsif ($options{command} && $options{command} eq 'service') { - # Check for "service env" subcommand - if ($options{env_action}) { - my ($public_key, $secret_key) = get_api_key($options{api_key}); - cmd_service_env($options{env_action}, $options{env_target}, $options{env}, $options{env_file}, $public_key, $secret_key); - } else { - cmd_service(\%options); + # Handle commands + if ($options{command}) { + if ($options{command} eq 'session') { + cmd_session(\%options); + } elsif ($options{command} eq 'service') { + if ($options{env_action}) { + cmd_service_env($options{env_action}, $options{env_target}, + $options{env}, $options{env_file}); + } else { + $options{command} = $options{command_str} if $options{command_str}; + cmd_service(\%options); + } + } elsif ($options{command} eq 'snapshot') { + cmd_snapshot(\%options); + } elsif ($options{command} eq 'image') { + cmd_image(\%options); + } elsif ($options{command} eq 'languages') { + cmd_languages(\%options); + } elsif ($options{command} eq 'key') { + cmd_key(\%options); } - } elsif ($options{command} && $options{command} eq 'languages') { - cmd_languages(\%options); - } elsif ($options{command} && $options{command} eq 'image') { - cmd_image(\%options); - } elsif ($options{command} && $options{command} eq 'key') { - cmd_key(\%options); - } elsif ($options{source_file}) { + } elsif ($options{source_file} || $options{inline_code}) { cmd_execute(\%options); } else { - print <<'HELP'; -Unsandbox CLI - Execute code in secure sandboxes - -Usage: - $0 [options] - $0 session [options] - $0 service [options] - $0 image [options] - $0 languages [--json] - $0 key [options] - -Languages options: - --json Output as JSON array - -Image options: - --list, -l List all images - --info ID Get image details - --delete ID Delete an image - --lock ID Lock image to prevent deletion - --unlock ID Unlock image - --publish ID Publish image from service/snapshot - --source-type TYPE Source type: service or snapshot - --visibility ID MODE Set visibility: private, unlisted, or public - --spawn ID Spawn new service from image - --clone ID Clone an image - --name NAME Name for spawned service or cloned image - --ports PORTS Ports for spawned service - -Execute options: - -e KEY=VALUE Environment variable (multiple allowed) - -f FILE Input file (multiple allowed) - -a Return artifacts - -o DIR Output directory for artifacts - -n MODE Network mode (zerotrust|semitrusted) - -v N vCPU count (1-8) - -k KEY API key - -Session options: - -s, --shell NAME Shell/REPL (default: bash) - -l, --list List sessions - --attach ID Attach to session - --kill ID Terminate session - --audit Record session - --tmux Enable tmux persistence - --screen Enable screen persistence - -Service options: - --name NAME Service name - --ports PORTS Comma-separated ports - --domains DOMAINS Custom domains - --type TYPE Service type (minecraft|mumble|teamspeak|source|tcp|udp) - --bootstrap CMD Bootstrap command or URI - --bootstrap-file FILE Upload local file as bootstrap script - -l, --list List services - --info ID Get service details - --logs ID Get all logs - --tail ID Get last 9000 lines - --freeze ID Freeze service - --unfreeze ID Unfreeze service - --destroy ID Destroy service - --resize ID Resize service (requires -v) - --execute ID Execute command in service - --command CMD Command to execute (with --execute) - --dump-bootstrap ID Dump bootstrap script - --dump-file FILE File to save bootstrap (with --dump-bootstrap) - --set-unfreeze-on-demand ID BOOL Enable/disable auto-unfreeze on HTTP request - --set-show-freeze-page ID BOOL Enable/disable showing freeze page when frozen - -Key options: - --extend Open browser to extend/renew key -HELP - exit 1; + show_help(); } } -main(); +main() unless caller; +1; diff --git a/clients/perl/tests/test_library.pl b/clients/perl/tests/test_library.pl new file mode 100644 index 0000000..3e5879c --- /dev/null +++ b/clients/perl/tests/test_library.pl @@ -0,0 +1,239 @@ +#!/usr/bin/env perl +# Unit Tests for un.pl Library Functions +# +# Tests the ACTUAL exported functions from Un package. +# NO local re-implementations. NO mocking. +# +# Run: perl tests/test_library.pl + +use strict; +use warnings; +use Test::More; +use FindBin qw($Bin); +use lib "$Bin/../sync/src"; + +# Load the Un module +require "$Bin/../sync/src/un.pl"; + +# Test counters +my $tests_passed = 0; +my $tests_failed = 0; + +# ============================================================================ +# Test: Un::version() +# ============================================================================ + +subtest 'Un::version()' => sub { + my $version = Un::version(); + ok(defined $version, 'version() returns defined value'); + ok(length($version) > 0, 'version() returns non-empty string'); + like($version, qr/^\d+\.\d+\.\d+$/, 'version() matches X.Y.Z format'); + diag("Version: $version"); +}; + +# ============================================================================ +# Test: Un::detect_language() +# ============================================================================ + +subtest 'Un::detect_language()' => sub { + my @tests = ( + ['test.py', 'python'], + ['app.js', 'javascript'], + ['main.go', 'go'], + ['script.rb', 'ruby'], + ['lib.rs', 'rust'], + ['main.c', 'c'], + ['app.cpp', 'cpp'], + ['Main.java', 'java'], + ['index.php', 'php'], + ['script.pl', 'perl'], + ['init.lua', 'lua'], + ['run.sh', 'bash'], + ['main.ts', 'typescript'], + ['app.kt', 'kotlin'], + ['lib.ex', 'elixir'], + ['main.hs', 'haskell'], + ); + + for my $test (@tests) { + my ($file, $expected) = @$test; + my $result = Un::detect_language($file); + is($result, $expected, "detect_language('$file') -> '$expected'"); + } + + # Test NULL handling + my $null_result = Un::detect_language(undef); + ok(!defined $null_result, 'detect_language(undef) returns undef'); + + # Test unknown extension + my $unknown = Un::detect_language('file.xyz123'); + ok(!defined $unknown, 'detect_language(unknown ext) returns undef'); + + # Test no extension + my $noext = Un::detect_language('Makefile'); + ok(!defined $noext, 'detect_language(no ext) returns undef'); +}; + +# ============================================================================ +# Test: Un::hmac_sign() +# ============================================================================ + +subtest 'Un::hmac_sign()' => sub { + # Test basic signature generation + my $sig = Un::hmac_sign('secret_key', '1234567890:POST:/execute:{}'); + ok(defined $sig, 'hmac_sign() returns defined value'); + is(length($sig), 64, 'hmac_sign() returns 64-char hex string'); + + # Verify hex characters + like($sig, qr/^[0-9a-fA-F]+$/, 'hmac_sign() returns valid hex'); + + # Test deterministic output + my $sig1 = Un::hmac_sign('key', 'message'); + my $sig2 = Un::hmac_sign('key', 'message'); + is($sig1, $sig2, 'hmac_sign() is deterministic'); + + # Test different keys produce different signatures + my $sig_a = Un::hmac_sign('key_a', 'message'); + my $sig_b = Un::hmac_sign('key_b', 'message'); + isnt($sig_a, $sig_b, 'Different keys produce different signatures'); + + # Test different messages produce different signatures + my $sig_m1 = Un::hmac_sign('key', 'message1'); + my $sig_m2 = Un::hmac_sign('key', 'message2'); + isnt($sig_m1, $sig_m2, 'Different messages produce different signatures'); + + # Test NULL handling + my $null_key = Un::hmac_sign(undef, 'message'); + ok(!defined $null_key, 'hmac_sign(undef, msg) returns undef'); + + my $null_msg = Un::hmac_sign('key', undef); + ok(!defined $null_msg, 'hmac_sign(key, undef) returns undef'); + + # Test known HMAC value + my $known_sig = Un::hmac_sign('key', 'message'); + like($known_sig, qr/^6e9ef29b75fffc5b7abae527d58fdadb/, + 'HMAC-SHA256("key", "message") matches expected prefix'); +}; + +# ============================================================================ +# Test: Un::last_error() +# ============================================================================ + +subtest 'Un::last_error()' => sub { + # Initially should be empty or previous error + my $error = Un::last_error(); + ok(defined $error, 'last_error() returns defined value'); + + # Set an error and check + Un::set_error('test error'); + is(Un::last_error(), 'test error', 'last_error() returns set error'); +}; + +# ============================================================================ +# Test: Memory stress test +# ============================================================================ + +subtest 'Memory stress test' => sub { + # Stress test HMAC allocation + for my $i (0..999) { + my $sig = Un::hmac_sign('key', 'message'); + } + pass('1000 HMAC calls without crash'); + + # Stress test language detection + for my $i (0..999) { + Un::detect_language('test.py'); + } + pass('1000 detect_language calls without crash'); + + # Stress test version + for my $i (0..999) { + Un::version(); + } + pass('1000 version calls without crash'); +}; + +# ============================================================================ +# Test: Function existence +# ============================================================================ + +subtest 'Library function existence' => sub { + # Execution functions (8) + can_ok('Un', 'execute'); + can_ok('Un', 'execute_async'); + can_ok('Un', 'wait_job'); + can_ok('Un', 'get_job'); + can_ok('Un', 'cancel_job'); + can_ok('Un', 'list_jobs'); + can_ok('Un', 'get_languages'); + can_ok('Un', 'detect_language'); + + # Session functions (9) + can_ok('Un', 'session_list'); + can_ok('Un', 'session_get'); + can_ok('Un', 'session_create'); + can_ok('Un', 'session_destroy'); + can_ok('Un', 'session_freeze'); + can_ok('Un', 'session_unfreeze'); + can_ok('Un', 'session_boost'); + can_ok('Un', 'session_unboost'); + can_ok('Un', 'session_execute'); + + # Service functions (17) + can_ok('Un', 'service_list'); + can_ok('Un', 'service_get'); + can_ok('Un', 'service_create'); + can_ok('Un', 'service_destroy'); + can_ok('Un', 'service_freeze'); + can_ok('Un', 'service_unfreeze'); + can_ok('Un', 'service_lock'); + can_ok('Un', 'service_unlock'); + can_ok('Un', 'service_set_unfreeze_on_demand'); + can_ok('Un', 'service_redeploy'); + can_ok('Un', 'service_logs'); + can_ok('Un', 'service_execute'); + can_ok('Un', 'service_env_get'); + can_ok('Un', 'service_env_set'); + can_ok('Un', 'service_env_delete'); + can_ok('Un', 'service_env_export'); + can_ok('Un', 'service_resize'); + + # Snapshot functions (9) + can_ok('Un', 'snapshot_list'); + can_ok('Un', 'snapshot_get'); + can_ok('Un', 'snapshot_session'); + can_ok('Un', 'snapshot_service'); + can_ok('Un', 'snapshot_restore'); + can_ok('Un', 'snapshot_delete'); + can_ok('Un', 'snapshot_lock'); + can_ok('Un', 'snapshot_unlock'); + can_ok('Un', 'snapshot_clone'); + + # Image functions (13) + can_ok('Un', 'image_list'); + can_ok('Un', 'image_get'); + can_ok('Un', 'image_publish'); + can_ok('Un', 'image_delete'); + can_ok('Un', 'image_lock'); + can_ok('Un', 'image_unlock'); + can_ok('Un', 'image_set_visibility'); + can_ok('Un', 'image_grant_access'); + can_ok('Un', 'image_revoke_access'); + can_ok('Un', 'image_list_trusted'); + can_ok('Un', 'image_transfer'); + can_ok('Un', 'image_spawn'); + can_ok('Un', 'image_clone'); + + # PaaS Logs (2) + can_ok('Un', 'logs_fetch'); + can_ok('Un', 'logs_stream'); + + # Utilities + can_ok('Un', 'validate_keys'); + can_ok('Un', 'hmac_sign'); + can_ok('Un', 'health_check'); + can_ok('Un', 'version'); + can_ok('Un', 'last_error'); +}; + +done_testing(); diff --git a/clients/php/sync/src/un.php b/clients/php/sync/src/un.php index c862830..482620e 100644 --- a/clients/php/sync/src/un.php +++ b/clients/php/sync/src/un.php @@ -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. * diff --git a/clients/php/sync/tests/NewFunctionsTest.php b/clients/php/sync/tests/NewFunctionsTest.php new file mode 100644 index 0000000..213f47a --- /dev/null +++ b/clients/php/sync/tests/NewFunctionsTest.php @@ -0,0 +1,298 @@ +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']); + } +} diff --git a/clients/powershell/sync/src/un.ps1 b/clients/powershell/sync/src/un.ps1 index b15315f..4c54cd6 100644 --- a/clients/powershell/sync/src/un.ps1 +++ b/clients/powershell/sync/src/un.ps1 @@ -757,6 +757,97 @@ function Invoke-Image { exit 1 } +function Invoke-Snapshot { + param($Args) + + # Parse arguments + $listMode = $Args -contains "--list" -or $Args -contains "-l" + $infoId = $null + $deleteId = $null + $lockId = $null + $unlockId = $null + $restoreId = $null + $cloneId = $null + $cloneType = $null + $name = $null + $shell = $null + $ports = $null + + for ($i = 0; $i -lt $Args.Count; $i++) { + switch ($Args[$i]) { + "--info" { $infoId = $Args[$i + 1]; $i++ } + "--delete" { $deleteId = $Args[$i + 1]; $i++ } + "--lock" { $lockId = $Args[$i + 1]; $i++ } + "--unlock" { $unlockId = $Args[$i + 1]; $i++ } + "--restore" { $restoreId = $Args[$i + 1]; $i++ } + "--clone" { $cloneId = $Args[$i + 1]; $i++ } + "--type" { $cloneType = $Args[$i + 1]; $i++ } + "--name" { $name = $Args[$i + 1]; $i++ } + "--shell" { $shell = $Args[$i + 1]; $i++ } + "-s" { $shell = $Args[$i + 1]; $i++ } + "--ports" { $ports = $Args[$i + 1]; $i++ } + } + } + + if ($listMode) { + $result = Invoke-Api -Endpoint "/snapshots" + $result | ConvertTo-Json -Depth 5 + return + } + + if ($infoId) { + $result = Invoke-Api -Endpoint "/snapshots/$infoId" + $result | ConvertTo-Json -Depth 5 + return + } + + if ($deleteId) { + Invoke-ApiWithSudo -Endpoint "/snapshots/$deleteId" -Method "DELETE" + Write-Host "`e[32mSnapshot deleted: $deleteId`e[0m" + return + } + + if ($lockId) { + Invoke-Api -Endpoint "/snapshots/$lockId/lock" -Method "POST" -Body "{}" + Write-Host "`e[32mSnapshot locked: $lockId`e[0m" + return + } + + if ($unlockId) { + Invoke-ApiWithSudo -Endpoint "/snapshots/$unlockId/unlock" -Method "POST" -Body "{}" + Write-Host "`e[32mSnapshot unlocked: $unlockId`e[0m" + return + } + + if ($restoreId) { + $result = Invoke-Api -Endpoint "/snapshots/$restoreId/restore" -Method "POST" -Body "{}" + Write-Host "`e[32mRestored from snapshot`e[0m" + $result | ConvertTo-Json -Depth 5 + return + } + + if ($cloneId) { + if (-not $cloneType) { + $cloneType = "session" + } + $payload = @{ type = $cloneType } + if ($name) { $payload["name"] = $name } + if ($shell) { $payload["shell"] = $shell } + if ($ports) { + $portList = $ports -split "," | ForEach-Object { [int]$_ } + $payload["ports"] = $portList + } + $body = $payload | ConvertTo-Json + $result = Invoke-Api -Endpoint "/snapshots/$cloneId/clone" -Method "POST" -Body $body + Write-Host "`e[32mCloned to $cloneType`e[0m" + $result | ConvertTo-Json -Depth 5 + return + } + + Write-Error "Error: Use --list, --info, --delete, --lock, --unlock, --restore, or --clone" + exit 1 +} + function Invoke-Service { param($Args) @@ -1011,6 +1102,7 @@ if ($args.Count -eq 0 -or $args[0] -eq "--help" -or $args[0] -eq "-h") { Usage: pwsh un.ps1 [options] pwsh un.ps1 session [options] pwsh un.ps1 service [options] + pwsh un.ps1 snapshot [options] pwsh un.ps1 image [options] pwsh un.ps1 languages [--json] pwsh un.ps1 key [options] @@ -1068,6 +1160,19 @@ Service env commands: env export ID Export vault contents env delete ID Delete vault +Snapshot options: + --list, -l List all snapshots + --info ID Get snapshot details + --delete ID Delete snapshot + --lock ID Lock snapshot + --unlock ID Unlock snapshot + --restore ID Restore from snapshot + --clone ID Clone snapshot to session/service + --type TYPE Clone type: session or service + --name NAME Name for cloned resource + --shell NAME Shell for cloned session + --ports PORTS Ports for cloned service + Key options: --extend Open browser to extend key "@ @@ -1078,6 +1183,8 @@ if ($args[0] -eq "session") { Invoke-Session -Args $args[1..($args.Count-1)] } elseif ($args[0] -eq "service") { Invoke-Service -Args $args[1..($args.Count-1)] +} elseif ($args[0] -eq "snapshot") { + Invoke-Snapshot -Args $args[1..($args.Count-1)] } elseif ($args[0] -eq "image") { Invoke-Image -Args $args[1..($args.Count-1)] } elseif ($args[0] -eq "languages") { diff --git a/clients/powershell/tests/Test-Unsandbox.ps1 b/clients/powershell/tests/Test-Unsandbox.ps1 new file mode 100644 index 0000000..4f44b2a --- /dev/null +++ b/clients/powershell/tests/Test-Unsandbox.ps1 @@ -0,0 +1,162 @@ +#!/usr/bin/env pwsh +# PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +# Unit and Functional Tests for Unsandbox PowerShell SDK + +$ErrorActionPreference = "Stop" + +# Source the main script to get access to functions +. "$PSScriptRoot/../sync/src/un.ps1" + +function Write-TestResult { + param($Name, $Passed, $Message = "") + if ($Passed) { + Write-Host " [PASS] $Name" -ForegroundColor Green + } else { + Write-Host " [FAIL] $Name $Message" -ForegroundColor Red + } +} + +function Test-Unit { + Write-Host "" + Write-Host "=== PowerShell SDK Unit Tests ===" -ForegroundColor Cyan + Write-Host "" + + # Test extension map + Write-Host "Testing extension detection..." + $tests = @{ + ".py" = "python" + ".js" = "javascript" + ".go" = "go" + ".rs" = "rust" + ".ps1" = "powershell" + } + + $passed = 0 + foreach ($ext in $tests.Keys) { + $expected = $tests[$ext] + $actual = $EXT_MAP[$ext] + if ($actual -eq $expected) { + $passed++ + } + } + Write-TestResult "ExtensionMap" ($passed -eq $tests.Count) "($passed/$($tests.Count))" + + # Test HMAC signing + Write-Host "Testing HMAC signing..." + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes("key") + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("message")) + $signature = [System.BitConverter]::ToString($hash).Replace("-", "").ToLower() + $expected = "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a" + Write-TestResult "HmacSign" ($signature -eq $expected) + + # Test env content building + Write-Host "Testing env content building..." + $envs = @("KEY1=value1", "KEY2=value2") + $result = Build-EnvContent -Envs $envs -EnvFile $null + $hasKey1 = $result -match "KEY1=value1" + $hasKey2 = $result -match "KEY2=value2" + Write-TestResult "BuildEnvContent" ($hasKey1 -and $hasKey2) +} + +function Test-Functional { + Write-Host "" + Write-Host "=== PowerShell SDK Functional Tests ===" -ForegroundColor Cyan + Write-Host "" + + $publicKey = $env:UNSANDBOX_PUBLIC_KEY + $secretKey = $env:UNSANDBOX_SECRET_KEY + + if (-not $publicKey -or -not $secretKey) { + Write-Host " [SKIP] No API credentials (set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY)" -ForegroundColor Yellow + return + } + + # Test key validation + Write-Host "Testing key validation..." + try { + $result = Invoke-Api -Endpoint "/keys/validate" -Method "POST" -BaseUrl $PORTAL_BASE + Write-TestResult "ValidateKeys" ($null -ne $result) + } catch { + Write-TestResult "ValidateKeys" $false $_.Exception.Message + } + + # Test languages endpoint + Write-Host "Testing languages endpoint..." + try { + $result = Invoke-Api -Endpoint "/languages" + $hasLanguages = ($result.languages -and $result.languages.Count -gt 0) + Write-TestResult "GetLanguages" $hasLanguages "($($result.languages.Count) languages)" + } catch { + Write-TestResult "GetLanguages" $false $_.Exception.Message + } + + # Test execute + Write-Host "Testing execute endpoint..." + try { + $payload = @{ + language = "python" + code = 'print("hello from powershell test")' + } | ConvertTo-Json + $result = Invoke-Api -Endpoint "/execute" -Method "POST" -Body $payload + $hasOutput = $result.stdout -match "hello" + Write-TestResult "Execute" $hasOutput + } catch { + Write-TestResult "Execute" $false $_.Exception.Message + } + + # Test session list + Write-Host "Testing session list..." + try { + $result = Invoke-Api -Endpoint "/sessions" + Write-TestResult "SessionList" ($null -ne $result) + } catch { + Write-TestResult "SessionList" $false $_.Exception.Message + } + + # Test service list + Write-Host "Testing service list..." + try { + $result = Invoke-Api -Endpoint "/services" + Write-TestResult "ServiceList" ($null -ne $result) + } catch { + Write-TestResult "ServiceList" $false $_.Exception.Message + } + + # Test snapshot list + Write-Host "Testing snapshot list..." + try { + $result = Invoke-Api -Endpoint "/snapshots" + Write-TestResult "SnapshotList" ($null -ne $result) + } catch { + Write-TestResult "SnapshotList" $false $_.Exception.Message + } + + # Test image list + Write-Host "Testing image list..." + try { + $result = Invoke-Api -Endpoint "/images" + Write-TestResult "ImageList" ($null -ne $result) + } catch { + Write-TestResult "ImageList" $false $_.Exception.Message + } + + # Test snapshot list + Write-Host "Testing snapshot list..." + try { + $result = Invoke-Api -Endpoint "/snapshots" + Write-TestResult "SnapshotList" ($null -ne $result) + } catch { + Write-TestResult "SnapshotList" $false $_.Exception.Message + } +} + +# Main +Write-Host "Unsandbox PowerShell SDK Tests" -ForegroundColor Cyan +Write-Host "==============================" -ForegroundColor Cyan + +Test-Unit +Test-Functional + +Write-Host "" +Write-Host "Tests complete." -ForegroundColor Cyan diff --git a/clients/prolog/sync/src/un.pro b/clients/prolog/sync/src/un.pro index 56538a7..c83bfa3 100644 --- a/clients/prolog/sync/src/un.pro +++ b/clients/prolog/sync/src/un.pro @@ -747,10 +747,24 @@ main(Argv) :- -> write(user_error, 'Usage: un.pro [options] \n'), write(user_error, ' un.pro session [options]\n'), write(user_error, ' un.pro service [options]\n'), + write(user_error, ' un.pro snapshot [options]\n'), write(user_error, ' un.pro image [options]\n'), write(user_error, ' un.pro languages [--json]\n'), write(user_error, ' un.pro key [options]\n'), write(user_error, '\n'), + write(user_error, 'Snapshot options:\n'), + write(user_error, ' --list, -l List all snapshots\n'), + write(user_error, ' --info ID Get snapshot details\n'), + write(user_error, ' --delete ID Delete a snapshot\n'), + write(user_error, ' --lock ID Lock snapshot\n'), + write(user_error, ' --unlock ID Unlock snapshot\n'), + write(user_error, ' --restore ID Restore from snapshot\n'), + write(user_error, ' --clone ID Clone snapshot (--type required)\n'), + write(user_error, ' --type TYPE Clone type: session or service\n'), + write(user_error, ' --name NAME Name for cloned resource\n'), + write(user_error, ' --shell SHELL Shell for cloned session\n'), + write(user_error, ' --ports PORTS Ports for cloned service\n'), + write(user_error, '\n'), write(user_error, 'Image options:\n'), write(user_error, ' --list, -l List all images\n'), write(user_error, ' --info ID Get image details\n'), @@ -772,6 +786,8 @@ main(Argv) :- -> handle_session(Rest) ; Argv = ['service'|Rest] -> handle_service(Rest) + ; Argv = ['snapshot'|Rest] + -> handle_snapshot(Rest) ; Argv = ['image'|Rest] -> handle_image(Rest) ; Argv = ['languages'|Rest] @@ -783,3 +799,173 @@ main(Argv) :- ; write(user_error, 'Error: Invalid arguments\n'), halt(1) ). + +% ============================================================================= +% Snapshot handlers +% ============================================================================= + +handle_snapshot(Args) :- + get_public_key(PublicKey), + get_secret_key(SecretKey), + handle_snapshot_cmd(Args, PublicKey, SecretKey). + +handle_snapshot_cmd(['--list'|_], PublicKey, SecretKey) :- !, + atomic_list_concat([ + 'TS=$(date +%s); ', + 'SIG=$(echo -n "$TS:GET:/snapshots:" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'curl -s -X GET "https://api.unsandbox.com/snapshots" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS" ', + '-H "X-Signature: $SIG" | jq .' + ], Cmd), + shell(Cmd). + +handle_snapshot_cmd(['-l'|_], PublicKey, SecretKey) :- !, + handle_snapshot_cmd(['--list'], PublicKey, SecretKey). + +handle_snapshot_cmd(['--info', Id|_], PublicKey, SecretKey) :- !, + atomic_list_concat([ + 'TS=$(date +%s); ', + 'SIG=$(echo -n "$TS:GET:/snapshots/', Id, ':" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'curl -s -X GET "https://api.unsandbox.com/snapshots/', Id, '" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS" ', + '-H "X-Signature: $SIG" | jq .' + ], Cmd), + shell(Cmd). + +handle_snapshot_cmd(['--delete', Id|_], PublicKey, SecretKey) :- !, + atomic_list_concat([ + 'TS=$(date +%s); ', + 'SIG=$(echo -n "$TS:DELETE:/snapshots/', Id, ':" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'RESP=$(curl -s -w "\\n%{http_code}" -X DELETE "https://api.unsandbox.com/snapshots/', Id, '" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS" ', + '-H "X-Signature: $SIG"); ', + 'HTTP_CODE=$(echo "$RESP" | tail -1); ', + 'BODY=$(echo "$RESP" | head -n -1); ', + 'if [ "$HTTP_CODE" = "428" ]; then ', + 'OTP=$(echo "$BODY" | jq -r ".otp // empty"); ', + 'if [ -n "$OTP" ]; then ', + 'TS2=$(date +%s); ', + 'SIG2=$(echo -n "$TS2:DELETE:/snapshots/', Id, ':" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'curl -s -X DELETE "https://api.unsandbox.com/snapshots/', Id, '" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS2" ', + '-H "X-Signature: $SIG2" ', + '-H "X-Sudo-OTP: $OTP" | jq .; ', + 'echo -e "\\x1b[32mSnapshot deleted\\x1b[0m"; fi; ', + 'else echo "$BODY" | jq .; fi' + ], Cmd), + shell(Cmd). + +handle_snapshot_cmd(['--lock', Id|_], PublicKey, SecretKey) :- !, + atomic_list_concat([ + 'TS=$(date +%s); ', + 'SIG=$(echo -n "$TS:POST:/snapshots/', Id, '/lock:" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'curl -s -X POST "https://api.unsandbox.com/snapshots/', Id, '/lock" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS" ', + '-H "X-Signature: $SIG" | jq . && ', + 'echo -e "\\x1b[32mSnapshot locked\\x1b[0m"' + ], Cmd), + shell(Cmd). + +handle_snapshot_cmd(['--unlock', Id|_], PublicKey, SecretKey) :- !, + atomic_list_concat([ + 'TS=$(date +%s); ', + 'BODY="{}"; ', + 'SIG=$(echo -n "$TS:POST:/snapshots/', Id, '/unlock:$BODY" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'RESP=$(curl -s -w "\\n%{http_code}" -X POST "https://api.unsandbox.com/snapshots/', Id, '/unlock" ', + '-H "Content-Type: application/json" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS" ', + '-H "X-Signature: $SIG" ', + '-d "$BODY"); ', + 'HTTP_CODE=$(echo "$RESP" | tail -1); ', + 'BODY_RESP=$(echo "$RESP" | head -n -1); ', + 'if [ "$HTTP_CODE" = "428" ]; then ', + 'OTP=$(echo "$BODY_RESP" | jq -r ".otp // empty"); ', + 'if [ -n "$OTP" ]; then ', + 'TS2=$(date +%s); ', + 'SIG2=$(echo -n "$TS2:POST:/snapshots/', Id, '/unlock:$BODY" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'curl -s -X POST "https://api.unsandbox.com/snapshots/', Id, '/unlock" ', + '-H "Content-Type: application/json" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS2" ', + '-H "X-Signature: $SIG2" ', + '-H "X-Sudo-OTP: $OTP" ', + '-d "$BODY" | jq .; ', + 'echo -e "\\x1b[32mSnapshot unlocked\\x1b[0m"; fi; ', + 'else echo "$BODY_RESP" | jq .; fi' + ], Cmd), + shell(Cmd). + +handle_snapshot_cmd(['--restore', Id|_], PublicKey, SecretKey) :- !, + atomic_list_concat([ + 'TS=$(date +%s); ', + 'BODY="{}"; ', + 'SIG=$(echo -n "$TS:POST:/snapshots/', Id, '/restore:$BODY" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'curl -s -X POST "https://api.unsandbox.com/snapshots/', Id, '/restore" ', + '-H "Content-Type: application/json" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS" ', + '-H "X-Signature: $SIG" ', + '-d "$BODY" | jq . && ', + 'echo -e "\\x1b[32mSnapshot restored\\x1b[0m"' + ], Cmd), + shell(Cmd). + +handle_snapshot_cmd(['--clone', Id|Rest], PublicKey, SecretKey) :- !, + parse_snapshot_clone_args(Rest, Type, Name, Ports, Shell), + ( Type = '' + -> write(user_error, '\x1b[31mError: --type required for --clone (session or service)\x1b[0m\n'), + halt(1) + ; true + ), + build_snapshot_clone_body(Type, Name, Ports, Shell, Body), + atomic_list_concat([ + 'TS=$(date +%s); ', + 'BODY=''', Body, '''; ', + 'SIG=$(echo -n "$TS:POST:/snapshots/', Id, '/clone:$BODY" | openssl dgst -sha256 -hmac "', SecretKey, '" | cut -d" " -f2); ', + 'curl -s -X POST "https://api.unsandbox.com/snapshots/', Id, '/clone" ', + '-H "Content-Type: application/json" ', + '-H "Authorization: Bearer ', PublicKey, '" ', + '-H "X-Timestamp: $TS" ', + '-H "X-Signature: $SIG" ', + '-d "$BODY" | jq . && ', + 'echo -e "\\x1b[32mSnapshot cloned\\x1b[0m"' + ], Cmd), + shell(Cmd). + +handle_snapshot_cmd(_, _, _) :- + write(user_error, '\x1b[31mError: Use --list, --info, --delete, --lock, --unlock, --restore, or --clone\x1b[0m\n'), + halt(1). + +parse_snapshot_clone_args([], '', '', '', ''). +parse_snapshot_clone_args(['--type', Type|Rest], Type, Name, Ports, Shell) :- !, + parse_snapshot_clone_args(Rest, _, Name, Ports, Shell). +parse_snapshot_clone_args(['--name', Name|Rest], Type, Name, Ports, Shell) :- !, + parse_snapshot_clone_args(Rest, Type, _, Ports, Shell). +parse_snapshot_clone_args(['--ports', Ports|Rest], Type, Name, Ports, Shell) :- !, + parse_snapshot_clone_args(Rest, Type, Name, _, Shell). +parse_snapshot_clone_args(['--shell', Shell|Rest], Type, Name, Ports, Shell) :- !, + parse_snapshot_clone_args(Rest, Type, Name, Ports, _). +parse_snapshot_clone_args([_|Rest], Type, Name, Ports, Shell) :- + parse_snapshot_clone_args(Rest, Type, Name, Ports, Shell). + +build_snapshot_clone_body(Type, Name, Ports, Shell, Body) :- + atomic_list_concat(['{"type":"', Type, '"'], Base), + ( Name \= '' + -> atomic_list_concat([Base, ',"name":"', Name, '"'], Base2) + ; Base2 = Base + ), + ( Ports \= '' + -> atomic_list_concat([Base2, ',"ports":[', Ports, ']'], Base3) + ; Base3 = Base2 + ), + ( Shell \= '' + -> atomic_list_concat([Base3, ',"shell":"', Shell, '"'], Base4) + ; Base4 = Base3 + ), + atomic_list_concat([Base4, '}'], Body). diff --git a/clients/prolog/tests/test_un.sh b/clients/prolog/tests/test_un.sh new file mode 100755 index 0000000..150a51f --- /dev/null +++ b/clients/prolog/tests/test_un.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Test suite for Prolog Unsandbox SDK +# Run: bash tests/test_un.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_DIR="$SCRIPT_DIR/../sync/src" +SOURCE="$SDK_DIR/un.pro" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +TESTS_RUN=0 +TESTS_PASSED=0 + +# Test helper +test_that() { + local description="$1" + local test_cmd="$2" + TESTS_RUN=$((TESTS_RUN + 1)) + + if eval "$test_cmd" >/dev/null 2>&1; then + echo -e "[${GREEN}PASS${NC}] $description" + TESTS_PASSED=$((TESTS_PASSED + 1)) + return 0 + else + echo -e "[${RED}FAIL${NC}] $description" + return 1 + fi +} + +# Test source file exists +echo "" +echo "=== Source File ===" +test_that "Source file exists" "[ -f '$SOURCE' ]" + +echo "" +echo "=== Command Handlers ===" +test_that "Session handler defined" "grep -q 'handle_session' '$SOURCE'" +test_that "Service handler defined" "grep -q 'handle_service' '$SOURCE'" +test_that "Snapshot handler defined" "grep -q 'handle_snapshot' '$SOURCE'" +test_that "Image handler defined" "grep -q 'handle_image' '$SOURCE'" +test_that "Languages handler defined" "grep -q 'handle_languages' '$SOURCE'" +test_that "Key handler defined" "grep -q 'handle_key' '$SOURCE'" + +echo "" +echo "=== Snapshot Operations ===" +test_that "Snapshot list implemented" "grep -q \"handle_snapshot_cmd.*--list\" '$SOURCE'" +test_that "Snapshot info implemented" "grep -q \"handle_snapshot_cmd.*--info\" '$SOURCE'" +test_that "Snapshot delete implemented" "grep -q \"handle_snapshot_cmd.*--delete\" '$SOURCE'" +test_that "Snapshot lock implemented" "grep -q \"handle_snapshot_cmd.*--lock\" '$SOURCE'" +test_that "Snapshot unlock implemented" "grep -q \"handle_snapshot_cmd.*--unlock\" '$SOURCE'" +test_that "Snapshot restore implemented" "grep -q \"handle_snapshot_cmd.*--restore\" '$SOURCE'" +test_that "Snapshot clone implemented" "grep -q \"handle_snapshot_cmd.*--clone\" '$SOURCE'" + +echo "" +echo "=== Snapshot Clone Helper ===" +test_that "Clone args parser defined" "grep -q 'parse_snapshot_clone_args' '$SOURCE'" +test_that "Clone body builder defined" "grep -q 'build_snapshot_clone_body' '$SOURCE'" + +echo "" +echo "=== Language Detection ===" +test_that "Python detection" "grep -q \"ext_lang.*py.*python\" '$SOURCE'" +test_that "JavaScript detection" "grep -q \"ext_lang.*js.*javascript\" '$SOURCE'" +test_that "Julia detection" "grep -q \"ext_lang.*jl.*julia\" '$SOURCE'" +test_that "R detection" "grep -q \"ext_lang.*\\.r.*r\" '$SOURCE'" +test_that "Fortran detection" "grep -q \"ext_lang.*f90.*fortran\" '$SOURCE'" +test_that "COBOL detection" "grep -q \"ext_lang.*cob.*cobol\" '$SOURCE'" +test_that "Prolog detection" "grep -q \"ext_lang.*pro.*prolog\" '$SOURCE'" + +echo "" +echo "=== HMAC Authentication ===" +test_that "Uses openssl for HMAC" "grep -q 'openssl dgst -sha256 -hmac' '$SOURCE'" +test_that "Has X-Signature header" "grep -q 'X-Signature' '$SOURCE'" +test_that "Has X-Timestamp header" "grep -q 'X-Timestamp' '$SOURCE'" + +echo "" +echo "=== Sudo OTP Handling ===" +test_that "Handles 428 response" "grep -q '428' '$SOURCE'" +test_that "Has X-Sudo-OTP header" "grep -q 'X-Sudo-OTP' '$SOURCE'" + +echo "" +echo "=== Constants ===" +test_that "Portal base defined" "grep -q 'portal_base' '$SOURCE'" +test_that "Languages cache TTL defined" "grep -q 'languages_cache_ttl' '$SOURCE'" + +echo "" +echo "=== Summary ===" +echo "Tests passed: $TESTS_PASSED / $TESTS_RUN" + +if [ $TESTS_PASSED -eq $TESTS_RUN ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi diff --git a/clients/python/sync/src/un.py b/clients/python/sync/src/un.py index 74e2fd4..8dc5125 100644 --- a/clients/python/sync/src/un.py +++ b/clients/python/sync/src/un.py @@ -6,7 +6,7 @@ unsandbox.com Python SDK (Synchronous) Library Usage: from un import ( - # Execution + # Execution (8) execute_code, execute_async, get_job, @@ -15,7 +15,7 @@ Library Usage: list_jobs, get_languages, detect_language, - # Sessions + # Sessions (9) list_sessions, get_session, create_session, @@ -25,7 +25,7 @@ Library Usage: boost_session, unboost_session, shell_session, - # Services + # Services (17) list_services, create_service, get_service, @@ -44,18 +44,42 @@ Library Usage: export_service_env, redeploy_service, execute_in_service, - # Snapshots + resize_service, + # Snapshots (9) session_snapshot, service_snapshot, list_snapshots, + get_snapshot, restore_snapshot, delete_snapshot, lock_snapshot, unlock_snapshot, clone_snapshot, + # Images (13) + image_publish, + list_images, + get_image, + delete_image, + lock_image, + unlock_image, + set_image_visibility, + grant_image_access, + revoke_image_access, + list_image_trusted, + transfer_image, + spawn_from_image, + clone_image, + # PaaS Logs (2) + logs_fetch, + logs_stream, # Key validation validate_keys, - # Image generation + # Utilities + version, + health_check, + last_error, + hmac_sign, + # Image generation (AI) image, ) @@ -777,7 +801,7 @@ def list_snapshots( secret_key: Optional[str] = None, ) -> List[Dict[str, Any]]: """ - List all snapshots (NEW). + List all snapshots. Args: public_key: Optional API key @@ -796,6 +820,39 @@ def list_snapshots( return response.get("snapshots", []) +def get_snapshot( + snapshot_id: str, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get details of a specific snapshot. + + Args: + snapshot_id: Snapshot ID to get details for + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Snapshot details dict 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 + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request("GET", f"/snapshots/{snapshot_id}", public_key, secret_key) + + def restore_snapshot( snapshot_id: str, public_key: Optional[str] = None, @@ -1715,6 +1772,39 @@ def execute_in_service( ) +def resize_service( + service_id: str, + vcpu: int, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Resize a service's vCPU allocation. + + Args: + service_id: Service ID to resize + vcpu: Number of vCPUs (1-8 typically) + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Response dict with updated service info + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + return _make_request( + "PATCH", + f"/services/{service_id}", + public_key, + secret_key, + {"vcpu": vcpu}, + ) + + # ============================================================================= # Additional Snapshot Functions # ============================================================================= @@ -2269,6 +2359,177 @@ def image( return _make_request("POST", "/image", public_key, secret_key, payload) +# ============================================================================= +# PaaS Logs Functions +# ============================================================================= + +_last_error: Optional[str] = None + + +def logs_fetch( + source: str = "all", + lines: int = 100, + since: str = "5m", + grep: Optional[str] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> Dict[str, Any]: + """ + Fetch batch logs from the PaaS platform. + + Args: + source: Log source - "all", "api", "portal", "pool/cammy", "pool/ai" + lines: Number of lines to fetch (1-10000) + since: Time window - "1m", "5m", "1h", "1d" + grep: Optional filter pattern + public_key: Optional API key + secret_key: Optional API secret + + Returns: + Dict with log entries + + Raises: + requests.RequestException: Network errors + ValueError: Invalid response format + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + params = f"?source={source}&lines={lines}&since={since}" + if grep: + params += f"&grep={grep}" + return _make_request("GET", f"/logs{params}", public_key, secret_key) + + +def logs_stream( + source: str = "all", + grep: Optional[str] = None, + callback=None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, +) -> None: + """ + Stream logs via Server-Sent Events. + + Blocks until interrupted or server closes connection. + + Args: + source: Log source - "all", "api", "portal", "pool/cammy", "pool/ai" + grep: Optional filter pattern + callback: Function called for each log line (signature: callback(source, line)) + public_key: Optional API key + secret_key: Optional API secret + + Raises: + requests.RequestException: Network errors + CredentialsError: Missing credentials + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + + url = f"{API_BASE}/logs/stream?source={source}" + if grep: + url += f"&grep={grep}" + + timestamp = int(time.time()) + path = f"/logs/stream?source={source}" + if grep: + path += f"&grep={grep}" + + signature = _sign_request(secret_key, timestamp, "GET", path, None) + + headers = { + "Authorization": f"Bearer {public_key}", + "X-Timestamp": str(timestamp), + "X-Signature": signature, + "Accept": "text/event-stream", + } + + with requests.get(url, headers=headers, stream=True, timeout=None) as response: + response.raise_for_status() + for line in response.iter_lines(): + if line: + decoded = line.decode("utf-8") + if decoded.startswith("data: "): + data = decoded[6:] + try: + entry = json.loads(data) + if callback: + callback(entry.get("source", source), entry.get("line", data)) + else: + print(f"[{entry.get('source', source)}] {entry.get('line', data)}") + except json.JSONDecodeError: + if callback: + callback(source, data) + else: + print(f"[{source}] {data}") + + +# ============================================================================= +# Utility Functions +# ============================================================================= + +SDK_VERSION = "4.2.0" + + +def version() -> str: + """ + Get the SDK version string. + + Returns: + Version string (e.g., "4.2.0") + """ + return SDK_VERSION + + +def health_check() -> bool: + """ + Check if the API is healthy and responding. + + Returns: + True if API is healthy, False otherwise + """ + global _last_error + try: + response = requests.get(f"{API_BASE}/health", timeout=10) + if response.status_code == 200: + return True + _last_error = f"Health check failed: HTTP {response.status_code}" + return False + except Exception as e: + _last_error = f"Health check failed: {str(e)}" + return False + + +def last_error() -> Optional[str]: + """ + Get the last error message. + + Returns: + Last error message or None + """ + return _last_error + + +def hmac_sign(secret_key: str, message: str) -> str: + """ + Sign a message using HMAC-SHA256. + + This is the underlying signing function used for request authentication. + Exposed for testing and debugging purposes. + + Args: + secret_key: The secret key for signing + message: The message to sign + + Returns: + 64-character lowercase hex string + """ + return hmac.new( + secret_key.encode(), + message.encode(), + hashlib.sha256, + ).hexdigest() + + # ============================================================================= # CLI Implementation # ============================================================================= diff --git a/clients/python/sync/tests/test_new_functions.py b/clients/python/sync/tests/test_new_functions.py new file mode 100644 index 0000000..6a42755 --- /dev/null +++ b/clients/python/sync/tests/test_new_functions.py @@ -0,0 +1,294 @@ +"""Tests for new SDK functions (feature parity with C implementation)""" + +import pytest +from un import ( + # Execution + execute_code, + execute_async, + get_job, + wait_for_job, + cancel_job, + list_jobs, + get_languages, + detect_language, + # Sessions + list_sessions, + get_session, + create_session, + delete_session, + freeze_session, + unfreeze_session, + boost_session, + unboost_session, + shell_session, + # Services + list_services, + create_service, + get_service, + update_service, + delete_service, + freeze_service, + unfreeze_service, + lock_service, + unlock_service, + set_unfreeze_on_demand, + get_service_logs, + get_service_env, + set_service_env, + delete_service_env, + export_service_env, + redeploy_service, + execute_in_service, + resize_service, + # Snapshots + session_snapshot, + service_snapshot, + list_snapshots, + get_snapshot, + restore_snapshot, + delete_snapshot, + lock_snapshot, + unlock_snapshot, + clone_snapshot, + # Images + image_publish, + list_images, + get_image, + delete_image, + lock_image, + unlock_image, + set_image_visibility, + grant_image_access, + revoke_image_access, + list_image_trusted, + transfer_image, + spawn_from_image, + clone_image, + # PaaS Logs + logs_fetch, + logs_stream, + # Utilities + version, + health_check, + last_error, + hmac_sign, + validate_keys, +) + + +class TestUtilityFunctions: + """Test utility functions that don't require API calls""" + + def test_version_returns_string(self): + """Test version function returns a string""" + v = version() + assert isinstance(v, str) + assert len(v) > 0 + # Should be semantic version format + parts = v.split('.') + assert len(parts) >= 2 + + def test_hmac_sign_basic(self): + """Test HMAC signing produces correct format""" + signature = hmac_sign("secret_key", "message_to_sign") + assert isinstance(signature, str) + assert len(signature) == 64 + # Should be lowercase hex + assert all(c in '0123456789abcdef' for c in signature) + + def test_hmac_sign_deterministic(self): + """Test HMAC signing is deterministic""" + sig1 = hmac_sign("secret", "message") + sig2 = hmac_sign("secret", "message") + assert sig1 == sig2 + + def test_hmac_sign_different_secrets(self): + """Test different secrets produce different signatures""" + sig1 = hmac_sign("secret1", "message") + sig2 = hmac_sign("secret2", "message") + assert sig1 != sig2 + + def test_hmac_sign_different_messages(self): + """Test different messages produce different signatures""" + sig1 = hmac_sign("secret", "message1") + sig2 = hmac_sign("secret", "message2") + assert sig1 != sig2 + + def test_hmac_sign_known_value(self): + """Test HMAC signing matches known value""" + # This is a known test vector + signature = hmac_sign("test_secret", "1234567890:POST:/execute:") + assert len(signature) == 64 + + def test_last_error_initial_none(self): + """Test last_error is None initially or after success""" + # Note: This may not be None if previous tests failed + error = last_error() + assert error is None or isinstance(error, str) + + +class TestFunctionExports: + """Test that all required functions are exported""" + + def test_execution_functions_exported(self): + """Test execution functions are exported""" + assert callable(execute_code) + assert callable(execute_async) + assert callable(get_job) + assert callable(wait_for_job) + assert callable(cancel_job) + assert callable(list_jobs) + assert callable(get_languages) + assert callable(detect_language) + + def test_session_functions_exported(self): + """Test session functions are exported""" + assert callable(list_sessions) + assert callable(get_session) + assert callable(create_session) + assert callable(delete_session) + assert callable(freeze_session) + assert callable(unfreeze_session) + assert callable(boost_session) + assert callable(unboost_session) + assert callable(shell_session) + + def test_service_functions_exported(self): + """Test service functions are exported""" + assert callable(list_services) + assert callable(create_service) + assert callable(get_service) + assert callable(update_service) + assert callable(delete_service) + assert callable(freeze_service) + assert callable(unfreeze_service) + assert callable(lock_service) + assert callable(unlock_service) + assert callable(set_unfreeze_on_demand) + assert callable(get_service_logs) + assert callable(get_service_env) + assert callable(set_service_env) + assert callable(delete_service_env) + assert callable(export_service_env) + assert callable(redeploy_service) + assert callable(execute_in_service) + assert callable(resize_service) # New function + + def test_snapshot_functions_exported(self): + """Test snapshot functions are exported""" + assert callable(session_snapshot) + assert callable(service_snapshot) + assert callable(list_snapshots) + assert callable(get_snapshot) # New function + assert callable(restore_snapshot) + assert callable(delete_snapshot) + assert callable(lock_snapshot) + assert callable(unlock_snapshot) + assert callable(clone_snapshot) + + def test_image_functions_exported(self): + """Test image functions are exported""" + assert callable(image_publish) + assert callable(list_images) + assert callable(get_image) + assert callable(delete_image) + assert callable(lock_image) + assert callable(unlock_image) + assert callable(set_image_visibility) + assert callable(grant_image_access) + assert callable(revoke_image_access) + assert callable(list_image_trusted) + assert callable(transfer_image) + assert callable(spawn_from_image) + assert callable(clone_image) + + def test_logs_functions_exported(self): + """Test PaaS logs functions are exported""" + assert callable(logs_fetch) # New function + assert callable(logs_stream) # New function + + def test_utility_functions_exported(self): + """Test utility functions are exported""" + assert callable(validate_keys) + assert callable(version) # New function + assert callable(health_check) # New function + assert callable(last_error) # New function + assert callable(hmac_sign) # New function + + +class TestLanguageDetection: + """Test language detection (subset, full tests in test_language_detection.py)""" + + def test_common_extensions(self): + """Test common file extensions""" + assert detect_language("test.py") == "python" + assert detect_language("test.js") == "javascript" + assert detect_language("test.rb") == "ruby" + assert detect_language("test.go") == "go" + assert detect_language("test.rs") == "rust" + + def test_unknown_returns_none(self): + """Test unknown extensions return None""" + assert detect_language("test.unknown") is None + assert detect_language("Makefile") is None + + +# Functional tests require API credentials - mark as skip without credentials +@pytest.mark.skipif( + not all([ + __import__('os').environ.get('UNSANDBOX_PUBLIC_KEY'), + __import__('os').environ.get('UNSANDBOX_SECRET_KEY'), + ]), + reason="UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY required" +) +class TestFunctionalAPI: + """Functional tests that require real API credentials""" + + def test_health_check(self): + """Test API health check""" + result = health_check() + assert isinstance(result, bool) + # API should be healthy + assert result is True + + def test_validate_keys(self): + """Test key validation""" + result = validate_keys() + assert isinstance(result, dict) + assert 'valid' in result or 'tier' in result + + def test_get_languages(self): + """Test getting supported languages""" + languages = get_languages() + assert isinstance(languages, list) + assert len(languages) > 0 + assert 'python' in languages + assert 'javascript' in languages + + def test_list_sessions(self): + """Test listing sessions""" + sessions = list_sessions() + assert isinstance(sessions, list) + + def test_list_services(self): + """Test listing services""" + services = list_services() + assert isinstance(services, list) + + def test_list_snapshots(self): + """Test listing snapshots""" + snapshots = list_snapshots() + assert isinstance(snapshots, list) + + def test_list_images(self): + """Test listing images""" + images = list_images() + assert isinstance(images, list) + + def test_execute_code(self): + """Test code execution""" + result = execute_code("python", "print('hello world')") + assert isinstance(result, dict) + assert result.get('status') in ['completed', 'pending'] + if result.get('status') == 'completed': + assert 'hello world' in result.get('stdout', '') diff --git a/clients/r/sync/src/un.r b/clients/r/sync/src/un.r index ba4f6b6..0170a24 100644 --- a/clients/r/sync/src/un.r +++ b/clients/r/sync/src/un.r @@ -666,6 +666,843 @@ languages <- function(public_key = NULL, secret_key = NULL) { return(result) } +# ============================================================================= +# Session Library Functions +# ============================================================================= + +#' List Sessions +#' +#' Retrieves a list of all active sessions. +#' +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list containing sessions +#' @export +session_list <- function(public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request("/sessions", creds$public_key, creds$secret_key) + return(result$sessions %||% list()) +} + +#' Get Session Details +#' +#' @param session_id Session ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Session details +#' @export +session_get <- function(session_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + return(api_request(paste0("/sessions/", session_id), creds$public_key, creds$secret_key)) +} + +#' Create Session +#' +#' @param shell Shell type (default: "bash") +#' @param network Network mode (default: "zerotrust") +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Created session details +#' @export +session_create <- function(shell = "bash", network = "zerotrust", + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- list(shell = shell) + if (network != "zerotrust") payload$network <- network + return(api_request("/sessions", creds$public_key, creds$secret_key, + method = "POST", data = payload)) +} + +#' Destroy Session +#' +#' @param session_id Session ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +session_destroy <- function(session_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/sessions/", session_id), creds$public_key, creds$secret_key, + method = "DELETE") + return(TRUE) +} + +#' Freeze Session +#' +#' @param session_id Session ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +session_freeze <- function(session_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/sessions/", session_id, "/freeze"), creds$public_key, creds$secret_key, + method = "POST") + return(TRUE) +} + +#' Unfreeze Session +#' +#' @param session_id Session ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +session_unfreeze <- function(session_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/sessions/", session_id, "/unfreeze"), creds$public_key, creds$secret_key, + method = "POST") + return(TRUE) +} + +#' Boost Session +#' +#' @param session_id Session ID +#' @param vcpu vCPU count +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +session_boost <- function(session_id, vcpu, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/sessions/", session_id), creds$public_key, creds$secret_key, + method = "PATCH", data = list(vcpu = vcpu)) + return(TRUE) +} + +#' Unboost Session +#' +#' @param session_id Session ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +session_unboost <- function(session_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/sessions/", session_id), creds$public_key, creds$secret_key, + method = "PATCH", data = list(vcpu = 1)) + return(TRUE) +} + +#' Execute Command in Session +#' +#' @param session_id Session ID +#' @param command Command to execute +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Execution result +#' @export +session_execute <- function(session_id, command, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + return(api_request(paste0("/sessions/", session_id, "/execute"), creds$public_key, creds$secret_key, + method = "POST", data = list(command = command))) +} + +# ============================================================================= +# Service Library Functions +# ============================================================================= + +#' List Services +#' +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list of services +#' @export +service_list <- function(public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request("/services", creds$public_key, creds$secret_key) + return(result$services %||% list()) +} + +#' Get Service Details +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Service details +#' @export +service_get <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + return(api_request(paste0("/services/", service_id), creds$public_key, creds$secret_key)) +} + +#' Create Service +#' +#' @param name Service name +#' @param ports Comma-separated ports +#' @param domains Comma-separated domains +#' @param bootstrap Bootstrap command +#' @param network Network mode +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Created service details +#' @export +service_create <- function(name, ports = NULL, domains = NULL, bootstrap = NULL, + network = "semitrusted", public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- list(name = name) + if (!is.null(ports)) payload$ports <- as.integer(strsplit(ports, ",")[[1]]) + if (!is.null(domains)) payload$domains <- strsplit(domains, ",")[[1]] + if (!is.null(bootstrap)) payload$bootstrap <- bootstrap + if (network != "semitrusted") payload$network <- network + return(api_request("/services", creds$public_key, creds$secret_key, + method = "POST", data = payload)) +} + +#' Destroy Service +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_destroy <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request_with_sudo(paste0("/services/", service_id), creds$public_key, creds$secret_key, + method = "DELETE") + return(TRUE) +} + +#' Freeze Service +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_freeze <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/services/", service_id, "/freeze"), creds$public_key, creds$secret_key, + method = "POST") + return(TRUE) +} + +#' Unfreeze Service +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_unfreeze <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/services/", service_id, "/unfreeze"), creds$public_key, creds$secret_key, + method = "POST") + return(TRUE) +} + +#' Lock Service +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_lock <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/services/", service_id, "/lock"), creds$public_key, creds$secret_key, + method = "POST") + return(TRUE) +} + +#' Unlock Service +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_unlock <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request_with_sudo(paste0("/services/", service_id, "/unlock"), creds$public_key, creds$secret_key, + method = "POST", data = list()) + return(TRUE) +} + +#' Set Unfreeze On Demand +#' +#' @param service_id Service ID +#' @param enabled TRUE to enable +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_set_unfreeze_on_demand <- function(service_id, enabled, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/services/", service_id), creds$public_key, creds$secret_key, + method = "PATCH", data = list(unfreeze_on_demand = enabled)) + return(TRUE) +} + +#' Redeploy Service +#' +#' @param service_id Service ID +#' @param bootstrap New bootstrap command (optional) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_redeploy <- function(service_id, bootstrap = NULL, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- if (!is.null(bootstrap)) list(bootstrap = bootstrap) else list() + api_request(paste0("/services/", service_id, "/redeploy"), creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(TRUE) +} + +#' Get Service Logs +#' +#' @param service_id Service ID +#' @param all_logs Get all logs (default: FALSE) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Logs string +#' @export +service_logs <- function(service_id, all_logs = FALSE, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + endpoint <- if (all_logs) paste0("/services/", service_id, "/logs?all=true") + else paste0("/services/", service_id, "/logs") + result <- api_request(endpoint, creds$public_key, creds$secret_key) + return(result$logs %||% "") +} + +#' Execute Command in Service +#' +#' @param service_id Service ID +#' @param command Command to execute +#' @param timeout_ms Timeout in milliseconds +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Execution result +#' @export +service_execute <- function(service_id, command, timeout_ms = 30000, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + return(api_request(paste0("/services/", service_id, "/execute"), creds$public_key, creds$secret_key, + method = "POST", data = list(command = command, timeout_ms = timeout_ms))) +} + +#' Get Service Environment +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Environment content +#' @export +service_env_get <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request(paste0("/services/", service_id, "/env"), creds$public_key, creds$secret_key) + return(result$env %||% "") +} + +#' Set Service Environment +#' +#' @param service_id Service ID +#' @param env_content Environment content +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_env_set <- function(service_id, env_content, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/services/", service_id, "/env"), creds$public_key, creds$secret_key, + method = "POST", data = list(env = env_content)) + return(TRUE) +} + +#' Delete Service Environment +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_env_delete <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/services/", service_id, "/env"), creds$public_key, creds$secret_key, + method = "DELETE") + return(TRUE) +} + +#' Export Service Environment +#' +#' @param service_id Service ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Exported environment +#' @export +service_env_export <- function(service_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request(paste0("/services/", service_id, "/env/export"), creds$public_key, creds$secret_key) + return(result$export %||% "") +} + +#' Resize Service +#' +#' @param service_id Service ID +#' @param vcpu vCPU count +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +service_resize <- function(service_id, vcpu, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/services/", service_id), creds$public_key, creds$secret_key, + method = "PATCH", data = list(vcpu = vcpu)) + return(TRUE) +} + +# ============================================================================= +# Snapshot Library Functions +# ============================================================================= + +#' List Snapshots +#' +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list of snapshots +#' @export +snapshot_list <- function(public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request("/snapshots", creds$public_key, creds$secret_key) + return(result$snapshots %||% list()) +} + +#' Get Snapshot Details +#' +#' @param snapshot_id Snapshot ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Snapshot details +#' @export +snapshot_get <- function(snapshot_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + return(api_request(paste0("/snapshots/", snapshot_id), creds$public_key, creds$secret_key)) +} + +#' Snapshot Session +#' +#' @param session_id Session ID +#' @param name Snapshot name (optional) +#' @param hot Hot snapshot (default: FALSE) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Snapshot ID +#' @export +snapshot_session <- function(session_id, name = NULL, hot = FALSE, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- list() + if (!is.null(name)) payload$name <- name + if (hot) payload$hot <- TRUE + result <- api_request(paste0("/sessions/", session_id, "/snapshot"), creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(result$id %||% "") +} + +#' Snapshot Service +#' +#' @param service_id Service ID +#' @param name Snapshot name (optional) +#' @param hot Hot snapshot (default: FALSE) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Snapshot ID +#' @export +snapshot_service <- function(service_id, name = NULL, hot = FALSE, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- list() + if (!is.null(name)) payload$name <- name + if (hot) payload$hot <- TRUE + result <- api_request(paste0("/services/", service_id, "/snapshot"), creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(result$id %||% "") +} + +#' Restore Snapshot +#' +#' @param snapshot_id Snapshot ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +snapshot_restore <- function(snapshot_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/snapshots/", snapshot_id, "/restore"), creds$public_key, creds$secret_key, + method = "POST", data = list()) + return(TRUE) +} + +#' Delete Snapshot +#' +#' @param snapshot_id Snapshot ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +snapshot_delete <- function(snapshot_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request_with_sudo(paste0("/snapshots/", snapshot_id), creds$public_key, creds$secret_key, + method = "DELETE") + return(TRUE) +} + +#' Lock Snapshot +#' +#' @param snapshot_id Snapshot ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +snapshot_lock <- function(snapshot_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/snapshots/", snapshot_id, "/lock"), creds$public_key, creds$secret_key, + method = "POST") + return(TRUE) +} + +#' Unlock Snapshot +#' +#' @param snapshot_id Snapshot ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +snapshot_unlock <- function(snapshot_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request_with_sudo(paste0("/snapshots/", snapshot_id, "/unlock"), creds$public_key, creds$secret_key, + method = "POST", data = list()) + return(TRUE) +} + +#' Clone Snapshot +#' +#' @param snapshot_id Snapshot ID +#' @param clone_type "session" or "service" +#' @param name Name for cloned resource (optional) +#' @param ports Ports for service (optional) +#' @param shell Shell for session (optional) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Cloned resource ID +#' @export +snapshot_clone <- function(snapshot_id, clone_type, name = NULL, ports = NULL, shell = NULL, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- list(type = clone_type) + if (!is.null(name)) payload$name <- name + if (!is.null(ports)) payload$ports <- as.integer(strsplit(ports, ",")[[1]]) + if (!is.null(shell)) payload$shell <- shell + result <- api_request(paste0("/snapshots/", snapshot_id, "/clone"), creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(result$id %||% "") +} + +# ============================================================================= +# Image Library Functions +# ============================================================================= + +#' List Images +#' +#' @param filter Filter type (optional: "owned", "shared", "public") +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list of images +#' @export +image_list <- function(filter = NULL, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + endpoint <- if (!is.null(filter)) paste0("/images?filter=", filter) else "/images" + result <- api_request(endpoint, creds$public_key, creds$secret_key) + return(result$images %||% list()) +} + +#' Get Image Details +#' +#' @param image_id Image ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Image details +#' @export +image_get <- function(image_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + return(api_request(paste0("/images/", image_id), creds$public_key, creds$secret_key)) +} + +#' Publish Image +#' +#' @param source_type "service" or "snapshot" +#' @param source_id Source ID +#' @param name Image name (optional) +#' @param description Image description (optional) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Image ID +#' @export +image_publish <- function(source_type, source_id, name = NULL, description = NULL, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- list(source_type = source_type, source_id = source_id) + if (!is.null(name)) payload$name <- name + if (!is.null(description)) payload$description <- description + result <- api_request("/images/publish", creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(result$id %||% "") +} + +#' Delete Image +#' +#' @param image_id Image ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +image_delete <- function(image_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request_with_sudo(paste0("/images/", image_id), creds$public_key, creds$secret_key, + method = "DELETE") + return(TRUE) +} + +#' Lock Image +#' +#' @param image_id Image ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +image_lock <- function(image_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/images/", image_id, "/lock"), creds$public_key, creds$secret_key, + method = "POST") + return(TRUE) +} + +#' Unlock Image +#' +#' @param image_id Image ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +image_unlock <- function(image_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request_with_sudo(paste0("/images/", image_id, "/unlock"), creds$public_key, creds$secret_key, + method = "POST", data = list()) + return(TRUE) +} + +#' Set Image Visibility +#' +#' @param image_id Image ID +#' @param visibility "private", "unlisted", or "public" +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +image_set_visibility <- function(image_id, visibility, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/images/", image_id, "/visibility"), creds$public_key, creds$secret_key, + method = "POST", data = list(visibility = visibility)) + return(TRUE) +} + +#' Grant Image Access +#' +#' @param image_id Image ID +#' @param trusted_api_key API key to grant access +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +image_grant_access <- function(image_id, trusted_api_key, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/images/", image_id, "/access"), creds$public_key, creds$secret_key, + method = "POST", data = list(trusted_api_key = trusted_api_key)) + return(TRUE) +} + +#' Revoke Image Access +#' +#' @param image_id Image ID +#' @param trusted_api_key API key to revoke access +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +image_revoke_access <- function(image_id, trusted_api_key, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/images/", image_id, "/access/", trusted_api_key), creds$public_key, creds$secret_key, + method = "DELETE") + return(TRUE) +} + +#' List Trusted Keys for Image +#' +#' @param image_id Image ID +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return List of trusted API keys +#' @export +image_list_trusted <- function(image_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request(paste0("/images/", image_id, "/access"), creds$public_key, creds$secret_key) + return(result$trusted_keys %||% list()) +} + +#' Transfer Image +#' +#' @param image_id Image ID +#' @param to_api_key Target API key +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return TRUE on success +#' @export +image_transfer <- function(image_id, to_api_key, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + api_request(paste0("/images/", image_id, "/transfer"), creds$public_key, creds$secret_key, + method = "POST", data = list(to_api_key = to_api_key)) + return(TRUE) +} + +#' Spawn Service from Image +#' +#' @param image_id Image ID +#' @param name Service name (optional) +#' @param ports Comma-separated ports (optional) +#' @param bootstrap Bootstrap command (optional) +#' @param network Network mode (optional) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Service ID +#' @export +image_spawn <- function(image_id, name = NULL, ports = NULL, bootstrap = NULL, network = NULL, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- list() + if (!is.null(name)) payload$name <- name + if (!is.null(ports)) payload$ports <- as.integer(strsplit(ports, ",")[[1]]) + if (!is.null(bootstrap)) payload$bootstrap <- bootstrap + if (!is.null(network)) payload$network <- network + result <- api_request(paste0("/images/", image_id, "/spawn"), creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(result$id %||% "") +} + +#' Clone Image +#' +#' @param image_id Image ID +#' @param name Clone name (optional) +#' @param description Clone description (optional) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Cloned image ID +#' @export +image_clone <- function(image_id, name = NULL, description = NULL, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + payload <- list() + if (!is.null(name)) payload$name <- name + if (!is.null(description)) payload$description <- description + result <- api_request(paste0("/images/", image_id, "/clone"), creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(result$id %||% "") +} + +# ============================================================================= +# PaaS Logs Functions +# ============================================================================= + +#' Fetch PaaS Logs +#' +#' @param source Log source ("all", "api", "portal", "pool/cammy", "pool/ai") +#' @param lines Number of lines +#' @param since Time window ("1m", "5m", "1h", "1d") +#' @param grep Filter pattern (optional) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Logs string +#' @export +logs_fetch <- function(source = "all", lines = 100, since = "1h", grep = NULL, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + endpoint <- sprintf("/logs?source=%s&lines=%d&since=%s", source, lines, since) + if (!is.null(grep)) endpoint <- paste0(endpoint, "&grep=", grep) + result <- api_request(endpoint, creds$public_key, creds$secret_key) + return(result$logs %||% "") +} + +# ============================================================================= +# Utility Functions +# ============================================================================= + +#' Validate API Keys +#' +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Validation result +#' @export +validate_keys <- function(public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + timestamp <- as.integer(Sys.time()) + body <- "{}" + message <- paste(timestamp, "POST", "/keys/validate", body, sep = ":") + signature <- compute_signature(creds$secret_key, message) + + response <- tryCatch({ + POST( + paste0(PORTAL_BASE, "/keys/validate"), + add_headers( + "Authorization" = paste("Bearer", creds$public_key), + "X-Timestamp" = as.character(timestamp), + "X-Signature" = signature, + "Content-Type" = "application/json" + ), + body = body, + encode = "raw" + ) + }, error = function(e) { + return(list(valid = FALSE, error = conditionMessage(e))) + }) + + if (inherits(response, "list")) return(response) + return(content(response, as = "parsed")) +} + +#' Health Check +#' +#' @return TRUE if API is healthy +#' @export +health_check <- function() { + tryCatch({ + response <- GET(paste0(API_BASE, "/health")) + return(status_code(response) == 200) + }, error = function(e) { + return(FALSE) + }) +} + +#' Get SDK Version +#' +#' @return Version string +#' @export +sdk_version <- function() { + return("1.0.0") +} + +#' HMAC Sign +#' +#' @param secret_key Secret key +#' @param message Message to sign +#' @return HMAC-SHA256 signature +#' @export +hmac_sign <- function(secret_key, message) { + return(digest(message, algo = "sha256", key = secret_key, serialize = FALSE, hmac = TRUE)) +} + +# Null coalescing operator +`%||%` <- function(a, b) if (is.null(a)) b else a + # ============================================================================= # Client Class (R6) # ============================================================================= diff --git a/clients/r/tests/test_un.r b/clients/r/tests/test_un.r new file mode 100644 index 0000000..a422dfd --- /dev/null +++ b/clients/r/tests/test_un.r @@ -0,0 +1,215 @@ +#!/usr/bin/env Rscript +# Test suite for R Unsandbox SDK +# Run: Rscript tests/test_un.r + +# Load the SDK +source(file.path(dirname(sys.frame(1)$ofile), "..", "sync", "src", "un.r")) + +# Test helper +test_that <- function(description, test_expr) { + result <- tryCatch({ + test_expr + TRUE + }, error = function(e) { + FALSE + }) + status <- if (result) "\033[32mPASS\033[0m" else "\033[31mFAIL\033[0m" + cat(sprintf("[%s] %s\n", status, description)) + return(result) +} + +all_passed <- TRUE + +# Test Constants +cat("\n=== Constants ===\n") +all_passed <- all_passed && test_that("API_BASE is correct", { + stopifnot(API_BASE == "https://api.unsandbox.com") +}) + +all_passed <- all_passed && test_that("PORTAL_BASE is correct", { + stopifnot(PORTAL_BASE == "https://unsandbox.com") +}) + +# Test Language Detection +cat("\n=== Language Detection ===\n") +all_passed <- all_passed && test_that("detect Python", { + stopifnot(detect_language("test.py") == "python") +}) + +all_passed <- all_passed && test_that("detect JavaScript", { + stopifnot(detect_language("test.js") == "javascript") +}) + +all_passed <- all_passed && test_that("detect Ruby", { + stopifnot(detect_language("test.rb") == "ruby") +}) + +all_passed <- all_passed && test_that("detect Julia", { + stopifnot(detect_language("test.jl") == "julia") +}) + +all_passed <- all_passed && test_that("detect R", { + stopifnot(detect_language("test.r") == "r") +}) + +all_passed <- all_passed && test_that("detect unknown", { + stopifnot(detect_language("test.xyz") == "unknown") +}) + +# Test HMAC signing +cat("\n=== HMAC Signing ===\n") +all_passed <- all_passed && test_that("hmac_sign returns 64 char hex", { + sig <- hmac_sign("secret", "message") + stopifnot(is.character(sig)) + stopifnot(nchar(sig) == 64) +}) + +all_passed <- all_passed && test_that("hmac_sign is deterministic", { + sig1 <- hmac_sign("secret", "message") + sig2 <- hmac_sign("secret", "message") + stopifnot(sig1 == sig2) +}) + +# Test Version +cat("\n=== Version ===\n") +all_passed <- all_passed && test_that("sdk_version returns string", { + v <- sdk_version() + stopifnot(is.character(v)) + stopifnot(nchar(v) > 0) +}) + +# Test Library Functions Exist +cat("\n=== Library Function Existence ===\n") + +# Execution functions +all_passed <- all_passed && test_that("execute function exists", { + stopifnot(exists("execute") && is.function(execute)) +}) +all_passed <- all_passed && test_that("execute_async function exists", { + stopifnot(exists("execute_async") && is.function(execute_async)) +}) +all_passed <- all_passed && test_that("get_job function exists", { + stopifnot(exists("get_job") && is.function(get_job)) +}) +all_passed <- all_passed && test_that("wait function exists", { + stopifnot(exists("wait") && is.function(wait)) +}) +all_passed <- all_passed && test_that("cancel_job function exists", { + stopifnot(exists("cancel_job") && is.function(cancel_job)) +}) +all_passed <- all_passed && test_that("list_jobs function exists", { + stopifnot(exists("list_jobs") && is.function(list_jobs)) +}) +all_passed <- all_passed && test_that("languages function exists", { + stopifnot(exists("languages") && is.function(languages)) +}) + +# Session functions +all_passed <- all_passed && test_that("session_list function exists", { + stopifnot(exists("session_list") && is.function(session_list)) +}) +all_passed <- all_passed && test_that("session_get function exists", { + stopifnot(exists("session_get") && is.function(session_get)) +}) +all_passed <- all_passed && test_that("session_create function exists", { + stopifnot(exists("session_create") && is.function(session_create)) +}) +all_passed <- all_passed && test_that("session_destroy function exists", { + stopifnot(exists("session_destroy") && is.function(session_destroy)) +}) +all_passed <- all_passed && test_that("session_freeze function exists", { + stopifnot(exists("session_freeze") && is.function(session_freeze)) +}) +all_passed <- all_passed && test_that("session_unfreeze function exists", { + stopifnot(exists("session_unfreeze") && is.function(session_unfreeze)) +}) +all_passed <- all_passed && test_that("session_execute function exists", { + stopifnot(exists("session_execute") && is.function(session_execute)) +}) + +# Service functions +all_passed <- all_passed && test_that("service_list function exists", { + stopifnot(exists("service_list") && is.function(service_list)) +}) +all_passed <- all_passed && test_that("service_get function exists", { + stopifnot(exists("service_get") && is.function(service_get)) +}) +all_passed <- all_passed && test_that("service_create function exists", { + stopifnot(exists("service_create") && is.function(service_create)) +}) +all_passed <- all_passed && test_that("service_destroy function exists", { + stopifnot(exists("service_destroy") && is.function(service_destroy)) +}) +all_passed <- all_passed && test_that("service_env_get function exists", { + stopifnot(exists("service_env_get") && is.function(service_env_get)) +}) +all_passed <- all_passed && test_that("service_env_set function exists", { + stopifnot(exists("service_env_set") && is.function(service_env_set)) +}) +all_passed <- all_passed && test_that("service_env_delete function exists", { + stopifnot(exists("service_env_delete") && is.function(service_env_delete)) +}) + +# Snapshot functions +all_passed <- all_passed && test_that("snapshot_list function exists", { + stopifnot(exists("snapshot_list") && is.function(snapshot_list)) +}) +all_passed <- all_passed && test_that("snapshot_get function exists", { + stopifnot(exists("snapshot_get") && is.function(snapshot_get)) +}) +all_passed <- all_passed && test_that("snapshot_session function exists", { + stopifnot(exists("snapshot_session") && is.function(snapshot_session)) +}) +all_passed <- all_passed && test_that("snapshot_service function exists", { + stopifnot(exists("snapshot_service") && is.function(snapshot_service)) +}) +all_passed <- all_passed && test_that("snapshot_restore function exists", { + stopifnot(exists("snapshot_restore") && is.function(snapshot_restore)) +}) +all_passed <- all_passed && test_that("snapshot_delete function exists", { + stopifnot(exists("snapshot_delete") && is.function(snapshot_delete)) +}) +all_passed <- all_passed && test_that("snapshot_clone function exists", { + stopifnot(exists("snapshot_clone") && is.function(snapshot_clone)) +}) + +# Image functions +all_passed <- all_passed && test_that("image_list function exists", { + stopifnot(exists("image_list") && is.function(image_list)) +}) +all_passed <- all_passed && test_that("image_get function exists", { + stopifnot(exists("image_get") && is.function(image_get)) +}) +all_passed <- all_passed && test_that("image_publish function exists", { + stopifnot(exists("image_publish") && is.function(image_publish)) +}) +all_passed <- all_passed && test_that("image_delete function exists", { + stopifnot(exists("image_delete") && is.function(image_delete)) +}) +all_passed <- all_passed && test_that("image_spawn function exists", { + stopifnot(exists("image_spawn") && is.function(image_spawn)) +}) +all_passed <- all_passed && test_that("image_clone function exists", { + stopifnot(exists("image_clone") && is.function(image_clone)) +}) + +# Utility functions +all_passed <- all_passed && test_that("validate_keys function exists", { + stopifnot(exists("validate_keys") && is.function(validate_keys)) +}) +all_passed <- all_passed && test_that("health_check function exists", { + stopifnot(exists("health_check") && is.function(health_check)) +}) +all_passed <- all_passed && test_that("logs_fetch function exists", { + stopifnot(exists("logs_fetch") && is.function(logs_fetch)) +}) + +# Summary +cat("\n=== Summary ===\n") +if (all_passed) { + cat("\033[32mAll tests passed!\033[0m\n") + quit(status = 0) +} else { + cat("\033[31mSome tests failed!\033[0m\n") + quit(status = 1) +} diff --git a/clients/raku/sync/src/un.raku b/clients/raku/sync/src/un.raku index aa2173c..8c081c0 100644 --- a/clients/raku/sync/src/un.raku +++ b/clients/raku/sync/src/un.raku @@ -712,6 +712,488 @@ sub languages(Str :$public-key, Str :$secret-key) returns Hash is export { return %result; } +# ============================================================================ +# Session Functions +# ============================================================================ + +#| List all sessions for this API key +sub session-list(Str :$public-key, Str :$secret-key) returns Array is export { + my %result = api-request('/sessions', 'GET', :$public-key, :$secret-key); + return %result // []; +} + +#| Get session details by ID +sub session-get(Str $session-id, Str :$public-key, Str :$secret-key) returns Hash is export { + return api-request("/sessions/{$session-id}", 'GET', :$public-key, :$secret-key); +} + +#| Create a new interactive session +sub session-create( + Str :$network-mode = 'zerotrust', + Str :$shell = 'bash', + Str :$public-key, + Str :$secret-key +) returns Hash is export { + my %payload = shell => $shell, network_mode => $network-mode; + return api-request('/sessions', 'POST', %payload, :$public-key, :$secret-key); +} + +#| Destroy a session +sub session-destroy(Str $session-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/sessions/{$session-id}", 'DELETE', :$public-key, :$secret-key); + return True; +} + +#| Freeze a session +sub session-freeze(Str $session-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/sessions/{$session-id}/freeze", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Unfreeze a session +sub session-unfreeze(Str $session-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/sessions/{$session-id}/unfreeze", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Boost session CPU (1-8 vCPUs) +sub session-boost(Str $session-id, Int $vcpu, Str :$public-key, Str :$secret-key) returns Bool is export { + my %payload = vcpu => $vcpu; + api-request("/sessions/{$session-id}/boost", 'POST', %payload, :$public-key, :$secret-key); + return True; +} + +#| Remove CPU boost from session +sub session-unboost(Str $session-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/sessions/{$session-id}/unboost", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Execute command in a session +sub session-execute(Str $session-id, Str $command, Str :$public-key, Str :$secret-key) returns Hash is export { + my %payload = command => $command; + return api-request("/sessions/{$session-id}/execute", 'POST', %payload, :$public-key, :$secret-key); +} + +# ============================================================================ +# Service Functions +# ============================================================================ + +#| List all services for this API key +sub service-list(Str :$public-key, Str :$secret-key) returns Array is export { + my %result = api-request('/services', 'GET', :$public-key, :$secret-key); + return %result // []; +} + +#| Get service details by ID +sub service-get(Str $service-id, Str :$public-key, Str :$secret-key) returns Hash is export { + return api-request("/services/{$service-id}", 'GET', :$public-key, :$secret-key); +} + +#| Create a new service +sub service-create( + Str :$name!, + Str :$ports, + Str :$domains, + Str :$bootstrap, + Str :$network-mode, + Str :$public-key, + Str :$secret-key +) returns Str is export { + my %payload = name => $name; + %payload = $ports.split(',')>>.Int if $ports; + %payload = $domains.split(',') if $domains; + %payload = $bootstrap if $bootstrap; + %payload = $network-mode if $network-mode; + my %result = api-request('/services', 'POST', %payload, :$public-key, :$secret-key); + return %result // ''; +} + +#| Destroy a service +sub service-destroy(Str $service-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/services/{$service-id}", 'DELETE', :$public-key, :$secret-key); + return True; +} + +#| Freeze a service +sub service-freeze(Str $service-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/services/{$service-id}/freeze", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Unfreeze a service +sub service-unfreeze(Str $service-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/services/{$service-id}/unfreeze", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Lock a service (prevent deletion) +sub service-lock(Str $service-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/services/{$service-id}/lock", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Unlock a service +sub service-unlock(Str $service-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/services/{$service-id}/unlock", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Set unfreeze-on-demand for a service +sub service-set-unfreeze-on-demand(Str $service-id, Bool $enabled, Str :$public-key, Str :$secret-key) returns Bool is export { + my %payload = unfreeze_on_demand => $enabled; + api-request("/services/{$service-id}", 'PATCH', %payload, :$public-key, :$secret-key); + return True; +} + +#| Redeploy a service with optional new bootstrap +sub service-redeploy(Str $service-id, Str :$bootstrap, Str :$public-key, Str :$secret-key) returns Bool is export { + my %payload; + %payload = $bootstrap if $bootstrap; + api-request("/services/{$service-id}/redeploy", 'POST', %payload, :$public-key, :$secret-key); + return True; +} + +#| Get service logs +sub service-logs(Str $service-id, Bool :$all = False, Str :$public-key, Str :$secret-key) returns Str is export { + my $endpoint = "/services/{$service-id}/logs"; + $endpoint ~= "?all=true" if $all; + my %result = api-request($endpoint, 'GET', :$public-key, :$secret-key); + return %result // ''; +} + +#| Execute command in a service +sub service-execute(Str $service-id, Str $command, Int :$timeout-ms, Str :$public-key, Str :$secret-key) returns Hash is export { + my %payload = command => $command; + %payload = $timeout-ms if $timeout-ms; + return api-request("/services/{$service-id}/execute", 'POST', %payload, :$public-key, :$secret-key); +} + +#| Get service environment vault +sub service-env-get(Str $service-id, Str :$public-key, Str :$secret-key) returns Str is export { + my %result = api-request("/services/{$service-id}/env", 'GET', :$public-key, :$secret-key); + return %result // ''; +} + +#| Set service environment vault +sub service-env-set(Str $service-id, Str $content, Str :$public-key, Str :$secret-key) returns Bool is export { + my ($pk, $sk) = get-credentials(:$public-key, :$secret-key); + my $url = "{$API_BASE}/services/{$service-id}/env"; + my $timestamp = now.Int; + my $signature = sign-request($sk, $timestamp, 'PUT', "/services/{$service-id}/env", $content); + + my @args = 'curl', '-s', '-X', 'PUT', $url; + @args.append: '-H', 'Content-Type: text/plain'; + @args.append: '-H', "Authorization: Bearer $pk"; + @args.append: '-H', "X-Timestamp: $timestamp"; + @args.append: '-H', "X-Signature: $signature"; + @args.append: '-d', $content; + + my $proc = run |@args, :out, :err; + return $proc.exitcode == 0; +} + +#| Delete service environment vault +sub service-env-delete(Str $service-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/services/{$service-id}/env", 'DELETE', :$public-key, :$secret-key); + return True; +} + +#| Export service environment vault +sub service-env-export(Str $service-id, Str :$public-key, Str :$secret-key) returns Str is export { + my %result = api-request("/services/{$service-id}/env/export", 'POST', :$public-key, :$secret-key); + return %result // ''; +} + +#| Resize service (change vCPU count) +sub service-resize(Str $service-id, Int $vcpu, Str :$public-key, Str :$secret-key) returns Bool is export { + my %payload = vcpu => $vcpu; + api-request("/services/{$service-id}", 'PATCH', %payload, :$public-key, :$secret-key); + return True; +} + +# ============================================================================ +# Snapshot Functions +# ============================================================================ + +#| List all snapshots for this API key +sub snapshot-list(Str :$public-key, Str :$secret-key) returns Array is export { + my %result = api-request('/snapshots', 'GET', :$public-key, :$secret-key); + return %result // []; +} + +#| Get snapshot details by ID +sub snapshot-get(Str $snapshot-id, Str :$public-key, Str :$secret-key) returns Hash is export { + return api-request("/snapshots/{$snapshot-id}", 'GET', :$public-key, :$secret-key); +} + +#| Create snapshot from a session +sub snapshot-session(Str $session-id, Str :$name, Bool :$hot = False, Str :$public-key, Str :$secret-key) returns Str is export { + my %payload = session_id => $session-id, hot => $hot; + %payload = $name if $name; + my %result = api-request('/snapshots', 'POST', %payload, :$public-key, :$secret-key); + return %result // ''; +} + +#| Create snapshot from a service +sub snapshot-service(Str $service-id, Str :$name, Bool :$hot = False, Str :$public-key, Str :$secret-key) returns Str is export { + my %payload = service_id => $service-id, hot => $hot; + %payload = $name if $name; + my %result = api-request('/snapshots', 'POST', %payload, :$public-key, :$secret-key); + return %result // ''; +} + +#| Restore a snapshot +sub snapshot-restore(Str $snapshot-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/snapshots/{$snapshot-id}/restore", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Delete a snapshot +sub snapshot-delete(Str $snapshot-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/snapshots/{$snapshot-id}", 'DELETE', :$public-key, :$secret-key); + return True; +} + +#| Lock a snapshot (prevent deletion) +sub snapshot-lock(Str $snapshot-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/snapshots/{$snapshot-id}/lock", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Unlock a snapshot +sub snapshot-unlock(Str $snapshot-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/snapshots/{$snapshot-id}/unlock", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Clone a snapshot to create a new session or service +sub snapshot-clone( + Str $snapshot-id, + Str :$clone-type!, # "session" or "service" + Str :$name, # name for cloned service + Str :$ports, # ports for cloned service + Str :$shell, # shell for cloned session + Str :$public-key, + Str :$secret-key +) returns Str is export { + my %payload = clone_type => $clone-type; + %payload = $name if $name; + %payload = $ports.split(',')>>.Int if $ports; + %payload = $shell if $shell; + my %result = api-request("/snapshots/{$snapshot-id}/clone", 'POST', %payload, :$public-key, :$secret-key); + return %result // ''; +} + +# ============================================================================ +# Image Functions +# ============================================================================ + +#| List images (filter: owned, shared, public, or all) +sub image-list(Str :$filter, Str :$public-key, Str :$secret-key) returns Array is export { + my $endpoint = '/images'; + $endpoint ~= "?filter={$filter}" if $filter; + my %result = api-request($endpoint, 'GET', :$public-key, :$secret-key); + return %result // []; +} + +#| Get image details by ID +sub image-get(Str $image-id, Str :$public-key, Str :$secret-key) returns Hash is export { + return api-request("/images/{$image-id}", 'GET', :$public-key, :$secret-key); +} + +#| Publish an image from a service or snapshot +sub image-publish( + Str :$source-type!, # "service" or "snapshot" + Str :$source-id!, + Str :$name, + Str :$description, + Str :$public-key, + Str :$secret-key +) returns Str is export { + my %payload = source_type => $source-type, source_id => $source-id; + %payload = $name if $name; + %payload = $description if $description; + my %result = api-request('/images/publish', 'POST', %payload, :$public-key, :$secret-key); + return %result // ''; +} + +#| Delete an image +sub image-delete(Str $image-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/images/{$image-id}", 'DELETE', :$public-key, :$secret-key); + return True; +} + +#| Lock an image (prevent deletion) +sub image-lock(Str $image-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/images/{$image-id}/lock", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Unlock an image +sub image-unlock(Str $image-id, Str :$public-key, Str :$secret-key) returns Bool is export { + api-request("/images/{$image-id}/unlock", 'POST', :$public-key, :$secret-key); + return True; +} + +#| Set image visibility (private, unlisted, public) +sub image-set-visibility(Str $image-id, Str $visibility, Str :$public-key, Str :$secret-key) returns Bool is export { + my %payload = visibility => $visibility; + api-request("/images/{$image-id}/visibility", 'POST', %payload, :$public-key, :$secret-key); + return True; +} + +#| Grant access to an image for another API key +sub image-grant-access(Str $image-id, Str $trusted-api-key, Str :$public-key, Str :$secret-key) returns Bool is export { + my %payload = trusted_api_key => $trusted-api-key; + api-request("/images/{$image-id}/access/grant", 'POST', %payload, :$public-key, :$secret-key); + return True; +} + +#| Revoke access to an image from another API key +sub image-revoke-access(Str $image-id, Str $trusted-api-key, Str :$public-key, Str :$secret-key) returns Bool is export { + my %payload = trusted_api_key => $trusted-api-key; + api-request("/images/{$image-id}/access/revoke", 'POST', %payload, :$public-key, :$secret-key); + return True; +} + +#| List trusted API keys for an image +sub image-list-trusted(Str $image-id, Str :$public-key, Str :$secret-key) returns Array is export { + my %result = api-request("/images/{$image-id}/access", 'GET', :$public-key, :$secret-key); + return %result // []; +} + +#| Transfer image ownership to another API key +sub image-transfer(Str $image-id, Str $to-api-key, Str :$public-key, Str :$secret-key) returns Bool is export { + my %payload = to_api_key => $to-api-key; + api-request("/images/{$image-id}/transfer", 'POST', %payload, :$public-key, :$secret-key); + return True; +} + +#| Spawn a new service from an image +sub image-spawn( + Str $image-id, + Str :$name, + Str :$ports, + Str :$bootstrap, + Str :$network-mode, + Str :$public-key, + Str :$secret-key +) returns Str is export { + my %payload; + %payload = $name if $name; + %payload = $ports.split(',')>>.Int if $ports; + %payload = $bootstrap if $bootstrap; + %payload = $network-mode if $network-mode; + my %result = api-request("/images/{$image-id}/spawn", 'POST', %payload, :$public-key, :$secret-key); + return %result // ''; +} + +#| Clone an image +sub image-clone(Str $image-id, Str :$name, Str :$description, Str :$public-key, Str :$secret-key) returns Str is export { + my %payload; + %payload = $name if $name; + %payload = $description if $description; + my %result = api-request("/images/{$image-id}/clone", 'POST', %payload, :$public-key, :$secret-key); + return %result // ''; +} + +# ============================================================================ +# PaaS Logs Functions +# ============================================================================ + +#| Fetch batch logs from portal +#| source: "all", "api", "portal", "pool/cammy", "pool/ai" +#| lines: number of lines (1-10000) +#| since: time window ("1m", "5m", "1h", "1d") +#| grep: optional filter pattern +sub logs-fetch( + Str :$source = 'all', + Int :$lines = 100, + Str :$since = '1h', + Str :$grep, + Str :$public-key, + Str :$secret-key +) returns Str is export { + my $endpoint = "/logs?source={$source}&lines={$lines}&since={$since}"; + $endpoint ~= "&grep={uri-encode($grep)}" if $grep; + my %result = api-request($endpoint, 'GET', :$public-key, :$secret-key); + return to-json(%result); +} + +#| Stream logs via SSE (blocking call) +#| Returns when interrupted or server closes connection +sub logs-stream( + Str :$source = 'all', + Str :$grep, + :&callback, + Str :$public-key, + Str :$secret-key +) returns Bool is export { + my ($pk, $sk) = get-credentials(:$public-key, :$secret-key); + my $endpoint = "/logs/stream?source={$source}"; + $endpoint ~= "&grep={uri-encode($grep)}" if $grep; + + my $timestamp = now.Int; + my $signature = sign-request($sk, $timestamp, 'GET', $endpoint, ''); + + my @args = 'curl', '-s', '-N', "{$API_BASE}{$endpoint}"; + @args.append: '-H', "Authorization: Bearer $pk"; + @args.append: '-H', "X-Timestamp: $timestamp"; + @args.append: '-H', "X-Signature: $signature"; + @args.append: '-H', 'Accept: text/event-stream'; + + my $proc = run |@args, :out; + for $proc.out.lines -> $line { + if $line.starts-with('data: ') { + my $data = $line.substr(6); + &callback($source, $data) if &callback; + } + } + return True; +} + +# ============================================================================ +# Key Validation +# ============================================================================ + +#| Validate API keys and get account info +sub validate-keys(Str :$public-key, Str :$secret-key) returns Hash is export { + my ($pk, $sk) = get-credentials(:$public-key, :$secret-key); + + my $url = "{$PORTAL_BASE}/keys/validate"; + my $timestamp = now.Int; + my $signature = sign-request($sk, $timestamp, 'POST', '/keys/validate', ''); + + my @args = 'curl', '-s', '-X', 'POST', $url; + @args.append: '-H', 'Content-Type: application/json'; + @args.append: '-H', "Authorization: Bearer $pk"; + @args.append: '-H', "X-Timestamp: $timestamp"; + @args.append: '-H', "X-Signature: $signature"; + + my $proc = run |@args, :out; + my $resp = $proc.out.slurp; + return from-json($resp); +} + +# ============================================================================ +# Utility Functions +# ============================================================================ + +#| Check API health +sub health-check(Str :$public-key, Str :$secret-key) returns Bool is export { + try { + my %result = api-request('/health', 'GET', :$public-key, :$secret-key); + return %result eq 'ok'; + CATCH { default { return False; } } + } +} + +#| Get SDK version +sub version() returns Str is export { + return '0.1.0'; +} + #| Detect programming language from file extension or shebang #| #| Returns: Language name or Nil if undetected @@ -814,6 +1296,66 @@ class Client is export { method languages() returns Hash { return languages(:$.public-key, :$.secret-key); } + + # Session methods + method session-list() returns Array { return session-list(:$.public-key, :$.secret-key); } + method session-get(Str $id) returns Hash { return session-get($id, :$.public-key, :$.secret-key); } + method session-create(*%opts) returns Hash { return session-create(:$.public-key, :$.secret-key, |%opts); } + method session-destroy(Str $id) returns Bool { return session-destroy($id, :$.public-key, :$.secret-key); } + method session-freeze(Str $id) returns Bool { return session-freeze($id, :$.public-key, :$.secret-key); } + method session-unfreeze(Str $id) returns Bool { return session-unfreeze($id, :$.public-key, :$.secret-key); } + method session-boost(Str $id, Int $vcpu) returns Bool { return session-boost($id, $vcpu, :$.public-key, :$.secret-key); } + method session-unboost(Str $id) returns Bool { return session-unboost($id, :$.public-key, :$.secret-key); } + method session-execute(Str $id, Str $cmd) returns Hash { return session-execute($id, $cmd, :$.public-key, :$.secret-key); } + + # Service methods + method service-list() returns Array { return service-list(:$.public-key, :$.secret-key); } + method service-get(Str $id) returns Hash { return service-get($id, :$.public-key, :$.secret-key); } + method service-create(*%opts) returns Str { return service-create(:$.public-key, :$.secret-key, |%opts); } + method service-destroy(Str $id) returns Bool { return service-destroy($id, :$.public-key, :$.secret-key); } + method service-freeze(Str $id) returns Bool { return service-freeze($id, :$.public-key, :$.secret-key); } + method service-unfreeze(Str $id) returns Bool { return service-unfreeze($id, :$.public-key, :$.secret-key); } + method service-lock(Str $id) returns Bool { return service-lock($id, :$.public-key, :$.secret-key); } + method service-unlock(Str $id) returns Bool { return service-unlock($id, :$.public-key, :$.secret-key); } + method service-redeploy(Str $id, *%opts) returns Bool { return service-redeploy($id, :$.public-key, :$.secret-key, |%opts); } + method service-logs(Str $id, *%opts) returns Str { return service-logs($id, :$.public-key, :$.secret-key, |%opts); } + method service-execute(Str $id, Str $cmd, *%opts) returns Hash { return service-execute($id, $cmd, :$.public-key, :$.secret-key, |%opts); } + method service-resize(Str $id, Int $vcpu) returns Bool { return service-resize($id, $vcpu, :$.public-key, :$.secret-key); } + + # Snapshot methods + method snapshot-list() returns Array { return snapshot-list(:$.public-key, :$.secret-key); } + method snapshot-get(Str $id) returns Hash { return snapshot-get($id, :$.public-key, :$.secret-key); } + method snapshot-session(Str $id, *%opts) returns Str { return snapshot-session($id, :$.public-key, :$.secret-key, |%opts); } + method snapshot-service(Str $id, *%opts) returns Str { return snapshot-service($id, :$.public-key, :$.secret-key, |%opts); } + method snapshot-restore(Str $id) returns Bool { return snapshot-restore($id, :$.public-key, :$.secret-key); } + method snapshot-delete(Str $id) returns Bool { return snapshot-delete($id, :$.public-key, :$.secret-key); } + method snapshot-lock(Str $id) returns Bool { return snapshot-lock($id, :$.public-key, :$.secret-key); } + method snapshot-unlock(Str $id) returns Bool { return snapshot-unlock($id, :$.public-key, :$.secret-key); } + method snapshot-clone(Str $id, *%opts) returns Str { return snapshot-clone($id, :$.public-key, :$.secret-key, |%opts); } + + # Image methods + method image-list(*%opts) returns Array { return image-list(:$.public-key, :$.secret-key, |%opts); } + method image-get(Str $id) returns Hash { return image-get($id, :$.public-key, :$.secret-key); } + method image-publish(*%opts) returns Str { return image-publish(:$.public-key, :$.secret-key, |%opts); } + method image-delete(Str $id) returns Bool { return image-delete($id, :$.public-key, :$.secret-key); } + method image-lock(Str $id) returns Bool { return image-lock($id, :$.public-key, :$.secret-key); } + method image-unlock(Str $id) returns Bool { return image-unlock($id, :$.public-key, :$.secret-key); } + method image-set-visibility(Str $id, Str $v) returns Bool { return image-set-visibility($id, $v, :$.public-key, :$.secret-key); } + method image-grant-access(Str $id, Str $key) returns Bool { return image-grant-access($id, $key, :$.public-key, :$.secret-key); } + method image-revoke-access(Str $id, Str $key) returns Bool { return image-revoke-access($id, $key, :$.public-key, :$.secret-key); } + method image-list-trusted(Str $id) returns Array { return image-list-trusted($id, :$.public-key, :$.secret-key); } + method image-transfer(Str $id, Str $key) returns Bool { return image-transfer($id, $key, :$.public-key, :$.secret-key); } + method image-spawn(Str $id, *%opts) returns Str { return image-spawn($id, :$.public-key, :$.secret-key, |%opts); } + method image-clone(Str $id, *%opts) returns Str { return image-clone($id, :$.public-key, :$.secret-key, |%opts); } + + # Logs methods + method logs-fetch(*%opts) returns Str { return logs-fetch(:$.public-key, :$.secret-key, |%opts); } + method logs-stream(*%opts) returns Bool { return logs-stream(:$.public-key, :$.secret-key, |%opts); } + + # Utility methods + method validate-keys() returns Hash { return validate-keys(:$.public-key, :$.secret-key); } + method health-check() returns Bool { return health-check(:$.public-key, :$.secret-key); } + method version() returns Str { return version(); } } # ============================================================================ @@ -1548,11 +2090,146 @@ sub cmd-image(@args) { exit 1; } +sub cmd-snapshot(@args) { + my ($public-key, $secret-key) = get-credentials(); + my $list-mode = False; + my $info-id = ''; + my $delete-id = ''; + my $lock-id = ''; + my $unlock-id = ''; + my $restore-id = ''; + my $clone-id = ''; + my $session-id = ''; + my $service-id = ''; + my $name = ''; + my $hot = False; + my $clone-type = ''; + my $ports = ''; + my $shell = ''; + + # Parse arguments + my $i = 0; + while $i < @args.elems { + given @args[$i] { + when '--list' | '-l' { $list-mode = True; } + when '--info' { $i++; $info-id = @args[$i]; } + when '--delete' { $i++; $delete-id = @args[$i]; } + when '--lock' { $i++; $lock-id = @args[$i]; } + when '--unlock' { $i++; $unlock-id = @args[$i]; } + when '--restore' { $i++; $restore-id = @args[$i]; } + when '--clone' { $i++; $clone-id = @args[$i]; } + when '--session' { $i++; $session-id = @args[$i]; } + when '--service' { $i++; $service-id = @args[$i]; } + when '--name' { $i++; $name = @args[$i]; } + when '--hot' { $hot = True; } + when '--clone-type' { $i++; $clone-type = @args[$i]; } + when '--ports' { $i++; $ports = @args[$i]; } + when '--shell' { $i++; $shell = @args[$i]; } + } + $i++; + } + + if $list-mode { + my @snapshots = snapshot-list(:$public-key, :$secret-key); + unless @snapshots { + say "No snapshots found"; + return; + } + say sprintf("%-40s %-20s %-10s %-10s %s", 'ID', 'Name', 'Type', 'Locked', 'Created'); + for @snapshots -> %s { + say sprintf("%-40s %-20s %-10s %-10s %s", + %s // 'N/A', %s // '-', %s // 'N/A', + %s ?? 'Yes' !! 'No', %s // 'N/A'); + } + return; + } + + if $info-id { + my %result = snapshot-get($info-id, :$public-key, :$secret-key); + say to-json(%result, :pretty); + return; + } + + if $delete-id { + my ($status, $body) = api-request-with-status("/snapshots/$delete-id", 'DELETE', :$public-key, :$secret-key); + if $status == 428 { + if handle-sudo-challenge($body, "/snapshots/$delete-id", 'DELETE', :$public-key, :$secret-key) { + say "{$GREEN}Snapshot deleted: $delete-id{$RESET}"; + } else { + note "{$RED}Error: Failed to delete snapshot (OTP verification failed){$RESET}"; + exit 1; + } + } elsif $status >= 200 && $status < 300 { + say "{$GREEN}Snapshot deleted: $delete-id{$RESET}"; + } else { + note "{$RED}Error: Failed to delete snapshot (HTTP $status){$RESET}"; + exit 1; + } + return; + } + + if $lock-id { + snapshot-lock($lock-id, :$public-key, :$secret-key); + say "{$GREEN}Snapshot locked: $lock-id{$RESET}"; + return; + } + + if $unlock-id { + my ($status, $body) = api-request-with-status("/snapshots/$unlock-id/unlock", 'POST', :$public-key, :$secret-key); + if $status == 428 { + if handle-sudo-challenge($body, "/snapshots/$unlock-id/unlock", 'POST', :$public-key, :$secret-key) { + say "{$GREEN}Snapshot unlocked: $unlock-id{$RESET}"; + } else { + note "{$RED}Error: Failed to unlock snapshot (OTP verification failed){$RESET}"; + exit 1; + } + } elsif $status >= 200 && $status < 300 { + say "{$GREEN}Snapshot unlocked: $unlock-id{$RESET}"; + } else { + note "{$RED}Error: Failed to unlock snapshot (HTTP $status){$RESET}"; + exit 1; + } + return; + } + + if $restore-id { + snapshot-restore($restore-id, :$public-key, :$secret-key); + say "{$GREEN}Snapshot restored: $restore-id{$RESET}"; + return; + } + + if $clone-id { + unless $clone-type { + note "{$RED}Error: --clone-type required (session or service){$RESET}"; + exit 1; + } + my $new-id = snapshot-clone($clone-id, :$clone-type, :$name, :$ports, :$shell, :$public-key, :$secret-key); + say "{$GREEN}Cloned to $clone-type: $new-id{$RESET}"; + return; + } + + if $session-id { + my $snap-id = snapshot-session($session-id, :$name, :$hot, :$public-key, :$secret-key); + say "{$GREEN}Snapshot created: $snap-id{$RESET}"; + return; + } + + if $service-id { + my $snap-id = snapshot-service($service-id, :$name, :$hot, :$public-key, :$secret-key); + say "{$GREEN}Snapshot created: $snap-id{$RESET}"; + return; + } + + note "{$RED}Error: Specify --list, --info ID, --delete ID, --lock ID, --unlock ID, --restore ID, --clone ID, --session ID, or --service ID{$RESET}"; + exit 1; +} + sub MAIN(*@args) is export { unless @args { note "Usage: un.raku [options] "; note " un.raku session [options]"; note " un.raku service [options]"; + note " un.raku snapshot [options]"; note " un.raku image [options]"; note " un.raku key [options]"; note " un.raku languages [--json]"; @@ -1560,6 +2237,22 @@ sub MAIN(*@args) is export { note "Languages options:"; note " --json Output as JSON array"; note ""; + note "Snapshot options:"; + note " --list List all snapshots"; + note " --info ID Get snapshot details"; + note " --delete ID Delete a snapshot"; + note " --lock ID Lock snapshot to prevent deletion"; + note " --unlock ID Unlock snapshot"; + note " --restore ID Restore a snapshot"; + note " --clone ID Clone snapshot (requires --clone-type)"; + note " --session ID Create snapshot from session"; + note " --service ID Create snapshot from service"; + note " --name NAME Name for the snapshot"; + note " --hot Create hot snapshot (while running)"; + note " --clone-type TYPE Clone type: session or service"; + note " --ports PORTS Ports for cloned service"; + note " --shell SHELL Shell for cloned session"; + note ""; note "Image options:"; note " --list List all images"; note " --info ID Get image details"; @@ -1573,6 +2266,10 @@ sub MAIN(*@args) is export { note " --clone ID Clone an image"; note " --name NAME Name for spawned service or cloned image"; note " --ports PORTS Ports for spawned service"; + note " --grant ID KEY Grant image access to API key"; + note " --revoke ID KEY Revoke image access from API key"; + note " --trusted ID List trusted API keys for image"; + note " --transfer ID KEY Transfer image ownership"; exit 1; } @@ -1583,6 +2280,9 @@ sub MAIN(*@args) is export { when 'service' { cmd-service(@args[1..*]); } + when 'snapshot' { + cmd-snapshot(@args[1..*]); + } when 'image' { cmd-image(@args[1..*]); } diff --git a/clients/ruby/sync/src/un.rb b/clients/ruby/sync/src/un.rb index 86df1bc..0c3c786 100644 --- a/clients/ruby/sync/src/un.rb +++ b/clients/ruby/sync/src/un.rb @@ -56,6 +56,7 @@ require 'json' require 'openssl' require 'fileutils' require 'optparse' +require 'cgi' # Unsandbox Ruby SDK module (synchronous) module Un @@ -343,6 +344,31 @@ module Un response['snapshots'] || [] end + # Get details of a specific snapshot + # + # @param snapshot_id [String] Snapshot ID to get details for + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Snapshot details hash 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 + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # snapshot = Un.get_snapshot(snapshot_id) + # puts "Snapshot: #{snapshot['name']} (#{snapshot['type']})" + def get_snapshot(snapshot_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('GET', "/snapshots/#{snapshot_id}", pk, sk) + end + # Restore a snapshot # # @param snapshot_id [String] Snapshot ID to restore @@ -1219,6 +1245,23 @@ module Un wait_for_job(job_id, public_key: pk, secret_key: sk, timeout: (timeout / 1000) + 10) end + # Resize a service's vCPU allocation + # + # @param service_id [String] Service ID to resize + # @param vcpu [Integer] Number of vCPUs (1-8 typically) + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with updated service info + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.resize_service(service_id, 4) + def resize_service(service_id, vcpu, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('PATCH', "/services/#{service_id}", pk, sk, { vcpu: vcpu }) + end + # ============================================================================ # Key Validation # ============================================================================ @@ -1270,6 +1313,158 @@ module Un make_request('POST', '/image', pk, sk, payload) end + # ============================================================================ + # PaaS Logs Functions + # ============================================================================ + + # Fetch batch logs from the PaaS platform + # + # @param source [String] Log source - "all", "api", "portal", "pool/cammy", "pool/ai" + # @param lines [Integer] Number of lines to fetch (1-10000) + # @param since [String] Time window - "1m", "5m", "1h", "1d" + # @param grep [String, nil] Optional filter pattern + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Log entries + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # logs = Un.logs_fetch(source: 'api', lines: 50, since: '5m') + def logs_fetch(source: 'all', lines: 100, since: '5m', grep: nil, + public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + path = "/logs?source=#{source}&lines=#{lines}&since=#{since}" + path += "&grep=#{CGI.escape(grep)}" if grep + make_request('GET', path, pk, sk) + end + + # Stream logs via Server-Sent Events + # + # Blocks until interrupted or server closes connection. + # + # @param source [String] Log source - "all", "api", "portal", "pool/cammy", "pool/ai" + # @param grep [String, nil] Optional filter pattern + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @yield [source, line] Called for each log line received + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.logs_stream(source: 'api') do |src, line| + # puts "[#{src}] #{line}" + # end + def logs_stream(source: 'all', grep: nil, public_key: nil, secret_key: nil, &block) + pk, sk = resolve_credentials(public_key, secret_key) + path = "/logs/stream?source=#{source}" + path += "&grep=#{CGI.escape(grep)}" if grep + + uri = URI("#{API_BASE}#{path}") + timestamp = Time.now.to_i + signature = sign_request(sk, timestamp, 'GET', path) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = nil + + request = Net::HTTP::Get.new(uri) + request['Authorization'] = "Bearer #{pk}" + request['X-Timestamp'] = timestamp.to_s + request['X-Signature'] = signature + request['Accept'] = 'text/event-stream' + + http.request(request) do |response| + response.read_body do |chunk| + chunk.split("\n").each do |line| + next unless line.start_with?('data: ') + + data = line[6..] + begin + entry = JSON.parse(data) + if block_given? + yield entry['source'] || source, entry['line'] || data + else + puts "[#{entry['source'] || source}] #{entry['line'] || data}" + end + rescue JSON::ParserError + if block_given? + yield source, data + else + puts "[#{source}] #{data}" + end + end + end + end + end + end + + # ============================================================================ + # Utility Functions + # ============================================================================ + + SDK_VERSION = '4.2.0' + + # Thread-local storage for last error + @last_error = nil + + class << self + attr_accessor :last_error_value + end + + # Get the SDK version string + # + # @return [String] Version string (e.g., "4.2.0") + # + # @example + # puts Un.version # "4.2.0" + def version + SDK_VERSION + end + + # Check if the API is healthy and responding + # + # @return [Boolean] true if API is healthy, false otherwise + # + # @example + # if Un.health_check + # puts "API is healthy" + # end + def health_check + uri = URI("#{API_BASE}/health") + response = Net::HTTP.get_response(uri) + response.code == '200' + rescue StandardError => e + Un.last_error_value = "Health check failed: #{e.message}" + false + end + + # Get the last error message + # + # @return [String, nil] Last error message or nil + # + # @example + # result = Un.health_check + # puts Un.last_error unless result + def last_error + Un.last_error_value + end + + # Sign a message using HMAC-SHA256 + # + # This is the underlying signing function used for request authentication. + # Exposed for testing and debugging purposes. + # + # @param secret_key [String] The secret key for signing + # @param message [String] The message to sign + # @return [String] 64-character lowercase hex string + # + # @example + # signature = Un.hmac_sign("secret", "message") + def hmac_sign(secret_key, message) + OpenSSL::HMAC.hexdigest('SHA256', secret_key, message) + end + private # Language detection mapping (file extension -> language) diff --git a/clients/ruby/sync/test/test_new_functions.rb b/clients/ruby/sync/test/test_new_functions.rb new file mode 100644 index 0000000..090822d --- /dev/null +++ b/clients/ruby/sync/test/test_new_functions.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +class TestNewFunctions < Minitest::Test + # ========================================================================= + # Utility Functions Tests + # ========================================================================= + + def test_version_returns_string + v = Un.version + assert_instance_of String, v + refute_empty v + end + + def test_version_is_semantic_format + v = Un.version + parts = v.split('.') + assert parts.length >= 2, "Version should be semantic: #{v}" + end + + def test_hmac_sign_returns_64_hex_chars + signature = Un.hmac_sign('secret_key', 'message_to_sign') + assert_instance_of String, signature + assert_equal 64, signature.length + assert_match(/\A[0-9a-f]+\z/, signature) + end + + def test_hmac_sign_is_deterministic + sig1 = Un.hmac_sign('secret', 'message') + sig2 = Un.hmac_sign('secret', 'message') + assert_equal sig1, sig2 + end + + def test_hmac_sign_different_secrets_different_signatures + sig1 = Un.hmac_sign('secret1', 'message') + sig2 = Un.hmac_sign('secret2', 'message') + refute_equal sig1, sig2 + end + + def test_hmac_sign_different_messages_different_signatures + sig1 = Un.hmac_sign('secret', 'message1') + sig2 = Un.hmac_sign('secret', 'message2') + refute_equal sig1, sig2 + end + + def test_hmac_sign_matches_openssl + secret = 'test_secret' + message = '1234567890:POST:/execute:' + signature = Un.hmac_sign(secret, message) + expected = OpenSSL::HMAC.hexdigest('SHA256', secret, message) + assert_equal expected, signature + end + + def test_last_error_returns_nil_or_string + error = Un.last_error + assert error.nil? || error.is_a?(String) + end + + # ========================================================================= + # Function Exports Tests + # ========================================================================= + + def test_execution_functions_defined + # 8 execution functions + assert Un.respond_to?(:execute_code) + assert Un.respond_to?(:execute_async) + assert Un.respond_to?(:get_job) + assert Un.respond_to?(:wait_for_job) + assert Un.respond_to?(:cancel_job) + assert Un.respond_to?(:list_jobs) + assert Un.respond_to?(:get_languages) + assert Un.respond_to?(:detect_language) + end + + def test_session_functions_defined + # 9 session functions + assert Un.respond_to?(:list_sessions) + assert Un.respond_to?(:get_session) + assert Un.respond_to?(:create_session) + assert Un.respond_to?(:delete_session) + assert Un.respond_to?(:freeze_session) + assert Un.respond_to?(:unfreeze_session) + assert Un.respond_to?(:boost_session) + assert Un.respond_to?(:unboost_session) + assert Un.respond_to?(:shell_session) + end + + def test_service_functions_defined + # 17 service functions + assert Un.respond_to?(:list_services) + assert Un.respond_to?(:create_service) + assert Un.respond_to?(:get_service) + assert Un.respond_to?(:update_service) + assert Un.respond_to?(:delete_service) + assert Un.respond_to?(:freeze_service) + assert Un.respond_to?(:unfreeze_service) + assert Un.respond_to?(:lock_service) + assert Un.respond_to?(:unlock_service) + assert Un.respond_to?(:set_unfreeze_on_demand) + assert Un.respond_to?(:get_service_logs) + assert Un.respond_to?(:get_service_env) + assert Un.respond_to?(:set_service_env) + assert Un.respond_to?(:delete_service_env) + assert Un.respond_to?(:export_service_env) + assert Un.respond_to?(:redeploy_service) + assert Un.respond_to?(:execute_in_service) + assert Un.respond_to?(:resize_service), 'resize_service should be defined (NEW)' + end + + def test_snapshot_functions_defined + # 9 snapshot functions + assert Un.respond_to?(:session_snapshot) + assert Un.respond_to?(:service_snapshot) + assert Un.respond_to?(:list_snapshots) + assert Un.respond_to?(:get_snapshot), 'get_snapshot should be defined (NEW)' + assert Un.respond_to?(:restore_snapshot) + assert Un.respond_to?(:delete_snapshot) + assert Un.respond_to?(:lock_snapshot) + assert Un.respond_to?(:unlock_snapshot) + assert Un.respond_to?(:clone_snapshot) + end + + def test_image_functions_defined + # 13 image functions + assert Un.respond_to?(:image_publish) + assert Un.respond_to?(:list_images) + assert Un.respond_to?(:get_image) + assert Un.respond_to?(:delete_image) + assert Un.respond_to?(:lock_image) + assert Un.respond_to?(:unlock_image) + assert Un.respond_to?(:set_image_visibility) + assert Un.respond_to?(:grant_image_access) + assert Un.respond_to?(:revoke_image_access) + assert Un.respond_to?(:list_image_trusted) + assert Un.respond_to?(:transfer_image) + assert Un.respond_to?(:spawn_from_image) + assert Un.respond_to?(:clone_image) + end + + def test_logs_functions_defined + # 2 PaaS logs functions (NEW) + assert Un.respond_to?(:logs_fetch), 'logs_fetch should be defined (NEW)' + assert Un.respond_to?(:logs_stream), 'logs_stream should be defined (NEW)' + end + + def test_utility_functions_defined + assert Un.respond_to?(:validate_keys) + assert Un.respond_to?(:version), 'version should be defined (NEW)' + assert Un.respond_to?(:health_check), 'health_check should be defined (NEW)' + assert Un.respond_to?(:last_error), 'last_error should be defined (NEW)' + assert Un.respond_to?(:hmac_sign), 'hmac_sign should be defined (NEW)' + end + + # ========================================================================= + # Language Detection Tests + # ========================================================================= + + def test_detect_language_python + assert_equal 'python', Un.detect_language('test.py') + end + + def test_detect_language_javascript + assert_equal 'javascript', Un.detect_language('test.js') + end + + def test_detect_language_typescript + assert_equal 'typescript', Un.detect_language('test.ts') + end + + def test_detect_language_ruby + assert_equal 'ruby', Un.detect_language('test.rb') + end + + def test_detect_language_go + assert_equal 'go', Un.detect_language('test.go') + end + + def test_detect_language_rust + assert_equal 'rust', Un.detect_language('test.rs') + end + + def test_detect_language_unknown_returns_nil + assert_nil Un.detect_language('test.unknown') + end +end + +# Functional tests require API credentials +class TestFunctionalAPI < Minitest::Test + def setup + skip 'UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY required' unless credentials_available? + WebMock.allow_net_connect! + end + + def teardown + WebMock.disable_net_connect! + end + + def test_health_check + result = Un.health_check + assert [true, false].include?(result) + end + + def test_validate_keys + result = Un.validate_keys + assert result.is_a?(Hash) + end + + def test_get_languages + languages = Un.get_languages + assert languages.is_a?(Array) + assert languages.include?('python') + end + + def test_list_sessions + sessions = Un.list_sessions + assert sessions.is_a?(Array) + end + + def test_list_services + services = Un.list_services + assert services.is_a?(Array) + end + + def test_list_snapshots + snapshots = Un.list_snapshots + assert snapshots.is_a?(Array) + end + + def test_list_images + images = Un.list_images + assert images.is_a?(Array) + end + + def test_execute_code + result = Un.execute_code('python', 'print("hello")') + assert result.is_a?(Hash) + assert %w[completed pending].include?(result['status']) + end + + private + + def credentials_available? + ENV['UNSANDBOX_PUBLIC_KEY'] && ENV['UNSANDBOX_SECRET_KEY'] + end +end diff --git a/clients/rust/sync/src/lib.rs b/clients/rust/sync/src/lib.rs index 99b276e..a0c932d 100644 --- a/clients/rust/sync/src/lib.rs +++ b/clients/rust/sync/src/lib.rs @@ -1302,6 +1302,19 @@ pub fn list_snapshots(creds: &Credentials) -> Result> { 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 { + 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 Result { + 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) -> 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, + /// Time window ("1m", "5m", "1h", "1d") + pub since: Option, + /// Optional filter pattern + pub grep: Option, +} + +/// 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, creds: &Credentials) -> Result> { + 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, + } + + 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(¤t_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 = 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 // ============================================================================= diff --git a/clients/rust/sync/tests/lib_test.rs b/clients/rust/sync/tests/lib_test.rs new file mode 100644 index 0000000..d73b126 --- /dev/null +++ b/clients/rust/sync/tests/lib_test.rs @@ -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 { + 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) +} diff --git a/clients/scheme/sync/src/un.scm b/clients/scheme/sync/src/un.scm index acdce2f..c2e2d35 100644 --- a/clients/scheme/sync/src/un.scm +++ b/clients/scheme/sync/src/un.scm @@ -764,6 +764,190 @@ (display "Error: --name required to create service, or use env subcommand\n" (current-error-port)) (exit 1))))) +;; Image access management functions +(define (image-grant-access id trusted-key) + (let* ((api-key (get-api-key)) + (json (format #f "{\"trusted_api_key\":\"~a\"}" trusted-key))) + (curl-post api-key (format #f "/images/~a/grant-access" id) json) + (format #t "~aAccess granted to: ~a~a\n" green trusted-key reset))) + +(define (image-revoke-access id trusted-key) + (let* ((api-key (get-api-key)) + (json (format #f "{\"trusted_api_key\":\"~a\"}" trusted-key))) + (curl-post api-key (format #f "/images/~a/revoke-access" id) json) + (format #t "~aAccess revoked from: ~a~a\n" green trusted-key reset))) + +(define (image-list-trusted id) + (let ((api-key (get-api-key))) + (display (curl-get api-key (format #f "/images/~a/trusted" id))) + (newline))) + +(define (image-transfer id to-key) + (let* ((api-key (get-api-key)) + (json (format #f "{\"to_api_key\":\"~a\"}" to-key))) + (curl-post api-key (format #f "/images/~a/transfer" id) json) + (format #t "~aImage transferred to: ~a~a\n" green to-key reset))) + +;; Snapshot functions +(define (snapshot-list) + (let ((api-key (get-api-key))) + (display (curl-get api-key "/snapshots")) + (newline))) + +(define (snapshot-info id) + (let ((api-key (get-api-key))) + (display (curl-get api-key (format #f "/snapshots/~a" id))) + (newline))) + +(define (snapshot-session session-id name hot) + (let* ((api-key (get-api-key)) + (name-json (if name (format #f ",\"name\":\"~a\"" (escape-json name)) "")) + (hot-json (if hot ",\"hot\":true" "")) + (json (format #f "{\"session_id\":\"~a\"~a~a}" session-id name-json hot-json))) + (format #t "~aSnapshot created~a\n" green reset) + (display (curl-post api-key "/snapshots" json)) + (newline))) + +(define (snapshot-service-create service-id name hot) + (let* ((api-key (get-api-key)) + (name-json (if name (format #f ",\"name\":\"~a\"" (escape-json name)) "")) + (hot-json (if hot ",\"hot\":true" "")) + (json (format #f "{\"service_id\":\"~a\"~a~a}" service-id name-json hot-json))) + (format #t "~aSnapshot created~a\n" green reset) + (display (curl-post api-key "/snapshots" json)) + (newline))) + +(define (snapshot-restore id) + (let ((api-key (get-api-key))) + (curl-post api-key (format #f "/snapshots/~a/restore" id) "{}") + (format #t "~aSnapshot restored: ~a~a\n" green id reset))) + +(define (snapshot-delete id) + (let* ((api-key (get-api-key)) + (result (curl-delete-with-sudo api-key (format #f "/snapshots/~a" id)))) + (if (car result) + (format #t "~aSnapshot deleted: ~a~a\n" green id reset) + (begin + (format (current-error-port) "~aError deleting snapshot~a\n" red reset) + (exit 1))))) + +(define (snapshot-lock id) + (let ((api-key (get-api-key))) + (curl-post api-key (format #f "/snapshots/~a/lock" id) "{}") + (format #t "~aSnapshot locked: ~a~a\n" green id reset))) + +(define (snapshot-unlock id) + (let* ((api-key (get-api-key)) + (result (curl-post-with-sudo api-key (format #f "/snapshots/~a/unlock" id) "{}"))) + (if (car result) + (format #t "~aSnapshot unlocked: ~a~a\n" green id reset) + (begin + (format (current-error-port) "~aError unlocking snapshot~a\n" red reset) + (exit 1))))) + +(define (snapshot-clone id clone-type name ports shell) + (let* ((api-key (get-api-key)) + (type-json (format #f "\"clone_type\":\"~a\"" clone-type)) + (name-json (if name (format #f ",\"name\":\"~a\"" (escape-json name)) "")) + (ports-json (if ports (format #f ",\"ports\":[~a]" ports) "")) + (shell-json (if shell (format #f ",\"shell\":\"~a\"" shell) "")) + (json (format #f "{~a~a~a~a}" type-json name-json ports-json shell-json))) + (format #t "~aSnapshot cloned~a\n" green reset) + (display (curl-post api-key (format #f "/snapshots/~a/clone" id) json)) + (newline))) + +(define (snapshot-cmd action id name ports shell hot) + (cond + ((equal? action "list") (snapshot-list)) + ((equal? action "info") (snapshot-info id)) + ((equal? action "session") (snapshot-session id name hot)) + ((equal? action "service") (snapshot-service-create id name hot)) + ((equal? action "restore") (snapshot-restore id)) + ((equal? action "delete") (snapshot-delete id)) + ((equal? action "lock") (snapshot-lock id)) + ((equal? action "unlock") (snapshot-unlock id)) + ((equal? action "clone") (snapshot-clone id "session" name ports shell)) + (else + (display "Error: Unknown snapshot action\n" (current-error-port)) + (exit 1)))) + +;; Session additional functions +(define (session-info id) + (let ((api-key (get-api-key))) + (display (curl-get api-key (format #f "/sessions/~a" id))) + (newline))) + +(define (session-boost id vcpu) + (let* ((api-key (get-api-key)) + (json (format #f "{\"vcpu\":~a}" vcpu))) + (curl-patch api-key (format #f "/sessions/~a" id) json) + (format #t "~aSession boosted to ~a vCPU~a\n" green vcpu reset))) + +(define (session-unboost id) + (let* ((api-key (get-api-key)) + (json "{\"vcpu\":1}")) + (curl-patch api-key (format #f "/sessions/~a" id) json) + (format #t "~aSession unboosted to 1 vCPU~a\n" green reset))) + +(define (session-execute id command) + (let* ((api-key (get-api-key)) + (json (format #f "{\"command\":\"~a\"}" (escape-json command))) + (response (curl-post api-key (format #f "/sessions/~a/execute" id) json)) + (stdout-val (json-extract-string response "stdout"))) + (when stdout-val + (display (format #f "~a~a~a" blue stdout-val reset))))) + +;; Service additional functions +(define (service-lock id) + (let ((api-key (get-api-key))) + (curl-post api-key (format #f "/services/~a/lock" id) "{}") + (format #t "~aService locked: ~a~a\n" green id reset))) + +(define (service-unlock id) + (let* ((api-key (get-api-key)) + (result (curl-post-with-sudo api-key (format #f "/services/~a/unlock" id) "{}"))) + (if (car result) + (format #t "~aService unlocked: ~a~a\n" green id reset) + (begin + (format (current-error-port) "~aError unlocking service~a\n" red reset) + (exit 1))))) + +(define (service-redeploy id bootstrap) + (let* ((api-key (get-api-key)) + (json (if bootstrap + (format #f "{\"bootstrap\":\"~a\"}" (escape-json bootstrap)) + "{}"))) + (curl-post api-key (format #f "/services/~a/redeploy" id) json) + (format #t "~aService redeploying: ~a~a\n" green id reset))) + +;; PaaS logs functions +(define (logs-fetch source lines since grep-pattern) + (let* ((api-key (get-api-key)) + (params (format #f "?source=~a&lines=~a~a~a" + (or source "all") + (or lines 100) + (if since (format #f "&since=~a" since) "") + (if grep-pattern (format #f "&grep=~a" grep-pattern) "")))) + (display (curl-get api-key (format #f "/logs~a" params))) + (newline))) + +;; Utility functions +(define (health-check) + (let* ((cmd "curl -s https://api.unsandbox.com/health") + (port (open-input-pipe cmd)) + (result (let loop ((chars '())) + (let ((char (read-char port))) + (if (eof-object? char) + (list->string (reverse chars)) + (loop (cons char chars))))))) + (close-pipe port) + (display result) + (newline) + (string-contains result "ok"))) + +(define (sdk-version) + "4.2.0") + (define (image-cmd action id source-type visibility-mode name ports) (let ((api-key (get-api-key))) (cond @@ -880,6 +1064,28 @@ ((equal? (car args) "key") (let ((extend (and (> (length args) 1) (equal? (cadr args) "--extend")))) (validate-key-cmd extend))) + ((equal? (car args) "snapshot") + (cond + ((and (> (length args) 1) (equal? (cadr args) "--list")) + (snapshot-cmd "list" #f #f #f #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--info")) + (snapshot-cmd "info" (caddr args) #f #f #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--session")) + (snapshot-cmd "session" (caddr args) #f #f #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--service")) + (snapshot-cmd "service" (caddr args) #f #f #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--restore")) + (snapshot-cmd "restore" (caddr args) #f #f #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--delete")) + (snapshot-cmd "delete" (caddr args) #f #f #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--lock")) + (snapshot-cmd "lock" (caddr args) #f #f #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--unlock")) + (snapshot-cmd "unlock" (caddr args) #f #f #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--clone")) + (snapshot-cmd "clone" (caddr args) #f #f #f #f)) + (else + (snapshot-cmd "list" #f #f #f #f #f)))) ((equal? (car args) "image") (cond ((and (> (length args) 1) (equal? (cadr args) "--list")) diff --git a/clients/swift/sync/src/un.swift b/clients/swift/sync/src/un.swift index cea980a..918f23e 100644 --- a/clients/swift/sync/src/un.swift +++ b/clients/swift/sync/src/un.swift @@ -1197,6 +1197,166 @@ func cloneImage(_ imageId: String, name: String? = nil, description: String? = n return try makeRequest(method: "POST", path: "/images/\(imageId)/clone", publicKey: pk, secretKey: sk, data: data) } +/// Grant access to an image +func grantImageAccess(_ imageId: String, trustedApiKey: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/images/\(imageId)/grant", publicKey: pk, secretKey: sk, data: ["trusted_api_key": trustedApiKey]) +} + +/// Revoke access to an image +func revokeImageAccess(_ imageId: String, trustedApiKey: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/images/\(imageId)/revoke", publicKey: pk, secretKey: sk, data: ["trusted_api_key": trustedApiKey]) +} + +/// List trusted keys for an image +func listImageTrusted(_ imageId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "GET", path: "/images/\(imageId)/trusted", publicKey: pk, secretKey: sk) +} + +/// Transfer image ownership +func transferImage(_ imageId: String, toApiKey: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequestWithSudo(method: "POST", path: "/images/\(imageId)/transfer", publicKey: pk, secretKey: sk, data: ["to_api_key": toApiKey]) +} + +// MARK: - PaaS Logs + +/// Fetch batch logs from the portal +func fetchLogs(source: String = "all", lines: Int = 100, since: String = "1h", grep: String? = nil, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var endpoint = "/paas/logs?source=\(source)&lines=\(lines)&since=\(since)" + if let grep = grep { + endpoint += "&grep=\(grep.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? grep)" + } + + let url = URL(string: "\(PORTAL_BASE)\(endpoint)")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 30 + + let timestamp = Int(Date().timeIntervalSince1970) + let signature = signRequest(secretKey: sk, timestamp: timestamp, method: "GET", path: endpoint, body: nil) + + request.setValue("Bearer \(pk)", forHTTPHeaderField: "Authorization") + request.setValue("\(timestamp)", forHTTPHeaderField: "X-Timestamp") + request.setValue(signature, forHTTPHeaderField: "X-Signature") + + var result: [String: Any]? + var requestError: Error? + + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + + if let error = error { + requestError = UnsandboxError.networkError(error.localizedDescription) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + requestError = UnsandboxError.invalidResponse("No HTTP response") + return + } + + guard let data = data else { + requestError = UnsandboxError.invalidResponse("No data received") + return + } + + if httpResponse.statusCode >= 400 { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + requestError = UnsandboxError.apiError(httpResponse.statusCode, body) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + result = json + } + } catch { + requestError = UnsandboxError.invalidResponse("Failed to parse JSON") + } + } + + task.resume() + semaphore.wait() + + if let error = requestError { + throw error + } + + return result ?? [:] +} + +// MARK: - Health Check + +/// Check API health status +func healthCheck() throws -> [String: Any] { + let url = URL(string: "\(API_BASE)/health")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 10 + + var result: [String: Any]? + var requestError: Error? + + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + + if let error = error { + requestError = UnsandboxError.networkError(error.localizedDescription) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + requestError = UnsandboxError.invalidResponse("No HTTP response") + return + } + + guard let data = data else { + requestError = UnsandboxError.invalidResponse("No data received") + return + } + + if httpResponse.statusCode >= 400 { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + requestError = UnsandboxError.apiError(httpResponse.statusCode, body) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + result = json + } + } catch { + requestError = UnsandboxError.invalidResponse("Failed to parse JSON") + } + } + + task.resume() + semaphore.wait() + + if let error = requestError { + throw error + } + + return result ?? [:] +} + +/// Get SDK version information +func getVersion() -> [String: String] { + return [ + "version": "1.0.0", + "api": API_BASE, + "portal": PORTAL_BASE + ] +} + // MARK: - Key Validation /// Validate API keys against the portal @@ -1538,6 +1698,19 @@ class CLIArgs { var visibility: String? var visibilityMode: String? var spawn: String? + var grant: String? + var revoke: String? + var trusted: String? + var trustedKey: String? + var transfer: String? + var toKey: String? + + // Logs options + var logsSource: String? + var logsLines: Int? + var logsSince: String? + var logsGrep: String? + var logsFollow: Bool = false // Service-env var serviceEnvAction: String? @@ -1704,7 +1877,39 @@ class CLIArgs { case "--spawn": i += 1 if i < args.count { spawn = args[i] } - case "session", "service", "snapshot", "image", "key", "languages": + case "--grant": + i += 1 + if i < args.count { grant = args[i] } + case "--revoke": + i += 1 + if i < args.count { revoke = args[i] } + case "--trusted": + i += 1 + if i < args.count { trusted = args[i] } + case "--trusted-key": + i += 1 + if i < args.count { trustedKey = args[i] } + case "--transfer": + i += 1 + if i < args.count { transfer = args[i] } + case "--to-key": + i += 1 + if i < args.count { toKey = args[i] } + case "--source": + i += 1 + if i < args.count { logsSource = args[i] } + case "--lines": + i += 1 + if i < args.count { logsLines = Int(args[i]) } + case "--since": + i += 1 + if i < args.count { logsSince = args[i] } + case "--grep": + i += 1 + if i < args.count { logsGrep = args[i] } + case "--follow": + logsFollow = true + case "session", "service", "snapshot", "image", "key", "languages", "logs", "health", "version": command = arg case "service-env": command = "service-env" @@ -2128,12 +2333,83 @@ func handleImageCommand(_ args: CLIArgs, _ pk: String, _ sk: String) throws { let result = try cloneImage(cloneId, name: args.name, publicKey: pk, secretKey: sk) let imageId = result["image_id"] as? String ?? result["id"] as? String ?? "" print("Image cloned: \(imageId)") + } else if let grantId = args.grant { + guard let trustedKey = args.trustedKey else { + fputs("Error: --trusted-key required for --grant\n", stderr) + exit(2) + } + _ = try grantImageAccess(grantId, trustedApiKey: trustedKey, publicKey: pk, secretKey: sk) + print("Access granted to \(trustedKey)") + } else if let revokeId = args.revoke { + guard let trustedKey = args.trustedKey else { + fputs("Error: --trusted-key required for --revoke\n", stderr) + exit(2) + } + _ = try revokeImageAccess(revokeId, trustedApiKey: trustedKey, publicKey: pk, secretKey: sk) + print("Access revoked from \(trustedKey)") + } else if let trustedId = args.trusted { + let result = try listImageTrusted(trustedId, publicKey: pk, secretKey: sk) + let jsonData = try JSONSerialization.data(withJSONObject: result, options: .prettyPrinted) + print(String(data: jsonData, encoding: .utf8) ?? "{}") + } else if let transferId = args.transfer { + guard let toKey = args.toKey else { + fputs("Error: --to-key required for --transfer\n", stderr) + exit(2) + } + _ = try transferImage(transferId, toApiKey: toKey, publicKey: pk, secretKey: sk) + print("Image transferred to \(toKey)") } else { fputs("Error: No action specified for image command\n", stderr) exit(2) } } +/// Handle logs command +func handleLogsCommand(_ args: CLIArgs, _ pk: String, _ sk: String) throws { + let source = args.logsSource ?? "all" + let lines = args.logsLines ?? 100 + let since = args.logsSince ?? "1h" + let grep = args.logsGrep + + let result = try fetchLogs(source: source, lines: lines, since: since, grep: grep, publicKey: pk, secretKey: sk) + + if let logs = result["logs"] as? [[String: Any]] { + for log in logs { + let src = log["source"] as? String ?? "unknown" + let line = log["line"] as? String ?? "" + let timestamp = log["timestamp"] as? String ?? "" + if timestamp.isEmpty { + print("[\(src)] \(line)") + } else { + print("[\(timestamp)] [\(src)] \(line)") + } + } + } else { + let jsonData = try JSONSerialization.data(withJSONObject: result, options: .prettyPrinted) + print(String(data: jsonData, encoding: .utf8) ?? "{}") + } +} + +/// Handle health command +func handleHealthCommand() throws { + let result = try healthCheck() + if result["status"] as? String == "healthy" || result["ok"] as? Bool == true { + print("\u{001B}[32mAPI is healthy\u{001B}[0m") + } else { + print("\u{001B}[31mAPI may be unhealthy\u{001B}[0m") + } + let jsonData = try JSONSerialization.data(withJSONObject: result, options: .prettyPrinted) + print(String(data: jsonData, encoding: .utf8) ?? "{}") +} + +/// Handle version command +func handleVersionCommand() { + let info = getVersion() + print("un.swift version \(info["version"] ?? "unknown")") + print("API: \(info["api"] ?? "unknown")") + print("Portal: \(info["portal"] ?? "unknown")") +} + /// Format image list output func formatImageListOutput(_ images: [[String: Any]]) -> String { if images.isEmpty { @@ -2224,6 +2500,12 @@ struct UnCLI { try handleKeyCommand(pk, sk) case "languages": try handleLanguagesCommand(args, pk, sk) + case "logs": + try handleLogsCommand(args, pk, sk) + case "health": + try handleHealthCommand() + case "version": + handleVersionCommand() default: if args.source != nil || args.shell != nil { try handleExecuteCommand(args, pk, sk) diff --git a/clients/tcl/sync/src/un.tcl b/clients/tcl/sync/src/un.tcl index 2e4837e..673989e 100755 --- a/clients/tcl/sync/src/un.tcl +++ b/clients/tcl/sync/src/un.tcl @@ -1,42 +1,21 @@ #!/usr/bin/env tclsh # PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # -# This is free public domain software for the public good of a permacomputer hosted -# at permacomputer.com - an always-on computer by the people, for the people. One -# which is durable, easy to repair, and distributed like tap water for machine -# learning intelligence. +# unsandbox.com TCL SDK (Synchronous) +# Full API with execution, sessions, services, snapshots, and images. # -# The permacomputer is community-owned infrastructure optimized around four values: +# Library Usage: +# source un.tcl +# set result [Un::execute "python" "print(42)"] +# puts [dict get $result stdout] # -# TRUTH - First principles, math & science, open source code freely distributed -# FREEDOM - Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY - Minimal waste, self-renewing systems with diverse thriving connections -# LOVE - Be yourself without hurting others, cooperation through natural law -# -# This software contributes to that vision by enabling code execution across 42+ -# programming languages through a unified interface, accessible to all. Code is -# seeds to sprout on any abandoned technology. -# -# Learn more: https://www.permacomputer.com -# -# Anyone is free to copy, modify, publish, use, compile, sell, or distribute this -# software, either in source code form or as a compiled binary, for any purpose, -# commercial or non-commercial, and by any means. -# -# NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. -# -# That said, our permacomputer's digital membrane stratum continuously runs unit, -# integration, and functional tests on all of it's own software - with our -# permacomputer monitoring itself, repairing itself, with minimal human in the -# loop guidance. Our agents do their best. +# CLI Usage: +# tclsh un.tcl script.py +# tclsh un.tcl -s python 'print(42)' +# tclsh un.tcl session --list +# tclsh un.tcl service --list # # Copyright 2025 TimeHexOn & foxhop & russell@unturf -# https://www.timehexon.com -# https://www.foxhop.net -# https://www.unturf.com/software - -# unsandbox CLI - TCL implementation -# Full-featured CLI matching un.c/un.py capabilities package require http package require json @@ -47,1386 +26,1055 @@ package require sha256 # Register https support ::http::register https 443 ::tls::socket -set API_BASE "https://api.unsandbox.com" -set PORTAL_BASE "https://unsandbox.com" -set LANGUAGES_CACHE_TTL 3600 -set LANGUAGES_CACHE_FILE [file join $::env(HOME) ".unsandbox" "languages.json"] -set BLUE "\033\[34m" -set RED "\033\[31m" -set GREEN "\033\[32m" -set YELLOW "\033\[33m" -set RESET "\033\[0m" +namespace eval Un { + variable VERSION "4.2.50" + variable API_BASE "https://api.unsandbox.com" + variable PORTAL_BASE "https://unsandbox.com" + variable LANGUAGES_CACHE_TTL 3600 + variable LANGUAGES_CACHE_FILE [file join $::env(HOME) ".unsandbox" "languages.json"] + variable LAST_ERROR "" -# Extension to language mapping -array set EXT_MAP { - .py python .js javascript .ts typescript - .rb ruby .php php .pl perl .lua lua - .sh bash .go go .rs rust .c c - .cpp cpp .cc cpp .cxx cpp - .java java .kt kotlin .cs csharp .fs fsharp - .hs haskell .ml ocaml .clj clojure .scm scheme - .lisp commonlisp .erl erlang .ex elixir .exs elixir - .jl julia .r r .R r .cr crystal - .d d .nim nim .zig zig .v vlang - .dart dart .groovy groovy .scala scala - .f90 fortran .f95 fortran .cob cobol - .pro prolog .forth forth .4th forth - .tcl tcl .raku raku .m objc -} + # Colors + variable BLUE "\033\[34m" + variable RED "\033\[31m" + variable GREEN "\033\[32m" + variable YELLOW "\033\[33m" + variable RESET "\033\[0m" -proc get_api_keys {} { - set public_key "" - set secret_key "" - - if {[info exists ::env(UNSANDBOX_PUBLIC_KEY)]} { - set public_key $::env(UNSANDBOX_PUBLIC_KEY) - } - if {[info exists ::env(UNSANDBOX_SECRET_KEY)]} { - set secret_key $::env(UNSANDBOX_SECRET_KEY) + # Extension to language mapping + variable EXT_MAP + array set EXT_MAP { + .py python .js javascript .ts typescript + .rb ruby .php php .pl perl .lua lua + .sh bash .go go .rs rust .c c + .cpp cpp .cc cpp .cxx cpp + .java java .kt kotlin .cs csharp .fs fsharp + .hs haskell .ml ocaml .clj clojure .scm scheme + .lisp commonlisp .erl erlang .ex elixir .exs elixir + .jl julia .r r .R r .cr crystal + .d d .nim nim .zig zig .v vlang + .dart dart .groovy groovy .scala scala + .f90 fortran .f95 fortran .cob cobol + .pro prolog .forth forth .4th forth + .tcl tcl .raku raku .m objc } - # Fallback to old UNSANDBOX_API_KEY for backwards compat - if {$public_key eq "" && [info exists ::env(UNSANDBOX_API_KEY)]} { - set public_key $::env(UNSANDBOX_API_KEY) - set secret_key "" + # ============================================================================ + # Utility Functions + # ============================================================================ + + proc version {} { + variable VERSION + return $VERSION } - if {$public_key eq ""} { - puts stderr "${::RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${::RESET}" - exit 1 - } - return [list $public_key $secret_key] -} - -proc detect_language {filename} { - set ext [file extension $filename] - if {[info exists ::EXT_MAP($ext)]} { - return $::EXT_MAP($ext) + proc last_error {} { + variable LAST_ERROR + return $LAST_ERROR } - # Try reading shebang - if {[catch {open $filename r} fp] == 0} { - set first_line [gets $fp] + proc set_error {msg} { + variable LAST_ERROR + set LAST_ERROR $msg + } + + proc detect_language {filename} { + variable EXT_MAP + if {$filename eq ""} { return "" } + set ext [file extension $filename] + if {[info exists EXT_MAP($ext)]} { + return $EXT_MAP($ext) + } + return "" + } + + proc hmac_sign {secret message} { + if {$secret eq "" || $message eq ""} { return "" } + return [::sha2::hmac -hex -key $secret $message] + } + + proc health_check {} { + variable API_BASE + if {[catch { + set token [::http::geturl "$API_BASE/health" -timeout 5000] + set ncode [::http::ncode $token] + ::http::cleanup $token + return [expr {$ncode == 200}] + }]} { + return 0 + } + } + + # ============================================================================ + # Credential Management + # ============================================================================ + + proc load_accounts_csv {path} { + if {![file exists $path]} { return "" } + if {[catch {open $path r} fp]} { return "" } + set line [gets $fp] close $fp - if {[string match "#!*" $first_line]} { - if {[string match "*python*" $first_line]} { return "python" } - if {[string match "*node*" $first_line]} { return "javascript" } - if {[string match "*ruby*" $first_line]} { return "ruby" } - if {[string match "*perl*" $first_line]} { return "perl" } - if {[string match "*bash*" $first_line] || [string match "*/sh*" $first_line]} { return "bash" } + if {[string index $line 0] eq "#"} { return "" } + return $line + } + + proc get_credentials {{public_key ""} {secret_key ""}} { + # Tier 1: Arguments + if {$public_key ne "" && $secret_key ne ""} { + return [list $public_key $secret_key] } + + # Tier 2: Environment + if {[info exists ::env(UNSANDBOX_PUBLIC_KEY)] && [info exists ::env(UNSANDBOX_SECRET_KEY)]} { + return [list $::env(UNSANDBOX_PUBLIC_KEY) $::env(UNSANDBOX_SECRET_KEY)] + } + + # Legacy fallback + if {[info exists ::env(UNSANDBOX_API_KEY)]} { + return [list $::env(UNSANDBOX_API_KEY) ""] + } + + # Tier 3: Home directory + set creds [load_accounts_csv [file join $::env(HOME) ".unsandbox" "accounts.csv"]] + if {$creds ne ""} { + set parts [split $creds ","] + return [list [lindex $parts 0] [lindex $parts 1]] + } + + # Tier 4: Local directory + set creds [load_accounts_csv "./accounts.csv"] + if {$creds ne ""} { + set parts [split $creds ","] + return [list [lindex $parts 0] [lindex $parts 1]] + } + + set_error "No credentials found" + error "No credentials found" } - puts stderr "${::RED}Error: Cannot detect language for $filename${::RESET}" - exit 1 -} + # ============================================================================ + # API Communication + # ============================================================================ -proc api_request {endpoint method data public_key secret_key {extra_headers {}}} { - set url "${::API_BASE}${endpoint}" - set headers [list Authorization "Bearer $public_key" Content-Type "application/json"] + proc api_request {endpoint method body {public_key ""} {secret_key ""} {extra_headers {}} {content_type "application/json"}} { + variable API_BASE - set json_data "" - if {$method ne "GET" && $method ne "DELETE" && [llength $data] > 0} { - set json_data [::json::write object {*}$data] - } + lassign [get_credentials $public_key $secret_key] pk sk - # Add HMAC signature if secret_key is present - if {$secret_key ne ""} { - set timestamp [clock seconds] - set sig_input "${timestamp}:${method}:${endpoint}:${json_data}" - set signature [::sha2::hmac -hex -key $secret_key $sig_input] - lappend headers X-Timestamp $timestamp - lappend headers X-Signature $signature - } + set headers [list Authorization "Bearer $pk" Content-Type $content_type] - # Add extra headers (for sudo OTP) - foreach {k v} $extra_headers { - lappend headers $k $v - } + # Add HMAC signature if secret key exists + if {$sk ne ""} { + set timestamp [clock seconds] + set sig_input "${timestamp}:${method}:${endpoint}:${body}" + set signature [hmac_sign $sk $sig_input] + lappend headers X-Timestamp $timestamp + lappend headers X-Signature $signature + } - if {$method eq "GET"} { - set token [::http::geturl $url -headers $headers -timeout 300000] - } elseif {$method eq "DELETE"} { - set token [::http::geturl $url -method DELETE -headers $headers -timeout 300000] - } else { - set token [::http::geturl $url -method $method -headers $headers -query $json_data -timeout 300000] - } + # Add extra headers + foreach {k v} $extra_headers { + lappend headers $k $v + } - set status [::http::status $token] - set ncode [::http::ncode $token] - set body [::http::data $token] - ::http::cleanup $token + set url "${API_BASE}${endpoint}" - if {$status ne "ok" || ($ncode != 200 && $ncode != 201)} { - if {$ncode == 401 && [string match -nocase "*timestamp*" $body]} { - puts stderr "${::RED}Error: Request timestamp expired (must be within 5 minutes of server time)${::RESET}" - puts stderr "${::YELLOW}Your computer's clock may have drifted.${::RESET}" - puts stderr "Check your system time and sync with NTP if needed:" - puts stderr " Linux: sudo ntpdate -s time.nist.gov" - puts stderr " macOS: sudo sntp -sS time.apple.com" - puts stderr " Windows: w32tm /resync" + if {$method eq "GET"} { + set token [::http::geturl $url -headers $headers -timeout 300000] + } elseif {$method eq "DELETE"} { + set token [::http::geturl $url -method DELETE -headers $headers -timeout 300000] } else { - puts stderr "${::RED}Error: HTTP $ncode${::RESET}" - puts stderr $body + set token [::http::geturl $url -method $method -headers $headers -query $body -timeout 300000] } - exit 1 + + set ncode [::http::ncode $token] + set response [::http::data $token] + ::http::cleanup $token + + return [list $ncode $response] } - return [::json::json2dict $body] -} - -# Handle 428 Sudo OTP challenge - prompt user for OTP and retry -proc handle_sudo_challenge {response_body endpoint method data public_key secret_key} { - # Extract challenge_id from response - set challenge_id "" - if {[catch {set response_data [::json::json2dict $response_body]}] == 0} { - if {[dict exists $response_data challenge_id]} { - set challenge_id [dict get $response_data challenge_id] + proc api_request_json {endpoint method body {public_key ""} {secret_key ""}} { + lassign [api_request $endpoint $method $body $public_key $secret_key] ncode response + if {$ncode >= 200 && $ncode < 300} { + if {[catch {::json::json2dict $response} result]} { + return $response + } + return $result } + set_error "API error ($ncode): $response" + error "API error ($ncode)" } - puts stderr "${::YELLOW}Confirmation required. Check your email for a one-time code.${::RESET}" - puts -nonewline stderr "Enter OTP: " - flush stderr + proc api_request_with_sudo {endpoint method body {public_key ""} {secret_key ""}} { + variable YELLOW RED GREEN RESET - gets stdin otp - set otp [string trim $otp] + lassign [api_request $endpoint $method $body $public_key $secret_key] ncode response - if {$otp eq ""} { - puts stderr "${::RED}Error: Operation cancelled${::RESET}" - return 0 - } - - # Retry with sudo headers - set extra_headers [list X-Sudo-OTP $otp] - if {$challenge_id ne ""} { - lappend extra_headers X-Sudo-Challenge $challenge_id - } - - if {[catch {api_request $endpoint $method $data $public_key $secret_key $extra_headers} result]} { - return 0 - } - - puts "${::GREEN}Operation completed successfully${::RESET}" - return 1 -} - -# API request with 428 sudo handling for destructive operations -proc api_request_with_sudo {endpoint method data public_key secret_key} { - set url "${::API_BASE}${endpoint}" - set headers [list Authorization "Bearer $public_key" Content-Type "application/json"] - - set json_data "" - if {$method ne "GET" && $method ne "DELETE" && [llength $data] > 0} { - set json_data [::json::write object {*}$data] - } - - # Add HMAC signature if secret_key is present - if {$secret_key ne ""} { - set timestamp [clock seconds] - set sig_input "${timestamp}:${method}:${endpoint}:${json_data}" - set signature [::sha2::hmac -hex -key $secret_key $sig_input] - lappend headers X-Timestamp $timestamp - lappend headers X-Signature $signature - } - - if {$method eq "GET"} { - set token [::http::geturl $url -headers $headers -timeout 300000] - } elseif {$method eq "DELETE"} { - set token [::http::geturl $url -method DELETE -headers $headers -timeout 300000] - } else { - set token [::http::geturl $url -method $method -headers $headers -query $json_data -timeout 300000] - } - - set status [::http::status $token] - set ncode [::http::ncode $token] - set body [::http::data $token] - ::http::cleanup $token - - # Handle 428 Precondition Required (sudo OTP needed) - if {$ncode == 428} { - return [handle_sudo_challenge $body $endpoint $method $data $public_key $secret_key] - } - - if {$status ne "ok" || ($ncode != 200 && $ncode != 201)} { - puts stderr "${::RED}Error: HTTP $ncode${::RESET}" - puts stderr $body - exit 1 - } - - return [::json::json2dict $body] -} - -proc api_request_text {endpoint method body public_key secret_key} { - set url "${::API_BASE}${endpoint}" - set headers [list Authorization "Bearer $public_key" Content-Type "text/plain"] - - # Add HMAC signature if secret_key is present - if {$secret_key ne ""} { - set timestamp [clock seconds] - set sig_input "${timestamp}:${method}:${endpoint}:${body}" - set signature [::sha2::hmac -hex -key $secret_key $sig_input] - lappend headers X-Timestamp $timestamp - lappend headers X-Signature $signature - } - - set token [::http::geturl $url -method $method -headers $headers -query $body -timeout 300000] - set status [::http::status $token] - set ncode [::http::ncode $token] - set response [::http::data $token] - ::http::cleanup $token - - return [list $ncode $response] -} - -proc read_env_file {path} { - if {![file exists $path]} { - puts stderr "${::RED}Error: Env file not found: $path${::RESET}" - exit 1 - } - set fp [open $path r] - set content [read $fp] - close $fp - return $content -} - -proc build_env_content {envs env_file} { - set lines [list] - - # Add from -e flags - foreach env $envs { - lappend lines $env - } - - # Add from --env-file - if {$env_file ne ""} { - set content [read_env_file $env_file] - foreach line [split $content "\n"] { - set line [string trim $line] - if {$line ne "" && [string index $line 0] ne "#"} { - lappend lines $line - } - } - } - - return [join $lines "\n"] -} - -set MAX_ENV_CONTENT_SIZE 65536 - -proc service_env_status {service_id public_key secret_key} { - return [api_request "/services/$service_id/env" "GET" {} $public_key $secret_key] -} - -proc service_env_set {service_id env_content public_key secret_key} { - if {[string length $env_content] > $::MAX_ENV_CONTENT_SIZE} { - puts stderr "${::RED}Error: Env content exceeds maximum size of 64KB${::RESET}" - return 0 - } - - lassign [api_request_text "/services/$service_id/env" "PUT" $env_content $public_key $secret_key] ncode response - if {$ncode == 200 || $ncode == 201} { - return 1 - } - return 0 -} - -proc service_env_export {service_id public_key secret_key} { - return [api_request "/services/$service_id/env/export" "POST" {} $public_key $secret_key] -} - -proc service_env_delete {service_id public_key secret_key} { - if {[catch {api_request "/services/$service_id/env" "DELETE" {} $public_key $secret_key}]} { - return 0 - } - return 1 -} - -proc cmd_service_env {action target envs env_file public_key secret_key} { - switch -exact -- $action { - status { - if {$target eq ""} { - puts stderr "${::RED}Error: service env status requires service ID${::RESET}" - exit 1 - } - set result [service_env_status $target $public_key $secret_key] - if {[dict exists $result has_vault] && [dict get $result has_vault]} { - puts "${::GREEN}Vault: configured${::RESET}" - if {[dict exists $result env_count]} { - puts "Variables: [dict get $result env_count]" - } - if {[dict exists $result updated_at]} { - puts "Updated: [dict get $result updated_at]" - } - } else { - puts "${::YELLOW}Vault: not configured${::RESET}" - } - } - set { - if {$target eq ""} { - puts stderr "${::RED}Error: service env set requires service ID${::RESET}" - exit 1 - } - if {[llength $envs] == 0 && $env_file eq ""} { - puts stderr "${::RED}Error: service env set requires -e or --env-file${::RESET}" - exit 1 - } - set env_content [build_env_content $envs $env_file] - if {[service_env_set $target $env_content $public_key $secret_key]} { - puts "${::GREEN}Vault updated for service $target${::RESET}" - } else { - puts stderr "${::RED}Error: Failed to update vault${::RESET}" - exit 1 - } - } - export { - if {$target eq ""} { - puts stderr "${::RED}Error: service env export requires service ID${::RESET}" - exit 1 - } - set result [service_env_export $target $public_key $secret_key] - if {[dict exists $result content]} { - puts -nonewline [dict get $result content] - } - } - delete { - if {$target eq ""} { - puts stderr "${::RED}Error: service env delete requires service ID${::RESET}" - exit 1 - } - if {[service_env_delete $target $public_key $secret_key]} { - puts "${::GREEN}Vault deleted for service $target${::RESET}" - } else { - puts stderr "${::RED}Error: Failed to delete vault${::RESET}" - exit 1 - } - } - default { - puts stderr "${::RED}Error: Unknown env action: $action${::RESET}" - puts stderr "Usage: un.tcl service env " - exit 1 - } - } -} - -proc cmd_execute {args} { - lassign [get_api_keys] public_key secret_key - set source_file "" - set env_vars [dict create] - set input_files [list] - set artifacts 0 - set output_dir "." - set network "" - set vcpu 0 - - # Parse arguments - for {set i 0} {$i < [llength $args]} {incr i} { - set arg [lindex $args $i] - switch -exact -- $arg { - -e { - incr i - set env_spec [lindex $args $i] - if {[regexp {^([^=]+)=(.*)$} $env_spec -> key value]} { - dict set env_vars $key $value + # Handle 428 - Sudo OTP required + if {$ncode == 428} { + set challenge_id "" + if {[catch {::json::json2dict $response} resp_dict] == 0} { + if {[dict exists $resp_dict challenge_id]} { + set challenge_id [dict get $resp_dict challenge_id] } } - -f { - incr i - lappend input_files [lindex $args $i] + + puts stderr "${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}" + puts -nonewline stderr "Enter OTP: " + flush stderr + + gets stdin otp + set otp [string trim $otp] + + if {$otp eq ""} { + set_error "Operation cancelled" + error "Operation cancelled" } - -a { - set artifacts 1 + + # Retry with sudo headers + set extra_headers [list X-Sudo-OTP $otp] + if {$challenge_id ne ""} { + lappend extra_headers X-Sudo-Challenge $challenge_id } - -o { - incr i - set output_dir [lindex $args $i] - } - -n { - incr i - set network [lindex $args $i] - } - -v { - incr i - set vcpu [lindex $args $i] - } - default { - set source_file $arg - } - } - } - if {$source_file eq ""} { - puts stderr "Usage: un.tcl \[options\] " - exit 1 - } - - if {![file exists $source_file]} { - puts stderr "${::RED}Error: File not found: $source_file${::RESET}" - exit 1 - } - - # Read source file - set fp [open $source_file r] - set code [read $fp] - close $fp - - set language [detect_language $source_file] - - # Build request payload - set payload [list language [::json::write string $language] code [::json::write string $code]] - - # Add environment variables - if {[dict size $env_vars] > 0} { - set env_json [list] - dict for {key value} $env_vars { - lappend env_json $key [::json::write string $value] - } - lappend payload env [::json::write object {*}$env_json] - } - - # Add input files - if {[llength $input_files] > 0} { - set files_json [list] - foreach filepath $input_files { - if {![file exists $filepath]} { - puts stderr "${::RED}Error: Input file not found: $filepath${::RESET}" - exit 1 - } - set fp [open $filepath rb] - set content [read $fp] - close $fp - set b64_content [::base64::encode $content] - lappend files_json [::json::write object \ - filename [::json::write string [file tail $filepath]] \ - content_base64 [::json::write string $b64_content]] - } - lappend payload input_files [::json::write array {*}$files_json] - } - - # Add options - if {$artifacts} { - lappend payload return_artifacts [::json::write string true] - } - if {$network ne ""} { - lappend payload network [::json::write string $network] - } - if {$vcpu > 0} { - lappend payload vcpu $vcpu - } - - # Execute - set result [api_request "/execute" "POST" $payload $public_key $secret_key] - - # Print output - if {[dict exists $result stdout]} { - set stdout_text [dict get $result stdout] - if {$stdout_text ne ""} { - puts -nonewline "${::BLUE}${stdout_text}${::RESET}" - } - } - if {[dict exists $result stderr]} { - set stderr_text [dict get $result stderr] - if {$stderr_text ne ""} { - puts -nonewline stderr "${::RED}${stderr_text}${::RESET}" - } - } - - # Save artifacts - if {$artifacts && [dict exists $result artifacts]} { - file mkdir $output_dir - foreach artifact [dict get $result artifacts] { - set filename [dict get $artifact filename] - set content [::base64::decode [dict get $artifact content_base64]] - set path [file join $output_dir $filename] - set fp [open $path wb] - puts -nonewline $fp $content - close $fp - file attributes $path -permissions 0755 - puts stderr "${::GREEN}Saved: $path${::RESET}" - } - } - - set exit_code 0 - if {[dict exists $result exit_code]} { - set exit_code [dict get $result exit_code] - } - exit $exit_code -} - -proc cmd_session {args} { - lassign [get_api_keys] public_key secret_key - set list_mode 0 - set kill_id "" - set shell "" - set network "" - set vcpu 0 - set input_files [list] - - # Parse arguments - for {set i 0} {$i < [llength $args]} {incr i} { - set arg [lindex $args $i] - switch -exact -- $arg { - --list { - set list_mode 1 - } - --kill { - incr i - set kill_id [lindex $args $i] - } - --shell { - incr i - set shell [lindex $args $i] - } - -n { - incr i - set network [lindex $args $i] - } - -v { - incr i - set vcpu [lindex $args $i] - } - -f { - incr i - lappend input_files [lindex $args $i] - } - default { - if {[string index $arg 0] eq "-"} { - puts stderr "${::RED}Unknown option: $arg${::RESET}" - puts stderr "Usage: un.tcl session \[options\]" - exit 1 - } - } - } - } - - if {$list_mode} { - set result [api_request "/sessions" "GET" {} $public_key $secret_key] - set sessions [dict get $result sessions] - if {[llength $sessions] == 0} { - puts "No active sessions" - } else { - puts [format "%-40s %-10s %-10s %s" "ID" "Shell" "Status" "Created"] - foreach s $sessions { - puts [format "%-40s %-10s %-10s %s" \ - [dict get $s id] \ - [dict get $s shell] \ - [dict get $s status] \ - [dict get $s created_at]] - } - } - return - } - - if {$kill_id ne ""} { - api_request "/sessions/$kill_id" "DELETE" {} $public_key $secret_key - puts "${::GREEN}Session terminated: $kill_id${::RESET}" - return - } - - # Create new session - set payload [list] - if {$shell ne ""} { - lappend payload shell [::json::write string $shell] - } else { - lappend payload shell [::json::write string "bash"] - } - if {$network ne ""} { - lappend payload network [::json::write string $network] - } - if {$vcpu > 0} { - lappend payload vcpu $vcpu - } - - # Add input files - if {[llength $input_files] > 0} { - set files_json [list] - foreach filepath $input_files { - if {![file exists $filepath]} { - puts stderr "${::RED}Error: Input file not found: $filepath${::RESET}" - exit 1 - } - set fp [open $filepath rb] - set content [read $fp] - close $fp - set b64_content [::base64::encode $content] - lappend files_json [::json::write object \ - filename [::json::write string [file tail $filepath]] \ - content_base64 [::json::write string $b64_content]] - } - lappend payload input_files [::json::write array {*}$files_json] - } - - puts "${::YELLOW}Creating session...${::RESET}" - set result [api_request "/sessions" "POST" $payload $public_key $secret_key] - puts "${::GREEN}Session created: [dict get $result id]${::RESET}" - puts "${::YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${::RESET}" -} - -proc read_languages_cache {} { - if {![file exists $::LANGUAGES_CACHE_FILE]} { - return {} - } - - if {[catch {open $::LANGUAGES_CACHE_FILE r} fp]} { - return {} - } - set content [read $fp] - close $fp - - if {[catch {::json::json2dict $content} cache_data]} { - return {} - } - - # Check if cache is valid (within TTL) - if {[dict exists $cache_data timestamp]} { - set cache_time [dict get $cache_data timestamp] - set current_time [clock seconds] - if {($current_time - $cache_time) < $::LANGUAGES_CACHE_TTL} { - if {[dict exists $cache_data languages]} { - return [dict get $cache_data languages] - } - } - } - return {} -} - -proc write_languages_cache {languages} { - # Ensure ~/.unsandbox directory exists - set cache_dir [file dirname $::LANGUAGES_CACHE_FILE] - if {![file exists $cache_dir]} { - file mkdir $cache_dir - } - - # Build cache JSON - set json_langs [list] - foreach lang $languages { - lappend json_langs [::json::write string $lang] - } - set langs_array [::json::write array {*}$json_langs] - set timestamp [clock seconds] - set cache_json [::json::write object languages $langs_array timestamp $timestamp] - - # Write cache file - set fp [open $::LANGUAGES_CACHE_FILE w] - puts -nonewline $fp $cache_json - close $fp -} - -proc cmd_languages {args} { - set json_output 0 - - # Parse arguments - for {set i 0} {$i < [llength $args]} {incr i} { - set arg [lindex $args $i] - if {$arg eq "--json"} { - set json_output 1 - } - } - - # Try to read from cache first - set cached_langs [read_languages_cache] - if {[llength $cached_langs] > 0} { - if {$json_output} { - set json_langs [list] - foreach lang $cached_langs { - lappend json_langs [::json::write string $lang] - } - puts [::json::write array {*}$json_langs] - } else { - foreach lang $cached_langs { - puts $lang - } - } - return - } - - # No valid cache, fetch from API - lassign [get_api_keys] public_key secret_key - set result [api_request "/languages" "GET" {} $public_key $secret_key] - set langs [dict get $result languages] - - # Save to cache - write_languages_cache $langs - - if {$json_output} { - # JSON array output - set json_langs [list] - foreach lang $langs { - lappend json_langs [::json::write string $lang] - } - puts [::json::write array {*}$json_langs] - } else { - # One language per line (default) - foreach lang $langs { - puts $lang - } - } -} - -proc cmd_key {args} { - lassign [get_api_keys] public_key secret_key - set extend_mode 0 - - # Parse arguments - for {set i 0} {$i < [llength $args]} {incr i} { - set arg [lindex $args $i] - switch -exact -- $arg { - --extend { - set extend_mode 1 - } - } - } - - # POST to /keys/validate with Bearer auth - set url "${::PORTAL_BASE}/keys/validate" - set headers [list Authorization "Bearer $public_key" Content-Type "application/json"] - - # Add HMAC signature if secret_key is present - if {$secret_key ne ""} { - set timestamp [clock seconds] - set sig_input "${timestamp}:POST:/keys/validate:" - set signature [::sha2::hmac -hex -key $secret_key $sig_input] - lappend headers X-Timestamp $timestamp - lappend headers X-Signature $signature - } - - set token [::http::geturl $url -method POST -headers $headers -timeout 30000] - set status [::http::status $token] - set ncode [::http::ncode $token] - set body [::http::data $token] - ::http::cleanup $token - - if {$status ne "ok"} { - puts stderr "${::RED}Error: Failed to connect to validation endpoint${::RESET}" - exit 1 - } - - if {$ncode == 401 || $ncode == 403} { - puts "${::RED}Invalid${::RESET}" - puts "Status: Invalid API key" - exit 1 - } - - if {$ncode != 200} { - puts stderr "${::RED}Error: HTTP $ncode${::RESET}" - puts stderr $body - exit 1 - } - - set result [::json::json2dict $body] - set key_status [dict get $result status] - set public_key [dict get $result public_key] - set tier [dict get $result tier] - - if {$key_status eq "valid"} { - puts "${::GREEN}Valid${::RESET}" - puts "Public Key: $public_key" - puts "Tier: $tier" - - if {[dict exists $result expires_at]} { - set expires_at [dict get $result expires_at] - puts "Expires: $expires_at" + lassign [api_request $endpoint $method $body $public_key $secret_key $extra_headers] ncode response } - if {$extend_mode} { - set extend_url "${::PORTAL_BASE}/keys/extend?pk=${public_key}" - puts "${::YELLOW}Opening browser to extend key...${::RESET}" - exec xdg-open $extend_url & - } - } elseif {$key_status eq "expired"} { - puts "${::RED}Expired${::RESET}" - puts "Public Key: $public_key" - puts "Tier: $tier" - - if {[dict exists $result expired_at]} { - set expired_at [dict get $result expired_at] - puts "Expired: $expired_at" - } - - puts "${::YELLOW}To renew: Visit ${::PORTAL_BASE}/keys/extend${::RESET}" - - if {$extend_mode} { - set extend_url "${::PORTAL_BASE}/keys/extend?pk=${public_key}" - puts "${::YELLOW}Opening browser to extend key...${::RESET}" - exec xdg-open $extend_url & - } - } else { - puts "${::RED}Invalid${::RESET}" - puts "Status: Unknown key status" - exit 1 - } -} - -proc cmd_image {args} { - lassign [get_api_keys] public_key secret_key - set list_mode 0 - set info_id "" - set delete_id "" - set lock_id "" - set unlock_id "" - set publish_id "" - set source_type "" - set visibility_id "" - set visibility_mode "" - set spawn_id "" - set clone_id "" - set name "" - set ports "" - - # Parse arguments - for {set i 0} {$i < [llength $args]} {incr i} { - set arg [lindex $args $i] - switch -exact -- $arg { - --list { - set list_mode 1 - } - -l { - set list_mode 1 - } - --info { - incr i - set info_id [lindex $args $i] - } - --delete { - incr i - set delete_id [lindex $args $i] - } - --lock { - incr i - set lock_id [lindex $args $i] - } - --unlock { - incr i - set unlock_id [lindex $args $i] - } - --publish { - incr i - set publish_id [lindex $args $i] - } - --source-type { - incr i - set source_type [lindex $args $i] - } - --visibility { - incr i - set visibility_id [lindex $args $i] - incr i - if {$i < [llength $args] && [string index [lindex $args $i] 0] ne "-"} { - set visibility_mode [lindex $args $i] - } else { - incr i -1 - } - } - --spawn { - incr i - set spawn_id [lindex $args $i] - } - --clone { - incr i - set clone_id [lindex $args $i] - } - --name { - incr i - set name [lindex $args $i] - } - --ports { - incr i - set ports [lindex $args $i] - } + if {$ncode >= 200 && $ncode < 300} { + if {[catch {::json::json2dict $response} result]} { + return $response + } + return $result } + set_error "API error ($ncode)" + error "API error ($ncode)" } - if {$list_mode} { - set result [api_request "/images" "GET" {} $public_key $secret_key] - set images [dict get $result images] - if {[llength $images] == 0} { - puts "No images found" - } else { - puts [format "%-40s %-20s %-12s %s" "ID" "Name" "Visibility" "Created"] - foreach img $images { - puts [format "%-40s %-20s %-12s %s" \ - [dict get $img id] \ - [expr {[dict exists $img name] ? [dict get $img name] : "-"}] \ - [dict get $img visibility] \ - [dict get $img created_at]] + # ============================================================================ + # Execution Functions (8) + # ============================================================================ + + proc execute {language code {network_mode "zerotrust"} {public_key ""} {secret_key ""}} { + set body [::json::write object \ + language [::json::write string $language] \ + code [::json::write string $code] \ + network_mode [::json::write string $network_mode] \ + ttl 60] + return [api_request_json "/execute" "POST" $body $public_key $secret_key] + } + + proc execute_async {language code {network_mode "zerotrust"} {public_key ""} {secret_key ""}} { + set body [::json::write object \ + language [::json::write string $language] \ + code [::json::write string $code] \ + network_mode [::json::write string $network_mode] \ + ttl 300] + return [api_request_json "/execute/async" "POST" $body $public_key $secret_key] + } + + proc wait_job {job_id {public_key ""} {secret_key ""}} { + set delays {300 450 700 900 650 1600 2000} + for {set i 0} {$i < 120} {incr i} { + set job [get_job $job_id $public_key $secret_key] + set status [dict get $job status] + if {$status eq "completed"} { return $job } + if {$status eq "failed"} { + set_error "Job failed" + error "Job failed" } + set delay [lindex $delays [expr {$i % 7}]] + after $delay } - return + set_error "Max polls exceeded" + error "Max polls exceeded" } - if {$info_id ne ""} { - set result [api_request "/images/$info_id" "GET" {} $public_key $secret_key] - puts "${::BLUE}Image Details${::RESET}" - puts "" - puts "Image ID: [dict get $result id]" - puts "Name: [expr {[dict exists $result name] ? [dict get $result name] : \"-\"}]" - puts "Visibility: [dict get $result visibility]" - puts "Created: [dict get $result created_at]" - return + proc get_job {job_id {public_key ""} {secret_key ""}} { + return [api_request_json "/jobs/$job_id" "GET" "" $public_key $secret_key] } - if {$delete_id ne ""} { - api_request_with_sudo "/images/$delete_id" "DELETE" {} $public_key $secret_key - puts "${::GREEN}Image deleted successfully${::RESET}" - return + proc cancel_job {job_id {public_key ""} {secret_key ""}} { + return [api_request_json "/jobs/$job_id" "DELETE" "" $public_key $secret_key] } - if {$lock_id ne ""} { - api_request "/images/$lock_id/lock" "POST" {} $public_key $secret_key - puts "${::GREEN}Image locked successfully${::RESET}" - return + proc list_jobs {{public_key ""} {secret_key ""}} { + return [api_request_json "/jobs" "GET" "" $public_key $secret_key] } - if {$unlock_id ne ""} { - api_request_with_sudo "/images/$unlock_id/unlock" "POST" {} $public_key $secret_key - puts "${::GREEN}Image unlocked successfully${::RESET}" - return - } + proc get_languages {{public_key ""} {secret_key ""}} { + variable LANGUAGES_CACHE_FILE LANGUAGES_CACHE_TTL - if {$publish_id ne ""} { - if {$source_type eq ""} { - puts stderr "${::RED}Error: --source-type required for --publish (service or snapshot)${::RESET}" - exit 1 - } - set payload [list source_type [::json::write string $source_type] source_id [::json::write string $publish_id]] - if {$name ne ""} { - lappend payload name [::json::write string $name] - } - set result [api_request "/images/publish" "POST" $payload $public_key $secret_key] - puts "${::GREEN}Image published successfully${::RESET}" - puts "Image ID: [dict get $result id]" - return - } - - if {$visibility_id ne ""} { - if {$visibility_mode eq ""} { - puts stderr "${::RED}Error: visibility mode required (private, unlisted, or public)${::RESET}" - exit 1 - } - set payload [list visibility [::json::write string $visibility_mode]] - api_request "/images/$visibility_id/visibility" "POST" $payload $public_key $secret_key - puts "${::GREEN}Image visibility set to $visibility_mode${::RESET}" - return - } - - if {$spawn_id ne ""} { - set payload [list] - if {$name ne ""} { - lappend payload name [::json::write string $name] - } - if {$ports ne ""} { - set port_list [split $ports ","] - set port_json [list] - foreach p $port_list { - lappend port_json $p - } - lappend payload ports [::json::write array {*}$port_json] - } - set result [api_request "/images/$spawn_id/spawn" "POST" $payload $public_key $secret_key] - puts "${::GREEN}Service spawned from image${::RESET}" - puts "Service ID: [dict get $result id]" - return - } - - if {$clone_id ne ""} { - set payload [list] - if {$name ne ""} { - lappend payload name [::json::write string $name] - } - set result [api_request "/images/$clone_id/clone" "POST" $payload $public_key $secret_key] - puts "${::GREEN}Image cloned successfully${::RESET}" - puts "Image ID: [dict get $result id]" - return - } - - puts stderr "${::RED}Error: Specify --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID${::RESET}" - exit 1 -} - -proc cmd_service {args} { - lassign [get_api_keys] public_key secret_key - set list_mode 0 - set info_id "" - set logs_id "" - set sleep_id "" - set wake_id "" - set destroy_id "" - set resize_id "" - set dump_bootstrap_id "" - set dump_file "" - set name "" - set ports "" - set service_type "" - set bootstrap "" - set bootstrap_file "" - set network "" - set vcpu 0 - set input_files [list] - set envs [list] - set env_file "" - set env_action "" - set env_target "" - set unfreeze_on_demand 0 - set unfreeze_on_demand_id "" - set unfreeze_on_demand_enabled "" - - # Parse arguments - for {set i 0} {$i < [llength $args]} {incr i} { - set arg [lindex $args $i] - switch -exact -- $arg { - env { - # Parse: env [target] - if {$i + 1 < [llength $args]} { - set next [lindex $args [expr {$i + 1}]] - if {[string index $next 0] ne "-"} { - incr i - set env_action $next - if {$i + 1 < [llength $args]} { - set next2 [lindex $args [expr {$i + 1}]] - if {[string index $next2 0] ne "-"} { - incr i - set env_target $next2 - } - } + # Check cache + if {[file exists $LANGUAGES_CACHE_FILE]} { + set mtime [file mtime $LANGUAGES_CACHE_FILE] + set age [expr {[clock seconds] - $mtime}] + if {$age < $LANGUAGES_CACHE_TTL} { + if {[catch {open $LANGUAGES_CACHE_FILE r} fp] == 0} { + set content [read $fp] + close $fp + if {[catch {::json::json2dict $content} cache]} { + return [dict get $cache languages] } } } - --list { - set list_mode 1 - } - --info { - incr i - set info_id [lindex $args $i] - } - --logs { - incr i - set logs_id [lindex $args $i] - } - --freeze { - incr i - set sleep_id [lindex $args $i] - } - --unfreeze { - incr i - set wake_id [lindex $args $i] - } - --destroy { - incr i - set destroy_id [lindex $args $i] - } - --resize { - incr i - set resize_id [lindex $args $i] - } - --dump-bootstrap { - incr i - set dump_bootstrap_id [lindex $args $i] - } - --dump-file { - incr i - set dump_file [lindex $args $i] - } - --name { - incr i - set name [lindex $args $i] - } - --ports { - incr i - set ports [lindex $args $i] - } - --type { - incr i - set service_type [lindex $args $i] - } - --bootstrap { - incr i - set bootstrap [lindex $args $i] - } - --bootstrap-file { - incr i - set bootstrap_file [lindex $args $i] - } - -n { - incr i - set network [lindex $args $i] - } - -v { - incr i - set vcpu [lindex $args $i] - } - -f { - incr i - lappend input_files [lindex $args $i] - } - -e { - incr i - lappend envs [lindex $args $i] - } - --env-file { - incr i - set env_file [lindex $args $i] - } - --unfreeze-on-demand { - set unfreeze_on_demand 1 - } - --set-unfreeze-on-demand { - incr i - set unfreeze_on_demand_id [lindex $args $i] - incr i - set unfreeze_on_demand_enabled [lindex $args $i] - } } + + # Fetch from API + set result [api_request_json "/languages" "GET" "" $public_key $secret_key] + + # Save to cache + file mkdir [file dirname $LANGUAGES_CACHE_FILE] + set cache_json [::json::write object \ + languages [dict get $result languages] \ + timestamp [clock seconds]] + set fp [open $LANGUAGES_CACHE_FILE w] + puts -nonewline $fp $cache_json + close $fp + + return [dict get $result languages] } - # Handle env subcommand - if {$env_action ne ""} { - cmd_service_env $env_action $env_target $envs $env_file $public_key $secret_key - return + # ============================================================================ + # Session Functions (9) + # ============================================================================ + + proc session_list {{public_key ""} {secret_key ""}} { + return [api_request_json "/sessions" "GET" "" $public_key $secret_key] } - # Handle set-unfreeze-on-demand - if {$unfreeze_on_demand_id ne ""} { - set enabled_val [expr {$unfreeze_on_demand_enabled eq "true" || $unfreeze_on_demand_enabled eq "1"}] - set payload [list unfreeze_on_demand [::json::write string [expr {$enabled_val ? "true" : "false"}]]] - api_request "/services/$unfreeze_on_demand_id" "PATCH" $payload $public_key $secret_key - if {$enabled_val} { - puts "${::GREEN}Unfreeze-on-demand enabled for service $unfreeze_on_demand_id${::RESET}" - } else { - puts "${::GREEN}Unfreeze-on-demand disabled for service $unfreeze_on_demand_id${::RESET}" + proc session_get {session_id {public_key ""} {secret_key ""}} { + return [api_request_json "/sessions/$session_id" "GET" "" $public_key $secret_key] + } + + proc session_create {{shell "bash"} {network ""} {vcpu ""} {public_key ""} {secret_key ""}} { + set body [::json::write object shell [::json::write string $shell]] + if {$network ne ""} { + set body [string trimright $body "\}"] + append body ",\"network\":\"$network\"\}" } - return - } - - if {$list_mode} { - set result [api_request "/services" "GET" {} $public_key $secret_key] - set services [dict get $result services] - if {[llength $services] == 0} { - puts "No services" - } else { - puts [format "%-20s %-15s %-10s %-15s %s" "ID" "Name" "Status" "Ports" "Domains"] - foreach s $services { - set port_list [dict get $s ports] - set domain_list [dict get $s domains] - puts [format "%-20s %-15s %-10s %-15s %s" \ - [dict get $s id] \ - [dict get $s name] \ - [dict get $s status] \ - [join $port_list ","] \ - [join $domain_list ","]] - } + if {$vcpu ne ""} { + set body [string trimright $body "\}"] + append body ",\"vcpu\":$vcpu\}" } - return + return [api_request_json "/sessions" "POST" $body $public_key $secret_key] } - if {$info_id ne ""} { - set result [api_request "/services/$info_id" "GET" {} $public_key $secret_key] - puts [::json::write object {*}[dict_to_json_list $result]] - return + proc session_destroy {session_id {public_key ""} {secret_key ""}} { + return [api_request_json "/sessions/$session_id" "DELETE" "" $public_key $secret_key] } - if {$logs_id ne ""} { - set result [api_request "/services/$logs_id/logs" "GET" {} $public_key $secret_key] - puts [dict get $result logs] - return + proc session_freeze {session_id {public_key ""} {secret_key ""}} { + return [api_request_json "/sessions/$session_id/freeze" "POST" "\{\}" $public_key $secret_key] } - if {$sleep_id ne ""} { - api_request "/services/$sleep_id/freeze" "POST" {} $public_key $secret_key - puts "${::GREEN}Service frozen: $sleep_id${::RESET}" - return + proc session_unfreeze {session_id {public_key ""} {secret_key ""}} { + return [api_request_json "/sessions/$session_id/unfreeze" "POST" "\{\}" $public_key $secret_key] } - if {$wake_id ne ""} { - api_request "/services/$wake_id/unfreeze" "POST" {} $public_key $secret_key - puts "${::GREEN}Service unfreezing: $wake_id${::RESET}" - return + proc session_boost {session_id {vcpu 2} {public_key ""} {secret_key ""}} { + set body "\{\"vcpu\":$vcpu\}" + return [api_request_json "/sessions/$session_id/boost" "POST" $body $public_key $secret_key] } - if {$destroy_id ne ""} { - api_request_with_sudo "/services/$destroy_id" "DELETE" {} $public_key $secret_key - puts "${::GREEN}Service destroyed: $destroy_id${::RESET}" - return + proc session_unboost {session_id {public_key ""} {secret_key ""}} { + return [api_request_json "/sessions/$session_id/unboost" "POST" "\{\}" $public_key $secret_key] } - if {$resize_id ne ""} { - if {$vcpu < 1 || $vcpu > 8} { - puts stderr "${::RED}Error: --resize requires --vcpu N (1-8)${::RESET}" - exit 1 - } - set payload [list vcpu $vcpu] - api_request "/services/$resize_id" "PATCH" $payload $public_key $secret_key - set ram [expr {$vcpu * 2}] - puts "${::GREEN}Service resized to $vcpu vCPU, $ram GB RAM${::RESET}" - return + proc session_execute {session_id command {public_key ""} {secret_key ""}} { + set body [::json::write object command [::json::write string $command]] + return [api_request_json "/sessions/$session_id/execute" "POST" $body $public_key $secret_key] } - if {$dump_bootstrap_id ne ""} { - puts stderr "Fetching bootstrap script from $dump_bootstrap_id..." - set payload [list command [::json::write string "cat /tmp/bootstrap.sh"]] - set result [api_request "/services/$dump_bootstrap_id/execute" "POST" $payload $public_key $secret_key] + # ============================================================================ + # Service Functions (17) + # ============================================================================ - if {[dict exists $result stdout] && [dict get $result stdout] ne ""} { - set bootstrap [dict get $result stdout] - if {$dump_file ne ""} { - # Write to file - set fp [open $dump_file w] - puts -nonewline $fp $bootstrap - close $fp - file attributes $dump_file -permissions 0755 - puts "Bootstrap saved to $dump_file" - } else { - # Print to stdout - puts -nonewline $bootstrap - } - } else { - puts stderr "${::RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file)${::RESET}" - exit 1 - } - return + proc service_list {{public_key ""} {secret_key ""}} { + return [api_request_json "/services" "GET" "" $public_key $secret_key] } - # Create new service - if {$name ne ""} { - set payload [list name [::json::write string $name]] + proc service_get {service_id {public_key ""} {secret_key ""}} { + return [api_request_json "/services/$service_id" "GET" "" $public_key $secret_key] + } + proc service_create {name {ports ""} {bootstrap ""} {public_key ""} {secret_key ""}} { + set body [::json::write object name [::json::write string $name]] if {$ports ne ""} { - set port_list [split $ports ","] - set port_json [list] - foreach p $port_list { - lappend port_json $p - } - lappend payload ports [::json::write array {*}$port_json] + set body [string trimright $body "\}"] + append body ",\"ports\":\[$ports\]\}" } - - if {$service_type ne ""} { - lappend payload service_type [::json::write string $service_type] - } - if {$bootstrap ne ""} { - lappend payload bootstrap [::json::write string $bootstrap] + set body [string trimright $body "\}"] + append body ",\"bootstrap\":\"[string map {\" \\\" \\ \\\\} $bootstrap]\"\}" + } + return [api_request_json "/services" "POST" $body $public_key $secret_key] + } + + proc service_destroy {service_id {public_key ""} {secret_key ""}} { + return [api_request_with_sudo "/services/$service_id" "DELETE" "" $public_key $secret_key] + } + + proc service_freeze {service_id {public_key ""} {secret_key ""}} { + return [api_request_json "/services/$service_id/freeze" "POST" "\{\}" $public_key $secret_key] + } + + proc service_unfreeze {service_id {public_key ""} {secret_key ""}} { + return [api_request_json "/services/$service_id/unfreeze" "POST" "\{\}" $public_key $secret_key] + } + + proc service_lock {service_id {public_key ""} {secret_key ""}} { + return [api_request_json "/services/$service_id/lock" "POST" "\{\}" $public_key $secret_key] + } + + proc service_unlock {service_id {public_key ""} {secret_key ""}} { + return [api_request_with_sudo "/services/$service_id/unlock" "POST" "\{\}" $public_key $secret_key] + } + + proc service_set_unfreeze_on_demand {service_id enabled {public_key ""} {secret_key ""}} { + set body "\{\"unfreeze_on_demand\":$enabled\}" + return [api_request_json "/services/$service_id" "PATCH" $body $public_key $secret_key] + } + + proc service_redeploy {service_id {bootstrap ""} {public_key ""} {secret_key ""}} { + set body "\{\}" + if {$bootstrap ne ""} { + set body [::json::write object bootstrap [::json::write string $bootstrap]] + } + return [api_request_json "/services/$service_id/redeploy" "POST" $body $public_key $secret_key] + } + + proc service_logs {service_id {lines ""} {public_key ""} {secret_key ""}} { + set endpoint "/services/$service_id/logs" + if {$lines ne ""} { + append endpoint "?lines=$lines" + } + return [api_request_json $endpoint "GET" "" $public_key $secret_key] + } + + proc service_execute {service_id command {public_key ""} {secret_key ""}} { + set body [::json::write object command [::json::write string $command]] + return [api_request_json "/services/$service_id/execute" "POST" $body $public_key $secret_key] + } + + proc service_env_get {service_id {public_key ""} {secret_key ""}} { + return [api_request_json "/services/$service_id/env" "GET" "" $public_key $secret_key] + } + + proc service_env_set {service_id env_content {public_key ""} {secret_key ""}} { + lassign [api_request "/services/$service_id/env" "PUT" $env_content $public_key $secret_key {} "text/plain"] ncode response + return [expr {$ncode >= 200 && $ncode < 300}] + } + + proc service_env_delete {service_id {public_key ""} {secret_key ""}} { + return [api_request_json "/services/$service_id/env" "DELETE" "" $public_key $secret_key] + } + + proc service_env_export {service_id {public_key ""} {secret_key ""}} { + return [api_request_json "/services/$service_id/env/export" "POST" "\{\}" $public_key $secret_key] + } + + proc service_resize {service_id vcpu {public_key ""} {secret_key ""}} { + set body "\{\"vcpu\":$vcpu\}" + return [api_request_json "/services/$service_id" "PATCH" $body $public_key $secret_key] + } + + # ============================================================================ + # Snapshot Functions (9) + # ============================================================================ + + proc snapshot_list {{public_key ""} {secret_key ""}} { + return [api_request_json "/snapshots" "GET" "" $public_key $secret_key] + } + + proc snapshot_get {snapshot_id {public_key ""} {secret_key ""}} { + return [api_request_json "/snapshots/$snapshot_id" "GET" "" $public_key $secret_key] + } + + proc snapshot_session {session_id {name ""} {hot false} {public_key ""} {secret_key ""}} { + set body "\{\}" + if {$name ne "" || $hot} { + set parts [list] + if {$name ne ""} { lappend parts "\"name\":\"$name\"" } + if {$hot} { lappend parts "\"hot\":true" } + set body "\{[join $parts ","]\}" + } + return [api_request_json "/sessions/$session_id/snapshot" "POST" $body $public_key $secret_key] + } + + proc snapshot_service {service_id {name ""} {hot false} {public_key ""} {secret_key ""}} { + set body "\{\}" + if {$name ne "" || $hot} { + set parts [list] + if {$name ne ""} { lappend parts "\"name\":\"$name\"" } + if {$hot} { lappend parts "\"hot\":true" } + set body "\{[join $parts ","]\}" + } + return [api_request_json "/services/$service_id/snapshot" "POST" $body $public_key $secret_key] + } + + proc snapshot_restore {snapshot_id {public_key ""} {secret_key ""}} { + return [api_request_json "/snapshots/$snapshot_id/restore" "POST" "\{\}" $public_key $secret_key] + } + + proc snapshot_delete {snapshot_id {public_key ""} {secret_key ""}} { + return [api_request_with_sudo "/snapshots/$snapshot_id" "DELETE" "" $public_key $secret_key] + } + + proc snapshot_lock {snapshot_id {public_key ""} {secret_key ""}} { + return [api_request_json "/snapshots/$snapshot_id/lock" "POST" "\{\}" $public_key $secret_key] + } + + proc snapshot_unlock {snapshot_id {public_key ""} {secret_key ""}} { + return [api_request_with_sudo "/snapshots/$snapshot_id/unlock" "POST" "\{\}" $public_key $secret_key] + } + + proc snapshot_clone {snapshot_id {clone_type "session"} {name ""} {public_key ""} {secret_key ""}} { + set parts [list "\"clone_type\":\"$clone_type\""] + if {$name ne ""} { lappend parts "\"name\":\"$name\"" } + set body "\{[join $parts ","]\}" + return [api_request_json "/snapshots/$snapshot_id/clone" "POST" $body $public_key $secret_key] + } + + # ============================================================================ + # Image Functions (13) + # ============================================================================ + + proc image_list {{filter ""} {public_key ""} {secret_key ""}} { + set endpoint "/images" + if {$filter ne ""} { + append endpoint "?filter=$filter" + } + return [api_request_json $endpoint "GET" "" $public_key $secret_key] + } + + proc image_get {image_id {public_key ""} {secret_key ""}} { + return [api_request_json "/images/$image_id" "GET" "" $public_key $secret_key] + } + + proc image_publish {source_type source_id {name ""} {public_key ""} {secret_key ""}} { + set parts [list "\"source_type\":\"$source_type\"" "\"source_id\":\"$source_id\""] + if {$name ne ""} { lappend parts "\"name\":\"$name\"" } + set body "\{[join $parts ","]\}" + return [api_request_json "/images/publish" "POST" $body $public_key $secret_key] + } + + proc image_delete {image_id {public_key ""} {secret_key ""}} { + return [api_request_with_sudo "/images/$image_id" "DELETE" "" $public_key $secret_key] + } + + proc image_lock {image_id {public_key ""} {secret_key ""}} { + return [api_request_json "/images/$image_id/lock" "POST" "\{\}" $public_key $secret_key] + } + + proc image_unlock {image_id {public_key ""} {secret_key ""}} { + return [api_request_with_sudo "/images/$image_id/unlock" "POST" "\{\}" $public_key $secret_key] + } + + proc image_set_visibility {image_id visibility {public_key ""} {secret_key ""}} { + set body "\{\"visibility\":\"$visibility\"\}" + return [api_request_json "/images/$image_id/visibility" "POST" $body $public_key $secret_key] + } + + proc image_grant_access {image_id trusted_key {public_key ""} {secret_key ""}} { + set body "\{\"api_key\":\"$trusted_key\"\}" + return [api_request_json "/images/$image_id/access" "POST" $body $public_key $secret_key] + } + + proc image_revoke_access {image_id trusted_key {public_key ""} {secret_key ""}} { + return [api_request_json "/images/$image_id/access/$trusted_key" "DELETE" "" $public_key $secret_key] + } + + proc image_list_trusted {image_id {public_key ""} {secret_key ""}} { + return [api_request_json "/images/$image_id/access" "GET" "" $public_key $secret_key] + } + + proc image_transfer {image_id to_key {public_key ""} {secret_key ""}} { + set body "\{\"to_api_key\":\"$to_key\"\}" + return [api_request_json "/images/$image_id/transfer" "POST" $body $public_key $secret_key] + } + + proc image_spawn {image_id {name ""} {ports ""} {public_key ""} {secret_key ""}} { + set parts [list] + if {$name ne ""} { lappend parts "\"name\":\"$name\"" } + if {$ports ne ""} { lappend parts "\"ports\":\[$ports\]" } + set body "\{[join $parts ","]\}" + return [api_request_json "/images/$image_id/spawn" "POST" $body $public_key $secret_key] + } + + proc image_clone {image_id {name ""} {public_key ""} {secret_key ""}} { + set body "\{\}" + if {$name ne ""} { + set body "\{\"name\":\"$name\"\}" + } + return [api_request_json "/images/$image_id/clone" "POST" $body $public_key $secret_key] + } + + # ============================================================================ + # PaaS Logs Functions (2) + # ============================================================================ + + proc logs_fetch {{source "all"} {lines 100} {since "1h"} {grep ""} {public_key ""} {secret_key ""}} { + set parts [list "\"source\":\"$source\"" "\"lines\":$lines" "\"since\":\"$since\""] + if {$grep ne ""} { lappend parts "\"grep\":\"$grep\"" } + set body "\{[join $parts ","]\}" + return [api_request_json "/paas/logs" "POST" $body $public_key $secret_key] + } + + proc logs_stream {args} { + set_error "logs_stream requires async support" + error "logs_stream requires async support" + } + + # ============================================================================ + # Key Validation + # ============================================================================ + + proc validate_keys {{public_key ""} {secret_key ""}} { + variable PORTAL_BASE + + lassign [get_credentials $public_key $secret_key] pk sk + + set headers [list Authorization "Bearer $pk" Content-Type "application/json"] + + if {$sk ne ""} { + set timestamp [clock seconds] + set sig_input "${timestamp}:POST:/keys/validate:" + set signature [hmac_sign $sk $sig_input] + lappend headers X-Timestamp $timestamp + lappend headers X-Signature $signature } - if {$bootstrap_file ne ""} { - if {[file exists $bootstrap_file]} { - set fp [open $bootstrap_file r] - set bootstrap_content [read $fp] - close $fp - lappend payload bootstrap_content [::json::write string $bootstrap_content] - } else { - puts stderr "${::RED}Error: Bootstrap file not found: $bootstrap_file${::RESET}" + set token [::http::geturl "$PORTAL_BASE/keys/validate" -method POST -headers $headers -timeout 30000] + set ncode [::http::ncode $token] + set response [::http::data $token] + ::http::cleanup $token + + if {$ncode == 200} { + return [::json::json2dict $response] + } + set_error "Key validation failed ($ncode)" + error "Key validation failed" + } + + # ============================================================================ + # CLI Commands + # ============================================================================ + + proc cmd_languages {args} { + variable BLUE RESET + set json_output 0 + foreach arg $args { + if {$arg eq "--json"} { set json_output 1 } + } + + set langs [get_languages] + + if {$json_output} { + puts $langs + } else { + foreach lang $langs { + puts $lang + } + } + } + + proc cmd_key {args} { + variable GREEN RED YELLOW PORTAL_BASE RESET + + set extend 0 + foreach arg $args { + if {$arg eq "--extend"} { set extend 1 } + } + + set result [validate_keys] + set pk [dict get $result public_key] + + if {$extend && $pk ne ""} { + set url "$PORTAL_BASE/keys/extend?pk=$pk" + puts "${YELLOW}Opening browser to extend key...${RESET}" + catch {exec xdg-open $url &} + return + } + + if {[dict exists $result expired] && [dict get $result expired]} { + puts "${RED}Expired${RESET}" + puts "Public Key: $pk" + puts "Tier: [dict get $result tier]" + puts "${YELLOW}To renew: Visit $PORTAL_BASE/keys/extend${RESET}" + exit 1 + } + + puts "${GREEN}Valid${RESET}" + puts "Public Key: $pk" + puts "Tier: [dict get $result tier]" + puts "Status: [dict get $result status]" + if {[dict exists $result expires_at]} { + puts "Expires: [dict get $result expires_at]" + } + if {[dict exists $result time_remaining]} { + puts "Time Remaining: [dict get $result time_remaining]" + } + } + + proc cmd_session {args} { + variable GREEN RESET + set action "" + set target "" + + for {set i 0} {$i < [llength $args]} {incr i} { + set arg [lindex $args $i] + switch -exact -- $arg { + --list - -l { set action "list" } + --info { set action "info"; incr i; set target [lindex $args $i] } + --kill { set action "kill"; incr i; set target [lindex $args $i] } + --freeze { set action "freeze"; incr i; set target [lindex $args $i] } + --unfreeze { set action "unfreeze"; incr i; set target [lindex $args $i] } + } + } + + switch -exact -- $action { + list { + set result [session_list] + if {[dict exists $result sessions]} { + foreach s [dict get $result sessions] { + puts "[dict get $s id]\t[dict get $s shell]\t[dict get $s status]\t[dict get $s created_at]" + } + } + } + info { puts [session_get $target] } + kill { + session_destroy $target + puts "${GREEN}Session terminated: $target${RESET}" + } + freeze { + session_freeze $target + puts "${GREEN}Session frozen: $target${RESET}" + } + unfreeze { + session_unfreeze $target + puts "${GREEN}Session unfreezing: $target${RESET}" + } + default { + puts stderr "Usage: un.tcl session --list|--info ID|--kill ID|--freeze ID|--unfreeze ID" exit 1 } } + } - if {$network ne ""} { - lappend payload network [::json::write string $network] - } - if {$vcpu > 0} { - lappend payload vcpu $vcpu - } - if {$unfreeze_on_demand} { - lappend payload unfreeze_on_demand true + proc cmd_service {args} { + variable GREEN RESET + set action "" + set target "" + set name "" + set ports "" + + for {set i 0} {$i < [llength $args]} {incr i} { + set arg [lindex $args $i] + switch -exact -- $arg { + --list - -l { set action "list" } + --info { set action "info"; incr i; set target [lindex $args $i] } + --destroy { set action "destroy"; incr i; set target [lindex $args $i] } + --freeze { set action "freeze"; incr i; set target [lindex $args $i] } + --unfreeze { set action "unfreeze"; incr i; set target [lindex $args $i] } + --lock { set action "lock"; incr i; set target [lindex $args $i] } + --unlock { set action "unlock"; incr i; set target [lindex $args $i] } + --logs { set action "logs"; incr i; set target [lindex $args $i] } + --name { incr i; set name [lindex $args $i] } + --ports { incr i; set ports [lindex $args $i] } + } } - # Add input files - if {[llength $input_files] > 0} { - set files_json [list] - foreach filepath $input_files { - if {![file exists $filepath]} { - puts stderr "${::RED}Error: Input file not found: $filepath${::RESET}" + switch -exact -- $action { + list { + set result [service_list] + if {[dict exists $result services]} { + foreach s [dict get $result services] { + puts "[dict get $s id]\t[dict get $s name]\t[dict get $s status]" + } + } + } + info { puts [service_get $target] } + destroy { + service_destroy $target + puts "${GREEN}Service destroyed: $target${RESET}" + } + freeze { + service_freeze $target + puts "${GREEN}Service frozen: $target${RESET}" + } + unfreeze { + service_unfreeze $target + puts "${GREEN}Service unfreezing: $target${RESET}" + } + lock { + service_lock $target + puts "${GREEN}Service locked: $target${RESET}" + } + unlock { + service_unlock $target + puts "${GREEN}Service unlocked: $target${RESET}" + } + logs { + set result [service_logs $target] + if {[dict exists $result logs]} { + puts [dict get $result logs] + } + } + default { + if {$name ne ""} { + set result [service_create $name $ports ""] + puts "${GREEN}Service created${RESET}" + puts $result + } else { + puts stderr "Usage: un.tcl service --list|--info ID|--destroy ID|--name NAME" exit 1 } - set fp [open $filepath rb] - set content [read $fp] - close $fp - set b64_content [::base64::encode $content] - lappend files_json [::json::write object \ - filename [::json::write string [file tail $filepath]] \ - content_base64 [::json::write string $b64_content]] } - lappend payload input_files [::json::write array {*}$files_json] + } + } + + proc cmd_snapshot {args} { + variable GREEN RESET + set action "" + set target "" + + for {set i 0} {$i < [llength $args]} {incr i} { + set arg [lindex $args $i] + switch -exact -- $arg { + --list - -l { set action "list" } + --info { set action "info"; incr i; set target [lindex $args $i] } + --delete { set action "delete"; incr i; set target [lindex $args $i] } + --restore { set action "restore"; incr i; set target [lindex $args $i] } + --lock { set action "lock"; incr i; set target [lindex $args $i] } + --unlock { set action "unlock"; incr i; set target [lindex $args $i] } + } } - set result [api_request "/services" "POST" $payload $public_key $secret_key] - set service_id [dict get $result id] - puts "${::GREEN}Service created: $service_id${::RESET}" - puts "Name: [dict get $result name]" - if {[dict exists $result url]} { - puts "URL: [dict get $result url]" - } - - # Auto-set vault if env vars were provided - if {[llength $envs] > 0 || $env_file ne ""} { - set env_content [build_env_content $envs $env_file] - if {$env_content ne ""} { - if {[service_env_set $service_id $env_content $public_key $secret_key]} { - puts "${::GREEN}Vault configured with environment variables${::RESET}" - } else { - puts stderr "${::YELLOW}Warning: Failed to set vault${::RESET}" + switch -exact -- $action { + list { + set result [snapshot_list] + if {[dict exists $result snapshots]} { + foreach s [dict get $result snapshots] { + puts "[dict get $s id]\t[dict get $s name]\t[dict get $s type]" + } } } + info { puts [snapshot_get $target] } + delete { + snapshot_delete $target + puts "${GREEN}Snapshot deleted: $target${RESET}" + } + restore { + snapshot_restore $target + puts "${GREEN}Snapshot restored${RESET}" + } + lock { + snapshot_lock $target + puts "${GREEN}Snapshot locked: $target${RESET}" + } + unlock { + snapshot_unlock $target + puts "${GREEN}Snapshot unlocked: $target${RESET}" + } + default { + puts stderr "Usage: un.tcl snapshot --list|--info ID|--delete ID|--restore ID" + exit 1 + } } - return } - puts stderr "${::RED}Error: Specify --name to create a service, or use --list, --info, etc.${::RESET}" - exit 1 -} + proc cmd_image {args} { + variable GREEN RESET + set action "" + set target "" + set source_type "" + set visibility_mode "" + set name "" + set ports "" -proc main {argv} { - if {[llength $argv] == 0} { - puts stderr "Usage: un.tcl \[options\] " - puts stderr " un.tcl session \[options\]" - puts stderr " un.tcl service \[options\]" - puts stderr " un.tcl service env \[options\]" - puts stderr " un.tcl image \[options\]" - puts stderr " un.tcl key \[--extend\]" - puts stderr " un.tcl languages \[--json\]" - puts stderr "" - puts stderr "Languages options:" - puts stderr " --json Output as JSON array" - puts stderr "" - puts stderr "Service env commands:" - puts stderr " env status ID Check vault status" - puts stderr " env set ID Set vault (use -e or --env-file)" - puts stderr " env export ID Export vault contents" - puts stderr " env delete ID Delete vault" - puts stderr "" - puts stderr "Service vault options:" - puts stderr " -e KEY=VALUE Set vault env var (with --name or env set)" - puts stderr " --env-file FILE Load vault vars from file" - puts stderr "" - puts stderr "Image options:" - puts stderr " --list List all images" - puts stderr " --info ID Get image details" - puts stderr " --delete ID Delete an image" - puts stderr " --lock ID Lock image to prevent deletion" - puts stderr " --unlock ID Unlock image" - puts stderr " --publish ID Publish image from service/snapshot" - puts stderr " --source-type TYPE Source type: service or snapshot" - puts stderr " --visibility ID MODE Set visibility: private, unlisted, public" - puts stderr " --spawn ID Spawn new service from image" - puts stderr " --clone ID Clone an image" - puts stderr " --name NAME Name for spawned service or cloned image" - puts stderr " --ports PORTS Ports for spawned service" - exit 1 + for {set i 0} {$i < [llength $args]} {incr i} { + set arg [lindex $args $i] + switch -exact -- $arg { + --list - -l { set action "list" } + --info { set action "info"; incr i; set target [lindex $args $i] } + --delete { set action "delete"; incr i; set target [lindex $args $i] } + --lock { set action "lock"; incr i; set target [lindex $args $i] } + --unlock { set action "unlock"; incr i; set target [lindex $args $i] } + --publish { set action "publish"; incr i; set target [lindex $args $i] } + --source-type { incr i; set source_type [lindex $args $i] } + --visibility { + set action "visibility" + incr i; set target [lindex $args $i] + incr i; set visibility_mode [lindex $args $i] + } + --spawn { set action "spawn"; incr i; set target [lindex $args $i] } + --clone { set action "clone"; incr i; set target [lindex $args $i] } + --name { incr i; set name [lindex $args $i] } + --ports { incr i; set ports [lindex $args $i] } + } + } + + switch -exact -- $action { + list { + set result [image_list] + if {[dict exists $result images]} { + foreach img [dict get $result images] { + set img_name "-" + if {[dict exists $img name]} { set img_name [dict get $img name] } + puts "[dict get $img id]\t$img_name\t[dict get $img visibility]" + } + } + } + info { puts [image_get $target] } + delete { + image_delete $target + puts "${GREEN}Image deleted: $target${RESET}" + } + lock { + image_lock $target + puts "${GREEN}Image locked: $target${RESET}" + } + unlock { + image_unlock $target + puts "${GREEN}Image unlocked: $target${RESET}" + } + publish { + if {$source_type eq ""} { + puts stderr "Error: --source-type required" + exit 1 + } + set result [image_publish $source_type $target $name] + puts "${GREEN}Image published${RESET}" + puts $result + } + visibility { + image_set_visibility $target $visibility_mode + puts "${GREEN}Visibility set to $visibility_mode${RESET}" + } + spawn { + set result [image_spawn $target $name $ports] + puts "${GREEN}Service spawned from image${RESET}" + puts $result + } + clone { + set result [image_clone $target $name] + puts "${GREEN}Image cloned${RESET}" + puts $result + } + default { + puts stderr "Usage: un.tcl image --list|--info ID|--delete ID|--publish ID|--spawn ID|--clone ID" + exit 1 + } + } } - set first_arg [lindex $argv 0] + proc run_file {file} { + variable BLUE RED RESET - if {$first_arg eq "session"} { - cmd_session [lrange $argv 1 end] - } elseif {$first_arg eq "service"} { - cmd_service [lrange $argv 1 end] - } elseif {$first_arg eq "image"} { - cmd_image [lrange $argv 1 end] - } elseif {$first_arg eq "key"} { - cmd_key [lrange $argv 1 end] - } elseif {$first_arg eq "languages"} { - cmd_languages [lrange $argv 1 end] - } else { - cmd_execute $argv + if {![file exists $file]} { + puts stderr "${RED}Error: File not found: $file${RESET}" + exit 1 + } + + set fp [open $file r] + set code [read $fp] + close $fp + + set lang [detect_language $file] + if {$lang eq ""} { + puts stderr "${RED}Error: Cannot detect language${RESET}" + exit 1 + } + + set result [execute $lang $code] + + if {[dict exists $result stdout]} { + puts -nonewline "${BLUE}[dict get $result stdout]${RESET}" + } + if {[dict exists $result stderr]} { + puts -nonewline stderr "${RED}[dict get $result stderr]${RESET}" + } + + set exit_code 0 + if {[dict exists $result exit_code]} { + set exit_code [dict get $result exit_code] + } + exit $exit_code + } + + proc show_help {} { + puts {Unsandbox CLI - Execute code in secure sandboxes + +Usage: + tclsh un.tcl [options] + tclsh un.tcl -s '' + tclsh un.tcl session [options] + tclsh un.tcl service [options] + tclsh un.tcl snapshot [options] + tclsh un.tcl image [options] + tclsh un.tcl languages [--json] + tclsh un.tcl key [--extend] + +Commands: + languages List available programming languages + key Validate API key + session Manage interactive sessions + service Manage persistent services + snapshot Manage snapshots + image Manage images + +Environment: + UNSANDBOX_PUBLIC_KEY API public key + UNSANDBOX_SECRET_KEY API secret key} + } + + proc main {argv} { + variable BLUE RED RESET + + if {[llength $argv] == 0} { + show_help + exit 1 + } + + set first_arg [lindex $argv 0] + + switch -exact -- $first_arg { + languages { cmd_languages {*}[lrange $argv 1 end] } + key { cmd_key {*}[lrange $argv 1 end] } + session { cmd_session {*}[lrange $argv 1 end] } + service { cmd_service {*}[lrange $argv 1 end] } + snapshot { cmd_snapshot {*}[lrange $argv 1 end] } + image { cmd_image {*}[lrange $argv 1 end] } + --help - -h { show_help } + -s { + set lang [lindex $argv 1] + set code [lindex $argv 2] + if {$lang eq "" || $code eq ""} { + puts stderr "${RED}Error: -s requires language and code${RESET}" + exit 1 + } + set result [execute $lang $code] + if {[dict exists $result stdout]} { + puts -nonewline [dict get $result stdout] + } + if {[dict exists $result stderr]} { + puts -nonewline stderr [dict get $result stderr] + } + set exit_code 0 + if {[dict exists $result exit_code]} { + set exit_code [dict get $result exit_code] + } + exit $exit_code + } + default { + run_file $first_arg + } + } } } -main $argv +# CLI entry point +if {[info script] eq $argv0} { + Un::main $argv +} diff --git a/clients/tcl/tests/test_library.tcl b/clients/tcl/tests/test_library.tcl new file mode 100755 index 0000000..d53c570 --- /dev/null +++ b/clients/tcl/tests/test_library.tcl @@ -0,0 +1,236 @@ +#!/usr/bin/env tclsh +# Unit Tests for un.tcl Library Functions +# +# Tests the ACTUAL exported functions from Un module. +# NO local re-implementations. NO mocking. +# +# Run: tclsh tests/test_library.tcl + +# Adjust package path to find the module +set script_dir [file dirname [info script]] +source [file join $script_dir "../sync/src/un.tcl"] + +# Test counters +set tests_passed 0 +set tests_failed 0 + +proc PASS {msg} { + global tests_passed + puts " \033\[32m\[PASS\]\033\[0m $msg" + incr tests_passed +} + +proc FAIL {msg} { + global tests_failed + puts " \033\[31m\[FAIL\]\033\[0m $msg" + incr tests_failed +} + +proc assert_equal {actual expected msg} { + if {$actual eq $expected} { + PASS $msg + } else { + FAIL "$msg (expected: $expected, got: $actual)" + } +} + +proc assert_not_empty {value msg} { + if {$value ne ""} { + PASS $msg + } else { + FAIL "$msg (expected non-empty)" + } +} + +proc assert_match {value pattern msg} { + if {[regexp $pattern $value]} { + PASS $msg + } else { + FAIL "$msg (value: $value does not match pattern: $pattern)" + } +} + +# ============================================================================ +# Test: Un::version() +# ============================================================================ + +puts "\nTesting Un::version()..." + +set ver [Un::version] +assert_not_empty $ver "version() returns non-empty string" +assert_match $ver {^\d+\.\d+\.\d+$} "version() matches X.Y.Z format" +puts " Version: $ver" + +# ============================================================================ +# Test: Un::detect_language() +# ============================================================================ + +puts "\nTesting Un::detect_language()..." + +set tests { + {"test.py" "python"} + {"app.js" "javascript"} + {"main.go" "go"} + {"script.rb" "ruby"} + {"lib.rs" "rust"} + {"main.c" "c"} + {"app.cpp" "cpp"} + {"Main.java" "java"} + {"index.php" "php"} + {"script.pl" "perl"} + {"init.lua" "lua"} + {"run.sh" "bash"} + {"main.ts" "typescript"} + {"app.kt" "kotlin"} + {"lib.ex" "elixir"} + {"main.hs" "haskell"} +} + +foreach test $tests { + set file [lindex $test 0] + set expected [lindex $test 1] + set result [Un::detect_language $file] + assert_equal $result $expected "detect_language('$file') -> '$expected'" +} + +# Test unknown extension +set unknown [Un::detect_language "file.xyz123"] +assert_equal $unknown "" "detect_language(unknown ext) returns empty" + +# Test no extension +set noext [Un::detect_language "Makefile"] +assert_equal $noext "" "detect_language(no ext) returns empty" + +# ============================================================================ +# Test: Un::hmac_sign() +# ============================================================================ + +puts "\nTesting Un::hmac_sign()..." + +# Test basic signature generation +set sig [Un::hmac_sign "secret_key" "1234567890:POST:/execute:{}"] +assert_not_empty $sig "hmac_sign() returns non-nil" +assert_equal [string length $sig] 64 "hmac_sign() returns 64-char hex string" + +# Verify hex characters +assert_match $sig {^[0-9a-fA-F]+$} "hmac_sign() returns valid hex" + +# Test deterministic output +set sig1 [Un::hmac_sign "key" "message"] +set sig2 [Un::hmac_sign "key" "message"] +assert_equal $sig1 $sig2 "hmac_sign() is deterministic" + +# Test different keys produce different signatures +set sig_a [Un::hmac_sign "key_a" "message"] +set sig_b [Un::hmac_sign "key_b" "message"] +if {$sig_a ne $sig_b} { + PASS "Different keys produce different signatures" +} else { + FAIL "Different keys produce different signatures" +} + +# Test different messages produce different signatures +set sig_m1 [Un::hmac_sign "key" "message1"] +set sig_m2 [Un::hmac_sign "key" "message2"] +if {$sig_m1 ne $sig_m2} { + PASS "Different messages produce different signatures" +} else { + FAIL "Different messages produce different signatures" +} + +# Test known HMAC value +set known_sig [Un::hmac_sign "key" "message"] +if {[string range $known_sig 0 31] eq "6e9ef29b75fffc5b7abae527d58fdadb"} { + PASS "HMAC-SHA256('key', 'message') matches expected prefix" +} else { + FAIL "HMAC-SHA256('key', 'message') matches expected prefix (got: $known_sig)" +} + +# ============================================================================ +# Test: Un::last_error() +# ============================================================================ + +puts "\nTesting Un::last_error()..." + +Un::set_error "test error" +set err [Un::last_error] +assert_equal $err "test error" "last_error() returns set error" + +# ============================================================================ +# Test: Memory stress test +# ============================================================================ + +puts "\nTesting Memory Management..." + +# Stress test HMAC allocation +for {set i 0} {$i < 1000} {incr i} { + Un::hmac_sign "key" "message" +} +PASS "1000 HMAC calls without crash" + +# Stress test language detection +for {set i 0} {$i < 1000} {incr i} { + Un::detect_language "test.py" +} +PASS "1000 detect_language calls without crash" + +# Stress test version +for {set i 0} {$i < 1000} {incr i} { + Un::version +} +PASS "1000 version calls without crash" + +# ============================================================================ +# Test: Function existence +# ============================================================================ + +puts "\nTesting Library function existence..." + +set functions { + execute execute_async wait_job get_job + cancel_job list_jobs get_languages detect_language + + session_list session_get session_create session_destroy + session_freeze session_unfreeze session_boost session_unboost + session_execute + + service_list service_get service_create service_destroy + service_freeze service_unfreeze service_lock service_unlock + service_set_unfreeze_on_demand service_redeploy service_logs + service_execute service_env_get service_env_set + service_env_delete service_env_export service_resize + + snapshot_list snapshot_get snapshot_session snapshot_service + snapshot_restore snapshot_delete snapshot_lock snapshot_unlock + snapshot_clone + + image_list image_get image_publish image_delete + image_lock image_unlock image_set_visibility + image_grant_access image_revoke_access image_list_trusted + image_transfer image_spawn image_clone + + logs_fetch logs_stream + + validate_keys hmac_sign health_check version last_error +} + +foreach func $functions { + if {[llength [info procs Un::$func]] > 0 || [llength [info commands Un::$func]] > 0} { + PASS "Un::$func() exists" + } else { + FAIL "Un::$func() exists" + } +} + +# ============================================================================ +# Summary +# ============================================================================ + +puts "\n=====================================" +puts "Test Summary" +puts "=====================================" +puts "Passed: \033\[32m$tests_passed\033\[0m" +puts "Failed: \033\[31m$tests_failed\033\[0m" +puts "=====================================" + +exit [expr {$tests_failed > 0 ? 1 : 0}] diff --git a/clients/typescript/sync/src/un.ts b/clients/typescript/sync/src/un.ts index e089c86..b0aaa5f 100644 --- a/clients/typescript/sync/src/un.ts +++ b/clients/typescript/sync/src/un.ts @@ -59,6 +59,56 @@ import * as crypto from 'crypto'; const API_BASE = "https://api.unsandbox.com"; const PORTAL_BASE = "https://unsandbox.com"; const LANGUAGES_CACHE_TTL = 3600; // 1 hour in seconds +const SDK_VERSION = "4.2.0"; + +// Thread-local error storage +let _lastError: string | null = null; + +// ============================================================================= +// Exported Utility Functions (for library usage) +// ============================================================================= + +/** + * Get the SDK version string. + */ +export function version(): string { + return SDK_VERSION; +} + +/** + * Get the last error message. + */ +export function lastError(): string | null { + return _lastError; +} + +/** + * Sign a message using HMAC-SHA256. + * Exposed for testing and debugging purposes. + */ +export function hmacSign(secretKey: string, message: string): string { + return crypto.createHmac('sha256', secretKey).update(message).digest('hex'); +} + +/** + * Check if the API is healthy and responding. + */ +export async function healthCheck(): Promise { + return new Promise((resolve) => { + const url = new URL(`${API_BASE}/health`); + const req = https.get(url, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', (e) => { + _lastError = `Health check failed: ${e.message}`; + resolve(false); + }); + req.setTimeout(10000, () => { + _lastError = 'Health check failed: timeout'; + resolve(false); + }); + }); +} const BLUE = "\x1b[34m"; const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; diff --git a/clients/v/sync/src/un.v b/clients/v/sync/src/un.v index 2c7080e..ad082b4 100644 --- a/clients/v/sync/src/un.v +++ b/clients/v/sync/src/un.v @@ -712,7 +712,155 @@ fn get_secret_key() string { return '' } -fn cmd_image(list bool, info string, delete string, lock string, unlock string, publish string, source_type string, visibility_id string, visibility_mode string, spawn string, clone string, name string, ports string, api_key string) { +fn cmd_snapshot(list bool, info string, session string, service string, restore string, delete string, lock string, unlock string, clone string, clone_type string, name string, ports string, hot bool, api_key string) { + pub_key := get_public_key() + secret_key := get_secret_key() + + if list { + cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:GET:/snapshots:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/snapshots' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" + println(exec_curl(cmd)) + return + } + + if info != '' { + cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:GET:/snapshots/${info}:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/snapshots/${info}' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" + println(exec_curl(cmd)) + return + } + + if session != '' { + mut json := '{' + mut has_content := false + if name != '' { + json += '"name":"${name}"' + has_content = true + } + if hot { + if has_content { json += ',' } + json += '"hot":true' + } + json += '}' + cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/sessions/${session}/snapshot:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/sessions/${session}/snapshot' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" + result := exec_curl(cmd) + println('${green}Snapshot created${reset}') + println(result) + return + } + + if service != '' { + mut json := '{' + mut has_content := false + if name != '' { + json += '"name":"${name}"' + has_content = true + } + if hot { + if has_content { json += ',' } + json += '"hot":true' + } + json += '}' + cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/services/${service}/snapshot:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services/${service}/snapshot' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" + result := exec_curl(cmd) + println('${green}Snapshot created${reset}') + println(result) + return + } + + if restore != '' { + cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/snapshots/${restore}/restore:{}\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/snapshots/${restore}/restore' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d '{}'" + result := exec_curl(cmd) + println('${green}Snapshot restored${reset}') + println(result) + return + } + + if delete != '' { + endpoint := '/snapshots/${delete}' + status := exec_curl_delete_with_sudo(endpoint, pub_key, secret_key) + if status >= 200 && status < 300 { + println('${green}Snapshot deleted: ${delete}${reset}') + } else if status != 428 { + eprintln('${red}Error: Failed to delete snapshot${reset}') + exit(1) + } + return + } + + if lock != '' { + cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/snapshots/${lock}/lock:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/snapshots/${lock}/lock' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" + exec_curl(cmd) + println('${green}Snapshot locked: ${lock}${reset}') + return + } + + if unlock != '' { + endpoint := '/snapshots/${unlock}/unlock' + status := exec_curl_post_with_sudo(endpoint, '{}', pub_key, secret_key) + if status >= 200 && status < 300 { + println('${green}Snapshot unlocked: ${unlock}${reset}') + } else if status != 428 { + eprintln('${red}Error: Failed to unlock snapshot${reset}') + exit(1) + } + return + } + + if clone != '' { + ct := if clone_type != '' { clone_type } else { 'session' } + mut json := '{"clone_type":"${ct}"' + if name != '' { json += ',"name":"${name}"' } + if ports != '' { json += ',"ports":[${ports}]' } + json += '}' + cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/snapshots/${clone}/clone:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/snapshots/${clone}/clone' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" + result := exec_curl(cmd) + println('${green}Snapshot cloned${reset}') + println(result) + return + } + + eprintln('${red}Error: No snapshot action specified. Use --list, --info, --session, --service, --restore, --delete, --lock, --unlock, or --clone${reset}') + exit(1) +} + +fn cmd_logs(source string, lines int, since string, grep string, follow bool, api_key string) { + pub_key := get_public_key() + secret_key := get_secret_key() + + src := if source != '' { source } else { 'all' } + ln := if lines > 0 { lines } else { 100 } + sn := if since != '' { since } else { '1h' } + + if follow { + mut endpoint := '/paas/logs/stream?source=${src}' + if grep != '' { endpoint += '&grep=${grep}' } + cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:GET:${endpoint}:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -N -X GET '${portal_base}${endpoint}' -H 'Accept: text/event-stream' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" + println(exec_curl(cmd)) + } else { + mut endpoint := '/paas/logs?source=${src}&lines=${ln}&since=${sn}' + if grep != '' { endpoint += '&grep=${grep}' } + cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:GET:${endpoint}:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${portal_base}${endpoint}' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" + println(exec_curl(cmd)) + } +} + +fn cmd_health() { + cmd := "curl -s -X GET '${api_base}/health'" + result := os.execute(cmd) + if result.output.contains('"status":"healthy"') || result.output.contains('"ok":true') { + println('${green}API is healthy${reset}') + } else { + println('${red}API may be unhealthy${reset}') + } + println(result.output) +} + +fn cmd_version() { + println('un.v version 1.0.0') + println('API: ${api_base}') + println('Portal: ${portal_base}') +} + +fn cmd_image(list bool, info string, delete string, lock string, unlock string, publish string, source_type string, visibility_id string, visibility_mode string, spawn string, clone string, name string, ports string, grant string, revoke string, trusted string, trusted_key string, transfer string, to_key string, api_key string) { pub_key := get_public_key() secret_key := get_secret_key() @@ -811,7 +959,54 @@ fn cmd_image(list bool, info string, delete string, lock string, unlock string, return } - eprintln('${red}Error: No image action specified. Use --list, --info, --delete, --publish, etc.${reset}') + if grant != '' { + if trusted_key == '' { + eprintln('${red}Error: --grant requires --trusted-key${reset}') + exit(1) + } + json := '{"trusted_api_key":"${trusted_key}"}' + cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/images/${grant}/grant:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/images/${grant}/grant' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" + exec_curl(cmd) + println('${green}Access granted to ${trusted_key}${reset}') + return + } + + if revoke != '' { + if trusted_key == '' { + eprintln('${red}Error: --revoke requires --trusted-key${reset}') + exit(1) + } + json := '{"trusted_api_key":"${trusted_key}"}' + cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/images/${revoke}/revoke:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/images/${revoke}/revoke' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" + exec_curl(cmd) + println('${green}Access revoked from ${trusted_key}${reset}') + return + } + + if trusted != '' { + cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:GET:/images/${trusted}/trusted:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/images/${trusted}/trusted' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" + println(exec_curl(cmd)) + return + } + + if transfer != '' { + if to_key == '' { + eprintln('${red}Error: --transfer requires --to-key${reset}') + exit(1) + } + json := '{"to_api_key":"${to_key}"}' + endpoint := '/images/${transfer}/transfer' + status := exec_curl_post_with_sudo(endpoint, json, pub_key, secret_key) + if status >= 200 && status < 300 { + println('${green}Image transferred to ${to_key}${reset}') + } else if status != 428 { + eprintln('${red}Error: Failed to transfer image${reset}') + exit(1) + } + return + } + + eprintln('${red}Error: No image action specified. Use --list, --info, --delete, --publish, --grant, --revoke, --trusted, --transfer, etc.${reset}') exit(1) } @@ -1277,6 +1472,12 @@ fn main() { mut clone := '' mut name := '' mut ports := '' + mut grant := '' + mut revoke := '' + mut trusted := '' + mut trusted_key := '' + mut transfer := '' + mut to_key := '' mut i := 2 for i < os.args.len { @@ -1330,6 +1531,30 @@ fn main() { i++ ports = os.args[i] } + '--grant' { + i++ + grant = os.args[i] + } + '--revoke' { + i++ + revoke = os.args[i] + } + '--trusted' { + i++ + trusted = os.args[i] + } + '--trusted-key' { + i++ + trusted_key = os.args[i] + } + '--transfer' { + i++ + transfer = os.args[i] + } + '--to-key' { + i++ + to_key = os.args[i] + } '-k' { i++ api_key = os.args[i] @@ -1339,7 +1564,83 @@ fn main() { i++ } - cmd_image(list, info, delete, lock, unlock, publish, source_type, visibility_id, visibility_mode, spawn, clone, name, ports, api_key) + cmd_image(list, info, delete, lock, unlock, publish, source_type, visibility_id, visibility_mode, spawn, clone, name, ports, grant, revoke, trusted, trusted_key, transfer, to_key, api_key) + return + } + + if os.args[1] == 'snapshot' { + mut list := false + mut info := '' + mut session := '' + mut service := '' + mut restore := '' + mut delete := '' + mut lock := '' + mut unlock := '' + mut clone := '' + mut clone_type := '' + mut name := '' + mut ports := '' + mut hot := false + + mut i := 2 + for i < os.args.len { + match os.args[i] { + '--list', '-l' { list = true } + '--info' { i++; info = os.args[i] } + '--session' { i++; session = os.args[i] } + '--service' { i++; service = os.args[i] } + '--restore' { i++; restore = os.args[i] } + '--delete' { i++; delete = os.args[i] } + '--lock' { i++; lock = os.args[i] } + '--unlock' { i++; unlock = os.args[i] } + '--clone' { i++; clone = os.args[i] } + '--clone-type' { i++; clone_type = os.args[i] } + '--name' { i++; name = os.args[i] } + '--ports' { i++; ports = os.args[i] } + '--hot' { hot = true } + '-k' { i++; api_key = os.args[i] } + else {} + } + i++ + } + + cmd_snapshot(list, info, session, service, restore, delete, lock, unlock, clone, clone_type, name, ports, hot, api_key) + return + } + + if os.args[1] == 'logs' { + mut source := '' + mut lines := 0 + mut since := '' + mut grep := '' + mut follow := false + + mut i := 2 + for i < os.args.len { + match os.args[i] { + '--source' { i++; source = os.args[i] } + '--lines' { i++; lines = os.args[i].int() } + '--since' { i++; since = os.args[i] } + '--grep' { i++; grep = os.args[i] } + '--follow', '-f' { follow = true } + '-k' { i++; api_key = os.args[i] } + else {} + } + i++ + } + + cmd_logs(source, lines, since, grep, follow, api_key) + return + } + + if os.args[1] == 'health' { + cmd_health() + return + } + + if os.args[1] == 'version' { + cmd_version() return } diff --git a/clients/zig/sync/src/un.zig b/clients/zig/sync/src/un.zig index f366ad9..db691a2 100644 --- a/clients/zig/sync/src/un.zig +++ b/clients/zig/sync/src/un.zig @@ -319,6 +319,401 @@ fn execCurlPut(allocator: std.mem.Allocator, endpoint: []const u8, body: []const return ret == 0; } +// ============================================================================ +// Library Functions for Zig SDK (matching C reference un.h) +// ============================================================================ + +pub const SDK_VERSION = "4.2.0"; + +// Generic API request helper +fn makeApiRequest(allocator: std.mem.Allocator, method: []const u8, path: []const u8, body: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const result = try execCurlWithStatus(allocator, method, path, body, public_key, secret_key, ""); + return result.body; +} + +// Execute code synchronously +pub fn execute(allocator: std.mem.Allocator, language: []const u8, code: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const body = try std.fmt.allocPrint(allocator, "{{\"language\":\"{s}\",\"code\":\"{s}\"}}", .{ language, code }); + defer allocator.free(body); + return try makeApiRequest(allocator, "POST", "/execute", body, public_key, secret_key); +} + +// Execute code asynchronously (returns job_id) +pub fn executeAsync(allocator: std.mem.Allocator, language: []const u8, code: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const body = try std.fmt.allocPrint(allocator, "{{\"language\":\"{s}\",\"code\":\"{s}\",\"async\":true}}", .{ language, code }); + defer allocator.free(body); + return try makeApiRequest(allocator, "POST", "/execute", body, public_key, secret_key); +} + +// Get job status +pub fn getJob(allocator: std.mem.Allocator, job_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/jobs/{s}", .{job_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "GET", path, "", public_key, secret_key); +} + +// Wait for job completion +pub fn waitForJob(allocator: std.mem.Allocator, job_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const poll_delays = [_]u64{ 300, 450, 700, 900, 650, 1600, 2000 }; + var delay_idx: usize = 0; + + while (true) { + const result = try getJob(allocator, job_id, public_key, secret_key); + + // Check for terminal states + if (mem.indexOf(u8, result, "\"status\":\"completed\"") != null or + mem.indexOf(u8, result, "\"status\":\"failed\"") != null or + mem.indexOf(u8, result, "\"status\":\"timeout\"") != null or + mem.indexOf(u8, result, "\"status\":\"cancelled\"") != null) + { + return result; + } + + allocator.free(result); + std.time.sleep(poll_delays[delay_idx] * std.time.ns_per_ms); + if (delay_idx < poll_delays.len - 1) delay_idx += 1; + } +} + +// Cancel a job +pub fn cancelJob(allocator: std.mem.Allocator, job_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/jobs/{s}/cancel", .{job_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +// List all jobs +pub fn listJobs(allocator: std.mem.Allocator, public_key: []const u8, secret_key: []const u8) ![]const u8 { + return try makeApiRequest(allocator, "GET", "/jobs", "", public_key, secret_key); +} + +// Get supported languages +pub fn getLanguages(allocator: std.mem.Allocator, public_key: []const u8, secret_key: []const u8) ![]const u8 { + return try makeApiRequest(allocator, "GET", "/languages", "", public_key, secret_key); +} + +// Detect language from filename +pub fn detectLanguage(filename: []const u8) ?[]const u8 { + const lang_map = [_]struct { ext: []const u8, lang: []const u8 }{ + .{ .ext = ".py", .lang = "python" }, + .{ .ext = ".js", .lang = "javascript" }, + .{ .ext = ".ts", .lang = "typescript" }, + .{ .ext = ".go", .lang = "go" }, + .{ .ext = ".rs", .lang = "rust" }, + .{ .ext = ".c", .lang = "c" }, + .{ .ext = ".cpp", .lang = "cpp" }, + .{ .ext = ".cc", .lang = "cpp" }, + .{ .ext = ".d", .lang = "d" }, + .{ .ext = ".zig", .lang = "zig" }, + .{ .ext = ".rb", .lang = "ruby" }, + .{ .ext = ".php", .lang = "php" }, + .{ .ext = ".sh", .lang = "bash" }, + .{ .ext = ".lua", .lang = "lua" }, + .{ .ext = ".nim", .lang = "nim" }, + .{ .ext = ".v", .lang = "v" }, + }; + + const idx = mem.lastIndexOfScalar(u8, filename, '.') orelse return null; + const ext = filename[idx..]; + + for (lang_map) |entry| { + if (mem.eql(u8, ext, entry.ext)) { + return entry.lang; + } + } + return null; +} + +// Session functions +pub fn sessionList(allocator: std.mem.Allocator, public_key: []const u8, secret_key: []const u8) ![]const u8 { + return try makeApiRequest(allocator, "GET", "/sessions", "", public_key, secret_key); +} + +pub fn sessionGet(allocator: std.mem.Allocator, session_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/sessions/{s}", .{session_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "GET", path, "", public_key, secret_key); +} + +pub fn sessionCreate(allocator: std.mem.Allocator, shell: ?[]const u8, network: ?[]const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + var body_buf: [512]u8 = undefined; + var stream = std.io.fixedBufferStream(&body_buf); + const writer = stream.writer(); + try writer.print("{{\"shell\":\"{s}\"", .{shell orelse "bash"}); + if (network) |n| try writer.print(",\"network\":\"{s}\"", .{n}); + try writer.writeAll("}"); + const body = stream.getWritten(); + return try makeApiRequest(allocator, "POST", "/sessions", body, public_key, secret_key); +} + +pub fn sessionDestroy(allocator: std.mem.Allocator, session_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/sessions/{s}", .{session_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "DELETE", path, "", public_key, secret_key); +} + +pub fn sessionFreeze(allocator: std.mem.Allocator, session_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/sessions/{s}/freeze", .{session_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn sessionUnfreeze(allocator: std.mem.Allocator, session_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/sessions/{s}/unfreeze", .{session_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn sessionBoost(allocator: std.mem.Allocator, session_id: []const u8, vcpu: ?u32, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/sessions/{s}/boost", .{session_id}); + defer allocator.free(path); + var body: []const u8 = "{}"; + if (vcpu) |v| { + body = try std.fmt.allocPrint(allocator, "{{\"vcpu\":{d}}}", .{v}); + } + return try makeApiRequest(allocator, "POST", path, body, public_key, secret_key); +} + +pub fn sessionUnboost(allocator: std.mem.Allocator, session_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/sessions/{s}/unboost", .{session_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn sessionExecute(allocator: std.mem.Allocator, session_id: []const u8, command: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/sessions/{s}/shell", .{session_id}); + defer allocator.free(path); + const body = try std.fmt.allocPrint(allocator, "{{\"command\":\"{s}\"}}", .{command}); + defer allocator.free(body); + return try makeApiRequest(allocator, "POST", path, body, public_key, secret_key); +} + +// Service functions +pub fn serviceList(allocator: std.mem.Allocator, public_key: []const u8, secret_key: []const u8) ![]const u8 { + return try makeApiRequest(allocator, "GET", "/services", "", public_key, secret_key); +} + +pub fn serviceGet(allocator: std.mem.Allocator, service_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}", .{service_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "GET", path, "", public_key, secret_key); +} + +pub fn serviceDestroy(allocator: std.mem.Allocator, service_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}", .{service_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "DELETE", path, "", public_key, secret_key); +} + +pub fn serviceFreeze(allocator: std.mem.Allocator, service_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}/freeze", .{service_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn serviceUnfreeze(allocator: std.mem.Allocator, service_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}/unfreeze", .{service_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn serviceLock(allocator: std.mem.Allocator, service_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}/lock", .{service_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn serviceUnlock(allocator: std.mem.Allocator, service_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}/unlock", .{service_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn serviceRedeploy(allocator: std.mem.Allocator, service_id: []const u8, bootstrap: ?[]const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}/redeploy", .{service_id}); + defer allocator.free(path); + const body = if (bootstrap) |b| try std.fmt.allocPrint(allocator, "{{\"bootstrap\":\"{s}\"}}", .{b}) else try allocator.dupe(u8, "{}"); + defer allocator.free(body); + return try makeApiRequest(allocator, "POST", path, body, public_key, secret_key); +} + +pub fn serviceLogs(allocator: std.mem.Allocator, service_id: []const u8, all: bool, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = if (all) + try std.fmt.allocPrint(allocator, "/services/{s}/logs?all=true", .{service_id}) + else + try std.fmt.allocPrint(allocator, "/services/{s}/logs", .{service_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "GET", path, "", public_key, secret_key); +} + +pub fn serviceExecute(allocator: std.mem.Allocator, service_id: []const u8, command: []const u8, timeout_ms: ?u32, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}/execute", .{service_id}); + defer allocator.free(path); + const body = if (timeout_ms) |t| + try std.fmt.allocPrint(allocator, "{{\"command\":\"{s}\",\"timeout\":{d}}}", .{ command, t }) + else + try std.fmt.allocPrint(allocator, "{{\"command\":\"{s}\"}}", .{command}); + defer allocator.free(body); + return try makeApiRequest(allocator, "POST", path, body, public_key, secret_key); +} + +pub fn serviceResize(allocator: std.mem.Allocator, service_id: []const u8, vcpu: u32, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/services/{s}/resize", .{service_id}); + defer allocator.free(path); + const body = try std.fmt.allocPrint(allocator, "{{\"vcpu\":{d}}}", .{vcpu}); + defer allocator.free(body); + return try makeApiRequest(allocator, "POST", path, body, public_key, secret_key); +} + +// Snapshot functions +pub fn snapshotList(allocator: std.mem.Allocator, public_key: []const u8, secret_key: []const u8) ![]const u8 { + return try makeApiRequest(allocator, "GET", "/snapshots", "", public_key, secret_key); +} + +pub fn snapshotGet(allocator: std.mem.Allocator, snapshot_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/snapshots/{s}", .{snapshot_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "GET", path, "", public_key, secret_key); +} + +pub fn snapshotRestore(allocator: std.mem.Allocator, snapshot_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/snapshots/{s}/restore", .{snapshot_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn snapshotDelete(allocator: std.mem.Allocator, snapshot_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/snapshots/{s}", .{snapshot_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "DELETE", path, "", public_key, secret_key); +} + +pub fn snapshotLock(allocator: std.mem.Allocator, snapshot_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/snapshots/{s}/lock", .{snapshot_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn snapshotUnlock(allocator: std.mem.Allocator, snapshot_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/snapshots/{s}/unlock", .{snapshot_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +// Image functions +pub fn imageList(allocator: std.mem.Allocator, filter: ?[]const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = if (filter) |f| + try std.fmt.allocPrint(allocator, "/images?filter={s}", .{f}) + else + try allocator.dupe(u8, "/images"); + defer allocator.free(path); + return try makeApiRequest(allocator, "GET", path, "", public_key, secret_key); +} + +pub fn imageGet(allocator: std.mem.Allocator, image_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/images/{s}", .{image_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "GET", path, "", public_key, secret_key); +} + +pub fn imageDelete(allocator: std.mem.Allocator, image_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/images/{s}", .{image_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "DELETE", path, "", public_key, secret_key); +} + +pub fn imageLock(allocator: std.mem.Allocator, image_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/images/{s}/lock", .{image_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +pub fn imageUnlock(allocator: std.mem.Allocator, image_id: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + const path = try std.fmt.allocPrint(allocator, "/images/{s}/unlock", .{image_id}); + defer allocator.free(path); + return try makeApiRequest(allocator, "POST", path, "", public_key, secret_key); +} + +// PaaS Logs functions +pub fn logsFetch(allocator: std.mem.Allocator, source: ?[]const u8, lines: ?u32, since: ?[]const u8, grep: ?[]const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + var path_buf: [512]u8 = undefined; + var stream = std.io.fixedBufferStream(&path_buf); + const writer = stream.writer(); + try writer.writeAll("/paas/logs?"); + var has_param = false; + if (source) |s| { + try writer.print("source={s}", .{s}); + has_param = true; + } + if (lines) |l| { + if (has_param) try writer.writeAll("&"); + try writer.print("lines={d}", .{l}); + has_param = true; + } + if (since) |s| { + if (has_param) try writer.writeAll("&"); + try writer.print("since={s}", .{s}); + has_param = true; + } + if (grep) |g| { + if (has_param) try writer.writeAll("&"); + try writer.print("grep={s}", .{g}); + } + const path = stream.getWritten(); + return try makeApiRequest(allocator, "GET", path, "", public_key, secret_key); +} + +// Key validation +pub fn validateKeys(allocator: std.mem.Allocator, public_key: []const u8, secret_key: []const u8) ![]const u8 { + return try makeApiRequest(allocator, "POST", "/keys/validate", "", public_key, secret_key); +} + +// Utility functions +pub fn hmacSign(allocator: std.mem.Allocator, secret_key: []const u8, message: []const u8) ![]const u8 { + const hmac_cmd = try computeHmacCmd(allocator, secret_key, message); + defer allocator.free(hmac_cmd); + + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = &[_][]const u8{ "sh", "-c", hmac_cmd }, + }); + defer allocator.free(result.stderr); + + const trimmed = mem.trim(u8, result.stdout, &std.ascii.whitespace); + const signature = try allocator.dupe(u8, trimmed); + allocator.free(result.stdout); + return signature; +} + +pub fn healthCheck(allocator: std.mem.Allocator) !bool { + const cmd = try std.fmt.allocPrint(allocator, "curl -s -o /dev/null -w '%{{http_code}}' '{s}/health' 2>/dev/null", .{API_BASE}); + defer allocator.free(cmd); + + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = &[_][]const u8{ "sh", "-c", cmd }, + }); + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + const trimmed = mem.trim(u8, result.stdout, &std.ascii.whitespace); + return mem.eql(u8, trimmed, "200"); +} + +pub fn version() []const u8 { + return SDK_VERSION; +} + +var last_error_msg: []const u8 = ""; + +pub fn setLastError(msg: []const u8) void { + last_error_msg = msg; +} + +pub fn lastError() []const u8 { + return last_error_msg; +} + fn cmdServiceEnv(allocator: std.mem.Allocator, action: []const u8, target: []const u8, envs: std.ArrayList([]const u8), env_file: ?[]const u8, public_key: []const u8, secret_key: []const u8) !void { if (mem.eql(u8, action, "status")) { const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{target}); diff --git a/clients/zig/sync/tests/test_un.zig b/clients/zig/sync/tests/test_un.zig new file mode 100644 index 0000000..f539452 --- /dev/null +++ b/clients/zig/sync/tests/test_un.zig @@ -0,0 +1,213 @@ +// Tests for the Zig unsandbox SDK +// Run with: zig test tests/test_un.zig + +const std = @import("std"); +const un = @import("../src/un.zig"); +const testing = std.testing; +const mem = std.mem; + +// ============================================================================ +// Unit Tests - Test exported library functions +// ============================================================================ + +test "detectLanguage" { + try testing.expectEqualStrings("python", un.detectLanguage("script.py") orelse ""); + try testing.expectEqualStrings("javascript", un.detectLanguage("script.js") orelse ""); + try testing.expectEqualStrings("typescript", un.detectLanguage("script.ts") orelse ""); + try testing.expectEqualStrings("go", un.detectLanguage("script.go") orelse ""); + try testing.expectEqualStrings("rust", un.detectLanguage("script.rs") orelse ""); + try testing.expectEqualStrings("c", un.detectLanguage("script.c") orelse ""); + try testing.expectEqualStrings("cpp", un.detectLanguage("script.cpp") orelse ""); + try testing.expectEqualStrings("d", un.detectLanguage("script.d") orelse ""); + try testing.expectEqualStrings("zig", un.detectLanguage("script.zig") orelse ""); + try testing.expectEqualStrings("bash", un.detectLanguage("script.sh") orelse ""); + try testing.expectEqualStrings("ruby", un.detectLanguage("script.rb") orelse ""); + try testing.expectEqualStrings("php", un.detectLanguage("script.php") orelse ""); + try testing.expect(un.detectLanguage("script.unknown") == null); + try testing.expect(un.detectLanguage("script") == null); +} + +test "version" { + const v = un.version(); + try testing.expect(v.len > 0); + // Should be in semver format (at least "0.0.0") + try testing.expect(v.len >= 5); +} + +test "lastError" { + // Set an error + un.setLastError("test error message"); + + // Retrieve it + const err = un.lastError(); + try testing.expectEqualStrings("test error message", err); + + // Clear it + un.setLastError(""); + const err2 = un.lastError(); + try testing.expectEqualStrings("", err2); +} + +test "SDK_VERSION constant" { + try testing.expect(un.SDK_VERSION.len > 0); + try testing.expect(un.SDK_VERSION.len >= 5); +} + +// ============================================================================ +// Integration Tests - Test SDK internal consistency +// ============================================================================ + +test "hmacSign" { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const secret_key = "test-secret"; + const message = "test-message"; + + const result = try un.hmacSign(allocator, secret_key, message); + defer allocator.free(result); + + // Should return a 64-character hex string + try testing.expectEqual(@as(usize, 64), result.len); + + // Should be deterministic + const result2 = try un.hmacSign(allocator, secret_key, message); + defer allocator.free(result2); + try testing.expectEqualStrings(result, result2); + + // Different inputs should produce different outputs + const result3 = try un.hmacSign(allocator, secret_key, "different-message"); + defer allocator.free(result3); + try testing.expect(!mem.eql(u8, result, result3)); +} + +// ============================================================================ +// Functional Tests - Test against real API (requires credentials) +// These tests are marked as skipped by default since they require credentials +// ============================================================================ + +fn hasCredentials() bool { + const pk = std.posix.getenv("UNSANDBOX_PUBLIC_KEY") orelse return false; + const sk = std.posix.getenv("UNSANDBOX_SECRET_KEY") orelse return false; + return pk.len > 0 and sk.len > 0; +} + +fn getCredentials() ?struct { pk: []const u8, sk: []const u8 } { + const pk = std.posix.getenv("UNSANDBOX_PUBLIC_KEY") orelse return null; + const sk = std.posix.getenv("UNSANDBOX_SECRET_KEY") orelse return null; + if (pk.len == 0 or sk.len == 0) return null; + return .{ .pk = pk, .sk = sk }; +} + +test "healthCheck functional" { + if (!hasCredentials()) { + std.debug.print("SKIP (no credentials)\n", .{}); + return; + } + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const healthy = try un.healthCheck(allocator); + std.debug.print("Health check result: {}\n", .{healthy}); +} + +test "getLanguages functional" { + const creds = getCredentials() orelse { + std.debug.print("SKIP (no credentials)\n", .{}); + return; + }; + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const result = try un.getLanguages(allocator, creds.pk, creds.sk); + defer allocator.free(result); + + try testing.expect(result.len > 0); + // Should contain python + try testing.expect(mem.indexOf(u8, result, "python") != null); +} + +test "validateKeys functional" { + const creds = getCredentials() orelse { + std.debug.print("SKIP (no credentials)\n", .{}); + return; + }; + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const result = try un.validateKeys(allocator, creds.pk, creds.sk); + defer allocator.free(result); + + try testing.expect(result.len > 0); +} + +test "sessionList functional" { + const creds = getCredentials() orelse { + std.debug.print("SKIP (no credentials)\n", .{}); + return; + }; + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const result = try un.sessionList(allocator, creds.pk, creds.sk); + defer allocator.free(result); + + try testing.expect(result.len > 0); +} + +test "serviceList functional" { + const creds = getCredentials() orelse { + std.debug.print("SKIP (no credentials)\n", .{}); + return; + }; + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const result = try un.serviceList(allocator, creds.pk, creds.sk); + defer allocator.free(result); + + try testing.expect(result.len > 0); +} + +test "snapshotList functional" { + const creds = getCredentials() orelse { + std.debug.print("SKIP (no credentials)\n", .{}); + return; + }; + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const result = try un.snapshotList(allocator, creds.pk, creds.sk); + defer allocator.free(result); + + try testing.expect(result.len > 0); +} + +test "imageList functional" { + const creds = getCredentials() orelse { + std.debug.print("SKIP (no credentials)\n", .{}); + return; + }; + + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const result = try un.imageList(allocator, null, creds.pk, creds.sk); + defer allocator.free(result); + + try testing.expect(result.len > 0); +} diff --git a/tests/test_un_clj.clj b/tests/test_un_clj.clj index 9122daa..fe08820 100755 --- a/tests/test_un_clj.clj +++ b/tests/test_un_clj.clj @@ -120,6 +120,25 @@ (catch Exception e (->TestResult false (str "Exception: " (.getMessage e)))))))) +;; Test 4: Snapshot command support (feature parity test) +(defn test-snapshot-command [] + (let [api-key (System/getenv "UNSANDBOX_API_KEY")] + (if (nil? api-key) + (->TestResult true "Skipped - no API key") + (try + ;; Run the CLI with snapshot --list + (let [result (shell/sh "./un.clj" "snapshot" "--list") + {:keys [exit out err]} result] + + ;; Check if it executed without errors (may return empty list) + (if (= exit 0) + (->TestResult true nil) + (->TestResult false (str "Snapshot list failed: exit=" exit + ", stdout=" out + ", stderr=" err)))) + (catch Exception e + (->TestResult false (str "Exception: " (.getMessage e)))))))) + ;; Main test runner (defn main [] (println "=== Clojure UN CLI Test Suite ===") @@ -134,7 +153,8 @@ ;; Run tests (let [results [(print-result "Extension detection" (test-extension-detection)) (print-result "API integration" (test-api-integration)) - (print-result "Fibonacci end-to-end test" (test-fibonacci))] + (print-result "Fibonacci end-to-end test" (test-fibonacci)) + (print-result "Snapshot command support" (test-snapshot-command))] passed (count (filter true? results)) total (count results)] diff --git a/tests/test_un_cr.cr b/tests/test_un_cr.cr index cba0c1c..b68cd57 100755 --- a/tests/test_un_cr.cr +++ b/tests/test_un_cr.cr @@ -158,6 +158,143 @@ print_test("Unknown extension returns 'unknown'", detect_language("file.unknown" print_test("Case insensitive detection", detect_language("TEST.CR") == "crystal") print_test("Multiple dots in filename", detect_language("my.test.py") == "python") +# Test 5: CLI Command Tests (new features) +puts "\n#{BLUE}Test Suite 5: CLI Command Tests (Feature Parity)#{RESET}" + +un_script = File.expand_path("../clients/crystal/sync/src/un.cr", File.dirname(__FILE__)) +un_script = File.expand_path("../../clients/crystal/sync/src/un.cr", File.dirname(__FILE__)) unless File.exists?(un_script) + +if File.exists?(un_script) + # Test: --help + begin + output = IO::Memory.new + error = IO::Memory.new + process = Process.run("crystal", args: ["run", un_script, "--", "--help"], + output: output, error: error) + result = output.to_s + error.to_s + has_help = result.includes?("Usage") || result.includes?("usage") + print_test("CLI: --help shows usage", has_help) + rescue ex + print_test("CLI: --help shows usage", false) + puts " Error: #{ex.message}" + end + + # Test: version command + begin + output = IO::Memory.new + error = IO::Memory.new + process = Process.run("crystal", args: ["run", un_script, "--", "version"], + output: output, error: error) + result = output.to_s + error.to_s + has_version = result.includes?("version") || result.includes?("Version") + print_test("CLI: version command works", has_version) + rescue ex + print_test("CLI: version command works", false) + puts " Error: #{ex.message}" + end + + # Test: health command (requires network) + begin + output = IO::Memory.new + error = IO::Memory.new + process = Process.run("crystal", args: ["run", un_script, "--", "health"], + output: output, error: error) + result = output.to_s + error.to_s + has_health = result.includes?("health") || result.includes?("API") + print_test("CLI: health command works", has_health) + rescue ex + print_test("CLI: health command works", false) + puts " Error: #{ex.message}" + end + + # Test: languages command + begin + output = IO::Memory.new + error = IO::Memory.new + process = Process.run("crystal", args: ["run", un_script, "--", "languages"], + output: output, error: error) + result = output.to_s + error.to_s + # Should list languages or require auth + has_langs = result.includes?("python") || result.includes?("Error") || result.includes?("API key") + print_test("CLI: languages command works", has_langs) + rescue ex + print_test("CLI: languages command works", false) + puts " Error: #{ex.message}" + end +else + puts "#{BLUE}ℹ SKIP#{RESET}: CLI tests (un.cr not found at expected location: #{un_script})" +end + +# Test 6: API Command Tests (require API key) +puts "\n#{BLUE}Test Suite 6: API Command Tests (require auth)#{RESET}" +public_key = ENV["UNSANDBOX_PUBLIC_KEY"]? +secret_key = ENV["UNSANDBOX_SECRET_KEY"]? + +if (public_key.nil? || public_key.empty?) && (secret_key.nil? || secret_key.empty?) + puts "#{BLUE}ℹ SKIP#{RESET}: API command tests (UNSANDBOX_PUBLIC_KEY/SECRET_KEY not set)" +else + if File.exists?(un_script) + # Test: snapshot --list + begin + output = IO::Memory.new + error = IO::Memory.new + process = Process.run("crystal", args: ["run", un_script, "--", "snapshot", "--list"], + output: output, error: error) + result = output.to_s + error.to_s + # Should return JSON or error message + has_response = result.includes?("[") || result.includes?("{") || result.includes?("Error") || result.includes?("snapshots") + print_test("CLI: snapshot --list works", has_response) + rescue ex + print_test("CLI: snapshot --list works", false) + puts " Error: #{ex.message}" + end + + # Test: session --list + begin + output = IO::Memory.new + error = IO::Memory.new + process = Process.run("crystal", args: ["run", un_script, "--", "session", "--list"], + output: output, error: error) + result = output.to_s + error.to_s + has_response = result.includes?("[") || result.includes?("{") || result.includes?("Error") || result.includes?("sessions") + print_test("CLI: session --list works", has_response) + rescue ex + print_test("CLI: session --list works", false) + puts " Error: #{ex.message}" + end + + # Test: service --list + begin + output = IO::Memory.new + error = IO::Memory.new + process = Process.run("crystal", args: ["run", un_script, "--", "service", "--list"], + output: output, error: error) + result = output.to_s + error.to_s + has_response = result.includes?("[") || result.includes?("{") || result.includes?("Error") || result.includes?("services") + print_test("CLI: service --list works", has_response) + rescue ex + print_test("CLI: service --list works", false) + puts " Error: #{ex.message}" + end + + # Test: image --list + begin + output = IO::Memory.new + error = IO::Memory.new + process = Process.run("crystal", args: ["run", un_script, "--", "image", "--list"], + output: output, error: error) + result = output.to_s + error.to_s + has_response = result.includes?("[") || result.includes?("{") || result.includes?("Error") || result.includes?("images") + print_test("CLI: image --list works", has_response) + rescue ex + print_test("CLI: image --list works", false) + puts " Error: #{ex.message}" + end + else + puts "#{BLUE}ℹ SKIP#{RESET}: API command tests (un.cr not found)" + end +end + # Print summary puts "\n#{BLUE}========================================#{RESET}" puts "#{BLUE}Test Summary#{RESET}" diff --git a/tests/test_un_dart.dart b/tests/test_un_dart.dart index a00f259..bdd3288 100644 --- a/tests/test_un_dart.dart +++ b/tests/test_un_dart.dart @@ -21,6 +21,7 @@ void main() async { if (apiKey != null && apiKey.isNotEmpty) { await testApiCall(); await testFibExecution(); + await testSnapshotCommand(); } else { print('SKIP: API integration tests (UNSANDBOX_API_KEY not set)\n'); } @@ -220,3 +221,28 @@ Future testFibExecution() async { } print(''); } + +Future testSnapshotCommand() async { + print('--- Feature Parity Test: Snapshot Command ---'); + testsRun++; + + try { + // Execute Dart CLI with snapshot --list + final result = await Process.run('dart', ['../un.dart', 'snapshot', '--list']); + + if (result.exitCode == 0) { + testsPassed++; + print('PASS: snapshot --list command works'); + } else { + testsFailed++; + print('FAIL: snapshot --list command failed'); + print('Exit code: ${result.exitCode}'); + print('Output: ${result.stdout}'); + print('Error: ${result.stderr}'); + } + } catch (e) { + testsFailed++; + print('FAIL: snapshot command test threw exception: $e'); + } + print(''); +} diff --git a/tests/test_un_forth.fth b/tests/test_un_forth.fth index da76393..6af851b 100644 --- a/tests/test_un_forth.fth +++ b/tests/test_un_forth.fth @@ -125,6 +125,11 @@ cr blue ." Test Suite 3: End-to-End Functional Test" reset cr blue ." ℹ SKIP" reset ." : E2E test (requires runtime environment and API key)" cr +\ Test Suite 3b: Snapshot Command Support (feature parity test) +cr +blue ." Test Suite 3b: Snapshot Command Support" reset cr +blue ." ℹ SKIP" reset ." : Snapshot test (requires runtime environment and API key)" cr + \ Test Suite 4: Error Handling cr blue ." Test Suite 4: Error Handling" reset cr diff --git a/tests/test_un_lisp.lisp b/tests/test_un_lisp.lisp index 98bf84a..cdae8db 100755 --- a/tests/test_un_lisp.lisp +++ b/tests/test_un_lisp.lisp @@ -147,6 +147,25 @@ (make-test-result :passed nil :message (format nil "Exception: ~A" e))))))) +;;; Test 4: Snapshot command support (feature parity test) +(defun test-snapshot-command () + (let ((api-key (uiop:getenv "UNSANDBOX_API_KEY"))) + (if (not api-key) + (make-test-result :passed t) ; Skip test if no API key + (handler-case + (let* ((result (run-command "./un.lisp snapshot --list 2>&1")) + (status (car result)) + (output (cdr result))) + + ;; Check if it executed without errors (may return empty list) + (if (= status 0) + (make-test-result :passed t) + (make-test-result :passed nil + :message (format nil "Snapshot list failed: ~A" output)))) + (error (e) + (make-test-result :passed nil + :message (format nil "Exception: ~A" e))))))) + ;;; Main test runner (defun main () (format t "=== Common Lisp UN CLI Test Suite ===~%~%") @@ -159,7 +178,8 @@ ;; Run tests (let ((results (list (print-result "Extension detection" (test-extension-detection)) (print-result "API integration" (test-api-integration)) - (print-result "Fibonacci end-to-end test" (test-fibonacci))))) + (print-result "Fibonacci end-to-end test" (test-fibonacci)) + (print-result "Snapshot command support" (test-snapshot-command))))) (format t "~%") diff --git a/tests/test_un_m.sh b/tests/test_un_m.sh index 6e8cb9b..fb353da 100755 --- a/tests/test_un_m.sh +++ b/tests/test_un_m.sh @@ -132,6 +132,139 @@ else test_skipped "Handles unknown file extension (could not compile test binary)" fi +# CLI Command Tests (Feature Parity) +echo -e "\n${BLUE}=== CLI Command Tests (Feature Parity) ===${NC}" + +# Compile for CLI tests +if clang -x objective-c -framework Foundation "$UN_M" -o "$TEST_BINARY" 2>/dev/null; then + # Test: --help + if output=$("$TEST_BINARY" --help 2>&1); then + if echo "$output" | grep -qi "usage"; then + test_passed "CLI: --help shows usage" + else + test_failed "CLI: --help shows usage" "No usage indication" + fi + else + if echo "$output" | grep -qi "usage"; then + test_passed "CLI: --help shows usage" + else + test_failed "CLI: --help shows usage" "Command failed" + fi + fi + + # Test: version command + if output=$("$TEST_BINARY" version 2>&1); then + if echo "$output" | grep -qi "version"; then + test_passed "CLI: version command works" + else + test_failed "CLI: version command works" "No version output" + fi + else + test_failed "CLI: version command works" "Command failed" + fi + + # Test: health command + if output=$("$TEST_BINARY" health 2>&1); then + if echo "$output" | grep -qi "health\|api"; then + test_passed "CLI: health command works" + else + test_failed "CLI: health command works" "No health output" + fi + else + test_failed "CLI: health command works" "Command failed" + fi + + # Test: languages command + if output=$("$TEST_BINARY" languages 2>&1); then + if echo "$output" | grep -qi "python\|error\|api key"; then + test_passed "CLI: languages command works" + else + test_failed "CLI: languages command works" "No languages output" + fi + else + test_failed "CLI: languages command works" "Command failed" + fi + + rm -f "$TEST_BINARY" +else + echo -e "${YELLOW}Could not compile un.m - skipping CLI command tests${NC}" +fi + +# API Command Tests (require API key) +if [ -n "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -n "${UNSANDBOX_SECRET_KEY:-}" ]; then + echo -e "\n${BLUE}=== API Command Tests (require auth) ===${NC}" + + # Compile the binary for API tests + if clang -x objective-c -framework Foundation "$UN_M" -o "$TEST_BINARY" 2>/dev/null; then + + # Test: snapshot --list + if output=$("$TEST_BINARY" snapshot --list 2>&1); then + if echo "$output" | grep -qE '\[|\{|Error|snapshots'; then + test_passed "CLI: snapshot --list works" + else + test_failed "CLI: snapshot --list works" "Unexpected output" + fi + else + if echo "$output" | grep -qE '\[|\{|Error|snapshots'; then + test_passed "CLI: snapshot --list works" + else + test_failed "CLI: snapshot --list works" "Command failed" + fi + fi + + # Test: session --list + if output=$("$TEST_BINARY" session --list 2>&1); then + if echo "$output" | grep -qE '\[|\{|Error|sessions'; then + test_passed "CLI: session --list works" + else + test_failed "CLI: session --list works" "Unexpected output" + fi + else + if echo "$output" | grep -qE '\[|\{|Error|sessions'; then + test_passed "CLI: session --list works" + else + test_failed "CLI: session --list works" "Command failed" + fi + fi + + # Test: service --list + if output=$("$TEST_BINARY" service --list 2>&1); then + if echo "$output" | grep -qE '\[|\{|Error|services'; then + test_passed "CLI: service --list works" + else + test_failed "CLI: service --list works" "Unexpected output" + fi + else + if echo "$output" | grep -qE '\[|\{|Error|services'; then + test_passed "CLI: service --list works" + else + test_failed "CLI: service --list works" "Command failed" + fi + fi + + # Test: image --list + if output=$("$TEST_BINARY" image --list 2>&1); then + if echo "$output" | grep -qE '\[|\{|Error|images'; then + test_passed "CLI: image --list works" + else + test_failed "CLI: image --list works" "Unexpected output" + fi + else + if echo "$output" | grep -qE '\[|\{|Error|images'; then + test_passed "CLI: image --list works" + else + test_failed "CLI: image --list works" "Command failed" + fi + fi + + rm -f "$TEST_BINARY" + else + echo -e "${YELLOW}Could not compile un.m - skipping API command tests${NC}" + fi +else + echo -e "\n${YELLOW}Skipping API command tests (UNSANDBOX_PUBLIC_KEY/SECRET_KEY not set)${NC}" +fi + # Integration Tests (require API key and successful compilation) if [ -n "${UNSANDBOX_API_KEY:-}" ]; then echo -e "\n${BLUE}=== Integration Tests for un.m ===${NC}" diff --git a/tests/test_un_nim.nim b/tests/test_un_nim.nim index da48628..b240379 100644 --- a/tests/test_un_nim.nim +++ b/tests/test_un_nim.nim @@ -144,6 +144,172 @@ proc testFibExecution(): bool = echo "Functional Test: passed\n" return true +proc testCliCommands(): bool = + echo "=== Test 4: CLI Commands (Feature Parity) ===" + + var passed = 0 + var failed = 0 + + # Find un.nim script + let scriptPaths = [ + "../clients/nim/sync/src/un.nim", + "../../clients/nim/sync/src/un.nim", + "../un.nim" + ] + + var unScript = "" + for path in scriptPaths: + if fileExists(path): + unScript = path + break + + if unScript == "": + echo " SKIP: un.nim not found" + echo "CLI Commands: skipped\n" + return true + + # Test: --help + let (helpOutput, helpCode) = try: + execCmdEx("nim r " & unScript & " -- --help") + except: + ("", -1) + + if helpOutput.contains("Usage") or helpOutput.contains("usage"): + echo " PASS: --help shows usage" + inc passed + else: + echo " FAIL: --help does not show usage" + inc failed + + # Test: version command + let (versionOutput, versionCode) = try: + execCmdEx("nim r " & unScript & " -- version") + except: + ("", -1) + + if versionOutput.contains("version") or versionOutput.contains("Version"): + echo " PASS: version command works" + inc passed + else: + echo " FAIL: version command does not work" + inc failed + + # Test: health command + let (healthOutput, healthCode) = try: + execCmdEx("nim r " & unScript & " -- health") + except: + ("", -1) + + if healthOutput.contains("health") or healthOutput.contains("API"): + echo " PASS: health command works" + inc passed + else: + echo " FAIL: health command does not work" + inc failed + + # Test: languages command + let (langsOutput, langsCode) = try: + execCmdEx("nim r " & unScript & " -- languages") + except: + ("", -1) + + if langsOutput.contains("python") or langsOutput.contains("Error") or langsOutput.contains("API key"): + echo " PASS: languages command works" + inc passed + else: + echo " FAIL: languages command does not work" + inc failed + + echo "CLI Commands: ", passed, " passed, ", failed, " failed\n" + return failed == 0 + +proc testApiCommands(): bool = + echo "=== Test 5: API Commands (require auth) ===" + + let publicKey = getEnv("UNSANDBOX_PUBLIC_KEY") + let secretKey = getEnv("UNSANDBOX_SECRET_KEY") + + if publicKey == "" and secretKey == "": + echo " SKIP: UNSANDBOX_PUBLIC_KEY/SECRET_KEY not set" + echo "API Commands: skipped\n" + return true + + var passed = 0 + var failed = 0 + + # Find un.nim script + let scriptPaths = [ + "../clients/nim/sync/src/un.nim", + "../../clients/nim/sync/src/un.nim", + "../un.nim" + ] + + var unScript = "" + for path in scriptPaths: + if fileExists(path): + unScript = path + break + + if unScript == "": + echo " SKIP: un.nim not found" + echo "API Commands: skipped\n" + return true + + # Test: snapshot --list + let (snapOutput, snapCode) = try: + execCmdEx("nim r " & unScript & " -- snapshot --list") + except: + ("", -1) + + if snapOutput.contains("[") or snapOutput.contains("{") or snapOutput.contains("Error") or snapOutput.contains("snapshots"): + echo " PASS: snapshot --list works" + inc passed + else: + echo " FAIL: snapshot --list does not work" + inc failed + + # Test: session --list + let (sessOutput, sessCode) = try: + execCmdEx("nim r " & unScript & " -- session --list") + except: + ("", -1) + + if sessOutput.contains("[") or sessOutput.contains("{") or sessOutput.contains("Error") or sessOutput.contains("sessions"): + echo " PASS: session --list works" + inc passed + else: + echo " FAIL: session --list does not work" + inc failed + + # Test: service --list + let (svcOutput, svcCode) = try: + execCmdEx("nim r " & unScript & " -- service --list") + except: + ("", -1) + + if svcOutput.contains("[") or svcOutput.contains("{") or svcOutput.contains("Error") or svcOutput.contains("services"): + echo " PASS: service --list works" + inc passed + else: + echo " FAIL: service --list does not work" + inc failed + + # Test: image --list + let (imgOutput, imgCode) = try: + execCmdEx("nim r " & unScript & " -- image --list") + except: + ("", -1) + + if imgOutput.contains("[") or imgOutput.contains("{") or imgOutput.contains("Error") or imgOutput.contains("images"): + echo " PASS: image --list works" + inc passed + else: + echo " FAIL: image --list does not work" + inc failed + + echo "API Commands: ", passed, " passed, ", failed, " failed\n" + return failed == 0 + proc main() = echo "UN CLI Nim Implementation Test Suite" echo "=====================================\n" @@ -159,6 +325,12 @@ proc main() = if not testFibExecution(): allPassed = false + if not testCliCommands(): + allPassed = false + + if not testApiCommands(): + allPassed = false + echo "=====================================" if allPassed: echo "RESULT: ALL TESTS PASSED" diff --git a/tests/test_un_raku.raku b/tests/test_un_raku.raku index 86fcc29..c021289 100755 --- a/tests/test_un_raku.raku +++ b/tests/test_un_raku.raku @@ -138,6 +138,14 @@ if %*ENV:exists && %*ENV { } else { test-skipped("Executes Bash file successfully (fib.sh not found)"); } + + # Test: Snapshot command support (feature parity test) + ($exit-code, $output) = run-command([$UN_RAKU, 'snapshot', '--list']); + if $exit-code == 0 { + test-passed("Snapshot list command works"); + } else { + test-failed("Snapshot list command works", "Expected exit code 0"); + } } else { say ""; say color-yellow("Skipping integration tests (UNSANDBOX_API_KEY not set)"); diff --git a/tests/test_un_scm.scm b/tests/test_un_scm.scm index 9a7e221..e1b537b 100755 --- a/tests/test_un_scm.scm +++ b/tests/test_un_scm.scm @@ -138,6 +138,26 @@ (print-result "Fibonacci end-to-end test" #f (format #f "Exception: ~a" args))))))) +;;; Test 4: Snapshot command support (feature parity test) +(define (test-snapshot-command) + (let ((api-key (getenv "UNSANDBOX_API_KEY"))) + (if (not api-key) + (print-result "Snapshot command support" #t #f) ; Skip test if no API key + (catch #t + (lambda () + (let* ((result (run-command "./un.scm snapshot --list 2>&1")) + (status (car result)) + (output (cdr result))) + + ;; Check if it executed without errors (may return empty list) + (if (= status 0) + (print-result "Snapshot command support" #t #f) + (print-result "Snapshot command support" #f + (format #f "Snapshot list failed: ~a" output))))) + (lambda (key . args) + (print-result "Snapshot command support" #f + (format #f "Exception: ~a" args))))))) + ;;; Main test runner (define (main) (display "=== Scheme UN CLI Test Suite ===\n\n") @@ -150,7 +170,8 @@ ;; Run tests (let ((results (list (test-extension-detection) (test-api-integration) - (test-fibonacci)))) + (test-fibonacci) + (test-snapshot-command)))) (display "\n") diff --git a/tests/test_un_swift.sh b/tests/test_un_swift.sh new file mode 100755 index 0000000..9bbb373 --- /dev/null +++ b/tests/test_un_swift.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# Test suite for un.swift (Swift implementation) +# Note: un.swift requires Swift compiler + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UN_SWIFT="$SCRIPT_DIR/../clients/swift/sync/src/un.swift" +TEST_DIR="$SCRIPT_DIR/../test" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Test result tracking +test_passed() { + TESTS_PASSED=$((TESTS_PASSED + 1)) + TESTS_RUN=$((TESTS_RUN + 1)) + echo -e "${GREEN}PASS${NC}: $1" +} + +test_failed() { + TESTS_FAILED=$((TESTS_FAILED + 1)) + TESTS_RUN=$((TESTS_RUN + 1)) + echo -e "${RED}FAIL${NC}: $1" + if [ -n "${2:-}" ]; then + echo -e "${RED} Error: $2${NC}" + fi +} + +test_skipped() { + echo -e "${YELLOW}SKIP${NC}: $1" +} + +# Check if Swift is available +if ! command -v swift &> /dev/null; then + echo -e "${YELLOW}Swift not found - skipping all Swift tests${NC}" + exit 0 +fi + +# Check if un.swift exists +if [ ! -f "$UN_SWIFT" ]; then + # Try alternative paths + UN_SWIFT="$SCRIPT_DIR/../un.swift" + if [ ! -f "$UN_SWIFT" ]; then + echo -e "${YELLOW}un.swift not found - skipping all tests${NC}" + exit 0 + fi +fi + +# Unit Tests +echo -e "${BLUE}=== Unit Tests for un.swift ===${NC}" + +# Test: Script exists +if [ -f "$UN_SWIFT" ]; then + test_passed "Script exists" +else + test_failed "Script exists" "File not found" + exit 1 +fi + +# Test: Script is readable +if [ -r "$UN_SWIFT" ]; then + test_passed "Script is readable" +else + test_failed "Script is readable" "File not readable" +fi + +# CLI Command Tests (Feature Parity) +echo -e "\n${BLUE}=== CLI Command Tests (Feature Parity) ===${NC}" + +# Test: --help +if output=$(swift "$UN_SWIFT" --help 2>&1); then + if echo "$output" | grep -qi "usage"; then + test_passed "CLI: --help shows usage" + else + test_failed "CLI: --help shows usage" "No usage indication" + fi +else + if echo "$output" | grep -qi "usage"; then + test_passed "CLI: --help shows usage" + else + test_failed "CLI: --help shows usage" "Command failed" + fi +fi + +# Test: version command +if output=$(swift "$UN_SWIFT" version 2>&1); then + if echo "$output" | grep -qi "version"; then + test_passed "CLI: version command works" + else + test_failed "CLI: version command works" "No version output" + fi +else + test_failed "CLI: version command works" "Command failed" +fi + +# Test: health command +if output=$(swift "$UN_SWIFT" health 2>&1); then + if echo "$output" | grep -qi "health\|api"; then + test_passed "CLI: health command works" + else + test_failed "CLI: health command works" "No health output" + fi +else + test_failed "CLI: health command works" "Command failed" +fi + +# Test: languages command +if output=$(swift "$UN_SWIFT" languages 2>&1); then + if echo "$output" | grep -qi "python\|error\|api key"; then + test_passed "CLI: languages command works" + else + test_failed "CLI: languages command works" "No languages output" + fi +else + test_failed "CLI: languages command works" "Command failed" +fi + +# API Command Tests (require API key) +if [ -n "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -n "${UNSANDBOX_SECRET_KEY:-}" ]; then + echo -e "\n${BLUE}=== API Command Tests (require auth) ===${NC}" + + # Test: snapshot --list + if output=$(swift "$UN_SWIFT" snapshot --list 2>&1); then + if echo "$output" | grep -qE '\[|\{|Error|snapshots'; then + test_passed "CLI: snapshot --list works" + else + test_failed "CLI: snapshot --list works" "Unexpected output" + fi + else + if echo "$output" | grep -qE '\[|\{|Error|snapshots'; then + test_passed "CLI: snapshot --list works" + else + test_failed "CLI: snapshot --list works" "Command failed" + fi + fi + + # Test: session --list + if output=$(swift "$UN_SWIFT" session --list 2>&1); then + if echo "$output" | grep -qE '\[|\{|Error|sessions'; then + test_passed "CLI: session --list works" + else + test_failed "CLI: session --list works" "Unexpected output" + fi + else + if echo "$output" | grep -qE '\[|\{|Error|sessions'; then + test_passed "CLI: session --list works" + else + test_failed "CLI: session --list works" "Command failed" + fi + fi + + # Test: service --list + if output=$(swift "$UN_SWIFT" service --list 2>&1); then + if echo "$output" | grep -qE '\[|\{|Error|services'; then + test_passed "CLI: service --list works" + else + test_failed "CLI: service --list works" "Unexpected output" + fi + else + if echo "$output" | grep -qE '\[|\{|Error|services'; then + test_passed "CLI: service --list works" + else + test_failed "CLI: service --list works" "Command failed" + fi + fi + + # Test: image --list + if output=$(swift "$UN_SWIFT" image --list 2>&1); then + if echo "$output" | grep -qE '\[|\{|Error|images'; then + test_passed "CLI: image --list works" + else + test_failed "CLI: image --list works" "Unexpected output" + fi + else + if echo "$output" | grep -qE '\[|\{|Error|images'; then + test_passed "CLI: image --list works" + else + test_failed "CLI: image --list works" "Command failed" + fi + fi +else + echo -e "\n${YELLOW}Skipping API command tests (UNSANDBOX_PUBLIC_KEY/SECRET_KEY not set)${NC}" +fi + +# Integration Tests (require API key) +if [ -n "${UNSANDBOX_API_KEY:-}" ]; then + echo -e "\n${BLUE}=== Integration Tests for un.swift ===${NC}" + + # Test: Can execute Python file + if [ -f "$TEST_DIR/fib.py" ]; then + if output=$(swift "$UN_SWIFT" "$TEST_DIR/fib.py" 2>&1); then + if echo "$output" | grep -q "fib(10)"; then + test_passed "Executes Python file successfully" + else + test_failed "Executes Python file successfully" "Expected fibonacci output" + fi + else + test_failed "Executes Python file successfully" "Script failed: $output" + fi + else + test_skipped "Executes Python file successfully (fib.py not found)" + fi + + # Test: Can execute Bash file + if [ -f "$TEST_DIR/fib.sh" ]; then + if output=$(swift "$UN_SWIFT" "$TEST_DIR/fib.sh" 2>&1); then + if echo "$output" | grep -q "fib(10)"; then + test_passed "Executes Bash file successfully" + else + test_failed "Executes Bash file successfully" "Expected fibonacci output" + fi + else + test_failed "Executes Bash file successfully" "Script failed: $output" + fi + else + test_skipped "Executes Bash file successfully (fib.sh not found)" + fi +else + echo -e "\n${YELLOW}Skipping integration tests (UNSANDBOX_API_KEY not set)${NC}" +fi + +# Summary +echo -e "\n${BLUE}=== Test Summary ===${NC}" +echo "Total: $TESTS_RUN | Passed: $TESTS_PASSED | Failed: $TESTS_FAILED" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi diff --git a/tests/test_un_v.v b/tests/test_un_v.v index d3e2939..4a24ba7 100644 --- a/tests/test_un_v.v +++ b/tests/test_un_v.v @@ -161,6 +161,161 @@ fn test_fib_execution() bool { return true } +fn test_cli_commands() bool { + println('=== Test 4: CLI Commands (Feature Parity) ===') + + mut passed := 0 + mut failed := 0 + + // Find un.v script + script_paths := [ + '../clients/v/sync/src/un.v', + '../../clients/v/sync/src/un.v', + '../un.v', + ] + + mut un_script := '' + for path in script_paths { + if os.exists(path) { + un_script = path + break + } + } + + if un_script == '' { + println(' SKIP: un.v not found') + println('CLI Commands: skipped\n') + return true + } + + // Test: --help + help_result := os.execute('v run ${un_script} -- --help') + if help_result.output.contains('Usage') || help_result.output.contains('usage') { + println(' PASS: --help shows usage') + passed++ + } else { + println(' FAIL: --help does not show usage') + failed++ + } + + // Test: version command + version_result := os.execute('v run ${un_script} -- version') + if version_result.output.contains('version') || version_result.output.contains('Version') { + println(' PASS: version command works') + passed++ + } else { + println(' FAIL: version command does not work') + failed++ + } + + // Test: health command + health_result := os.execute('v run ${un_script} -- health') + if health_result.output.contains('health') || health_result.output.contains('API') { + println(' PASS: health command works') + passed++ + } else { + println(' FAIL: health command does not work') + failed++ + } + + // Test: languages command + langs_result := os.execute('v run ${un_script} -- languages') + if langs_result.output.contains('python') || langs_result.output.contains('Error') || langs_result.output.contains('API key') { + println(' PASS: languages command works') + passed++ + } else { + println(' FAIL: languages command does not work') + failed++ + } + + println('CLI Commands: ${passed} passed, ${failed} failed\n') + return failed == 0 +} + +fn test_api_commands() bool { + println('=== Test 5: API Commands (require auth) ===') + + public_key := os.getenv('UNSANDBOX_PUBLIC_KEY') + secret_key := os.getenv('UNSANDBOX_SECRET_KEY') + + if public_key == '' && secret_key == '' { + println(' SKIP: UNSANDBOX_PUBLIC_KEY/SECRET_KEY not set') + println('API Commands: skipped\n') + return true + } + + mut passed := 0 + mut failed := 0 + + // Find un.v script + script_paths := [ + '../clients/v/sync/src/un.v', + '../../clients/v/sync/src/un.v', + '../un.v', + ] + + mut un_script := '' + for path in script_paths { + if os.exists(path) { + un_script = path + break + } + } + + if un_script == '' { + println(' SKIP: un.v not found') + println('API Commands: skipped\n') + return true + } + + // Test: snapshot --list + snap_result := os.execute('v run ${un_script} -- snapshot --list') + if snap_result.output.contains('[') || snap_result.output.contains('{') || + snap_result.output.contains('Error') || snap_result.output.contains('snapshots') { + println(' PASS: snapshot --list works') + passed++ + } else { + println(' FAIL: snapshot --list does not work') + failed++ + } + + // Test: session --list + sess_result := os.execute('v run ${un_script} -- session --list') + if sess_result.output.contains('[') || sess_result.output.contains('{') || + sess_result.output.contains('Error') || sess_result.output.contains('sessions') { + println(' PASS: session --list works') + passed++ + } else { + println(' FAIL: session --list does not work') + failed++ + } + + // Test: service --list + svc_result := os.execute('v run ${un_script} -- service --list') + if svc_result.output.contains('[') || svc_result.output.contains('{') || + svc_result.output.contains('Error') || svc_result.output.contains('services') { + println(' PASS: service --list works') + passed++ + } else { + println(' FAIL: service --list does not work') + failed++ + } + + // Test: image --list + img_result := os.execute('v run ${un_script} -- image --list') + if img_result.output.contains('[') || img_result.output.contains('{') || + img_result.output.contains('Error') || img_result.output.contains('images') { + println(' PASS: image --list works') + passed++ + } else { + println(' FAIL: image --list does not work') + failed++ + } + + println('API Commands: ${passed} passed, ${failed} failed\n') + return failed == 0 +} + fn main() { println('UN CLI V Implementation Test Suite') println('===================================\n') @@ -179,6 +334,14 @@ fn main() { all_passed = false } + if !test_cli_commands() { + all_passed = false + } + + if !test_api_commands() { + all_passed = false + } + println('===================================') if all_passed { println('RESULT: ALL TESTS PASSED')