modified: .gitignore

modified:   CLAUDE.md
	deleted:    __pycache__/un.cpython-312.pyc
	deleted:    __pycache__/un.cpython-313.pyc
	modified:   clients/c/src/un.c
	modified:   clients/java/sync/src/Un.java
	modified:   clients/javascript/sync/src/un.js
	modified:   clients/php/sync/src/un.php
	modified:   clients/ruby/async/src/un_async.rb
	modified:   clients/ruby/sync/src/un.rb
	modified:   clients/rust/async/Cargo.toml
	modified:   clients/rust/async/src/lib.rs
	new file:   clients/rust/async/src/main.rs
	modified:   clients/rust/sync/Cargo.toml
	modified:   clients/rust/sync/src/lib.rs
	new file:   clients/rust/sync/src/main.rs
	new file:   clients/swift/sync/src/un.swift
This commit is contained in:
russell@unturf.com 2026-01-15 19:05:53 -05:00
parent 6d91e987ce
commit bf4417c914
17 changed files with 6154 additions and 25 deletions

2
.gitignore vendored
View file

@ -14,6 +14,8 @@
/un_zig
/Un.class
__pycache__/
# Build directories
_build/
deps/

View file

@ -24,39 +24,56 @@ On 2026-01-11, raw `lxc delete` destroyed 8 production services causing complete
UN CLI Inception - The UN CLI written in every language it can execute. 42+ implementations, one unified interface.
### SDK Architecture (In Growth)
### SDK Architecture
**Current State**: Root-level implementations (un.py, un.c, un.go, etc.) serving as both CLI + embeddable libraries.
**Target State**: Migrate to `clients/` directory structure:
**Directory Structure**:
```
clients/
├── python/ # clients/python/un.py - sync/async client + CLI
├── javascript/ # clients/javascript/un.js - SDK + CLI
├── go/ # clients/go/un.go - SDK + CLI
├── java/ # clients/java/Un.java - SDK + CLI
├── ruby/ # clients/ruby/un.rb - SDK + CLI
├── php/ # clients/php/un.php - SDK + CLI
├── rust/ # clients/rust/un.rs - SDK + CLI
├── {42+ more}/
├── python/
│ ├── sync/src/un.py # Synchronous (requests) - 2,698 lines
│ └── async/src/un_async.py # Asynchronous (aiohttp) - 2,333 lines
├── javascript/
│ ├── sync/src/un.js # Synchronous (https) - 2,307 lines
│ └── async/src/un_async.js # Asynchronous (fetch) - 2,131 lines
├── go/
│ ├── sync/src/un.go # Synchronous (net/http) - 2,652 lines
│ └── async/src/un_async.go # Asynchronous (goroutines) - 3,011 lines
├── java/
│ ├── sync/src/Un.java # Synchronous (HttpURLConnection) - 3,051 lines
│ └── async/src/UnsandboxAsync.java # Asynchronous (CompletableFuture) - 2,685 lines
├── ruby/
│ ├── sync/src/un.rb # Synchronous (net/http) - 2,423 lines
│ └── async/src/un_async.rb # Asynchronous (Future) - 2,441 lines
├── rust/
│ ├── sync/src/lib.rs # Synchronous (reqwest blocking) - 3,665 lines
│ └── async/src/lib.rs # Asynchronous (reqwest + tokio) - 3,375 lines
├── php/
│ ├── sync/src/un.php # Synchronous (cURL) - 2,818 lines
│ └── async/src/UnsandboxAsync.php # Asynchronous (Guzzle promises) - 2,457 lines
├── CLI_SPEC.md # Full CLI specification (all SDKs must match)
└── README.md # SDK documentation
```
**Each client implementation serves THREE purposes**:
1. **Standalone CLI program** - Argparse/getopt with full command support (execute, session, service)
2. **Importable client library** - Can import and use as an SDK in other code
3. **Embeddable library** - Can be bundled into other language projects
**Total: 38,125 lines across 14 SDK files (7 languages × 2 variants)**
**Example (Python)**:
```python
# As CLI: python clients/python/un.py test/fib.py
# As library: from clients.python.un import UnsandboxClient
# As embedded: copy un.py into your project, import locally
**Each SDK is BOTH a library AND a CLI tool** (see `clients/CLI_SPEC.md`):
```bash
# Library usage
python -c "from un import execute_code; print(execute_code('python', 'print(1)'))"
# CLI usage (identical across all languages)
python un.py script.py # Execute code file
python un.py -s bash 'echo hello' # Inline code
python un.py session --tmux # Interactive session
python un.py service --list # Manage services
```
**Migration Path**:
- Phase 1 (Current): Grow clients/ directory in parallel with root un.* files
- Phase 2: Root files eventually deprecated in favor of clients/
- Phase 3: Root files maintained for backwards compatibility only
**Every SDK implements**:
- **43+ API functions** (execute, jobs, sessions, services, snapshots, utilities)
- **Full CLI** (execute, session, service, service env, snapshot, key subcommands)
- **4-tier credential resolution** (args > env > ~/.unsandbox/accounts.csv > ./accounts.csv)
- **HMAC-SHA256 request signing**
- **1-hour languages cache** (~/.unsandbox/languages.json)
## Authentication

Binary file not shown.

Binary file not shown.

View file

@ -4673,6 +4673,584 @@ static int clone_snapshot(const UnsandboxCredentials *creds, const char *snapsho
// End Service Management Support
// ============================================================================
// ============================================================================
// LXD Container Images API
// ============================================================================
// Publish an image from a service or snapshot
static char* image_publish(const UnsandboxCredentials *creds, const char *source_type,
const char *source_id, const char *name, const char *description) {
if (!creds || !creds->public_key || !creds->secret_key || !source_type || !source_id) {
return NULL;
}
CURL *curl = curl_easy_init();
if (!curl) return NULL;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
snprintf(url, sizeof(url), "%s/images", API_BASE);
// Build JSON body
char body[2048];
char *p = body;
p += sprintf(p, "{\"source_type\":\"%s\",\"source_id\":\"%s\"", source_type, source_id);
if (name && strlen(name) > 0) {
char *esc = escape_json_string(name);
p += sprintf(p, ",\"name\":\"%s\"", esc);
free(esc);
}
if (description && strlen(description) > 0) {
char *esc = escape_json_string(description);
p += sprintf(p, ",\"description\":\"%s\"", esc);
free(esc);
}
p += sprintf(p, "}");
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", "/images", body);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
free(response.data);
return NULL;
}
return response.data;
}
// List images (filter_type can be NULL, "owned", "shared", or "public")
static char* list_images(const UnsandboxCredentials *creds, const char *filter_type) {
if (!creds || !creds->public_key || !creds->secret_key) {
return NULL;
}
CURL *curl = curl_easy_init();
if (!curl) return NULL;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
if (filter_type && strlen(filter_type) > 0) {
snprintf(url, sizeof(url), "%s/images/%s", API_BASE, filter_type);
snprintf(path, sizeof(path), "/images/%s", filter_type);
} else {
snprintf(url, sizeof(url), "%s/images", API_BASE);
snprintf(path, sizeof(path), "/images");
}
struct curl_slist *headers = NULL;
headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
free(response.data);
return NULL;
}
return response.data;
}
// Get image details
static char* get_image(const UnsandboxCredentials *creds, const char *image_id) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id) {
return NULL;
}
CURL *curl = curl_easy_init();
if (!curl) return NULL;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s", image_id);
struct curl_slist *headers = NULL;
headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
free(response.data);
return NULL;
}
return response.data;
}
// Delete an image
static int delete_image(const UnsandboxCredentials *creds, const char *image_id) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id) {
return 1;
}
CURL *curl = curl_easy_init();
if (!curl) return 1;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s", image_id);
struct curl_slist *headers = NULL;
headers = add_hmac_auth_headers(headers, creds, "DELETE", path, NULL);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
free(response.data);
return (res == CURLE_OK) ? 0 : 1;
}
// Lock an image
static int lock_image(const UnsandboxCredentials *creds, const char *image_id) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id) {
return 1;
}
CURL *curl = curl_easy_init();
if (!curl) return 1;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/lock", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/lock", image_id);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}");
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
free(response.data);
return (res == CURLE_OK) ? 0 : 1;
}
// Unlock an image
static int unlock_image(const UnsandboxCredentials *creds, const char *image_id) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id) {
return 1;
}
CURL *curl = curl_easy_init();
if (!curl) return 1;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/unlock", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/unlock", image_id);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}");
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
free(response.data);
return (res == CURLE_OK) ? 0 : 1;
}
// Set image visibility (private, unlisted, public)
static int set_image_visibility(const UnsandboxCredentials *creds, const char *image_id, const char *visibility) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id || !visibility) {
return 1;
}
CURL *curl = curl_easy_init();
if (!curl) return 1;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/visibility", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/visibility", image_id);
char body[128];
snprintf(body, sizeof(body), "{\"visibility\":\"%s\"}", visibility);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", path, body);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
free(response.data);
return (res == CURLE_OK) ? 0 : 1;
}
// Grant image access to another API key
static int grant_image_access(const UnsandboxCredentials *creds, const char *image_id, const char *trusted_api_key) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id || !trusted_api_key) {
return 1;
}
CURL *curl = curl_easy_init();
if (!curl) return 1;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/grant", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/grant", image_id);
char body[256];
snprintf(body, sizeof(body), "{\"trusted_api_key\":\"%s\"}", trusted_api_key);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", path, body);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
free(response.data);
return (res == CURLE_OK) ? 0 : 1;
}
// Revoke image access from another API key
static int revoke_image_access(const UnsandboxCredentials *creds, const char *image_id, const char *trusted_api_key) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id || !trusted_api_key) {
return 1;
}
CURL *curl = curl_easy_init();
if (!curl) return 1;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/revoke", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/revoke", image_id);
char body[256];
snprintf(body, sizeof(body), "{\"trusted_api_key\":\"%s\"}", trusted_api_key);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", path, body);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
free(response.data);
return (res == CURLE_OK) ? 0 : 1;
}
// List API keys with access to an image
static char* list_image_trusted(const UnsandboxCredentials *creds, const char *image_id) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id) {
return NULL;
}
CURL *curl = curl_easy_init();
if (!curl) return NULL;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/trusted", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/trusted", image_id);
struct curl_slist *headers = NULL;
headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
free(response.data);
return NULL;
}
return response.data;
}
// Transfer image ownership to another API key
static int transfer_image(const UnsandboxCredentials *creds, const char *image_id, const char *to_api_key) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id || !to_api_key) {
return 1;
}
CURL *curl = curl_easy_init();
if (!curl) return 1;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/transfer", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/transfer", image_id);
char body[256];
snprintf(body, sizeof(body), "{\"to_api_key\":\"%s\"}", to_api_key);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", path, body);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
free(response.data);
return (res == CURLE_OK) ? 0 : 1;
}
// Spawn a new service from an image
static char* spawn_from_image(const UnsandboxCredentials *creds, const char *image_id,
const char *name, const char *ports, const char *bootstrap,
const char *network_mode) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id) {
return NULL;
}
CURL *curl = curl_easy_init();
if (!curl) return NULL;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/spawn", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/spawn", image_id);
// Build JSON body
char body[4096];
char *p = body;
p += sprintf(p, "{");
int need_comma = 0;
if (name && strlen(name) > 0) {
char *esc = escape_json_string(name);
p += sprintf(p, "\"name\":\"%s\"", esc);
free(esc);
need_comma = 1;
}
if (ports && strlen(ports) > 0) {
if (need_comma) p += sprintf(p, ",");
p += sprintf(p, "\"ports\":\"%s\"", ports);
need_comma = 1;
}
if (bootstrap && strlen(bootstrap) > 0) {
if (need_comma) p += sprintf(p, ",");
char *esc = escape_json_string(bootstrap);
p += sprintf(p, "\"bootstrap\":\"%s\"", esc);
free(esc);
need_comma = 1;
}
if (network_mode && strlen(network_mode) > 0) {
if (need_comma) p += sprintf(p, ",");
p += sprintf(p, "\"network_mode\":\"%s\"", network_mode);
}
p += sprintf(p, "}");
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", path, body);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
free(response.data);
return NULL;
}
return response.data;
}
// Clone an image to create a copy owned by the current user
static char* clone_image(const UnsandboxCredentials *creds, const char *image_id,
const char *name, const char *description) {
if (!creds || !creds->public_key || !creds->secret_key || !image_id) {
return NULL;
}
CURL *curl = curl_easy_init();
if (!curl) return NULL;
struct ResponseBuffer response = {0};
response.data = malloc(1);
response.size = 0;
char url[256];
char path[128];
snprintf(url, sizeof(url), "%s/images/%s/clone", API_BASE, image_id);
snprintf(path, sizeof(path), "/images/%s/clone", image_id);
// Build JSON body
char body[2048];
char *p = body;
p += sprintf(p, "{");
int need_comma = 0;
if (name && strlen(name) > 0) {
char *esc = escape_json_string(name);
p += sprintf(p, "\"name\":\"%s\"", esc);
free(esc);
need_comma = 1;
}
if (description && strlen(description) > 0) {
if (need_comma) p += sprintf(p, ",");
char *esc = escape_json_string(description);
p += sprintf(p, "\"description\":\"%s\"", esc);
free(esc);
}
p += sprintf(p, "}");
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = add_hmac_auth_headers(headers, creds, "POST", path, body);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
free(response.data);
return NULL;
}
return response.data;
}
// ============================================================================
// End LXD Container Images API
// ============================================================================
// ============================================================================
// Key Validation Support
// ============================================================================

View file

@ -1718,6 +1718,369 @@ public class Un {
return makeRequest("POST", "/snapshots/" + snapshotId + "/clone", creds[0], creds[1], data);
}
// ========================================================================
// Images API Methods (LXD Container Images)
// ========================================================================
/**
* Publish a session or service as a reusable LXD container image.
*
* @param sourceType Source type: "session" or "service"
* @param sourceId ID of the session or service to publish
* @param name Name for the image
* @param description Optional description for the image
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map containing image_id
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> imagePublish(
String sourceType,
String sourceId,
String name,
String description,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
Map<String, Object> data = new LinkedHashMap<>();
data.put("source_type", sourceType);
data.put("source_id", sourceId);
data.put("name", name);
if (description != null && !description.isEmpty()) {
data.put("description", description);
}
return makeRequest("POST", "/images", creds[0], creds[1], data);
}
/**
* List container images with optional filtering.
*
* @param filterType Optional filter: "own", "shared", "public", or null for all
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return List of image maps containing id, name, description, visibility, etc.
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
@SuppressWarnings("unchecked")
public static List<Map<String, Object>> listImages(
String filterType,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
String path = "/images";
if (filterType != null && !filterType.isEmpty()) {
path = "/images/" + filterType;
}
Map<String, Object> response = makeRequest("GET", path, creds[0], creds[1], null);
Object images = response.get("images");
if (images instanceof List) {
List<Map<String, Object>> result = new ArrayList<>();
for (Object item : (List<?>) images) {
if (item instanceof Map) {
result.add((Map<String, Object>) item);
}
}
return result;
}
return new ArrayList<>();
}
/**
* Get details of a specific image.
*
* @param imageId Image ID to retrieve
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Image details map
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> getImage(
String imageId,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
return makeRequest("GET", "/images/" + imageId, creds[0], creds[1], null);
}
/**
* Delete an image.
*
* @param imageId Image ID to delete
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map with deletion confirmation
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> deleteImage(
String imageId,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
return makeRequest("DELETE", "/images/" + imageId, creds[0], creds[1], null);
}
/**
* Lock an image (prevent deletion and modifications).
*
* @param imageId Image ID to lock
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map with lock confirmation
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> lockImage(
String imageId,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
return makeRequest("POST", "/images/" + imageId + "/lock", creds[0], creds[1], new LinkedHashMap<>());
}
/**
* Unlock an image (allow deletion and modifications).
*
* @param imageId Image ID to unlock
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map with unlock confirmation
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> unlockImage(
String imageId,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
return makeRequest("POST", "/images/" + imageId + "/unlock", creds[0], creds[1], new LinkedHashMap<>());
}
/**
* Set the visibility of an image.
*
* @param imageId Image ID to modify
* @param visibility Visibility level: "private", "shared", or "public"
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map with visibility change confirmation
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> setImageVisibility(
String imageId,
String visibility,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
Map<String, Object> data = new LinkedHashMap<>();
data.put("visibility", visibility);
return makeRequest("POST", "/images/" + imageId + "/visibility", creds[0], creds[1], data);
}
/**
* Grant access to an image for another API key (for shared images).
*
* @param imageId Image ID to grant access to
* @param trustedApiKey Public API key to grant access to
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map with grant confirmation
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> grantImageAccess(
String imageId,
String trustedApiKey,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
Map<String, Object> data = new LinkedHashMap<>();
data.put("trusted_api_key", trustedApiKey);
return makeRequest("POST", "/images/" + imageId + "/grant", creds[0], creds[1], data);
}
/**
* Revoke access to an image from another API key.
*
* @param imageId Image ID to revoke access from
* @param trustedApiKey Public API key to revoke access from
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map with revoke confirmation
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> revokeImageAccess(
String imageId,
String trustedApiKey,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
Map<String, Object> data = new LinkedHashMap<>();
data.put("trusted_api_key", trustedApiKey);
return makeRequest("POST", "/images/" + imageId + "/revoke", creds[0], creds[1], data);
}
/**
* List API keys that have been granted access to an image.
*
* @param imageId Image ID to list trusted keys for
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return List of trusted API key maps
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
@SuppressWarnings("unchecked")
public static List<Map<String, Object>> listImageTrusted(
String imageId,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
Map<String, Object> response = makeRequest("GET", "/images/" + imageId + "/trusted", creds[0], creds[1], null);
Object trusted = response.get("trusted");
if (trusted instanceof List) {
List<Map<String, Object>> result = new ArrayList<>();
for (Object item : (List<?>) trusted) {
if (item instanceof Map) {
result.add((Map<String, Object>) item);
}
}
return result;
}
return new ArrayList<>();
}
/**
* Transfer ownership of an image to another API key.
*
* @param imageId Image ID to transfer
* @param toApiKey Public API key of the new owner
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map with transfer confirmation
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> transferImage(
String imageId,
String toApiKey,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
Map<String, Object> data = new LinkedHashMap<>();
data.put("to_api_key", toApiKey);
return makeRequest("POST", "/images/" + imageId + "/transfer", creds[0], creds[1], data);
}
/**
* Spawn a new service from an image.
*
* @param imageId Image ID to spawn from
* @param name Name for the new service
* @param ports Comma-separated list of ports to expose (e.g., "80,443")
* @param bootstrap Optional bootstrap script or URL
* @param networkMode Network mode: "zerotrust" or "semitrusted"
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map containing service_id
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> spawnFromImage(
String imageId,
String name,
String ports,
String bootstrap,
String networkMode,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
Map<String, Object> data = new LinkedHashMap<>();
data.put("name", name);
if (ports != null && !ports.isEmpty()) {
List<Integer> portList = new ArrayList<>();
for (String p : ports.split(",")) {
try {
portList.add(Integer.parseInt(p.trim()));
} catch (NumberFormatException e) {
// Skip invalid port
}
}
data.put("ports", portList);
}
if (bootstrap != null && !bootstrap.isEmpty()) {
if (bootstrap.startsWith("http://") || bootstrap.startsWith("https://")) {
data.put("bootstrap_url", bootstrap);
} else {
data.put("bootstrap", bootstrap);
}
}
if (networkMode != null && !networkMode.isEmpty()) {
data.put("network_mode", networkMode);
}
return makeRequest("POST", "/images/" + imageId + "/spawn", creds[0], creds[1], data);
}
/**
* Clone an image to create a new image with a different name.
*
* @param imageId Image ID to clone
* @param name Name for the cloned image
* @param description Optional description for the cloned image
* @param publicKey Optional API key
* @param secretKey Optional API secret
* @return Response map containing new image_id
* @throws IOException on network errors
* @throws CredentialsException if credentials cannot be found
* @throws ApiException if API returns an error
*/
public static Map<String, Object> cloneImage(
String imageId,
String name,
String description,
String publicKey,
String secretKey
) throws IOException {
String[] creds = resolveCredentials(publicKey, secretKey);
Map<String, Object> data = new LinkedHashMap<>();
if (name != null && !name.isEmpty()) {
data.put("name", name);
}
if (description != null && !description.isEmpty()) {
data.put("description", description);
}
return makeRequest("POST", "/images/" + imageId + "/clone", creds[0], creds[1], data);
}
// ========================================================================
// Key Validation API
// ========================================================================

View file

@ -19,6 +19,11 @@
* // Snapshot management
* sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot,
* deleteSnapshot, lockSnapshot, unlockSnapshot, cloneSnapshot,
* // Images API (LXD container images)
* imagePublish, listImages, getImage, deleteImage,
* lockImage, unlockImage, setImageVisibility,
* grantImageAccess, revokeImageAccess, listImageTrusted,
* transferImage, spawnFromImage, cloneImage,
* // Key validation
* validateKeys,
* } = require('./un.js');
@ -1367,6 +1372,20 @@ module.exports = {
lockSnapshot,
unlockSnapshot,
cloneSnapshot,
// Images API (LXD container images)
imagePublish,
listImages,
getImage,
deleteImage,
lockImage,
unlockImage,
setImageVisibility,
grantImageAccess,
revokeImageAccess,
listImageTrusted,
transferImage,
spawnFromImage,
cloneImage,
// Key validation
validateKeys,
// Image generation

View file

@ -510,6 +510,310 @@ class Unsandbox {
return $this->makeRequest('POST', "/snapshots/{$snapshotId}/clone", $publicKey, $secretKey, $data);
}
// =========================================================================
// Images Methods (LXD Container Images)
// =========================================================================
/**
* Publish a new image from a session or service container.
*
* @param string $sourceType Source type: "session" or "service"
* @param string $sourceId Session ID or Service ID to publish from
* @param string|null $name Optional name for the image
* @param string|null $description Optional description for the image
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with image_id and image details
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function imagePublish(
string $sourceType,
string $sourceId,
?string $name = null,
?string $description = null,
?string $publicKey = null,
?string $secretKey = null
): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$data = [
'source_type' => $sourceType,
'source_id' => $sourceId,
];
if ($name !== null) {
$data['name'] = $name;
}
if ($description !== null) {
$data['description'] = $description;
}
return $this->makeRequest('POST', '/images', $publicKey, $secretKey, $data);
}
/**
* List all images owned by the authenticated account.
*
* @param string|null $filterType Optional filter: "owned", "shared", "public", or null for all
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array List of image arrays
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function listImages(?string $filterType = null, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$path = $filterType !== null ? "/images/{$filterType}" : '/images';
$response = $this->makeRequest('GET', $path, $publicKey, $secretKey);
return $response['images'] ?? [];
}
/**
* Get details of a specific image.
*
* @param string $imageId Image ID
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Image details
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function getImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('GET', "/images/{$imageId}", $publicKey, $secretKey);
}
/**
* Delete an image.
*
* @param string $imageId Image ID to delete
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with deletion confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function deleteImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('DELETE', "/images/{$imageId}", $publicKey, $secretKey);
}
/**
* Lock an image to prevent deletion.
*
* @param string $imageId Image ID to lock
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with lock confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function lockImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('POST', "/images/{$imageId}/lock", $publicKey, $secretKey, []);
}
/**
* Unlock an image to allow deletion.
*
* @param string $imageId Image ID to unlock
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with unlock confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function unlockImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('POST', "/images/{$imageId}/unlock", $publicKey, $secretKey, []);
}
/**
* Set visibility of an image.
*
* @param string $imageId Image ID
* @param string $visibility Visibility level: "private" or "public"
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with visibility update confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function setImageVisibility(
string $imageId,
string $visibility,
?string $publicKey = null,
?string $secretKey = null
): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('POST', "/images/{$imageId}/visibility", $publicKey, $secretKey, [
'visibility' => $visibility,
]);
}
/**
* Grant access to an image for another API key.
*
* @param string $imageId Image ID
* @param string $trustedApiKey The API key to grant access to
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with grant confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function grantImageAccess(
string $imageId,
string $trustedApiKey,
?string $publicKey = null,
?string $secretKey = null
): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('POST', "/images/{$imageId}/grant", $publicKey, $secretKey, [
'trusted_api_key' => $trustedApiKey,
]);
}
/**
* Revoke access to an image from another API key.
*
* @param string $imageId Image ID
* @param string $trustedApiKey The API key to revoke access from
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with revoke confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function revokeImageAccess(
string $imageId,
string $trustedApiKey,
?string $publicKey = null,
?string $secretKey = null
): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('POST', "/images/{$imageId}/revoke", $publicKey, $secretKey, [
'trusted_api_key' => $trustedApiKey,
]);
}
/**
* List API keys that have been granted access to an image.
*
* @param string $imageId Image ID
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array List of trusted API key information
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function listImageTrusted(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$response = $this->makeRequest('GET', "/images/{$imageId}/trusted", $publicKey, $secretKey);
return $response['trusted'] ?? [];
}
/**
* Transfer ownership of an image to another API key.
*
* @param string $imageId Image ID to transfer
* @param string $toApiKey The API key to transfer ownership to
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with transfer confirmation
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function transferImage(
string $imageId,
string $toApiKey,
?string $publicKey = null,
?string $secretKey = null
): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
return $this->makeRequest('POST', "/images/{$imageId}/transfer", $publicKey, $secretKey, [
'to_api_key' => $toApiKey,
]);
}
/**
* Spawn a new service from an image.
*
* @param string $imageId Image ID to spawn from
* @param string|null $name Optional service name
* @param array|string|null $ports Optional port(s) to expose (array of ints or comma-separated string)
* @param string|null $bootstrap Optional bootstrap command or URL
* @param string $networkMode Network mode: "zerotrust" or "semitrusted"
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with spawned service info
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function spawnFromImage(
string $imageId,
?string $name = null,
$ports = null,
?string $bootstrap = null,
string $networkMode = 'zerotrust',
?string $publicKey = null,
?string $secretKey = null
): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$data = [
'network_mode' => $networkMode,
];
if ($name !== null) {
$data['name'] = $name;
}
if ($ports !== null) {
// Convert ports to array if string
if (is_string($ports)) {
$ports = array_map('intval', explode(',', $ports));
}
$data['ports'] = $ports;
}
if ($bootstrap !== null) {
$data['bootstrap'] = $bootstrap;
}
return $this->makeRequest('POST', "/images/{$imageId}/spawn", $publicKey, $secretKey, $data);
}
/**
* Clone an image to create a new image with a different name/description.
*
* @param string $imageId Image ID to clone
* @param string|null $name Optional name for the cloned image
* @param string|null $description Optional description for the cloned image
* @param string|null $publicKey Optional API key
* @param string|null $secretKey Optional API secret
* @return array Response array with cloned image info
* @throws CredentialsException Missing credentials
* @throws ApiException API request failed
*/
public function cloneImage(
string $imageId,
?string $name = null,
?string $description = null,
?string $publicKey = null,
?string $secretKey = null
): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$data = [];
if ($name !== null) {
$data['name'] = $name;
}
if ($description !== null) {
$data['description'] = $description;
}
return $this->makeRequest('POST', "/images/{$imageId}/clone", $publicKey, $secretKey, $data);
}
// =========================================================================
// Session Methods
// =========================================================================

View file

@ -1549,4 +1549,893 @@ module UnAsync
nil
end
end
# ============================================================================
# CLI Implementation
# ============================================================================
# Exit codes
EXIT_SUCCESS = 0
EXIT_ERROR = 1
EXIT_INVALID_ARGS = 2
EXIT_AUTH_ERROR = 3
EXIT_API_ERROR = 4
EXIT_TIMEOUT = 5
class << self
# Main CLI entry point
def cli_main
# Global options
options = {
shell: nil,
env: [],
files: [],
file_paths: [],
public_key: nil,
secret_key: nil,
network: 'zerotrust',
vcpu: 1,
yes: false,
artifacts: false,
output: nil
}
# Check for subcommands first
if ARGV.empty?
cli_show_help
exit(EXIT_INVALID_ARGS)
end
case ARGV[0]
when 'session'
ARGV.shift
cli_session(options)
when 'service'
ARGV.shift
cli_service(options)
when 'snapshot'
ARGV.shift
cli_snapshot(options)
when 'key'
ARGV.shift
cli_key(options)
when '-h', '--help', 'help'
cli_show_help
exit(EXIT_SUCCESS)
else
cli_execute(options)
end
rescue CredentialsError => e
$stderr.puts "Error: #{e.message}"
exit(EXIT_AUTH_ERROR)
rescue APIError => e
$stderr.puts "Error: #{e.message}"
exit(EXIT_API_ERROR)
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
$stderr.puts "Error: #{e.message}"
exit(EXIT_INVALID_ARGS)
rescue Interrupt
$stderr.puts "\nInterrupted"
exit(EXIT_ERROR)
end
private
# Show main help
def cli_show_help
puts <<~HELP
unsandbox.com Ruby SDK (Async) - Secure Code Execution
Usage:
ruby un_async.rb [options] <source_file> Execute code file
ruby un_async.rb [options] -s LANG 'code' Execute inline code
ruby un_async.rb session [options] Manage sessions
ruby un_async.rb service [options] Manage services
ruby un_async.rb snapshot [options] Manage snapshots
ruby un_async.rb key Check API key
Global Options:
-s, --shell LANG Language for inline code
-e, --env KEY=VAL Set environment variable (can repeat)
-f, --file FILE Add input file to /tmp/
-F, --file-path FILE Add input file with path preserved
-a, --artifacts Return compiled artifacts
-o, --output DIR Output directory for artifacts
-p, --public-key KEY API public key
-k, --secret-key KEY API secret key
-n, --network MODE Network mode: zerotrust or semitrusted
-v, --vcpu N vCPU count (1-8)
-y, --yes Skip confirmation prompts
-h, --help Show this help
Examples:
ruby un_async.rb script.py
ruby un_async.rb -s python 'print("hello")'
ruby un_async.rb -n semitrusted crawler.py
ruby un_async.rb session --list
ruby un_async.rb service --name web --ports 80 --bootstrap "python -m http.server 80"
HELP
end
# Parse global options from ARGV
def parse_global_options(options)
OptionParser.new do |opts|
opts.on('-s', '--shell LANG', 'Language for inline code') do |v|
options[:shell] = v
end
opts.on('-e', '--env KEY=VAL', 'Set environment variable') do |v|
options[:env] << v
end
opts.on('-f', '--file FILE', 'Add input file to /tmp/') do |v|
options[:files] << v
end
opts.on('-F', '--file-path FILE', 'Add input file with path preserved') do |v|
options[:file_paths] << v
end
opts.on('-a', '--artifacts', 'Return compiled artifacts') do
options[:artifacts] = true
end
opts.on('-o', '--output DIR', 'Output directory for artifacts') do |v|
options[:output] = v
end
opts.on('-p', '--public-key KEY', 'API public key') do |v|
options[:public_key] = v
end
opts.on('-k', '--secret-key KEY', 'API secret key') do |v|
options[:secret_key] = v
end
opts.on('-n', '--network MODE', 'Network mode') do |v|
options[:network] = v
end
opts.on('-v', '--vcpu N', Integer, 'vCPU count (1-8)') do |v|
options[:vcpu] = v
end
opts.on('-y', '--yes', 'Skip confirmation prompts') do
options[:yes] = true
end
opts.on('-h', '--help', 'Show help') do
yield if block_given?
exit(EXIT_SUCCESS)
end
end
end
# Execute code command
def cli_execute(options)
parser = parse_global_options(options) do
cli_show_help
end
parser.parse!(ARGV)
if ARGV.empty?
$stderr.puts 'Error: No source file or code provided'
exit(EXIT_INVALID_ARGS)
end
code = nil
language = nil
if options[:shell]
# Inline code mode: -s LANG 'code'
language = options[:shell]
code = ARGV.join(' ')
else
# File mode: script.py
filename = ARGV[0]
unless File.exist?(filename)
$stderr.puts "Error: File not found: #{filename}"
exit(EXIT_INVALID_ARGS)
end
code = File.read(filename)
language = detect_language(filename)
unless language
$stderr.puts "Error: Cannot detect language for: #{filename}"
$stderr.puts 'Use -s/--shell to specify language'
exit(EXIT_INVALID_ARGS)
end
end
# Execute the code (async call, await result)
result = execute_code(
language,
code,
public_key: options[:public_key],
secret_key: options[:secret_key]
).value
# Output results
cli_print_execute_result(result)
end
# Print execution result
def cli_print_execute_result(result)
puts result['stdout'] if result['stdout'] && !result['stdout'].empty?
$stderr.puts result['stderr'] if result['stderr'] && !result['stderr'].empty?
puts '---'
puts "Exit code: #{result['exit_code'] || 0}"
if result['execution_time_ms']
puts "Execution time: #{result['execution_time_ms']}ms"
end
end
# Session subcommand
def cli_session(options)
session_opts = {
list: false,
attach: nil,
kill: nil,
freeze: nil,
unfreeze: nil,
boost: nil,
unboost: nil,
snapshot: nil,
snapshot_name: nil,
hot: false,
tmux: false,
screen: false,
shell: 'bash',
audit: false
}
parser = parse_global_options(options) do
cli_session_help
end
parser.on('-l', '--list', 'List active sessions') do
session_opts[:list] = true
end
parser.on('--attach ID', 'Reconnect to existing session') do |v|
session_opts[:attach] = v
end
parser.on('--kill ID', 'Terminate a session') do |v|
session_opts[:kill] = v
end
parser.on('--freeze ID', 'Pause session') do |v|
session_opts[:freeze] = v
end
parser.on('--unfreeze ID', 'Resume session') do |v|
session_opts[:unfreeze] = v
end
parser.on('--boost ID', 'Add vCPUs/RAM') do |v|
session_opts[:boost] = v
end
parser.on('--unboost ID', 'Remove boost') do |v|
session_opts[:unboost] = v
end
parser.on('--snapshot ID', 'Create snapshot') do |v|
session_opts[:snapshot] = v
end
parser.on('--snapshot-name NAME', 'Name for snapshot') do |v|
session_opts[:snapshot_name] = v
end
parser.on('--hot', 'Live snapshot (no freeze)') do
session_opts[:hot] = true
end
parser.on('--tmux', 'Enable persistence with tmux') do
session_opts[:tmux] = true
end
parser.on('--screen', 'Enable persistence with screen') do
session_opts[:screen] = true
end
parser.on('--shell SHELL', 'Shell/REPL to use') do |v|
session_opts[:shell] = v
end
parser.on('--audit', 'Record session') do
session_opts[:audit] = true
end
parser.parse!(ARGV)
creds = { public_key: options[:public_key], secret_key: options[:secret_key] }
if session_opts[:list]
sessions = list_sessions(**creds).value
cli_print_sessions_table(sessions)
elsif session_opts[:kill]
delete_session(session_opts[:kill], **creds).value
puts "Session #{session_opts[:kill]} terminated"
elsif session_opts[:freeze]
freeze_session(session_opts[:freeze], **creds).value
puts "Session #{session_opts[:freeze]} frozen"
elsif session_opts[:unfreeze]
unfreeze_session(session_opts[:unfreeze], **creds).value
puts "Session #{session_opts[:unfreeze]} unfrozen"
elsif session_opts[:boost]
boost_session(session_opts[:boost], **creds).value
puts "Session #{session_opts[:boost]} boosted"
elsif session_opts[:unboost]
unboost_session(session_opts[:unboost], **creds).value
puts "Session #{session_opts[:unboost]} unboosted"
elsif session_opts[:snapshot]
snapshot_id = session_snapshot(
session_opts[:snapshot],
name: session_opts[:snapshot_name],
ephemeral: session_opts[:hot],
**creds
).value
puts "Snapshot created: #{snapshot_id}"
elsif session_opts[:attach]
# Attach to existing session - show info
session = get_session(session_opts[:attach], **creds).value
puts "Session: #{session['id']}"
puts "Status: #{session['status']}"
puts "WebSocket URL: #{session['websocket_url']}" if session['websocket_url']
puts "\nNote: Use a WebSocket client to connect interactively"
else
# Create new session
multiplexer = nil
multiplexer = 'tmux' if session_opts[:tmux]
multiplexer = 'screen' if session_opts[:screen]
result = create_session(
session_opts[:shell],
network_mode: options[:network],
vcpu: options[:vcpu],
multiplexer: multiplexer,
**creds
).value
puts "Session created: #{result['session_id']}"
puts "Container: #{result['container_name']}" if result['container_name']
puts "WebSocket URL: #{result['websocket_url']}" if result['websocket_url']
puts "\nNote: Use a WebSocket client to connect interactively"
end
end
# Print sessions table
def cli_print_sessions_table(sessions)
if sessions.empty?
puts 'No active sessions'
return
end
# Header
puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'STATUS', 'CREATED')
sessions.each do |s|
puts format('%-38s %-20s %-10s %-20s',
s['id'] || s['session_id'] || '-',
s['name'] || '-',
s['status'] || s['state'] || '-',
s['created_at'] || '-')
end
end
# Session help
def cli_session_help
puts <<~HELP
Session Management
Usage:
ruby un_async.rb session [options]
Options:
--shell SHELL Shell/REPL to use (default: bash)
-l, --list List active sessions
--attach ID Reconnect to existing session
--kill ID Terminate a session
--freeze ID Pause session
--unfreeze ID Resume session
--boost ID Add vCPUs/RAM
--unboost ID Remove boost
--tmux Enable persistence with tmux
--screen Enable persistence with screen
--snapshot ID Create snapshot
--snapshot-name NAME Name for snapshot
--hot Live snapshot (no freeze)
--audit Record session
Examples:
ruby un_async.rb session # New bash session
ruby un_async.rb session --shell python3 # Python REPL
ruby un_async.rb session --tmux # Persistent session
ruby un_async.rb session --list # List sessions
ruby un_async.rb session --kill abc123 # Kill session
HELP
end
# Service subcommand
def cli_service(options)
service_opts = {
list: false,
name: nil,
ports: nil,
domains: nil,
type: nil,
bootstrap: nil,
bootstrap_file: nil,
env_file: nil,
info: nil,
logs: nil,
tail: nil,
freeze: nil,
unfreeze: nil,
destroy: nil,
lock: nil,
unlock: nil,
resize: nil,
redeploy: nil,
execute: nil,
execute_cmd: nil,
snapshot: nil,
snapshot_name: nil
}
parser = parse_global_options(options) do
cli_service_help
end
parser.on('-l', '--list', 'List all services') do
service_opts[:list] = true
end
parser.on('--name NAME', 'Service name (creates new)') do |v|
service_opts[:name] = v
end
parser.on('--ports PORTS', 'Comma-separated ports') do |v|
service_opts[:ports] = v.split(',').map(&:to_i)
end
parser.on('--domains DOMAINS', 'Custom domains') do |v|
service_opts[:domains] = v.split(',')
end
parser.on('--type TYPE', 'Service type (minecraft, tcp, udp)') do |v|
service_opts[:type] = v
end
parser.on('--bootstrap CMD', 'Bootstrap command') do |v|
service_opts[:bootstrap] = v
end
parser.on('--bootstrap-file FILE', 'Bootstrap from file') do |v|
service_opts[:bootstrap_file] = v
end
parser.on('--env-file FILE', 'Load env from .env file') do |v|
service_opts[:env_file] = v
end
parser.on('--info ID', 'Get service details') do |v|
service_opts[:info] = v
end
parser.on('--logs ID', 'Get all logs') do |v|
service_opts[:logs] = v
end
parser.on('--tail ID', 'Get last 9000 lines') do |v|
service_opts[:tail] = v
end
parser.on('--freeze ID', 'Pause service') do |v|
service_opts[:freeze] = v
end
parser.on('--unfreeze ID', 'Resume service') do |v|
service_opts[:unfreeze] = v
end
parser.on('--destroy ID', 'Delete service') do |v|
service_opts[:destroy] = v
end
parser.on('--lock ID', 'Prevent deletion') do |v|
service_opts[:lock] = v
end
parser.on('--unlock ID', 'Allow deletion') do |v|
service_opts[:unlock] = v
end
parser.on('--resize ID', 'Resize (with --vcpu)') do |v|
service_opts[:resize] = v
end
parser.on('--redeploy ID', 'Re-run bootstrap') do |v|
service_opts[:redeploy] = v
end
parser.on('--execute ID', 'Run command in service') do |v|
service_opts[:execute] = v
end
parser.on('--snapshot ID', 'Create snapshot') do |v|
service_opts[:snapshot] = v
end
parser.on('--snapshot-name NAME', 'Name for snapshot') do |v|
service_opts[:snapshot_name] = v
end
parser.parse!(ARGV)
# Check for env subcommand
if ARGV[0] == 'env'
ARGV.shift
cli_service_env(options, service_opts)
return
end
# Get command argument for execute
service_opts[:execute_cmd] = ARGV.join(' ') if service_opts[:execute] && !ARGV.empty?
creds = { public_key: options[:public_key], secret_key: options[:secret_key] }
if service_opts[:list]
services = list_services(**creds).value
cli_print_services_table(services)
elsif service_opts[:info]
service = get_service(service_opts[:info], **creds).value
cli_print_service_info(service)
elsif service_opts[:logs]
result = get_service_logs(service_opts[:logs], all: true, **creds).value
puts result['log'] || result['logs'] || ''
elsif service_opts[:tail]
result = get_service_logs(service_opts[:tail], all: false, **creds).value
puts result['log'] || result['logs'] || ''
elsif service_opts[:freeze]
freeze_service(service_opts[:freeze], **creds).value
puts "Service #{service_opts[:freeze]} frozen"
elsif service_opts[:unfreeze]
unfreeze_service(service_opts[:unfreeze], **creds).value
puts "Service #{service_opts[:unfreeze]} unfrozen"
elsif service_opts[:destroy]
delete_service(service_opts[:destroy], **creds).value
puts "Service #{service_opts[:destroy]} destroyed"
elsif service_opts[:lock]
lock_service(service_opts[:lock], **creds).value
puts "Service #{service_opts[:lock]} locked"
elsif service_opts[:unlock]
unlock_service(service_opts[:unlock], **creds).value
puts "Service #{service_opts[:unlock]} unlocked"
elsif service_opts[:resize]
update_service(service_opts[:resize], vcpu: options[:vcpu], **creds).value
puts "Service #{service_opts[:resize]} resized to #{options[:vcpu]} vCPUs"
elsif service_opts[:redeploy]
bootstrap = nil
if service_opts[:bootstrap_file]
bootstrap = File.read(service_opts[:bootstrap_file])
elsif service_opts[:bootstrap]
bootstrap = service_opts[:bootstrap]
end
redeploy_service(service_opts[:redeploy], bootstrap: bootstrap, **creds).value
puts "Service #{service_opts[:redeploy]} redeployed"
elsif service_opts[:execute]
cmd = service_opts[:execute_cmd]
if cmd.nil? || cmd.empty?
$stderr.puts 'Error: No command provided for --execute'
exit(EXIT_INVALID_ARGS)
end
result = execute_in_service(service_opts[:execute], cmd, **creds).value
cli_print_execute_result(result)
elsif service_opts[:snapshot]
snapshot_id = service_snapshot(
service_opts[:snapshot],
name: service_opts[:snapshot_name],
**creds
).value
puts "Snapshot created: #{snapshot_id}"
elsif service_opts[:name]
# Create new service
bootstrap = service_opts[:bootstrap]
if service_opts[:bootstrap_file]
bootstrap = File.read(service_opts[:bootstrap_file])
end
unless bootstrap
$stderr.puts 'Error: --bootstrap or --bootstrap-file required'
exit(EXIT_INVALID_ARGS)
end
unless service_opts[:ports]
$stderr.puts 'Error: --ports required'
exit(EXIT_INVALID_ARGS)
end
result = create_service(
service_opts[:name],
service_opts[:ports],
bootstrap,
network_mode: options[:network],
vcpu: options[:vcpu],
custom_domains: service_opts[:domains],
service_type: service_opts[:type],
**creds
).value
puts "Service created: #{result['service_id']}"
puts "URL: #{result['url']}" if result['url']
else
cli_service_help
exit(EXIT_INVALID_ARGS)
end
end
# Print services table
def cli_print_services_table(services)
if services.empty?
puts 'No services'
return
end
puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'STATUS', 'CREATED')
services.each do |s|
puts format('%-38s %-20s %-10s %-20s',
s['id'] || s['service_id'] || '-',
s['name'] || '-',
s['status'] || s['state'] || '-',
s['created_at'] || '-')
end
end
# Print service info
def cli_print_service_info(service)
puts "ID: #{service['id'] || service['service_id']}"
puts "Name: #{service['name']}"
puts "Status: #{service['status'] || service['state']}"
puts "URL: #{service['url']}" if service['url']
puts "Ports: #{service['ports']&.join(', ')}" if service['ports']
puts "vCPU: #{service['vcpu']}" if service['vcpu']
puts "Network: #{service['network_mode']}" if service['network_mode']
puts "Created: #{service['created_at']}" if service['created_at']
puts "Locked: #{service['locked']}" if service.key?('locked')
end
# Service help
def cli_service_help
puts <<~HELP
Service Management
Usage:
ruby un_async.rb service [options]
ruby un_async.rb service env <command> <id>
Options:
--name NAME Service name (creates new)
--ports PORTS Comma-separated ports
--domains DOMAINS Custom domains
--type TYPE Service type (minecraft, tcp, udp)
--bootstrap CMD Bootstrap command
--bootstrap-file FILE Bootstrap from file
--env-file FILE Load env from .env file
-l, --list List all services
--info ID Get service details
--logs ID Get all logs
--tail ID Get last 9000 lines
--freeze ID Pause service
--unfreeze ID Resume service
--destroy ID Delete service
--lock ID Prevent deletion
--unlock ID Allow deletion
--resize ID Resize (with --vcpu)
--redeploy ID Re-run bootstrap
--execute ID 'cmd' Run command in service
--snapshot ID Create snapshot
Env Subcommands:
ruby un_async.rb service env status ID Show vault status
ruby un_async.rb service env set ID Set from --env-file or stdin
ruby un_async.rb service env export ID Export to stdout
ruby un_async.rb service env delete ID Delete vault
Examples:
ruby un_async.rb service --name web --ports 80 --bootstrap "python -m http.server 80"
ruby un_async.rb service --list
ruby un_async.rb service --logs abc123
ruby un_async.rb service --execute abc123 'ls -la'
ruby un_async.rb service env status abc123
HELP
end
# Service env subcommand
def cli_service_env(options, service_opts)
if ARGV.empty?
$stderr.puts 'Error: env subcommand requires: status, set, export, or delete'
exit(EXIT_INVALID_ARGS)
end
cmd = ARGV.shift
service_id = ARGV.shift
unless service_id
$stderr.puts 'Error: Service ID required'
exit(EXIT_INVALID_ARGS)
end
creds = { public_key: options[:public_key], secret_key: options[:secret_key] }
case cmd
when 'status'
result = get_service_env(service_id, **creds).value
puts "Has vault: #{result['has_vault'] || false}"
puts "Variables: #{result['count'] || 0}"
puts "Updated: #{result['updated_at']}" if result['updated_at']
when 'set'
env_content = nil
if service_opts[:env_file]
env_content = File.read(service_opts[:env_file])
elsif !$stdin.tty?
env_content = $stdin.read
else
$stderr.puts 'Error: Provide --env-file or pipe content to stdin'
exit(EXIT_INVALID_ARGS)
end
set_service_env(service_id, env_content, **creds).value
puts "Environment vault updated for #{service_id}"
when 'export'
result = export_service_env(service_id, **creds).value
puts result['env'] || ''
when 'delete'
delete_service_env(service_id, **creds).value
puts "Environment vault deleted for #{service_id}"
else
$stderr.puts "Error: Unknown env command: #{cmd}"
exit(EXIT_INVALID_ARGS)
end
end
# Snapshot subcommand
def cli_snapshot(options)
snapshot_opts = {
list: false,
info: nil,
delete: nil,
lock: nil,
unlock: nil,
clone: nil,
clone_type: 'session',
clone_name: nil,
clone_shell: nil,
clone_ports: nil
}
parser = parse_global_options(options) do
cli_snapshot_help
end
parser.on('-l', '--list', 'List all snapshots') do
snapshot_opts[:list] = true
end
parser.on('--info ID', 'Get snapshot details') do |v|
snapshot_opts[:info] = v
end
parser.on('--delete ID', 'Delete snapshot') do |v|
snapshot_opts[:delete] = v
end
parser.on('--lock ID', 'Prevent deletion') do |v|
snapshot_opts[:lock] = v
end
parser.on('--unlock ID', 'Allow deletion') do |v|
snapshot_opts[:unlock] = v
end
parser.on('--clone ID', 'Clone snapshot') do |v|
snapshot_opts[:clone] = v
end
parser.on('--type TYPE', 'Clone type: session or service') do |v|
snapshot_opts[:clone_type] = v
end
parser.on('--name NAME', 'Name for cloned resource') do |v|
snapshot_opts[:clone_name] = v
end
parser.on('--shell SHELL', 'Shell for cloned session') do |v|
snapshot_opts[:clone_shell] = v
end
parser.on('--ports PORTS', 'Ports for cloned service') do |v|
snapshot_opts[:clone_ports] = v.split(',').map(&:to_i)
end
parser.parse!(ARGV)
creds = { public_key: options[:public_key], secret_key: options[:secret_key] }
if snapshot_opts[:list]
snapshots = list_snapshots(**creds).value
cli_print_snapshots_table(snapshots)
elsif snapshot_opts[:info]
# Get snapshot details via restore endpoint or list
snapshots = list_snapshots(**creds).value
snapshot = snapshots.find { |s| s['snapshot_id'] == snapshot_opts[:info] || s['id'] == snapshot_opts[:info] }
if snapshot
cli_print_snapshot_info(snapshot)
else
$stderr.puts "Error: Snapshot not found: #{snapshot_opts[:info]}"
exit(EXIT_ERROR)
end
elsif snapshot_opts[:delete]
delete_snapshot(snapshot_opts[:delete], **creds).value
puts "Snapshot #{snapshot_opts[:delete]} deleted"
elsif snapshot_opts[:lock]
lock_snapshot(snapshot_opts[:lock], **creds).value
puts "Snapshot #{snapshot_opts[:lock]} locked"
elsif snapshot_opts[:unlock]
unlock_snapshot(snapshot_opts[:unlock], **creds).value
puts "Snapshot #{snapshot_opts[:unlock]} unlocked"
elsif snapshot_opts[:clone]
result = clone_snapshot(
snapshot_opts[:clone],
type: snapshot_opts[:clone_type],
name: snapshot_opts[:clone_name],
shell: snapshot_opts[:clone_shell],
ports: snapshot_opts[:clone_ports],
**creds
).value
if result['session_id']
puts "Session created: #{result['session_id']}"
elsif result['service_id']
puts "Service created: #{result['service_id']}"
else
puts 'Clone completed'
puts JSON.pretty_generate(result)
end
else
cli_snapshot_help
exit(EXIT_INVALID_ARGS)
end
end
# Print snapshots table
def cli_print_snapshots_table(snapshots)
if snapshots.empty?
puts 'No snapshots'
return
end
puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'TYPE', 'CREATED')
snapshots.each do |s|
puts format('%-38s %-20s %-10s %-20s',
s['snapshot_id'] || s['id'] || '-',
s['name'] || '-',
s['type'] || s['source_type'] || '-',
s['created_at'] || '-')
end
end
# Print snapshot info
def cli_print_snapshot_info(snapshot)
puts "ID: #{snapshot['snapshot_id'] || snapshot['id']}"
puts "Name: #{snapshot['name']}" if snapshot['name']
puts "Type: #{snapshot['type'] || snapshot['source_type']}"
puts "Source ID: #{snapshot['source_id']}" if snapshot['source_id']
puts "Size: #{snapshot['size']}" if snapshot['size']
puts "Locked: #{snapshot['locked']}" if snapshot.key?('locked')
puts "Created: #{snapshot['created_at']}" if snapshot['created_at']
end
# Snapshot help
def cli_snapshot_help
puts <<~HELP
Snapshot Management
Usage:
ruby un_async.rb snapshot [options]
Options:
-l, --list List all snapshots
--info ID Get snapshot details
--delete ID Delete snapshot
--lock ID Prevent deletion
--unlock ID Allow deletion
--clone ID Clone snapshot
--type TYPE Clone type: session or service
--name NAME Name for cloned resource
--shell SHELL Shell for cloned session
--ports PORTS Ports for cloned service
Examples:
ruby un_async.rb snapshot --list
ruby un_async.rb snapshot --clone abc123 --type service --name myapp --ports 80
HELP
end
# Key command
def cli_key(options)
parser = parse_global_options(options) do
puts 'Usage: ruby un_async.rb key [-p PUBLIC_KEY] [-k SECRET_KEY]'
puts
puts 'Check API key validity and show account info'
end
parser.parse!(ARGV)
creds = { public_key: options[:public_key], secret_key: options[:secret_key] }
begin
result = validate_keys(**creds).value
puts "Valid: #{result['valid']}"
puts "Account: #{result['account'] || result['account_id']}" if result['account'] || result['account_id']
puts "Email: #{result['email']}" if result['email']
puts "Plan: #{result['plan']}" if result['plan']
puts "Credits: #{result['credits']}" if result['credits']
rescue APIError => e
if e.status_code == 401 || e.status_code == 403
puts 'Valid: false'
puts "Error: #{e.message}"
exit(EXIT_AUTH_ERROR)
end
raise
end
end
end
end
# CLI entry point
if __FILE__ == $0
UnAsync.cli_main
end

View file

@ -436,6 +436,267 @@ module Un
make_request('POST', "/snapshots/#{snapshot_id}/clone", pk, sk, data)
end
# ============================================================================
# Image Functions (LXD Container Images)
# ============================================================================
# Publish an LXD container image from a session or service
#
# @param source_type [String] Source type ("session" or "service")
# @param source_id [String] ID of the session or service to publish
# @param name [String, nil] Optional name for the image
# @param description [String, nil] Optional description for the image
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with image_id and other metadata
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example Publish from session
# result = Un.image_publish("session", session_id, name: "my-image")
# puts result["image_id"]
#
# @example Publish from service
# result = Un.image_publish("service", service_id, description: "Production snapshot")
# puts result["image_id"]
def image_publish(source_type, source_id, name: nil, description: nil, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
data = { source_type: source_type, source_id: source_id }
data[:name] = name if name
data[:description] = description if description
make_request('POST', '/images', pk, sk, data)
end
# List all images for the authenticated account
#
# @param filter_type [String, nil] Optional filter: "own", "shared", or "public"
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Array<Hash>] List of image hashes
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example List all images
# images = Un.list_images
# images.each { |img| puts "#{img['image_id']}: #{img['name']}" }
#
# @example List only owned images
# owned = Un.list_images(filter_type: "own")
#
# @example List shared images
# shared = Un.list_images(filter_type: "shared")
def list_images(filter_type: nil, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
path = filter_type ? "/images/#{filter_type}" : '/images'
response = make_request('GET', path, pk, sk)
response['images'] || []
end
# Get image details by ID
#
# @param image_id [String] Image ID to retrieve
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Image details hash
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# image = Un.get_image(image_id)
# puts "#{image['name']}: #{image['description']}"
def get_image(image_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('GET', "/images/#{image_id}", pk, sk)
end
# Delete an image
#
# @param image_id [String] Image ID to delete
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with deletion confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.delete_image(image_id)
def delete_image(image_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('DELETE', "/images/#{image_id}", pk, sk)
end
# Lock an image to prevent deletion
#
# @param image_id [String] Image ID to lock
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with lock confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.lock_image(image_id)
def lock_image(image_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/images/#{image_id}/lock", pk, sk, {})
end
# Unlock an image to allow deletion
#
# @param image_id [String] Image ID to unlock
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with unlock confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.unlock_image(image_id)
def unlock_image(image_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/images/#{image_id}/unlock", pk, sk, {})
end
# Set image visibility (private, public, or shared)
#
# @param image_id [String] Image ID to update
# @param visibility [String] Visibility level: "private", "public", or "shared"
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with visibility confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example Make image public
# Un.set_image_visibility(image_id, "public")
#
# @example Make image private
# Un.set_image_visibility(image_id, "private")
def set_image_visibility(image_id, visibility, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/images/#{image_id}/visibility", pk, sk, { visibility: visibility })
end
# Grant access to an image for another API key
#
# @param image_id [String] Image ID to share
# @param trusted_api_key [String] Public API key to grant access to
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with grant confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.grant_image_access(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
def grant_image_access(image_id, trusted_api_key, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/images/#{image_id}/grant", pk, sk, { trusted_api_key: trusted_api_key })
end
# Revoke access to an image from another API key
#
# @param image_id [String] Image ID to revoke access from
# @param trusted_api_key [String] Public API key to revoke access from
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with revoke confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.revoke_image_access(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
def revoke_image_access(image_id, trusted_api_key, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/images/#{image_id}/revoke", pk, sk, { trusted_api_key: trusted_api_key })
end
# List API keys with access to an image
#
# @param image_id [String] Image ID to list trusted keys for
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Array<Hash>] List of trusted API key hashes
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# trusted = Un.list_image_trusted(image_id)
# trusted.each { |t| puts t["api_key"] }
def list_image_trusted(image_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
response = make_request('GET', "/images/#{image_id}/trusted", pk, sk)
response['trusted'] || []
end
# Transfer image ownership to another API key
#
# @param image_id [String] Image ID to transfer
# @param to_api_key [String] Public API key to transfer ownership to
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with transfer confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.transfer_image(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx")
def transfer_image(image_id, to_api_key, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/images/#{image_id}/transfer", pk, sk, { to_api_key: to_api_key })
end
# Spawn a new service from an image
#
# @param image_id [String] Image ID to spawn from
# @param name [String, nil] Optional name for the service
# @param ports [Array<Integer>, nil] Optional ports to expose
# @param bootstrap [String, nil] Optional bootstrap script
# @param network_mode [String] Network mode ("zerotrust" or "semitrusted", default: "zerotrust")
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with service_id and other metadata
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example Spawn with defaults
# result = Un.spawn_from_image(image_id)
# puts result["service_id"]
#
# @example Spawn with custom config
# result = Un.spawn_from_image(image_id, name: "web", ports: [80, 443], network_mode: "semitrusted")
# puts result["service_id"]
def spawn_from_image(image_id, name: nil, ports: nil, bootstrap: nil, network_mode: 'zerotrust', public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
data = { network_mode: network_mode }
data[:name] = name if name
data[:ports] = ports if ports
data[:bootstrap] = bootstrap if bootstrap
make_request('POST', "/images/#{image_id}/spawn", pk, sk, data)
end
# Clone an image to create a new image
#
# @param image_id [String] Image ID to clone
# @param name [String, nil] Optional name for the cloned image
# @param description [String, nil] Optional description for the cloned image
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with new image_id
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# result = Un.clone_image(image_id, name: "my-clone", description: "Cloned from production")
# puts result["image_id"]
def clone_image(image_id, name: nil, description: nil, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
data = {}
data[:name] = name if name
data[:description] = description if description
make_request('POST', "/images/#{image_id}/clone", pk, sk, data)
end
# ============================================================================
# Session Functions
# ============================================================================

View file

@ -20,6 +20,10 @@ categories = ["api-bindings", "development-tools", "asynchronous"]
name = "un_async"
path = "src/lib.rs"
[[bin]]
name = "un-async"
path = "src/main.rs"
[dependencies]
# HTTP client (async)
reqwest = { version = "0.12", features = ["json"] }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
//
// unsandbox.com CLI (Rust Async)
//
// Binary entry point for the CLI.
//
// Usage:
// un script.py # Execute Python script
// un -s bash 'echo hello' # Execute inline code
// un session --list # List sessions
// un service --list # List services
// un key # Check API key
fn main() {
std::process::exit(un_async::cli_main());
}

View file

@ -20,6 +20,10 @@ categories = ["api-bindings", "development-tools"]
name = "un"
path = "src/lib.rs"
[[bin]]
name = "un"
path = "src/main.rs"
[dependencies]
# HTTP client (blocking feature for sync)
reqwest = { version = "0.12", features = ["blocking", "json"] }

View file

@ -541,6 +541,16 @@ struct EnvExportResponse {
content: String,
}
#[derive(Debug, Deserialize)]
struct ImagesListResponse {
images: Vec<LxdImage>,
}
#[derive(Debug, Deserialize)]
struct TrustedKeysResponse {
trusted_keys: Vec<String>,
}
// =============================================================================
// Language Detection
// =============================================================================
@ -1195,6 +1205,333 @@ pub fn clone_snapshot(snapshot_id: &str, name: &str, creds: &Credentials) -> Res
make_request("POST", &path, creds, Some(&body))
}
// =============================================================================
// Images API Functions (LXD Container Images)
// =============================================================================
/// Publish an LXD container image from a session or service.
///
/// Creates a reusable container image from an existing session or service.
/// The image can later be used to spawn new services.
///
/// # Arguments
/// * `source_type` - Source type: "session" or "service"
/// * `source_id` - ID of the session or service to publish from
/// * `name` - Name for the new image
/// * `description` - Optional description for the image
/// * `creds` - API credentials
///
/// # Returns
/// LxdImage information for the published image
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let image = image_publish("service", "svc-abc123", "my-app-image", Some("Production app v1.0"), &creds)?;
/// println!("Published image: {}", image.image_id);
/// ```
pub fn image_publish(
source_type: &str,
source_id: &str,
name: &str,
description: Option<&str>,
creds: &Credentials,
) -> Result<LxdImage> {
let mut body = serde_json::json!({
"source_type": source_type,
"source_id": source_id,
"name": name
});
if let Some(desc) = description {
body["description"] = serde_json::json!(desc);
}
make_request("POST", "/images", creds, Some(&body))
}
/// List LXD container images.
///
/// Returns images based on the filter type:
/// - None or "owned": Images owned by the authenticated user
/// - "shared": Images shared with the authenticated user
/// - "public": Publicly available images
/// - "all": All images accessible to the authenticated user
///
/// # Arguments
/// * `filter_type` - Optional filter: "owned", "shared", "public", or "all"
/// * `creds` - API credentials
///
/// # Returns
/// Vector of LxdImage information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // List owned images (default)
/// let my_images = list_images(None, &creds)?;
///
/// // List shared images
/// let shared = list_images(Some("shared"), &creds)?;
///
/// // List all accessible images
/// let all = list_images(Some("all"), &creds)?;
/// ```
pub fn list_images(filter_type: Option<&str>, creds: &Credentials) -> Result<Vec<LxdImage>> {
let path = match filter_type {
Some(ft) => format!("/images/{}", ft),
None => "/images".to_string(),
};
let response: ImagesListResponse = make_request("GET", &path, creds, None::<&()>)?;
Ok(response.images)
}
/// Get details of a specific LXD container image.
///
/// # Arguments
/// * `image_id` - Image ID to retrieve
/// * `creds` - API credentials
///
/// # Returns
/// LxdImage information
pub fn get_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
let path = format!("/images/{}", image_id);
make_request("GET", &path, creds, None::<&()>)
}
/// Delete an LXD container image.
///
/// The image must be unlocked to be deleted.
///
/// # Arguments
/// * `image_id` - Image ID to delete
/// * `creds` - API credentials
pub fn delete_image(image_id: &str, creds: &Credentials) -> Result<()> {
let path = format!("/images/{}", image_id);
let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>)?;
Ok(())
}
/// Lock an LXD container image to prevent modification or deletion.
///
/// # Arguments
/// * `image_id` - Image ID to lock
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn lock_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
let path = format!("/images/{}/lock", image_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Unlock an LXD container image to allow modification or deletion.
///
/// # Arguments
/// * `image_id` - Image ID to unlock
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn unlock_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
let path = format!("/images/{}/unlock", image_id);
let body = serde_json::json!({});
make_request("POST", &path, creds, Some(&body))
}
/// Set the visibility of an LXD container image.
///
/// # Arguments
/// * `image_id` - Image ID to update
/// * `visibility` - New visibility: "private", "shared", or "public"
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Make image public
/// set_image_visibility("img-abc123", "public", &creds)?;
///
/// // Make image private
/// set_image_visibility("img-abc123", "private", &creds)?;
/// ```
pub fn set_image_visibility(
image_id: &str,
visibility: &str,
creds: &Credentials,
) -> Result<LxdImage> {
let path = format!("/images/{}/visibility", image_id);
let body = serde_json::json!({
"visibility": visibility
});
make_request("POST", &path, creds, Some(&body))
}
/// Grant access to an LXD container image for another API key.
///
/// The image visibility must be "shared" for this to take effect.
///
/// # Arguments
/// * `image_id` - Image ID to grant access to
/// * `trusted_api_key` - API key (public key) to grant access
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn grant_image_access(
image_id: &str,
trusted_api_key: &str,
creds: &Credentials,
) -> Result<LxdImage> {
let path = format!("/images/{}/grant", image_id);
let body = serde_json::json!({
"trusted_api_key": trusted_api_key
});
make_request("POST", &path, creds, Some(&body))
}
/// Revoke access to an LXD container image from another API key.
///
/// # Arguments
/// * `image_id` - Image ID to revoke access from
/// * `trusted_api_key` - API key (public key) to revoke access
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn revoke_image_access(
image_id: &str,
trusted_api_key: &str,
creds: &Credentials,
) -> Result<LxdImage> {
let path = format!("/images/{}/revoke", image_id);
let body = serde_json::json!({
"trusted_api_key": trusted_api_key
});
make_request("POST", &path, creds, Some(&body))
}
/// List API keys that have access to an LXD container image.
///
/// # Arguments
/// * `image_id` - Image ID to list trusted keys for
/// * `creds` - API credentials
///
/// # Returns
/// Vector of trusted API keys (public keys)
pub fn list_image_trusted(image_id: &str, creds: &Credentials) -> Result<Vec<String>> {
let path = format!("/images/{}/trusted", image_id);
let response: TrustedKeysResponse = make_request("GET", &path, creds, None::<&()>)?;
Ok(response.trusted_keys)
}
/// Transfer ownership of an LXD container image to another API key.
///
/// After transfer, the original owner loses ownership but may retain
/// access if they are in the trusted keys list.
///
/// # Arguments
/// * `image_id` - Image ID to transfer
/// * `to_api_key` - API key (public key) of the new owner
/// * `creds` - API credentials
///
/// # Returns
/// Updated LxdImage information
pub fn transfer_image(image_id: &str, to_api_key: &str, creds: &Credentials) -> Result<LxdImage> {
let path = format!("/images/{}/transfer", image_id);
let body = serde_json::json!({
"to_api_key": to_api_key
});
make_request("POST", &path, creds, Some(&body))
}
/// Spawn a new service from an LXD container image.
///
/// Creates a new running service based on the specified image.
///
/// # Arguments
/// * `image_id` - Image ID to spawn from
/// * `name` - Name for the new service
/// * `ports` - Optional list of ports to expose
/// * `bootstrap` - Optional bootstrap script to run after spawn
/// * `network_mode` - Optional network mode: "zerotrust" or "semitrusted"
/// * `creds` - API credentials
///
/// # Returns
/// SpawnFromImageResult with the new service information
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
///
/// // Spawn with default options
/// let result = spawn_from_image("img-abc123", "my-service", None, None, None, &creds)?;
/// println!("Service URL: {}", result.url);
///
/// // Spawn with custom ports and bootstrap
/// let result = spawn_from_image(
/// "img-abc123",
/// "web-server",
/// Some(&[80, 443]),
/// Some("systemctl start nginx"),
/// Some("semitrusted"),
/// &creds
/// )?;
/// ```
pub fn spawn_from_image(
image_id: &str,
name: &str,
ports: Option<&[u16]>,
bootstrap: Option<&str>,
network_mode: Option<&str>,
creds: &Credentials,
) -> Result<SpawnFromImageResult> {
let path = format!("/images/{}/spawn", image_id);
let mut body = serde_json::json!({
"name": name
});
if let Some(p) = ports {
body["ports"] = serde_json::json!(p);
}
if let Some(b) = bootstrap {
body["bootstrap"] = serde_json::json!(b);
}
if let Some(nm) = network_mode {
body["network_mode"] = serde_json::json!(nm);
}
make_request("POST", &path, creds, Some(&body))
}
/// Clone an LXD container image to create a new image with a different name.
///
/// # Arguments
/// * `image_id` - Image ID to clone
/// * `name` - Name for the new image
/// * `description` - Optional description for the new image
/// * `creds` - API credentials
///
/// # Returns
/// New LxdImage information
pub fn clone_image(
image_id: &str,
name: &str,
description: Option<&str>,
creds: &Credentials,
) -> Result<LxdImage> {
let path = format!("/images/{}/clone", image_id);
let mut body = serde_json::json!({
"name": name
});
if let Some(desc) = description {
body["description"] = serde_json::json!(desc);
}
make_request("POST", &path, creds, Some(&body))
}
// =============================================================================
// Session API Functions
// =============================================================================

View file

@ -0,0 +1,16 @@
// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
//
// unsandbox.com CLI (Rust Sync)
//
// Binary entry point for the CLI.
//
// Usage:
// un script.py # Execute Python script
// un -s bash 'echo hello' # Execute inline code
// un session --list # List sessions
// un service --list # List services
// un key # Check API key
fn main() {
std::process::exit(un::cli_main());
}

File diff suppressed because it is too large Load diff