un-inception/clients/ruby/sync/src/un.rb

1234 lines
46 KiB
Ruby

# frozen_string_literal: true
# PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
#
# unsandbox.com Ruby SDK (Synchronous)
#
# Library Usage:
# require_relative 'un'
#
# # Execute code synchronously
# result = Un.execute_code("python", 'print("hello")')
#
# # Execute asynchronously and get job_id
# job_id = Un.execute_async("javascript", 'console.log("hello")')
#
# # Wait for job completion with exponential backoff
# result = Un.wait_for_job(job_id)
#
# # List all jobs
# jobs = Un.list_jobs
#
# # Get supported languages (cached for 1 hour)
# languages = Un.get_languages
#
# # Snapshot operations
# snapshot_id = Un.session_snapshot(session_id)
#
# Authentication Priority (4-tier):
# 1. Method 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)
#
# Request Authentication (HMAC-SHA256):
# Authorization: Bearer <public_key>
# X-Timestamp: <unix_seconds>
# X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body")
#
# Languages Cache:
# - Cached in ~/.unsandbox/languages.json
# - TTL: 1 hour
# - Updated on successful API calls
require 'net/http'
require 'uri'
require 'json'
require 'openssl'
require 'fileutils'
# Unsandbox Ruby SDK module (synchronous)
module Un
# API base URL
API_BASE = 'https://api.unsandbox.com'
# Polling delays in milliseconds for exponential backoff
POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000].freeze
# Languages cache TTL in seconds (1 hour)
LANGUAGES_CACHE_TTL = 3600
# HTTP request timeout in seconds
REQUEST_TIMEOUT = 120
# Error raised when credentials cannot be found or are invalid
class CredentialsError < StandardError; end
# Error raised for API request failures
class APIError < StandardError
attr_reader :status_code, :response_body
# @param message [String] Error message
# @param status_code [Integer, nil] HTTP status code
# @param response_body [String, nil] Response body
def initialize(message, status_code: nil, response_body: nil)
super(message)
@status_code = status_code
@response_body = response_body
end
end
class << self
# Execute code synchronously (blocks until completion)
#
# @param language [String] Programming language (e.g., "python", "javascript", "go")
# @param code [String] Source code to execute
# @param public_key [String, nil] Optional API key (uses credentials resolution if not provided)
# @param secret_key [String, nil] Optional API secret (uses credentials resolution if not provided)
# @return [Hash] Response hash containing stdout, stderr, exit code, etc.
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# result = Un.execute_code("python", 'print("hello")')
# puts result["stdout"] # => "hello\n"
def execute_code(language, code, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
response = make_request(
'POST',
'/execute',
pk,
sk,
{ language: language, code: code }
)
# If we got a job_id, poll until completion
job_id = response['job_id']
status = response['status']
if job_id && %w[pending running].include?(status)
return wait_for_job(job_id, public_key: pk, secret_key: sk)
end
response
end
# Execute code asynchronously (returns immediately with job_id)
#
# @param language [String] Programming language (e.g., "python", "javascript")
# @param code [String] Source code to execute
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [String] Job ID string
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# job_id = Un.execute_async("python", 'import time; time.sleep(10); print("done")')
# # Later...
# result = Un.wait_for_job(job_id)
def execute_async(language, code, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
response = make_request(
'POST',
'/execute',
pk,
sk,
{ language: language, code: code }
)
response['job_id']
end
# Get current status/result of a job (single poll, no waiting)
#
# @param job_id [String] Job ID from execute_async
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Job response hash
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# job = Un.get_job(job_id)
# puts job["status"] # => "running" or "completed"
def get_job(job_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('GET', "/jobs/#{job_id}", pk, sk)
end
# Wait for job completion with exponential backoff polling
#
# Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
# Cumulative: 300, 750, 1450, 2350, 3000, 4600, 6600ms+
#
# @param job_id [String] Job ID from execute_async
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param timeout [Integer] Maximum time to wait in seconds (default: 3600)
# @return [Hash] Final job result when status is terminal (completed, failed, timeout, cancelled)
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails or timeout reached
#
# @example
# result = Un.wait_for_job(job_id, timeout: 60)
# puts result["stdout"]
def wait_for_job(job_id, public_key: nil, secret_key: nil, timeout: 3600)
pk, sk = resolve_credentials(public_key, secret_key)
poll_count = 0
start_time = Time.now
loop do
# Check timeout
elapsed = Time.now - start_time
raise APIError, "Job wait timeout after #{timeout} seconds" if elapsed > timeout
# Sleep before polling
delay_idx = [poll_count, POLL_DELAYS_MS.length - 1].min
sleep(POLL_DELAYS_MS[delay_idx] / 1000.0)
poll_count += 1
response = get_job(job_id, public_key: pk, secret_key: sk)
status = response['status']
return response if %w[completed failed timeout cancelled].include?(status)
# Still running, continue polling
end
end
# Cancel a running job
#
# @param job_id [String] Job ID to cancel
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with cancellation confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.cancel_job(job_id)
def cancel_job(job_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('DELETE', "/jobs/#{job_id}", pk, sk)
end
# List all jobs for the authenticated account
#
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Array<Hash>] List of job hashes
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# jobs = Un.list_jobs
# jobs.each { |job| puts "#{job['job_id']}: #{job['status']}" }
def list_jobs(public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
response = make_request('GET', '/jobs', pk, sk)
response['jobs'] || []
end
# Get list of supported programming languages
#
# Results are cached for 1 hour in ~/.unsandbox/languages.json
#
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Array<String>] List of language identifiers (e.g., ["python", "javascript", "go", ...])
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# languages = Un.get_languages
# puts languages.join(", ")
def get_languages(public_key: nil, secret_key: nil)
# Try cache first
cached = load_languages_cache
return cached if cached
pk, sk = resolve_credentials(public_key, secret_key)
response = make_request('GET', '/languages', pk, sk)
languages = response['languages'] || []
# Cache the result
save_languages_cache(languages)
languages
end
# Detect programming language from filename extension
#
# @param filename [String] Filename to detect language from (e.g., "script.py")
# @return [String, nil] Language identifier (e.g., "python") or nil if unknown
#
# @example
# Un.detect_language("hello.py") # => "python"
# Un.detect_language("script.js") # => "javascript"
# Un.detect_language("main.go") # => "go"
# Un.detect_language("unknown") # => nil
def detect_language(filename)
return nil if filename.nil? || !filename.include?('.')
ext = filename.split('.').last&.downcase
LANGUAGE_MAP[ext]
end
# Create a snapshot of a session
#
# @param session_id [String] Session ID to snapshot
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param name [String, nil] Optional snapshot name
# @param ephemeral [Boolean] If true, create ephemeral snapshot (default: false)
# @return [String] Snapshot ID
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# snapshot_id = Un.session_snapshot(session_id, name: "my-snapshot")
def session_snapshot(session_id, public_key: nil, secret_key: nil, name: nil, ephemeral: false)
pk, sk = resolve_credentials(public_key, secret_key)
data = { session_id: session_id, hot: ephemeral }
data[:name] = name if name
response = make_request('POST', '/snapshots', pk, sk, data)
response['snapshot_id']
end
# Create a snapshot of a service
#
# @param service_id [String] Service ID to snapshot
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param name [String, nil] Optional snapshot name
# @return [String] Snapshot ID
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# snapshot_id = Un.service_snapshot(service_id, name: "production-backup")
def service_snapshot(service_id, public_key: nil, secret_key: nil, name: nil)
pk, sk = resolve_credentials(public_key, secret_key)
data = { service_id: service_id, hot: false }
data[:name] = name if name
response = make_request('POST', '/snapshots', pk, sk, data)
response['snapshot_id']
end
# List all snapshots
#
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Array<Hash>] List of snapshot hashes
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# snapshots = Un.list_snapshots
# snapshots.each { |s| puts "#{s['snapshot_id']}: #{s['name']}" }
def list_snapshots(public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
response = make_request('GET', '/snapshots', pk, sk)
response['snapshots'] || []
end
# Restore a snapshot
#
# @param snapshot_id [String] Snapshot ID to restore
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with restored resource info
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# result = Un.restore_snapshot(snapshot_id)
# puts result["session_id"] # or result["service_id"]
def restore_snapshot(snapshot_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/snapshots/#{snapshot_id}/restore", pk, sk, {})
end
# Delete a snapshot
#
# @param snapshot_id [String] Snapshot 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_snapshot(snapshot_id)
def delete_snapshot(snapshot_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('DELETE', "/snapshots/#{snapshot_id}", pk, sk)
end
# Lock a snapshot to prevent deletion
#
# @param snapshot_id [String] Snapshot 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_snapshot(snapshot_id)
def lock_snapshot(snapshot_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/snapshots/#{snapshot_id}/lock", pk, sk, {})
end
# Unlock a snapshot to allow deletion
#
# @param snapshot_id [String] Snapshot 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_snapshot(snapshot_id)
def unlock_snapshot(snapshot_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/snapshots/#{snapshot_id}/unlock", pk, sk, {})
end
# Clone a snapshot to create a new session or service
#
# @param snapshot_id [String] Snapshot ID to clone
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param name [String, nil] Optional name for the cloned resource
# @param type [String] Type of resource to create ("session" or "service")
# @param shell [String, nil] Optional shell for session clones
# @param ports [Array<Integer>, nil] Optional ports for service clones
# @return [Hash] Response hash with cloned resource info (session_id or service_id)
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example Clone to session
# result = Un.clone_snapshot(snapshot_id, type: "session")
# puts result["session_id"]
#
# @example Clone to service
# result = Un.clone_snapshot(snapshot_id, type: "service", ports: [80, 443])
# puts result["service_id"]
def clone_snapshot(snapshot_id, public_key: nil, secret_key: nil, name: nil, type: 'session', shell: nil, ports: nil)
pk, sk = resolve_credentials(public_key, secret_key)
data = { type: type }
data[:name] = name if name
data[:shell] = shell if shell
data[:ports] = ports if ports
make_request('POST', "/snapshots/#{snapshot_id}/clone", pk, sk, data)
end
# ============================================================================
# Session Functions
# ============================================================================
# List all sessions for the authenticated account
#
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Array<Hash>] List of session hashes
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# sessions = Un.list_sessions
# sessions.each { |s| puts "#{s['id']}: #{s['status']}" }
def list_sessions(public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
response = make_request('GET', '/sessions', pk, sk)
response['sessions'] || []
end
# Get session details by ID
#
# @param session_id [String] Session ID to retrieve
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Session details hash
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# session = Un.get_session(session_id)
# puts session["status"]
def get_session(session_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('GET', "/sessions/#{session_id}", pk, sk)
end
# Create a new interactive session
#
# @param language [String] Shell or language for the session (e.g., "bash", "python3")
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param network_mode [String] Network mode ("zerotrust" or "semitrusted")
# @param ttl [Integer] Time-to-live in seconds (default: 3600)
# @param multiplexer [String, nil] Terminal multiplexer ("tmux" or "screen")
# @param vcpu [Integer] Number of vCPUs (1-8)
# @return [Hash] Response hash with session_id and container_name
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# result = Un.create_session("bash", network_mode: "semitrusted")
# puts result["session_id"]
def create_session(language, public_key: nil, secret_key: nil, network_mode: 'zerotrust', ttl: 3600, multiplexer: nil, vcpu: 1)
pk, sk = resolve_credentials(public_key, secret_key)
data = {
network_mode: network_mode,
ttl: ttl
}
data[:shell] = language if language
data[:multiplexer] = multiplexer if multiplexer
data[:vcpu] = vcpu if vcpu > 1
make_request('POST', '/sessions', pk, sk, data)
end
# Delete (terminate) a session
#
# @param session_id [String] Session 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_session(session_id)
def delete_session(session_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('DELETE', "/sessions/#{session_id}", pk, sk)
end
# Freeze a session (pause execution, reduce resource usage)
#
# @param session_id [String] Session ID to freeze
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with freeze confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.freeze_session(session_id)
def freeze_session(session_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/sessions/#{session_id}/freeze", pk, sk, {})
end
# Unfreeze a session (resume execution)
#
# @param session_id [String] Session ID to unfreeze
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with unfreeze confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.unfreeze_session(session_id)
def unfreeze_session(session_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/sessions/#{session_id}/unfreeze", pk, sk, {})
end
# Boost a session (increase vCPU allocation)
#
# @param session_id [String] Session ID to boost
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with boost confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.boost_session(session_id)
def boost_session(session_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/sessions/#{session_id}/boost", pk, sk, {})
end
# Unboost a session (reduce vCPU allocation)
#
# @param session_id [String] Session ID to unboost
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with unboost confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.unboost_session(session_id)
def unboost_session(session_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/sessions/#{session_id}/unboost", pk, sk, {})
end
# Execute a shell command in an existing session
# Note: This is for non-interactive command execution, not for WebSocket shell access
#
# @param session_id [String] Session ID to execute command in
# @param command [String] Command to execute
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with command output
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# result = Un.shell_session(session_id, "ls -la")
# puts result["stdout"]
def shell_session(session_id, command, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/sessions/#{session_id}/shell", pk, sk, { command: command })
end
# ============================================================================
# Service Functions
# ============================================================================
# List all services for the authenticated account
#
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Array<Hash>] List of service hashes
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# services = Un.list_services
# services.each { |s| puts "#{s['id']}: #{s['name']} (#{s['state']})" }
def list_services(public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
response = make_request('GET', '/services', pk, sk)
response['services'] || []
end
# Create a new persistent service
#
# @param name [String] Service name
# @param ports [Array<Integer>] Ports to expose
# @param bootstrap [String] Bootstrap script content or URL
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param network_mode [String] Network mode ("zerotrust" or "semitrusted")
# @param vcpu [Integer] Number of vCPUs (1-8)
# @param custom_domains [Array<String>, nil] Custom domains for the service
# @param service_type [String, nil] Service type for SRV records (e.g., "minecraft")
# @return [Hash] Response hash with service_id
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# result = Un.create_service("web", [80, 443], "apt install -y nginx && nginx")
# puts result["service_id"]
def create_service(name, ports, bootstrap, public_key: nil, secret_key: nil, network_mode: 'semitrusted', vcpu: 1, custom_domains: nil, service_type: nil)
pk, sk = resolve_credentials(public_key, secret_key)
data = {
name: name,
ports: ports,
bootstrap: bootstrap,
network_mode: network_mode
}
data[:vcpu] = vcpu if vcpu > 1
data[:custom_domains] = custom_domains if custom_domains
data[:service_type] = service_type if service_type
make_request('POST', '/services', pk, sk, data)
end
# Get service details by ID
#
# @param service_id [String] Service ID to retrieve
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Service details hash
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# service = Un.get_service(service_id)
# puts service["status"]
def get_service(service_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('GET', "/services/#{service_id}", pk, sk)
end
# Update a service (e.g., resize vCPU)
#
# @param service_id [String] Service ID to update
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param vcpu [Integer, nil] New vCPU count (1-8)
# @param name [String, nil] New service name
# @return [Hash] Response hash with update confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.update_service(service_id, vcpu: 4)
def update_service(service_id, public_key: nil, secret_key: nil, vcpu: nil, name: nil)
pk, sk = resolve_credentials(public_key, secret_key)
data = {}
data[:vcpu] = vcpu if vcpu
data[:name] = name if name
make_request('PATCH', "/services/#{service_id}", pk, sk, data)
end
# Delete (destroy) a service
#
# @param service_id [String] Service 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_service(service_id)
def delete_service(service_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('DELETE', "/services/#{service_id}", pk, sk)
end
# Freeze a service (stop container, reduce resource usage)
#
# @param service_id [String] Service ID to freeze
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with freeze confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.freeze_service(service_id)
def freeze_service(service_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/services/#{service_id}/freeze", pk, sk, {})
end
# Unfreeze a service (start container)
#
# @param service_id [String] Service ID to unfreeze
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with unfreeze confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.unfreeze_service(service_id)
def unfreeze_service(service_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/services/#{service_id}/unfreeze", pk, sk, {})
end
# Lock a service to prevent deletion
#
# @param service_id [String] Service 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_service(service_id)
def lock_service(service_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/services/#{service_id}/lock", pk, sk, {})
end
# Unlock a service to allow deletion
#
# @param service_id [String] Service 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_service(service_id)
def unlock_service(service_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/services/#{service_id}/unlock", pk, sk, {})
end
# Get service logs (bootstrap output)
#
# @param service_id [String] Service ID to get logs for
# @param all [Boolean] If true, get all logs; if false, get last 9000 lines (default: false)
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with log content
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# logs = Un.get_service_logs(service_id)
# puts logs["log"]
def get_service_logs(service_id, all: false, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
path = "/services/#{service_id}/logs"
path += '?all=true' if all
make_request('GET', path, pk, sk)
end
# Get service environment vault status
#
# @param service_id [String] Service ID to get env status for
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with vault status (has_vault, count, updated_at)
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# status = Un.get_service_env(service_id)
# puts "Variables: #{status['count']}"
def get_service_env(service_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('GET', "/services/#{service_id}/env", pk, sk)
end
# Set service environment variables (replaces existing vault)
#
# @param service_id [String] Service ID to set env for
# @param env [String] Environment content in .env format (KEY=VALUE per line)
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.set_service_env(service_id, "API_KEY=secret\nDEBUG=true")
def set_service_env(service_id, env, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
# This endpoint uses text/plain content type and PUT method
make_request_text('PUT', "/services/#{service_id}/env", pk, sk, env)
end
# Delete service environment vault
#
# @param service_id [String] Service ID to delete env for
# @param keys [Array<String>, nil] Specific keys to delete (nil = delete entire vault)
# @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 Delete entire vault
# Un.delete_service_env(service_id)
#
# @example Delete specific keys (if API supports it)
# Un.delete_service_env(service_id, keys: ["API_KEY", "DEBUG"])
def delete_service_env(service_id, keys: nil, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
if keys
make_request('DELETE', "/services/#{service_id}/env", pk, sk, { keys: keys })
else
make_request('DELETE', "/services/#{service_id}/env", pk, sk)
end
end
# Export service environment vault (returns .env format)
#
# @param service_id [String] Service ID to export env from
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with env content
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# result = Un.export_service_env(service_id)
# puts result["env"] # API_KEY=secret\nDEBUG=true
def export_service_env(service_id, public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
make_request('POST', "/services/#{service_id}/env/export", pk, sk, {})
end
# Redeploy a service (re-run bootstrap script)
#
# @param service_id [String] Service ID to redeploy
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param bootstrap [String, nil] New bootstrap script (optional)
# @return [Hash] Response hash with redeploy confirmation
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# Un.redeploy_service(service_id)
def redeploy_service(service_id, public_key: nil, secret_key: nil, bootstrap: nil)
pk, sk = resolve_credentials(public_key, secret_key)
data = {}
data[:bootstrap] = bootstrap if bootstrap
make_request('POST', "/services/#{service_id}/redeploy", pk, sk, data)
end
# Execute a command in a running service
#
# @param service_id [String] Service ID to execute command in
# @param command [String] Command to execute
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @param timeout [Integer] Command timeout in milliseconds (default: 30000)
# @return [Hash] Response hash with command output (stdout, stderr, exit_code)
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails
#
# @example
# result = Un.execute_in_service(service_id, "ls -la")
# puts result["stdout"]
def execute_in_service(service_id, command, public_key: nil, secret_key: nil, timeout: 30_000)
pk, sk = resolve_credentials(public_key, secret_key)
# Start async execution
response = make_request('POST', "/services/#{service_id}/execute", pk, sk, {
command: command,
timeout: timeout
})
job_id = response['job_id']
return response unless job_id
# Poll for completion
wait_for_job(job_id, public_key: pk, secret_key: sk, timeout: (timeout / 1000) + 10)
end
# ============================================================================
# Key Validation
# ============================================================================
# Validate API keys
#
# @param public_key [String, nil] Optional API key
# @param secret_key [String, nil] Optional API secret
# @return [Hash] Response hash with validation result and account info
# @raise [CredentialsError] If no credentials found
# @raise [APIError] If API request fails or keys invalid
#
# @example
# result = Un.validate_keys
# puts result["valid"] # true or false
def validate_keys(public_key: nil, secret_key: nil)
pk, sk = resolve_credentials(public_key, secret_key)
# Note: This endpoint is on the portal, not API, but we use same auth
make_request('POST', '/keys/validate', pk, sk, {})
end
private
# Language detection mapping (file extension -> language)
LANGUAGE_MAP = {
'py' => 'python',
'js' => 'javascript',
'ts' => 'typescript',
'rb' => 'ruby',
'php' => 'php',
'pl' => 'perl',
'sh' => 'bash',
'r' => 'r',
'lua' => 'lua',
'go' => 'go',
'rs' => 'rust',
'c' => 'c',
'cpp' => 'cpp',
'cc' => 'cpp',
'cxx' => 'cpp',
'java' => 'java',
'kt' => 'kotlin',
'm' => 'objc',
'cs' => 'csharp',
'fs' => 'fsharp',
'hs' => 'haskell',
'ml' => 'ocaml',
'clj' => 'clojure',
'scm' => 'scheme',
'ss' => 'scheme',
'erl' => 'erlang',
'ex' => 'elixir',
'exs' => 'elixir',
'jl' => 'julia',
'd' => 'd',
'nim' => 'nim',
'zig' => 'zig',
'v' => 'v',
'cr' => 'crystal',
'dart' => 'dart',
'groovy' => 'groovy',
'f90' => 'fortran',
'f95' => 'fortran',
'lisp' => 'commonlisp',
'lsp' => 'commonlisp',
'cob' => 'cobol',
'tcl' => 'tcl',
'raku' => 'raku',
'pro' => 'prolog',
'p' => 'prolog',
'4th' => 'forth',
'forth' => 'forth',
'fth' => 'forth'
}.freeze
# Get ~/.unsandbox directory path, creating if necessary
#
# @return [String] Path to unsandbox config directory
def unsandbox_dir
dir = File.join(Dir.home, '.unsandbox')
FileUtils.mkdir_p(dir, mode: 0o700) unless Dir.exist?(dir)
dir
end
# Load credentials from CSV file (public_key,secret_key per line)
#
# @param csv_path [String] Path to CSV file
# @param account_index [Integer] Account index (0-based)
# @return [Array<String>, nil] [public_key, secret_key] or nil if not found
def load_credentials_from_csv(csv_path, account_index = 0)
return nil unless File.exist?(csv_path)
current_index = 0
File.foreach(csv_path) do |line|
line = line.strip
next if line.empty? || line.start_with?('#')
if current_index == account_index
parts = line.split(',')
return [parts[0].strip, parts[1].strip] if parts.length >= 2
end
current_index += 1
end
nil
rescue StandardError
nil
end
# Resolve credentials from 4-tier priority system
#
# Priority:
# 1. Method arguments
# 2. Environment variables
# 3. ~/.unsandbox/accounts.csv
# 4. ./accounts.csv
#
# @param public_key [String, nil] Explicit public key
# @param secret_key [String, nil] Explicit secret key
# @param account_index [Integer, nil] Account index for CSV files
# @return [Array<String>] [public_key, secret_key]
# @raise [CredentialsError] If no credentials found
def resolve_credentials(public_key = nil, secret_key = nil, account_index = nil)
# Tier 1: Method arguments
return [public_key, secret_key] if public_key && secret_key
# Tier 2: Environment variables
env_pk = ENV['UNSANDBOX_PUBLIC_KEY']
env_sk = ENV['UNSANDBOX_SECRET_KEY']
return [env_pk, env_sk] if env_pk && env_sk
# Determine account index
account_index ||= ENV.fetch('UNSANDBOX_ACCOUNT', '0').to_i
# Tier 3: ~/.unsandbox/accounts.csv
creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), account_index)
return creds if creds
# Tier 4: ./accounts.csv
creds = load_credentials_from_csv('accounts.csv', account_index)
return creds if creds
raise CredentialsError, <<~MSG
No credentials found. Please provide via:
1. Method arguments (public_key:, secret_key:)
2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
3. ~/.unsandbox/accounts.csv
4. ./accounts.csv
MSG
end
# Sign a request using HMAC-SHA256
#
# Message format: "timestamp:METHOD:path:body"
#
# @param secret_key [String] Secret key for signing
# @param timestamp [Integer] Unix timestamp
# @param method [String] HTTP method (GET, POST, DELETE)
# @param path [String] API path
# @param body [String, nil] Request body (JSON string)
# @return [String] 64-character lowercase hex signature
def sign_request(secret_key, timestamp, method, path, body = nil)
body_str = body || ''
message = "#{timestamp}:#{method}:#{path}:#{body_str}"
OpenSSL::HMAC.hexdigest('SHA256', secret_key, message)
end
# Make an authenticated HTTP request to the API
#
# @param method [String] HTTP method (GET, POST, DELETE)
# @param path [String] API path
# @param public_key [String] API public key
# @param secret_key [String] API secret key
# @param data [Hash, nil] Request body data
# @return [Hash] Parsed JSON response
# @raise [APIError] If request fails
def make_request(method, path, public_key, secret_key, data = nil)
uri = URI.parse("#{API_BASE}#{path}")
timestamp = Time.now.to_i
body = data ? JSON.generate(data) : ''
signature = sign_request(secret_key, timestamp, method, path, data ? body : nil)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = REQUEST_TIMEOUT
http.read_timeout = REQUEST_TIMEOUT
headers = {
'Authorization' => "Bearer #{public_key}",
'X-Timestamp' => timestamp.to_s,
'X-Signature' => signature,
'Content-Type' => 'application/json'
}
response = case method
when 'GET'
http.get(uri.request_uri, headers)
when 'POST'
http.post(uri.request_uri, body, headers)
when 'PATCH'
http.patch(uri.request_uri, body, headers)
when 'PUT'
http.put(uri.request_uri, body, headers)
when 'DELETE'
req = Net::HTTP::Delete.new(uri.request_uri, headers)
req.body = body if data
http.request(req)
else
raise APIError, "Unsupported HTTP method: #{method}"
end
unless response.is_a?(Net::HTTPSuccess)
raise APIError.new(
"API request failed: #{response.code} #{response.message}",
status_code: response.code.to_i,
response_body: response.body
)
end
JSON.parse(response.body)
rescue JSON::ParserError => e
raise APIError, "Invalid JSON response: #{e.message}"
rescue Net::OpenTimeout, Net::ReadTimeout => e
raise APIError, "Request timeout: #{e.message}"
rescue StandardError => e
raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError)
raise
end
# Make an authenticated HTTP request with text/plain content type
#
# @param method [String] HTTP method (PUT)
# @param path [String] API path
# @param public_key [String] API public key
# @param secret_key [String] API secret key
# @param body [String] Plain text request body
# @return [Hash] Parsed JSON response
# @raise [APIError] If request fails
def make_request_text(method, path, public_key, secret_key, body)
uri = URI.parse("#{API_BASE}#{path}")
timestamp = Time.now.to_i
signature = sign_request(secret_key, timestamp, method, path, body)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = REQUEST_TIMEOUT
http.read_timeout = REQUEST_TIMEOUT
headers = {
'Authorization' => "Bearer #{public_key}",
'X-Timestamp' => timestamp.to_s,
'X-Signature' => signature,
'Content-Type' => 'text/plain'
}
response = case method
when 'PUT'
http.put(uri.request_uri, body, headers)
else
raise APIError, "Unsupported HTTP method for text: #{method}"
end
unless response.is_a?(Net::HTTPSuccess)
raise APIError.new(
"API request failed: #{response.code} #{response.message}",
status_code: response.code.to_i,
response_body: response.body
)
end
JSON.parse(response.body)
rescue JSON::ParserError => e
raise APIError, "Invalid JSON response: #{e.message}"
rescue Net::OpenTimeout, Net::ReadTimeout => e
raise APIError, "Request timeout: #{e.message}"
rescue StandardError => e
raise APIError, "Request failed: #{e.message}" unless e.is_a?(APIError)
raise
end
# Get path to languages cache file
#
# @return [String] Path to languages.json
def languages_cache_path
File.join(unsandbox_dir, 'languages.json')
end
# Load languages from cache if valid (< 1 hour old)
#
# @return [Array<String>, nil] Cached languages or nil if cache invalid/missing
def load_languages_cache
cache_path = languages_cache_path
return nil unless File.exist?(cache_path)
# Check if cache is fresh
age_seconds = Time.now - File.mtime(cache_path)
return nil if age_seconds >= LANGUAGES_CACHE_TTL
data = JSON.parse(File.read(cache_path))
data['languages']
rescue StandardError
nil
end
# Save languages to cache
#
# @param languages [Array<String>] Languages to cache
# @return [void]
def save_languages_cache(languages)
cache_path = languages_cache_path
File.write(cache_path, JSON.generate({
languages: languages,
timestamp: Time.now.to_i
}))
rescue StandardError
# Cache failures are non-fatal
nil
end
end
end