From ccd723db99d784a0a5c40f5521bbe75a62e3aad2 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 4 Jan 2026 19:02:06 -0500 Subject: [PATCH] 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. --- un.awk | 329 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- un.erl | 138 +++++++++++++++++++++++ un.ex | 98 ++++++++++++++++ un.fs | 166 +++++++++++++++++++++++++-- un.go | 147 ++++++++++++++++++++++-- un.groovy | 168 ++++++++++++++++++++++++++-- un.hs | 132 +++++++++++++++++++++- un.m | 153 +++++++++++++++++++++++++ un.py | 128 +++++++++++++++++++++ un.r | 185 ++++++++++++++++++++++++++++++ un.rb | 181 +++++++++++++++++++++++++++++- un.sh | 222 ++++++++++++++++++++++++++++++++++++ un.ts | 13 +++ 13 files changed, 2020 insertions(+), 40 deletions(-) mode change 100755 => 100644 un.sh diff --git a/un.awk b/un.awk index bb0b023..f2b3ed6 100644 --- a/un.awk +++ b/un.awk @@ -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 " 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 = "" diff --git a/un.erl b/un.erl index ae9f563..846a322 100755 --- a/un.erl +++ b/un.erl @@ -46,6 +46,7 @@ main([]) -> io:format("Usage: un.erl [options] ~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(), diff --git a/un.ex b/un.ex index b2694d2..e650cb7 100755 --- a/un.ex +++ b/un.ex @@ -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] ") 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() diff --git a/un.fs b/un.fs index 4224f11..7e029d0 100644 --- a/un.fs +++ b/un.fs @@ -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 diff --git a/un.go b/un.go index 037fb56..2249ab4 100644 --- a/un.go +++ b/un.go @@ -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] \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) } diff --git a/un.groovy b/un.groovy index cfc4c99..15fdfd1 100644 --- a/un.groovy +++ b/un.groovy @@ -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) { diff --git a/un.hs b/un.hs index b689e29..70d5fc4 100644 --- a/un.hs +++ b/un.hs @@ -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] 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 diff --git a/un.m b/un.m index 83dce34..73174dd 100644 --- a/un.m +++ b/un.m @@ -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] \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 { diff --git a/un.py b/un.py index 706f9fd..f87f028 100644 --- a/un.py +++ b/un.py @@ -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: diff --git a/un.r b/un.r index 2b85ad3..2a0842a 100644 --- a/un.r +++ b/un.r @@ -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] \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] \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) } diff --git a/un.rb b/un.rb index 461490b..77d7334 100644 --- a/un.rb +++ b/un.rb @@ -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 diff --git a/un.sh b/un.sh old mode 100755 new mode 100644 index 169bd6d..a50aac4 --- a/un.sh +++ b/un.sh @@ -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] $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 "$@" diff --git a/un.ts b/un.ts index 78f8a4a..b4d8d42 100644 --- a/un.ts +++ b/un.ts @@ -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 {