Fix restore CLI syntax - take snapshot ID directly

Changed --restore to take snapshot ID directly and call /snapshots/:id/restore
instead of requiring --from SNAPSHOT_ID and calling /sessions/:id/restore or
/services/:id/restore.

Updated session and service restore in all implementations:
- un.py, un.go, un.rb, un.sh, un.ex, un.erl, un.fs, un.hs
- un.groovy, un.r, un.m, un.awk

Also added snapshot management features where missing.
This commit is contained in:
Russell Ballestrini 2026-01-04 19:02:06 -05:00
parent 09ec15aa62
commit ccd723db99
13 changed files with 2020 additions and 40 deletions

329
un.awk
View file

@ -597,30 +597,286 @@ function cmd_key(do_extend) {
validate_key(do_extend)
}
function snapshot_list( timestamp, sig_headers, signature, sig_input, sig_cmd) {
get_api_keys()
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":GET:/snapshots:"
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'"
}
cmd = "curl -s '" API_BASE "/snapshots' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers
while ((cmd | getline line) > 0) print line
close(cmd)
}
function snapshot_info(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) {
get_api_keys()
endpoint = "/snapshots/" id
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":GET:" endpoint ":"
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'"
}
cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers
while ((cmd | getline line) > 0) print line
close(cmd)
}
function snapshot_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) {
get_api_keys()
endpoint = "/snapshots/" id
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":DELETE:" endpoint ":"
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'"
}
cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers
system(cmd)
print GREEN "Snapshot deleted: " id RESET
}
function session_snapshot(id, name, hot , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) {
get_api_keys()
endpoint = "/sessions/" id "/snapshot"
# Build JSON payload
json = "{"
if (name != "") {
json = json "\"name\":\"" escape_json(name) "\""
if (hot != "") json = json ","
}
if (hot != "") {
json = json "\"hot\":" hot
}
json = json "}"
# Write to temp file
tmp = "/tmp/un_awk_snap_" PROCINFO["pid"] ".json"
print json > tmp
close(tmp)
# Build HMAC signature if secret key exists
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":POST:" endpoint ":" json
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' "
}
# Call curl
cmd = "curl -s -X POST '" API_BASE endpoint "' " \
"-H 'Content-Type: application/json' " \
"-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \
sig_headers \
"-d '@" tmp "'"
response = ""
while ((cmd | getline line) > 0) {
response = response line
}
close(cmd)
# Clean up
system("rm -f " tmp)
print GREEN "Snapshot created" RESET
print response
}
function session_restore(snapshot_id , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) {
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
get_api_keys()
endpoint = "/snapshots/" snapshot_id "/restore"
json = "{}"
# Write to temp file
tmp = "/tmp/un_awk_restore_" PROCINFO["pid"] ".json"
print json > tmp
close(tmp)
# Build HMAC signature if secret key exists
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":POST:" endpoint ":" json
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' "
}
# Call curl
cmd = "curl -s -X POST '" API_BASE endpoint "' " \
"-H 'Content-Type: application/json' " \
"-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \
sig_headers \
"-d '@" tmp "'"
response = ""
while ((cmd | getline line) > 0) {
response = response line
}
close(cmd)
# Clean up
system("rm -f " tmp)
print GREEN "Session restored from snapshot" RESET
}
function service_snapshot(id, name, hot , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) {
get_api_keys()
endpoint = "/services/" id "/snapshot"
# Build JSON payload
json = "{"
if (name != "") {
json = json "\"name\":\"" escape_json(name) "\""
if (hot != "") json = json ","
}
if (hot != "") {
json = json "\"hot\":" hot
}
json = json "}"
# Write to temp file
tmp = "/tmp/un_awk_snap_" PROCINFO["pid"] ".json"
print json > tmp
close(tmp)
# Build HMAC signature if secret key exists
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":POST:" endpoint ":" json
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' "
}
# Call curl
cmd = "curl -s -X POST '" API_BASE endpoint "' " \
"-H 'Content-Type: application/json' " \
"-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \
sig_headers \
"-d '@" tmp "'"
response = ""
while ((cmd | getline line) > 0) {
response = response line
}
close(cmd)
# Clean up
system("rm -f " tmp)
print GREEN "Snapshot created" RESET
print response
}
function service_restore(snapshot_id , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) {
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
get_api_keys()
endpoint = "/snapshots/" snapshot_id "/restore"
json = "{}"
# Write to temp file
tmp = "/tmp/un_awk_restore_" PROCINFO["pid"] ".json"
print json > tmp
close(tmp)
# Build HMAC signature if secret key exists
timestamp = systime()
sig_headers = ""
if (GLOBAL_SECRET_KEY != "") {
sig_input = timestamp ":POST:" endpoint ":" json
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
sig_cmd | getline signature
close(sig_cmd)
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' "
}
# Call curl
cmd = "curl -s -X POST '" API_BASE endpoint "' " \
"-H 'Content-Type: application/json' " \
"-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \
sig_headers \
"-d '@" tmp "'"
response = ""
while ((cmd | getline line) > 0) {
response = response line
}
close(cmd)
# Clean up
system("rm -f " tmp)
print GREEN "Service restored from snapshot" RESET
}
function show_help() {
print "Usage: awk -f un.awk <source_file>"
print " awk -f un.awk session --list"
print " awk -f un.awk session --kill ID"
print " awk -f un.awk session [-s SHELL] [-f FILE]..."
print " awk -f un.awk session --snapshot SESSION_ID [--snapshot-name NAME] [--hot]"
print " awk -f un.awk session --restore SNAPSHOT_ID"
print " awk -f un.awk key [--extend]"
print " awk -f un.awk service --list"
print " awk -f un.awk service --create --name NAME [--ports PORTS] [--domains DOMAINS] [--type TYPE] [--bootstrap CMD] [-f FILE]..."
print " awk -f un.awk service --destroy ID"
print " awk -f un.awk service --dump-bootstrap ID [--dump-file FILE]"
print " awk -f un.awk service --snapshot SERVICE_ID [--snapshot-name NAME] [--hot]"
print " awk -f un.awk service --restore SNAPSHOT_ID"
print " awk -f un.awk snapshot --list"
print " awk -f un.awk snapshot --info ID"
print " awk -f un.awk snapshot --delete ID"
print ""
print "Session options:"
print " -s, --shell SHELL Shell to use (default: bash)"
print " -f FILE Input file to upload (can be repeated)"
print " -s, --shell SHELL Shell to use (default: bash)"
print " -f FILE Input file to upload (can be repeated)"
print " --snapshot SESSION_ID Create snapshot of session"
print " --restore SNAPSHOT_ID Restore from snapshot ID"
print " --snapshot-name N Name for snapshot"
print " --hot Take snapshot without freezing (live snapshot)"
print ""
print "Service options:"
print " --name NAME Service name (required for --create)"
print " --ports PORTS Comma-separated port numbers"
print " --domains DOMAINS Comma-separated domain names"
print " --type TYPE Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)"
print " --bootstrap CMD Bootstrap command or script"
print " --dump-bootstrap ID Dump bootstrap script from service"
print " --dump-file FILE Save bootstrap to file (with --dump-bootstrap)"
print " -f FILE Input file to upload (can be repeated)"
print " --name NAME Service name (required for --create)"
print " --ports PORTS Comma-separated port numbers"
print " --domains DOMAINS Comma-separated domain names"
print " --type TYPE Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)"
print " --bootstrap CMD Bootstrap command or script"
print " --dump-bootstrap ID Dump bootstrap script from service"
print " --dump-file FILE Save bootstrap to file (with --dump-bootstrap)"
print " -f FILE Input file to upload (can be repeated)"
print " --snapshot SERVICE_ID Create snapshot of service"
print " --restore SNAPSHOT_ID Restore from snapshot ID"
print " --snapshot-name N Name for snapshot"
print " --hot Take snapshot without freezing (live snapshot)"
print ""
print "Snapshot options:"
print " -l, --list List all snapshots"
print " --info ID Get snapshot details"
print " --delete ID Delete a snapshot"
print ""
print "Requires: UNSANDBOX_API_KEY environment variable"
}
@ -647,6 +903,26 @@ END {
session_list()
} else if (ARGC >= 4 && ARGV[2] == "--kill") {
session_kill(ARGV[3])
} else if (ARGC >= 4 && ARGV[2] == "--snapshot") {
# Parse snapshot options
snapshot_name = ""
hot = ""
i = 4
while (i < ARGC) {
if (ARGV[i] == "--snapshot-name" && i + 1 < ARGC) {
snapshot_name = ARGV[i + 1]
i += 2
} else if (ARGV[i] == "--hot") {
hot = "true"
i++
} else {
i++
}
}
session_snapshot(ARGV[3], snapshot_name, hot)
} else if (ARGC >= 4 && ARGV[2] == "--restore") {
# --restore takes snapshot ID directly
session_restore(ARGV[3])
} else {
# Parse session creation arguments
shell = ""
@ -693,6 +969,19 @@ END {
exit 0
}
if (ARGV[1] == "snapshot") {
if (ARGC >= 3 && (ARGV[2] == "--list" || ARGV[2] == "-l")) {
snapshot_list()
} else if (ARGC >= 4 && ARGV[2] == "--info") {
snapshot_info(ARGV[3])
} else if (ARGC >= 4 && ARGV[2] == "--delete") {
snapshot_delete(ARGV[3])
} else {
print "Usage: awk -f un.awk snapshot --list|--info ID|--delete ID"
}
exit 0
}
if (ARGV[1] == "service") {
if (ARGC >= 3 && ARGV[2] == "--list") {
service_list()
@ -704,6 +993,26 @@ END {
dump_file = ARGV[5]
}
service_dump_bootstrap(ARGV[3], dump_file)
} else if (ARGC >= 4 && ARGV[2] == "--snapshot") {
# Parse snapshot options
snapshot_name = ""
hot = ""
i = 4
while (i < ARGC) {
if (ARGV[i] == "--snapshot-name" && i + 1 < ARGC) {
snapshot_name = ARGV[i + 1]
i += 2
} else if (ARGV[i] == "--hot") {
hot = "true"
i++
} else {
i++
}
}
service_snapshot(ARGV[3], snapshot_name, hot)
} else if (ARGC >= 4 && ARGV[2] == "--restore") {
# --restore takes snapshot ID directly
service_restore(ARGV[3])
} else if (ARGV[2] == "--create") {
# Parse service creation arguments
name = ""

138
un.erl
View file

@ -46,6 +46,7 @@ main([]) ->
io:format("Usage: un.erl [options] <source_file>~n"),
io:format(" un.erl session [options]~n"),
io:format(" un.erl service [options]~n"),
io:format(" un.erl snapshot [options]~n"),
io:format(" un.erl key [options]~n"),
halt(1);
@ -55,6 +56,9 @@ main(["session" | Rest]) ->
main(["service" | Rest]) ->
service_command(Rest);
main(["snapshot" | Rest]) ->
snapshot_command(Rest);
main(["key" | Rest]) ->
key_command(Rest);
@ -115,12 +119,38 @@ session_command(Args) ->
io:format("\033[33mSession created (WebSocket required)\033[0m~n"),
io:format("~s~n", [Response]).
%% Session snapshot commands
session_command(["--snapshot", SessionId | Rest]) ->
ApiKey = get_api_key(),
Name = get_snapshot_name(Rest),
Hot = has_hot_flag(Rest),
Json = build_snapshot_json(Name, Hot),
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/sessions/" ++ SessionId ++ "/snapshot", TmpFile),
file:delete(TmpFile),
io:format("\033[32mSnapshot created\033[0m~n"),
io:format("~s~n", [Response]);
session_command(["--restore", SnapshotId | _Rest]) ->
% --restore takes snapshot ID directly, calls /snapshots/:id/restore
ApiKey = get_api_key(),
TmpFile = write_temp_file("{}"),
Response = curl_post(ApiKey, "/snapshots/" ++ SnapshotId ++ "/restore", TmpFile),
file:delete(TmpFile),
io:format("\033[32mSession restored from snapshot\033[0m~n"),
io:format("~s~n", [Response]);
validate_session_args([]) -> ok;
validate_session_args(["--shell", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["-s", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["-f", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["-n", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["-v", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--snapshot", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--restore", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--from", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--snapshot-name", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--hot" | Rest]) -> validate_session_args(Rest);
validate_session_args([Arg | _]) ->
case Arg of
[$- | _] ->
@ -131,6 +161,23 @@ validate_session_args([Arg | _]) ->
validate_session_args([])
end.
get_snapshot_name([]) -> undefined;
get_snapshot_name(["--snapshot-name", Name | _]) -> Name;
get_snapshot_name([_ | Rest]) -> get_snapshot_name(Rest).
get_from_snapshot([]) -> undefined;
get_from_snapshot(["--from", SnapshotId | _]) -> SnapshotId;
get_from_snapshot([_ | Rest]) -> get_from_snapshot(Rest).
has_hot_flag([]) -> false;
has_hot_flag(["--hot" | _]) -> true;
has_hot_flag([_ | Rest]) -> has_hot_flag(Rest).
build_snapshot_json(undefined, false) -> "{}";
build_snapshot_json(undefined, true) -> "{\"hot\":true}";
build_snapshot_json(Name, false) -> "{\"name\":\"" ++ escape_json(Name) ++ "\"}";
build_snapshot_json(Name, true) -> "{\"name\":\"" ++ escape_json(Name) ++ "\",\"hot\":true}".
%% Service command
service_command(["--list" | _]) ->
ApiKey = get_api_key(),
@ -209,6 +256,27 @@ service_command(["--dump-bootstrap", ServiceId | _]) ->
io:format("~s", [Script])
end;
%% Service snapshot commands
service_command(["--snapshot", ServiceId | Rest]) ->
ApiKey = get_api_key(),
Name = get_snapshot_name(Rest),
Hot = has_hot_flag(Rest),
Json = build_snapshot_json(Name, Hot),
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/services/" ++ ServiceId ++ "/snapshot", TmpFile),
file:delete(TmpFile),
io:format("\033[32mSnapshot created\033[0m~n"),
io:format("~s~n", [Response]);
service_command(["--restore", SnapshotId | _Rest]) ->
% --restore takes snapshot ID directly, calls /snapshots/:id/restore
ApiKey = get_api_key(),
TmpFile = write_temp_file("{}"),
Response = curl_post(ApiKey, "/snapshots/" ++ SnapshotId ++ "/restore", TmpFile),
file:delete(TmpFile),
io:format("\033[32mService restored from snapshot\033[0m~n"),
io:format("~s~n", [Response]);
service_command(Args) ->
case get_service_name(Args) of
undefined ->
@ -254,6 +322,76 @@ service_command(Args) ->
io:format("~s~n", [Response])
end.
%% Snapshot command
snapshot_command(["--list" | _]) ->
snapshot_command(["-l"]);
snapshot_command(["-l" | _]) ->
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/snapshots"),
io:format("~s~n", [Response]);
snapshot_command(["--info", SnapshotId | _]) ->
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/snapshots/" ++ SnapshotId),
io:format("~s~n", [Response]);
snapshot_command(["--delete", SnapshotId | _]) ->
ApiKey = get_api_key(),
_ = curl_delete(ApiKey, "/snapshots/" ++ SnapshotId),
io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]);
snapshot_command(["--clone", SnapshotId | Rest]) ->
ApiKey = get_api_key(),
Type = get_clone_type(Rest),
Name = get_clone_name(Rest),
Shell = get_clone_shell(Rest),
Ports = get_clone_ports(Rest),
Json = build_clone_json(Type, Name, Shell, Ports),
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/snapshots/" ++ SnapshotId ++ "/clone", TmpFile),
file:delete(TmpFile),
io:format("\033[32mCreated from snapshot\033[0m~n"),
io:format("~s~n", [Response]);
snapshot_command(_) ->
io:format(standard_error, "Error: Use --list, --info ID, --delete ID, or --clone ID --type TYPE~n", []),
halt(1).
get_clone_type([]) -> undefined;
get_clone_type(["--type", Type | _]) -> Type;
get_clone_type([_ | Rest]) -> get_clone_type(Rest).
get_clone_name([]) -> undefined;
get_clone_name(["--name", Name | _]) -> Name;
get_clone_name([_ | Rest]) -> get_clone_name(Rest).
get_clone_shell([]) -> undefined;
get_clone_shell(["--shell", Shell | _]) -> Shell;
get_clone_shell([_ | Rest]) -> get_clone_shell(Rest).
get_clone_ports([]) -> undefined;
get_clone_ports(["--ports", Ports | _]) -> Ports;
get_clone_ports([_ | Rest]) -> get_clone_ports(Rest).
build_clone_json(undefined, _, _, _) ->
io:format(standard_error, "\033[31mError: --type required (session or service)\033[0m~n"),
halt(1);
build_clone_json(Type, Name, Shell, Ports) ->
TypeJson = "{\"type\":\"" ++ Type ++ "\"",
NameJson = case Name of
undefined -> "";
N -> ",\"name\":\"" ++ escape_json(N) ++ "\""
end,
ShellJson = case Shell of
undefined -> "";
S -> ",\"shell\":\"" ++ S ++ "\""
end,
PortsJson = case Ports of
undefined -> "";
P -> ",\"ports\":[" ++ P ++ "]"
end,
TypeJson ++ NameJson ++ ShellJson ++ PortsJson ++ "}".
%% Key command
key_command(Args) ->
ApiKey = get_api_key(),

98
un.ex
View file

@ -78,6 +78,7 @@ defmodule Un do
def main([]), do: print_usage()
def main(["session" | rest]), do: session_command(rest)
def main(["service" | rest]), do: service_command(rest)
def main(["snapshot" | rest]), do: snapshot_command(rest)
def main(["key" | rest]), do: key_command(rest)
def main(args), do: execute_command(args)
@ -85,6 +86,7 @@ defmodule Un do
IO.puts("Usage: un.ex [options] <source_file>")
IO.puts(" un.ex session [options]")
IO.puts(" un.ex service [options]")
IO.puts(" un.ex snapshot [options]")
IO.puts(" un.ex key [--extend]")
System.halt(1)
end
@ -132,6 +134,26 @@ defmodule Un do
IO.puts("#{@green}Session terminated: #{session_id}#{@reset}")
end
defp session_command(["--snapshot", session_id | rest]) do
api_key = get_api_key()
name = get_opt(rest, "--snapshot-name", nil, nil)
hot = "--hot" in rest
name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: ""
hot_json = if hot, do: ",\"hot\":true", else: ""
json = "{#{String.slice(name_json <> hot_json, 1..-1)}}"
response = curl_post(api_key, "/sessions/#{session_id}/snapshot", json)
IO.puts("#{@green}Snapshot created#{@reset}")
IO.puts(response)
end
defp session_command(["--restore", snapshot_id | _rest]) do
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
api_key = get_api_key()
response = curl_post(api_key, "/snapshots/#{snapshot_id}/restore", "{}")
IO.puts("#{@green}Session restored from snapshot#{@reset}")
IO.puts(response)
end
defp session_command(args) do
validate_session_args(args)
api_key = get_api_key()
@ -156,6 +178,11 @@ defmodule Un do
defp validate_session_args(["-f", _ | rest]), do: validate_session_args(rest)
defp validate_session_args(["-n", _ | rest]), do: validate_session_args(rest)
defp validate_session_args(["-v", _ | rest]), do: validate_session_args(rest)
defp validate_session_args(["--snapshot", _ | rest]), do: validate_session_args(rest)
defp validate_session_args(["--restore", _ | rest]), do: validate_session_args(rest)
defp validate_session_args(["--from", _ | rest]), do: validate_session_args(rest)
defp validate_session_args(["--snapshot-name", _ | rest]), do: validate_session_args(rest)
defp validate_session_args(["--hot" | rest]), do: validate_session_args(rest)
defp validate_session_args([arg | _]) do
if String.starts_with?(arg, "-") do
IO.puts(:stderr, "Unknown option: #{arg}")
@ -203,6 +230,26 @@ defmodule Un do
IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}")
end
defp service_command(["--snapshot", service_id | rest]) do
api_key = get_api_key()
name = get_opt(rest, "--snapshot-name", nil, nil)
hot = "--hot" in rest
name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: ""
hot_json = if hot, do: ",\"hot\":true", else: ""
json = "{#{String.slice(name_json <> hot_json, 1..-1)}}"
response = curl_post(api_key, "/services/#{service_id}/snapshot", json)
IO.puts("#{@green}Snapshot created#{@reset}")
IO.puts(response)
end
defp service_command(["--restore", snapshot_id | _rest]) do
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
api_key = get_api_key()
response = curl_post(api_key, "/snapshots/#{snapshot_id}/restore", "{}")
IO.puts("#{@green}Service restored from snapshot#{@reset}")
IO.puts(response)
end
defp service_command(["--execute", service_id, "--command", command | _]) do
api_key = get_api_key()
json = "{\"command\":\"#{escape_json(command)}\"}"
@ -286,6 +333,57 @@ defmodule Un do
IO.puts(response)
end
# Snapshot command
defp snapshot_command(["--list" | _]) do
snapshot_command(["-l"])
end
defp snapshot_command(["-l" | _]) do
api_key = get_api_key()
response = curl_get(api_key, "/snapshots")
IO.puts(response)
end
defp snapshot_command(["--info", snapshot_id | _]) do
api_key = get_api_key()
response = curl_get(api_key, "/snapshots/#{snapshot_id}")
IO.puts(response)
end
defp snapshot_command(["--delete", snapshot_id | _]) do
api_key = get_api_key()
curl_delete(api_key, "/snapshots/#{snapshot_id}")
IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}")
end
defp snapshot_command(["--clone", snapshot_id | rest]) do
api_key = get_api_key()
clone_type = get_opt(rest, "--type", nil, nil)
name = get_opt(rest, "--name", nil, nil)
shell = get_opt(rest, "--shell", nil, nil)
ports = get_opt(rest, "--ports", nil, nil)
if !clone_type do
IO.puts(:stderr, "#{@red}Error: --type required (session or service)#{@reset}")
System.halt(1)
end
type_json = "\"type\":\"#{clone_type}\""
name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: ""
shell_json = if shell, do: ",\"shell\":\"#{shell}\"", else: ""
ports_json = if ports, do: ",\"ports\":[#{ports}]", else: ""
json = "{#{type_json}#{name_json}#{shell_json}#{ports_json}}"
response = curl_post(api_key, "/snapshots/#{snapshot_id}/clone", json)
IO.puts("#{@green}Created from snapshot#{@reset}")
IO.puts(response)
end
defp snapshot_command(_) do
IO.puts(:stderr, "Error: Use --list, --info ID, --delete ID, or --clone ID --type TYPE")
System.halt(1)
end
# Key command
defp key_command(args) do
api_key = get_api_key()

166
un.fs
View file

@ -84,6 +84,11 @@ type Args = {
mutable SessionList: bool
mutable SessionShell: string option
mutable SessionKill: string option
mutable SessionSnapshot: string option
mutable SessionRestore: string option
mutable SessionFrom: string option
mutable SessionSnapshotName: string option
mutable SessionHot: bool
mutable ServiceList: bool
mutable ServiceName: string option
mutable ServicePorts: string option
@ -100,6 +105,19 @@ type Args = {
mutable ServiceCommand: string option
mutable ServiceDumpBootstrap: string option
mutable ServiceDumpFile: string option
mutable ServiceSnapshot: string option
mutable ServiceRestore: string option
mutable ServiceFrom: string option
mutable ServiceSnapshotName: string option
mutable ServiceHot: bool
mutable SnapshotList: bool
mutable SnapshotInfo: string option
mutable SnapshotDelete: string option
mutable SnapshotClone: string option
mutable SnapshotType: string option
mutable SnapshotName: string option
mutable SnapshotShell: string option
mutable SnapshotPorts: string option
mutable KeyExtend: bool
}
@ -345,7 +363,21 @@ let cmdExecute (args: Args) =
let cmdSession (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey
if args.SessionList then
if args.SessionSnapshot.IsSome then
let mutable payload = []
if args.SessionSnapshotName.IsSome then
payload <- payload @ [("name", box args.SessionSnapshotName.Value)]
if args.SessionHot then
payload <- payload @ [("hot", box true)]
let result = apiRequest (sprintf "/sessions/%s/snapshot" args.SessionSnapshot.Value) "POST" (Some payload) publicKey secretKey
printfn "%sSnapshot created%s" green reset
printfn "%s" (toJson (box result))
elif args.SessionRestore.IsSome then
// --restore takes snapshot ID directly, calls /snapshots/:id/restore
let result = apiRequest (sprintf "/snapshots/%s/restore" args.SessionRestore.Value) "POST" None publicKey secretKey
printfn "%sSession restored from snapshot%s" green reset
printfn "%s" (toJson (box result))
elif args.SessionList then
let result = apiRequest "/sessions" "GET" None publicKey secretKey
printfn "%-40s %-10s %-10s %s" "ID" "Shell" "Status" "Created"
printfn "No sessions (list parsing not implemented)"
@ -465,10 +497,55 @@ let cmdKey (args: Args) =
printfn "Reason: %s" errorMsg
exit 1
let cmdSnapshot (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey
if args.SnapshotList then
let result = apiRequest "/snapshots" "GET" None publicKey secretKey
printfn "%s" (toJson (box result))
elif args.SnapshotInfo.IsSome then
let result = apiRequest (sprintf "/snapshots/%s" args.SnapshotInfo.Value) "GET" None publicKey secretKey
printfn "%s" (toJson (box result))
elif args.SnapshotDelete.IsSome then
let result = apiRequest (sprintf "/snapshots/%s" args.SnapshotDelete.Value) "DELETE" None publicKey secretKey
printfn "%sSnapshot deleted: %s%s" green args.SnapshotDelete.Value reset
elif args.SnapshotClone.IsSome then
if args.SnapshotType.IsNone then
eprintfn "%sError: --type required (session or service)%s" red reset
exit 1
let mutable payload = [("type", box args.SnapshotType.Value)]
if args.SnapshotName.IsSome then
payload <- payload @ [("name", box args.SnapshotName.Value)]
if args.SnapshotShell.IsSome then
payload <- payload @ [("shell", box args.SnapshotShell.Value)]
if args.SnapshotPorts.IsSome then
let ports = args.SnapshotPorts.Value.Split(',') |> Array.map (fun p -> box (int (p.Trim())))
payload <- payload @ [("ports", box ports)]
let result = apiRequest (sprintf "/snapshots/%s/clone" args.SnapshotClone.Value) "POST" (Some payload) publicKey secretKey
printfn "%sCreated from snapshot%s" green reset
printfn "%s" (toJson (box result))
else
eprintfn "%sError: Use --list, --info ID, --delete ID, or --clone ID --type TYPE%s" red reset
exit 1
let cmdService (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey
if args.ServiceList then
if args.ServiceSnapshot.IsSome then
let mutable payload = []
if args.ServiceSnapshotName.IsSome then
payload <- payload @ [("name", box args.ServiceSnapshotName.Value)]
if args.ServiceHot then
payload <- payload @ [("hot", box true)]
let result = apiRequest (sprintf "/services/%s/snapshot" args.ServiceSnapshot.Value) "POST" (Some payload) publicKey secretKey
printfn "%sSnapshot created%s" green reset
printfn "%s" (toJson (box result))
elif args.ServiceRestore.IsSome then
// --restore takes snapshot ID directly, calls /snapshots/:id/restore
let result = apiRequest (sprintf "/snapshots/%s/restore" args.ServiceRestore.Value) "POST" None publicKey secretKey
printfn "%sService restored from snapshot%s" green reset
printfn "%s" (toJson (box result))
elif args.ServiceList then
let result = apiRequest "/services" "GET" None publicKey secretKey
printfn "%-20s %-15s %-10s %-15s %s" "ID" "Name" "Status" "Ports" "Domains"
printfn "No services (list parsing not implemented)"
@ -580,6 +657,11 @@ let parseArgs (argv: string[]) =
SessionList = false
SessionShell = None
SessionKill = None
SessionSnapshot = None
SessionRestore = None
SessionFrom = None
SessionSnapshotName = None
SessionHot = false
ServiceList = false
ServiceName = None
ServicePorts = None
@ -596,6 +678,19 @@ let parseArgs (argv: string[]) =
ServiceCommand = None
ServiceDumpBootstrap = None
ServiceDumpFile = None
ServiceSnapshot = None
ServiceRestore = None
ServiceFrom = None
ServiceSnapshotName = None
ServiceHot = false
SnapshotList = false
SnapshotInfo = None
SnapshotDelete = None
SnapshotClone = None
SnapshotType = None
SnapshotName = None
SnapshotShell = None
SnapshotPorts = None
KeyExtend = false
}
@ -604,6 +699,7 @@ let parseArgs (argv: string[]) =
match argv.[i] with
| "session" -> args.Command <- Some "session"
| "service" -> args.Command <- Some "service"
| "snapshot" -> args.Command <- Some "snapshot"
| "key" -> args.Command <- Some "key"
| "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i]
| "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i]
@ -617,14 +713,69 @@ let parseArgs (argv: string[]) =
| Some "session" -> args.SessionList <- true
| Some "service" -> args.ServiceList <- true
| _ -> ()
| "-s" | "--shell" -> i <- i + 1; args.SessionShell <- Some argv.[i]
| "-s" | "--shell" ->
i <- i + 1
match args.Command with
| Some "snapshot" -> args.SnapshotShell <- Some argv.[i]
| _ -> args.SessionShell <- Some argv.[i]
| "--kill" -> i <- i + 1; args.SessionKill <- Some argv.[i]
| "--name" -> i <- i + 1; args.ServiceName <- Some argv.[i]
| "--ports" -> i <- i + 1; args.ServicePorts <- Some argv.[i]
| "--type" -> i <- i + 1; args.ServiceType <- Some argv.[i]
| "--snapshot" ->
i <- i + 1
match args.Command with
| Some "session" -> args.SessionSnapshot <- Some argv.[i]
| Some "service" -> args.ServiceSnapshot <- Some argv.[i]
| _ -> ()
| "--restore" ->
i <- i + 1
match args.Command with
| Some "session" -> args.SessionRestore <- Some argv.[i]
| Some "service" -> args.ServiceRestore <- Some argv.[i]
| _ -> ()
| "--from" ->
i <- i + 1
match args.Command with
| Some "session" -> args.SessionFrom <- Some argv.[i]
| Some "service" -> args.ServiceFrom <- Some argv.[i]
| _ -> ()
| "--snapshot-name" ->
i <- i + 1
match args.Command with
| Some "session" -> args.SessionSnapshotName <- Some argv.[i]
| Some "service" -> args.ServiceSnapshotName <- Some argv.[i]
| _ -> ()
| "--hot" ->
match args.Command with
| Some "session" -> args.SessionHot <- true
| Some "service" -> args.ServiceHot <- true
| _ -> ()
| "--info" ->
i <- i + 1
match args.Command with
| Some "snapshot" -> args.SnapshotInfo <- Some argv.[i]
| _ -> args.ServiceInfo <- Some argv.[i]
| "--delete" ->
i <- i + 1
match args.Command with
| Some "snapshot" -> args.SnapshotDelete <- Some argv.[i]
| _ -> ()
| "--clone" -> i <- i + 1; args.SnapshotClone <- Some argv.[i]
| "--type" ->
i <- i + 1
match args.Command with
| Some "snapshot" -> args.SnapshotType <- Some argv.[i]
| _ -> args.ServiceType <- Some argv.[i]
| "--name" ->
i <- i + 1
match args.Command with
| Some "snapshot" -> args.SnapshotName <- Some argv.[i]
| _ -> args.ServiceName <- Some argv.[i]
| "--ports" ->
i <- i + 1
match args.Command with
| Some "snapshot" -> args.SnapshotPorts <- Some argv.[i]
| _ -> args.ServicePorts <- Some argv.[i]
| "--bootstrap" -> i <- i + 1; args.ServiceBootstrap <- Some argv.[i]
| "--bootstrap-file" -> i <- i + 1; args.ServiceBootstrapFile <- Some argv.[i]
| "--info" -> i <- i + 1; args.ServiceInfo <- Some argv.[i]
| "--logs" -> i <- i + 1; args.ServiceLogs <- Some argv.[i]
| "--tail" -> i <- i + 1; args.ServiceTail <- Some argv.[i]
| "--freeze" -> i <- i + 1; args.ServiceSleep <- Some argv.[i]
@ -694,6 +845,7 @@ let main argv =
match args.Command with
| Some "session" -> cmdSession args; 0
| Some "service" -> cmdService args; 0
| Some "snapshot" -> cmdSnapshot args; 0
| Some "key" -> cmdKey args; 0
| _ ->
match args.SourceFile with

147
un.go
View file

@ -330,7 +330,31 @@ func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts boo
os.Exit(exitCode)
}
func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int, tmux, screen bool, files inputFiles, publicKey, secretKey string) {
func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, sessionRestore, sessionSnapshotName string, sessionHot bool, network string, vcpu int, tmux, screen bool, files inputFiles, publicKey, secretKey string) {
if sessionSnapshot != "" {
payload := map[string]interface{}{}
if sessionSnapshotName != "" {
payload["name"] = sessionSnapshotName
}
if sessionHot {
payload["hot"] = true
}
result := apiRequest("/sessions/"+sessionSnapshot+"/snapshot", "POST", payload, publicKey, secretKey)
fmt.Printf("%sSnapshot created%s\n", Green, Reset)
jsonData, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonData))
return
}
if sessionRestore != "" {
// --restore takes snapshot ID directly, calls /snapshots/:id/restore
result := apiRequest("/snapshots/"+sessionRestore+"/restore", "POST", nil, publicKey, secretKey)
fmt.Printf("%sSession restored from snapshot%s\n", Green, Reset)
jsonData, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonData))
return
}
if sessionList != "" {
result := apiRequest("/sessions", "GET", nil, publicKey, secretKey)
sessions := result["sessions"].([]interface{})
@ -395,7 +419,31 @@ func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int
fmt.Printf("%sSession created: %s%s\n", Green, result["id"], Reset)
}
func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, network string, vcpu int, files inputFiles, publicKey, secretKey string) {
func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFiles, publicKey, secretKey string) {
if serviceSnapshot != "" {
payload := map[string]interface{}{}
if serviceSnapshotName != "" {
payload["name"] = serviceSnapshotName
}
if serviceHot {
payload["hot"] = true
}
result := apiRequest("/services/"+serviceSnapshot+"/snapshot", "POST", payload, publicKey, secretKey)
fmt.Printf("%sSnapshot created%s\n", Green, Reset)
jsonData, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonData))
return
}
if serviceRestore != "" {
// --restore takes snapshot ID directly, calls /snapshots/:id/restore
result := apiRequest("/snapshots/"+serviceRestore+"/restore", "POST", nil, publicKey, secretKey)
fmt.Printf("%sService restored from snapshot%s\n", Green, Reset)
jsonData, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonData))
return
}
if serviceList != "" {
result := apiRequest("/services", "GET", nil, publicKey, secretKey)
services := result["services"].([]interface{})
@ -566,6 +614,60 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB
os.Exit(1)
}
func cmdSnapshot(snapshotList, snapshotInfo, snapshotDelete, snapshotClone, snapshotType, snapshotName, snapshotShell, snapshotPorts, publicKey, secretKey string) {
if snapshotList != "" || snapshotList == "" && snapshotInfo == "" && snapshotDelete == "" && snapshotClone == "" {
result := apiRequest("/snapshots", "GET", nil, publicKey, secretKey)
jsonData, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonData))
return
}
if snapshotInfo != "" {
result := apiRequest("/snapshots/"+snapshotInfo, "GET", nil, publicKey, secretKey)
jsonData, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonData))
return
}
if snapshotDelete != "" {
apiRequest("/snapshots/"+snapshotDelete, "DELETE", nil, publicKey, secretKey)
fmt.Printf("%sSnapshot deleted: %s%s\n", Green, snapshotDelete, Reset)
return
}
if snapshotClone != "" {
if snapshotType == "" {
fmt.Fprintf(os.Stderr, "%sError: --type required (session or service)%s\n", Red, Reset)
os.Exit(1)
}
payload := map[string]interface{}{
"type": snapshotType,
}
if snapshotName != "" {
payload["name"] = snapshotName
}
if snapshotShell != "" {
payload["shell"] = snapshotShell
}
if snapshotPorts != "" {
var ports []int
for _, p := range strings.Split(snapshotPorts, ",") {
port, _ := strconv.Atoi(strings.TrimSpace(p))
ports = append(ports, port)
}
payload["ports"] = ports
}
result := apiRequest("/snapshots/"+snapshotClone+"/clone", "POST", payload, publicKey, secretKey)
fmt.Printf("%sCreated from snapshot%s\n", Green, Reset)
jsonData, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(jsonData))
return
}
fmt.Fprintf(os.Stderr, "%sError: Use --list, --info ID, --delete ID, or --clone ID --type TYPE%s\n", Red, Reset)
os.Exit(1)
}
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
@ -647,14 +749,14 @@ func validateKey(publicKey, secretKey string, extend bool) {
valid, _ := result["valid"].(bool)
expired, _ := result["expired"].(bool)
publicKey, _ := result["public_key"].(string)
pubKey, _ := result["public_key"].(string)
tier, _ := result["tier"].(string)
status, _ := result["status"].(string)
if expired {
// Expired key
fmt.Printf("%sExpired%s\n", Red, Reset)
fmt.Printf("Public Key: %s\n", publicKey)
fmt.Printf("Public Key: %s\n", pubKey)
fmt.Printf("Tier: %s\n", tier)
if expiresAt, ok := result["expires_at"].(string); ok {
fmt.Printf("Expired: %s\n", expiresAt)
@ -662,7 +764,7 @@ func validateKey(publicKey, secretKey string, extend bool) {
fmt.Printf("%sTo renew: Visit https://unsandbox.com/keys/extend%s\n", Yellow, Reset)
if extend {
extendURL := PortalBase + "/keys/extend?pk=" + publicKey
extendURL := PortalBase + "/keys/extend?pk=" + pubKey
fmt.Printf("\n%sOpening browser to extend key...%s\n", Green, Reset)
if err := openBrowser(extendURL); err != nil {
fmt.Fprintf(os.Stderr, "%sError opening browser: %v%s\n", Red, err, Reset)
@ -675,7 +777,7 @@ func validateKey(publicKey, secretKey string, extend bool) {
if valid {
// Valid key
fmt.Printf("%sValid%s\n", Green, Reset)
fmt.Printf("Public Key: %s\n", publicKey)
fmt.Printf("Public Key: %s\n", pubKey)
fmt.Printf("Tier: %s\n", tier)
fmt.Printf("Status: %s\n", status)
@ -703,7 +805,7 @@ func validateKey(publicKey, secretKey string, extend bool) {
}
if extend {
extendURL := PortalBase + "/keys/extend?pk=" + publicKey
extendURL := PortalBase + "/keys/extend?pk=" + pubKey
fmt.Printf("\n%sOpening browser to extend key...%s\n", Green, Reset)
if err := openBrowser(extendURL); err != nil {
fmt.Fprintf(os.Stderr, "%sError opening browser: %v%s\n", Red, err, Reset)
@ -741,6 +843,10 @@ func main() {
sessionShell := sessionCmd.String("shell", "", "Shell/REPL to use")
sessionTmux := sessionCmd.Bool("tmux", false, "Enable tmux persistence")
sessionScreen := sessionCmd.Bool("screen", false, "Enable screen persistence")
sessionSnapshot := sessionCmd.String("snapshot", "", "Create snapshot of session")
sessionRestore := sessionCmd.String("restore", "", "Restore from snapshot ID")
sessionSnapshotName := sessionCmd.String("snapshot-name", "", "Name for snapshot")
sessionHot := sessionCmd.Bool("hot", false, "Take snapshot without freezing")
var sessionFiles inputFiles
sessionCmd.Var(&sessionFiles, "f", "Input file")
sessionNetwork := sessionCmd.String("n", "", "Network mode")
@ -768,10 +874,26 @@ func main() {
serviceCommand := serviceCmd.String("command", "", "Command to execute (with -execute)")
serviceDumpBootstrap := serviceCmd.String("dump-bootstrap", "", "Dump bootstrap script")
serviceDumpFile := serviceCmd.String("dump-file", "", "File to save bootstrap (with -dump-bootstrap)")
serviceSnapshot := serviceCmd.String("snapshot", "", "Create snapshot of service")
serviceRestore := serviceCmd.String("restore", "", "Restore from snapshot ID")
serviceSnapshotName := serviceCmd.String("snapshot-name", "", "Name for snapshot")
serviceHot := serviceCmd.Bool("hot", false, "Take snapshot without freezing")
serviceNetwork := serviceCmd.String("n", "", "Network mode")
serviceVcpu := serviceCmd.Int("v", 0, "vCPU count")
serviceKey := serviceCmd.String("k", "", "API key")
// Snapshot flags
snapshotCmd := flag.NewFlagSet("snapshot", flag.ExitOnError)
snapshotList := snapshotCmd.String("list", "", "List snapshots")
snapshotInfo := snapshotCmd.String("info", "", "Get snapshot info")
snapshotDelete := snapshotCmd.String("delete", "", "Delete snapshot")
snapshotClone := snapshotCmd.String("clone", "", "Clone snapshot")
snapshotType := snapshotCmd.String("type", "", "Clone type (session/service)")
snapshotName := snapshotCmd.String("name", "", "Name for cloned resource")
snapshotShell := snapshotCmd.String("shell", "", "Shell for cloned session")
snapshotPorts := snapshotCmd.String("ports", "", "Ports for cloned service")
snapshotKey := snapshotCmd.String("k", "", "API key")
// Key flags
keyCmd := flag.NewFlagSet("key", flag.ExitOnError)
keyExtend := keyCmd.Bool("extend", false, "Open browser to extend key")
@ -793,7 +915,7 @@ func main() {
if vc == 0 {
vc = *vcpu
}
cmdSession(*sessionList, *sessionKill, *sessionShell, net, vc, *sessionTmux, *sessionScreen, sessionFiles, publicKey, secretKey)
cmdSession(*sessionList, *sessionKill, *sessionShell, *sessionSnapshot, *sessionRestore, *sessionSnapshotName, *sessionHot, net, vc, *sessionTmux, *sessionScreen, sessionFiles, publicKey, secretKey)
return
case "service":
@ -807,7 +929,13 @@ func main() {
if vc == 0 {
vc = *vcpu
}
cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceBootstrapFile, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, net, vc, serviceFiles, publicKey, secretKey)
cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceBootstrapFile, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, *serviceSnapshot, *serviceRestore, *serviceSnapshotName, *serviceHot, net, vc, serviceFiles, publicKey, secretKey)
return
case "snapshot":
snapshotCmd.Parse(os.Args[2:])
publicKey, secretKey := getAPIKeys(*snapshotKey)
cmdSnapshot(*snapshotList, *snapshotInfo, *snapshotDelete, *snapshotClone, *snapshotType, *snapshotName, *snapshotShell, *snapshotPorts, publicKey, secretKey)
return
case "key":
@ -823,6 +951,7 @@ func main() {
fmt.Fprintf(os.Stderr, "Usage: %s [options] <source_file>\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s session [options]\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s service [options]\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s snapshot [options]\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s key [options]\n", os.Args[0])
os.Exit(1)
}

168
un.groovy
View file

@ -74,6 +74,11 @@ class Args {
Boolean sessionList = false
String sessionShell = null
String sessionKill = null
String sessionSnapshot = null
String sessionRestore = null
String sessionFrom = null
String sessionSnapshotName = null
Boolean sessionHot = false
Boolean serviceList = false
String serviceName = null
String servicePorts = null
@ -90,6 +95,19 @@ class Args {
String serviceCommand = null
String serviceDumpBootstrap = null
String serviceDumpFile = null
String serviceSnapshot = null
String serviceRestore = null
String serviceFrom = null
String serviceSnapshotName = null
Boolean serviceHot = false
Boolean snapshotList = false
String snapshotInfo = null
String snapshotDelete = null
String snapshotClone = null
String snapshotType = null
String snapshotName = null
String snapshotShell = null
String snapshotPorts = null
Boolean keyExtend = false
}
@ -288,6 +306,30 @@ def cmdExecute(args) {
def cmdSession(args) {
def (publicKey, secretKey) = getApiKeys(args.apiKey)
if (args.sessionSnapshot) {
def json = "{"
if (args.sessionSnapshotName) {
json += "\"name\":\"${args.sessionSnapshotName.replace('\\', '\\\\').replace('"', '\\"')}\""
}
if (args.sessionHot) {
if (args.sessionSnapshotName) json += ","
json += "\"hot\":true"
}
json += "}"
def output = apiRequest("/sessions/${args.sessionSnapshot}/snapshot", 'POST', json, publicKey, secretKey)
println("${GREEN}Snapshot created${RESET}")
println(output)
return
}
if (args.sessionRestore) {
// --restore takes snapshot ID directly, calls /snapshots/:id/restore
def output = apiRequest("/snapshots/${args.sessionRestore}/restore", 'POST', '{}', publicKey, secretKey)
println("${GREEN}Session restored from snapshot${RESET}")
println(output)
return
}
if (args.sessionList) {
def output = apiRequest('/sessions', 'GET', null, publicKey, secretKey)
println("%-40s %-10s %-10s %s".format("ID", "Shell", "Status", "Created"))
@ -351,6 +393,53 @@ def openBrowser(url) {
}
}
def cmdSnapshot(args) {
def (publicKey, secretKey) = getApiKeys(args.apiKey)
if (args.snapshotList) {
def output = apiRequest('/snapshots', 'GET', null, publicKey, secretKey)
println(output)
return
}
if (args.snapshotInfo) {
def output = apiRequest("/snapshots/${args.snapshotInfo}", 'GET', null, publicKey, secretKey)
println(output)
return
}
if (args.snapshotDelete) {
apiRequest("/snapshots/${args.snapshotDelete}", 'DELETE', null, publicKey, secretKey)
println("${GREEN}Snapshot deleted: ${args.snapshotDelete}${RESET}")
return
}
if (args.snapshotClone) {
if (!args.snapshotType) {
System.err.println("${RED}Error: --type required (session or service)${RESET}")
System.exit(1)
}
def json = "{\"type\":\"${args.snapshotType}\""
if (args.snapshotName) {
json += ",\"name\":\"${args.snapshotName.replace('\\', '\\\\').replace('"', '\\"')}\""
}
if (args.snapshotShell) {
json += ",\"shell\":\"${args.snapshotShell}\""
}
if (args.snapshotPorts) {
json += ",\"ports\":[${args.snapshotPorts}]"
}
json += "}"
def output = apiRequest("/snapshots/${args.snapshotClone}/clone", 'POST', json, publicKey, secretKey)
println("${GREEN}Created from snapshot${RESET}")
println(output)
return
}
System.err.println("Error: Use --list, --info ID, --delete ID, or --clone ID --type TYPE")
System.exit(1)
}
def cmdKey(args) {
def (publicKey, secretKey) = getApiKeys(args.apiKey)
@ -435,6 +524,30 @@ def cmdKey(args) {
def cmdService(args) {
def (publicKey, secretKey) = getApiKeys(args.apiKey)
if (args.serviceSnapshot) {
def json = "{"
if (args.serviceSnapshotName) {
json += "\"name\":\"${args.serviceSnapshotName.replace('\\', '\\\\').replace('"', '\\"')}\""
}
if (args.serviceHot) {
if (args.serviceSnapshotName) json += ","
json += "\"hot\":true"
}
json += "}"
def output = apiRequest("/services/${args.serviceSnapshot}/snapshot", 'POST', json, publicKey, secretKey)
println("${GREEN}Snapshot created${RESET}")
println(output)
return
}
if (args.serviceRestore) {
// --restore takes snapshot ID directly, calls /snapshots/:id/restore
def output = apiRequest("/snapshots/${args.serviceRestore}/restore", 'POST', '{}', publicKey, secretKey)
println("${GREEN}Service restored from snapshot${RESET}")
println(output)
return
}
if (args.serviceList) {
def output = apiRequest('/services', 'GET', null, publicKey, secretKey)
println("%-20s %-15s %-10s %-15s %s".format("ID", "Name", "Status", "Ports", "Domains"))
@ -619,6 +732,9 @@ def parseArgs(argv) {
case 'service':
args.command = 'service'
break
case 'snapshot':
args.command = 'snapshot'
break
case 'key':
args.command = 'key'
break
@ -662,14 +778,51 @@ def parseArgs(argv) {
case '--kill':
args.sessionKill = argv[++i]
break
case '--name':
args.serviceName = argv[++i]
case '--snapshot':
if (args.command == 'session') args.sessionSnapshot = argv[++i]
else if (args.command == 'service') args.serviceSnapshot = argv[++i]
break
case '--ports':
args.servicePorts = argv[++i]
case '--restore':
if (args.command == 'session') args.sessionRestore = argv[++i]
else if (args.command == 'service') args.serviceRestore = argv[++i]
break
case '--from':
if (args.command == 'session') args.sessionFrom = argv[++i]
else if (args.command == 'service') args.serviceFrom = argv[++i]
break
case '--snapshot-name':
if (args.command == 'session') args.sessionSnapshotName = argv[++i]
else if (args.command == 'service') args.serviceSnapshotName = argv[++i]
break
case '--hot':
if (args.command == 'session') args.sessionHot = true
else if (args.command == 'service') args.serviceHot = true
break
case '--info':
if (args.command == 'snapshot') args.snapshotInfo = argv[++i]
else args.serviceInfo = argv[++i]
break
case '--delete':
if (args.command == 'snapshot') args.snapshotDelete = argv[++i]
break
case '--clone':
args.snapshotClone = argv[++i]
break
case '--type':
args.serviceType = argv[++i]
if (args.command == 'snapshot') args.snapshotType = argv[++i]
else args.serviceType = argv[++i]
break
case '--name':
if (args.command == 'snapshot') args.snapshotName = argv[++i]
else args.serviceName = argv[++i]
break
case '--shell':
if (args.command == 'snapshot') args.snapshotShell = argv[++i]
else args.sessionShell = argv[++i]
break
case '--ports':
if (args.command == 'snapshot') args.snapshotPorts = argv[++i]
else args.servicePorts = argv[++i]
break
case '--bootstrap':
args.serviceBootstrap = argv[++i]
@ -677,9 +830,6 @@ def parseArgs(argv) {
case '--bootstrap-file':
args.serviceBootstrapFile = argv[++i]
break
case '--info':
args.serviceInfo = argv[++i]
break
case '--logs':
args.serviceLogs = argv[++i]
break
@ -774,6 +924,8 @@ try {
cmdSession(args)
} else if (args.command == 'service') {
cmdService(args)
} else if (args.command == 'snapshot') {
cmdSnapshot(args)
} else if (args.command == 'key') {
cmdKey(args)
} else if (args.sourceFile) {

132
un.hs
View file

@ -116,7 +116,7 @@ escapeJSON = concatMap escape
escape c = [c]
-- Parse command line arguments
data Command = Execute ExecuteOpts | Session SessionOpts | Service ServiceOpts | Key KeyOpts | Help
data Command = Execute ExecuteOpts | Session SessionOpts | Service ServiceOpts | Key KeyOpts | Snapshot SnapshotOpts | Help
data ExecuteOpts = ExecuteOpts
{ exFile :: String
@ -134,9 +134,13 @@ data SessionOpts = SessionOpts
, sessNetwork :: Maybe String
, sessVcpu :: Maybe Int
, sessFiles :: [String]
, sessSnapshotName :: Maybe String
, sessSnapshotFrom :: Maybe String
, sessHot :: Bool
}
data SessionAction = SessionList | SessionKill String | SessionCreate
| SessionSnapshot String | SessionRestore String
data ServiceOpts = ServiceOpts
{ svcAction :: ServiceAction
@ -148,12 +152,25 @@ data ServiceOpts = ServiceOpts
, svcNetwork :: Maybe String
, svcVcpu :: Maybe Int
, svcFiles :: [String]
, svcSnapshotName :: Maybe String
, svcSnapshotFrom :: Maybe String
, svcHot :: Bool
}
data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String
| ServiceSleep String | ServiceWake String | ServiceDestroy String
| ServiceExecute String String | ServiceDumpBootstrap String (Maybe String)
| ServiceCreate
| ServiceCreate | ServiceSnapshot String | ServiceRestore String
data SnapshotOpts = SnapshotOpts
{ snapAction :: SnapshotAction
, snapCloneType :: Maybe String
, snapCloneName :: Maybe String
, snapClonePorts :: Maybe String
}
data SnapshotAction = SnapshotList | SnapshotInfo String | SnapshotDelete String
| SnapshotClone String
data KeyOpts = KeyOpts
{ keyExtend :: Bool
@ -164,6 +181,7 @@ parseArgs :: [String] -> IO Command
parseArgs ("session":rest) = Session <$> parseSession rest
parseArgs ("service":rest) = Service <$> parseService rest
parseArgs ("key":rest) = Key <$> parseKey rest
parseArgs ("snapshot":rest) = Snapshot <$> parseSnapshot rest
parseArgs args = parseExecute args
parseKey :: [String] -> IO KeyOpts
@ -174,15 +192,35 @@ parseKey args = return $ parseKeyArgs args defaultKeyOpts
parseKeyArgs ("--extend":rest) opts = parseKeyArgs rest opts { keyExtend = True }
parseKeyArgs (_:rest) opts = parseKeyArgs rest opts
parseSnapshot :: [String] -> IO SnapshotOpts
parseSnapshot args = return $ parseSnapshotArgs args defaultSnapshotOpts
where
defaultSnapshotOpts = SnapshotOpts SnapshotList Nothing Nothing Nothing
parseSnapshotArgs [] opts = opts
parseSnapshotArgs ("--list":rest) opts = parseSnapshotArgs rest opts { snapAction = SnapshotList }
parseSnapshotArgs ("-l":rest) opts = parseSnapshotArgs rest opts { snapAction = SnapshotList }
parseSnapshotArgs ("--info":id:rest) opts = parseSnapshotArgs rest opts { snapAction = SnapshotInfo id }
parseSnapshotArgs ("--delete":id:rest) opts = parseSnapshotArgs rest opts { snapAction = SnapshotDelete id }
parseSnapshotArgs ("--clone":id:rest) opts = parseSnapshotArgs rest opts { snapAction = SnapshotClone id }
parseSnapshotArgs ("--type":t:rest) opts = parseSnapshotArgs rest opts { snapCloneType = Just t }
parseSnapshotArgs ("--name":n:rest) opts = parseSnapshotArgs rest opts { snapCloneName = Just n }
parseSnapshotArgs ("--ports":p:rest) opts = parseSnapshotArgs rest opts { snapClonePorts = Just p }
parseSnapshotArgs (_:rest) opts = parseSnapshotArgs rest opts
parseSession :: [String] -> IO SessionOpts
parseSession args = return $ parseSessionArgs args defaultSessionOpts
where
defaultSessionOpts = SessionOpts SessionCreate Nothing Nothing Nothing []
defaultSessionOpts = SessionOpts SessionCreate Nothing Nothing Nothing [] Nothing Nothing False
parseSessionArgs [] opts = opts
parseSessionArgs ("--list":rest) opts = parseSessionArgs rest opts { sessAction = SessionList }
parseSessionArgs ("--kill":id:rest) opts = parseSessionArgs rest opts { sessAction = SessionKill id }
parseSessionArgs ("--snapshot":id:rest) opts = parseSessionArgs rest opts { sessAction = SessionSnapshot id }
parseSessionArgs ("--restore":id:rest) opts = parseSessionArgs rest opts { sessAction = SessionRestore id }
parseSessionArgs ("--shell":sh:rest) opts = parseSessionArgs rest opts { sessShell = Just sh }
parseSessionArgs ("-s":sh:rest) opts = parseSessionArgs rest opts { sessShell = Just sh }
parseSessionArgs ("--snapshot-name":n:rest) opts = parseSessionArgs rest opts { sessSnapshotName = Just n }
parseSessionArgs ("--from":f:rest) opts = parseSessionArgs rest opts { sessSnapshotFrom = Just f }
parseSessionArgs ("--hot":rest) opts = parseSessionArgs rest opts { sessHot = True }
parseSessionArgs ("-n":net:rest) opts = parseSessionArgs rest opts { sessNetwork = Just net }
parseSessionArgs ("-v":v:rest) opts = parseSessionArgs rest opts { sessVcpu = Just (read v) }
parseSessionArgs ("-f":f:rest) opts = parseSessionArgs rest opts { sessFiles = sessFiles opts ++ [f] }
@ -191,7 +229,7 @@ parseSession args = return $ parseSessionArgs args defaultSessionOpts
parseService :: [String] -> IO ServiceOpts
parseService args = return $ parseServiceArgs args defaultServiceOpts
where
defaultServiceOpts = ServiceOpts ServiceCreate Nothing Nothing Nothing Nothing Nothing Nothing Nothing []
defaultServiceOpts = ServiceOpts ServiceCreate Nothing Nothing Nothing Nothing Nothing Nothing Nothing [] Nothing Nothing False
parseServiceArgs [] opts = opts
parseServiceArgs ("--list":rest) opts = parseServiceArgs rest opts { svcAction = ServiceList }
parseServiceArgs ("--info":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceInfo id }
@ -199,6 +237,8 @@ parseService args = return $ parseServiceArgs args defaultServiceOpts
parseServiceArgs ("--freeze":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceSleep id }
parseServiceArgs ("--unfreeze":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceWake id }
parseServiceArgs ("--destroy":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDestroy id }
parseServiceArgs ("--snapshot":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceSnapshot id }
parseServiceArgs ("--restore":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceRestore id }
parseServiceArgs ("--execute":id:"--command":cmd:rest) opts = parseServiceArgs rest opts { svcAction = ServiceExecute id cmd }
parseServiceArgs ("--dump-bootstrap":id:file:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id (Just file) }
parseServiceArgs ("--dump-bootstrap":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id Nothing }
@ -207,6 +247,9 @@ parseService args = return $ parseServiceArgs args defaultServiceOpts
parseServiceArgs ("--type":t:rest) opts = parseServiceArgs rest opts { svcType = Just t }
parseServiceArgs ("--bootstrap":b:rest) opts = parseServiceArgs rest opts { svcBootstrap = Just b }
parseServiceArgs ("--bootstrap-file":f:rest) opts = parseServiceArgs rest opts { svcBootstrapFile = Just f }
parseServiceArgs ("--snapshot-name":n:rest) opts = parseServiceArgs rest opts { svcSnapshotName = Just n }
parseServiceArgs ("--from":f:rest) opts = parseServiceArgs rest opts { svcSnapshotFrom = Just f }
parseServiceArgs ("--hot":rest) opts = parseServiceArgs rest opts { svcHot = True }
parseServiceArgs ("-n":net:rest) opts = parseServiceArgs rest opts { svcNetwork = Just net }
parseServiceArgs ("-v":v:rest) opts = parseServiceArgs rest opts { svcVcpu = Just (read v) }
parseServiceArgs ("-f":f:rest) opts = parseServiceArgs rest opts { svcFiles = svcFiles opts ++ [f] }
@ -246,6 +289,7 @@ main = do
Session opts -> sessionCommand opts
Service opts -> serviceCommand opts
Key opts -> keyCommand opts
Snapshot opts -> snapshotCommand opts
Help -> printHelp
printHelp :: IO ()
@ -254,6 +298,7 @@ printHelp = do
putStrLn " un.hs [options] <source_file> Execute code"
putStrLn " un.hs session [options] Manage sessions"
putStrLn " un.hs service [options] Manage services"
putStrLn " un.hs snapshot [options] Manage snapshots"
putStrLn " un.hs key [options] Validate/extend API key"
putStrLn ""
putStrLn "Execute options:"
@ -264,6 +309,29 @@ printHelp = do
putStrLn " -n MODE Network mode (zerotrust|semitrusted)"
putStrLn " -v N vCPU count (1-8)"
putStrLn ""
putStrLn "Session snapshot options:"
putStrLn " --snapshot ID Create snapshot of session"
putStrLn " --restore ID Restore session from snapshot"
putStrLn " --from SNAPSHOT_ID Snapshot ID to restore from"
putStrLn " --snapshot-name NAME Optional snapshot name"
putStrLn " --hot Take live snapshot without freezing"
putStrLn ""
putStrLn "Service snapshot options:"
putStrLn " --snapshot ID Create snapshot of service"
putStrLn " --restore ID Restore service from snapshot"
putStrLn " --from SNAPSHOT_ID Snapshot ID to restore from"
putStrLn " --snapshot-name NAME Optional snapshot name"
putStrLn " --hot Take live snapshot without freezing"
putStrLn ""
putStrLn "Snapshot management options:"
putStrLn " -l, --list List all snapshots"
putStrLn " --info ID Get snapshot details"
putStrLn " --delete ID Delete a snapshot"
putStrLn " --clone ID Clone snapshot to new session/service"
putStrLn " --type TYPE Type for clone (session|service)"
putStrLn " --name NAME Name for cloned instance"
putStrLn " --ports PORTS Ports for cloned service"
putStrLn ""
putStrLn "Key options:"
putStrLn " --extend Open browser to extend/renew key"
exitFailure
@ -322,6 +390,20 @@ sessionCommand opts = do
SessionKill sid -> do
(_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/sessions/" ++ sid)
putStrLn $ green ++ "Session terminated: " ++ sid ++ reset
SessionSnapshot sid -> do
let nameJSON = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\",") (sessSnapshotName opts)
let hotJSON = if sessHot opts then "\"hot\":true" else "\"hot\":false"
let json = "{" ++ nameJSON ++ hotJSON ++ "}"
hPutStrLn stderr $ "Creating snapshot of session " ++ sid ++ "..."
(_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/sessions/" ++ sid ++ "/snapshot") json
putStrLn $ green ++ "Snapshot created" ++ reset
putStrLn stdout
SessionRestore snapshotId -> do
-- --restore takes snapshot ID directly, calls /snapshots/:id/restore
hPutStrLn stderr $ "Restoring from snapshot " ++ snapshotId ++ "..."
(_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/snapshots/" ++ snapshotId ++ "/restore") "{}"
putStrLn $ green ++ "Session restored from snapshot" ++ reset
putStrLn stdout
SessionCreate -> do
let shell = maybe "bash" id (sessShell opts)
let networkJSON = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") (sessNetwork opts)
@ -387,6 +469,20 @@ serviceCommand opts = do
_ -> do
hPutStrLn stderr $ red ++ "Error: Failed to fetch bootstrap (service not running or no bootstrap file)" ++ reset
exitFailure
ServiceSnapshot sid -> do
let nameJSON = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\",") (svcSnapshotName opts)
let hotJSON = if svcHot opts then "\"hot\":true" else "\"hot\":false"
let json = "{" ++ nameJSON ++ hotJSON ++ "}"
hPutStrLn stderr $ "Creating snapshot of service " ++ sid ++ "..."
(_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/services/" ++ sid ++ "/snapshot") json
putStrLn $ green ++ "Snapshot created" ++ reset
putStrLn stdout
ServiceRestore snapshotId -> do
-- --restore takes snapshot ID directly, calls /snapshots/:id/restore
hPutStrLn stderr $ "Restoring from snapshot " ++ snapshotId ++ "..."
(_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/snapshots/" ++ snapshotId ++ "/restore") "{}"
putStrLn $ green ++ "Service restored from snapshot" ++ reset
putStrLn stdout
ServiceCreate -> do
case svcName opts of
Nothing -> do
@ -555,6 +651,34 @@ extractJsonString json field =
tails [] = [[]]
tails s@(_:xs) = s : tails xs
-- Snapshot command
snapshotCommand :: SnapshotOpts -> IO ()
snapshotCommand opts = do
apiKey <- getApiKey
case snapAction opts of
SnapshotList -> do
(_, stdout, _) <- curlGet apiKey "https://api.unsandbox.com/snapshots"
putStrLn stdout
SnapshotInfo sid -> do
(_, stdout, _) <- curlGet apiKey ("https://api.unsandbox.com/snapshots/" ++ sid)
putStrLn stdout
SnapshotDelete sid -> do
(_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/snapshots/" ++ sid)
putStrLn $ green ++ "Snapshot deleted: " ++ sid ++ reset
SnapshotClone sid -> do
case snapCloneType opts of
Nothing -> do
hPutStrLn stderr "Error: --type (session|service) required for clone"
exitFailure
Just cloneType -> do
let nameJSON = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\",") (snapCloneName opts)
let portsJSON = maybe "" (\p -> "\"ports\":[" ++ p ++ "],") (snapClonePorts opts)
let json = "{\"type\":\"" ++ cloneType ++ "\"," ++ nameJSON ++ portsJSON ++ "}"
hPutStrLn stderr $ "Cloning snapshot " ++ sid ++ " to create new " ++ cloneType ++ "..."
(_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/snapshots/" ++ sid ++ "/clone") json
putStrLn $ green ++ "Snapshot cloned" ++ reset
putStrLn stdout
-- Key command
keyCommand :: KeyOpts -> IO ()
keyCommand opts = do

153
un.m
View file

@ -454,6 +454,11 @@ void cmdSession(NSArray* args) {
NSString* network = nil;
int vcpu = 0;
NSMutableArray* inputFiles = [NSMutableArray array];
NSString* snapshotId = nil;
NSString* restoreId = nil;
NSString* fromSnapshot = nil;
NSString* snapshotName = nil;
BOOL hotSnapshot = NO;
// Parse arguments
for (NSUInteger i = 0; i < [args count]; i++) {
@ -462,6 +467,16 @@ void cmdSession(NSArray* args) {
listMode = YES;
} else if ([arg isEqualToString:@"--kill"] && i + 1 < [args count]) {
killId = args[++i];
} else if ([arg isEqualToString:@"--snapshot"] && i + 1 < [args count]) {
snapshotId = args[++i];
} else if ([arg isEqualToString:@"--restore"] && i + 1 < [args count]) {
restoreId = args[++i];
} else if ([arg isEqualToString:@"--from"] && i + 1 < [args count]) {
fromSnapshot = args[++i];
} else if ([arg isEqualToString:@"--snapshot-name"] && i + 1 < [args count]) {
snapshotName = args[++i];
} else if ([arg isEqualToString:@"--hot"]) {
hotSnapshot = YES;
} else if ([arg isEqualToString:@"--shell"] && i + 1 < [args count]) {
shell = args[++i];
} else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) {
@ -498,6 +513,26 @@ void cmdSession(NSArray* args) {
return;
}
if (snapshotId) {
fprintf(stderr, "Creating snapshot of session %s...\n", [snapshotId UTF8String]);
NSMutableDictionary* payload = [NSMutableDictionary dictionary];
if (snapshotName) payload[@"name"] = snapshotName;
if (hotSnapshot) payload[@"hot"] = @YES;
NSString* endpoint = [NSString stringWithFormat:@"/sessions/%@/snapshot", snapshotId];
NSDictionary* result = apiRequest(endpoint, @"POST", payload, publicKey, secretKey);
printf("%sSnapshot created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]);
return;
}
if (restoreId) {
// --restore takes snapshot ID directly, calls /snapshots/:id/restore
fprintf(stderr, "Restoring from snapshot %s...\n", [restoreId UTF8String]);
NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/restore", restoreId];
apiRequest(endpoint, @"POST", @{}, publicKey, secretKey);
printf("%sSession restored from snapshot%s\n", [GREEN UTF8String], [RESET UTF8String]);
return;
}
// Create new session
NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{
@"shell": shell ?: @"bash"
@ -551,6 +586,11 @@ void cmdService(NSArray* args) {
NSString* network = nil;
int vcpu = 0;
NSMutableArray* inputFiles = [NSMutableArray array];
NSString* snapshotId = nil;
NSString* restoreId = nil;
NSString* fromSnapshot = nil;
NSString* snapshotName = nil;
BOOL hotSnapshot = NO;
// Parse arguments
for (NSUInteger i = 0; i < [args count]; i++) {
@ -571,6 +611,16 @@ void cmdService(NSArray* args) {
dumpBootstrapId = args[++i];
} else if ([arg isEqualToString:@"--dump-file"] && i + 1 < [args count]) {
dumpFile = args[++i];
} else if ([arg isEqualToString:@"--snapshot"] && i + 1 < [args count]) {
snapshotId = args[++i];
} else if ([arg isEqualToString:@"--restore"] && i + 1 < [args count]) {
restoreId = args[++i];
} else if ([arg isEqualToString:@"--from"] && i + 1 < [args count]) {
fromSnapshot = args[++i];
} else if ([arg isEqualToString:@"--snapshot-name"] && i + 1 < [args count]) {
snapshotName = args[++i];
} else if ([arg isEqualToString:@"--hot"]) {
hotSnapshot = YES;
} else if ([arg isEqualToString:@"--name"] && i + 1 < [args count]) {
name = args[++i];
} else if ([arg isEqualToString:@"--ports"] && i + 1 < [args count]) {
@ -683,6 +733,26 @@ void cmdService(NSArray* args) {
return;
}
if (snapshotId) {
fprintf(stderr, "Creating snapshot of service %s...\n", [snapshotId UTF8String]);
NSMutableDictionary* payload = [NSMutableDictionary dictionary];
if (snapshotName) payload[@"name"] = snapshotName;
if (hotSnapshot) payload[@"hot"] = @YES;
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/snapshot", snapshotId];
NSDictionary* result = apiRequest(endpoint, @"POST", payload, publicKey, secretKey);
printf("%sSnapshot created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]);
return;
}
if (restoreId) {
// --restore takes snapshot ID directly, calls /snapshots/:id/restore
fprintf(stderr, "Restoring from snapshot %s...\n", [restoreId UTF8String]);
NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/restore", restoreId];
apiRequest(endpoint, @"POST", @{}, publicKey, secretKey);
printf("%sService restored from snapshot%s\n", [GREEN UTF8String], [RESET UTF8String]);
return;
}
// Create new service
if (name) {
NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{@"name": name}];
@ -753,12 +823,93 @@ void cmdService(NSArray* args) {
exit(1);
}
void cmdSnapshot(NSArray* args) {
NSString* publicKey, *secretKey;
getApiKeys(&publicKey, &secretKey);
BOOL listMode = NO;
NSString* infoId = nil;
NSString* deleteId = nil;
NSString* cloneId = nil;
NSString* cloneType = nil;
NSString* cloneName = nil;
for (NSUInteger i = 0; i < [args count]; i++) {
NSString* arg = args[i];
if ([arg isEqualToString:@"--list"] || [arg isEqualToString:@"-l"]) {
listMode = YES;
} else if ([arg isEqualToString:@"--info"] && i + 1 < [args count]) {
infoId = args[++i];
} else if ([arg isEqualToString:@"--delete"] && i + 1 < [args count]) {
deleteId = args[++i];
} else if ([arg isEqualToString:@"--clone"] && i + 1 < [args count]) {
cloneId = args[++i];
} else if ([arg isEqualToString:@"--type"] && i + 1 < [args count]) {
cloneType = args[++i];
} else if ([arg isEqualToString:@"--name"] && i + 1 < [args count]) {
cloneName = args[++i];
}
}
if (listMode) {
NSDictionary* result = apiRequest(@"/snapshots", @"GET", nil, publicKey, secretKey);
NSArray* snapshots = result[@"snapshots"];
if ([snapshots count] == 0) {
printf("No snapshots found\n");
} else {
printf("%-40s %-20s %-12s %-30s\n", "SNAPSHOT ID", "NAME", "SOURCE TYPE", "SOURCE ID");
for (NSDictionary* s in snapshots) {
printf("%-40s %-20s %-12s %-30s\n",
[s[@"id"] UTF8String],
[s[@"name"] UTF8String] ?: "-",
[s[@"source_type"] UTF8String],
[s[@"source_id"] UTF8String]);
}
}
return;
}
if (infoId) {
NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@", infoId];
NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey);
NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
printf("%s\n", [jsonString UTF8String]);
return;
}
if (deleteId) {
NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@", deleteId];
apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey);
printf("%sSnapshot deleted successfully%s\n", [GREEN UTF8String], [RESET UTF8String]);
return;
}
if (cloneId) {
if (!cloneType) {
fprintf(stderr, "%sError: --type required with --clone (session or service)%s\n", [RED UTF8String], [RESET UTF8String]);
exit(1);
}
NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{@"type": cloneType}];
if (cloneName) payload[@"name"] = cloneName;
NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/clone", cloneId];
NSDictionary* result = apiRequest(endpoint, @"POST", payload, publicKey, secretKey);
printf("%s%s created from snapshot: %s%s\n", [GREEN UTF8String],
[cloneType UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]);
return;
}
fprintf(stderr, "%sError: No snapshot action specified. Use --list, --info, --delete, or --clone%s\n",
[RED UTF8String], [RESET UTF8String]);
exit(1);
}
int main(int argc, const char* argv[]) {
@autoreleasepool {
if (argc < 2) {
fprintf(stderr, "Usage: un.m [options] <source_file>\n");
fprintf(stderr, " un.m session [options]\n");
fprintf(stderr, " un.m service [options]\n");
fprintf(stderr, " un.m snapshot [options]\n");
fprintf(stderr, " un.m key [options]\n");
return 1;
}
@ -774,6 +925,8 @@ int main(int argc, const char* argv[]) {
cmdSession([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]);
} else if ([firstArg isEqualToString:@"service"]) {
cmdService([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]);
} else if ([firstArg isEqualToString:@"snapshot"]) {
cmdSnapshot([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]);
} else if ([firstArg isEqualToString:@"key"]) {
cmdKey([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]);
} else {

128
un.py
View file

@ -296,6 +296,28 @@ def cmd_session(args):
print(f"{GREEN}Session terminated: {args.kill}{RESET}")
return
if args.snapshot:
payload = {}
if args.snapshot_name:
payload["name"] = args.snapshot_name
if args.hot:
payload["hot"] = True
print(f"{YELLOW}Creating snapshot of session {args.snapshot}...{RESET}", file=sys.stderr)
result = api_request(f"/sessions/{args.snapshot}/snapshot", method="POST", data=payload, public_key=public_key, secret_key=secret_key)
print(f"{GREEN}Snapshot created successfully{RESET}")
print(f"Snapshot ID: {result.get('id', 'N/A')}")
return
if args.restore:
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
print(f"{YELLOW}Restoring from snapshot {args.restore}...{RESET}", file=sys.stderr)
result = api_request(f"/snapshots/{args.restore}/restore", method="POST", public_key=public_key, secret_key=secret_key)
print(f"{GREEN}Session restored from snapshot{RESET}")
if result.get('session_id'):
print(f"New session ID: {result.get('session_id')}")
return
if args.attach:
print(f"{YELLOW}Attaching to session {args.attach}...{RESET}")
print(f"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}")
@ -420,6 +442,68 @@ def cmd_key(args):
validate_key(public_key, secret_key, extend=args.extend)
def cmd_snapshot(args):
"""Manage snapshots"""
public_key, secret_key = get_api_keys(args.api_key)
if args.list:
result = api_request("/snapshots", public_key=public_key, secret_key=secret_key)
snapshots = result.get("snapshots", [])
if not snapshots:
print("No snapshots found")
else:
print(f"{'ID':<40} {'Name':<20} {'Type':<12} {'Source ID':<30} {'Size':<10}")
for s in snapshots:
print(f"{s.get('id', 'N/A'):<40} {s.get('name', '-'):<20} {s.get('source_type', 'N/A'):<12} {s.get('source_id', 'N/A'):<30} {s.get('size', 'N/A'):<10}")
return
if args.info:
result = api_request(f"/snapshots/{args.info}", public_key=public_key, secret_key=secret_key)
print(f"{BLUE}Snapshot Details{RESET}\n")
print(f"Snapshot ID: {result.get('id', 'N/A')}")
print(f"Name: {result.get('name', '-')}")
print(f"Source Type: {result.get('source_type', 'N/A')}")
print(f"Source ID: {result.get('source_id', 'N/A')}")
print(f"Size: {result.get('size', 'N/A')}")
print(f"Created: {result.get('created_at', 'N/A')}")
print(f"Hot Snapshot: {result.get('hot', 'N/A')}")
return
if args.delete:
result = api_request(f"/snapshots/{args.delete}", method="DELETE", public_key=public_key, secret_key=secret_key)
print(f"{GREEN}Snapshot deleted successfully{RESET}")
return
if args.clone:
if not args.type:
print(f"{RED}Error: --type required for --clone (session or service){RESET}", file=sys.stderr)
sys.exit(1)
if args.type not in ["session", "service"]:
print(f"{RED}Error: --type must be 'session' or 'service'{RESET}", file=sys.stderr)
sys.exit(1)
payload = {"type": args.type}
if args.name:
payload["name"] = args.name
if args.shell:
payload["shell"] = args.shell
if args.ports:
payload["ports"] = [int(p) for p in args.ports.split(',')]
result = api_request(f"/snapshots/{args.clone}/clone", method="POST", data=payload, public_key=public_key, secret_key=secret_key)
if args.type == "session":
print(f"{GREEN}Session created from snapshot{RESET}")
print(f"Session ID: {result.get('id', 'N/A')}")
else:
print(f"{GREEN}Service created from snapshot{RESET}")
print(f"Service ID: {result.get('id', 'N/A')}")
return
print(f"{RED}Error: Specify --list, --info ID, --delete ID, or --clone ID --type TYPE{RESET}", file=sys.stderr)
sys.exit(1)
def cmd_service(args):
"""Manage persistent services"""
public_key, secret_key = get_api_keys(args.api_key)
@ -467,6 +551,28 @@ def cmd_service(args):
print(f"{GREEN}Service destroyed: {args.destroy}{RESET}")
return
if args.snapshot:
payload = {}
if args.snapshot_name:
payload["name"] = args.snapshot_name
if args.hot:
payload["hot"] = True
print(f"{YELLOW}Creating snapshot of service {args.snapshot}...{RESET}", file=sys.stderr)
result = api_request(f"/services/{args.snapshot}/snapshot", method="POST", data=payload, public_key=public_key, secret_key=secret_key)
print(f"{GREEN}Snapshot created successfully{RESET}")
print(f"Snapshot ID: {result.get('id', 'N/A')}")
return
if args.restore:
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
print(f"{YELLOW}Restoring from snapshot {args.restore}...{RESET}", file=sys.stderr)
result = api_request(f"/snapshots/{args.restore}/restore", method="POST", public_key=public_key, secret_key=secret_key)
print(f"{GREEN}Service restored from snapshot{RESET}")
if result.get('service_id'):
print(f"New service ID: {result.get('service_id')}")
return
if args.execute:
payload = {"command": args.command}
result = api_request(f"/services/{args.execute}/execute", method="POST", data=payload, public_key=public_key, secret_key=secret_key)
@ -586,6 +692,10 @@ Examples:
session_parser.add_argument("-l", "--list", action="store_true", help="List active sessions")
session_parser.add_argument("--attach", metavar="ID", help="Reconnect to session")
session_parser.add_argument("--kill", metavar="ID", help="Terminate session")
session_parser.add_argument("--snapshot", metavar="SESSION_ID", help="Create snapshot of session")
session_parser.add_argument("--restore", metavar="SNAPSHOT_ID", help="Restore from snapshot ID")
session_parser.add_argument("--snapshot-name", metavar="NAME", help="Name for snapshot")
session_parser.add_argument("--hot", action="store_true", help="Hot snapshot (no freeze)")
session_parser.add_argument("--audit", action="store_true", help="Record session")
session_parser.add_argument("--tmux", action="store_true", help="Enable tmux persistence")
session_parser.add_argument("--screen", action="store_true", help="Enable screen persistence")
@ -610,6 +720,10 @@ Examples:
service_parser.add_argument("--freeze", metavar="ID", help="Freeze service")
service_parser.add_argument("--unfreeze", metavar="ID", help="Unfreeze service")
service_parser.add_argument("--destroy", metavar="ID", help="Destroy service")
service_parser.add_argument("--snapshot", metavar="SERVICE_ID", help="Create snapshot of service")
service_parser.add_argument("--restore", metavar="SNAPSHOT_ID", help="Restore from snapshot ID")
service_parser.add_argument("--snapshot-name", metavar="NAME", help="Name for snapshot")
service_parser.add_argument("--hot", action="store_true", help="Hot snapshot (no freeze)")
service_parser.add_argument("--execute", metavar="ID", help="Execute command in service")
service_parser.add_argument("--command", help="Command to execute (with --execute)")
service_parser.add_argument("--dump-bootstrap", metavar="ID", help="Dump bootstrap script")
@ -618,6 +732,18 @@ Examples:
service_parser.add_argument("-v", "--vcpu", type=int, choices=range(1, 9))
service_parser.add_argument("-k", "--api-key")
# Snapshot subcommand
snapshot_parser = subparsers.add_parser("snapshot", help="Manage container snapshots")
snapshot_parser.add_argument("-l", "--list", action="store_true", help="List all snapshots")
snapshot_parser.add_argument("--info", metavar="ID", help="Get snapshot details")
snapshot_parser.add_argument("--delete", metavar="ID", help="Delete a snapshot")
snapshot_parser.add_argument("--clone", metavar="ID", help="Clone snapshot to new session/service")
snapshot_parser.add_argument("--type", help="Type for clone (session or service)")
snapshot_parser.add_argument("--name", help="Name for cloned session/service")
snapshot_parser.add_argument("--shell", help="Shell for cloned session")
snapshot_parser.add_argument("--ports", help="Ports for cloned service")
snapshot_parser.add_argument("-k", "--api-key")
# Execute options (default command)
parser.add_argument("source_file", nargs="?", help="Source file to execute")
parser.add_argument("-e", "--env", action="append", metavar="KEY=VALUE", help="Set environment variable")
@ -634,6 +760,8 @@ Examples:
cmd_session(args)
elif args.command == "service":
cmd_service(args)
elif args.command == "snapshot":
cmd_snapshot(args)
elif args.source_file:
cmd_execute(args)
else:

185
un.r
View file

@ -281,6 +281,30 @@ cmd_session <- function(args) {
return()
}
if (!is.null(args$snapshot_id)) {
payload <- list()
if (!is.null(args$snapshot_name)) {
payload$name <- args$snapshot_name
}
if (!is.null(args$hot) && args$hot) {
payload$hot <- TRUE
}
cat(sprintf("%sCreating snapshot of session %s...%s\n", YELLOW, args$snapshot_id, RESET), file = stderr())
result <- api_request(paste0("/sessions/", args$snapshot_id, "/snapshot"), public_key, secret_key, method = "POST", data = payload)
cat(sprintf("%sSnapshot created successfully%s\n", GREEN, RESET))
cat(sprintf("Snapshot ID: %s\n", if (!is.null(result$id)) result$id else "N/A"))
return()
}
if (!is.null(args$restore_id)) {
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
cat(sprintf("%sRestoring from snapshot %s...%s\n", YELLOW, args$restore_id, RESET), file = stderr())
result <- api_request(paste0("/snapshots/", args$restore_id, "/restore"), public_key, secret_key, method = "POST", data = list())
cat(sprintf("%sSession restored from snapshot%s\n", GREEN, RESET))
return()
}
# Create new session
payload <- list(shell = "bash")
@ -412,6 +436,86 @@ cmd_key <- function(args) {
})
}
cmd_snapshot <- function(args) {
keys <- get_api_keys(args$api_key)
public_key <- keys$public_key
secret_key <- keys$secret_key
if (!is.null(args$list) && args$list) {
result <- api_request("/snapshots", public_key, secret_key)
snapshots <- if (!is.null(result$snapshots)) result$snapshots else list()
if (length(snapshots) == 0) {
cat("No snapshots found\n")
} else {
cat(sprintf("%-40s %-20s %-12s %-30s %s\n", "ID", "Name", "Type", "Source ID", "Size"))
for (s in snapshots) {
cat(sprintf("%-40s %-20s %-12s %-30s %s\n",
if (!is.null(s$id)) s$id else "N/A",
if (!is.null(s$name)) s$name else "-",
if (!is.null(s$source_type)) s$source_type else "N/A",
if (!is.null(s$source_id)) s$source_id else "N/A",
if (!is.null(s$size)) s$size else "N/A"))
}
}
return()
}
if (!is.null(args$info)) {
result <- api_request(paste0("/snapshots/", args$info), public_key, secret_key)
cat(sprintf("%sSnapshot Details%s\n\n", BLUE, RESET))
cat(sprintf("Snapshot ID: %s\n", if (!is.null(result$id)) result$id else "N/A"))
cat(sprintf("Name: %s\n", if (!is.null(result$name)) result$name else "-"))
cat(sprintf("Source Type: %s\n", if (!is.null(result$source_type)) result$source_type else "N/A"))
cat(sprintf("Source ID: %s\n", if (!is.null(result$source_id)) result$source_id else "N/A"))
cat(sprintf("Size: %s\n", if (!is.null(result$size)) result$size else "N/A"))
cat(sprintf("Created: %s\n", if (!is.null(result$created_at)) result$created_at else "N/A"))
return()
}
if (!is.null(args$delete)) {
result <- api_request(paste0("/snapshots/", args$delete), public_key, secret_key, method = "DELETE")
cat(sprintf("%sSnapshot deleted successfully%s\n", GREEN, RESET))
return()
}
if (!is.null(args$clone)) {
if (is.null(args$type)) {
cat(sprintf("%sError: --type required for --clone (session or service)%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
if (!(args$type %in% c("session", "service"))) {
cat(sprintf("%sError: --type must be 'session' or 'service'%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
payload <- list(type = args$type)
if (!is.null(args$clone_name)) {
payload$name <- args$clone_name
}
if (!is.null(args$shell)) {
payload$shell <- args$shell
}
if (!is.null(args$ports)) {
ports_vec <- as.integer(strsplit(args$ports, ",")[[1]])
payload$ports <- ports_vec
}
result <- api_request(paste0("/snapshots/", args$clone, "/clone"), public_key, secret_key, method = "POST", data = payload)
if (args$type == "session") {
cat(sprintf("%sSession created from snapshot%s\n", GREEN, RESET))
cat(sprintf("Session ID: %s\n", if (!is.null(result$id)) result$id else "N/A"))
} else {
cat(sprintf("%sService created from snapshot%s\n", GREEN, RESET))
cat(sprintf("Service ID: %s\n", if (!is.null(result$id)) result$id else "N/A"))
}
return()
}
cat(sprintf("%sError: Specify --list, --info ID, --delete ID, or --clone ID --type TYPE%s\n", RED, RESET), file = stderr())
quit(status = 1)
}
cmd_service <- function(args) {
keys <- get_api_keys(args$api_key)
public_key <- keys$public_key
@ -467,6 +571,30 @@ cmd_service <- function(args) {
return()
}
if (!is.null(args$snapshot_svc)) {
payload <- list()
if (!is.null(args$snapshot_name)) {
payload$name <- args$snapshot_name
}
if (!is.null(args$hot) && args$hot) {
payload$hot <- TRUE
}
cat(sprintf("%sCreating snapshot of service %s...%s\n", YELLOW, args$snapshot_svc, RESET), file = stderr())
result <- api_request(paste0("/services/", args$snapshot_svc, "/snapshot"), public_key, secret_key, method = "POST", data = payload)
cat(sprintf("%sSnapshot created successfully%s\n", GREEN, RESET))
cat(sprintf("Snapshot ID: %s\n", if (!is.null(result$id)) result$id else "N/A"))
return()
}
if (!is.null(args$restore_svc)) {
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
cat(sprintf("%sRestoring from snapshot %s...%s\n", YELLOW, args$restore_svc, RESET), file = stderr())
result <- api_request(paste0("/snapshots/", args$restore_svc, "/restore"), public_key, secret_key, method = "POST", data = list())
cat(sprintf("%sService restored from snapshot%s\n", GREEN, RESET))
return()
}
if (!is.null(args$dump_bootstrap)) {
cat(sprintf("Fetching bootstrap script from %s...\n", args$dump_bootstrap), file = stderr())
payload <- list(command = "cat /tmp/bootstrap.sh")
@ -579,11 +707,22 @@ parse_args <- function() {
command = NULL,
list = FALSE,
kill = NULL,
snapshot_id = NULL,
snapshot_svc = NULL,
restore_id = NULL,
restore_svc = NULL,
from_snapshot = NULL,
snapshot_name = NULL,
hot = FALSE,
info = NULL,
logs = NULL,
sleep = NULL,
wake = NULL,
destroy = NULL,
delete = NULL,
clone = NULL,
clone_name = NULL,
shell = NULL,
dump_bootstrap = NULL,
dump_file = NULL,
name = NULL,
@ -609,6 +748,9 @@ parse_args <- function() {
} else if (arg == "key") {
result$command <- "key"
i <- i + 1
} else if (arg == "snapshot") {
result$command <- "snapshot"
i <- i + 1
} else if (arg %in% c("-k", "--api-key")) {
i <- i + 1
result$api_key <- args[i]
@ -695,6 +837,45 @@ parse_args <- function() {
i <- i + 1
result$vcpu <- as.integer(args[i])
i <- i + 1
} else if (arg == "--snapshot") {
i <- i + 1
if (result$command == "session") {
result$snapshot_id <- args[i]
} else if (result$command == "service") {
result$snapshot_svc <- args[i]
}
i <- i + 1
} else if (arg == "--restore") {
i <- i + 1
if (result$command == "session") {
result$restore_id <- args[i]
} else if (result$command == "service") {
result$restore_svc <- args[i]
}
i <- i + 1
} else if (arg == "--from") {
i <- i + 1
result$from_snapshot <- args[i]
i <- i + 1
} else if (arg == "--snapshot-name") {
i <- i + 1
result$snapshot_name <- args[i]
i <- i + 1
} else if (arg == "--hot") {
result$hot <- TRUE
i <- i + 1
} else if (arg == "--delete") {
i <- i + 1
result$delete <- args[i]
i <- i + 1
} else if (arg == "--clone") {
i <- i + 1
result$clone <- args[i]
i <- i + 1
} else if (arg == "--shell") {
i <- i + 1
result$shell <- args[i]
i <- i + 1
} else if (arg == "--extend") {
result$extend <- TRUE
i <- i + 1
@ -706,6 +887,7 @@ parse_args <- function() {
cat("Usage: un.r [options] <source_file>\n", file = stderr())
cat(" un.r session [options]\n", file = stderr())
cat(" un.r service [options]\n", file = stderr())
cat(" un.r snapshot [options]\n", file = stderr())
cat(" un.r key [options]\n", file = stderr())
quit(status = 1)
}
@ -721,6 +903,8 @@ main <- function() {
cmd_session(args)
} else if (!is.null(args$command) && args$command == "service") {
cmd_service(args)
} else if (!is.null(args$command) && args$command == "snapshot") {
cmd_snapshot(args)
} else if (!is.null(args$command) && args$command == "key") {
cmd_key(args)
} else if (!is.null(args$source_file)) {
@ -729,6 +913,7 @@ main <- function() {
cat("Usage: un.r [options] <source_file>\n", file = stderr())
cat(" un.r session [options]\n", file = stderr())
cat(" un.r service [options]\n", file = stderr())
cat(" un.r snapshot [options]\n", file = stderr())
cat(" un.r key [options]\n", file = stderr())
quit(status = 1)
}

181
un.rb
View file

@ -255,6 +255,27 @@ def cmd_session(options)
return
end
if options[:snapshot_session]
payload = {}
payload[:name] = options[:snapshot_name] if options[:snapshot_name]
payload[:hot] = true if options[:hot]
warn "#{YELLOW}Creating snapshot of session #{options[:snapshot_session]}...#{RESET}"
result = api_request("/sessions/#{options[:snapshot_session]}/snapshot", method: 'POST', data: payload, keys: keys)
puts "#{GREEN}Snapshot created successfully#{RESET}"
puts "Snapshot ID: #{result['id'] || 'N/A'}"
return
end
if options[:restore_session]
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
warn "#{YELLOW}Restoring from snapshot #{options[:restore_session]}...#{RESET}"
result = api_request("/snapshots/#{options[:restore_session]}/restore", method: 'POST', keys: keys)
puts "#{GREEN}Session restored from snapshot#{RESET}"
puts "New session ID: #{result['session_id']}" if result['session_id']
return
end
if options[:attach]
puts "#{YELLOW}Attaching to session #{options[:attach]}...#{RESET}"
puts "#{YELLOW}(Interactive sessions require WebSocket - use un2 for full support)#{RESET}"
@ -378,6 +399,75 @@ def cmd_key(options)
end
end
def cmd_snapshot(options)
keys = get_api_keys(options[:api_key])
if options[:list]
result = api_request('/snapshots', keys: keys)
snapshots = result['snapshots'] || []
if snapshots.empty?
puts 'No snapshots found'
else
puts format('%-40s %-20s %-12s %-30s %s', 'ID', 'Name', 'Type', 'Source ID', 'Size')
snapshots.each do |s|
puts format('%-40s %-20s %-12s %-30s %s',
s['id'] || 'N/A', s['name'] || '-',
s['source_type'] || 'N/A', s['source_id'] || 'N/A',
s['size'] || 'N/A')
end
end
return
end
if options[:info_snapshot]
result = api_request("/snapshots/#{options[:info_snapshot]}", keys: keys)
puts "#{BLUE}Snapshot Details#{RESET}\n"
puts "Snapshot ID: #{result['id'] || 'N/A'}"
puts "Name: #{result['name'] || '-'}"
puts "Source Type: #{result['source_type'] || 'N/A'}"
puts "Source ID: #{result['source_id'] || 'N/A'}"
puts "Size: #{result['size'] || 'N/A'}"
puts "Created: #{result['created_at'] || 'N/A'}"
return
end
if options[:delete_snapshot]
api_request("/snapshots/#{options[:delete_snapshot]}", method: 'DELETE', keys: keys)
puts "#{GREEN}Snapshot deleted successfully#{RESET}"
return
end
if options[:clone_snapshot]
unless options[:clone_type]
warn "#{RED}Error: --type required for --clone (session or service)#{RESET}"
exit 1
end
unless ['session', 'service'].include?(options[:clone_type])
warn "#{RED}Error: --type must be 'session' or 'service'#{RESET}"
exit 1
end
payload = { type: options[:clone_type] }
payload[:name] = options[:clone_name] if options[:clone_name]
payload[:shell] = options[:clone_shell] if options[:clone_shell]
payload[:ports] = options[:clone_ports].split(',').map(&:to_i) if options[:clone_ports]
result = api_request("/snapshots/#{options[:clone_snapshot]}/clone", method: 'POST', data: payload, keys: keys)
if options[:clone_type] == 'session'
puts "#{GREEN}Session created from snapshot#{RESET}"
puts "Session ID: #{result['id'] || 'N/A'}"
else
puts "#{GREEN}Service created from snapshot#{RESET}"
puts "Service ID: #{result['id'] || 'N/A'}"
end
return
end
warn "#{RED}Error: Specify --list, --info ID, --delete ID, or --clone ID --type TYPE#{RESET}"
exit 1
end
def cmd_service(options)
keys = get_api_keys(options[:api_key])
@ -435,6 +525,27 @@ def cmd_service(options)
return
end
if options[:snapshot_service]
payload = {}
payload[:name] = options[:snapshot_name] if options[:snapshot_name]
payload[:hot] = true if options[:hot]
warn "#{YELLOW}Creating snapshot of service #{options[:snapshot_service]}...#{RESET}"
result = api_request("/services/#{options[:snapshot_service]}/snapshot", method: 'POST', data: payload, keys: keys)
puts "#{GREEN}Snapshot created successfully#{RESET}"
puts "Snapshot ID: #{result['id'] || 'N/A'}"
return
end
if options[:restore_service]
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
warn "#{YELLOW}Restoring from snapshot #{options[:restore_service]}...#{RESET}"
result = api_request("/snapshots/#{options[:restore_service]}/restore", method: 'POST', keys: keys)
puts "#{GREEN}Service restored from snapshot#{RESET}"
puts "New service ID: #{result['service_id']}" if result['service_id']
return
end
if options[:execute]
payload = { command: options[:command] }
result = api_request("/services/#{options[:execute]}/execute", method: 'POST', data: payload, keys: keys)
@ -527,6 +638,20 @@ def main
list: false,
attach: nil,
kill: nil,
snapshot_session: nil,
snapshot_service: nil,
restore_session: nil,
restore_service: nil,
from_snapshot: nil,
snapshot_name: nil,
hot: false,
info_snapshot: nil,
delete_snapshot: nil,
clone_snapshot: nil,
clone_type: nil,
clone_name: nil,
clone_shell: nil,
clone_ports: nil,
audit: false,
tmux: false,
screen: false,
@ -544,7 +669,8 @@ def main
execute: nil,
dump_bootstrap: nil,
dump_file: nil,
extend: false
extend: false,
bootstrap_file: nil
}
# Manual argument parsing
@ -553,7 +679,7 @@ def main
arg = ARGV[i]
case arg
when 'session', 'service', 'key'
when 'session', 'service', 'key', 'snapshot'
options[:command] = arg
when '-e'
i += 1
@ -640,6 +766,55 @@ def main
when '--dump-file'
i += 1
options[:dump_file] = ARGV[i]
when '--snapshot'
i += 1
if options[:command] == 'session'
options[:snapshot_session] = ARGV[i]
elsif options[:command] == 'service'
options[:snapshot_service] = ARGV[i]
end
when '--restore'
i += 1
if options[:command] == 'session'
options[:restore_session] = ARGV[i]
elsif options[:command] == 'service'
options[:restore_service] = ARGV[i]
end
when '--from'
i += 1
options[:from_snapshot] = ARGV[i]
when '--snapshot-name'
i += 1
options[:snapshot_name] = ARGV[i]
when '--hot'
options[:hot] = true
when '--info'
i += 1
if options[:command] == 'snapshot'
options[:info_snapshot] = ARGV[i]
else
options[:info] = ARGV[i]
end
when '--delete'
i += 1
options[:delete_snapshot] = ARGV[i]
when '--clone'
i += 1
options[:clone_snapshot] = ARGV[i]
when '--type'
i += 1
if options[:clone_snapshot]
options[:clone_type] = ARGV[i]
else
options[:type] = ARGV[i]
end
when '--shell'
i += 1
if options[:clone_snapshot]
options[:clone_shell] = ARGV[i]
else
options[:shell] = ARGV[i]
end
when '--extend'
options[:extend] = true
else
@ -659,6 +834,8 @@ def main
cmd_session(options)
when 'service'
cmd_service(options)
when 'snapshot'
cmd_snapshot(options)
when 'key'
cmd_key(options)
else

222
un.sh Executable file → Normal file
View file

@ -348,6 +348,11 @@ cmd_session() {
local vcpu=""
local api_key="${UNSANDBOX_API_KEY:-}"
local -a input_files=()
local snapshot=""
local restore=""
local from_snapshot=""
local snapshot_name=""
local hot=false
while [[ $# -gt 0 ]]; do
case "$1" in
@ -379,6 +384,26 @@ cmd_session() {
screen=true
shift
;;
--snapshot)
snapshot="$2"
shift 2
;;
--restore)
restore="$2"
shift 2
;;
--from)
from_snapshot="$2"
shift 2
;;
--snapshot-name)
snapshot_name="$2"
shift 2
;;
--hot)
hot=true
shift
;;
-f)
input_files+=("$2")
shift 2
@ -426,6 +451,26 @@ cmd_session() {
return
fi
if [[ -n "$snapshot" ]]; then
local payload=$(jq -n --arg name "$snapshot_name" --argjson hot "$hot" '{name: $name, hot: $hot}')
echo -e "${YELLOW}Creating snapshot of session $snapshot...${RESET}"
local result=$(api_request "/sessions/$snapshot/snapshot" "POST" "$payload" "$api_key")
local snapshot_id=$(echo "$result" | jq -r '.id // "N/A"')
echo -e "${GREEN}Snapshot created successfully${RESET}"
echo "Snapshot ID: $snapshot_id"
return
fi
if [[ -n "$restore" ]]; then
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
echo -e "${YELLOW}Restoring from snapshot $restore...${RESET}"
local result=$(api_request "/snapshots/$restore/restore" "POST" "{}" "$api_key")
echo -e "${GREEN}Session restored from snapshot${RESET}"
local new_id=$(echo "$result" | jq -r '.session_id // empty')
[[ -n "$new_id" ]] && echo "New session ID: $new_id"
return
fi
if [[ -n "$attach" ]]; then
echo -e "${YELLOW}Attaching to session $attach...${RESET}"
echo -e "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}"
@ -483,6 +528,11 @@ cmd_service() {
local vcpu=""
local api_key="${UNSANDBOX_API_KEY:-}"
local -a input_files=()
local snapshot=""
local restore=""
local from_snapshot=""
local snapshot_name=""
local hot=false
while [[ $# -gt 0 ]]; do
case "$1" in
@ -558,6 +608,26 @@ cmd_service() {
dump_file="$2"
shift 2
;;
--snapshot)
snapshot="$2"
shift 2
;;
--restore)
restore="$2"
shift 2
;;
--from)
from_snapshot="$2"
shift 2
;;
--snapshot-name)
snapshot_name="$2"
shift 2
;;
--hot)
hot=true
shift
;;
-n)
network="$2"
shift 2
@ -595,6 +665,26 @@ cmd_service() {
return
fi
if [[ -n "$snapshot" ]]; then
local payload=$(jq -n --arg name "$snapshot_name" --argjson hot "$hot" '{name: $name, hot: $hot}')
echo -e "${YELLOW}Creating snapshot of service $snapshot...${RESET}"
local result=$(api_request "/services/$snapshot/snapshot" "POST" "$payload" "$api_key")
local snapshot_id=$(echo "$result" | jq -r '.id // "N/A"')
echo -e "${GREEN}Snapshot created successfully${RESET}"
echo "Snapshot ID: $snapshot_id"
return
fi
if [[ -n "$restore" ]]; then
# --restore takes snapshot ID directly, calls /snapshots/:id/restore
echo -e "${YELLOW}Restoring from snapshot $restore...${RESET}"
local result=$(api_request "/snapshots/$restore/restore" "POST" "{}" "$api_key")
echo -e "${GREEN}Service restored from snapshot${RESET}"
local new_id=$(echo "$result" | jq -r '.service_id // empty')
[[ -n "$new_id" ]] && echo "New service ID: $new_id"
return
fi
if [[ -n "$info" ]]; then
local result=$(api_request "/services/$info" "GET" "" "$api_key")
echo "$result" | jq '.'
@ -728,6 +818,116 @@ cmd_service() {
exit 1
}
cmd_snapshot() {
local api_key="${UNSANDBOX_API_KEY:-}"
local list=false
local info=""
local delete=""
local clone=""
local clone_type=""
local clone_name=""
local clone_shell=""
local clone_ports=""
while [[ $# -gt 0 ]]; do
case "$1" in
-l|--list)
list=true
shift
;;
--info)
info="$2"
shift 2
;;
--delete)
delete="$2"
shift 2
;;
--clone)
clone="$2"
shift 2
;;
--type)
clone_type="$2"
shift 2
;;
--name)
clone_name="$2"
shift 2
;;
--shell)
clone_shell="$2"
shift 2
;;
--ports)
clone_ports="$2"
shift 2
;;
-k)
api_key="$2"
shift 2
;;
-*)
echo -e "${RED}Unknown option: $1${RESET}" >&2
exit 1
;;
*)
shift
;;
esac
done
if [[ "$list" == true ]]; then
local result=$(api_request "/snapshots" "GET" "" "$api_key")
echo "$result" | jq '.'
return
fi
if [[ -n "$info" ]]; then
local result=$(api_request "/snapshots/$info" "GET" "" "$api_key")
echo "$result" | jq '.'
return
fi
if [[ -n "$delete" ]]; then
api_request "/snapshots/$delete" "DELETE" "" "$api_key" > /dev/null
echo -e "${GREEN}Snapshot deleted successfully${RESET}"
return
fi
if [[ -n "$clone" ]]; then
if [[ -z "$clone_type" ]]; then
echo -e "${RED}Error: --type required with --clone (session or service)${RESET}" >&2
exit 1
fi
local payload=$(jq -n --arg type "$clone_type" '{type: $type}')
[[ -n "$clone_name" ]] && payload=$(echo "$payload" | jq --arg n "$clone_name" '. + {name: $n}')
[[ -n "$clone_shell" ]] && payload=$(echo "$payload" | jq --arg s "$clone_shell" '. + {shell: $s}')
if [[ -n "$clone_ports" ]]; then
local ports_json="[$(echo "$clone_ports" | sed 's/,/,/g')]"
payload=$(echo "$payload" | jq --argjson p "$ports_json" '. + {ports: $p}')
fi
echo -e "${YELLOW}Cloning snapshot $clone to create new $clone_type...${RESET}"
local result=$(api_request "/snapshots/$clone/clone" "POST" "$payload" "$api_key")
if [[ "$clone_type" == "session" ]]; then
local session_id=$(echo "$result" | jq -r '.session_id // "N/A"')
echo -e "${GREEN}Session created from snapshot${RESET}"
echo "Session ID: $session_id"
else
local service_id=$(echo "$result" | jq -r '.service_id // "N/A"')
echo -e "${GREEN}Service created from snapshot${RESET}"
echo "Service ID: $service_id"
fi
return
fi
echo -e "${RED}Error: Specify --list, --info, --delete, or --clone${RESET}" >&2
exit 1
}
validate_key() {
local api_key="$1"
local extend_mode="$2"
@ -884,6 +1084,7 @@ Usage:
$0 [options] <source_file>
$0 session [options]
$0 service [options]
$0 snapshot [options]
$0 key [options]
Execute options:
@ -903,6 +1104,10 @@ Session options:
--audit Record session
--tmux Enable tmux persistence
--screen Enable screen persistence
--snapshot SESSION_ID Create snapshot of session
--restore SNAPSHOT_ID Restore from snapshot ID
--snapshot-name NAME Optional name for snapshot
--hot Take snapshot without freezing (live snapshot)
Service options:
--name NAME Service name
@ -922,6 +1127,20 @@ Service options:
--command CMD Command to execute (with --execute)
--dump-bootstrap ID Dump bootstrap script
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
--snapshot SERVICE_ID Create snapshot of service
--restore SNAPSHOT_ID Restore from snapshot ID
--snapshot-name NAME Optional name for snapshot
--hot Take snapshot without freezing (live snapshot)
Snapshot options:
-l, --list List all snapshots
--info ID Get snapshot details
--delete ID Delete a snapshot
--clone ID Clone snapshot to new session/service (--type required)
--type TYPE Type for clone: session or service
--name NAME Name for cloned session/service
--shell NAME Shell for cloned session
--ports PORTS Ports for cloned service
Key options:
-k KEY API key to validate
@ -942,6 +1161,9 @@ if [[ "$1" == "session" ]]; then
elif [[ "$1" == "service" ]]; then
shift
cmd_service "$@"
elif [[ "$1" == "snapshot" ]]; then
shift
cmd_snapshot "$@"
elif [[ "$1" == "key" ]]; then
shift
cmd_key "$@"

13
un.ts
View file

@ -112,6 +112,19 @@ interface Args {
execute: string | null;
command_arg: string | null;
extend: boolean;
snapshot: string | null;
restore: string | null;
from: string | null;
snapshotName: string | null;
hot: boolean;
deleteSnapshot: string | null;
clone: string | null;
cloneType: string | null;
cloneName: string | null;
cloneShell: string | null;
clonePorts: string | null;
dumpBootstrap: string | null;
dumpFile: string | null;
}
interface ApiKeys {