diff --git a/clients/bash/sync/src/un.sh b/clients/bash/sync/src/un.sh index d734267..70b672a 100644 --- a/clients/bash/sync/src/un.sh +++ b/clients/bash/sync/src/un.sh @@ -424,6 +424,7 @@ service_create() { local name="$1" local ports="${2:-}" local bootstrap="${3:-}" + local input_files_json="${4:-}" local body body=$(jq -n --arg name "$name" '{name: $name}') @@ -434,6 +435,9 @@ service_create() { if [ -n "$bootstrap" ]; then body=$(echo "$body" | jq --arg boot "$bootstrap" '. + {bootstrap: $boot}') fi + if [ -n "$input_files_json" ]; then + body=$(echo "$body" | jq --argjson files "$input_files_json" '. + {input_files: $files}') + fi api_request "POST" "/services" "$body" } @@ -472,10 +476,14 @@ service_set_unfreeze_on_demand() { service_redeploy() { local service_id="$1" local bootstrap="${2:-}" + local input_files_json="${3:-}" local body="{}" if [ -n "$bootstrap" ]; then body=$(jq -n --arg boot "$bootstrap" '{bootstrap: $boot}') fi + if [ -n "$input_files_json" ]; then + body=$(echo "$body" | jq --argjson files "$input_files_json" '. + {input_files: $files}') + fi api_request "POST" "/services/$service_id/redeploy" "$body" } @@ -902,6 +910,9 @@ cmd_service() { local target="" local name="" local ports="" + local bootstrap="" + local bootstrap_file="" + local -a files=() while [ $# -gt 0 ]; do case "$1" in @@ -913,13 +924,47 @@ cmd_service() { --lock) action="lock"; target="$2"; shift ;; --unlock) action="unlock"; target="$2"; shift ;; --logs) action="logs"; target="$2"; shift ;; + --redeploy) action="redeploy"; target="$2"; shift ;; --name) name="$2"; shift ;; --ports) ports="$2"; shift ;; + --bootstrap) bootstrap="$2"; shift ;; + --bootstrap-file) bootstrap_file="$2"; shift ;; + -f|--file) files+=("$2"); shift ;; *) ;; esac shift done + # Build input_files JSON from -f args + local input_files_json="" + if [ ${#files[@]} -gt 0 ]; then + input_files_json="[" + local first=1 + for fpath in "${files[@]}"; do + if [ ! -f "$fpath" ]; then + echo -e "${RED}Error: File not found: $fpath${RESET}" >&2 + exit 1 + fi + local encoded + encoded=$(base64 -w0 "$fpath" 2>/dev/null || base64 "$fpath" 2>/dev/null) + local fname + fname=$(basename "$fpath") + [ "$first" -eq 0 ] && input_files_json="$input_files_json," + input_files_json="$input_files_json{\"filename\":$(echo "$fname" | jq -Rs .),\"content\":$(echo "$encoded" | jq -Rs .)}" + first=0 + done + input_files_json="$input_files_json]" + fi + + # Resolve bootstrap from file if provided + if [ -n "$bootstrap_file" ]; then + if [ ! -f "$bootstrap_file" ]; then + echo -e "${RED}Error: Bootstrap file not found: $bootstrap_file${RESET}" >&2 + exit 1 + fi + bootstrap=$(cat "$bootstrap_file") + fi + case "$action" in list) local result @@ -954,14 +999,19 @@ cmd_service() { result=$(service_logs "$target") echo "$result" | jq -r '.logs // empty' ;; + redeploy) + local result + result=$(service_redeploy "$target" "$bootstrap" "$input_files_json") + echo -e "${GREEN}Service redeployed: $target${RESET}" + ;; *) if [ -n "$name" ]; then local result - result=$(service_create "$name" "$ports" "") + result=$(service_create "$name" "$ports" "$bootstrap" "$input_files_json") echo -e "${GREEN}Service created${RESET}" echo "$result" | jq -r '"ID: \(.id)\nName: \(.name)"' else - echo "Usage: bash un.sh service --list|--info ID|--destroy ID|--name NAME" >&2 + echo "Usage: bash un.sh service --list|--info ID|--destroy ID|--redeploy ID|--name NAME" >&2 exit 1 fi ;; @@ -1138,8 +1188,12 @@ Service options: --lock ID Lock service --unlock ID Unlock service --logs ID Get service logs + --redeploy ID Re-run bootstrap (supports -f, --bootstrap) --name NAME Create service with name --ports PORTS Service ports (comma-separated) + --bootstrap CMD Bootstrap command + --bootstrap-file FILE Bootstrap from file + -f, --file FILE Add input file (can repeat) Snapshot options: --list List all snapshots diff --git a/clients/cpp/sync/src/un.cpp b/clients/cpp/sync/src/un.cpp index e4ffaef..3d4d309 100644 --- a/clients/cpp/sync/src/un.cpp +++ b/clients/cpp/sync/src/un.cpp @@ -705,9 +705,32 @@ string service_unlock(const string& service_id, const string& public_key, const return exec_curl(cmd); } -string service_redeploy(const string& service_id, const string& bootstrap, const string& public_key, const string& secret_key) { +string service_redeploy(const string& service_id, const string& bootstrap, const vector& input_files, const string& public_key, const string& secret_key) { string path = "/services/" + service_id + "/redeploy"; - string body = bootstrap.empty() ? "{}" : "{\"bootstrap\":\"" + escape_json(bootstrap) + "\"}"; + ostringstream json; + json << "{"; + bool has_field = false; + if (!bootstrap.empty()) { + json << "\"bootstrap\":\"" << escape_json(bootstrap) << "\""; + has_field = true; + } + if (!input_files.empty()) { + if (has_field) json << ","; + json << "\"input_files\":["; + for (size_t i = 0; i < input_files.size(); i++) { + if (i > 0) json << ","; + ifstream file(input_files[i], ios::binary); + if (!file) continue; + ostringstream content; + content << file.rdbuf(); + string b64 = base64_encode(content.str()); + string filename = input_files[i].substr(input_files[i].find_last_of("/\\") + 1); + json << "{\"filename\":\"" << filename << "\",\"content_base64\":\"" << b64 << "\"}"; + } + json << "]"; + } + json << "}"; + string body = json.str(); string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); string cmd = "curl -s -X POST '" + API_BASE + path + "' " "-H 'Content-Type: application/json' " @@ -1202,7 +1225,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& 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& set_unfreeze_on_demand_id, int set_unfreeze_on_demand_enabled, int unfreeze_on_demand, 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& redeploy, const string& network, int vcpu, const vector& envs, const string& env_file, const string& env_action, const string& env_target, const string& set_unfreeze_on_demand_id, int set_unfreeze_on_demand_enabled, int unfreeze_on_demand, 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); @@ -1328,6 +1351,62 @@ void cmd_service(const string& name, const string& ports, const string& type, co return; } + if (!redeploy.empty()) { + // Bootstrap is optional for redeploy: + // - If provided via --bootstrap or --bootstrap-file, use it + // - If omitted, API will use the stored encrypted bootstrap + string bootstrap_to_use = bootstrap; + if (!bootstrap_file.empty()) { + struct stat st; + if (stat(bootstrap_file.c_str(), &st) == 0) { + bootstrap_to_use = read_file(bootstrap_file); + } else { + cerr << RED << "Error: Bootstrap file not found: " << bootstrap_file << RESET << endl; + exit(1); + } + } + cout << YELLOW << "Redeploying service " << redeploy << "..." << RESET << endl; + ostringstream json; + json << "{"; + bool has_field = false; + if (!bootstrap_to_use.empty()) { + if (!bootstrap_file.empty()) { + json << "\"bootstrap_content\":\"" << escape_json(bootstrap_to_use) << "\""; + } else { + json << "\"bootstrap\":\"" << escape_json(bootstrap_to_use) << "\""; + } + has_field = true; + } + if (!files.empty()) { + if (has_field) json << ","; + json << "\"input_files\":["; + for (size_t i = 0; i < files.size(); i++) { + if (i > 0) json << ","; + ifstream file(files[i], ios::binary); + if (!file) { + cerr << RED << "Error: Input file not found: " << files[i] << RESET << endl; + exit(1); + } + ostringstream content; + content << file.rdbuf(); + string b64 = base64_encode(content.str()); + string filename = files[i].substr(files[i].find_last_of("/\\") + 1); + json << "{\"filename\":\"" << filename << "\",\"content_base64\":\"" << b64 << "\"}"; + } + json << "]"; + } + json << "}"; + string path = "/services/" + redeploy + "/redeploy"; + string auth_headers = build_auth_headers("POST", path, json.str(), public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " " + "-d '" + json.str() + "'"; + string result = exec_curl(cmd); + cout << result << endl; + return; + } + if (!dump_bootstrap.empty()) { cerr << "Fetching bootstrap script from " << dump_bootstrap << "..." << endl; string json_body = "{\"command\":\"cat /tmp/bootstrap.sh\"}"; @@ -1778,7 +1857,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, resize, execute, command, dump_bootstrap, dump_file, network; + string info, logs, tail, sleep, wake, destroy, resize, execute, command, dump_bootstrap, dump_file, network, redeploy; int vcpu = 0; vector files; vector envs; @@ -1816,6 +1895,7 @@ int main(int argc, char* argv[]) { 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 == "--redeploy" && i+1 < argc) redeploy = argv[++i]; else if (arg == "--dump-bootstrap" && i+1 < argc) dump_bootstrap = argv[++i]; else if (arg == "--dump-file" && i+1 < argc) dump_file = argv[++i]; else if (arg == "-n" && i+1 < argc) network = argv[++i]; @@ -1832,7 +1912,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, resize, execute, command, dump_bootstrap, dump_file, network, vcpu, envs, env_file, env_action, env_target, set_unfreeze_on_demand_id, set_unfreeze_on_demand_enabled, unfreeze_on_demand, 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, redeploy, network, vcpu, envs, env_file, env_action, env_target, set_unfreeze_on_demand_id, set_unfreeze_on_demand_enabled, unfreeze_on_demand, public_key, secret_key); return 0; } diff --git a/clients/csharp/sync/src/Un.cs b/clients/csharp/sync/src/Un.cs index 56c385e..a0b5c27 100644 --- a/clients/csharp/sync/src/Un.cs +++ b/clients/csharp/sync/src/Un.cs @@ -92,6 +92,10 @@ class Un { CmdKey(parsedArgs); } + else if (parsedArgs.Command == "languages") + { + CmdLanguages(parsedArgs); + } else if (parsedArgs.SourceFile != null) { CmdExecute(parsedArgs); @@ -438,6 +442,32 @@ class Un return; } + if (args.ServiceRedeploy != null) + { + var payload = new Dictionary(); + if (args.ServiceBootstrap != null) + { + payload["bootstrap"] = args.ServiceBootstrap; + } + if (args.Files.Count > 0) + { + var inputFiles = new List>(); + foreach (var filepath in args.Files) + { + var content = File.ReadAllBytes(filepath); + inputFiles.Add(new Dictionary + { + ["filename"] = Path.GetFileName(filepath), + ["content"] = Convert.ToBase64String(content) + }); + } + payload["input_files"] = inputFiles; + } + ApiRequest($"/services/{args.ServiceRedeploy}/redeploy", "POST", payload.Count > 0 ? payload : null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service redeployed: {args.ServiceRedeploy}{RESET}"); + return; + } + if (args.ServiceExecute != null) { var payload = new Dictionary @@ -529,6 +559,20 @@ class Un { payload["unfreeze_on_demand"] = true; } + if (args.Files.Count > 0) + { + var inputFiles = new List>(); + foreach (var filepath in args.Files) + { + var content = File.ReadAllBytes(filepath); + inputFiles.Add(new Dictionary + { + ["filename"] = Path.GetFileName(filepath), + ["content"] = Convert.ToBase64String(content) + }); + } + payload["input_files"] = inputFiles; + } var result = ApiRequest("/services", "POST", payload, publicKey, secretKey); string serviceId = result.ContainsKey("id") ? (string)result["id"] : null; @@ -1234,10 +1278,12 @@ class Un public string ServiceShowFreezePage = null; public bool ServiceShowFreezePageEnabled = true; public bool ServiceCreateUnfreezeOnDemand = false; + public string ServiceRedeploy = null; public string EnvFile = null; public string EnvAction = null; public string EnvTarget = null; public bool KeyExtend = false; + public bool LanguagesJson = false; } static Args ParseArgs(string[] args) @@ -1249,6 +1295,7 @@ class Un if (arg == "session") result.Command = "session"; else if (arg == "service") result.Command = "service"; else if (arg == "key") result.Command = "key"; + else if (arg == "languages") result.Command = "languages"; else if (arg == "env" && result.Command == "service") { // Parse: service env @@ -1295,12 +1342,98 @@ class Un else if (arg == "--show-freeze-page") result.ServiceShowFreezePage = args[++i]; else if (arg == "--show-freeze-page-enabled") result.ServiceShowFreezePageEnabled = args[++i].ToLower() == "true"; else if (arg == "--with-unfreeze-on-demand") result.ServiceCreateUnfreezeOnDemand = true; + else if (arg == "--redeploy") result.ServiceRedeploy = args[++i]; else if (arg == "--extend") result.KeyExtend = true; + else if (arg == "--json") result.LanguagesJson = true; else if (!arg.StartsWith("-")) result.SourceFile = arg; } return result; } + static string GetLanguagesCachePath() + { + string home = Environment.GetEnvironmentVariable("HOME") + ?? Environment.GetEnvironmentVariable("USERPROFILE") + ?? "."; + return Path.Combine(home, ".unsandbox", "languages.json"); + } + + static List LoadLanguagesCache() + { + string cachePath = GetLanguagesCachePath(); + if (!File.Exists(cachePath)) return null; + + try + { + string content = File.ReadAllText(cachePath); + double mtime = new DateTimeOffset(File.GetLastWriteTimeUtc(cachePath)).ToUnixTimeSeconds(); + double now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (now - mtime < 3600) + { + var data = ParseJson(content); + if (data.ContainsKey("languages") && data["languages"] is List langs) + return langs.ConvertAll(x => x.ToString()); + } + } + catch { } + return null; + } + + static void SaveLanguagesCache(List languages) + { + try + { + string cachePath = GetLanguagesCachePath(); + string cacheDir = Path.GetDirectoryName(cachePath); + if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); + + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sb = new StringBuilder(); + sb.Append("{\"languages\":["); + for (int i = 0; i < languages.Count; i++) + { + if (i > 0) sb.Append(","); + sb.Append("\"").Append(languages[i]).Append("\""); + } + sb.Append("],\"timestamp\":").Append(timestamp).Append("}"); + File.WriteAllText(cachePath, sb.ToString()); + } + catch { } + } + + static void CmdLanguages(Args args) + { + // Try cache first + var languages = LoadLanguagesCache(); + + if (languages == null) + { + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + var result = ApiRequest("/languages", "GET", null, publicKey, secretKey); + languages = new List(); + if (result.ContainsKey("languages") && result["languages"] is List langs) + languages = langs.ConvertAll(x => x.ToString()); + SaveLanguagesCache(languages); + } + + if (args.LanguagesJson) + { + var sb = new StringBuilder("["); + for (int i = 0; i < languages.Count; i++) + { + if (i > 0) sb.Append(","); + sb.Append("\"").Append(languages[i]).Append("\""); + } + sb.Append("]"); + Console.WriteLine(sb.ToString()); + } + else + { + foreach (var lang in languages) + Console.WriteLine(lang); + } + } + static void PrintHelp() { Console.WriteLine(@"Usage: Un [options] @@ -1308,6 +1441,7 @@ class Un Un service [options] Un service env [options] Un key [options] + Un languages [--json] Execute options: -e KEY=VALUE Set environment variable @@ -1340,6 +1474,7 @@ Service options: --show-freeze-page-enabled BOOL Enable/disable (default: true) --with-unfreeze-on-demand Enable unfreeze-on-demand when creating service --destroy ID Destroy service + --redeploy ID Re-run bootstrap (with optional --bootstrap, -f) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script @@ -1354,7 +1489,10 @@ Service env commands: env delete ID Delete vault Key options: - --extend Open browser to extend expired key"); + --extend Open browser to extend expired key + +Languages options: + --json Output as JSON array"); } } @@ -1486,15 +1624,76 @@ public static class Unsandbox catch (Exception ex) { _lastError = ex.Message; return new List(); } } - /// Get available programming languages + private const int LANGUAGES_CACHE_TTL = 3600; // 1 hour + + private static string GetLanguagesCachePath() + { + string home = Environment.GetEnvironmentVariable("HOME") + ?? Environment.GetEnvironmentVariable("USERPROFILE") + ?? "."; + return Path.Combine(home, ".unsandbox", "languages.json"); + } + + private static List LoadLanguagesCache() + { + string cachePath = GetLanguagesCachePath(); + if (!File.Exists(cachePath)) return null; + + try + { + string content = File.ReadAllText(cachePath); + double mtime = new DateTimeOffset(File.GetLastWriteTimeUtc(cachePath)).ToUnixTimeSeconds(); + double now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (now - mtime < LANGUAGES_CACHE_TTL) + { + var data = ParseJson(content); + if (data.ContainsKey("languages") && data["languages"] is List langs) + return langs.ConvertAll(x => x.ToString()); + } + } + catch { } + return null; + } + + private static void SaveLanguagesCache(List languages) + { + try + { + string cachePath = GetLanguagesCachePath(); + string cacheDir = Path.GetDirectoryName(cachePath); + if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); + + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sb = new StringBuilder(); + sb.Append("{\"languages\":["); + for (int i = 0; i < languages.Count; i++) + { + if (i > 0) sb.Append(","); + sb.Append("\"").Append(languages[i]).Append("\""); + } + sb.Append("],\"timestamp\":").Append(timestamp).Append("}"); + File.WriteAllText(cachePath, sb.ToString()); + } + catch { } + } + + /// Get available programming languages (cached for 1 hour) public static List GetLanguages(string publicKey = null, string secretKey = null) { + // Try cache first + var cached = LoadLanguagesCache(); + if (cached != null) return cached; + var (pk, sk) = ResolveKeys(publicKey, secretKey); try { var result = ApiCall("/languages", "GET", null, pk, sk); if (result.ContainsKey("languages") && result["languages"] is List langs) - return langs.ConvertAll(x => x.ToString()); + { + var languages = langs.ConvertAll(x => x.ToString()); + SaveLanguagesCache(languages); + return languages; + } return new List(); } catch (Exception ex) { _lastError = ex.Message; return new List(); } @@ -1632,7 +1831,7 @@ public static class Unsandbox catch (Exception ex) { _lastError = ex.Message; return null; } } - public static string ServiceCreate(string name, string ports = null, string domains = null, string bootstrap = null, string networkMode = null, string publicKey = null, string secretKey = null) + public static string ServiceCreate(string name, string ports = null, string domains = null, string bootstrap = null, string networkMode = null, List> inputFiles = null, string publicKey = null, string secretKey = null) { var (pk, sk) = ResolveKeys(publicKey, secretKey); var payload = new Dictionary { ["name"] = name }; @@ -1645,6 +1844,7 @@ public static class Unsandbox if (domains != null) payload["domains"] = domains; if (bootstrap != null) payload["bootstrap"] = bootstrap; if (networkMode != null) payload["network"] = networkMode; + if (inputFiles != null && inputFiles.Count > 0) payload["input_files"] = inputFiles; try { var result = ApiCall("/services", "POST", payload, pk, sk); @@ -1696,10 +1896,16 @@ public static class Unsandbox catch (Exception ex) { _lastError = ex.Message; return false; } } - public static bool ServiceRedeploy(string serviceId, string bootstrap = null, string publicKey = null, string secretKey = null) + public static bool ServiceRedeploy(string serviceId, string bootstrap = null, List> inputFiles = null, string publicKey = null, string secretKey = null) { var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = bootstrap != null ? new Dictionary { ["bootstrap"] = bootstrap } : null; + Dictionary payload = null; + if (bootstrap != null || (inputFiles != null && inputFiles.Count > 0)) + { + payload = new Dictionary(); + if (bootstrap != null) payload["bootstrap"] = bootstrap; + if (inputFiles != null && inputFiles.Count > 0) payload["input_files"] = inputFiles; + } try { ApiCall($"/services/{serviceId}/redeploy", "POST", payload, pk, sk); return true; } catch (Exception ex) { _lastError = ex.Message; return false; } } diff --git a/clients/dotnet/sync/src/Un.cs b/clients/dotnet/sync/src/Un.cs index 2ee98e9..952c789 100644 --- a/clients/dotnet/sync/src/Un.cs +++ b/clients/dotnet/sync/src/Un.cs @@ -392,7 +392,22 @@ void CmdService(Args args) if (args.ServiceRedeploy != null) { - ApiRequest($"/services/{args.ServiceRedeploy}/redeploy", HttpMethod.Post, null, publicKey, secretKey); + var payload = new Dictionary(); + if (args.Files.Count > 0) + { + var inputFiles = new List>(); + foreach (var filepath in args.Files) + { + var content = File.ReadAllBytes(filepath); + inputFiles.Add(new Dictionary + { + ["filename"] = Path.GetFileName(filepath), + ["content"] = Convert.ToBase64String(content) + }); + } + payload["input_files"] = inputFiles; + } + ApiRequest($"/services/{args.ServiceRedeploy}/redeploy", HttpMethod.Post, payload.Count > 0 ? payload : null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service redeploying: {args.ServiceRedeploy}{RESET}"); return; } @@ -452,6 +467,20 @@ void CmdService(Args args) if (args.Network != null) payload["network"] = args.Network; if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; if (args.ServiceCreateUnfreezeOnDemand) payload["unfreeze_on_demand"] = true; + if (args.Files.Count > 0) + { + var inputFiles = new List>(); + foreach (var filepath in args.Files) + { + var content = File.ReadAllBytes(filepath); + inputFiles.Add(new Dictionary + { + ["filename"] = Path.GetFileName(filepath), + ["content"] = Convert.ToBase64String(content) + }); + } + payload["input_files"] = inputFiles; + } var result = ApiRequest("/services", HttpMethod.Post, payload, publicKey, secretKey); var serviceId = result.TryGetValue("id", out var id) && id is JsonElement idEl ? idEl.GetString() : null; @@ -1085,7 +1114,7 @@ Service options: --lock ID Prevent deletion --unlock ID Allow deletion --resize ID Resize (use with -v) - --redeploy ID Re-run bootstrap + --redeploy ID Re-run bootstrap (use -f to include input files) --snapshot ID Create snapshot from service --unfreeze-on-demand ID Set unfreeze-on-demand for service --unfreeze-on-demand-enabled BOOL Enable/disable (default: true) @@ -1098,6 +1127,7 @@ Service options: --dump-bootstrap ID Dump bootstrap script --dump-file FILE File to save bootstrap (with --dump-bootstrap) -e KEY=VALUE Set vault env var (with --name or env set) + -f FILE Add input file (with --name or --redeploy) --env-file FILE Load vault vars from file Service env commands: @@ -1406,7 +1436,7 @@ public static class Unsandbox catch (Exception ex) { _lastError = ex.Message; return null; } } - public static string? ServiceCreate(string name, string? ports = null, string? domains = null, string? bootstrap = null, string? networkMode = null, string? publicKey = null, string? secretKey = null) + public static string? ServiceCreate(string name, string? ports = null, string? domains = null, string? bootstrap = null, string? networkMode = null, List>? inputFiles = null, string? publicKey = null, string? secretKey = null) { var (pk, sk) = ResolveKeys(publicKey, secretKey); var payload = new Dictionary { ["name"] = name }; @@ -1414,6 +1444,7 @@ public static class Unsandbox if (domains != null) payload["domains"] = domains; if (bootstrap != null) payload["bootstrap"] = bootstrap; if (networkMode != null) payload["network"] = networkMode; + if (inputFiles != null && inputFiles.Count > 0) payload["input_files"] = inputFiles; try { var result = ApiCall("/services", HttpMethod.Post, payload, pk, sk); @@ -1465,10 +1496,16 @@ public static class Unsandbox catch (Exception ex) { _lastError = ex.Message; return false; } } - public static bool ServiceRedeploy(string serviceId, string? bootstrap = null, string? publicKey = null, string? secretKey = null) + public static bool ServiceRedeploy(string serviceId, string? bootstrap = null, List>? inputFiles = null, string? publicKey = null, string? secretKey = null) { var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = bootstrap != null ? new Dictionary { ["bootstrap"] = bootstrap } : null; + Dictionary? payload = null; + if (bootstrap != null || inputFiles != null) + { + payload = new Dictionary(); + if (bootstrap != null) payload["bootstrap"] = bootstrap; + if (inputFiles != null && inputFiles.Count > 0) payload["input_files"] = inputFiles; + } try { ApiCall($"/services/{serviceId}/redeploy", HttpMethod.Post, payload, pk, sk); return true; } catch (Exception ex) { _lastError = ex.Message; return false; } } diff --git a/clients/go/sync/src/un.go b/clients/go/sync/src/un.go index 03097b2..09df8e9 100644 --- a/clients/go/sync/src/un.go +++ b/clients/go/sync/src/un.go @@ -21,6 +21,7 @@ import ( "bytes" "crypto/hmac" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -840,12 +841,19 @@ func ShellSession(creds *Credentials, sessionID, command string) (map[string]int // Service Operations // ============================================================================ +// InputFile represents a file to upload with a service create or redeploy. +type InputFile struct { + Filename string `json:"filename"` + Content string `json:"content"` // base64-encoded +} + // ServiceOptions contains optional parameters for service creation. type ServiceOptions struct { - NetworkMode string // "zerotrust" (default) or "semitrusted" - Shell string // Shell to use for bootstrap - VCPU int // Number of virtual CPUs - UnfreezeOnDemand bool // Enable automatic unfreezing on HTTP request + NetworkMode string // "zerotrust" (default) or "semitrusted" + Shell string // Shell to use for bootstrap + VCPU int // Number of virtual CPUs + UnfreezeOnDemand bool // Enable automatic unfreezing on HTTP request + InputFiles []InputFile // Files to include (written to /tmp/ in container) } // ServiceUpdateOptions contains optional parameters for service updates. @@ -902,6 +910,9 @@ func CreateService(creds *Credentials, name string, ports []int, bootstrap strin if opts.UnfreezeOnDemand { data["unfreeze_on_demand"] = true } + if len(opts.InputFiles) > 0 { + data["input_files"] = opts.InputFiles + } } return makeRequest("POST", "/services", creds, data) @@ -1017,11 +1028,15 @@ func ExportServiceEnv(creds *Credentials, serviceID string) (map[string]interfac // creds: API credentials // serviceID: Service ID // bootstrap: New bootstrap script (empty string to keep existing) -func RedeployService(creds *Credentials, serviceID string, bootstrap string) (map[string]interface{}, error) { +// inputFiles: Optional files to include (written to /tmp/ in container) +func RedeployService(creds *Credentials, serviceID string, bootstrap string, inputFiles []InputFile) (map[string]interface{}, error) { data := make(map[string]interface{}) if bootstrap != "" { data["bootstrap"] = bootstrap } + if len(inputFiles) > 0 { + data["input_files"] = inputFiles + } return makeRequest("POST", fmt.Sprintf("/services/%s/redeploy", serviceID), creds, data) } @@ -1899,6 +1914,22 @@ func readFileContents(path string) (string, error) { return string(data), nil } +// buildInputFiles reads files from paths and returns InputFile structs with base64-encoded content. +func buildInputFiles(paths []string) ([]InputFile, error) { + var files []InputFile + for _, fpath := range paths { + data, err := os.ReadFile(fpath) + if err != nil { + return nil, fmt.Errorf("cannot read input file %s: %w", fpath, err) + } + files = append(files, InputFile{ + Filename: filepath.Base(fpath), + Content: base64.StdEncoding.EncodeToString(data), + }) + } + return files, nil +} + // readEnvFile reads environment variables from a .env file func readEnvFile(path string) (map[string]string, error) { data, err := os.ReadFile(path) @@ -2353,7 +2384,15 @@ func runService(creds *Credentials, args []string, opts *CLIOptions) int { // Redeploy service if fs.redeploy != "" { - _, err := RedeployService(creds, fs.redeploy, fs.bootstrap) + var inputFiles []InputFile + if len(opts.Files) > 0 { + var err error + inputFiles, err = buildInputFiles(opts.Files) + if err != nil { + return cliError(err.Error(), ExitGeneralError) + } + } + _, err := RedeployService(creds, fs.redeploy, fs.bootstrap, inputFiles) if err != nil { return cliError(err.Error(), ExitAPIError) } @@ -2412,6 +2451,13 @@ func runService(creds *Credentials, args []string, opts *CLIOptions) int { if opts.VCPU > 0 { serviceOpts.VCPU = opts.VCPU } + if len(opts.Files) > 0 { + inputFiles, err := buildInputFiles(opts.Files) + if err != nil { + return cliError(err.Error(), ExitGeneralError) + } + serviceOpts.InputFiles = inputFiles + } service, err := CreateService(creds, fs.name, ports, bootstrap, serviceOpts) if err != nil { diff --git a/clients/java/sync/src/Un.java b/clients/java/sync/src/Un.java index 4984ca3..a6aaeca 100644 --- a/clients/java/sync/src/Un.java +++ b/clients/java/sync/src/Un.java @@ -1493,6 +1493,31 @@ public class Un { String bootstrap, String publicKey, String secretKey + ) throws IOException { + return createService(name, ports, bootstrap, null, publicKey, secretKey); + } + + /** + * Create a new service (long-running container) with optional input files. + * + * @param name Service name + * @param ports Comma-separated list of ports to expose (e.g., "80,443") + * @param bootstrap Bootstrap script or URL to run on service creation + * @param inputFiles Optional list of maps with "filename" and "content" (base64-encoded) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing service_id + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map createService( + String name, + String ports, + String bootstrap, + List> inputFiles, + String publicKey, + String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); @@ -1517,6 +1542,9 @@ public class Un { data.put("bootstrap", bootstrap); } } + if (inputFiles != null && !inputFiles.isEmpty()) { + data.put("input_files", inputFiles); + } return makeRequest("POST", "/services", creds[0], creds[1], data); } @@ -1542,6 +1570,33 @@ public class Un { boolean unfreezeOnDemand, String publicKey, String secretKey + ) throws IOException { + return createService(name, ports, bootstrap, unfreezeOnDemand, null, publicKey, secretKey); + } + + /** + * Create a new service (long-running container) with unfreeze-on-demand option and input files. + * + * @param name Service name (used for hostname) + * @param ports Comma-separated list of ports to expose (e.g., "80,443") + * @param bootstrap Bootstrap script or URL to run on service creation + * @param unfreezeOnDemand If true, frozen service will auto-wake on HTTP request + * @param inputFiles Optional list of maps with "filename" and "content" (base64-encoded) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing service_id + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map createService( + String name, + String ports, + String bootstrap, + boolean unfreezeOnDemand, + List> inputFiles, + String publicKey, + String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); @@ -1569,6 +1624,9 @@ public class Un { if (unfreezeOnDemand) { data.put("unfreeze_on_demand", true); } + if (inputFiles != null && !inputFiles.isEmpty()) { + data.put("input_files", inputFiles); + } return makeRequest("POST", "/services", creds[0], creds[1], data); } @@ -1901,9 +1959,34 @@ public class Un { String serviceId, String publicKey, String secretKey + ) throws IOException { + return redeployService(serviceId, null, publicKey, secretKey); + } + + /** + * Redeploy a service (re-run bootstrap script) with optional input files. + * + * @param serviceId Service ID to redeploy + * @param inputFiles Optional list of maps with "filename" and "content" (base64-encoded) + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with redeploy confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map redeployService( + String serviceId, + List> inputFiles, + String publicKey, + String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeRequest("POST", "/services/" + serviceId + "/redeploy", creds[0], creds[1], new LinkedHashMap<>()); + Map data = new LinkedHashMap<>(); + if (inputFiles != null && !inputFiles.isEmpty()) { + data.put("input_files", inputFiles); + } + return makeRequest("POST", "/services/" + serviceId + "/redeploy", creds[0], creds[1], data); } /** @@ -2859,7 +2942,7 @@ public class Un { handleSession(positionalArgs, publicKey, secretKey, networkMode, vcpu, language); break; case "service": - handleService(positionalArgs, publicKey, secretKey, networkMode, vcpu, envVars); + handleService(positionalArgs, publicKey, secretKey, networkMode, vcpu, envVars, files); break; case "snapshot": handleSnapshot(positionalArgs, publicKey, secretKey); @@ -3181,7 +3264,8 @@ public class Un { String secretKey, String networkMode, int vcpu, - List envVars + List envVars, + List files ) throws Exception { // Check for "env" subcommand if (args.size() > 1 && args.get(1).equals("env")) { @@ -3347,14 +3431,17 @@ public class Un { System.err.print(stderr); } } else if (redeployId != null) { - redeployService(redeployId, publicKey, secretKey); + // Build input_files from -f args + List> inputFiles = buildInputFiles(files); + redeployService(redeployId, inputFiles, publicKey, secretKey); System.out.println("Service redeployed: " + redeployId); } else if (snapshotId != null) { String snapId = serviceSnapshot(snapshotId, publicKey, secretKey, null); System.out.println("Snapshot created: " + snapId); } else if (name != null) { - // Create new service - Map result = createService(name, ports, bootstrap, publicKey, secretKey); + // Build input_files from -f args + List> inputFiles = buildInputFiles(files); + Map result = createService(name, ports, bootstrap, inputFiles, publicKey, secretKey); System.out.println("Service created:"); printMap(result); } else { @@ -3363,6 +3450,26 @@ public class Un { } } + /** + * Build input_files list from -f file paths: read each file, base64-encode, return list of maps. + */ + private static List> buildInputFiles(List filePaths) throws IOException { + if (filePaths == null || filePaths.isEmpty()) { + return null; + } + List> inputFiles = new ArrayList<>(); + for (String fpath : filePaths) { + Path p = Paths.get(fpath); + byte[] content = Files.readAllBytes(p); + String encoded = Base64.getEncoder().encodeToString(content); + Map entry = new LinkedHashMap<>(); + entry.put("filename", p.getFileName().toString()); + entry.put("content", encoded); + inputFiles.add(entry); + } + return inputFiles; + } + private static void handleServiceEnv( List args, String publicKey, diff --git a/clients/javascript/sync/src/un.js b/clients/javascript/sync/src/un.js index d178c8d..f975033 100644 --- a/clients/javascript/sync/src/un.js +++ b/clients/javascript/sync/src/un.js @@ -1093,6 +1093,7 @@ async function listServices(publicKey, secretKey) { * - domains: Array of custom domains * - serviceType: Service type for SRV records (minecraft, mumble, etc.) * - unfreezeOnDemand: If true, frozen services wake automatically on HTTP traffic + * - inputFiles: Array of {filename, content} objects (content is base64-encoded) * * Returns: Promise (service info with service_id) */ @@ -1114,6 +1115,7 @@ async function createService(name, ports, bootstrap, opts = {}, publicKey, secre if (opts.domains) data.custom_domains = opts.domains; if (opts.serviceType) data.service_type = opts.serviceType; if (opts.unfreezeOnDemand) data.unfreeze_on_demand = true; + if (opts.inputFiles && opts.inputFiles.length > 0) data.input_files = opts.inputFiles; return makeRequest('POST', '/services', publicKey, secretKey, data); } @@ -1331,10 +1333,11 @@ async function exportServiceEnv(serviceId, publicKey, secretKey) { * Args: * serviceId: Service ID to redeploy * bootstrap: Optional new bootstrap script content or URL + * inputFiles: Optional array of {filename, content} objects (content is base64-encoded) * * Returns: Promise (redeploy confirmation) */ -async function redeployService(serviceId, bootstrap = null, publicKey, secretKey) { +async function redeployService(serviceId, bootstrap = null, inputFiles = null, publicKey, secretKey) { [publicKey, secretKey] = resolveCredentials(publicKey, secretKey); const data = {}; if (bootstrap) { @@ -1344,6 +1347,7 @@ async function redeployService(serviceId, bootstrap = null, publicKey, secretKey data.bootstrap_content = bootstrap; } } + if (inputFiles && inputFiles.length > 0) data.input_files = inputFiles; return makeRequest('POST', `/services/${serviceId}/redeploy`, publicKey, secretKey, data); } @@ -2146,7 +2150,7 @@ SERVICE COMMANDS: node un.js service --lock Prevent deletion node un.js service --unlock Allow deletion node un.js service --execute Run command in service - node un.js service --redeploy Re-run bootstrap + node un.js service --redeploy Re-run bootstrap (supports -f) node un.js service --snapshot Create snapshot SERVICE ENV COMMANDS: @@ -2732,7 +2736,19 @@ async function handleService(opts) { if (opts.bootstrapFile) { bootstrap = fs.readFileSync(opts.bootstrapFile, 'utf-8'); } - await redeployService(opts.redeploy, bootstrap, pk, sk); + // Build input_files from -f args + let inputFiles = null; + if (opts.files && opts.files.length > 0) { + inputFiles = []; + for (const fpath of opts.files) { + const content = fs.readFileSync(fpath); + inputFiles.push({ + filename: path.basename(fpath), + content: content.toString('base64'), + }); + } + } + await redeployService(opts.redeploy, bootstrap, inputFiles, pk, sk); console.log(`Service ${opts.redeploy} redeployed.`); return; } @@ -2786,6 +2802,17 @@ async function handleService(opts) { if (opts.type) { serviceOpts.serviceType = opts.type; } + // Build input_files from -f args + if (opts.files && opts.files.length > 0) { + serviceOpts.inputFiles = []; + for (const fpath of opts.files) { + const content = fs.readFileSync(fpath); + serviceOpts.inputFiles.push({ + filename: path.basename(fpath), + content: content.toString('base64'), + }); + } + } const service = await createService(opts.name, ports, bootstrap, serviceOpts, pk, sk); console.log(`Service created: ${service.service_id}`); diff --git a/clients/perl/sync/src/un.pl b/clients/perl/sync/src/un.pl index fa7e372..470ef50 100644 --- a/clients/perl/sync/src/un.pl +++ b/clients/perl/sync/src/un.pl @@ -472,6 +472,7 @@ sub service_redeploy { my ($service_id, %opts) = @_; my $body = {}; $body->{bootstrap} = $opts{bootstrap} if $opts{bootstrap}; + $body->{input_files} = $opts{input_files} if $opts{input_files}; return api_request('POST', "/services/$service_id/redeploy", $body, %opts); } @@ -998,7 +999,12 @@ sub cmd_service { } if ($options->{redeploy}) { - Un::service_redeploy($options->{redeploy}, bootstrap => $options->{bootstrap}); + my %opts; + $opts{bootstrap} = $options->{bootstrap} if $options->{bootstrap}; + if ($options->{files} && @{$options->{files}}) { + $opts{input_files} = build_input_files(@{$options->{files}}); + } + Un::service_redeploy($options->{redeploy}, %opts); print "${GREEN}Service redeployed: $options->{redeploy}${RESET}\n"; return; } diff --git a/clients/php/sync/src/un.php b/clients/php/sync/src/un.php index 482620e..0ac0920 100644 --- a/clients/php/sync/src/un.php +++ b/clients/php/sync/src/un.php @@ -1339,13 +1339,18 @@ class Unsandbox { * @param string $serviceId Service ID * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret + * @param array $opts Optional parameters: 'input_files' * @return array Response array with redeploy confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ - public function redeployService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { + public function redeployService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null, array $opts = []): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); - return $this->makeRequest('POST', "/services/{$serviceId}/redeploy", $publicKey, $secretKey, []); + $data = []; + if (isset($opts['input_files'])) { + $data['input_files'] = $opts['input_files']; + } + return $this->makeRequest('POST', "/services/{$serviceId}/redeploy", $publicKey, $secretKey, $data); } /** @@ -2603,7 +2608,31 @@ class Unsandbox { } if ($serviceOpts['redeploy']) { - $result = $this->redeployService($serviceOpts['redeploy']); + $redeployOpts = []; + $inputFiles = []; + foreach ($opts['files'] as $filepath) { + if (file_exists($filepath)) { + $content = file_get_contents($filepath); + $inputFiles[] = [ + 'name' => basename($filepath), + 'content' => base64_encode($content), + ]; + } + } + foreach ($opts['files_path'] as $filepath) { + if (file_exists($filepath)) { + $content = file_get_contents($filepath); + $inputFiles[] = [ + 'name' => $filepath, + 'content' => base64_encode($content), + 'preserve_path' => true, + ]; + } + } + if (!empty($inputFiles)) { + $redeployOpts['input_files'] = $inputFiles; + } + $result = $this->redeployService($serviceOpts['redeploy'], null, null, $redeployOpts); echo "Service redeploying: " . $serviceOpts['redeploy'] . "\n"; return; } @@ -2647,6 +2676,31 @@ class Unsandbox { $createOpts['unfreeze_on_demand'] = true; } + // Handle input files + $inputFiles = []; + foreach ($opts['files'] as $filepath) { + if (file_exists($filepath)) { + $content = file_get_contents($filepath); + $inputFiles[] = [ + 'name' => basename($filepath), + 'content' => base64_encode($content), + ]; + } + } + foreach ($opts['files_path'] as $filepath) { + if (file_exists($filepath)) { + $content = file_get_contents($filepath); + $inputFiles[] = [ + 'name' => $filepath, + 'content' => base64_encode($content), + 'preserve_path' => true, + ]; + } + } + if (!empty($inputFiles)) { + $createOpts['input_files'] = $inputFiles; + } + // Handle bootstrap from file $bootstrap = $serviceOpts['bootstrap'] ?? ''; if (!empty($serviceOpts['bootstrap_file'])) { @@ -3438,7 +3492,7 @@ SERVICE OPTIONS: --lock ID Prevent service deletion --unlock ID Allow service deletion --resize ID Resize service (with --vcpu) - --redeploy ID Re-run bootstrap + --redeploy ID Re-run bootstrap (supports -f/-F for input files) --execute ID 'cmd' Execute command in service --snapshot ID Create service snapshot diff --git a/clients/ruby/sync/src/un.rb b/clients/ruby/sync/src/un.rb index 0c3c786..7dd6ce5 100644 --- a/clients/ruby/sync/src/un.rb +++ b/clients/ruby/sync/src/un.rb @@ -57,6 +57,7 @@ require 'openssl' require 'fileutils' require 'optparse' require 'cgi' +require 'base64' # Unsandbox Ruby SDK module (synchronous) module Un @@ -922,6 +923,7 @@ module Un # @param custom_domains [Array, nil] Custom domains for the service # @param service_type [String, nil] Service type for SRV records (e.g., "minecraft") # @param unfreeze_on_demand [Boolean] If true, service will auto-wake on HTTP request (default: false) + # @param input_files [Array, nil] Optional list of hashes with "filename" and "content" (base64) # @return [Hash] Response hash with service_id # @raise [CredentialsError] If no credentials found # @raise [APIError] If API request fails @@ -929,7 +931,7 @@ module Un # @example # result = Un.create_service("web", [80, 443], "apt install -y nginx && nginx") # puts result["service_id"] - def create_service(name, ports, bootstrap, public_key: nil, secret_key: nil, network_mode: 'semitrusted', vcpu: 1, custom_domains: nil, service_type: nil, unfreeze_on_demand: false) + def create_service(name, ports, bootstrap, public_key: nil, secret_key: nil, network_mode: 'semitrusted', vcpu: 1, custom_domains: nil, service_type: nil, unfreeze_on_demand: false, input_files: nil) pk, sk = resolve_credentials(public_key, secret_key) data = { name: name, @@ -941,6 +943,7 @@ module Un data[:custom_domains] = custom_domains if custom_domains data[:service_type] = service_type if service_type data[:unfreeze_on_demand] = unfreeze_on_demand if unfreeze_on_demand + data[:input_files] = input_files if input_files && !input_files.empty? make_request('POST', '/services', pk, sk, data) end @@ -1202,16 +1205,18 @@ module Un # @param public_key [String, nil] Optional API key # @param secret_key [String, nil] Optional API secret # @param bootstrap [String, nil] New bootstrap script (optional) + # @param input_files [Array, nil] Optional list of hashes with "filename" and "content" (base64) # @return [Hash] Response hash with redeploy confirmation # @raise [CredentialsError] If no credentials found # @raise [APIError] If API request fails # # @example # Un.redeploy_service(service_id) - def redeploy_service(service_id, public_key: nil, secret_key: nil, bootstrap: nil) + def redeploy_service(service_id, public_key: nil, secret_key: nil, bootstrap: nil, input_files: nil) pk, sk = resolve_credentials(public_key, secret_key) data = {} data[:bootstrap] = bootstrap if bootstrap + data[:input_files] = input_files if input_files && !input_files.empty? make_request('POST', "/services/#{service_id}/redeploy", pk, sk, data) end @@ -2420,7 +2425,17 @@ module Un elsif service_opts[:bootstrap] bootstrap = service_opts[:bootstrap] end - redeploy_service(service_opts[:redeploy], bootstrap: bootstrap, **creds) + # Build input_files from -f args + service_input_files = nil + unless options[:files].empty? + service_input_files = options[:files].map do |fpath| + { + filename: File.basename(fpath), + content: Base64.strict_encode64(File.binread(fpath)) + } + end + end + redeploy_service(service_opts[:redeploy], bootstrap: bootstrap, input_files: service_input_files, **creds) puts "Service #{service_opts[:redeploy]} redeployed" elsif service_opts[:execute] cmd = service_opts[:execute_cmd] @@ -2453,6 +2468,17 @@ module Un exit(EXIT_INVALID_ARGS) end + # Build input_files from -f args + service_input_files = nil + unless options[:files].empty? + service_input_files = options[:files].map do |fpath| + { + filename: File.basename(fpath), + content: Base64.strict_encode64(File.binread(fpath)) + } + end + end + result = create_service( service_opts[:name], service_opts[:ports], @@ -2461,6 +2487,7 @@ module Un vcpu: options[:vcpu], custom_domains: service_opts[:domains], service_type: service_opts[:type], + input_files: service_input_files, **creds ) puts "Service created: #{result['service_id']}" diff --git a/clients/rust/sync/Cargo.toml b/clients/rust/sync/Cargo.toml index 9d0a05c..beaad14 100644 --- a/clients/rust/sync/Cargo.toml +++ b/clients/rust/sync/Cargo.toml @@ -32,6 +32,9 @@ reqwest = { version = "0.12", features = ["blocking", "json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +# Base64 encoding (for input_files) +base64 = "0.22" + # HMAC-SHA256 authentication hmac = "0.12" sha2 = "0.10" diff --git a/clients/rust/sync/src/lib.rs b/clients/rust/sync/src/lib.rs index a0c932d..a5117be 100644 --- a/clients/rust/sync/src/lib.rs +++ b/clients/rust/sync/src/lib.rs @@ -58,6 +58,7 @@ // - TTL: 1 hour // - Updated on successful API calls +use base64::Engine; use hmac::{Hmac, Mac}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; @@ -382,6 +383,17 @@ pub struct ServiceCreateOptions { pub bootstrap_url: Option, /// Whether to enable automatic unfreezing on incoming HTTP requests pub unfreeze_on_demand: Option, + /// Input files to upload (filename + base64-encoded content) + pub input_files: Option>, +} + +/// A file to upload with a service create or redeploy request +#[derive(Debug, Clone, Serialize)] +pub struct InputFile { + /// Filename (basename only) + pub filename: String, + /// Base64-encoded file content + pub content: String, } /// Options for updating a service @@ -1997,6 +2009,11 @@ pub fn create_service( if let Some(unfreeze_on_demand) = opts.unfreeze_on_demand { body["unfreeze_on_demand"] = serde_json::json!(unfreeze_on_demand); } + if let Some(input_files) = opts.input_files { + if !input_files.is_empty() { + body["input_files"] = serde_json::json!(input_files); + } + } } make_request("POST", "/services", creds, Some(&body)) @@ -2269,12 +2286,22 @@ pub fn export_service_env(service_id: &str, creds: &Credentials) -> Result Result { +pub fn redeploy_service( + service_id: &str, + creds: &Credentials, + input_files: Option>, +) -> Result { let path = format!("/services/{}/redeploy", service_id); - let body = serde_json::json!({}); + let mut body = serde_json::json!({}); + if let Some(files) = input_files { + if !files.is_empty() { + body["input_files"] = serde_json::json!(files); + } + } make_request("POST", &path, creds, Some(&body)) } @@ -2861,7 +2888,7 @@ SESSION COMMANDS: SERVICE COMMANDS: un service --list List all services - un service --name NAME --ports P Create service + un service --name NAME --ports P [-f FILE] Create service un service --info ID Get service details un service --logs ID Get all logs un service --tail ID Get last 9000 lines @@ -2871,7 +2898,7 @@ SERVICE COMMANDS: un service --lock ID Prevent deletion un service --unlock ID Allow deletion un service --execute ID 'cmd' Run command - un service --redeploy ID Re-run bootstrap + un service --redeploy ID [-f FILE] Re-run bootstrap un service --snapshot ID Create snapshot un service env status ID Show vault status un service env set ID Set env vars @@ -3586,6 +3613,33 @@ fn cmd_session(opts: &CliOptions) -> i32 { } } +/// Build input_files from -f file paths: read each file, base64 encode, return as InputFile vec. +fn build_input_files(files: &[String]) -> Option> { + if files.is_empty() { + return None; + } + let engine = base64::engine::general_purpose::STANDARD; + let mut input_files = Vec::new(); + for fpath in files { + let path = std::path::Path::new(fpath); + match fs::read(path) { + Ok(data) => { + let filename = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| fpath.clone()); + input_files.push(InputFile { + filename, + content: engine.encode(&data), + }); + } + Err(e) => { + eprintln!("Warning: Cannot read file '{}': {}", fpath, e); + } + } + } + if input_files.is_empty() { None } else { Some(input_files) } +} + fn cmd_service(opts: &CliOptions) -> i32 { let creds = match get_credentials(opts) { Ok(c) => c, @@ -3736,7 +3790,9 @@ fn cmd_service(opts: &CliOptions) -> i32 { } if let Some(ref id) = opts.service_redeploy { - match redeploy_service(id, &creds) { + // Build input_files from -f args + let service_input_files = build_input_files(&opts.files); + match redeploy_service(id, &creds, service_input_files) { Ok(service) => { println!("Service {} redeployed.", service.service_id); return EXIT_SUCCESS; @@ -3809,12 +3865,16 @@ fn cmd_service(opts: &CliOptions) -> i32 { String::new() }; + // Build input_files from -f args + let service_input_files = build_input_files(&opts.files); + let service_opts = ServiceCreateOptions { network_mode: opts.network.clone(), vcpu: opts.vcpu, domains: opts.service_domains.as_ref().map(|d| { d.split(',').map(|s| s.trim().to_string()).collect() }), + input_files: service_input_files, ..Default::default() }; diff --git a/clients/typescript/sync/src/un.ts b/clients/typescript/sync/src/un.ts index b0aaa5f..c1f668e 100644 --- a/clients/typescript/sync/src/un.ts +++ b/clients/typescript/sync/src/un.ts @@ -195,6 +195,7 @@ interface Args { setUnfreezeOnDemand: string | null; showFreezePage: boolean | null; setShowFreezePage: string | null; + redeploy: string | null; } interface ApiKeys { @@ -897,6 +898,37 @@ async function cmdService(args: Args): Promise { return; } + if (args.redeploy) { + const payload: any = {}; + if (args.bootstrap) { + payload.bootstrap = args.bootstrap; + } + if (args.bootstrapFile) { + if (!fs.existsSync(args.bootstrapFile)) { + console.error(`${RED}Error: Bootstrap file not found: ${args.bootstrapFile}${RESET}`); + process.exit(1); + } + payload.bootstrap_content = fs.readFileSync(args.bootstrapFile, 'utf-8'); + } + if (args.files && args.files.length > 0) { + payload.input_files = args.files.map(filepath => { + try { + const content = fs.readFileSync(filepath); + return { + filename: path.basename(filepath), + content_base64: content.toString('base64') + }; + } catch (e) { + console.error(`${RED}Error: Input file not found: ${filepath}${RESET}`); + process.exit(1); + } + }); + } + await apiRequest(`/services/${args.redeploy}/redeploy`, "POST", payload, keys); + console.log(`${GREEN}Service redeployed: ${args.redeploy}${RESET}`); + return; + } + if (args.execute) { const payload = { command: args.command_arg }; const result = await apiRequest(`/services/${args.execute}/execute`, "POST", payload, keys); @@ -1225,6 +1257,7 @@ function parseArgs(argv: string[]): Args { setUnfreezeOnDemand: null, showFreezePage: null, setShowFreezePage: null, + redeploy: null, }; let i = 2; @@ -1397,6 +1430,9 @@ function parseArgs(argv: string[]): Args { } else if (arg === '--set-show-freeze-page' && i + 1 < argv.length) { args.setShowFreezePage = argv[++i]; i++; + } else if (arg === '--redeploy' && i + 1 < argv.length) { + args.redeploy = argv[++i]; + i++; } else if (!arg.startsWith('-')) { args.sourceFile = arg; i++; @@ -1474,6 +1510,7 @@ Service options: --unfreeze ID Unfreeze service --destroy ID Destroy service --resize ID Resize service (requires -v) + --redeploy ID Re-run bootstrap (with optional --bootstrap, --bootstrap-file, -f) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script