diff --git a/un.awk b/un.awk index d6a9841..0627a0f 100644 --- a/un.awk +++ b/un.awk @@ -272,6 +272,40 @@ function service_destroy(id , timestamp, sig_headers, signature, sig_input, s print GREEN "Service destroyed: " id RESET } +function service_resize(id, vcpu , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, ram) { + get_api_keys() + endpoint = "/services/" id + json = "{\"vcpu\":" vcpu "}" + + # Write to temp file + tmp = "/tmp/un_awk_resize_" PROCINFO["pid"] ".json" + print json > tmp + close(tmp) + + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":PATCH:" endpoint ":" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + + cmd = "curl -s -X PATCH '" API_BASE endpoint "' " \ + "-H 'Content-Type: application/json' " \ + "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ + sig_headers \ + "-d '@" tmp "'" + system(cmd " > /dev/null") + + # Clean up + system("rm -f " tmp) + + ram = vcpu * 2 + print GREEN "Service resized to " vcpu " vCPU, " ram " GB RAM" RESET +} + function service_dump_bootstrap(id, dump_file , endpoint, json_body, timestamp, sig_headers, signature, sig_input, sig_cmd) { get_api_keys() print "Fetching bootstrap script from " id "..." > "/dev/stderr" @@ -985,6 +1019,7 @@ function show_help() { print " awk -f un.awk service --list" print " awk -f un.awk service --create --name NAME [--ports PORTS] [--domains DOMAINS] [--type TYPE] [--bootstrap CMD] [-e KEY=VAL] [--env-file FILE] [-f FILE]..." print " awk -f un.awk service --destroy ID" + print " awk -f un.awk service --resize ID -v VCPU" 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" @@ -1010,6 +1045,8 @@ function show_help() { 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 " --destroy ID Destroy service" + print " --resize ID Resize service (requires -v)" print " --dump-bootstrap ID Dump bootstrap script from service" print " --dump-file FILE Save bootstrap to file (with --dump-bootstrap)" print " -e KEY=VAL Environment variable for vault (can be repeated)" @@ -1140,6 +1177,24 @@ END { service_list() } else if (ARGC >= 4 && ARGV[2] == "--destroy") { service_destroy(ARGV[3]) + } else if (ARGC >= 4 && ARGV[2] == "--resize") { + # Parse -v for vcpu + resize_id = ARGV[3] + resize_vcpu = "" + i = 4 + while (i < ARGC) { + if (ARGV[i] == "-v" && i + 1 < ARGC) { + resize_vcpu = ARGV[i + 1] + i += 2 + } else { + i++ + } + } + if (resize_vcpu == "") { + print RED "Error: --vcpu (-v) is required with --resize" RESET > "/dev/stderr" + exit 1 + } + service_resize(resize_id, resize_vcpu) } else if (ARGC >= 4 && ARGV[2] == "--dump-bootstrap") { dump_file = "" if (ARGC >= 6 && ARGV[4] == "--dump-file") { diff --git a/un.clj b/un.clj index 6434cac..d745659 100644 --- a/un.clj +++ b/un.clj @@ -217,6 +217,21 @@ (let [status (Integer/parseInt (str/trim out))] (and (>= status 200) (< status 300)))))) +(defn curl-patch [api-key endpoint json-data] + (let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".json") + [public-key secret-key] (get-api-keys) + auth-headers (build-auth-headers public-key secret-key "PATCH" endpoint json-data)] + (spit tmp-file json-data) + (let [args (concat ["curl" "-s" "-X" "PATCH" + (str "https://api.unsandbox.com" endpoint) + "-H" "Content-Type: application/json"] + auth-headers + ["-d" (str "@" tmp-file)]) + {:keys [out]} (apply sh args)] + (io/delete-file tmp-file true) + (check-clock-drift-error out) + out))) + (def max-env-content-size 65536) (defn read-env-file [path] @@ -396,6 +411,16 @@ :destroy (do (curl-delete api-key (str "/services/" sid)) (println (str green "Service destroyed: " sid reset))) + :resize (when sid + (if (or (nil? vcpu) (< vcpu 1) (> vcpu 8)) + (do + (binding [*out* *err*] + (println (str red "Error: --resize requires -v N (1-8)" reset))) + (System/exit 1)) + (let [json (str "{\"vcpu\":" vcpu "}") + _ (curl-patch api-key (str "/services/" sid) json) + ram (* vcpu 2)] + (println (str green "Service resized to " vcpu " vCPU, " ram " GB RAM" reset))))) :execute (when (and sid bootstrap) (let [json (str "{\"command\":\"" (escape-json bootstrap) "\"}") response (curl-post api-key (str "/services/" sid "/execute") json) @@ -591,6 +616,10 @@ (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files :destroy (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode) + (and (= mode :service) (= (first args) "--resize")) + (recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files + :resize (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode) + (and (= mode :service) (= (first args) "--execute")) (recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files :execute (second args) service-name service-ports (nth args 2) service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode) diff --git a/un.cob b/un.cob index 56d08d4..689de0f 100644 --- a/un.cob +++ b/un.cob @@ -84,6 +84,9 @@ 01 WS-SVC-ENV-FILE PIC X(256). 01 WS-ENV-ACTION PIC X(32). 01 WS-ENV-TARGET PIC X(256). + 01 WS-VCPU PIC 9(2) VALUE 0. + 01 WS-VCPU-STR PIC X(8). + 01 WS-RAM PIC 9(4) VALUE 0. PROCEDURE DIVISION. MAIN-PROCEDURE. @@ -244,6 +247,10 @@ ELSE IF WS-ARG2 = "--dump-bootstrap" ACCEPT WS-ID FROM ARGUMENT-VALUE PERFORM SERVICE-DUMP-BOOTSTRAP + ELSE IF WS-ARG2 = "--resize" + ACCEPT WS-ID FROM ARGUMENT-VALUE + PERFORM PARSE-SERVICE-RESIZE-ARGS + PERFORM SERVICE-RESIZE ELSE IF WS-ARG2 = "--name" ACCEPT WS-NAME FROM ARGUMENT-VALUE PERFORM PARSE-SERVICE-CREATE-ARGS @@ -251,7 +258,7 @@ ELSE DISPLAY "Error: Use --list, --info, --logs, " "--freeze, --unfreeze, --destroy, --dump-bootstrap, " - "--name, or env" UPON SYSERR + "--resize, --name, or env" UPON SYSERR MOVE 1 TO RETURN-CODE END-IF. @@ -934,3 +941,50 @@ END-STRING. CALL "SYSTEM" USING WS-CURL-CMD. + + PARSE-SERVICE-RESIZE-ARGS. + * Parse -v argument for vcpu + MOVE 0 TO WS-VCPU. + ACCEPT WS-ARG3 FROM ARGUMENT-VALUE. + PERFORM UNTIL WS-ARG3 = SPACES + IF WS-ARG3 = "-v" + ACCEPT WS-VCPU-STR FROM ARGUMENT-VALUE + MOVE FUNCTION NUMVAL(WS-VCPU-STR) TO WS-VCPU + END-IF + ACCEPT WS-ARG3 FROM ARGUMENT-VALUE + END-PERFORM. + + SERVICE-RESIZE. + * Validate vcpu + IF WS-VCPU < 1 OR WS-VCPU > 8 + DISPLAY "Error: --resize requires -v N (1-8)" + UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN + END-IF. + + * Calculate RAM + COMPUTE WS-RAM = WS-VCPU * 2. + + * Build and execute resize request with HMAC auth + STRING "TS=$(date +%s); " + "BODY='{\"vcpu\":" WS-VCPU "}'; " + "SIG=$(echo -n \"$TS:PATCH:/services/" + FUNCTION TRIM(WS-ID) + ":$BODY\" | openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X PATCH 'https://api.unsandbox.com/services/" + FUNCTION TRIM(WS-ID) + "' " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " FUNCTION TRIM(WS-PUBLIC-KEY) "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG " + "-d \"$BODY\" >/dev/null && " + "echo -e '\x1b[32mService resized to " WS-VCPU + " vCPU, " WS-RAM " GB RAM\x1b[0m'" + DELIMITED BY SIZE INTO WS-CURL-CMD + END-STRING. + + CALL "SYSTEM" USING WS-CURL-CMD. diff --git a/un.cpp b/un.cpp index 83aafb7..f01242d 100644 --- a/un.cpp +++ b/un.cpp @@ -439,7 +439,7 @@ void cmd_session(bool list, const string& kill, const string& shell, const strin cout << exec_curl(cmd) << endl; } -void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, const string& bootstrap_file, const vector& files, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const vector& envs, const string& env_file, const string& env_action, const string& env_target, const string& public_key, const string& secret_key) { +void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, const string& bootstrap_file, const vector& files, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& resize, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const vector& envs, const string& env_file, const string& env_action, const string& env_target, const string& public_key, const string& secret_key) { // Handle service env subcommand if (!env_action.empty()) { cmd_service_env(env_action, env_target, envs, env_file, public_key, secret_key); @@ -498,6 +498,23 @@ void cmd_service(const string& name, const string& ports, const string& type, co return; } + if (!resize.empty()) { + if (vcpu <= 0) { + cerr << RED << "Error: --resize requires -v " << RESET << endl; + exit(1); + } + ostringstream json; + json << "{\"vcpu\":" << vcpu << "}"; + string auth_headers = build_auth_headers("PATCH", "/services/" + resize, json.str(), public_key, secret_key); + string cmd = "curl -s -X PATCH '" + API_BASE + "/services/" + resize + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " " + "-d '" + json.str() + "'"; + exec_curl(cmd); + cout << GREEN << "Service resized to " << vcpu << " vCPU, " << (vcpu * 2) << " GB RAM" << RESET << endl; + return; + } + if (!execute.empty()) { ostringstream json; json << "{\"command\":\"" << escape_json(command) << "\"}"; @@ -685,12 +702,12 @@ void cmd_validate_key(bool extend, const string& public_key, const string& secre size_t status_end = result.find("\"", status_pos); string status = result.substr(status_pos, status_end - status_pos); - // Extract public_key - string public_key; + // Extract public_key from response + string resp_public_key; if (public_key_pos != string::npos) { public_key_pos += 14; size_t pk_end = result.find("\"", public_key_pos); - public_key = result.substr(public_key_pos, pk_end - public_key_pos); + resp_public_key = result.substr(public_key_pos, pk_end - public_key_pos); } // Extract tier @@ -711,8 +728,8 @@ void cmd_validate_key(bool extend, const string& public_key, const string& secre if (status == "valid") { cout << GREEN << "Valid" << RESET << endl; - if (!public_key.empty()) { - cout << "Public Key: " << public_key << endl; + if (!resp_public_key.empty()) { + cout << "Public Key: " << resp_public_key << endl; } if (!tier.empty()) { cout << "Tier: " << tier << endl; @@ -722,8 +739,8 @@ void cmd_validate_key(bool extend, const string& public_key, const string& secre } } else if (status == "expired") { cout << RED << "Expired" << RESET << endl; - if (!public_key.empty()) { - cout << "Public Key: " << public_key << endl; + if (!resp_public_key.empty()) { + cout << "Public Key: " << resp_public_key << endl; } if (!tier.empty()) { cout << "Tier: " << tier << endl; @@ -733,8 +750,8 @@ void cmd_validate_key(bool extend, const string& public_key, const string& secre } cout << YELLOW << "To renew: Visit " << PORTAL_BASE << "/keys/extend" << RESET << endl; - if (extend && !public_key.empty()) { - string url = PORTAL_BASE + "/keys/extend?pk=" + public_key; + if (extend && !resp_public_key.empty()) { + string url = PORTAL_BASE + "/keys/extend?pk=" + resp_public_key; string browser_cmd = "xdg-open '" + url + "' 2>/dev/null || open '" + url + "' 2>/dev/null"; system(browser_cmd.c_str()); } @@ -789,7 +806,7 @@ int main(int argc, char* argv[]) { if (cmd_type == "service") { string name, ports, type, bootstrap, bootstrap_file; bool list = false; - string info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network; + string info, logs, tail, sleep, wake, destroy, resize, execute, command, dump_bootstrap, dump_file, network; int vcpu = 0; vector files; vector envs; @@ -821,6 +838,7 @@ int main(int argc, char* argv[]) { else if (arg == "--freeze" && i+1 < argc) sleep = argv[++i]; else if (arg == "--unfreeze" && i+1 < argc) wake = argv[++i]; else if (arg == "--destroy" && i+1 < argc) destroy = argv[++i]; + else if (arg == "--resize" && i+1 < argc) resize = argv[++i]; else if (arg == "--execute" && i+1 < argc) execute = argv[++i]; else if (arg == "--command" && i+1 < argc) command = argv[++i]; else if (arg == "--dump-bootstrap" && i+1 < argc) dump_bootstrap = argv[++i]; @@ -830,7 +848,7 @@ int main(int argc, char* argv[]) { else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; } - cmd_service(name, ports, type, bootstrap, bootstrap_file, files, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, vcpu, envs, env_file, env_action, env_target, public_key, secret_key); + cmd_service(name, ports, type, bootstrap, bootstrap_file, files, list, info, logs, tail, sleep, wake, destroy, resize, execute, command, dump_bootstrap, dump_file, network, vcpu, envs, env_file, env_action, env_target, public_key, secret_key); return 0; } diff --git a/un.cr b/un.cr index b3b9e1b..a4969da 100644 --- a/un.cr +++ b/un.cr @@ -122,6 +122,8 @@ def api_request(endpoint : String, public_key : String, secret_key : String?, me HTTP::Client.get(url, headers: headers) when "POST" HTTP::Client.post(url, headers: headers, body: body) + when "PATCH" + HTTP::Client.patch(url, headers: headers, body: body) when "DELETE" HTTP::Client.delete(url, headers: headers) else @@ -600,6 +602,19 @@ def cmd_service(args) return end + if resize_id = args[:resize]?.as?(String) + vcpu = args[:vcpu]?.as?(Int32) + if vcpu.nil? || vcpu < 1 || vcpu > 8 + STDERR.puts "#{RED}Error: --resize requires -v N (1-8)#{RESET}" + exit 1 + end + payload = JSON.parse({vcpu: vcpu}.to_json) + api_request("/services/#{resize_id}", public_key, secret_key, method: "PATCH", data: payload) + ram = vcpu * 2 + puts "#{GREEN}Service resized to #{vcpu} vCPU, #{ram} GB RAM#{RESET}" + return + end + # Create new service if name = args[:name]?.as?(String) payload = JSON.parse({name: name}.to_json) @@ -684,7 +699,7 @@ def cmd_service(args) return end - STDERR.puts "#{RED}Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, or --name to create#{RESET}" + STDERR.puts "#{RED}Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --resize, or --name to create#{RESET}" exit 1 end @@ -708,6 +723,8 @@ def main execute: nil, dump_bootstrap: nil, dump_file: nil, + resize: nil, + vcpu: nil, name: nil, ports: nil, domains: nil, @@ -744,6 +761,8 @@ def main opts.on("--command=CMD", "Command to execute (with --execute)") { |cmd| args[:command] = cmd } opts.on("--dump-bootstrap=ID", "Dump bootstrap script") { |id| args[:dump_bootstrap] = id } opts.on("--dump-file=FILE", "File to save bootstrap (with --dump-bootstrap)") { |file| args[:dump_file] = file } + opts.on("--resize=ID", "Resize service vCPU") { |id| args[:resize] = id } + opts.on("-v VCPU", "--vcpu=VCPU", "vCPU count (1-8) for resize") { |v| args[:vcpu] = v.to_i } opts.on("--name=NAME", "Service name") { |n| args[:name] = n } opts.on("--ports=PORTS", "Comma-separated ports") { |p| args[:ports] = p } opts.on("--domains=DOMAINS", "Comma-separated domains") { |d| args[:domains] = d } diff --git a/un.d b/un.d index 3f15bff..faad545 100644 --- a/un.d +++ b/un.d @@ -375,7 +375,7 @@ void cmdSession(bool list, string kill, string shell, string network, int vcpu, writeln(execCurl(cmd)); } -void cmdService(string name, string ports, string bootstrap, string bootstrapFile, string type, bool list, string info, string logs, string tail, string sleep, string wake, string destroy, string execute, string command, string dumpBootstrap, string dumpFile, string network, int vcpu, string[] inputFiles, string[] svcEnvs, string svcEnvFile, string envAction, string envTarget, string publicKey, string secretKey) { +void cmdService(string name, string ports, string bootstrap, string bootstrapFile, string type, bool list, string info, string logs, string tail, string sleep, string wake, string destroy, string resize, int resizeVcpu, string execute, string command, string dumpBootstrap, string dumpFile, string network, int vcpu, string[] inputFiles, string[] svcEnvs, string svcEnvFile, string envAction, string envTarget, string publicKey, string secretKey) { // Handle env subcommand if (!envAction.empty) { cmdServiceEnv(envAction, envTarget, svcEnvs, svcEnvFile, publicKey, secretKey); @@ -440,6 +440,21 @@ void cmdService(string name, string ports, string bootstrap, string bootstrapFil return; } + if (!resize.empty) { + if (resizeVcpu < 1 || resizeVcpu > 8) { + stderr.writefln("%sError: --vcpu must be between 1 and 8%s", RED, RESET); + exit(1); + } + string json = format(`{"vcpu":%d}`, resizeVcpu); + string path = format("/services/%s", resize); + string authHeaders = buildAuthHeaders("PATCH", path, json, publicKey, secretKey); + string cmd = format(`curl -s -X PATCH '%s/services/%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, resize, authHeaders, json); + execCurl(cmd); + int ram = resizeVcpu * 2; + writefln("%sService resized to %d vCPU, %d GB RAM%s", GREEN, resizeVcpu, ram, RESET); + return; + } + if (!execute.empty) { string json = format(`{"command":"%s"}`, escapeJson(command)); string path = format("/services/%s/execute", execute); @@ -731,8 +746,9 @@ int main(string[] args) { if (args[1] == "service") { string name, ports, bootstrap, bootstrapFile, type; bool list = false; - string info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network; + string info, logs, tail, sleep, wake, destroy, resize, execute, command, dumpBootstrap, dumpFile, network; int vcpu = 0; + int resizeVcpu = 0; string[] inputFiles; string[] svcEnvs; string svcEnvFile; @@ -747,7 +763,7 @@ int main(string[] args) { else if (args[i] == "--env-file" && i+1 < args.length) svcEnvFile = args[++i]; else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; } - cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey); + cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey); return 0; } @@ -764,6 +780,8 @@ int main(string[] args) { else if (args[i] == "--freeze" && i+1 < args.length) sleep = args[++i]; else if (args[i] == "--unfreeze" && i+1 < args.length) wake = args[++i]; else if (args[i] == "--destroy" && i+1 < args.length) destroy = args[++i]; + else if (args[i] == "--resize" && i+1 < args.length) resize = args[++i]; + else if (args[i] == "--vcpu" && i+1 < args.length) resizeVcpu = to!int(args[++i]); else if (args[i] == "--execute" && i+1 < args.length) execute = args[++i]; else if (args[i] == "--command" && i+1 < args.length) command = args[++i]; else if (args[i] == "--dump-bootstrap" && i+1 < args.length) dumpBootstrap = args[++i]; @@ -776,7 +794,7 @@ int main(string[] args) { else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; } - cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey); + cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey); return 0; } diff --git a/un.dart b/un.dart index 05bad2e..cdb9e8e 100644 --- a/un.dart +++ b/un.dart @@ -93,6 +93,8 @@ class Args { String? serviceSleep; String? serviceWake; String? serviceDestroy; + String? serviceResize; + int serviceResizeVcpu = 0; String? serviceExecute; String? serviceCommand; String? serviceDumpBootstrap; @@ -571,6 +573,18 @@ Future cmdService(Args args) async { return; } + if (args.serviceResize != null) { + if (args.serviceResizeVcpu < 1 || args.serviceResizeVcpu > 8) { + stderr.writeln('${red}Error: --vcpu must be between 1 and 8$reset'); + exit(1); + } + final payload = {'vcpu': args.serviceResizeVcpu}; + await apiRequestCurl('/services/${args.serviceResize}', 'PATCH', jsonEncode(payload), publicKey, secretKey); + final ram = args.serviceResizeVcpu * 2; + print('${green}Service resized to ${args.serviceResizeVcpu} vCPU, $ram GB RAM$reset'); + return; + } + if (args.serviceExecute != null) { final payload = { 'command': args.serviceCommand, @@ -836,6 +850,12 @@ Args parseArgs(List argv) { case '--destroy': args.serviceDestroy = argv[++i]; break; + case '--resize': + args.serviceResize = argv[++i]; + break; + case '--vcpu': + args.serviceResizeVcpu = int.parse(argv[++i]); + break; case '--execute': args.serviceExecute = argv[++i]; break; diff --git a/un.erl b/un.erl index 519b9e8..38f4565 100755 --- a/un.erl +++ b/un.erl @@ -213,6 +213,12 @@ service_command(["--destroy", ServiceId | _]) -> _ = curl_delete(ApiKey, "/services/" ++ ServiceId), io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]); +service_command(["--resize", ServiceId, "--vcpu", VcpuStr | _]) -> + service_resize(ServiceId, VcpuStr); + +service_command(["--resize", ServiceId, "-v", VcpuStr | _]) -> + service_resize(ServiceId, VcpuStr); + service_command(["--execute", ServiceId, "--command", Command | _]) -> ApiKey = get_api_key(), Json = "{\"command\":\"" ++ escape_json(Command) ++ "\"}", @@ -688,6 +694,19 @@ curl_delete(ApiKey, Endpoint) -> check_clock_drift_error(Result), Result. +curl_patch(ApiKey, Endpoint, TmpFile) -> + {ok, Body} = file:read_file(TmpFile), + BodyStr = binary_to_list(Body), + {PublicKey, SecretKey} = get_api_keys(), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PATCH", Endpoint, BodyStr), + Cmd = "curl -s -X PATCH https://api.unsandbox.com" ++ Endpoint ++ + " -H 'Content-Type: application/json'" ++ + AuthHeaders ++ + " -d @" ++ TmpFile, + Result = os:cmd(Cmd), + check_clock_drift_error(Result), + Result. + curl_put_text(Endpoint, Content) -> {PublicKey, SecretKey} = get_api_keys(), TmpFile = write_temp_file(Content), @@ -746,6 +765,22 @@ service_env_delete(ServiceId) -> _ = curl_delete(ApiKey, "/services/" ++ ServiceId ++ "/env"), io:format("\033[32mVault deleted: ~s\033[0m~n", [ServiceId]). +service_resize(ServiceId, VcpuStr) -> + ApiKey = get_api_key(), + Vcpu = list_to_integer(VcpuStr), + if + Vcpu < 1 orelse Vcpu > 8 -> + io:format(standard_error, "\033[31mError: --vcpu must be between 1 and 8\033[0m~n", []), + halt(1); + true -> ok + end, + Json = "{\"vcpu\":" ++ integer_to_list(Vcpu) ++ "}", + TmpFile = write_temp_file(Json), + _ = curl_patch(ApiKey, "/services/" ++ ServiceId, TmpFile), + file:delete(TmpFile), + Ram = Vcpu * 2, + io:format("\033[32mService resized to ~B vCPU, ~B GB RAM\033[0m~n", [Vcpu, Ram]). + %% Argument parsing parse_exec_args([], Opts) -> {maps:get(file, Opts), Opts}; diff --git a/un.ex b/un.ex index 126c716..ce0f8fb 100755 --- a/un.ex +++ b/un.ex @@ -234,6 +234,28 @@ defmodule Un do IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}") end + defp service_command(["--resize", service_id | rest]) do + vcpu = get_opt(rest, "--vcpu", "-v", nil) + + if is_nil(vcpu) do + IO.puts(:stderr, "#{@red}Error: --resize requires --vcpu N#{@reset}") + System.halt(1) + end + + vcpu_int = String.to_integer(vcpu) + + if vcpu_int < 1 or vcpu_int > 8 do + IO.puts(:stderr, "#{@red}Error: --vcpu must be between 1 and 8#{@reset}") + System.halt(1) + end + + api_key = get_api_key() + json = "{\"vcpu\":#{vcpu_int}}" + curl_patch(api_key, "/services/#{service_id}", json) + ram = vcpu_int * 2 + IO.puts("#{@green}Service resized to #{vcpu_int} vCPU, #{ram} GB RAM#{@reset}") + end + defp service_command(["--snapshot", service_id | rest]) do api_key = get_api_key() name = get_opt(rest, "--snapshot-name", nil, nil) @@ -750,6 +772,26 @@ defmodule Un do output end + defp curl_patch(api_key, endpoint, json) do + tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" + File.write!(tmp_file, json) + + {public_key, secret_key} = get_api_keys() + headers = build_auth_headers(public_key, secret_key, "PATCH", endpoint, json) + + args = [ + "-s", "-X", "PATCH", + "https://api.unsandbox.com#{endpoint}", + "-H", "Content-Type: application/json" + ] ++ headers ++ ["-d", "@#{tmp_file}"] + + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) + + File.rm(tmp_file) + check_clock_drift(output) + output + end + defp curl_put_text(endpoint, body) do tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.txt" File.write!(tmp_file, body) diff --git a/un.f90 b/un.f90 index 69ad5e0..07045a6 100644 --- a/un.f90 +++ b/un.f90 @@ -319,7 +319,7 @@ contains character(len=256) :: arg, service_id, operation, service_type, service_name character(len=1024) :: input_files, public_key, secret_key character(len=2048) :: svc_envs, svc_env_file, env_action, env_target - integer :: i, stat + integer :: i, stat, resize_vcpu logical :: list_mode list_mode = .false. @@ -332,6 +332,7 @@ contains svc_env_file = '' env_action = '' env_target = '' + resize_vcpu = 0 ! Parse service arguments i = 2 @@ -412,6 +413,24 @@ contains call get_command_argument(i+1, service_id) i = i + 1 end if + else if (trim(arg) == '--resize') then + operation = 'resize' + if (i+1 <= command_argument_count()) then + call get_command_argument(i+1, service_id) + i = i + 1 + end if + else if (trim(arg) == '--vcpu') then + if (i+1 <= command_argument_count()) then + call get_command_argument(i+1, arg) + read(arg, *) resize_vcpu + i = i + 1 + end if + else if (trim(arg) == '-v' .and. operation == 'resize') then + if (i+1 <= command_argument_count()) then + call get_command_argument(i+1, arg) + read(arg, *) resize_vcpu + i = i + 1 + end if else if (trim(arg) == '--dump-bootstrap') then operation = 'dump-bootstrap' if (i+1 <= command_argument_count()) then @@ -582,6 +601,26 @@ contains '-H "X-Signature: $SIG" >/dev/null && ', & 'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) + else if (trim(operation) == 'resize' .and. len_trim(service_id) > 0) then + if (resize_vcpu < 1 .or. resize_vcpu > 8) then + write(0, '(A)') char(27)//'[31mError: --vcpu must be between 1 and 8'//char(27)//'[0m' + stop 1 + end if + write(full_cmd, '(30A,I0,A,I0,A,I0,A)') & + 'VCPU=', resize_vcpu, '; ', & + 'RAM=$((VCPU * 2)); ', & + 'BODY=''{"vcpu":''$VCPU''}''; ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:PATCH:/services/', trim(service_id), ':$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X PATCH https://api.unsandbox.com/services/', & + trim(service_id), ' ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-d "$BODY" >/dev/null && ', & + 'echo -e "\x1b[32mService resized to ', resize_vcpu, ' vCPU, ', resize_vcpu * 2, ' GB RAM\x1b[0m"' + call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'dump-bootstrap' .and. len_trim(service_id) > 0) then write(full_cmd, '(30A)') & 'echo "Fetching bootstrap script from ', trim(service_id), '..." >&2; ', & diff --git a/un.forth b/un.forth index c75e81e..cf10d44 100644 --- a/un.forth +++ b/un.forth @@ -356,6 +356,38 @@ s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; +\ Service resize +: service-resize ( service-id-addr service-id-len vcpu-addr vcpu-len -- ) + get-api-key + s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r + s" #!/bin/bash" r@ write-line throw + s" SERVICE_ID='" r@ write-file throw + 2over r@ write-file throw + s" '" r@ write-line throw + s" VCPU='" r@ write-file throw + 2dup r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" if [ \"$VCPU\" -lt 1 ] || [ \"$VCPU\" -gt 8 ]; then" r@ write-line throw + s" echo -e '\\x1b[31mError: --vcpu must be between 1 and 8\\x1b[0m' >&2" r@ write-line throw + s" exit 1" r@ write-line throw + s" fi" r@ write-line throw + s" RAM=$((VCPU * 2))" r@ write-line throw + s" BODY='{\"vcpu\":'$VCPU'}'" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:PATCH:/services/$SERVICE_ID:$BODY\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X PATCH https://api.unsandbox.com/services/$SERVICE_ID -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" >/dev/null && echo -e \"\\x1b[32mService resized to $VCPU vCPU, $RAM GB RAM\\x1b[0m\"" r@ write-line throw + r> close-file throw + 2drop 2drop \ clean up the stack + s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system +; + \ Service env status : service-env-status ( addr len -- ) get-api-key @@ -841,6 +873,40 @@ 0 (bye) then + 2dup s" --resize" compare 0= if + 2drop + argc @ 4 < if + s" Error: --resize requires service ID" type cr + 1 (bye) + then + \ Look for --vcpu or -v in remaining args + argc @ 5 < if + s" Error: --resize requires --vcpu N" type cr + 1 (bye) + then + 4 arg 2dup s" --vcpu" compare 0= if + 2drop + argc @ 6 < if + s" Error: --vcpu requires a value" type cr + 1 (bye) + then + 3 arg 5 arg service-resize + 0 (bye) + then + 2dup s" -v" compare 0= if + 2drop + argc @ 6 < if + s" Error: -v requires a value" type cr + 1 (bye) + then + 3 arg 5 arg service-resize + 0 (bye) + then + 2drop + s" Error: --resize requires --vcpu N" type cr + 1 (bye) + then + 2dup s" --dump-bootstrap" compare 0= if 2drop argc @ 4 < if diff --git a/un.fs b/un.fs index 9e15545..2cd1dbc 100644 --- a/un.fs +++ b/un.fs @@ -105,6 +105,7 @@ type Args = { mutable ServiceCommand: string option mutable ServiceDumpBootstrap: string option mutable ServiceDumpFile: string option + mutable ServiceResize: string option mutable ServiceSnapshot: string option mutable ServiceRestore: string option mutable ServiceFrom: string option @@ -314,6 +315,64 @@ let apiRequest (endpoint: string) (method: string) (data: (string * obj) list op failwithf "HTTP error - %s" errorMsg +let apiRequestPatch (endpoint: string) (data: (string * obj) list) (publicKey: string) (secretKey: string) = + ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls + + let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest + request.Method <- "PATCH" + request.ContentType <- "application/json" + request.Timeout <- 300000 + + let body = toJson (box data) + + // Add HMAC authentication headers if secretKey is provided + if not (String.IsNullOrEmpty(secretKey)) then + let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + let message = sprintf "%d:%s:%s:%s" timestamp "PATCH" endpoint body + + use hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)) + let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)) + let signature = BitConverter.ToString(hash).Replace("-", "").ToLower() + + request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) + request.Headers.Add("X-Timestamp", timestamp.ToString()) + request.Headers.Add("X-Signature", signature) + else + request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) + + let bytes = Encoding.UTF8.GetBytes(body) + request.ContentLength <- int64 bytes.Length + use stream = request.GetRequestStream() + stream.Write(bytes, 0, bytes.Length) + + try + use response = request.GetResponse() :?> HttpWebResponse + if response.StatusCode <> HttpStatusCode.OK then + failwithf "HTTP %A" response.StatusCode + use reader = new StreamReader(response.GetResponseStream()) + let responseText = reader.ReadToEnd() + parseJson responseText + with + | :? WebException as ex -> + let errorMsg = + if ex.Response <> null then + use reader = new StreamReader(ex.Response.GetResponseStream()) + reader.ReadToEnd() + else + ex.Message + + // Check for clock drift error + if errorMsg.Contains("timestamp") && (errorMsg.Contains("401") || errorMsg.Contains("expired") || errorMsg.Contains("invalid")) then + eprintfn "%sError: Request timestamp expired (must be within 5 minutes of server time)%s" red reset + eprintfn "%sYour computer's clock may have drifted.%s" yellow reset + eprintfn "Check your system time and sync with NTP if needed:" + eprintfn " Linux: sudo ntpdate -s time.nist.gov" + eprintfn " macOS: sudo sntp -sS time.apple.com" + eprintfn " Windows: w32tm /resync%s" reset + exit 1 + + failwithf "HTTP error - %s" errorMsg + let apiRequestText (endpoint: string) (method: string) (body: string) (publicKey: string) (secretKey: string) = ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls @@ -735,6 +794,14 @@ let cmdService (args: Args) = elif args.ServiceDestroy.IsSome then let result = apiRequest (sprintf "/services/%s" args.ServiceDestroy.Value) "DELETE" None publicKey secretKey printfn "%sService destroyed: %s%s" green args.ServiceDestroy.Value reset + elif args.ServiceResize.IsSome then + if args.Vcpu <= 0 then + eprintfn "%sError: --resize requires --vcpu N (1-8)%s" red reset + exit 1 + let payload = [("vcpu", box args.Vcpu)] + let result = apiRequestPatch (sprintf "/services/%s" args.ServiceResize.Value) payload publicKey secretKey + let ram = args.Vcpu * 2 + printfn "%sService resized to %d vCPU, %d GB RAM%s" green args.Vcpu ram reset elif args.ServiceExecute.IsSome then let payload = [("command", box args.ServiceCommand.Value)] let result = apiRequest (sprintf "/services/%s/execute" args.ServiceExecute.Value) "POST" (Some payload) publicKey secretKey @@ -854,6 +921,7 @@ let parseArgs (argv: string[]) = ServiceCommand = None ServiceDumpBootstrap = None ServiceDumpFile = None + ServiceResize = None ServiceSnapshot = None ServiceRestore = None ServiceFrom = None @@ -969,6 +1037,7 @@ let parseArgs (argv: string[]) = | "--freeze" -> i <- i + 1; args.ServiceSleep <- Some argv.[i] | "--unfreeze" -> i <- i + 1; args.ServiceWake <- Some argv.[i] | "--destroy" -> i <- i + 1; args.ServiceDestroy <- Some argv.[i] + | "--resize" -> i <- i + 1; args.ServiceResize <- Some argv.[i] | "--execute" -> i <- i + 1; args.ServiceExecute <- Some argv.[i] | "--command" -> i <- i + 1; args.ServiceCommand <- Some argv.[i] | "--dump-bootstrap" -> i <- i + 1; args.ServiceDumpBootstrap <- Some argv.[i] @@ -1017,6 +1086,7 @@ let printHelp () = printfn " --freeze ID Freeze service" printfn " --unfreeze ID Unfreeze service" printfn " --destroy ID Destroy service" + printfn " --resize ID Resize service (requires --vcpu N)" printfn " --execute ID Execute command in service" printfn " --command CMD Command to execute (with --execute)" printfn " --dump-bootstrap ID Dump bootstrap script" diff --git a/un.go b/un.go index bd638d7..cad10cf 100644 --- a/un.go +++ b/un.go @@ -596,7 +596,7 @@ func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, session 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, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFiles, envs envVars, envFile, publicKey, secretKey string) { +func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceResize, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFiles, envs envVars, envFile, publicKey, secretKey string) { if serviceSnapshot != "" { payload := map[string]interface{}{} if serviceSnapshotName != "" { @@ -690,6 +690,17 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB return } + if serviceResize != "" { + if vcpu <= 0 { + fmt.Fprintf(os.Stderr, "%sError: --resize requires -v %s\n", Red, Reset) + os.Exit(1) + } + payload := map[string]interface{}{"vcpu": vcpu} + apiRequest("/services/"+serviceResize, "PATCH", payload, publicKey, secretKey) + fmt.Printf("%sService resized to %d vCPU, %d GB RAM%s\n", Green, vcpu, vcpu*2, Reset) + return + } + if serviceExecute != "" { payload := map[string]interface{}{"command": serviceCommand} result := apiRequest("/services/"+serviceExecute+"/execute", "POST", payload, publicKey, secretKey) @@ -1057,6 +1068,7 @@ func main() { serviceSleep := serviceCmd.String("sleep", "", "Freeze service") serviceWake := serviceCmd.String("wake", "", "Unfreeze service") serviceDestroy := serviceCmd.String("destroy", "", "Destroy service") + serviceResize := serviceCmd.String("resize", "", "Resize service vCPU") serviceExecute := serviceCmd.String("execute", "", "Execute command in service") serviceCommand := serviceCmd.String("command", "", "Command to execute (with -execute)") serviceDumpBootstrap := serviceCmd.String("dump-bootstrap", "", "Dump bootstrap script") @@ -1143,7 +1155,7 @@ 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, *serviceSnapshot, *serviceRestore, *serviceSnapshotName, *serviceHot, net, vc, serviceFiles, serviceEnvs, *serviceEnvFile, publicKey, secretKey) + cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceBootstrapFile, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceResize, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, *serviceSnapshot, *serviceRestore, *serviceSnapshotName, *serviceHot, net, vc, serviceFiles, serviceEnvs, *serviceEnvFile, publicKey, secretKey) return case "snapshot": diff --git a/un.groovy b/un.groovy index c9098e8..451e7a9 100644 --- a/un.groovy +++ b/un.groovy @@ -96,6 +96,7 @@ class Args { String serviceCommand = null String serviceDumpBootstrap = null String serviceDumpFile = null + String serviceResize = null String serviceSnapshot = null String serviceRestore = null String serviceFrom = null @@ -210,6 +211,65 @@ def apiRequest(endpoint, method, data, publicKey, secretKey) { } } +def apiRequestPatch(endpoint, data, publicKey, secretKey) { + def tempFile = File.createTempFile('un_request_', '.json') + try { + def body = data ?: "" + if (data) { + tempFile.text = data + } + + def curlCmd = ['curl', '-s', '-X', 'PATCH', "${API_BASE}${endpoint}", + '-H', 'Content-Type: application/json'] + + // Add HMAC authentication headers if secretKey is provided + if (secretKey) { + def timestamp = (System.currentTimeMillis() / 1000) as long + def message = "${timestamp}:PATCH:${endpoint}:${body}" + + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() + + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + curlCmd += ['-H', "X-Timestamp: ${timestamp}"] + curlCmd += ['-H', "X-Signature: ${signature}"] + } else { + // Legacy API key authentication + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + } + + if (data) { + curlCmd += ['-d', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def output = proc.text + proc.waitFor() + + if (proc.exitValue() != 0) { + System.err.println("${RED}Error: curl failed${RESET}") + System.exit(1) + } + + // Check for timestamp authentication errors + if (output.toLowerCase().contains('timestamp') && + (output.contains('401') || output.toLowerCase().contains('expired') || output.toLowerCase().contains('invalid'))) { + System.err.println("${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}") + System.err.println("${YELLOW}Your computer's clock may have drifted.${RESET}") + System.err.println("Check your system time and sync with NTP if needed:") + System.err.println(" Linux: sudo ntpdate -s time.nist.gov") + System.err.println(" macOS: sudo sntp -sS time.apple.com") + System.err.println(" Windows: w32tm /resync") + System.exit(1) + } + + return output + } finally { + tempFile.delete() + } +} + def readEnvFile(filename) { def file = new File(filename) if (!file.exists()) { @@ -713,6 +773,18 @@ def cmdService(args) { return } + if (args.serviceResize) { + if (args.vcpu <= 0) { + System.err.println("${RED}Error: --resize requires --vcpu N (1-8)${RESET}") + System.exit(1) + } + def json = """{"vcpu":${args.vcpu}}""" + apiRequestPatch("/services/${args.serviceResize}", json, publicKey, secretKey) + def ram = args.vcpu * 2 + println("${GREEN}Service resized to ${args.vcpu} vCPU, ${ram} GB RAM${RESET}") + return + } + if (args.serviceExecute) { def json = """{"command":"${args.serviceCommand}"}""" def output = apiRequest("/services/${args.serviceExecute}/execute", 'POST', json, publicKey, secretKey) @@ -987,6 +1059,9 @@ def parseArgs(argv) { case '--destroy': args.serviceDestroy = argv[++i] break + case '--resize': + args.serviceResize = argv[++i] + break case '--execute': args.serviceExecute = argv[++i] break @@ -1050,6 +1125,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires --vcpu N) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.hs b/un.hs index 27b0590..bca8fb2 100644 --- a/un.hs +++ b/un.hs @@ -161,7 +161,7 @@ data ServiceOpts = ServiceOpts data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String | ServiceSleep String | ServiceWake String | ServiceDestroy String - | ServiceExecute String String | ServiceDumpBootstrap String (Maybe String) + | ServiceResize String | ServiceExecute String String | ServiceDumpBootstrap String (Maybe String) | ServiceCreate | ServiceSnapshot String | ServiceRestore String | ServiceEnv String (Maybe String) -- action, target @@ -240,6 +240,7 @@ 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 ("--resize":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceResize 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 } @@ -322,6 +323,10 @@ printHelp = do putStrLn "Service options:" putStrLn " -e KEY=VALUE Set vault env var (with --name or env set)" putStrLn " --env-file FILE Load vault vars from file" + putStrLn " --freeze ID Freeze service" + putStrLn " --unfreeze ID Unfreeze service" + putStrLn " --destroy ID Destroy service" + putStrLn " --resize ID Resize service (requires -v N)" putStrLn "" putStrLn "Service env commands:" putStrLn " env status ID Check vault status" @@ -467,6 +472,16 @@ serviceCommand opts = do ServiceDestroy sid -> do (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/services/" ++ sid) putStrLn $ green ++ "Service destroyed: " ++ sid ++ reset + ServiceResize sid -> do + case svcVcpu opts of + Nothing -> do + hPutStrLn stderr $ red ++ "Error: --resize requires -v N (1-8)" ++ reset + exitFailure + Just vcpu -> do + let json = "{\"vcpu\":" ++ show vcpu ++ "}" + let ram = vcpu * 2 + (_, stdout, _) <- curlPatch apiKey ("https://api.unsandbox.com/services/" ++ sid) json + putStrLn $ green ++ "Service resized to " ++ show vcpu ++ " vCPU, " ++ show ram ++ " GB RAM" ++ reset ServiceExecute sid cmd -> do let json = "{\"command\":\"" ++ escapeJSON cmd ++ "\"}" (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/services/" ++ sid ++ "/execute") json @@ -667,6 +682,19 @@ curlDelete apiKey url = do checkClockDriftError stdout return (exitCode, stdout, stderr) +curlPatch :: String -> String -> String -> IO (ExitCode, String, String) +curlPatch apiKey url body = do + (publicKey, secretKey) <- getApiKeys + let path = drop (length "https://api.unsandbox.com") url + authHeaders <- buildAuthHeaders publicKey secretKey "PATCH" path body + (exitCode, stdout, stderr) <- readProcessWithExitCode "curl" + ([ "-s", "-X", "PATCH" + , url + , "-H", "Content-Type: application/json" + ] ++ authHeaders ++ ["-d", body]) "" + checkClockDriftError stdout + return (exitCode, stdout, stderr) + curlPut :: String -> String -> String -> IO (ExitCode, String, String) curlPut apiKey url body = do (publicKey, secretKey) <- getApiKeys diff --git a/un.jl b/un.jl index 520153b..4bdfcdd 100755 --- a/un.jl +++ b/un.jl @@ -159,6 +159,46 @@ function api_request(endpoint::String, public_key::String, secret_key::String; m end end +function api_request_patch(endpoint::String, public_key::String, secret_key::String; data=nothing) + url = API_BASE * endpoint + + # Prepare body + body = data !== nothing ? JSON.json(data) : "" + + # Generate timestamp and signature + timestamp = Int64(floor(time())) + signature = compute_signature(secret_key, timestamp, "PATCH", endpoint, body) + + headers = [ + "Authorization" => "Bearer $public_key", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, + "Content-Type" => "application/json" + ] + + try + response = HTTP.request("PATCH", url, headers, body, readtimeout=300) + return JSON.parse(String(response.body)) + catch e + if isa(e, HTTP.ExceptionRequest.StatusError) + error_body = String(e.response.body) + if e.status == 401 && occursin("timestamp", lowercase(error_body)) + println(stderr, "$(RED)Error: Request timestamp expired (must be within 5 minutes of server time)$(RESET)") + println(stderr, "$(YELLOW)Your computer's clock may have drifted.$(RESET)") + println(stderr, "Check your system time and sync with NTP if needed:") + println(stderr, " Linux: sudo ntpdate -s time.nist.gov") + println(stderr, " macOS: sudo sntp -sS time.apple.com") + println(stderr, " Windows: w32tm /resync") + else + println(stderr, "$(RED)Error: HTTP $(e.status) - $(error_body)$(RESET)") + end + else + println(stderr, "$(RED)Error: Request failed: $e$(RESET)") + end + exit(1) + end +end + function api_request_text(endpoint::String, public_key::String, secret_key::String, body::String)::Bool url = API_BASE * endpoint timestamp = Int64(floor(time())) @@ -508,6 +548,18 @@ function cmd_service(args) return end + if args["resize"] !== nothing + vcpu = args["vcpu"] + if vcpu === nothing || vcpu <= 0 + println(stderr, "$(RED)Error: --resize requires --vcpu N (1-8)$(RESET)") + exit(1) + end + api_request_patch("/services/$(args["resize"])", public_key, secret_key, data=Dict("vcpu" => vcpu)) + ram = vcpu * 2 + println("$(GREEN)Service resized to $(vcpu) vCPU, $(ram) GB RAM$(RESET)") + return + end + if args["dump-bootstrap"] !== nothing println(stderr, "Fetching bootstrap script from $(args["dump-bootstrap"])...") payload = Dict("command" => "cat /tmp/bootstrap.sh") @@ -862,6 +914,8 @@ function main() help = "Unfreeze service" "--destroy" help = "Destroy service" + "--resize" + help = "Resize service (requires --vcpu N)" "--dump-bootstrap" help = "Dump bootstrap script from service" "--dump-file" diff --git a/un.js b/un.js index a926c92..ca419b3 100644 --- a/un.js +++ b/un.js @@ -696,6 +696,17 @@ async function cmdService(args) { return; } + if (args.resize) { + if (!args.vcpu) { + console.error(`${RED}Error: --vcpu required with --resize${RESET}`); + process.exit(1); + } + const payload = { vcpu: args.vcpu }; + await apiRequest(`/services/${args.resize}`, "PATCH", payload, publicKey, secretKey); + console.log(`${GREEN}Service resized to ${args.vcpu} vCPU, ${args.vcpu * 2}GB RAM${RESET}`); + return; + } + if (args.execute) { const payload = { command: args.command }; const result = await apiRequest(`/services/${args.execute}/execute`, "POST", payload, publicKey, secretKey); @@ -816,6 +827,7 @@ function parseArgs(argv) { sleep: null, wake: null, destroy: null, + resize: null, execute: null, command_arg: null, dumpBootstrap: null, @@ -931,6 +943,9 @@ function parseArgs(argv) { } else if (arg === '--destroy' && i + 1 < argv.length) { args.destroy = argv[++i]; i++; + } else if (arg === '--resize' && i + 1 < argv.length) { + args.resize = argv[++i]; + i++; } else if (arg === '--execute' && i + 1 < argv.length) { args.execute = argv[++i]; i++; @@ -1013,6 +1028,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires -v) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.kt b/un.kt index 7269c77..8870868 100644 --- a/un.kt +++ b/un.kt @@ -101,6 +101,7 @@ data class Args( var serviceCommand: String? = null, var serviceDumpBootstrap: String? = null, var serviceDumpFile: String? = null, + var serviceResize: String? = null, var keyExtend: Boolean = false, var envFile: String? = null, var envAction: String? = null, @@ -333,6 +334,18 @@ fun cmdService(args: Args) { return } + if (args.serviceResize != null) { + if (args.vcpu <= 0) { + System.err.println("${RED}Error: --resize requires --vcpu N (1-8)${RESET}") + exitProcess(1) + } + val payload = mapOf("vcpu" to args.vcpu) + apiRequestPatch("/services/${args.serviceResize}", payload, publicKey, secretKey) + val ram = args.vcpu * 2 + println("${GREEN}Service resized to ${args.vcpu} vCPU, ${ram} GB RAM${RESET}") + return + } + if (args.serviceExecute != null) { val payload = mutableMapOf("command" to args.serviceCommand!!) val result = apiRequest("/services/${args.serviceExecute}/execute", "POST", payload, publicKey, secretKey) @@ -609,6 +622,44 @@ fun apiRequest(endpoint: String, method: String, data: Map?, public return parseJson(response) } +fun apiRequestPatch(endpoint: String, data: Map, publicKey: String?, secretKey: String): Map { + val timestamp = System.currentTimeMillis() / 1000 + val body = toJson(data) + val signatureData = "$timestamp:PATCH:$endpoint:$body" + val signature = hmacSha256(secretKey, signatureData) + + val url = URL(API_BASE + endpoint) + val connection = url.openConnection() as HttpURLConnection + + connection.requestMethod = "PATCH" + connection.setRequestProperty("Authorization", "Bearer ${publicKey ?: secretKey}") + connection.setRequestProperty("X-Timestamp", timestamp.toString()) + connection.setRequestProperty("X-Signature", signature) + connection.setRequestProperty("Content-Type", "application/json") + connection.connectTimeout = 30000 + connection.readTimeout = 300000 + + connection.doOutput = true + connection.outputStream.use { it.write(body.toByteArray()) } + + if (connection.responseCode !in 200..299) { + val error = connection.errorStream?.bufferedReader()?.readText() ?: "" + if (connection.responseCode == 401 && error.lowercase().contains("timestamp")) { + System.err.println("${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}") + System.err.println("${YELLOW}Your computer's clock may have drifted.${RESET}") + System.err.println("Check your system time and sync with NTP if needed:") + System.err.println(" Linux: sudo ntpdate -s time.nist.gov") + System.err.println(" macOS: sudo sntp -sS time.apple.com") + System.err.println(" Windows: w32tm /resync") + exitProcess(1) + } + throw RuntimeException("HTTP ${connection.responseCode} - $error") + } + + val response = connection.inputStream.bufferedReader().readText() + return parseJson(response) +} + fun apiRequestText(endpoint: String, method: String, body: String, publicKey: String?, secretKey: String): Pair { val timestamp = System.currentTimeMillis() / 1000 val signatureData = "$timestamp:$method:$endpoint:$body" @@ -912,6 +963,7 @@ fun parseArgs(args: Array): Args { "--freeze" -> result.serviceSleep = args[++i] "--unfreeze" -> result.serviceWake = args[++i] "--destroy" -> result.serviceDestroy = args[++i] + "--resize" -> result.serviceResize = args[++i] "--execute" -> result.serviceExecute = args[++i] "--command" -> result.serviceCommand = args[++i] "--dump-bootstrap" -> result.serviceDumpBootstrap = args[++i] @@ -975,6 +1027,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires --vcpu N) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.lisp b/un.lisp index a39a538..6f7a7e6 100644 --- a/un.lisp +++ b/un.lisp @@ -186,6 +186,19 @@ response)) (delete-file tmp-file)))) +(defun curl-patch (api-key endpoint json-data) + (let ((tmp-file (write-temp-file json-data))) + (unwind-protect + (destructuring-bind (public-key secret-key) (get-api-keys) + (let* ((auth-headers (build-auth-headers public-key secret-key "PATCH" endpoint json-data)) + (base-args (list "curl" "-s" "-X" "PATCH" + (format nil "https://api.unsandbox.com~a" endpoint) + "-H" "Content-Type: application/json")) + (response (run-curl (append base-args auth-headers (list "-d" (format nil "@~a" tmp-file)))))) + (check-clock-drift response) + response)) + (delete-file tmp-file)))) + (defun curl-put-text (api-key endpoint content) "PUT request with text/plain content type (for vault)" (let ((tmp-file (write-temp-file content))) @@ -316,6 +329,16 @@ ((string= action "destroy") (curl-delete api-key (format nil "/services/~a" id)) (format t "~aService destroyed: ~a~a~%" *green* id *reset*)) + ((string= action "resize") + (if (or (null service-type) (string= service-type "")) + (progn + (format *error-output* "~aError: --resize requires --vcpu N (1-8)~a~%" *red* *reset*) + (uiop:quit 1)) + (let* ((vcpu (parse-integer service-type)) + (ram (* vcpu 2)) + (json (format nil "{\"vcpu\":~a}" vcpu))) + (curl-patch api-key (format nil "/services/~a" id) json) + (format t "~aService resized to ~a vCPU, ~a GB RAM~a~%" *green* vcpu ram *reset*)))) ((string= action "execute") (when (and id bootstrap) (let* ((json (format nil "{\"command\":\"~a\"}" (escape-json bootstrap))) @@ -522,6 +545,15 @@ (service-cmd "wake" (third args) nil nil nil nil nil nil nil nil)) ((and (> (length args) 2) (string= (second args) "--destroy")) (service-cmd "destroy" (third args) nil nil nil nil nil nil nil nil)) + ((and (> (length args) 3) (string= (second args) "--resize")) + ;; --resize ID --vcpu N: id is third, vcpu is fourth (after -v flag) + (let ((id (third args)) + (vcpu (if (and (> (length args) 4) + (or (string= (fourth args) "-v") + (string= (fourth args) "--vcpu"))) + (fifth args) + nil))) + (service-cmd "resize" id nil nil nil nil vcpu nil nil nil))) ((and (> (length args) 3) (string= (second args) "--execute")) (service-cmd "execute" (third args) nil nil (fourth args) nil nil nil nil nil)) ((and (> (length args) 3) (string= (second args) "--dump-bootstrap")) diff --git a/un.lua b/un.lua index c0c8178..6132884 100644 --- a/un.lua +++ b/un.lua @@ -736,6 +736,23 @@ local function cmd_service(options) return end + if options.resize then + local vcpu = options.resize_vcpu or options.vcpu + if not vcpu then + io.stderr:write(RED .. "Error: --resize requires --vcpu or -v" .. RESET .. "\n") + os.exit(1) + end + if vcpu < 1 or vcpu > 8 then + io.stderr:write(RED .. "Error: vCPU must be between 1 and 8" .. RESET .. "\n") + os.exit(1) + end + local payload = { vcpu = vcpu } + api_request("/services/" .. options.resize, "PATCH", payload, keys) + local ram = vcpu * 2 + print(GREEN .. "Service resized to " .. vcpu .. " vCPU, " .. ram .. " GB RAM" .. RESET) + return + end + if options.execute then local payload = { command = options.command } local result = api_request("/services/" .. options.execute .. "/execute", "POST", payload, keys) @@ -867,6 +884,8 @@ local function main() sleep = nil, wake = nil, destroy = nil, + resize = nil, + resize_vcpu = nil, execute = nil, command = nil, dump_bootstrap = nil, @@ -979,6 +998,12 @@ local function main() elseif a == "--destroy" then i = i + 1 options.destroy = arg[i] + elseif a == "--resize" then + i = i + 1 + options.resize = arg[i] + elseif a == "--vcpu" then + i = i + 1 + options.resize_vcpu = tonumber(arg[i]) elseif a == "--execute" then i = i + 1 options.execute = arg[i] @@ -1059,6 +1084,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires --vcpu) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.m b/un.m index 42e0f1f..e40d1f7 100644 --- a/un.m +++ b/un.m @@ -663,6 +663,7 @@ void cmdService(NSArray* args) { NSString* sleepId = nil; NSString* wakeId = nil; NSString* destroyId = nil; + NSString* resizeId = nil; NSString* dumpBootstrapId = nil; NSString* dumpFile = nil; NSString* name = nil; @@ -736,6 +737,10 @@ void cmdService(NSArray* args) { wakeId = args[++i]; } else if ([arg isEqualToString:@"--destroy"] && i + 1 < [args count]) { destroyId = args[++i]; + } else if ([arg isEqualToString:@"--resize"] && i + 1 < [args count]) { + resizeId = args[++i]; + } else if ([arg isEqualToString:@"--vcpu"] && i + 1 < [args count]) { + vcpu = [args[++i] intValue]; } else if ([arg isEqualToString:@"--dump-bootstrap"] && i + 1 < [args count]) { dumpBootstrapId = args[++i]; } else if ([arg isEqualToString:@"--dump-file"] && i + 1 < [args count]) { @@ -833,6 +838,23 @@ void cmdService(NSArray* args) { return; } + if (resizeId) { + if (vcpu <= 0) { + fprintf(stderr, "%sError: --resize requires --vcpu or -v%s\n", [RED UTF8String], [RESET UTF8String]); + exit(1); + } + if (vcpu < 1 || vcpu > 8) { + fprintf(stderr, "%sError: vCPU must be between 1 and 8%s\n", [RED UTF8String], [RESET UTF8String]); + exit(1); + } + NSString* endpoint = [NSString stringWithFormat:@"/services/%@", resizeId]; + NSDictionary* payload = @{@"vcpu": @(vcpu)}; + apiRequest(endpoint, @"PATCH", payload, publicKey, secretKey); + int ram = vcpu * 2; + printf("%sService resized to %d vCPU, %d GB RAM%s\n", [GREEN UTF8String], vcpu, ram, [RESET UTF8String]); + return; + } + if (dumpBootstrapId) { fprintf(stderr, "Fetching bootstrap script from %s...\n", [dumpBootstrapId UTF8String]); NSDictionary* payload = @{@"command": @"cat /tmp/bootstrap.sh"}; diff --git a/un.ml b/un.ml index 2e5b82c..913c5e7 100755 --- a/un.ml +++ b/un.ml @@ -678,6 +678,33 @@ let service_command action name ports bootstrap bootstrap_file service_type netw | None -> Printf.fprintf stderr "Error: --destroy requires service ID\n"; exit 1) + | "resize" -> + (match (name, vcpu) with + | (Some sid, Some v) -> + if v < 1 || v > 8 then begin + Printf.fprintf stderr "%sError: vCPU must be between 1 and 8%s\n" red reset; + exit 1 + end; + let json = Printf.sprintf "{\"vcpu\":%d}" v in + let endpoint = Printf.sprintf "/services/%s" sid in + let (public_key, secret_key) = get_api_keys () in + let auth_headers = build_auth_headers public_key secret_key "PATCH" endpoint json in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc json; + close_out oc; + let cmd = Printf.sprintf "curl -s -X PATCH https://api.unsandbox.com%s -H 'Content-Type: application/json'%s -d @%s" + endpoint auth_headers tmp_file in + let _ = Sys.command cmd in + Sys.remove tmp_file; + let ram = v * 2 in + Printf.printf "%sService resized to %d vCPU, %d GB RAM%s\n" green v ram reset + | (Some _, None) -> + Printf.fprintf stderr "%sError: --resize requires --vcpu or -v%s\n" red reset; + exit 1 + | (None, _) -> + Printf.fprintf stderr "Error: --resize requires service ID\n"; + exit 1) | "execute" -> (match name with | Some sid -> @@ -859,6 +886,8 @@ let () = | "--freeze" :: id :: rest -> parse_service "sleep" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest | "--unfreeze" :: id :: rest -> parse_service "wake" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest | "--destroy" :: id :: rest -> parse_service "destroy" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest + | "--resize" :: id :: rest -> parse_service "resize" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest + | "--vcpu" :: v :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network (Some (int_of_string v)) env_file rest | "--execute" :: id :: "--command" :: cmd :: rest -> parse_service "execute" (Some id) ports (Some cmd) bootstrap_file service_type network vcpu env_file rest | "--dump-bootstrap" :: id :: file :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap (Some file) service_type network vcpu env_file rest | "--dump-bootstrap" :: id :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest diff --git a/un.nim b/un.nim index 01ef5eb..726c70a 100644 --- a/un.nim +++ b/un.nim @@ -302,7 +302,7 @@ proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, scree let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" echo execCurl(cmd) -proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, inputFiles: seq[string], svcEnvs: seq[string], svcEnvFile, envAction, envTarget: string, publicKey: string, secretKey: string) = +proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, resize: string, resizeVcpu: int, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, inputFiles: seq[string], svcEnvs: seq[string], svcEnvFile, envAction, envTarget: string, publicKey: string, secretKey: string) = # Handle env subcommand if envAction != "": cmdServiceEnv(envAction, envTarget, svcEnvs, svcEnvFile, publicKey, secretKey) @@ -359,6 +359,22 @@ proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list echo GREEN & "Service destroyed: " & destroy & RESET return + if resize != "": + if resizeVcpu <= 0: + stderr.writeLine(RED & "Error: --resize requires --vcpu or -v" & RESET) + quit(1) + if resizeVcpu < 1 or resizeVcpu > 8: + stderr.writeLine(RED & "Error: vCPU must be between 1 and 8" & RESET) + quit(1) + let json = fmt"""{{"vcpu":{resizeVcpu}}}""" + let path = fmt"/services/{resize}" + let authHeaders = buildAuthHeaders("PATCH", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X PATCH '{API_BASE}/services/{resize}' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" + discard execCurl(cmd) + let ram = resizeVcpu * 2 + echo GREEN & "Service resized to " & $resizeVcpu & " vCPU, " & $ram & " GB RAM" & RESET + return + if execute != "": let json = fmt"""{"command":"{escapeJson(command)}"}""" let path = fmt"/services/{execute}/execute" @@ -620,8 +636,9 @@ proc main() = if args[0] == "service": var name, ports, bootstrap, bootstrapFile, serviceType = "" var list = false - var info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network = "" + var info, logs, tail, sleep, wake, destroy, resize, execute, command, dumpBootstrap, dumpFile, network = "" var vcpu = 0 + var resizeVcpu = 0 var inputFiles: seq[string] = @[] var svcEnvs: seq[string] = @[] var svcEnvFile = "" @@ -642,7 +659,7 @@ proc main() = of "-k": publicKey = args[i+1]; inc i else: discard inc i - cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey) + cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey) return while i < args.len: @@ -659,6 +676,8 @@ proc main() = of "--freeze": sleep = args[i+1]; inc i of "--unfreeze": wake = args[i+1]; inc i of "--destroy": destroy = args[i+1]; inc i + of "--resize": resize = args[i+1]; inc i + of "--vcpu": resizeVcpu = parseInt(args[i+1]); inc i of "--execute": execute = args[i+1]; inc i of "--command": command = args[i+1]; inc i of "--dump-bootstrap": dumpBootstrap = args[i+1]; inc i @@ -678,7 +697,7 @@ proc main() = inc i else: discard inc i - cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey) + cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey) return # Execute mode diff --git a/un.php b/un.php index b409781..5d7101a 100755 --- a/un.php +++ b/un.php @@ -660,6 +660,18 @@ function cmd_service($options) { return; } + if ($options['resize']) { + if (!$options['vcpu']) { + fwrite(STDERR, RED . "Error: --vcpu is required with --resize" . RESET . "\n"); + exit(1); + } + $payload = ['vcpu' => $options['vcpu']]; + api_request("/services/{$options['resize']}", 'PATCH', $payload, $keys); + $ram = $options['vcpu'] * 2; + echo GREEN . "Service resized to {$options['vcpu']} vCPU, {$ram} GB RAM" . RESET . "\n"; + return; + } + if ($options['execute']) { $payload = ['command' => $options['command']]; $result = api_request("/services/{$options['execute']}/execute", 'POST', $payload, $keys); @@ -783,6 +795,7 @@ function main() { 'sleep' => null, 'wake' => null, 'destroy' => null, + 'resize' => null, 'execute' => null, 'command' => null, 'dump_bootstrap' => null, @@ -896,6 +909,9 @@ function main() { case '--destroy': $options['destroy'] = $argv[++$i]; break; + case '--resize': + $options['resize'] = $argv[++$i]; + break; case '--execute': $options['execute'] = $argv[++$i]; break; @@ -977,6 +993,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires -v) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.pl b/un.pl index 4b5c2db..81b4ec5 100644 --- a/un.pl +++ b/un.pl @@ -524,6 +524,18 @@ sub cmd_service { return; } + if ($options->{resize}) { + unless ($options->{vcpu}) { + print STDERR "${RED}Error: --vcpu is required with --resize${RESET}\n"; + exit 1; + } + my $payload = { vcpu => $options->{vcpu} }; + api_request("/services/$options->{resize}", 'PATCH', $payload, $public_key, $secret_key); + my $ram = $options->{vcpu} * 2; + print "${GREEN}Service resized to $options->{vcpu} vCPU, $ram GB RAM${RESET}\n"; + return; + } + if ($options->{execute}) { my $payload = { command => $options->{command} }; my $result = api_request("/services/$options->{execute}/execute", 'POST', $payload, $public_key, $secret_key); @@ -734,6 +746,7 @@ sub main { sleep => undef, wake => undef, destroy => undef, + resize => undef, execute => undef, command => undef, dump_bootstrap => undef, @@ -811,6 +824,8 @@ sub main { $options{wake} = $ARGV[++$i]; } elsif ($arg eq '--destroy') { $options{destroy} = $ARGV[++$i]; + } elsif ($arg eq '--resize') { + $options{resize} = $ARGV[++$i]; } elsif ($arg eq '--execute') { $options{execute} = $ARGV[++$i]; } elsif ($arg eq '--command') { @@ -885,6 +900,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires -v) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.pro b/un.pro index c74ae79..bf19353 100644 --- a/un.pro +++ b/un.pro @@ -230,6 +230,16 @@ service_destroy(ServiceId) :- [ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]), shell(Cmd, 0). +% Service resize +service_resize(ServiceId, Vcpu) :- + get_public_key(PublicKey), + get_secret_key(SecretKey), + Ram is Vcpu * 2, + format(atom(Cmd), + 'BODY=\'\'{\"vcpu\":~w}\'\'; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:PATCH:/services/~w:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X PATCH https://api.unsandbox.com/services/~w -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY" >/dev/null && echo -e "\\x1b[32mService resized to ~w vCPU, ~w GB RAM\\x1b[0m"', + [Vcpu, ServiceId, SecretKey, ServiceId, PublicKey, Vcpu, Ram]), + shell(Cmd, 0). + % Service env status service_env_status(ServiceId) :- get_public_key(PublicKey), @@ -417,6 +427,23 @@ parse_service_args(['--logs', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- ser parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_sleep(ServiceId). parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_wake(ServiceId). parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_destroy(ServiceId). +parse_service_args(['--resize', ServiceId, '--vcpu', VcpuAtom|_], _, _, _, _, _, _, _, _, _, _) :- + atom_number(VcpuAtom, Vcpu), + ( Vcpu >= 1, Vcpu =< 8 + -> service_resize(ServiceId, Vcpu) + ; write(user_error, '\x1b[31mError: vCPU must be between 1 and 8\x1b[0m\n'), + halt(1) + ). +parse_service_args(['--resize', ServiceId, '-v', VcpuAtom|_], _, _, _, _, _, _, _, _, _, _) :- + atom_number(VcpuAtom, Vcpu), + ( Vcpu >= 1, Vcpu =< 8 + -> service_resize(ServiceId, Vcpu) + ; write(user_error, '\x1b[31mError: vCPU must be between 1 and 8\x1b[0m\n'), + halt(1) + ). +parse_service_args(['--resize', _|_], _, _, _, _, _, _, _, _, _, _) :- + write(user_error, '\x1b[31mError: --resize requires --vcpu or -v\x1b[0m\n'), + halt(1). parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _, _, _, _, _, _) :- ( Rest = ['--dump-file', DumpFile|_] -> service_dump_bootstrap(ServiceId, DumpFile) diff --git a/un.ps1 b/un.ps1 index 91005fe..cf54659 100644 --- a/un.ps1 +++ b/un.ps1 @@ -539,6 +539,36 @@ function Invoke-Service { return } + if ($Args -contains "--resize") { + $idx = [array]::IndexOf($Args, "--resize") + $serviceId = $Args[$idx + 1] + + # Get vcpu value from --vcpu or -v + $vcpuValue = 0 + if ($Args -contains "--vcpu") { + $vIdx = [array]::IndexOf($Args, "--vcpu") + $vcpuValue = [int]$Args[$vIdx + 1] + } elseif ($Args -contains "-v") { + $vIdx = [array]::IndexOf($Args, "-v") + $vcpuValue = [int]$Args[$vIdx + 1] + } + + if ($vcpuValue -le 0) { + Write-Error "Error: --resize requires --vcpu or -v" + exit 1 + } + if ($vcpuValue -lt 1 -or $vcpuValue -gt 8) { + Write-Error "Error: vCPU must be between 1 and 8" + exit 1 + } + + $payload = @{ vcpu = $vcpuValue } | ConvertTo-Json + Invoke-Api -Endpoint "/services/$serviceId" -Method "PATCH" -Body $payload + $ram = $vcpuValue * 2 + Write-Host "`e[32mService resized to $vcpuValue vCPU, $ram GB RAM`e[0m" + return + } + if ($Args -contains "--dump-bootstrap") { $idx = [array]::IndexOf($Args, "--dump-bootstrap") $serviceId = $Args[$idx + 1] @@ -681,6 +711,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires --vcpu or -v) --dump-bootstrap ID Dump bootstrap script from service --dump-file FILE Save bootstrap to file (with --dump-bootstrap) diff --git a/un.py b/un.py index d6049f2..4315c2c 100644 --- a/un.py +++ b/un.py @@ -744,6 +744,15 @@ def cmd_service(args): print(f"{GREEN}Service destroyed: {args.destroy}{RESET}") return + if args.resize: + if not args.vcpu: + print(f"{RED}Error: --vcpu required with --resize{RESET}", file=sys.stderr) + sys.exit(1) + payload = {"vcpu": args.vcpu} + result = api_request(f"/services/{args.resize}", method="PATCH", data=payload, public_key=public_key, secret_key=secret_key) + print(f"{GREEN}Service resized to {args.vcpu} vCPU, {args.vcpu * 2}GB RAM{RESET}") + return + if args.snapshot: payload = {} if args.snapshot_name: @@ -925,9 +934,10 @@ Examples: service_parser.add_argument("--info", metavar="ID", help="Get service details") service_parser.add_argument("--tail", metavar="ID", help="Get last 9000 lines of logs") service_parser.add_argument("--logs", metavar="ID", help="Get all logs") - service_parser.add_argument("--freeze", metavar="ID", help="Freeze service") - service_parser.add_argument("--unfreeze", metavar="ID", help="Unfreeze service") + service_parser.add_argument("--freeze", "--sleep", dest="sleep", metavar="ID", help="Freeze service") + service_parser.add_argument("--unfreeze", "--wake", dest="wake", metavar="ID", help="Unfreeze service") service_parser.add_argument("--destroy", metavar="ID", help="Destroy service") + service_parser.add_argument("--resize", metavar="ID", help="Resize service vCPU/memory") 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") diff --git a/un.r b/un.r index ac7a740..d9f6399 100644 --- a/un.r +++ b/un.r @@ -145,6 +145,8 @@ api_request <- function(endpoint, public_key, secret_key, method = "GET", data = response <- POST(url, headers, body = body_content, encode = "raw", timeout(300)) } else if (method == "DELETE") { response <- DELETE(url, headers, timeout(300)) + } else if (method == "PATCH") { + response <- PATCH(url, headers, body = body_content, encode = "raw", timeout(300)) } else { stop(paste("Unsupported method:", method)) } @@ -710,6 +712,18 @@ cmd_service <- function(args) { return() } + if (!is.null(args$resize)) { + if (is.null(args$vcpu) || args$vcpu < 1 || args$vcpu > 8) { + cat(sprintf("%sError: --resize requires --vcpu N (1-8)%s\n", RED, RESET), file = stderr()) + quit(status = 1) + } + payload <- list(vcpu = args$vcpu) + result <- api_request(paste0("/services/", args$resize), public_key, secret_key, method = "PATCH", data = payload) + ram <- args$vcpu * 2 + cat(sprintf("%sService resized to %d vCPU, %d GB RAM%s\n", GREEN, args$vcpu, ram, RESET)) + return() + } + if (!is.null(args$snapshot_svc)) { payload <- list() if (!is.null(args$snapshot_name)) { @@ -871,6 +885,7 @@ parse_args <- function() { sleep = NULL, wake = NULL, destroy = NULL, + resize = NULL, delete = NULL, clone = NULL, clone_name = NULL, @@ -977,6 +992,10 @@ parse_args <- function() { i <- i + 1 result$destroy <- args[i] i <- i + 1 + } else if (arg == "--resize") { + i <- i + 1 + result$resize <- args[i] + i <- i + 1 } else if (arg == "--dump-bootstrap") { i <- i + 1 result$dump_bootstrap <- args[i] diff --git a/un.raku b/un.raku index 84af4eb..3cb0f5b 100644 --- a/un.raku +++ b/un.raku @@ -123,6 +123,13 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$sec $body = to-json(%data); @args.append: '-d', $body; } + } elsif $method eq 'PATCH' { + @args.append: '-X', 'PATCH'; + @args.append: '-H', 'Content-Type: application/json'; + if %data { + $body = to-json(%data); + @args.append: '-d', $body; + } } @args.append: '-H', "Authorization: Bearer $public-key"; @@ -438,6 +445,7 @@ sub cmd-service(@args) { my $sleep-id = ''; my $wake-id = ''; my $destroy-id = ''; + my $resize-id = ''; my $dump-bootstrap-id = ''; my $dump-file = ''; my $name = ''; @@ -531,6 +539,10 @@ sub cmd-service(@args) { $i++; $destroy-id = @args[$i]; } + when '--resize' { + $i++; + $resize-id = @args[$i]; + } when '--dump-bootstrap' { $i++; $dump-bootstrap-id = @args[$i]; @@ -630,6 +642,18 @@ sub cmd-service(@args) { return; } + if $resize-id { + unless $vcpu >= 1 && $vcpu <= 8 { + note "{$RED}Error: --resize requires --vcpu N (1-8){$RESET}"; + exit 1; + } + my %payload = vcpu => $vcpu; + api-request("/services/$resize-id", 'PATCH', %payload, :$public-key, :$secret-key); + my $ram = $vcpu * 2; + say "{$GREEN}Service resized to $vcpu vCPU, $ram GB RAM{$RESET}"; + return; + } + if $dump-bootstrap-id { note "Fetching bootstrap script from $dump-bootstrap-id..."; my %payload = command => "cat /tmp/bootstrap.sh"; diff --git a/un.rb b/un.rb index fcbeeb4..9a79316 100644 --- a/un.rb +++ b/un.rb @@ -139,6 +139,7 @@ def api_request(endpoint, method: 'GET', data: nil, keys:) when 'GET' then Net::HTTP::Get.new(uri) when 'POST' then Net::HTTP::Post.new(uri) when 'DELETE' then Net::HTTP::Delete.new(uri) + when 'PATCH' then Net::HTTP::Patch.new(uri) else raise "Unknown method: #{method}" end @@ -678,6 +679,18 @@ def cmd_service(options) return end + if options[:resize] + unless options[:vcpu] + warn "#{RED}Error: --vcpu is required with --resize#{RESET}" + exit 1 + end + payload = { vcpu: options[:vcpu] } + api_request("/services/#{options[:resize]}", method: 'PATCH', data: payload, keys: keys) + ram = options[:vcpu] * 2 + puts "#{GREEN}Service resized to #{options[:vcpu]} vCPU, #{ram} GB RAM#{RESET}" + return + end + if options[:snapshot_service] payload = {} payload[:name] = options[:snapshot_name] if options[:snapshot_name] @@ -826,6 +839,7 @@ def main sleep: nil, wake: nil, destroy: nil, + resize: nil, execute: nil, dump_bootstrap: nil, dump_file: nil, @@ -938,6 +952,9 @@ def main when '--destroy' i += 1 options[:destroy] = ARGV[i] + when '--resize' + i += 1 + options[:resize] = ARGV[i] when '--execute' i += 1 options[:execute] = ARGV[i] @@ -1073,6 +1090,7 @@ def main --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires -v) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.rs b/un.rs index aea0783..e2cb6b1 100644 --- a/un.rs +++ b/un.rs @@ -571,6 +571,7 @@ fn cmd_service( sleep: Option<&str>, wake: Option<&str>, destroy: Option<&str>, + resize: Option<&str>, execute: Option<&str>, command: Option<&str>, dump_bootstrap: Option<&str>, @@ -632,6 +633,17 @@ fn cmd_service( return; } + if let Some(id) = resize { + let v = vcpu.unwrap_or_else(|| { + eprintln!("{}Error: --resize requires -v {}", RED, RESET); + process::exit(1); + }); + let json = format!(r#"{{"vcpu":{}}}"#, v); + api_request(&format!("/services/{}", id), "PATCH", Some(&json), public_key, secret_key); + println!("{}Service resized to {} vCPU, {} GB RAM{}", GREEN, v, v * 2, RESET); + return; + } + if let Some(id) = execute { let cmd = command.unwrap_or(""); let json = format!(r#"{{"command":"{}"}}"#, escape_json(cmd)); @@ -994,6 +1006,7 @@ fn main() { args.iter().position(|x| x == "--freeze").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--unfreeze").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--destroy").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), + args.iter().position(|x| x == "--resize").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--execute").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--command").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--dump-bootstrap").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), diff --git a/un.scm b/un.scm index bfb283c..4906b10 100644 --- a/un.scm +++ b/un.scm @@ -211,6 +211,38 @@ (exit 1)) output)) +(define (curl-patch api-key endpoint json-data) + (let* ((tmp-file (write-temp-file json-data)) + (keys (get-api-keys)) + (public-key (car keys)) + (secret-key (cadr keys)) + (auth-headers (build-auth-headers public-key secret-key "PATCH" endpoint json-data)) + (cmd (string-append "curl -s -X PATCH https://api.unsandbox.com" endpoint + " -H 'Content-Type: application/json' " + (string-join auth-headers " ") + " -d @" tmp-file)) + (port (open-input-pipe cmd)) + (output (let loop ((lines '())) + (let ((line (read-line port))) + (if (eof-object? line) + (string-join (reverse lines) "\n") + (loop (cons line lines))))))) + (close-pipe port) + (delete-file tmp-file) + ;; Check for clock drift errors + (when (and (string-contains output "timestamp") + (or (string-contains output "401") + (string-contains output "expired") + (string-contains output "invalid"))) + (format (current-error-port) "~aError: Request timestamp expired (must be within 5 minutes of server time)~a\n" red reset) + (format (current-error-port) "~aYour computer's clock may have drifted.~a\n" yellow reset) + (format (current-error-port) "~aCheck your system time and sync with NTP if needed:~a\n" yellow reset) + (format (current-error-port) "~a Linux: sudo ntpdate -s time.nist.gov~a\n" yellow reset) + (format (current-error-port) "~a macOS: sudo sntp -sS time.apple.com~a\n" yellow reset) + (format (current-error-port) "~a Windows: w32tm /resync~a\n" yellow reset) + (exit 1)) + output)) + (define (curl-post-portal api-key endpoint json-data) (let* ((tmp-file (write-temp-file json-data)) (keys (get-api-keys)) @@ -434,7 +466,7 @@ (display response) (newline)))))) -(define (service-cmd action id name ports bootstrap bootstrap-file type input-files env-vars env-file) +(define (service-cmd action id name ports bootstrap bootstrap-file type input-files env-vars env-file vcpu) (let ((api-key (get-api-key))) (cond ((equal? action "list") @@ -455,6 +487,15 @@ ((equal? action "destroy") (curl-delete api-key (format #f "/services/~a" id)) (format #t "~aService destroyed: ~a~a\n" green id reset)) + ((equal? action "resize") + (if (and vcpu (>= vcpu 1) (<= vcpu 8)) + (let* ((json (format #f "{\"vcpu\":~a}" vcpu)) + (ram (* vcpu 2))) + (curl-patch api-key (format #f "/services/~a" id) json) + (format #t "~aService resized to ~a vCPU, ~a GB RAM~a\n" green vcpu ram reset)) + (begin + (format (current-error-port) "~aError: --resize requires --vcpu N (1-8)~a\n" red reset) + (exit 1)))) ((equal? action "env-status") (service-env-status api-key id)) ((equal? action "env-set") @@ -568,23 +609,37 @@ ((equal? (car args) "service") (cond ((and (> (length args) 1) (equal? (cadr args) "--list")) - (service-cmd "list" #f #f #f #f #f #f '() '() #f)) + (service-cmd "list" #f #f #f #f #f #f '() '() #f #f)) ((and (> (length args) 2) (equal? (cadr args) "--info")) - (service-cmd "info" (caddr args) #f #f #f #f #f '() '() #f)) + (service-cmd "info" (caddr args) #f #f #f #f #f '() '() #f #f)) ((and (> (length args) 2) (equal? (cadr args) "--logs")) - (service-cmd "logs" (caddr args) #f #f #f #f #f '() '() #f)) + (service-cmd "logs" (caddr args) #f #f #f #f #f '() '() #f #f)) ((and (> (length args) 2) (equal? (cadr args) "--freeze")) - (service-cmd "sleep" (caddr args) #f #f #f #f #f '() '() #f)) + (service-cmd "sleep" (caddr args) #f #f #f #f #f '() '() #f #f)) ((and (> (length args) 2) (equal? (cadr args) "--unfreeze")) - (service-cmd "wake" (caddr args) #f #f #f #f #f '() '() #f)) + (service-cmd "wake" (caddr args) #f #f #f #f #f '() '() #f #f)) ((and (> (length args) 2) (equal? (cadr args) "--destroy")) - (service-cmd "destroy" (caddr args) #f #f #f #f #f '() '() #f)) + (service-cmd "destroy" (caddr args) #f #f #f #f #f '() '() #f #f)) + ((and (> (length args) 2) (equal? (cadr args) "--resize")) + ;; Parse --resize ID -v N + (let* ((resize-id (caddr args)) + (rest-args (cdddr args)) + (vcpu-val #f)) + ;; Look for -v or --vcpu + (let loop ((args rest-args)) + (when (pair? args) + (cond + ((and (or (equal? (car args) "-v") (equal? (car args) "--vcpu")) (pair? (cdr args))) + (set! vcpu-val (string->number (cadr args))) + (loop (cddr args))) + (else (loop (cdr args)))))) + (service-cmd "resize" resize-id #f #f #f #f #f '() '() #f vcpu-val))) ((and (> (length args) 3) (equal? (cadr args) "--execute")) - (service-cmd "execute" (caddr args) #f #f (list-ref args 3) #f #f '() '() #f)) + (service-cmd "execute" (caddr args) #f #f (list-ref args 3) #f #f '() '() #f #f)) ((and (> (length args) 3) (equal? (cadr args) "--dump-bootstrap")) - (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f (list-ref args 3) '() '() #f)) + (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f (list-ref args 3) '() '() #f #f)) ((and (> (length args) 2) (equal? (cadr args) "--dump-bootstrap")) - (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f #f '() '() #f)) + (service-cmd "dump-bootstrap" (caddr args) #f #f #f #f #f '() '() #f #f)) ;; Service env subcommand: service env [options] ((and (> (length args) 1) (equal? (cadr args) "env")) (if (< (length args) 4) @@ -596,12 +651,12 @@ (rest-args (if (> (length args) 4) (list-tail args 4) '()))) (cond ((equal? env-action "status") - (service-cmd "env-status" service-id #f #f #f #f #f '() '() #f)) + (service-cmd "env-status" service-id #f #f #f #f #f '() '() #f #f)) ((equal? env-action "set") ;; Parse -e and --env-file from rest-args (let loop ((args rest-args) (env-vars '()) (env-file #f)) (if (null? args) - (service-cmd "env-set" service-id #f #f #f #f #f '() env-vars env-file) + (service-cmd "env-set" service-id #f #f #f #f #f '() env-vars env-file #f) (cond ((and (equal? (car args) "-e") (pair? (cdr args))) (loop (cddr args) (cons (cadr args) env-vars) env-file)) @@ -609,9 +664,9 @@ (loop (cddr args) env-vars (cadr args))) (else (loop (cdr args) env-vars env-file)))))) ((equal? env-action "export") - (service-cmd "env-export" service-id #f #f #f #f #f '() '() #f)) + (service-cmd "env-export" service-id #f #f #f #f #f '() '() #f #f)) ((equal? env-action "delete") - (service-cmd "env-delete" service-id #f #f #f #f #f '() '() #f)) + (service-cmd "env-delete" service-id #f #f #f #f #f '() '() #f #f)) (else (format (current-error-port) "~aUnknown env action: ~a~a\n" red env-action reset) (exit 1)))))) @@ -650,7 +705,7 @@ ((equal? (car args) "-f") (loop (cddr args))) ; skip -f, already parsed (else (loop (cdr args)))))) - (service-cmd "create" #f name ports bootstrap bootstrap-file type input-files env-vars env-file))) + (service-cmd "create" #f name ports bootstrap bootstrap-file type input-files env-vars env-file #f))) (else (display "Error: Invalid service command\n" (current-error-port)) (exit 1)))) diff --git a/un.sh b/un.sh index 7cd7462..e2be46d 100644 --- a/un.sh +++ b/un.sh @@ -773,6 +773,7 @@ cmd_service() { local sleep="" local wake="" local destroy="" + local resize="" local execute="" local command="" local network="" @@ -853,6 +854,10 @@ cmd_service() { destroy="$2" shift 2 ;; + --resize) + resize="$2" + shift 2 + ;; --execute) execute="$2" shift 2 @@ -982,6 +987,18 @@ cmd_service() { return fi + if [[ -n "$resize" ]]; then + if [[ -z "$vcpu" ]]; then + echo -e "${RED}Error: --vcpu is required with --resize${RESET}" >&2 + exit 1 + fi + local payload=$(jq -n --argjson v "$vcpu" '{vcpu: $v}') + api_request "/services/$resize" "PATCH" "$payload" "$api_key" > /dev/null + local ram=$((vcpu * 2)) + echo -e "${GREEN}Service resized to $vcpu vCPU, $ram GB RAM${RESET}" + return + fi + if [[ -n "$execute" ]]; then local payload=$(jq -n --arg cmd "$command" '{command: $cmd}') local result=$(api_request "/services/$execute/execute" "POST" "$payload" "$api_key") @@ -1418,6 +1435,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires -v) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.tcl b/un.tcl index 255aa77..e33a89a 100755 --- a/un.tcl +++ b/un.tcl @@ -675,6 +675,7 @@ proc cmd_service {args} { set sleep_id "" set wake_id "" set destroy_id "" + set resize_id "" set dump_bootstrap_id "" set dump_file "" set name "" @@ -734,6 +735,10 @@ proc cmd_service {args} { incr i set destroy_id [lindex $args $i] } + --resize { + incr i + set resize_id [lindex $args $i] + } --dump-bootstrap { incr i set dump_bootstrap_id [lindex $args $i] @@ -842,6 +847,18 @@ proc cmd_service {args} { return } + if {$resize_id ne ""} { + if {$vcpu < 1 || $vcpu > 8} { + puts stderr "${::RED}Error: --resize requires --vcpu N (1-8)${::RESET}" + exit 1 + } + set payload [list vcpu $vcpu] + api_request "/services/$resize_id" "PATCH" $payload $public_key $secret_key + set ram [expr {$vcpu * 2}] + puts "${::GREEN}Service resized to $vcpu vCPU, $ram GB RAM${::RESET}" + return + } + if {$dump_bootstrap_id ne ""} { puts stderr "Fetching bootstrap script from $dump_bootstrap_id..." set payload [list command [::json::write string "cat /tmp/bootstrap.sh"]] diff --git a/un.ts b/un.ts index 10f7401..dfb2339 100644 --- a/un.ts +++ b/un.ts @@ -109,6 +109,7 @@ interface Args { sleep: string | null; wake: string | null; destroy: string | null; + resize: string | null; execute: string | null; command_arg: string | null; extend: boolean; @@ -626,6 +627,17 @@ async function cmdService(args: Args): Promise { return; } + if (args.resize) { + if (!args.vcpu) { + console.error(`${RED}Error: --vcpu required with --resize${RESET}`); + process.exit(1); + } + const payload = { vcpu: args.vcpu }; + await apiRequest(`/services/${args.resize}`, "PATCH", payload, keys); + console.log(`${GREEN}Service resized to ${args.vcpu} vCPU, ${args.vcpu * 2}GB RAM${RESET}`); + return; + } + if (args.execute) { const payload = { command: args.command_arg }; const result = await apiRequest(`/services/${args.execute}/execute`, "POST", payload, keys); @@ -813,6 +825,7 @@ function parseArgs(argv: string[]): Args { sleep: null, wake: null, destroy: null, + resize: null, execute: null, command_arg: null, dumpBootstrap: null, @@ -922,6 +935,9 @@ function parseArgs(argv: string[]): Args { } else if (arg === '--destroy' && i + 1 < argv.length) { args.destroy = argv[++i]; i++; + } else if (arg === '--resize' && i + 1 < argv.length) { + args.resize = argv[++i]; + i++; } else if (arg === '--execute' && i + 1 < argv.length) { args.execute = argv[++i]; i++; @@ -1007,6 +1023,7 @@ Service options: --freeze ID Freeze service --unfreeze ID Unfreeze service --destroy ID Destroy service + --resize ID Resize service (requires -v) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script diff --git a/un.v b/un.v index 49a03f2..613d8d9 100644 --- a/un.v +++ b/un.v @@ -410,7 +410,7 @@ fn cmd_session(list bool, kill string, shell string, network string, vcpu int, t println(exec_curl(cmd)) } -fn cmd_service(name string, ports string, service_type string, bootstrap string, bootstrap_file string, list bool, info string, logs string, tail string, sleep string, wake string, destroy string, execute string, command string, dump_bootstrap string, dump_file string, network string, vcpu int, input_files []string, svc_envs []string, svc_env_file string, api_key string) { +fn cmd_service(name string, ports string, service_type string, bootstrap string, bootstrap_file string, list bool, info string, logs string, tail string, sleep string, wake string, destroy string, resize string, execute string, command string, dump_bootstrap string, dump_file string, network string, vcpu int, input_files []string, svc_envs []string, svc_env_file string, api_key string) { pub_key := get_public_key() secret_key := get_secret_key() @@ -459,6 +459,19 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string, return } + if resize != '' { + if vcpu < 1 || vcpu > 8 { + eprintln('${red}Error: --resize requires --vcpu N (1-8)${reset}') + exit(1) + } + json := '{"vcpu":${vcpu}}' + cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:PATCH:/services/${resize}:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X PATCH '${api_base}/services/${resize}' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" + exec_curl(cmd) + ram := vcpu * 2 + println('${green}Service resized to ${vcpu} vCPU, ${ram} GB RAM${reset}') + return + } + if execute != '' { json := '{"command":"${escape_json(command)}"}' cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/services/${execute}/execute:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services/${execute}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\"" @@ -667,6 +680,7 @@ fn main() { mut sleep := '' mut wake := '' mut destroy := '' + mut resize := '' mut execute := '' mut command := '' mut dump_bootstrap := '' @@ -736,6 +750,10 @@ fn main() { i++ destroy = os.args[i] } + '--resize' { + i++ + resize = os.args[i] + } '--execute' { i++ execute = os.args[i] @@ -793,7 +811,7 @@ fn main() { return } - cmd_service(name, ports, service_type, bootstrap, bootstrap_file, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, + cmd_service(name, ports, service_type, bootstrap, bootstrap_file, list, info, logs, tail, sleep, wake, destroy, resize, execute, command, dump_bootstrap, dump_file, network, vcpu, input_files, svc_envs, svc_env_file, api_key) return } diff --git a/un.zig b/un.zig index 250a2ff..65350dc 100644 --- a/un.zig +++ b/un.zig @@ -397,6 +397,8 @@ pub fn main() !u8 { var command: ?[]const u8 = null; var dump_bootstrap: ?[]const u8 = null; var dump_file: ?[]const u8 = null; + var resize: ?[]const u8 = null; + var vcpu: i32 = 0; var input_files = std.ArrayList([]const u8).init(allocator); defer input_files.deinit(); var svc_envs = std.ArrayList([]const u8).init(allocator); @@ -444,6 +446,12 @@ pub fn main() !u8 { } else if (mem.eql(u8, args[i], "--dump-file") and i + 1 < args.len) { i += 1; dump_file = args[i]; + } else if (mem.eql(u8, args[i], "--resize") and i + 1 < args.len) { + i += 1; + resize = args[i]; + } else if (mem.eql(u8, args[i], "-v") and i + 1 < args.len) { + i += 1; + vcpu = std.fmt.parseInt(i32, args[i], 10) catch 0; } else if (mem.eql(u8, args[i], "-e") and i + 1 < args.len) { i += 1; try svc_envs.append(args[i]); @@ -552,6 +560,35 @@ pub fn main() !u8 { std.debug.print("\x1b[31mError: Failed to fetch bootstrap (service not running or no bootstrap file)\x1b[0m\n", .{}); return 1; } + } else if (resize) |resize_id| { + // Validate vcpu + if (vcpu < 1 or vcpu > 8) { + std.debug.print("{s}Error: --resize requires -v N (1-8){s}\n", .{ RED, RESET }); + return 1; + } + + // Build JSON body + var vcpu_buf: [16]u8 = undefined; + const vcpu_str = std.fmt.bufPrint(&vcpu_buf, "{d}", .{vcpu}) catch "0"; + const json = try std.fmt.allocPrint(allocator, "{{\"vcpu\":{s}}}", .{vcpu_str}); + defer allocator.free(json); + + // Build path + const path = try std.fmt.allocPrint(allocator, "/services/{s}", .{resize_id}); + defer allocator.free(path); + + // Build auth headers + const auth_headers = try buildAuthCmd(allocator, "PATCH", path, json, public_key, secret_key); + defer allocator.free(auth_headers); + + // Execute PATCH request + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X PATCH '{s}/services/{s}' -H 'Content-Type: application/json' {s} -d '{s}'", .{ API_BASE, resize_id, auth_headers, json }); + defer allocator.free(cmd); + _ = std.c.system(cmd.ptr); + + // Calculate RAM + const ram = vcpu * 2; + std.debug.print("\n{s}Service resized to {d} vCPU, {d} GB RAM{s}\n", .{ GREEN, vcpu, ram, RESET }); } else if (name) |n| { var json_buf: [65536]u8 = undefined; var json_stream = std.io.fixedBufferStream(&json_buf);