Add service environment vault to all 40 un-inception implementations
Implements encrypted vault for storing service environment variables: - service env status <id> - Check vault status (GET /services/:id/env) - service env set <id> -e KEY=VAL - Set vault contents (PUT /services/:id/env) - service env export <id> - Export vault as .env format (POST /services/:id/env/export) - service env delete <id> - Delete vault (DELETE /services/:id/env) - Auto-vault on service creation with -e or --env-file flags All implementations use HMAC-SHA256 authentication and text/plain content type for vault PUT requests.
This commit is contained in:
parent
1b35f1099e
commit
87397949d1
40 changed files with 7523 additions and 291 deletions
292
Un.cs
292
Un.cs
|
|
@ -338,6 +338,13 @@ class Un
|
||||||
{
|
{
|
||||||
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
|
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
|
||||||
|
|
||||||
|
// Handle env subcommand
|
||||||
|
if (!string.IsNullOrEmpty(args.EnvAction))
|
||||||
|
{
|
||||||
|
CmdServiceEnv(args, publicKey, secretKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (args.ServiceList)
|
if (args.ServiceList)
|
||||||
{
|
{
|
||||||
var result = ApiRequest("/services", "GET", null, publicKey, secretKey);
|
var result = ApiRequest("/services", "GET", null, publicKey, secretKey);
|
||||||
|
|
@ -496,12 +503,30 @@ class Un
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = ApiRequest("/services", "POST", payload, publicKey, secretKey);
|
var result = ApiRequest("/services", "POST", payload, publicKey, secretKey);
|
||||||
Console.WriteLine($"{GREEN}Service created: {result["id"]}{RESET}");
|
string serviceId = result.ContainsKey("id") ? (string)result["id"] : null;
|
||||||
|
Console.WriteLine($"{GREEN}Service created: {serviceId}{RESET}");
|
||||||
Console.WriteLine($"Name: {result["name"]}");
|
Console.WriteLine($"Name: {result["name"]}");
|
||||||
if (result.ContainsKey("url"))
|
if (result.ContainsKey("url"))
|
||||||
{
|
{
|
||||||
Console.WriteLine($"URL: {result["url"]}");
|
Console.WriteLine($"URL: {result["url"]}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-set vault if env vars were provided
|
||||||
|
if (!string.IsNullOrEmpty(serviceId) && (args.Env.Count > 0 || !string.IsNullOrEmpty(args.EnvFile)))
|
||||||
|
{
|
||||||
|
string envContent = BuildEnvContent(args.Env, args.EnvFile);
|
||||||
|
if (!string.IsNullOrEmpty(envContent))
|
||||||
|
{
|
||||||
|
if (ServiceEnvSet(serviceId, envContent, publicKey, secretKey))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{GREEN}Vault configured with environment variables{RESET}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{YELLOW}Warning: Failed to set vault{RESET}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -634,6 +659,242 @@ class Un
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string ApiRequestText(string endpoint, string method, string body, string publicKey, string secretKey)
|
||||||
|
{
|
||||||
|
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;
|
||||||
|
|
||||||
|
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(API_BASE + endpoint);
|
||||||
|
request.Method = method;
|
||||||
|
request.ContentType = "text/plain";
|
||||||
|
request.Timeout = 300000;
|
||||||
|
|
||||||
|
if (body == null) body = "";
|
||||||
|
|
||||||
|
// Add HMAC authentication headers
|
||||||
|
if (!string.IsNullOrEmpty(secretKey))
|
||||||
|
{
|
||||||
|
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
string message = $"{timestamp}:{method}:{endpoint}:{body}";
|
||||||
|
|
||||||
|
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)))
|
||||||
|
{
|
||||||
|
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
|
||||||
|
string signature = BitConverter.ToString(hash).Replace("-", "").ToLower();
|
||||||
|
|
||||||
|
request.Headers.Add("Authorization", $"Bearer {publicKey}");
|
||||||
|
request.Headers.Add("X-Timestamp", timestamp.ToString());
|
||||||
|
request.Headers.Add("X-Signature", signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
request.Headers.Add("Authorization", $"Bearer {publicKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(body))
|
||||||
|
{
|
||||||
|
byte[] bytes = Encoding.UTF8.GetBytes(body);
|
||||||
|
request.ContentLength = bytes.Length;
|
||||||
|
using (Stream stream = request.GetRequestStream())
|
||||||
|
{
|
||||||
|
stream.Write(bytes, 0, bytes.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
|
||||||
|
{
|
||||||
|
using (StreamReader reader = new StreamReader(response.GetResponseStream()))
|
||||||
|
{
|
||||||
|
return reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (WebException ex)
|
||||||
|
{
|
||||||
|
string error = "";
|
||||||
|
if (ex.Response != null)
|
||||||
|
{
|
||||||
|
using (StreamReader reader = new StreamReader(ex.Response.GetResponseStream()))
|
||||||
|
{
|
||||||
|
error = reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Exception($"HTTP error - {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ReadEnvFile(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
throw new Exception($"Env file not found: {path}");
|
||||||
|
}
|
||||||
|
return File.ReadAllText(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BuildEnvContent(List<string> envs, string envFile)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
|
||||||
|
// Add from -e flags
|
||||||
|
foreach (var env in envs)
|
||||||
|
{
|
||||||
|
lines.Add(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add from --env-file
|
||||||
|
if (!string.IsNullOrEmpty(envFile))
|
||||||
|
{
|
||||||
|
string content = ReadEnvFile(envFile);
|
||||||
|
foreach (var line in content.Split('\n'))
|
||||||
|
{
|
||||||
|
string trimmed = line.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(trimmed) && !trimmed.StartsWith("#"))
|
||||||
|
{
|
||||||
|
lines.Add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Dictionary<string, object> ServiceEnvStatus(string serviceId, string publicKey, string secretKey)
|
||||||
|
{
|
||||||
|
return ApiRequest($"/services/{serviceId}/env", "GET", null, publicKey, secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ServiceEnvSet(string serviceId, string envContent, string publicKey, string secretKey)
|
||||||
|
{
|
||||||
|
const int MAX_ENV_CONTENT_SIZE = 65536;
|
||||||
|
if (envContent.Length > MAX_ENV_CONTENT_SIZE)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: Env content exceeds maximum size of 64KB{RESET}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ApiRequestText($"/services/{serviceId}/env", "PUT", envContent, publicKey, secretKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Dictionary<string, object> ServiceEnvExport(string serviceId, string publicKey, string secretKey)
|
||||||
|
{
|
||||||
|
return ApiRequest($"/services/{serviceId}/env/export", "POST", null, publicKey, secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ServiceEnvDelete(string serviceId, string publicKey, string secretKey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ApiRequest($"/services/{serviceId}/env", "DELETE", null, publicKey, secretKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void CmdServiceEnv(Args args, string publicKey, string secretKey)
|
||||||
|
{
|
||||||
|
string action = args.EnvAction;
|
||||||
|
string target = args.EnvTarget;
|
||||||
|
|
||||||
|
if (action == "status")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(target))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: service env status requires service ID{RESET}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
var result = ServiceEnvStatus(target, publicKey, secretKey);
|
||||||
|
if (result.ContainsKey("has_vault") && (bool)result["has_vault"])
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{GREEN}Vault: configured{RESET}");
|
||||||
|
if (result.ContainsKey("env_count"))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Variables: {result["env_count"]}");
|
||||||
|
}
|
||||||
|
if (result.ContainsKey("updated_at"))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Updated: {result["updated_at"]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{YELLOW}Vault: not configured{RESET}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (action == "set")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(target))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: service env set requires service ID{RESET}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
if (args.Env.Count == 0 && string.IsNullOrEmpty(args.EnvFile))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: service env set requires -e or --env-file{RESET}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
string envContent = BuildEnvContent(args.Env, args.EnvFile);
|
||||||
|
if (ServiceEnvSet(target, envContent, publicKey, secretKey))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{GREEN}Vault updated for service {target}{RESET}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: Failed to update vault{RESET}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (action == "export")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(target))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: service env export requires service ID{RESET}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
var result = ServiceEnvExport(target, publicKey, secretKey);
|
||||||
|
if (result.ContainsKey("content"))
|
||||||
|
{
|
||||||
|
Console.Write(result["content"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (action == "delete")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(target))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: service env delete requires service ID{RESET}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
if (ServiceEnvDelete(target, publicKey, secretKey))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{GREEN}Vault deleted for service {target}{RESET}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: Failed to delete vault{RESET}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{RED}Error: Unknown env action: {action}{RESET}");
|
||||||
|
Console.Error.WriteLine("Usage: Un service env <status|set|export|delete> <service_id>");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static string ToJson(object obj)
|
static string ToJson(object obj)
|
||||||
{
|
{
|
||||||
if (obj == null) return "null";
|
if (obj == null) return "null";
|
||||||
|
|
@ -884,6 +1145,9 @@ class Un
|
||||||
public string ServiceCommand = null;
|
public string ServiceCommand = null;
|
||||||
public string ServiceDumpBootstrap = null;
|
public string ServiceDumpBootstrap = null;
|
||||||
public string ServiceDumpFile = null;
|
public string ServiceDumpFile = null;
|
||||||
|
public string EnvFile = null;
|
||||||
|
public string EnvAction = null;
|
||||||
|
public string EnvTarget = null;
|
||||||
public bool KeyExtend = false;
|
public bool KeyExtend = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -896,10 +1160,23 @@ class Un
|
||||||
if (arg == "session") result.Command = "session";
|
if (arg == "session") result.Command = "session";
|
||||||
else if (arg == "service") result.Command = "service";
|
else if (arg == "service") result.Command = "service";
|
||||||
else if (arg == "key") result.Command = "key";
|
else if (arg == "key") result.Command = "key";
|
||||||
|
else if (arg == "env" && result.Command == "service")
|
||||||
|
{
|
||||||
|
// Parse: service env <action> <target>
|
||||||
|
if (i + 1 < args.Length && !args[i + 1].StartsWith("-"))
|
||||||
|
{
|
||||||
|
result.EnvAction = args[++i];
|
||||||
|
if (i + 1 < args.Length && !args[i + 1].StartsWith("-"))
|
||||||
|
{
|
||||||
|
result.EnvTarget = args[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (arg == "-k" || arg == "--api-key") result.ApiKey = args[++i];
|
else if (arg == "-k" || arg == "--api-key") result.ApiKey = args[++i];
|
||||||
else if (arg == "-n" || arg == "--network") result.Network = args[++i];
|
else if (arg == "-n" || arg == "--network") result.Network = args[++i];
|
||||||
else if (arg == "-v" || arg == "--vcpu") result.Vcpu = int.Parse(args[++i]);
|
else if (arg == "-v" || arg == "--vcpu") result.Vcpu = int.Parse(args[++i]);
|
||||||
else if (arg == "-e" || arg == "--env") result.Env.Add(args[++i]);
|
else if (arg == "-e" || arg == "--env") result.Env.Add(args[++i]);
|
||||||
|
else if (arg == "--env-file") result.EnvFile = args[++i];
|
||||||
else if (arg == "-f" || arg == "--files") result.Files.Add(args[++i]);
|
else if (arg == "-f" || arg == "--files") result.Files.Add(args[++i]);
|
||||||
else if (arg == "-a" || arg == "--artifacts") result.Artifacts = true;
|
else if (arg == "-a" || arg == "--artifacts") result.Artifacts = true;
|
||||||
else if (arg == "-o" || arg == "--output-dir") result.OutputDir = args[++i];
|
else if (arg == "-o" || arg == "--output-dir") result.OutputDir = args[++i];
|
||||||
|
|
@ -935,6 +1212,7 @@ class Un
|
||||||
Console.WriteLine(@"Usage: Un [options] <source_file>
|
Console.WriteLine(@"Usage: Un [options] <source_file>
|
||||||
Un session [options]
|
Un session [options]
|
||||||
Un service [options]
|
Un service [options]
|
||||||
|
Un service env <action> <service_id> [options]
|
||||||
Un key [options]
|
Un key [options]
|
||||||
|
|
||||||
Execute options:
|
Execute options:
|
||||||
|
|
@ -960,13 +1238,21 @@ Service options:
|
||||||
--info ID Get service details
|
--info ID Get service details
|
||||||
--logs ID Get all logs
|
--logs ID Get all logs
|
||||||
--tail ID Get last 9000 lines
|
--tail ID Get last 9000 lines
|
||||||
--freeze ID Freeze service
|
--freeze ID Freeze service
|
||||||
--unfreeze ID Unfreeze service
|
--unfreeze ID Unfreeze service
|
||||||
--destroy ID Destroy service
|
--destroy ID Destroy service
|
||||||
--execute ID Execute command in service
|
--execute ID Execute command in service
|
||||||
--command CMD Command to execute (with --execute)
|
--command CMD Command to execute (with --execute)
|
||||||
--dump-bootstrap ID Dump bootstrap script
|
--dump-bootstrap ID Dump bootstrap script
|
||||||
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
|
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
|
||||||
|
-e KEY=VALUE Set vault env var (with --name or env set)
|
||||||
|
--env-file FILE Load vault vars from file
|
||||||
|
|
||||||
|
Service env commands:
|
||||||
|
env status ID Check vault status
|
||||||
|
env set ID Set vault (use -e or --env-file)
|
||||||
|
env export ID Export vault contents
|
||||||
|
env delete ID Delete vault
|
||||||
|
|
||||||
Key options:
|
Key options:
|
||||||
--extend Open browser to extend expired key");
|
--extend Open browser to extend expired key");
|
||||||
|
|
|
||||||
179
Un.java
179
Un.java
|
|
@ -234,6 +234,12 @@ public class Un {
|
||||||
String publicKey = keys[0];
|
String publicKey = keys[0];
|
||||||
String secretKey = keys[1];
|
String secretKey = keys[1];
|
||||||
|
|
||||||
|
// Handle service env subcommand
|
||||||
|
if (args.envAction != null) {
|
||||||
|
cmdServiceEnv(args, publicKey, secretKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (args.serviceList) {
|
if (args.serviceList) {
|
||||||
Map<String, Object> result = apiRequest("/services", "GET", null, publicKey, secretKey);
|
Map<String, Object> result = apiRequest("/services", "GET", null, publicKey, secretKey);
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|
@ -374,11 +380,25 @@ public class Un {
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> result = apiRequest("/services", "POST", payload, publicKey, secretKey);
|
Map<String, Object> result = apiRequest("/services", "POST", payload, publicKey, secretKey);
|
||||||
System.out.println(GREEN + "Service created: " + result.getOrDefault("id", "N/A") + RESET);
|
String serviceId = (String) result.get("id");
|
||||||
|
System.out.println(GREEN + "Service created: " + (serviceId != null ? serviceId : "N/A") + RESET);
|
||||||
System.out.println("Name: " + result.getOrDefault("name", "N/A"));
|
System.out.println("Name: " + result.getOrDefault("name", "N/A"));
|
||||||
if (result.containsKey("url")) {
|
if (result.containsKey("url")) {
|
||||||
System.out.println("URL: " + result.get("url"));
|
System.out.println("URL: " + result.get("url"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-set vault if env vars provided
|
||||||
|
if (serviceId != null && (!args.env.isEmpty() || (args.envFile != null && !args.envFile.isEmpty()))) {
|
||||||
|
try {
|
||||||
|
String envContent = buildEnvContent(args.env, args.envFile);
|
||||||
|
if (!envContent.isEmpty() && envContent.length() <= 65536) {
|
||||||
|
serviceEnvSet(serviceId, envContent, publicKey, secretKey);
|
||||||
|
System.out.println(GREEN + "Vault configured with environment variables" + RESET);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println(YELLOW + "Warning: Failed to set vault: " + e.getMessage() + RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -599,6 +619,141 @@ public class Un {
|
||||||
return parseJson(response);
|
return parseJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String apiRequestText(String endpoint, String method, String body, String publicKey, String secretKey) throws Exception {
|
||||||
|
long timestamp = System.currentTimeMillis() / 1000;
|
||||||
|
String signatureData = timestamp + ":" + method + ":" + endpoint + ":" + body;
|
||||||
|
String signature = hmacSha256(secretKey, signatureData);
|
||||||
|
|
||||||
|
URL url = new URL(API_BASE + endpoint);
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod(method);
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + (publicKey != null ? publicKey : secretKey));
|
||||||
|
conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp));
|
||||||
|
conn.setRequestProperty("X-Signature", signature);
|
||||||
|
conn.setRequestProperty("Content-Type", "text/plain");
|
||||||
|
conn.setConnectTimeout(30000);
|
||||||
|
conn.setReadTimeout(300000);
|
||||||
|
|
||||||
|
if (body != null && !body.isEmpty()) {
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(body.getBytes("UTF-8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int status = conn.getResponseCode();
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
String error = readStream(conn.getErrorStream());
|
||||||
|
throw new Exception("HTTP " + status + " - " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return readStream(conn.getInputStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readEnvFile(String path) throws Exception {
|
||||||
|
return new String(Files.readAllBytes(Paths.get(path)), "UTF-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildEnvContent(List<String> envs, String envFile) throws Exception {
|
||||||
|
StringBuilder parts = new StringBuilder();
|
||||||
|
if (envFile != null && !envFile.isEmpty()) {
|
||||||
|
parts.append(readEnvFile(envFile).trim());
|
||||||
|
}
|
||||||
|
for (String e : envs) {
|
||||||
|
if (e.contains("=")) {
|
||||||
|
if (parts.length() > 0) parts.append("\n");
|
||||||
|
parts.append(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> serviceEnvStatus(String serviceId, String publicKey, String secretKey) throws Exception {
|
||||||
|
return apiRequest("/services/" + serviceId + "/env", "GET", null, publicKey, secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean serviceEnvSet(String serviceId, String envContent, String publicKey, String secretKey) throws Exception {
|
||||||
|
apiRequestText("/services/" + serviceId + "/env", "PUT", envContent, publicKey, secretKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> serviceEnvExport(String serviceId, String publicKey, String secretKey) throws Exception {
|
||||||
|
return apiRequest("/services/" + serviceId + "/env/export", "POST", null, publicKey, secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean serviceEnvDelete(String serviceId, String publicKey, String secretKey) throws Exception {
|
||||||
|
apiRequest("/services/" + serviceId + "/env", "DELETE", null, publicKey, secretKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void cmdServiceEnv(Args args, String publicKey, String secretKey) throws Exception {
|
||||||
|
String action = args.envAction;
|
||||||
|
String target = args.envTarget;
|
||||||
|
|
||||||
|
if (action == null) {
|
||||||
|
System.err.println(RED + "Error: Usage: service env <status|set|export|delete> <service_id>" + RESET);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.equals("status")) {
|
||||||
|
if (target == null) {
|
||||||
|
System.err.println(RED + "Error: Usage: service env status <service_id>" + RESET);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
Map<String, Object> result = serviceEnvStatus(target, publicKey, secretKey);
|
||||||
|
Boolean hasEnv = (Boolean) result.get("has_env");
|
||||||
|
Number size = (Number) result.get("size");
|
||||||
|
String updatedAt = (String) result.get("updated_at");
|
||||||
|
System.out.println("Service: " + target);
|
||||||
|
System.out.println("Has Vault: " + (hasEnv != null && hasEnv ? "Yes" : "No"));
|
||||||
|
if (hasEnv != null && hasEnv) {
|
||||||
|
System.out.println("Size: " + (size != null ? size.intValue() : 0) + " bytes");
|
||||||
|
System.out.println("Updated: " + (updatedAt != null ? updatedAt : "N/A"));
|
||||||
|
}
|
||||||
|
} else if (action.equals("set")) {
|
||||||
|
if (target == null) {
|
||||||
|
System.err.println(RED + "Error: Usage: service env set <service_id> [-e KEY=VAL] [--env-file FILE]" + RESET);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
String envContent = buildEnvContent(args.env, args.envFile);
|
||||||
|
if (envContent.isEmpty()) {
|
||||||
|
System.err.println(RED + "Error: No environment variables specified. Use -e KEY=VAL or --env-file FILE" + RESET);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
if (envContent.length() > 65536) {
|
||||||
|
System.err.println(RED + "Error: Environment content exceeds 64KB limit" + RESET);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
serviceEnvSet(target, envContent, publicKey, secretKey);
|
||||||
|
System.out.println(GREEN + "Vault updated for service: " + target + RESET);
|
||||||
|
} else if (action.equals("export")) {
|
||||||
|
if (target == null) {
|
||||||
|
System.err.println(RED + "Error: Usage: service env export <service_id>" + RESET);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
Map<String, Object> result = serviceEnvExport(target, publicKey, secretKey);
|
||||||
|
String content = (String) result.get("content");
|
||||||
|
if (content != null && !content.isEmpty()) {
|
||||||
|
System.out.print(content);
|
||||||
|
if (!content.endsWith("\n")) {
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
System.err.println(YELLOW + "Vault is empty" + RESET);
|
||||||
|
}
|
||||||
|
} else if (action.equals("delete")) {
|
||||||
|
if (target == null) {
|
||||||
|
System.err.println(RED + "Error: Usage: service env delete <service_id>" + RESET);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
serviceEnvDelete(target, publicKey, secretKey);
|
||||||
|
System.out.println(GREEN + "Vault deleted for service: " + target + RESET);
|
||||||
|
} else {
|
||||||
|
System.err.println(RED + "Error: Unknown env action: " + action + ". Use status, set, export, or delete" + RESET);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String readStream(InputStream is) throws IOException {
|
private static String readStream(InputStream is) throws IOException {
|
||||||
if (is == null) return "";
|
if (is == null) return "";
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
@ -810,6 +965,11 @@ public class Un {
|
||||||
String serviceDumpBootstrap = null;
|
String serviceDumpBootstrap = null;
|
||||||
String serviceDumpFile = null;
|
String serviceDumpFile = null;
|
||||||
|
|
||||||
|
// Vault args
|
||||||
|
String envFile = null;
|
||||||
|
String envAction = null;
|
||||||
|
String envTarget = null;
|
||||||
|
|
||||||
// Key args
|
// Key args
|
||||||
boolean keyExtend = false;
|
boolean keyExtend = false;
|
||||||
}
|
}
|
||||||
|
|
@ -873,6 +1033,16 @@ public class Un {
|
||||||
result.serviceDumpBootstrap = args[++i];
|
result.serviceDumpBootstrap = args[++i];
|
||||||
} else if (arg.equals("--dump-file")) {
|
} else if (arg.equals("--dump-file")) {
|
||||||
result.serviceDumpFile = args[++i];
|
result.serviceDumpFile = args[++i];
|
||||||
|
} else if (arg.equals("--env-file")) {
|
||||||
|
result.envFile = args[++i];
|
||||||
|
} else if (arg.equals("env") && "service".equals(result.command)) {
|
||||||
|
// service env <action> <target>
|
||||||
|
if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
||||||
|
result.envAction = args[++i];
|
||||||
|
if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
||||||
|
result.envTarget = args[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (arg.equals("--extend")) {
|
} else if (arg.equals("--extend")) {
|
||||||
result.keyExtend = true;
|
result.keyExtend = true;
|
||||||
} else if (!arg.startsWith("-")) {
|
} else if (!arg.startsWith("-")) {
|
||||||
|
|
@ -923,6 +1093,13 @@ public class Un {
|
||||||
System.out.println(" --dump-bootstrap ID Dump bootstrap script");
|
System.out.println(" --dump-bootstrap ID Dump bootstrap script");
|
||||||
System.out.println(" --dump-file FILE File to save bootstrap (with --dump-bootstrap)");
|
System.out.println(" --dump-file FILE File to save bootstrap (with --dump-bootstrap)");
|
||||||
System.out.println();
|
System.out.println();
|
||||||
|
System.out.println("Service env (vault) commands:");
|
||||||
|
System.out.println(" service env status ID Check vault status");
|
||||||
|
System.out.println(" service env set ID [-e K=V] Set vault contents");
|
||||||
|
System.out.println(" service env export ID Export vault contents");
|
||||||
|
System.out.println(" service env delete ID Delete vault");
|
||||||
|
System.out.println(" --env-file FILE Read env vars from file");
|
||||||
|
System.out.println();
|
||||||
System.out.println("Key options:");
|
System.out.println("Key options:");
|
||||||
System.out.println(" --extend Open browser to extend key");
|
System.out.println(" --extend Open browser to extend key");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
220
un.awk
220
un.awk
|
|
@ -409,7 +409,7 @@ function session_create(shell, network, vcpu, input_files , json, tmp, timest
|
||||||
print response
|
print response
|
||||||
}
|
}
|
||||||
|
|
||||||
function service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, boot_content, line, input_files_json) {
|
function service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, boot_content, line, input_files_json, response) {
|
||||||
get_api_keys()
|
get_api_keys()
|
||||||
|
|
||||||
# Build JSON payload
|
# Build JSON payload
|
||||||
|
|
@ -495,6 +495,12 @@ function service_create(name, ports, domains, service_type, bootstrap, bootstrap
|
||||||
# Clean up
|
# Clean up
|
||||||
system("rm -f " tmp)
|
system("rm -f " tmp)
|
||||||
|
|
||||||
|
# Extract service ID for auto-vault
|
||||||
|
LAST_SERVICE_ID = ""
|
||||||
|
if (match(response, /"id":"([^"]+)"/, arr)) {
|
||||||
|
LAST_SERVICE_ID = arr[1]
|
||||||
|
}
|
||||||
|
|
||||||
# Print response
|
# Print response
|
||||||
print response
|
print response
|
||||||
}
|
}
|
||||||
|
|
@ -833,6 +839,141 @@ function service_restore(snapshot_id , endpoint, json, tmp, timestamp, sig_he
|
||||||
print GREEN "Service restored from snapshot" RESET
|
print GREEN "Service restored from snapshot" RESET
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Build env content from env_vars array and env_file
|
||||||
|
function build_env_content(env_vars_str, env_file , content, n, vars, i, line) {
|
||||||
|
content = ""
|
||||||
|
# Parse comma-separated env vars
|
||||||
|
if (env_vars_str != "") {
|
||||||
|
n = split(env_vars_str, vars, ",")
|
||||||
|
for (i = 1; i <= n; i++) {
|
||||||
|
if (content != "") content = content "\n"
|
||||||
|
content = content vars[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Read env file if provided
|
||||||
|
if (env_file != "") {
|
||||||
|
while ((getline line < env_file) > 0) {
|
||||||
|
# Skip empty lines and comments
|
||||||
|
if (line ~ /^[[:space:]]*$/) continue
|
||||||
|
if (line ~ /^[[:space:]]*#/) continue
|
||||||
|
if (content != "") content = content "\n"
|
||||||
|
content = content line
|
||||||
|
}
|
||||||
|
close(env_file)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function service_env_status(id , endpoint, timestamp, sig_headers, signature, sig_input, sig_cmd, line) {
|
||||||
|
get_api_keys()
|
||||||
|
endpoint = "/services/" id "/env"
|
||||||
|
timestamp = systime()
|
||||||
|
sig_headers = ""
|
||||||
|
if (GLOBAL_SECRET_KEY != "") {
|
||||||
|
sig_input = timestamp ":GET:" endpoint ":"
|
||||||
|
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
|
||||||
|
sig_cmd | getline signature
|
||||||
|
close(sig_cmd)
|
||||||
|
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'"
|
||||||
|
}
|
||||||
|
cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers
|
||||||
|
while ((cmd | getline line) > 0) print line
|
||||||
|
close(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function service_env_set(id, content , endpoint, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) {
|
||||||
|
get_api_keys()
|
||||||
|
endpoint = "/services/" id "/env"
|
||||||
|
|
||||||
|
# Write content to temp file
|
||||||
|
tmp = "/tmp/un_awk_env_" PROCINFO["pid"] ".txt"
|
||||||
|
print content > tmp
|
||||||
|
close(tmp)
|
||||||
|
|
||||||
|
timestamp = systime()
|
||||||
|
sig_headers = ""
|
||||||
|
if (GLOBAL_SECRET_KEY != "") {
|
||||||
|
sig_input = timestamp ":PUT:" endpoint ":" content
|
||||||
|
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 PUT '" API_BASE endpoint "' " \
|
||||||
|
"-H 'Content-Type: text/plain' " \
|
||||||
|
"-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \
|
||||||
|
sig_headers \
|
||||||
|
"--data-binary '@" tmp "'"
|
||||||
|
|
||||||
|
response = ""
|
||||||
|
while ((cmd | getline line) > 0) {
|
||||||
|
response = response line
|
||||||
|
}
|
||||||
|
close(cmd)
|
||||||
|
system("rm -f " tmp)
|
||||||
|
print response
|
||||||
|
}
|
||||||
|
|
||||||
|
function service_env_export(id , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) {
|
||||||
|
get_api_keys()
|
||||||
|
endpoint = "/services/" id "/env/export"
|
||||||
|
json = "{}"
|
||||||
|
|
||||||
|
tmp = "/tmp/un_awk_envexp_" PROCINFO["pid"] ".json"
|
||||||
|
print json > tmp
|
||||||
|
close(tmp)
|
||||||
|
|
||||||
|
timestamp = systime()
|
||||||
|
sig_headers = ""
|
||||||
|
if (GLOBAL_SECRET_KEY != "") {
|
||||||
|
sig_input = timestamp ":POST:" endpoint ":" json
|
||||||
|
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
|
||||||
|
sig_cmd | getline signature
|
||||||
|
close(sig_cmd)
|
||||||
|
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' "
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = "curl -s -X POST '" API_BASE endpoint "' " \
|
||||||
|
"-H 'Content-Type: application/json' " \
|
||||||
|
"-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \
|
||||||
|
sig_headers \
|
||||||
|
"-d '@" tmp "'"
|
||||||
|
|
||||||
|
response = ""
|
||||||
|
while ((cmd | getline line) > 0) {
|
||||||
|
response = response line
|
||||||
|
}
|
||||||
|
close(cmd)
|
||||||
|
system("rm -f " tmp)
|
||||||
|
|
||||||
|
# Extract content field from response
|
||||||
|
if (match(response, /"content":"([^"]*)"/, arr)) {
|
||||||
|
content = arr[1]
|
||||||
|
gsub(/\\n/, "\n", content)
|
||||||
|
printf "%s", content
|
||||||
|
} else {
|
||||||
|
print response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function service_env_delete(id , endpoint, timestamp, sig_headers, signature, sig_input, sig_cmd) {
|
||||||
|
get_api_keys()
|
||||||
|
endpoint = "/services/" id "/env"
|
||||||
|
timestamp = systime()
|
||||||
|
sig_headers = ""
|
||||||
|
if (GLOBAL_SECRET_KEY != "") {
|
||||||
|
sig_input = timestamp ":DELETE:" endpoint ":"
|
||||||
|
sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'"
|
||||||
|
sig_cmd | getline signature
|
||||||
|
close(sig_cmd)
|
||||||
|
sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'"
|
||||||
|
}
|
||||||
|
cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers
|
||||||
|
system(cmd)
|
||||||
|
print GREEN "Vault deleted: " id RESET
|
||||||
|
}
|
||||||
|
|
||||||
function show_help() {
|
function show_help() {
|
||||||
print "Usage: awk -f un.awk <source_file>"
|
print "Usage: awk -f un.awk <source_file>"
|
||||||
print " awk -f un.awk session --list"
|
print " awk -f un.awk session --list"
|
||||||
|
|
@ -842,11 +983,15 @@ function show_help() {
|
||||||
print " awk -f un.awk session --restore SNAPSHOT_ID"
|
print " awk -f un.awk session --restore SNAPSHOT_ID"
|
||||||
print " awk -f un.awk key [--extend]"
|
print " awk -f un.awk key [--extend]"
|
||||||
print " awk -f un.awk service --list"
|
print " awk -f un.awk service --list"
|
||||||
print " awk -f un.awk service --create --name NAME [--ports PORTS] [--domains DOMAINS] [--type TYPE] [--bootstrap CMD] [-f FILE]..."
|
print " awk -f un.awk service --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 --destroy ID"
|
||||||
print " awk -f un.awk service --dump-bootstrap ID [--dump-file FILE]"
|
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 --snapshot SERVICE_ID [--snapshot-name NAME] [--hot]"
|
||||||
print " awk -f un.awk service --restore SNAPSHOT_ID"
|
print " awk -f un.awk service --restore SNAPSHOT_ID"
|
||||||
|
print " awk -f un.awk service env status ID"
|
||||||
|
print " awk -f un.awk service env set ID [-e KEY=VAL]... [--env-file FILE]"
|
||||||
|
print " awk -f un.awk service env export ID"
|
||||||
|
print " awk -f un.awk service env delete ID"
|
||||||
print " awk -f un.awk snapshot --list"
|
print " awk -f un.awk snapshot --list"
|
||||||
print " awk -f un.awk snapshot --info ID"
|
print " awk -f un.awk snapshot --info ID"
|
||||||
print " awk -f un.awk snapshot --delete ID"
|
print " awk -f un.awk snapshot --delete ID"
|
||||||
|
|
@ -867,12 +1012,20 @@ function show_help() {
|
||||||
print " --bootstrap CMD Bootstrap command or script"
|
print " --bootstrap CMD Bootstrap command or script"
|
||||||
print " --dump-bootstrap ID Dump bootstrap script from service"
|
print " --dump-bootstrap ID Dump bootstrap script from service"
|
||||||
print " --dump-file FILE Save bootstrap to file (with --dump-bootstrap)"
|
print " --dump-file FILE Save bootstrap to file (with --dump-bootstrap)"
|
||||||
|
print " -e KEY=VAL Environment variable for vault (can be repeated)"
|
||||||
|
print " --env-file FILE Load env vars from file for vault"
|
||||||
print " -f FILE Input file to upload (can be repeated)"
|
print " -f FILE Input file to upload (can be repeated)"
|
||||||
print " --snapshot SERVICE_ID Create snapshot of service"
|
print " --snapshot SERVICE_ID Create snapshot of service"
|
||||||
print " --restore SNAPSHOT_ID Restore from snapshot ID"
|
print " --restore SNAPSHOT_ID Restore from snapshot ID"
|
||||||
print " --snapshot-name N Name for snapshot"
|
print " --snapshot-name N Name for snapshot"
|
||||||
print " --hot Take snapshot without freezing (live snapshot)"
|
print " --hot Take snapshot without freezing (live snapshot)"
|
||||||
print ""
|
print ""
|
||||||
|
print "Vault options (service env):"
|
||||||
|
print " status ID Check vault status"
|
||||||
|
print " set ID Set vault contents"
|
||||||
|
print " export ID Export vault contents"
|
||||||
|
print " delete ID Delete vault"
|
||||||
|
print ""
|
||||||
print "Snapshot options:"
|
print "Snapshot options:"
|
||||||
print " -l, --list List all snapshots"
|
print " -l, --list List all snapshots"
|
||||||
print " --info ID Get snapshot details"
|
print " --info ID Get snapshot details"
|
||||||
|
|
@ -1013,6 +1166,50 @@ END {
|
||||||
} else if (ARGC >= 4 && ARGV[2] == "--restore") {
|
} else if (ARGC >= 4 && ARGV[2] == "--restore") {
|
||||||
# --restore takes snapshot ID directly
|
# --restore takes snapshot ID directly
|
||||||
service_restore(ARGV[3])
|
service_restore(ARGV[3])
|
||||||
|
} else if (ARGC >= 4 && ARGV[2] == "env") {
|
||||||
|
# Service vault commands: service env <action> <id> [options]
|
||||||
|
env_action = ARGV[3]
|
||||||
|
if (ARGC < 5) {
|
||||||
|
print RED "Error: service env requires action and service ID" RESET > "/dev/stderr"
|
||||||
|
print "Usage: awk -f un.awk service env <status|set|export|delete> <service_id> [options]" > "/dev/stderr"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
env_service_id = ARGV[4]
|
||||||
|
|
||||||
|
if (env_action == "status") {
|
||||||
|
service_env_status(env_service_id)
|
||||||
|
} else if (env_action == "set") {
|
||||||
|
# Parse -e and --env-file options
|
||||||
|
env_vars = ""
|
||||||
|
env_file = ""
|
||||||
|
i = 5
|
||||||
|
while (i < ARGC) {
|
||||||
|
if (ARGV[i] == "-e" && i + 1 < ARGC) {
|
||||||
|
if (env_vars != "") env_vars = env_vars ","
|
||||||
|
env_vars = env_vars ARGV[i + 1]
|
||||||
|
i += 2
|
||||||
|
} else if (ARGV[i] == "--env-file" && i + 1 < ARGC) {
|
||||||
|
env_file = ARGV[i + 1]
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env_content = build_env_content(env_vars, env_file)
|
||||||
|
if (env_content == "") {
|
||||||
|
print RED "Error: No environment variables to set. Use -e KEY=VALUE or --env-file FILE" RESET > "/dev/stderr"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
service_env_set(env_service_id, env_content)
|
||||||
|
} else if (env_action == "export") {
|
||||||
|
service_env_export(env_service_id)
|
||||||
|
} else if (env_action == "delete") {
|
||||||
|
service_env_delete(env_service_id)
|
||||||
|
} else {
|
||||||
|
print RED "Unknown env action: " env_action RESET > "/dev/stderr"
|
||||||
|
print "Usage: awk -f un.awk service env <status|set|export|delete> <service_id>" > "/dev/stderr"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
} else if (ARGV[2] == "--create") {
|
} else if (ARGV[2] == "--create") {
|
||||||
# Parse service creation arguments
|
# Parse service creation arguments
|
||||||
name = ""
|
name = ""
|
||||||
|
|
@ -1022,6 +1219,8 @@ END {
|
||||||
bootstrap = ""
|
bootstrap = ""
|
||||||
bootstrap_file = ""
|
bootstrap_file = ""
|
||||||
input_files = ""
|
input_files = ""
|
||||||
|
env_vars = ""
|
||||||
|
env_file = ""
|
||||||
|
|
||||||
i = 3
|
i = 3
|
||||||
while (i < ARGC) {
|
while (i < ARGC) {
|
||||||
|
|
@ -1047,6 +1246,13 @@ END {
|
||||||
if (input_files != "") input_files = input_files ","
|
if (input_files != "") input_files = input_files ","
|
||||||
input_files = input_files ARGV[i + 1]
|
input_files = input_files ARGV[i + 1]
|
||||||
i += 2
|
i += 2
|
||||||
|
} else if (ARGV[i] == "-e" && i + 1 < ARGC) {
|
||||||
|
if (env_vars != "") env_vars = env_vars ","
|
||||||
|
env_vars = env_vars ARGV[i + 1]
|
||||||
|
i += 2
|
||||||
|
} else if (ARGV[i] == "--env-file" && i + 1 < ARGC) {
|
||||||
|
env_file = ARGV[i + 1]
|
||||||
|
i += 2
|
||||||
} else {
|
} else {
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
@ -1058,6 +1264,16 @@ END {
|
||||||
}
|
}
|
||||||
|
|
||||||
service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files)
|
service_create(name, ports, domains, service_type, bootstrap, bootstrap_file, input_files)
|
||||||
|
|
||||||
|
# Auto-set vault if env vars were provided
|
||||||
|
env_content = build_env_content(env_vars, env_file)
|
||||||
|
if (env_content != "") {
|
||||||
|
# Extract service ID from response (stored in LAST_SERVICE_ID global)
|
||||||
|
if (LAST_SERVICE_ID != "") {
|
||||||
|
print YELLOW "Setting vault for service..." RESET
|
||||||
|
service_env_set(LAST_SERVICE_ID, env_content)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
print "Usage: awk -f un.awk service --list|--create|--destroy ID"
|
print "Usage: awk -f un.awk service --list|--create|--destroy ID"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
209
un.clj
209
un.clj
|
|
@ -202,6 +202,121 @@
|
||||||
(check-clock-drift-error result)
|
(check-clock-drift-error result)
|
||||||
result))
|
result))
|
||||||
|
|
||||||
|
(defn curl-put-text [endpoint body]
|
||||||
|
(let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".txt")
|
||||||
|
[public-key secret-key] (get-api-keys)
|
||||||
|
auth-headers (build-auth-headers public-key secret-key "PUT" endpoint body)]
|
||||||
|
(spit tmp-file body)
|
||||||
|
(let [args (concat ["curl" "-s" "-o" "/dev/null" "-w" "%{http_code}" "-X" "PUT"
|
||||||
|
(str "https://api.unsandbox.com" endpoint)
|
||||||
|
"-H" "Content-Type: text/plain"]
|
||||||
|
auth-headers
|
||||||
|
["-d" (str "@" tmp-file)])
|
||||||
|
{:keys [out]} (apply sh args)]
|
||||||
|
(io/delete-file tmp-file true)
|
||||||
|
(let [status (Integer/parseInt (str/trim out))]
|
||||||
|
(and (>= status 200) (< status 300))))))
|
||||||
|
|
||||||
|
(def max-env-content-size 65536)
|
||||||
|
|
||||||
|
(defn read-env-file [path]
|
||||||
|
(if (.exists (io/file path))
|
||||||
|
(slurp path)
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: Env file not found: " path reset)))
|
||||||
|
(System/exit 1))))
|
||||||
|
|
||||||
|
(defn build-env-content [envs env-file]
|
||||||
|
(let [file-lines (if env-file
|
||||||
|
(->> (str/split (read-env-file env-file) #"\n")
|
||||||
|
(map str/trim)
|
||||||
|
(filter #(and (> (count %) 0) (not (.startsWith % "#")))))
|
||||||
|
[])]
|
||||||
|
(str/join "\n" (concat envs file-lines))))
|
||||||
|
|
||||||
|
(defn service-env-status [service-id]
|
||||||
|
(let [api-key (get-api-key)]
|
||||||
|
(curl-get api-key (str "/services/" service-id "/env"))))
|
||||||
|
|
||||||
|
(defn service-env-set [service-id env-content]
|
||||||
|
(if (> (count env-content) max-env-content-size)
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: Env content exceeds maximum size of 64KB" reset)))
|
||||||
|
false)
|
||||||
|
(curl-put-text (str "/services/" service-id "/env") env-content)))
|
||||||
|
|
||||||
|
(defn service-env-export [service-id]
|
||||||
|
(let [api-key (get-api-key)]
|
||||||
|
(curl-post api-key (str "/services/" service-id "/env/export") "{}")))
|
||||||
|
|
||||||
|
(defn service-env-delete [service-id]
|
||||||
|
(let [api-key (get-api-key)]
|
||||||
|
(try
|
||||||
|
(curl-delete api-key (str "/services/" service-id "/env"))
|
||||||
|
true
|
||||||
|
(catch Exception _ false))))
|
||||||
|
|
||||||
|
(defn service-env-command [action target envs env-file]
|
||||||
|
(case action
|
||||||
|
"status" (if target
|
||||||
|
(let [response (service-env-status target)
|
||||||
|
has-vault (= (extract-field "has_vault" response) "true")]
|
||||||
|
(if has-vault
|
||||||
|
(do
|
||||||
|
(println (str green "Vault: configured" reset))
|
||||||
|
(when-let [env-count (extract-field "env_count" response)]
|
||||||
|
(println (str "Variables: " env-count)))
|
||||||
|
(when-let [updated-at (extract-field "updated_at" response)]
|
||||||
|
(println (str "Updated: " updated-at))))
|
||||||
|
(println (str yellow "Vault: not configured" reset))))
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: service env status requires service ID" reset)))
|
||||||
|
(System/exit 1)))
|
||||||
|
"set" (if target
|
||||||
|
(if (and (empty? envs) (nil? env-file))
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: service env set requires -e or --env-file" reset)))
|
||||||
|
(System/exit 1))
|
||||||
|
(let [env-content (build-env-content envs env-file)]
|
||||||
|
(if (service-env-set target env-content)
|
||||||
|
(println (str green "Vault updated for service " target reset))
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: Failed to update vault" reset)))
|
||||||
|
(System/exit 1)))))
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: service env set requires service ID" reset)))
|
||||||
|
(System/exit 1)))
|
||||||
|
"export" (if target
|
||||||
|
(let [response (service-env-export target)
|
||||||
|
content (extract-field "content" response)]
|
||||||
|
(when content (print (unescape-json content))))
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: service env export requires service ID" reset)))
|
||||||
|
(System/exit 1)))
|
||||||
|
"delete" (if target
|
||||||
|
(if (service-env-delete target)
|
||||||
|
(println (str green "Vault deleted for service " target reset))
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: Failed to delete vault" reset)))
|
||||||
|
(System/exit 1)))
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: service env delete requires service ID" reset)))
|
||||||
|
(System/exit 1)))
|
||||||
|
(do
|
||||||
|
(binding [*out* *err*]
|
||||||
|
(println (str red "Error: Unknown env action: " action reset))
|
||||||
|
(println "Usage: un.clj service env <status|set|export|delete> <service_id>"))
|
||||||
|
(System/exit 1))))
|
||||||
|
|
||||||
(defn curl-portal-post [api-key endpoint json-data]
|
(defn curl-portal-post [api-key endpoint json-data]
|
||||||
(let [tmp-file (str "/tmp/un_clj_portal_" (rand-int 999999) ".json")
|
(let [tmp-file (str "/tmp/un_clj_portal_" (rand-int 999999) ".json")
|
||||||
[public-key secret-key] (get-api-keys)
|
[public-key secret-key] (get-api-keys)
|
||||||
|
|
@ -265,9 +380,10 @@
|
||||||
(println (str yellow "Session created (WebSocket required)" reset))
|
(println (str yellow "Session created (WebSocket required)" reset))
|
||||||
(println (curl-post api-key "/sessions" json))))))
|
(println (curl-post api-key "/sessions" json))))))
|
||||||
|
|
||||||
(defn service-command [action sid name ports bootstrap bootstrap-file service-type network vcpu input-files]
|
(defn service-command [action sid name ports bootstrap bootstrap-file service-type network vcpu input-files envs env-file]
|
||||||
(let [api-key (get-api-key)]
|
(let [api-key (get-api-key)]
|
||||||
(case action
|
(case action
|
||||||
|
:env (service-env-command sid name envs env-file)
|
||||||
:list (println (curl-get api-key "/services"))
|
:list (println (curl-get api-key "/services"))
|
||||||
:info (println (curl-get api-key (str "/services/" sid)))
|
:info (println (curl-get api-key (str "/services/" sid)))
|
||||||
:logs (println (curl-get api-key (str "/services/" sid "/logs")))
|
:logs (println (curl-get api-key (str "/services/" sid "/logs")))
|
||||||
|
|
@ -315,9 +431,18 @@
|
||||||
network-json (if network (str ",\"network\":\"" network "\"") "")
|
network-json (if network (str ",\"network\":\"" network "\"") "")
|
||||||
vcpu-json (if vcpu (str ",\"vcpu\":" vcpu) "")
|
vcpu-json (if vcpu (str ",\"vcpu\":" vcpu) "")
|
||||||
input-files-json (build-input-files-json input-files)
|
input-files-json (build-input-files-json input-files)
|
||||||
json (str "{\"name\":\"" name "\"" ports-json bootstrap-json bootstrap-content-json service-type-json network-json vcpu-json input-files-json "}")]
|
json (str "{\"name\":\"" name "\"" ports-json bootstrap-json bootstrap-content-json service-type-json network-json vcpu-json input-files-json "}")
|
||||||
|
response (curl-post api-key "/services" json)
|
||||||
|
service-id (extract-field "id" response)]
|
||||||
(println (str green "Service created" reset))
|
(println (str green "Service created" reset))
|
||||||
(println (curl-post api-key "/services" json)))))))
|
(println response)
|
||||||
|
;; Auto-set vault if env vars were provided
|
||||||
|
(when (and service-id (or (seq envs) env-file))
|
||||||
|
(let [env-content (build-env-content envs env-file)]
|
||||||
|
(when (> (count env-content) 0)
|
||||||
|
(if (service-env-set service-id env-content)
|
||||||
|
(println (str green "Vault configured with environment variables" reset))
|
||||||
|
(println (str yellow "Warning: Failed to set vault" reset))))))))))))
|
||||||
|
|
||||||
(defn validate-key [api-key extend?]
|
(defn validate-key [api-key extend?]
|
||||||
(let [response (curl-portal-post api-key "/keys/validate" "{}")
|
(let [response (curl-portal-post api-key "/keys/validate" "{}")
|
||||||
|
|
@ -388,33 +513,36 @@
|
||||||
service-bootstrap-file nil
|
service-bootstrap-file nil
|
||||||
service-type nil
|
service-type nil
|
||||||
service-input-files []
|
service-input-files []
|
||||||
|
service-envs []
|
||||||
|
service-env-file nil
|
||||||
key-extend false
|
key-extend false
|
||||||
mode :execute]
|
mode :execute]
|
||||||
(cond
|
(cond
|
||||||
(empty? args)
|
(empty? args)
|
||||||
(case mode
|
(case mode
|
||||||
:session (session-command (or session-action :create) session-id session-shell network vcpu session-input-files)
|
:session (session-command (or session-action :create) session-id session-shell network vcpu session-input-files)
|
||||||
:service (service-command (or service-action :create) service-id service-name service-ports service-bootstrap service-bootstrap-file service-type network vcpu service-input-files)
|
:service (service-command (or service-action :create) service-id service-name service-ports service-bootstrap service-bootstrap-file service-type network vcpu service-input-files service-envs service-env-file)
|
||||||
:key (key-command key-extend)
|
:key (key-command key-extend)
|
||||||
:execute (if file
|
:execute (if file
|
||||||
(execute-command file env-vars artifacts out-dir network vcpu)
|
(execute-command file env-vars artifacts out-dir network vcpu)
|
||||||
(do (println "Usage: un.clj [options] <source_file>")
|
(do (println "Usage: un.clj [options] <source_file>")
|
||||||
(println " un.clj session [options]")
|
(println " un.clj session [options]")
|
||||||
(println " un.clj service [options]")
|
(println " un.clj service [options]")
|
||||||
|
(println " un.clj service env <action> <service_id>")
|
||||||
(println " un.clj key [options]")
|
(println " un.clj key [options]")
|
||||||
(System/exit 1))))
|
(System/exit 1))))
|
||||||
|
|
||||||
(= (first args) "session")
|
(= (first args) "session")
|
||||||
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :session)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend :session)
|
||||||
|
|
||||||
(= (first args) "service")
|
(= (first args) "service")
|
||||||
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :service)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend :service)
|
||||||
|
|
||||||
(= (first args) "key")
|
(= (first args) "key")
|
||||||
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend :key)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend :key)
|
||||||
|
|
||||||
;; Key options
|
;; Key options
|
||||||
(and (= mode :key) (= (first args) "--extend"))
|
(and (= mode :key) (= (first args) "--extend"))
|
||||||
|
|
@ -424,107 +552,122 @@
|
||||||
;; Session options
|
;; Session options
|
||||||
(and (= mode :session) (= (first args) "--list"))
|
(and (= mode :session) (= (first args) "--list"))
|
||||||
(recur (rest args) file env-vars artifacts out-dir network vcpu :list session-id session-shell session-input-files
|
(recur (rest args) file env-vars artifacts out-dir network vcpu :list session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(and (= mode :session) (= (first args) "--kill"))
|
(and (= mode :session) (= (first args) "--kill"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu :kill (second args) session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu :kill (second args) session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(and (= mode :session) (or (= (first args) "--shell") (= (first args) "-s")))
|
(and (= mode :session) (or (= (first args) "--shell") (= (first args) "-s")))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id (second args) session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id (second args) session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(and (= mode :session) (= (first args) "-f"))
|
(and (= mode :session) (= (first args) "-f"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell (conj session-input-files (second args))
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell (conj session-input-files (second args))
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
;; Service options
|
;; Service options
|
||||||
(and (= mode :service) (= (first args) "--list"))
|
(and (= mode :service) (= (first args) "--list"))
|
||||||
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
:list service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
:list service-id 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) "--info"))
|
(and (= mode :service) (= (first args) "--info"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
:info (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
:info (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) "--logs"))
|
(and (= mode :service) (= (first args) "--logs"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
:logs (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
:logs (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) "--freeze"))
|
(and (= mode :service) (= (first args) "--freeze"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
:sleep (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
:sleep (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) "--unfreeze"))
|
(and (= mode :service) (= (first args) "--unfreeze"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
:wake (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
:wake (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) "--destroy"))
|
(and (= mode :service) (= (first args) "--destroy"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(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 key-extend mode)
|
: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) "--execute"))
|
(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
|
(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 key-extend mode)
|
: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)
|
||||||
|
|
||||||
(and (= mode :service) (= (first args) "--dump-bootstrap") (>= (count args) 3) (not (.startsWith (nth args 2) "-")))
|
(and (= mode :service) (= (first args) "--dump-bootstrap") (>= (count args) 3) (not (.startsWith (nth args 2) "-")))
|
||||||
(recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest (rest args))) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
:dump-bootstrap (second args) service-name service-ports service-bootstrap (nth args 2) service-type service-input-files key-extend mode)
|
:dump-bootstrap (second args) service-name service-ports service-bootstrap (nth args 2) service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(and (= mode :service) (= (first args) "--dump-bootstrap"))
|
(and (= mode :service) (= (first args) "--dump-bootstrap"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
:dump-bootstrap (second args) service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
:dump-bootstrap (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) "--name"))
|
(and (= mode :service) (= (first args) "--name"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
:create service-id (second args) service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
:create service-id (second args) 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) "--ports"))
|
(and (= mode :service) (= (first args) "--ports"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name (second args) service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name (second args) service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(and (= mode :service) (= (first args) "--bootstrap"))
|
(and (= mode :service) (= (first args) "--bootstrap"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports (second args) service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports (second args) service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(and (= mode :service) (= (first args) "--bootstrap-file"))
|
(and (= mode :service) (= (first args) "--bootstrap-file"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap (second args) service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap (second args) service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(and (= mode :service) (= (first args) "--type"))
|
(and (= mode :service) (= (first args) "--type"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file (second args) service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file (second args) service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(and (= mode :service) (= (first args) "-f"))
|
(and (= mode :service) (= (first args) "-f"))
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type (conj service-input-files (second args)) key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type (conj service-input-files (second args)) service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
|
(and (= mode :service) (= (first args) "env") (>= (count args) 2))
|
||||||
|
(let [env-action (second args)
|
||||||
|
env-target (when (and (>= (count args) 3) (not (.startsWith (nth args 2) "-"))) (nth args 2))
|
||||||
|
rest-args (if env-target (drop 3 args) (drop 2 args))]
|
||||||
|
(recur rest-args file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
|
:env env-action env-target 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) "-e"))
|
||||||
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files (conj service-envs (second args)) service-env-file key-extend mode)
|
||||||
|
|
||||||
|
(and (= mode :service) (= (first args) "--env-file"))
|
||||||
|
(recur (rest (rest args)) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs (second args) key-extend mode)
|
||||||
|
|
||||||
;; Execute options
|
;; Execute options
|
||||||
(= (first args) "-e")
|
(= (first args) "-e")
|
||||||
(let [[k v] (str/split (second args) #"=" 2)]
|
(let [[k v] (str/split (second args) #"=" 2)]
|
||||||
(recur (rest (rest args)) file (conj env-vars [k v]) artifacts out-dir network vcpu
|
(recur (rest (rest args)) file (conj env-vars [k v]) artifacts out-dir network vcpu
|
||||||
session-action session-id session-shell session-input-files service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode))
|
session-action session-id session-shell session-input-files service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode))
|
||||||
|
|
||||||
(= (first args) "-a")
|
(= (first args) "-a")
|
||||||
(recur (rest args) file env-vars true out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest args) file env-vars true out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(= (first args) "-o")
|
(= (first args) "-o")
|
||||||
(recur (rest (rest args)) file env-vars artifacts (second args) network vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts (second args) network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(= (first args) "-n")
|
(= (first args) "-n")
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir (second args) vcpu session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir (second args) vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
(= (first args) "-v")
|
(= (first args) "-v")
|
||||||
(recur (rest (rest args)) file env-vars artifacts out-dir network (Integer/parseInt (second args)) session-action session-id session-shell session-input-files
|
(recur (rest (rest args)) file env-vars artifacts out-dir network (Integer/parseInt (second args)) session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
;; Source file
|
;; Source file
|
||||||
(and (= mode :execute) (not (.startsWith (first args) "-")) (nil? file))
|
(and (= mode :execute) (not (.startsWith (first args) "-")) (nil? file))
|
||||||
(recur (rest args) (first args) env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest args) (first args) env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode)
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode)
|
||||||
|
|
||||||
;; Unknown option check
|
;; Unknown option check
|
||||||
(and (= mode :session) (.startsWith (first args) "-"))
|
(and (= mode :session) (.startsWith (first args) "-"))
|
||||||
|
|
@ -536,6 +679,6 @@
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
(recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files
|
||||||
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files key-extend mode))))
|
service-action service-id service-name service-ports service-bootstrap service-bootstrap-file service-type service-input-files service-envs service-env-file key-extend mode))))
|
||||||
|
|
||||||
(parse-args *command-line-args*)
|
(parse-args *command-line-args*)
|
||||||
|
|
|
||||||
305
un.cob
305
un.cob
|
|
@ -80,6 +80,10 @@
|
||||||
01 WS-PORTAL-BASE PIC X(256) VALUE
|
01 WS-PORTAL-BASE PIC X(256) VALUE
|
||||||
"https://unsandbox.com".
|
"https://unsandbox.com".
|
||||||
01 WS-EXTEND-FLAG PIC X(8).
|
01 WS-EXTEND-FLAG PIC X(8).
|
||||||
|
01 WS-SVC-ENVS PIC X(2048).
|
||||||
|
01 WS-SVC-ENV-FILE PIC X(256).
|
||||||
|
01 WS-ENV-ACTION PIC X(32).
|
||||||
|
01 WS-ENV-TARGET PIC X(256).
|
||||||
|
|
||||||
PROCEDURE DIVISION.
|
PROCEDURE DIVISION.
|
||||||
MAIN-PROCEDURE.
|
MAIN-PROCEDURE.
|
||||||
|
|
@ -177,12 +181,26 @@
|
||||||
END-IF.
|
END-IF.
|
||||||
|
|
||||||
HANDLE-SERVICE.
|
HANDLE-SERVICE.
|
||||||
* Get API key
|
* Get API keys (try new format first, fall back to old)
|
||||||
ACCEPT WS-API-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY".
|
ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_PUBLIC_KEY".
|
||||||
IF WS-API-KEY = SPACES
|
IF WS-PUBLIC-KEY NOT = SPACES
|
||||||
DISPLAY "Error: UNSANDBOX_API_KEY not set" UPON SYSERR
|
ACCEPT WS-SECRET-KEY FROM ENVIRONMENT "UNSANDBOX_SECRET_KEY"
|
||||||
MOVE 1 TO RETURN-CODE
|
IF WS-SECRET-KEY = SPACES
|
||||||
STOP RUN
|
DISPLAY "Error: UNSANDBOX_SECRET_KEY not set"
|
||||||
|
UPON SYSERR
|
||||||
|
MOVE 1 TO RETURN-CODE
|
||||||
|
STOP RUN
|
||||||
|
END-IF
|
||||||
|
ELSE
|
||||||
|
ACCEPT WS-API-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY"
|
||||||
|
IF WS-API-KEY = SPACES
|
||||||
|
DISPLAY "Error: UNSANDBOX_PUBLIC_KEY/SECRET_KEY or "
|
||||||
|
"UNSANDBOX_API_KEY not set" UPON SYSERR
|
||||||
|
MOVE 1 TO RETURN-CODE
|
||||||
|
STOP RUN
|
||||||
|
END-IF
|
||||||
|
MOVE WS-API-KEY TO WS-PUBLIC-KEY
|
||||||
|
MOVE WS-API-KEY TO WS-SECRET-KEY
|
||||||
END-IF.
|
END-IF.
|
||||||
|
|
||||||
* Initialize service parameters
|
* Initialize service parameters
|
||||||
|
|
@ -193,12 +211,21 @@
|
||||||
MOVE SPACES TO WS-BOOTSTRAP.
|
MOVE SPACES TO WS-BOOTSTRAP.
|
||||||
MOVE SPACES TO WS-BOOTSTRAP-FILE.
|
MOVE SPACES TO WS-BOOTSTRAP-FILE.
|
||||||
MOVE SPACES TO WS-INPUT-FILES.
|
MOVE SPACES TO WS-INPUT-FILES.
|
||||||
|
MOVE SPACES TO WS-SVC-ENVS.
|
||||||
|
MOVE SPACES TO WS-SVC-ENV-FILE.
|
||||||
|
MOVE SPACES TO WS-ENV-ACTION.
|
||||||
|
MOVE SPACES TO WS-ENV-TARGET.
|
||||||
|
|
||||||
* Parse service arguments
|
* Parse service arguments
|
||||||
ACCEPT WS-ARG2 FROM ARGUMENT-VALUE.
|
ACCEPT WS-ARG2 FROM ARGUMENT-VALUE.
|
||||||
|
|
||||||
IF WS-ARG2 = "-l" OR WS-ARG2 = "--list"
|
IF WS-ARG2 = "-l" OR WS-ARG2 = "--list"
|
||||||
PERFORM SERVICE-LIST
|
PERFORM SERVICE-LIST
|
||||||
|
ELSE IF WS-ARG2 = "env"
|
||||||
|
ACCEPT WS-ENV-ACTION FROM ARGUMENT-VALUE
|
||||||
|
ACCEPT WS-ENV-TARGET FROM ARGUMENT-VALUE
|
||||||
|
PERFORM PARSE-SERVICE-ENV-ARGS
|
||||||
|
PERFORM SERVICE-ENV
|
||||||
ELSE IF WS-ARG2 = "--info"
|
ELSE IF WS-ARG2 = "--info"
|
||||||
ACCEPT WS-ID FROM ARGUMENT-VALUE
|
ACCEPT WS-ID FROM ARGUMENT-VALUE
|
||||||
PERFORM SERVICE-INFO
|
PERFORM SERVICE-INFO
|
||||||
|
|
@ -223,7 +250,8 @@
|
||||||
PERFORM SERVICE-CREATE
|
PERFORM SERVICE-CREATE
|
||||||
ELSE
|
ELSE
|
||||||
DISPLAY "Error: Use --list, --info, --logs, "
|
DISPLAY "Error: Use --list, --info, --logs, "
|
||||||
"--freeze, --unfreeze, --destroy, --dump-bootstrap, or --name" UPON SYSERR
|
"--freeze, --unfreeze, --destroy, --dump-bootstrap, "
|
||||||
|
"--name, or env" UPON SYSERR
|
||||||
MOVE 1 TO RETURN-CODE
|
MOVE 1 TO RETURN-CODE
|
||||||
END-IF.
|
END-IF.
|
||||||
|
|
||||||
|
|
@ -558,6 +586,18 @@
|
||||||
ACCEPT WS-BOOTSTRAP FROM ARGUMENT-VALUE
|
ACCEPT WS-BOOTSTRAP FROM ARGUMENT-VALUE
|
||||||
ELSE IF WS-ARG3 = "--bootstrap-file"
|
ELSE IF WS-ARG3 = "--bootstrap-file"
|
||||||
ACCEPT WS-BOOTSTRAP-FILE FROM ARGUMENT-VALUE
|
ACCEPT WS-BOOTSTRAP-FILE FROM ARGUMENT-VALUE
|
||||||
|
ELSE IF WS-ARG3 = "-e"
|
||||||
|
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
|
||||||
|
IF WS-SVC-ENVS NOT = SPACES
|
||||||
|
STRING FUNCTION TRIM(WS-SVC-ENVS) X"0A"
|
||||||
|
FUNCTION TRIM(WS-ARG3)
|
||||||
|
DELIMITED BY SIZE INTO WS-SVC-ENVS
|
||||||
|
END-STRING
|
||||||
|
ELSE
|
||||||
|
MOVE WS-ARG3 TO WS-SVC-ENVS
|
||||||
|
END-IF
|
||||||
|
ELSE IF WS-ARG3 = "--env-file"
|
||||||
|
ACCEPT WS-SVC-ENV-FILE FROM ARGUMENT-VALUE
|
||||||
ELSE IF WS-ARG3 = "-f"
|
ELSE IF WS-ARG3 = "-f"
|
||||||
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
|
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
|
||||||
IF WS-INPUT-FILES NOT = SPACES
|
IF WS-INPUT-FILES NOT = SPACES
|
||||||
|
|
@ -572,23 +612,160 @@
|
||||||
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
|
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
|
||||||
END-PERFORM.
|
END-PERFORM.
|
||||||
|
|
||||||
|
PARSE-SERVICE-ENV-ARGS.
|
||||||
|
* Parse -e and --env-file for env set command
|
||||||
|
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE.
|
||||||
|
PERFORM UNTIL WS-ARG3 = SPACES
|
||||||
|
IF WS-ARG3 = "-e"
|
||||||
|
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
|
||||||
|
IF WS-SVC-ENVS NOT = SPACES
|
||||||
|
STRING FUNCTION TRIM(WS-SVC-ENVS) X"0A"
|
||||||
|
FUNCTION TRIM(WS-ARG3)
|
||||||
|
DELIMITED BY SIZE INTO WS-SVC-ENVS
|
||||||
|
END-STRING
|
||||||
|
ELSE
|
||||||
|
MOVE WS-ARG3 TO WS-SVC-ENVS
|
||||||
|
END-IF
|
||||||
|
ELSE IF WS-ARG3 = "--env-file"
|
||||||
|
ACCEPT WS-SVC-ENV-FILE FROM ARGUMENT-VALUE
|
||||||
|
END-IF
|
||||||
|
ACCEPT WS-ARG3 FROM ARGUMENT-VALUE
|
||||||
|
END-PERFORM.
|
||||||
|
|
||||||
|
SERVICE-ENV.
|
||||||
|
* Handle env subcommand (status/set/export/delete)
|
||||||
|
IF WS-ENV-ACTION = "status"
|
||||||
|
PERFORM SERVICE-ENV-STATUS
|
||||||
|
ELSE IF WS-ENV-ACTION = "set"
|
||||||
|
PERFORM SERVICE-ENV-SET
|
||||||
|
ELSE IF WS-ENV-ACTION = "export"
|
||||||
|
PERFORM SERVICE-ENV-EXPORT
|
||||||
|
ELSE IF WS-ENV-ACTION = "delete"
|
||||||
|
PERFORM SERVICE-ENV-DELETE
|
||||||
|
ELSE
|
||||||
|
DISPLAY "Error: Unknown env action: "
|
||||||
|
FUNCTION TRIM(WS-ENV-ACTION) UPON SYSERR
|
||||||
|
DISPLAY "Usage: un.cob service env "
|
||||||
|
"<status|set|export|delete> <service_id>" UPON SYSERR
|
||||||
|
MOVE 1 TO RETURN-CODE
|
||||||
|
END-IF.
|
||||||
|
|
||||||
|
SERVICE-ENV-STATUS.
|
||||||
|
STRING "TS=$(date +%s); "
|
||||||
|
"SIG=$(echo -n \"$TS:GET:/services/"
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"/env:\" | openssl dgst -sha256 -hmac '"
|
||||||
|
FUNCTION TRIM(WS-SECRET-KEY)
|
||||||
|
"' | cut -d' ' -f2); "
|
||||||
|
"curl -s -X GET 'https://api.unsandbox.com/services/"
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"/env' "
|
||||||
|
"-H 'Authorization: Bearer "
|
||||||
|
FUNCTION TRIM(WS-PUBLIC-KEY)
|
||||||
|
"' "
|
||||||
|
"-H 'X-Timestamp: '$TS "
|
||||||
|
"-H 'X-Signature: '$SIG | jq ."
|
||||||
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
|
END-STRING.
|
||||||
|
|
||||||
|
CALL "SYSTEM" USING WS-CURL-CMD.
|
||||||
|
|
||||||
|
SERVICE-ENV-SET.
|
||||||
|
STRING "ENV_CONTENT=''; "
|
||||||
|
"ENV_LINES='"
|
||||||
|
FUNCTION TRIM(WS-SVC-ENVS)
|
||||||
|
"'; "
|
||||||
|
"if [ -n \"$ENV_LINES\" ]; then "
|
||||||
|
"ENV_CONTENT=\"$ENV_LINES\"; fi; "
|
||||||
|
"ENV_FILE='"
|
||||||
|
FUNCTION TRIM(WS-SVC-ENV-FILE)
|
||||||
|
"'; "
|
||||||
|
"if [ -n \"$ENV_FILE\" ] && [ -f \"$ENV_FILE\" ]; then "
|
||||||
|
"while IFS= read -r line || [ -n \"$line\" ]; do "
|
||||||
|
"case \"$line\" in \"#\"*|\"\") continue ;; esac; "
|
||||||
|
"if [ -n \"$ENV_CONTENT\" ]; then "
|
||||||
|
"ENV_CONTENT=\"$ENV_CONTENT"
|
||||||
|
X"0A"
|
||||||
|
"\"; fi; "
|
||||||
|
"ENV_CONTENT=\"$ENV_CONTENT$line\"; "
|
||||||
|
"done < \"$ENV_FILE\"; fi; "
|
||||||
|
"if [ -z \"$ENV_CONTENT\" ]; then "
|
||||||
|
"echo -e '\x1b[31mError: No environment variables "
|
||||||
|
"to set\x1b[0m' >&2; exit 1; fi; "
|
||||||
|
"TS=$(date +%s); "
|
||||||
|
"SIG=$(echo -n \"$TS:PUT:/services/"
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"/env:$ENV_CONTENT\" | openssl dgst -sha256 -hmac '"
|
||||||
|
FUNCTION TRIM(WS-SECRET-KEY)
|
||||||
|
"' | cut -d' ' -f2); "
|
||||||
|
"curl -s -X PUT 'https://api.unsandbox.com/services/"
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"/env' "
|
||||||
|
"-H 'Authorization: Bearer "
|
||||||
|
FUNCTION TRIM(WS-PUBLIC-KEY)
|
||||||
|
"' "
|
||||||
|
"-H 'X-Timestamp: '$TS "
|
||||||
|
"-H 'X-Signature: '$SIG "
|
||||||
|
"-H 'Content-Type: text/plain' "
|
||||||
|
"--data-binary \"$ENV_CONTENT\" | jq ."
|
||||||
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
|
END-STRING.
|
||||||
|
|
||||||
|
CALL "SYSTEM" USING WS-CURL-CMD.
|
||||||
|
|
||||||
|
SERVICE-ENV-EXPORT.
|
||||||
|
STRING "TS=$(date +%s); "
|
||||||
|
"SIG=$(echo -n \"$TS:POST:/services/"
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"/env/export:\" | openssl dgst -sha256 -hmac '"
|
||||||
|
FUNCTION TRIM(WS-SECRET-KEY)
|
||||||
|
"' | cut -d' ' -f2); "
|
||||||
|
"curl -s -X POST 'https://api.unsandbox.com/services/"
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"/env/export' "
|
||||||
|
"-H 'Authorization: Bearer "
|
||||||
|
FUNCTION TRIM(WS-PUBLIC-KEY)
|
||||||
|
"' "
|
||||||
|
"-H 'X-Timestamp: '$TS "
|
||||||
|
"-H 'X-Signature: '$SIG | jq -r '.content // empty'"
|
||||||
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
|
END-STRING.
|
||||||
|
|
||||||
|
CALL "SYSTEM" USING WS-CURL-CMD.
|
||||||
|
|
||||||
|
SERVICE-ENV-DELETE.
|
||||||
|
STRING "TS=$(date +%s); "
|
||||||
|
"SIG=$(echo -n \"$TS:DELETE:/services/"
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"/env:\" | openssl dgst -sha256 -hmac '"
|
||||||
|
FUNCTION TRIM(WS-SECRET-KEY)
|
||||||
|
"' | cut -d' ' -f2); "
|
||||||
|
"curl -s -X DELETE 'https://api.unsandbox.com/services/"
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"/env' "
|
||||||
|
"-H 'Authorization: Bearer "
|
||||||
|
FUNCTION TRIM(WS-PUBLIC-KEY)
|
||||||
|
"' "
|
||||||
|
"-H 'X-Timestamp: '$TS "
|
||||||
|
"-H 'X-Signature: '$SIG >/dev/null && "
|
||||||
|
"echo -e '\x1b[32mVault deleted for: "
|
||||||
|
FUNCTION TRIM(WS-ENV-TARGET)
|
||||||
|
"\x1b[0m'"
|
||||||
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
|
END-STRING.
|
||||||
|
|
||||||
|
CALL "SYSTEM" USING WS-CURL-CMD.
|
||||||
|
|
||||||
SERVICE-CREATE.
|
SERVICE-CREATE.
|
||||||
* Build JSON payload for service creation
|
* Build service creation with HMAC auth and auto-vault
|
||||||
* Start with base payload containing name
|
STRING "BODY='{\"name\":\"" FUNCTION TRIM(WS-NAME) "\""
|
||||||
STRING "curl -s -X POST "
|
|
||||||
"https://api.unsandbox.com/services "
|
|
||||||
"-H 'Content-Type: application/json' "
|
|
||||||
"-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY)
|
|
||||||
"' -d '{""name"":"""
|
|
||||||
FUNCTION TRIM(WS-NAME)
|
|
||||||
""""
|
|
||||||
DELIMITED BY SIZE INTO WS-CURL-CMD
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
END-STRING.
|
END-STRING.
|
||||||
|
|
||||||
* Add ports if provided
|
* Add ports if provided
|
||||||
IF WS-PORTS NOT = SPACES
|
IF WS-PORTS NOT = SPACES
|
||||||
STRING FUNCTION TRIM(WS-CURL-CMD)
|
STRING FUNCTION TRIM(WS-CURL-CMD)
|
||||||
",""ports"":[" FUNCTION TRIM(WS-PORTS) "]"
|
",\"ports\":[" FUNCTION TRIM(WS-PORTS) "]"
|
||||||
DELIMITED BY SIZE INTO WS-CURL-CMD
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
END-STRING
|
END-STRING
|
||||||
END-IF.
|
END-IF.
|
||||||
|
|
@ -596,9 +773,7 @@
|
||||||
* Add domains if provided
|
* Add domains if provided
|
||||||
IF WS-DOMAINS NOT = SPACES
|
IF WS-DOMAINS NOT = SPACES
|
||||||
STRING FUNCTION TRIM(WS-CURL-CMD)
|
STRING FUNCTION TRIM(WS-CURL-CMD)
|
||||||
",""domains"":["""
|
",\"domains\":[\"" FUNCTION TRIM(WS-DOMAINS) "\"]"
|
||||||
FUNCTION TRIM(WS-DOMAINS)
|
|
||||||
"""]"
|
|
||||||
DELIMITED BY SIZE INTO WS-CURL-CMD
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
END-STRING
|
END-STRING
|
||||||
END-IF.
|
END-IF.
|
||||||
|
|
@ -606,9 +781,7 @@
|
||||||
* Add service_type if provided
|
* Add service_type if provided
|
||||||
IF WS-SERVICE-TYPE NOT = SPACES
|
IF WS-SERVICE-TYPE NOT = SPACES
|
||||||
STRING FUNCTION TRIM(WS-CURL-CMD)
|
STRING FUNCTION TRIM(WS-CURL-CMD)
|
||||||
",""service_type"":"""
|
",\"service_type\":\"" FUNCTION TRIM(WS-SERVICE-TYPE) "\""
|
||||||
FUNCTION TRIM(WS-SERVICE-TYPE)
|
|
||||||
""""
|
|
||||||
DELIMITED BY SIZE INTO WS-CURL-CMD
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
END-STRING
|
END-STRING
|
||||||
END-IF.
|
END-IF.
|
||||||
|
|
@ -616,53 +789,57 @@
|
||||||
* Add bootstrap if provided
|
* Add bootstrap if provided
|
||||||
IF WS-BOOTSTRAP NOT = SPACES
|
IF WS-BOOTSTRAP NOT = SPACES
|
||||||
STRING FUNCTION TRIM(WS-CURL-CMD)
|
STRING FUNCTION TRIM(WS-CURL-CMD)
|
||||||
",""bootstrap"":"""
|
",\"bootstrap\":\"" FUNCTION TRIM(WS-BOOTSTRAP) "\""
|
||||||
FUNCTION TRIM(WS-BOOTSTRAP)
|
|
||||||
""""
|
|
||||||
DELIMITED BY SIZE INTO WS-CURL-CMD
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
END-STRING
|
END-STRING
|
||||||
END-IF.
|
END-IF.
|
||||||
|
|
||||||
* Add bootstrap_content from file if provided
|
* Close JSON body
|
||||||
IF WS-BOOTSTRAP-FILE NOT = SPACES
|
STRING FUNCTION TRIM(WS-CURL-CMD) "}'; "
|
||||||
STRING FUNCTION TRIM(WS-CURL-CMD)
|
"TS=$(date +%s); "
|
||||||
"' | jq --rawfile b '"
|
"SIG=$(echo -n \"$TS:POST:/services:$BODY\" | "
|
||||||
FUNCTION TRIM(WS-BOOTSTRAP-FILE)
|
"openssl dgst -sha256 -hmac '"
|
||||||
"' '. + {bootstrap_content: $b}' | tr -d '\\n' | curl "
|
FUNCTION TRIM(WS-SECRET-KEY)
|
||||||
"-s -X POST https://api.unsandbox.com/services "
|
"' | cut -d' ' -f2); "
|
||||||
"-H 'Content-Type: application/json' "
|
"RESP=$(curl -s -X POST https://api.unsandbox.com/services "
|
||||||
"-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY)
|
"-H 'Content-Type: application/json' "
|
||||||
"' -d @- | jq -r '.id + "" created""'"
|
"-H 'Authorization: Bearer " FUNCTION TRIM(WS-PUBLIC-KEY) "' "
|
||||||
DELIMITED BY SIZE INTO WS-CURL-CMD
|
"-H 'X-Timestamp: '$TS "
|
||||||
END-STRING
|
"-H 'X-Signature: '$SIG "
|
||||||
CALL "SYSTEM" USING WS-CURL-CMD
|
"-d \"$BODY\"); "
|
||||||
EXIT PARAGRAPH
|
"SVC_ID=$(echo \"$RESP\" | jq -r '.id // empty'); "
|
||||||
END-IF.
|
"if [ -n \"$SVC_ID\" ]; then "
|
||||||
|
"echo -e '\x1b[32m'\"$SVC_ID\"' created\x1b[0m'; "
|
||||||
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
|
END-STRING.
|
||||||
|
|
||||||
* Add input_files if provided (use shell script for base64)
|
* Add auto-vault logic
|
||||||
IF WS-INPUT-FILES NOT = SPACES
|
|
||||||
STRING "INPUT_FILES=''; "
|
|
||||||
"IFS=',' read -ra FILES <<< '"
|
|
||||||
FUNCTION TRIM(WS-INPUT-FILES)
|
|
||||||
"'; "
|
|
||||||
"for f in \"${FILES[@]}\"; do "
|
|
||||||
"b64=$(base64 -w0 \"$f\" 2>/dev/null || base64 \"$f\"); "
|
|
||||||
"name=$(basename \"$f\"); "
|
|
||||||
"if [ -n \"$INPUT_FILES\" ]; then INPUT_FILES=\"$INPUT_FILES,\"; fi; "
|
|
||||||
"INPUT_FILES=\"$INPUT_FILES{\\\"filename\\\":\\\"$name\\\",\\\"content\\\":\\\"$b64\\\"}\"; "
|
|
||||||
"done; "
|
|
||||||
FUNCTION TRIM(WS-CURL-CMD)
|
|
||||||
",\"input_files\":['\"$INPUT_FILES\"']}' | "
|
|
||||||
"jq -r '.id + "" created""'"
|
|
||||||
DELIMITED BY SIZE INTO WS-CURL-CMD
|
|
||||||
END-STRING
|
|
||||||
CALL "SYSTEM" USING WS-CURL-CMD
|
|
||||||
EXIT PARAGRAPH
|
|
||||||
END-IF.
|
|
||||||
|
|
||||||
* Close JSON and add output formatting
|
|
||||||
STRING FUNCTION TRIM(WS-CURL-CMD)
|
STRING FUNCTION TRIM(WS-CURL-CMD)
|
||||||
"}' | jq -r '.id + "" created""'"
|
"ENV_CONTENT=''; "
|
||||||
|
"ENV_LINES='" FUNCTION TRIM(WS-SVC-ENVS) "'; "
|
||||||
|
"if [ -n \"$ENV_LINES\" ]; then ENV_CONTENT=\"$ENV_LINES\"; fi; "
|
||||||
|
"ENV_FILE='" FUNCTION TRIM(WS-SVC-ENV-FILE) "'; "
|
||||||
|
"if [ -n \"$ENV_FILE\" ] && [ -f \"$ENV_FILE\" ]; then "
|
||||||
|
"while IFS= read -r line || [ -n \"$line\" ]; do "
|
||||||
|
"case \"$line\" in \"#\"*|\"\") continue ;; esac; "
|
||||||
|
"if [ -n \"$ENV_CONTENT\" ]; then "
|
||||||
|
"ENV_CONTENT=\"$ENV_CONTENT" X"0A" "\"; fi; "
|
||||||
|
"ENV_CONTENT=\"$ENV_CONTENT$line\"; "
|
||||||
|
"done < \"$ENV_FILE\"; fi; "
|
||||||
|
"if [ -n \"$ENV_CONTENT\" ]; then "
|
||||||
|
"TS2=$(date +%s); "
|
||||||
|
"SIG2=$(echo -n \"$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT\" | "
|
||||||
|
"openssl dgst -sha256 -hmac '"
|
||||||
|
FUNCTION TRIM(WS-SECRET-KEY)
|
||||||
|
"' | cut -d' ' -f2); "
|
||||||
|
"curl -s -X PUT \"https://api.unsandbox.com/services/$SVC_ID/env\" "
|
||||||
|
"-H 'Authorization: Bearer " FUNCTION TRIM(WS-PUBLIC-KEY) "' "
|
||||||
|
"-H 'X-Timestamp: '$TS2 "
|
||||||
|
"-H 'X-Signature: '$SIG2 "
|
||||||
|
"-H 'Content-Type: text/plain' "
|
||||||
|
"--data-binary \"$ENV_CONTENT\" >/dev/null && "
|
||||||
|
"echo -e '\x1b[32mVault configured\x1b[0m'; fi; "
|
||||||
|
"else echo \"$RESP\" | jq .; fi"
|
||||||
DELIMITED BY SIZE INTO WS-CURL-CMD
|
DELIMITED BY SIZE INTO WS-CURL-CMD
|
||||||
END-STRING.
|
END-STRING.
|
||||||
|
|
||||||
|
|
|
||||||
178
un.cpp
178
un.cpp
|
|
@ -174,6 +174,140 @@ string build_auth_headers(const string& method, const string& path, const string
|
||||||
"-H 'X-Signature: " + signature + "'";
|
"-H 'X-Signature: " + signature + "'";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string build_env_content(const vector<string>& envs, const string& env_file) {
|
||||||
|
ostringstream parts;
|
||||||
|
if (!env_file.empty()) {
|
||||||
|
string content = read_file(env_file);
|
||||||
|
// Trim trailing whitespace
|
||||||
|
while (!content.empty() && (content.back() == '\n' || content.back() == '\r' || content.back() == ' ')) {
|
||||||
|
content.pop_back();
|
||||||
|
}
|
||||||
|
parts << content;
|
||||||
|
}
|
||||||
|
for (const auto& e : envs) {
|
||||||
|
if (e.find('=') != string::npos) {
|
||||||
|
if (parts.str().length() > 0) parts << "\n";
|
||||||
|
parts << e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
string service_env_status(const string& service_id, const string& public_key, const string& secret_key) {
|
||||||
|
string path = "/services/" + service_id + "/env";
|
||||||
|
string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key);
|
||||||
|
string cmd = "curl -s -X GET '" + API_BASE + path + "' " + auth_headers;
|
||||||
|
return exec_curl(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool service_env_set(const string& service_id, const string& env_content, const string& public_key, const string& secret_key) {
|
||||||
|
string path = "/services/" + service_id + "/env";
|
||||||
|
string timestamp = get_timestamp();
|
||||||
|
string message = timestamp + ":PUT:" + path + ":" + env_content;
|
||||||
|
string signature = compute_hmac(secret_key, message);
|
||||||
|
|
||||||
|
string cmd = "curl -s -X PUT '" + API_BASE + path + "' "
|
||||||
|
"-H 'Content-Type: text/plain' "
|
||||||
|
"-H 'Authorization: Bearer " + public_key + "' "
|
||||||
|
"-H 'X-Timestamp: " + timestamp + "' "
|
||||||
|
"-H 'X-Signature: " + signature + "' "
|
||||||
|
"-d '" + env_content + "'";
|
||||||
|
exec_curl(cmd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string service_env_export(const string& service_id, const string& public_key, const string& secret_key) {
|
||||||
|
string path = "/services/" + service_id + "/env/export";
|
||||||
|
string auth_headers = build_auth_headers("POST", path, "", public_key, secret_key);
|
||||||
|
string cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers;
|
||||||
|
return exec_curl(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool service_env_delete(const string& service_id, const string& public_key, const string& secret_key) {
|
||||||
|
string path = "/services/" + service_id + "/env";
|
||||||
|
string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key);
|
||||||
|
string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers;
|
||||||
|
exec_curl(cmd);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cmd_service_env(const string& action, const string& target, const vector<string>& envs, const string& env_file, const string& public_key, const string& secret_key) {
|
||||||
|
if (action == "status") {
|
||||||
|
if (target.empty()) {
|
||||||
|
cerr << RED << "Error: Usage: service env status <service_id>" << RESET << endl;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
string result = service_env_status(target, public_key, secret_key);
|
||||||
|
bool has_env = result.find("\"has_env\":true") != string::npos;
|
||||||
|
cout << "Service: " << target << endl;
|
||||||
|
cout << "Has Vault: " << (has_env ? "Yes" : "No") << endl;
|
||||||
|
if (has_env) {
|
||||||
|
size_t size_pos = result.find("\"size\":");
|
||||||
|
if (size_pos != string::npos) {
|
||||||
|
size_pos += 7;
|
||||||
|
size_t end = result.find_first_not_of("0123456789", size_pos);
|
||||||
|
cout << "Size: " << result.substr(size_pos, end - size_pos) << " bytes" << endl;
|
||||||
|
}
|
||||||
|
size_t updated_pos = result.find("\"updated_at\":\"");
|
||||||
|
if (updated_pos != string::npos) {
|
||||||
|
updated_pos += 14;
|
||||||
|
size_t end = result.find("\"", updated_pos);
|
||||||
|
cout << "Updated: " << result.substr(updated_pos, end - updated_pos) << endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (action == "set") {
|
||||||
|
if (target.empty()) {
|
||||||
|
cerr << RED << "Error: Usage: service env set <service_id> [-e KEY=VAL] [--env-file FILE]" << RESET << endl;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
string env_content = build_env_content(envs, env_file);
|
||||||
|
if (env_content.empty()) {
|
||||||
|
cerr << RED << "Error: No environment variables specified. Use -e KEY=VAL or --env-file FILE" << RESET << endl;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (env_content.length() > 65536) {
|
||||||
|
cerr << RED << "Error: Environment content exceeds 64KB limit" << RESET << endl;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
service_env_set(target, env_content, public_key, secret_key);
|
||||||
|
cout << GREEN << "Vault updated for service: " << target << RESET << endl;
|
||||||
|
} else if (action == "export") {
|
||||||
|
if (target.empty()) {
|
||||||
|
cerr << RED << "Error: Usage: service env export <service_id>" << RESET << endl;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
string result = service_env_export(target, public_key, secret_key);
|
||||||
|
size_t content_pos = result.find("\"content\":\"");
|
||||||
|
if (content_pos != string::npos) {
|
||||||
|
content_pos += 11;
|
||||||
|
size_t end = result.find("\"", content_pos);
|
||||||
|
while (end > 0 && result[end-1] == '\\') end = result.find("\"", end+1);
|
||||||
|
if (end != string::npos) {
|
||||||
|
string content = result.substr(content_pos, end - content_pos);
|
||||||
|
// Unescape
|
||||||
|
size_t pos = 0;
|
||||||
|
while ((pos = content.find("\\n", pos)) != string::npos) {
|
||||||
|
content.replace(pos, 2, "\n");
|
||||||
|
}
|
||||||
|
cout << content;
|
||||||
|
if (!content.empty() && content.back() != '\n') cout << endl;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cerr << YELLOW << "Vault is empty" << RESET << endl;
|
||||||
|
}
|
||||||
|
} else if (action == "delete") {
|
||||||
|
if (target.empty()) {
|
||||||
|
cerr << RED << "Error: Usage: service env delete <service_id>" << RESET << endl;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
service_env_delete(target, public_key, secret_key);
|
||||||
|
cout << GREEN << "Vault deleted for service: " << target << RESET << endl;
|
||||||
|
} else {
|
||||||
|
cerr << RED << "Error: Unknown env action: " << action << ". Use status, set, export, or delete" << RESET << endl;
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void cmd_execute(const string& source_file, const vector<string>& envs, const vector<string>& files, bool artifacts, const string& network, int vcpu, const string& public_key, const string& secret_key) {
|
void cmd_execute(const string& source_file, const vector<string>& envs, const vector<string>& files, bool artifacts, const string& network, int vcpu, const string& public_key, const string& secret_key) {
|
||||||
string lang = detect_language(source_file);
|
string lang = detect_language(source_file);
|
||||||
if (lang.empty()) {
|
if (lang.empty()) {
|
||||||
|
|
@ -305,7 +439,13 @@ void cmd_session(bool list, const string& kill, const string& shell, const strin
|
||||||
cout << exec_curl(cmd) << endl;
|
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<string>& 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 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<string>& 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<string>& 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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (list) {
|
if (list) {
|
||||||
string auth_headers = build_auth_headers("GET", "/services", "", public_key, secret_key);
|
string auth_headers = build_auth_headers("GET", "/services", "", public_key, secret_key);
|
||||||
string cmd = "curl -s -X GET '" + API_BASE + "/services' " + auth_headers;
|
string cmd = "curl -s -X GET '" + API_BASE + "/services' " + auth_headers;
|
||||||
|
|
@ -494,7 +634,26 @@ void cmd_service(const string& name, const string& ports, const string& type, co
|
||||||
"-H 'Content-Type: application/json' "
|
"-H 'Content-Type: application/json' "
|
||||||
+ auth_headers + " "
|
+ auth_headers + " "
|
||||||
"-d '" + json.str() + "'";
|
"-d '" + json.str() + "'";
|
||||||
cout << exec_curl(cmd) << endl;
|
string result = exec_curl(cmd);
|
||||||
|
cout << result << endl;
|
||||||
|
|
||||||
|
// Auto-set vault if env vars provided
|
||||||
|
if (!envs.empty() || !env_file.empty()) {
|
||||||
|
// Extract service ID from result
|
||||||
|
size_t id_pos = result.find("\"id\":\"");
|
||||||
|
if (id_pos != string::npos) {
|
||||||
|
id_pos += 6;
|
||||||
|
size_t id_end = result.find("\"", id_pos);
|
||||||
|
if (id_end != string::npos) {
|
||||||
|
string service_id = result.substr(id_pos, id_end - id_pos);
|
||||||
|
string env_content = build_env_content(envs, env_file);
|
||||||
|
if (!env_content.empty() && env_content.length() <= 65536) {
|
||||||
|
service_env_set(service_id, env_content, public_key, secret_key);
|
||||||
|
cout << GREEN << "Vault configured with environment variables" << RESET << endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,6 +792,8 @@ int main(int argc, char* argv[]) {
|
||||||
string info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network;
|
string info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network;
|
||||||
int vcpu = 0;
|
int vcpu = 0;
|
||||||
vector<string> files;
|
vector<string> files;
|
||||||
|
vector<string> envs;
|
||||||
|
string env_file, env_action, env_target;
|
||||||
|
|
||||||
for (int i = 2; i < argc; i++) {
|
for (int i = 2; i < argc; i++) {
|
||||||
string arg = argv[i];
|
string arg = argv[i];
|
||||||
|
|
@ -642,6 +803,17 @@ int main(int argc, char* argv[]) {
|
||||||
else if (arg == "--bootstrap" && i+1 < argc) bootstrap = argv[++i];
|
else if (arg == "--bootstrap" && i+1 < argc) bootstrap = argv[++i];
|
||||||
else if (arg == "--bootstrap-file" && i+1 < argc) bootstrap_file = argv[++i];
|
else if (arg == "--bootstrap-file" && i+1 < argc) bootstrap_file = argv[++i];
|
||||||
else if (arg == "-f" && i+1 < argc) files.push_back(argv[++i]);
|
else if (arg == "-f" && i+1 < argc) files.push_back(argv[++i]);
|
||||||
|
else if (arg == "-e" && i+1 < argc) envs.push_back(argv[++i]);
|
||||||
|
else if (arg == "--env-file" && i+1 < argc) env_file = argv[++i];
|
||||||
|
else if (arg == "env" && env_action.empty()) {
|
||||||
|
// service env <action> <target>
|
||||||
|
if (i+1 < argc && string(argv[i+1])[0] != '-') {
|
||||||
|
env_action = argv[++i];
|
||||||
|
if (i+1 < argc && string(argv[i+1])[0] != '-') {
|
||||||
|
env_target = argv[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (arg == "--list") list = true;
|
else if (arg == "--list") list = true;
|
||||||
else if (arg == "--info" && i+1 < argc) info = argv[++i];
|
else if (arg == "--info" && i+1 < argc) info = argv[++i];
|
||||||
else if (arg == "--logs" && i+1 < argc) logs = argv[++i];
|
else if (arg == "--logs" && i+1 < argc) logs = argv[++i];
|
||||||
|
|
@ -658,7 +830,7 @@ int main(int argc, char* argv[]) {
|
||||||
else if (arg == "-k" && i+1 < argc) public_key = argv[++i];
|
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, public_key, secret_key);
|
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);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
173
un.cr
173
un.cr
|
|
@ -69,6 +69,7 @@ RESET = "\033[0m"
|
||||||
|
|
||||||
API_BASE = "https://api.unsandbox.com"
|
API_BASE = "https://api.unsandbox.com"
|
||||||
PORTAL_BASE = "https://unsandbox.com"
|
PORTAL_BASE = "https://unsandbox.com"
|
||||||
|
MAX_ENV_CONTENT_SIZE = 65536
|
||||||
|
|
||||||
def detect_language(filename : String) : String
|
def detect_language(filename : String) : String
|
||||||
ext = File.extname(filename).downcase
|
ext = File.extname(filename).downcase
|
||||||
|
|
@ -145,6 +146,123 @@ def api_request(endpoint : String, public_key : String, secret_key : String?, me
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def api_request_text(endpoint : String, public_key : String, secret_key : String?, body : String) : Bool
|
||||||
|
url = URI.parse(API_BASE + endpoint)
|
||||||
|
headers = HTTP::Headers{
|
||||||
|
"Content-Type" => "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add HMAC authentication headers if secret_key is provided
|
||||||
|
if secret_key && !secret_key.empty?
|
||||||
|
timestamp = Time.utc.to_unix.to_s
|
||||||
|
message = "#{timestamp}:PUT:#{endpoint}:#{body}"
|
||||||
|
signature = OpenSSL::HMAC.hexdigest(:sha256, secret_key, message)
|
||||||
|
headers["Authorization"] = "Bearer #{public_key}"
|
||||||
|
headers["X-Timestamp"] = timestamp
|
||||||
|
headers["X-Signature"] = signature
|
||||||
|
else
|
||||||
|
headers["Authorization"] = "Bearer #{public_key}"
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
response = HTTP::Client.put(url, headers: headers, body: body)
|
||||||
|
return response.status_code >= 200 && response.status_code < 300
|
||||||
|
rescue
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_env_file(path : String) : String
|
||||||
|
unless File.exists?(path)
|
||||||
|
STDERR.puts "#{RED}Error: Env file not found: #{path}#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
File.read(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_env_content(envs : Array(String), env_file : String?) : String
|
||||||
|
lines = envs.dup
|
||||||
|
if env_file && !env_file.empty?
|
||||||
|
content = read_env_file(env_file)
|
||||||
|
content.split('\n').each do |line|
|
||||||
|
trimmed = line.strip
|
||||||
|
if !trimmed.empty? && !trimmed.starts_with?('#')
|
||||||
|
lines << trimmed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
lines.join('\n')
|
||||||
|
end
|
||||||
|
|
||||||
|
def cmd_service_env(args)
|
||||||
|
public_key, secret_key = get_api_keys(args[:api_key]?.as?(String))
|
||||||
|
|
||||||
|
action = args[:env_action]?.as?(String) || ""
|
||||||
|
target = args[:env_target]?.as?(String) || ""
|
||||||
|
|
||||||
|
case action
|
||||||
|
when "status"
|
||||||
|
if target.empty?
|
||||||
|
STDERR.puts "#{RED}Error: service env status requires service ID#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
result = api_request("/services/#{target}/env", public_key, secret_key)
|
||||||
|
if result["has_vault"]?.try(&.as_bool?) == true
|
||||||
|
puts "#{GREEN}Vault: configured#{RESET}"
|
||||||
|
if env_count = result["env_count"]?
|
||||||
|
puts "Variables: #{env_count}"
|
||||||
|
end
|
||||||
|
if updated_at = result["updated_at"]?.try(&.as_s?)
|
||||||
|
puts "Updated: #{updated_at}"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
puts "#{YELLOW}Vault: not configured#{RESET}"
|
||||||
|
end
|
||||||
|
when "set"
|
||||||
|
if target.empty?
|
||||||
|
STDERR.puts "#{RED}Error: service env set requires service ID#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
svc_envs = args[:svc_envs]?.as?(Array(String)) || [] of String
|
||||||
|
svc_env_file = args[:svc_env_file]?.as?(String)
|
||||||
|
if svc_envs.empty? && (svc_env_file.nil? || svc_env_file.empty?)
|
||||||
|
STDERR.puts "#{RED}Error: service env set requires -e or --env-file#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
env_content = build_env_content(svc_envs, svc_env_file)
|
||||||
|
if env_content.size > MAX_ENV_CONTENT_SIZE
|
||||||
|
STDERR.puts "#{RED}Error: Env content exceeds maximum size of 64KB#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
if api_request_text("/services/#{target}/env", public_key, secret_key, env_content)
|
||||||
|
puts "#{GREEN}Vault updated for service #{target}#{RESET}"
|
||||||
|
else
|
||||||
|
STDERR.puts "#{RED}Error: Failed to update vault#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
when "export"
|
||||||
|
if target.empty?
|
||||||
|
STDERR.puts "#{RED}Error: service env export requires service ID#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
result = api_request("/services/#{target}/env/export", public_key, secret_key, method: "POST", data: JSON.parse("{}"))
|
||||||
|
if content = result["content"]?.try(&.as_s?)
|
||||||
|
print content
|
||||||
|
end
|
||||||
|
when "delete"
|
||||||
|
if target.empty?
|
||||||
|
STDERR.puts "#{RED}Error: service env delete requires service ID#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
api_request("/services/#{target}/env", public_key, secret_key, method: "DELETE")
|
||||||
|
puts "#{GREEN}Vault deleted for service #{target}#{RESET}"
|
||||||
|
else
|
||||||
|
STDERR.puts "#{RED}Error: Unknown env action: #{action}#{RESET}"
|
||||||
|
STDERR.puts "Usage: un.cr service env <status|set|export|delete> <service_id>"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def cmd_execute(args)
|
def cmd_execute(args)
|
||||||
public_key, secret_key = get_api_keys(args[:api_key]?)
|
public_key, secret_key = get_api_keys(args[:api_key]?)
|
||||||
|
|
||||||
|
|
@ -389,6 +507,14 @@ def cmd_key(args)
|
||||||
end
|
end
|
||||||
|
|
||||||
def cmd_service(args)
|
def cmd_service(args)
|
||||||
|
# Handle env subcommand
|
||||||
|
if env_action = args[:env_action]?.as?(String)
|
||||||
|
if !env_action.empty?
|
||||||
|
cmd_service_env(args)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
public_key, secret_key = get_api_keys(args[:api_key]?)
|
public_key, secret_key = get_api_keys(args[:api_key]?)
|
||||||
|
|
||||||
if args[:list]?.as?(Bool)
|
if args[:list]?.as?(Bool)
|
||||||
|
|
@ -541,6 +667,20 @@ def cmd_service(args)
|
||||||
if url = result["url"]?.try(&.as_s?)
|
if url = result["url"]?.try(&.as_s?)
|
||||||
puts "URL: #{url}"
|
puts "URL: #{url}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Auto-set vault if -e or --env-file provided
|
||||||
|
svc_envs = args[:svc_envs]?.as?(Array(String)) || [] of String
|
||||||
|
svc_env_file = args[:svc_env_file]?.as?(String)
|
||||||
|
if !svc_envs.empty? || (svc_env_file && !svc_env_file.empty?)
|
||||||
|
if service_id = result["id"]?.try(&.as_s?)
|
||||||
|
env_content = build_env_content(svc_envs, svc_env_file)
|
||||||
|
if api_request_text("/services/#{service_id}/env", public_key, secret_key, env_content)
|
||||||
|
puts "#{GREEN}Vault configured for service #{service_id}#{RESET}"
|
||||||
|
else
|
||||||
|
STDERR.puts "#{YELLOW}Warning: Failed to set vault#{RESET}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -574,15 +714,22 @@ def main
|
||||||
service_type: nil,
|
service_type: nil,
|
||||||
bootstrap: nil,
|
bootstrap: nil,
|
||||||
bootstrap_file: nil,
|
bootstrap_file: nil,
|
||||||
extend: false
|
extend: false,
|
||||||
|
svc_envs: [] of String,
|
||||||
|
svc_env_file: nil,
|
||||||
|
env_action: nil,
|
||||||
|
env_target: nil
|
||||||
} of Symbol => (String | Array(String) | Bool | Nil)
|
} of Symbol => (String | Array(String) | Bool | Nil)
|
||||||
|
|
||||||
parser = OptionParser.new do |opts|
|
parser = OptionParser.new do |opts|
|
||||||
opts.banner = "Usage: un.cr [options] <source_file>\n un.cr session [options]\n un.cr service [options]\n un.cr key [options]"
|
opts.banner = "Usage: un.cr [options] <source_file>\n un.cr session [options]\n un.cr service [options]\n un.cr service env <action> <service_id> [options]\n un.cr key [options]\n\nService env commands:\n env status <id> Show vault status\n env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n env export <id> Export vault contents\n env delete <id> Delete vault"
|
||||||
|
|
||||||
opts.on("-k API_KEY", "--api-key=API_KEY", "API key") { |k| args[:api_key] = k }
|
opts.on("-k API_KEY", "--api-key=API_KEY", "API key") { |k| args[:api_key] = k }
|
||||||
opts.on("-n NETWORK", "--network=NETWORK", "Network mode") { |n| args[:network] = n }
|
opts.on("-n NETWORK", "--network=NETWORK", "Network mode") { |n| args[:network] = n }
|
||||||
opts.on("-e ENV", "--env=ENV", "Environment variable (KEY=VALUE)") { |e| args[:env].as(Array(String)) << e }
|
opts.on("-e ENV", "--env=ENV", "Environment variable (KEY=VALUE)") { |e|
|
||||||
|
args[:env].as(Array(String)) << e
|
||||||
|
args[:svc_envs].as(Array(String)) << e
|
||||||
|
}
|
||||||
opts.on("-f FILE", "--files=FILE", "Input file") { |f| args[:files].as(Array(String)) << f }
|
opts.on("-f FILE", "--files=FILE", "Input file") { |f| args[:files].as(Array(String)) << f }
|
||||||
opts.on("-a", "--artifacts", "Return artifacts") { args[:artifacts] = true }
|
opts.on("-a", "--artifacts", "Return artifacts") { args[:artifacts] = true }
|
||||||
opts.on("-o DIR", "--output-dir=DIR", "Output directory") { |d| args[:output_dir] = d }
|
opts.on("-o DIR", "--output-dir=DIR", "Output directory") { |d| args[:output_dir] = d }
|
||||||
|
|
@ -603,6 +750,7 @@ def main
|
||||||
opts.on("--type=TYPE", "Service type for SRV records") { |t| args[:service_type] = t }
|
opts.on("--type=TYPE", "Service type for SRV records") { |t| args[:service_type] = t }
|
||||||
opts.on("--bootstrap=CMD", "Bootstrap command or URI") { |b| args[:bootstrap] = b }
|
opts.on("--bootstrap=CMD", "Bootstrap command or URI") { |b| args[:bootstrap] = b }
|
||||||
opts.on("--bootstrap-file=FILE", "Upload local file as bootstrap script") { |f| args[:bootstrap_file] = f }
|
opts.on("--bootstrap-file=FILE", "Upload local file as bootstrap script") { |f| args[:bootstrap_file] = f }
|
||||||
|
opts.on("--env-file=FILE", "Load env vars from file (for vault)") { |f| args[:svc_env_file] = f }
|
||||||
opts.on("--extend", "Open browser to extend/renew key") { args[:extend] = true }
|
opts.on("--extend", "Open browser to extend/renew key") { args[:extend] = true }
|
||||||
|
|
||||||
opts.unknown_args do |before, after|
|
opts.unknown_args do |before, after|
|
||||||
|
|
@ -612,6 +760,25 @@ def main
|
||||||
args[:command] = "session"
|
args[:command] = "session"
|
||||||
when "service"
|
when "service"
|
||||||
args[:command] = "service"
|
args[:command] = "service"
|
||||||
|
# Check for env subcommand
|
||||||
|
if before.size > 1 && before[1] == "env"
|
||||||
|
if before.size > 2
|
||||||
|
args[:env_action] = before[2]
|
||||||
|
end
|
||||||
|
if before.size > 3 && !before[3].starts_with?("-")
|
||||||
|
args[:env_target] = before[3]
|
||||||
|
end
|
||||||
|
# Parse remaining args for -e
|
||||||
|
i = 4
|
||||||
|
while i < before.size
|
||||||
|
if before[i] == "-e" && i + 1 < before.size
|
||||||
|
args[:svc_envs].as(Array(String)) << before[i + 1]
|
||||||
|
i += 2
|
||||||
|
else
|
||||||
|
i += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
when "key"
|
when "key"
|
||||||
args[:command] = "key"
|
args[:command] = "key"
|
||||||
else
|
else
|
||||||
|
|
|
||||||
198
un.d
198
un.d
|
|
@ -60,6 +60,7 @@ immutable string RED = "\033[31m";
|
||||||
immutable string GREEN = "\033[32m";
|
immutable string GREEN = "\033[32m";
|
||||||
immutable string YELLOW = "\033[33m";
|
immutable string YELLOW = "\033[33m";
|
||||||
immutable string RESET = "\033[0m";
|
immutable string RESET = "\033[0m";
|
||||||
|
immutable size_t MAX_ENV_CONTENT_SIZE = 65536;
|
||||||
|
|
||||||
string detectLanguage(string filename) {
|
string detectLanguage(string filename) {
|
||||||
string[string] langMap = [
|
string[string] langMap = [
|
||||||
|
|
@ -164,6 +165,151 @@ string execCurl(string cmd) {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool execCurlPut(string endpoint, string body, string publicKey, string secretKey) {
|
||||||
|
import std.file : write, remove;
|
||||||
|
import std.random : uniform;
|
||||||
|
string tmpFile = format("/tmp/un_d_%d.txt", uniform(0, 999999));
|
||||||
|
write(tmpFile, body);
|
||||||
|
string authHeaders = buildAuthHeaders("PUT", endpoint, body, publicKey, secretKey);
|
||||||
|
string cmd = format(`curl -s -o /dev/null -w '%%{http_code}' -X PUT '%s%s' -H 'Content-Type: text/plain' %s -d @%s`, API_BASE, endpoint, authHeaders, tmpFile);
|
||||||
|
auto result = executeShell(cmd);
|
||||||
|
remove(tmpFile);
|
||||||
|
try {
|
||||||
|
int status = to!int(result.output.strip());
|
||||||
|
return status >= 200 && status < 300;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string readEnvFile(string path) {
|
||||||
|
if (!exists(path)) {
|
||||||
|
stderr.writefln("%sError: Env file not found: %s%s", RED, path, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
return readText(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
string buildEnvContent(string[] envs, string envFile) {
|
||||||
|
string[] lines = envs.dup;
|
||||||
|
if (!envFile.empty) {
|
||||||
|
string content = readEnvFile(envFile);
|
||||||
|
foreach (line; content.split("\n")) {
|
||||||
|
string trimmed = line.strip();
|
||||||
|
if (!trimmed.empty && !trimmed.startsWith("#")) {
|
||||||
|
lines ~= trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
import std.array : join;
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
string extractJsonField(string response, string field) {
|
||||||
|
import std.algorithm : findSplitAfter;
|
||||||
|
auto search = response.findSplitAfter(format(`"%s":"`, field));
|
||||||
|
if (search[0].length > 0 && search[1].length > 0) {
|
||||||
|
auto endSearch = search[1].findSplitAfter(`"`);
|
||||||
|
if (endSearch[0].length > 1) {
|
||||||
|
return endSearch[0][0..$-1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void cmdServiceEnv(string action, string target, string[] svcEnvs, string svcEnvFile, string publicKey, string secretKey) {
|
||||||
|
if (action == "status") {
|
||||||
|
if (target.empty) {
|
||||||
|
stderr.writefln("%sError: service env status requires service ID%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
string path = format("/services/%s/env", target);
|
||||||
|
string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey);
|
||||||
|
string cmd = format(`curl -s -X GET '%s/services/%s/env' %s`, API_BASE, target, authHeaders);
|
||||||
|
string response = execCurl(cmd);
|
||||||
|
|
||||||
|
import std.algorithm : canFind;
|
||||||
|
if (response.canFind(`"has_vault":true`)) {
|
||||||
|
writefln("%sVault: configured%s", GREEN, RESET);
|
||||||
|
string envCount = extractJsonField(response, "env_count");
|
||||||
|
if (!envCount.empty) writefln("Variables: %s", envCount);
|
||||||
|
string updatedAt = extractJsonField(response, "updated_at");
|
||||||
|
if (!updatedAt.empty) writefln("Updated: %s", updatedAt);
|
||||||
|
} else {
|
||||||
|
writefln("%sVault: not configured%s", YELLOW, RESET);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "set") {
|
||||||
|
if (target.empty) {
|
||||||
|
stderr.writefln("%sError: service env set requires service ID%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (svcEnvs.length == 0 && svcEnvFile.empty) {
|
||||||
|
stderr.writefln("%sError: service env set requires -e or --env-file%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
string envContent = buildEnvContent(svcEnvs, svcEnvFile);
|
||||||
|
if (envContent.length > MAX_ENV_CONTENT_SIZE) {
|
||||||
|
stderr.writefln("%sError: Env content exceeds maximum size of 64KB%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (execCurlPut(format("/services/%s/env", target), envContent, publicKey, secretKey)) {
|
||||||
|
writefln("%sVault updated for service %s%s", GREEN, target, RESET);
|
||||||
|
} else {
|
||||||
|
stderr.writefln("%sError: Failed to update vault%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "export") {
|
||||||
|
if (target.empty) {
|
||||||
|
stderr.writefln("%sError: service env export requires service ID%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
string path = format("/services/%s/env/export", target);
|
||||||
|
string authHeaders = buildAuthHeaders("POST", path, "{}", publicKey, secretKey);
|
||||||
|
string cmd = format(`curl -s -X POST '%s/services/%s/env/export' -H 'Content-Type: application/json' %s -d '{}'`, API_BASE, target, authHeaders);
|
||||||
|
string response = execCurl(cmd);
|
||||||
|
string content = extractJsonField(response, "content");
|
||||||
|
if (!content.empty) {
|
||||||
|
content = content.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\\"", "\"").replace("\\\\", "\\");
|
||||||
|
write(content);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "delete") {
|
||||||
|
if (target.empty) {
|
||||||
|
stderr.writefln("%sError: service env delete requires service ID%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
string path = format("/services/%s/env", target);
|
||||||
|
string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey);
|
||||||
|
string cmd = format(`curl -s -o /dev/null -w '%%{http_code}' -X DELETE '%s/services/%s/env' %s`, API_BASE, target, authHeaders);
|
||||||
|
auto result = executeShell(cmd);
|
||||||
|
try {
|
||||||
|
int status = to!int(result.output.strip());
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
writefln("%sVault deleted for service %s%s", GREEN, target, RESET);
|
||||||
|
} else {
|
||||||
|
stderr.writefln("%sError: Failed to delete vault%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
stderr.writefln("%sError: Failed to delete vault%s", RED, RESET);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr.writefln("%sError: Unknown env action: %s%s", RED, action, RESET);
|
||||||
|
stderr.writeln("Usage: un.d service env <status|set|export|delete> <service_id>");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network, int vcpu, string publicKey, string secretKey) {
|
void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network, int vcpu, string publicKey, string secretKey) {
|
||||||
string lang = detectLanguage(sourceFile);
|
string lang = detectLanguage(sourceFile);
|
||||||
if (lang.empty) {
|
if (lang.empty) {
|
||||||
|
|
@ -229,7 +375,13 @@ void cmdSession(bool list, string kill, string shell, string network, int vcpu,
|
||||||
writeln(execCurl(cmd));
|
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 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 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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (list) {
|
if (list) {
|
||||||
string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey);
|
string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey);
|
||||||
string cmd = format(`curl -s -X GET '%s/services' %s`, API_BASE, authHeaders);
|
string cmd = format(`curl -s -X GET '%s/services' %s`, API_BASE, authHeaders);
|
||||||
|
|
@ -385,7 +537,22 @@ void cmdService(string name, string ports, string bootstrap, string bootstrapFil
|
||||||
writefln("%sCreating service...%s", YELLOW, RESET);
|
writefln("%sCreating service...%s", YELLOW, RESET);
|
||||||
string authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey);
|
string authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey);
|
||||||
string cmd = format(`curl -s -X POST '%s/services' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, json);
|
string cmd = format(`curl -s -X POST '%s/services' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, json);
|
||||||
writeln(execCurl(cmd));
|
string response = execCurl(cmd);
|
||||||
|
writeln(response);
|
||||||
|
|
||||||
|
// Auto-set vault if -e or --env-file provided
|
||||||
|
if (svcEnvs.length > 0 || !svcEnvFile.empty) {
|
||||||
|
string serviceId = extractJsonField(response, "service_id");
|
||||||
|
if (serviceId.empty) serviceId = extractJsonField(response, "id");
|
||||||
|
if (!serviceId.empty) {
|
||||||
|
string envContent = buildEnvContent(svcEnvs, svcEnvFile);
|
||||||
|
if (execCurlPut(format("/services/%s/env", serviceId), envContent, publicKey, secretKey)) {
|
||||||
|
writefln("%sVault configured for service %s%s", GREEN, serviceId, RESET);
|
||||||
|
} else {
|
||||||
|
stderr.writefln("%sWarning: Failed to set vault%s", YELLOW, RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,7 +694,14 @@ int main(string[] args) {
|
||||||
stderr.writefln("Usage: %s [options] <source_file>", args[0]);
|
stderr.writefln("Usage: %s [options] <source_file>", args[0]);
|
||||||
stderr.writefln(" %s session [options]", args[0]);
|
stderr.writefln(" %s session [options]", args[0]);
|
||||||
stderr.writefln(" %s service [options]", args[0]);
|
stderr.writefln(" %s service [options]", args[0]);
|
||||||
|
stderr.writefln(" %s service env <action> <service_id> [options]", args[0]);
|
||||||
stderr.writefln(" %s key [options]", args[0]);
|
stderr.writefln(" %s key [options]", args[0]);
|
||||||
|
stderr.writeln("");
|
||||||
|
stderr.writeln("Service env commands:");
|
||||||
|
stderr.writeln(" env status <id> Show vault status");
|
||||||
|
stderr.writeln(" env set <id> Set vault (-e KEY=VALUE or --env-file FILE)");
|
||||||
|
stderr.writeln(" env export <id> Export vault contents");
|
||||||
|
stderr.writeln(" env delete <id> Delete vault");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,6 +734,22 @@ int main(string[] args) {
|
||||||
string info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network;
|
string info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network;
|
||||||
int vcpu = 0;
|
int vcpu = 0;
|
||||||
string[] inputFiles;
|
string[] inputFiles;
|
||||||
|
string[] svcEnvs;
|
||||||
|
string svcEnvFile;
|
||||||
|
string envAction, envTarget;
|
||||||
|
|
||||||
|
// Check for env subcommand
|
||||||
|
if (args.length > 2 && args[2] == "env") {
|
||||||
|
if (args.length > 3) envAction = args[3];
|
||||||
|
if (args.length > 4 && !args[4].startsWith("-")) envTarget = args[4];
|
||||||
|
for (size_t i = 5; i < args.length; i++) {
|
||||||
|
if (args[i] == "-e" && i+1 < args.length) svcEnvs ~= args[++i];
|
||||||
|
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);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
for (size_t i = 2; i < args.length; i++) {
|
for (size_t i = 2; i < args.length; i++) {
|
||||||
if (args[i] == "--name" && i+1 < args.length) name = args[++i];
|
if (args[i] == "--name" && i+1 < args.length) name = args[++i];
|
||||||
|
|
@ -581,10 +771,12 @@ int main(string[] args) {
|
||||||
else if (args[i] == "-n" && i+1 < args.length) network = args[++i];
|
else if (args[i] == "-n" && i+1 < args.length) network = args[++i];
|
||||||
else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]);
|
else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]);
|
||||||
else if (args[i] == "-f" && i+1 < args.length) inputFiles ~= args[++i];
|
else if (args[i] == "-f" && i+1 < args.length) inputFiles ~= args[++i];
|
||||||
|
else if (args[i] == "-e" && i+1 < args.length) svcEnvs ~= args[++i];
|
||||||
|
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];
|
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, publicKey, secretKey);
|
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);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
221
un.dart
221
un.dart
|
|
@ -98,6 +98,9 @@ class Args {
|
||||||
String? serviceDumpBootstrap;
|
String? serviceDumpBootstrap;
|
||||||
String? serviceDumpFile;
|
String? serviceDumpFile;
|
||||||
bool keyExtend = false;
|
bool keyExtend = false;
|
||||||
|
String? envFile;
|
||||||
|
String? envAction;
|
||||||
|
String? envTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String?> getApiKeys(String? argsKey) {
|
List<String?> getApiKeys(String? argsKey) {
|
||||||
|
|
@ -192,6 +195,180 @@ Future<Map<String, dynamic>> apiRequestCurl(String endpoint, String method, Stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> apiRequestTextCurl(String endpoint, String method, String body, String publicKey, String? secretKey) async {
|
||||||
|
final tempFile = await File('${Directory.systemTemp.path}/un_request_${DateTime.now().millisecondsSinceEpoch}.txt').create();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tempFile.writeAsString(body);
|
||||||
|
|
||||||
|
final args = ['curl', '-s', '-X', method, '$apiBase$endpoint',
|
||||||
|
'-H', 'Content-Type: text/plain'];
|
||||||
|
|
||||||
|
if (secretKey != null && secretKey.isNotEmpty) {
|
||||||
|
final timestamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
|
||||||
|
final message = '$timestamp:$method:$endpoint:$body';
|
||||||
|
|
||||||
|
final key = utf8.encode(secretKey);
|
||||||
|
final bytes = utf8.encode(message);
|
||||||
|
final hmacSha256 = Hmac(sha256, key);
|
||||||
|
final digest = hmacSha256.convert(bytes);
|
||||||
|
final signature = digest.toString();
|
||||||
|
|
||||||
|
args.addAll(['-H', 'Authorization: Bearer $publicKey']);
|
||||||
|
args.addAll(['-H', 'X-Timestamp: $timestamp']);
|
||||||
|
args.addAll(['-H', 'X-Signature: $signature']);
|
||||||
|
} else {
|
||||||
|
args.addAll(['-H', 'Authorization: Bearer $publicKey']);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.addAll(['-d', '@${tempFile.path}', '-w', '%{http_code}']);
|
||||||
|
|
||||||
|
final result = await Process.run(args[0], args.sublist(1));
|
||||||
|
final output = result.stdout as String;
|
||||||
|
|
||||||
|
// Last 3 characters are the status code
|
||||||
|
if (output.length >= 3) {
|
||||||
|
final statusCode = int.tryParse(output.substring(output.length - 3)) ?? 0;
|
||||||
|
final responseBody = output.substring(0, output.length - 3);
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
if (responseBody.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(responseBody) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return {'success': true};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {'success': true};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int maxEnvContentSize = 65536;
|
||||||
|
|
||||||
|
Future<String> readEnvFile(String path) async {
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) {
|
||||||
|
stderr.writeln('${red}Error: Env file not found: $path$reset');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
return await file.readAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> buildEnvContent(List<String> envs, String? envFile) async {
|
||||||
|
final lines = <String>[];
|
||||||
|
lines.addAll(envs);
|
||||||
|
if (envFile != null) {
|
||||||
|
final content = await readEnvFile(envFile);
|
||||||
|
for (final line in content.split('\n')) {
|
||||||
|
final trimmed = line.trim();
|
||||||
|
if (trimmed.isNotEmpty && !trimmed.startsWith('#')) {
|
||||||
|
lines.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> serviceEnvStatus(String serviceId, String publicKey, String? secretKey) async {
|
||||||
|
return await apiRequestCurl('/services/$serviceId/env', 'GET', null, publicKey, secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> serviceEnvSet(String serviceId, String envContent, String publicKey, String? secretKey) async {
|
||||||
|
if (envContent.length > maxEnvContentSize) {
|
||||||
|
stderr.writeln('${red}Error: Env content exceeds maximum size of 64KB$reset');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final result = await apiRequestTextCurl('/services/$serviceId/env', 'PUT', envContent, publicKey, secretKey);
|
||||||
|
return result != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> serviceEnvExport(String serviceId, String publicKey, String? secretKey) async {
|
||||||
|
return await apiRequestCurl('/services/$serviceId/env/export', 'POST', '{}', publicKey, secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> serviceEnvDelete(String serviceId, String publicKey, String? secretKey) async {
|
||||||
|
try {
|
||||||
|
await apiRequestCurl('/services/$serviceId/env', 'DELETE', null, publicKey, secretKey);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cmdServiceEnv(Args args) async {
|
||||||
|
final keys = getApiKeys(args.apiKey);
|
||||||
|
final publicKey = keys[0]!;
|
||||||
|
final secretKey = keys[1];
|
||||||
|
final action = args.envAction;
|
||||||
|
final target = args.envTarget;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'status':
|
||||||
|
if (target == null) {
|
||||||
|
stderr.writeln('${red}Error: service env status requires service ID$reset');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
final result = await serviceEnvStatus(target, publicKey, secretKey);
|
||||||
|
final hasVault = result['has_vault'] as bool? ?? false;
|
||||||
|
if (hasVault) {
|
||||||
|
print('${green}Vault: configured$reset');
|
||||||
|
final envCount = result['env_count'];
|
||||||
|
if (envCount != null) print('Variables: $envCount');
|
||||||
|
final updatedAt = result['updated_at'];
|
||||||
|
if (updatedAt != null) print('Updated: $updatedAt');
|
||||||
|
} else {
|
||||||
|
print('${yellow}Vault: not configured$reset');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'set':
|
||||||
|
if (target == null) {
|
||||||
|
stderr.writeln('${red}Error: service env set requires service ID$reset');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (args.env.isEmpty && args.envFile == null) {
|
||||||
|
stderr.writeln('${red}Error: service env set requires -e or --env-file$reset');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
final envContent = await buildEnvContent(args.env, args.envFile);
|
||||||
|
if (await serviceEnvSet(target, envContent, publicKey, secretKey)) {
|
||||||
|
print('${green}Vault updated for service $target$reset');
|
||||||
|
} else {
|
||||||
|
stderr.writeln('${red}Error: Failed to update vault$reset');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
if (target == null) {
|
||||||
|
stderr.writeln('${red}Error: service env export requires service ID$reset');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
final result = await serviceEnvExport(target, publicKey, secretKey);
|
||||||
|
final content = result['content'] as String?;
|
||||||
|
if (content != null) stdout.write(content);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
if (target == null) {
|
||||||
|
stderr.writeln('${red}Error: service env delete requires service ID$reset');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (await serviceEnvDelete(target, publicKey, secretKey)) {
|
||||||
|
print('${green}Vault deleted for service $target$reset');
|
||||||
|
} else {
|
||||||
|
stderr.writeln('${red}Error: Failed to delete vault$reset');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
stderr.writeln('${red}Error: Unknown env action: $action$reset');
|
||||||
|
stderr.writeln('Usage: dart un.dart service env <status|set|export|delete> <service_id>');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> cmdExecute(Args args) async {
|
Future<void> cmdExecute(Args args) async {
|
||||||
final keys = getApiKeys(args.apiKey);
|
final keys = getApiKeys(args.apiKey);
|
||||||
final publicKey = keys[0]!;
|
final publicKey = keys[0]!;
|
||||||
|
|
@ -335,6 +512,12 @@ Future<void> cmdService(Args args) async {
|
||||||
final publicKey = keys[0]!;
|
final publicKey = keys[0]!;
|
||||||
final secretKey = keys[1];
|
final secretKey = keys[1];
|
||||||
|
|
||||||
|
// Handle env subcommand
|
||||||
|
if (args.envAction != null) {
|
||||||
|
await cmdServiceEnv(args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (args.serviceList) {
|
if (args.serviceList) {
|
||||||
final result = await apiRequestCurl('/services', 'GET', null, publicKey, secretKey);
|
final result = await apiRequestCurl('/services', 'GET', null, publicKey, secretKey);
|
||||||
final services = result['services'] as List? ?? [];
|
final services = result['services'] as List? ?? [];
|
||||||
|
|
@ -480,11 +663,24 @@ Future<void> cmdService(Args args) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await apiRequestCurl('/services', 'POST', jsonEncode(payload), publicKey, secretKey);
|
final result = await apiRequestCurl('/services', 'POST', jsonEncode(payload), publicKey, secretKey);
|
||||||
print('${green}Service created: ${result['id'] ?? 'N/A'}$reset');
|
final serviceId = result['id'] as String?;
|
||||||
|
print('${green}Service created: ${serviceId ?? 'N/A'}$reset');
|
||||||
print('Name: ${result['name'] ?? 'N/A'}');
|
print('Name: ${result['name'] ?? 'N/A'}');
|
||||||
if (result.containsKey('url')) {
|
if (result.containsKey('url')) {
|
||||||
print('URL: ${result['url']}');
|
print('URL: ${result['url']}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-set vault if env vars were provided
|
||||||
|
if (serviceId != null && (args.env.isNotEmpty || args.envFile != null)) {
|
||||||
|
final envContent = await buildEnvContent(args.env, args.envFile);
|
||||||
|
if (envContent.isNotEmpty) {
|
||||||
|
if (await serviceEnvSet(serviceId, envContent, publicKey, secretKey)) {
|
||||||
|
print('${green}Vault configured with environment variables$reset');
|
||||||
|
} else {
|
||||||
|
print('${yellow}Warning: Failed to set vault$reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,6 +851,17 @@ Args parseArgs(List<String> argv) {
|
||||||
case '--extend':
|
case '--extend':
|
||||||
args.keyExtend = true;
|
args.keyExtend = true;
|
||||||
break;
|
break;
|
||||||
|
case '--env-file':
|
||||||
|
args.envFile = argv[++i];
|
||||||
|
break;
|
||||||
|
case 'env':
|
||||||
|
if (args.command == 'service' && i + 1 < argv.length) {
|
||||||
|
args.envAction = argv[++i];
|
||||||
|
if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
|
||||||
|
args.envTarget = argv[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
if (argv[i].startsWith('-')) {
|
if (argv[i].startsWith('-')) {
|
||||||
stderr.writeln('${RED}Unknown option: ${argv[i]}${RESET}');
|
stderr.writeln('${RED}Unknown option: ${argv[i]}${RESET}');
|
||||||
|
|
@ -695,17 +902,25 @@ Service options:
|
||||||
--ports PORTS Comma-separated ports
|
--ports PORTS Comma-separated ports
|
||||||
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
|
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
|
||||||
--bootstrap CMD Bootstrap command
|
--bootstrap CMD Bootstrap command
|
||||||
|
-e KEY=VALUE Environment variable for vault
|
||||||
|
--env-file FILE Load vault variables from file
|
||||||
--info ID Get service details
|
--info ID Get service details
|
||||||
--logs ID Get all logs
|
--logs ID Get all logs
|
||||||
--tail ID Get last 9000 lines
|
--tail ID Get last 9000 lines
|
||||||
--freeze ID Freeze service
|
--freeze ID Freeze service
|
||||||
--unfreeze ID Unfreeze service
|
--unfreeze ID Unfreeze service
|
||||||
--destroy ID Destroy service
|
--destroy ID Destroy service
|
||||||
--execute ID Execute command in service
|
--execute ID Execute command in service
|
||||||
--command CMD Command to execute (with --execute)
|
--command CMD Command to execute (with --execute)
|
||||||
--dump-bootstrap ID Dump bootstrap script
|
--dump-bootstrap ID Dump bootstrap script
|
||||||
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
|
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
|
||||||
|
|
||||||
|
Service env commands:
|
||||||
|
env status ID Show vault status
|
||||||
|
env set ID Set vault (-e KEY=VALUE or --env-file FILE)
|
||||||
|
env export ID Export vault contents
|
||||||
|
env delete ID Delete vault
|
||||||
|
|
||||||
Key options:
|
Key options:
|
||||||
--extend Open browser to extend key
|
--extend Open browser to extend key
|
||||||
''');
|
''');
|
||||||
|
|
|
||||||
111
un.erl
111
un.erl
|
|
@ -277,6 +277,32 @@ service_command(["--restore", SnapshotId | _Rest]) ->
|
||||||
io:format("\033[32mService restored from snapshot\033[0m~n"),
|
io:format("\033[32mService restored from snapshot\033[0m~n"),
|
||||||
io:format("~s~n", [Response]);
|
io:format("~s~n", [Response]);
|
||||||
|
|
||||||
|
%% Service env vault subcommand: service env <action> <id> [options]
|
||||||
|
service_command(["env", "status", ServiceId | _]) ->
|
||||||
|
service_env_status(ServiceId);
|
||||||
|
|
||||||
|
service_command(["env", "set", ServiceId | Rest]) ->
|
||||||
|
EnvVars = get_env_vars(Rest),
|
||||||
|
EnvFile = get_env_file(Rest),
|
||||||
|
Content = build_env_content(EnvVars, EnvFile),
|
||||||
|
case Content of
|
||||||
|
"" ->
|
||||||
|
io:format(standard_error, "Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE~n", []),
|
||||||
|
halt(1);
|
||||||
|
_ ->
|
||||||
|
service_env_set(ServiceId, Content)
|
||||||
|
end;
|
||||||
|
|
||||||
|
service_command(["env", "export", ServiceId | _]) ->
|
||||||
|
service_env_export(ServiceId);
|
||||||
|
|
||||||
|
service_command(["env", "delete", ServiceId | _]) ->
|
||||||
|
service_env_delete(ServiceId);
|
||||||
|
|
||||||
|
service_command(["env" | _]) ->
|
||||||
|
io:format(standard_error, "Usage: un.erl service env <status|set|export|delete> <service_id> [options]~n", []),
|
||||||
|
halt(1);
|
||||||
|
|
||||||
service_command(Args) ->
|
service_command(Args) ->
|
||||||
case get_service_name(Args) of
|
case get_service_name(Args) of
|
||||||
undefined ->
|
undefined ->
|
||||||
|
|
@ -289,6 +315,8 @@ service_command(Args) ->
|
||||||
BootstrapFile = get_service_bootstrap_file(Args),
|
BootstrapFile = get_service_bootstrap_file(Args),
|
||||||
Type = get_service_type(Args),
|
Type = get_service_type(Args),
|
||||||
InputFiles = get_input_files(Args),
|
InputFiles = get_input_files(Args),
|
||||||
|
EnvVars = get_env_vars(Args),
|
||||||
|
EnvFile = get_env_file(Args),
|
||||||
PortsJson = case Ports of
|
PortsJson = case Ports of
|
||||||
undefined -> "";
|
undefined -> "";
|
||||||
P -> ",\"ports\":[" ++ P ++ "]"
|
P -> ",\"ports\":[" ++ P ++ "]"
|
||||||
|
|
@ -319,7 +347,20 @@ service_command(Args) ->
|
||||||
Response = curl_post(ApiKey, "/services", TmpFile),
|
Response = curl_post(ApiKey, "/services", TmpFile),
|
||||||
file:delete(TmpFile),
|
file:delete(TmpFile),
|
||||||
io:format("\033[32mService created\033[0m~n"),
|
io:format("\033[32mService created\033[0m~n"),
|
||||||
io:format("~s~n", [Response])
|
io:format("~s~n", [Response]),
|
||||||
|
%% Auto-vault: set env vars if provided
|
||||||
|
EnvContent = build_env_content(EnvVars, EnvFile),
|
||||||
|
case EnvContent of
|
||||||
|
"" -> ok;
|
||||||
|
_ ->
|
||||||
|
ServiceId = extract_json_field(Response, "id"),
|
||||||
|
case ServiceId of
|
||||||
|
"" -> ok;
|
||||||
|
_ ->
|
||||||
|
io:format("Setting vault for ~s...~n", [ServiceId]),
|
||||||
|
service_env_set(ServiceId, EnvContent)
|
||||||
|
end
|
||||||
|
end
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%% Snapshot command
|
%% Snapshot command
|
||||||
|
|
@ -647,6 +688,64 @@ curl_delete(ApiKey, Endpoint) ->
|
||||||
check_clock_drift_error(Result),
|
check_clock_drift_error(Result),
|
||||||
Result.
|
Result.
|
||||||
|
|
||||||
|
curl_put_text(Endpoint, Content) ->
|
||||||
|
{PublicKey, SecretKey} = get_api_keys(),
|
||||||
|
TmpFile = write_temp_file(Content),
|
||||||
|
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PUT", Endpoint, Content),
|
||||||
|
Cmd = "curl -s -X PUT https://api.unsandbox.com" ++ Endpoint ++
|
||||||
|
" -H 'Content-Type: text/plain'" ++
|
||||||
|
AuthHeaders ++
|
||||||
|
" --data-binary @" ++ TmpFile,
|
||||||
|
Result = os:cmd(Cmd),
|
||||||
|
file:delete(TmpFile),
|
||||||
|
check_clock_drift_error(Result),
|
||||||
|
Result.
|
||||||
|
|
||||||
|
build_env_content(EnvVars, EnvFile) ->
|
||||||
|
% Build env content from list of env vars and env file
|
||||||
|
VarLines = EnvVars,
|
||||||
|
FileLines = case EnvFile of
|
||||||
|
undefined -> [];
|
||||||
|
"" -> [];
|
||||||
|
_ ->
|
||||||
|
case file:read_file(EnvFile) of
|
||||||
|
{ok, Bin} ->
|
||||||
|
Lines = string:split(binary_to_list(Bin), "\n", all),
|
||||||
|
[L || L <- Lines,
|
||||||
|
length(string:trim(L)) > 0,
|
||||||
|
not lists:prefix("#", string:trim(L))];
|
||||||
|
{error, _} -> []
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
string:join(VarLines ++ FileLines, "\n").
|
||||||
|
|
||||||
|
service_env_status(ServiceId) ->
|
||||||
|
ApiKey = get_api_key(),
|
||||||
|
Endpoint = "/services/" ++ ServiceId ++ "/env",
|
||||||
|
Response = curl_get(ApiKey, Endpoint),
|
||||||
|
io:format("~s~n", [Response]).
|
||||||
|
|
||||||
|
service_env_set(ServiceId, Content) ->
|
||||||
|
Endpoint = "/services/" ++ ServiceId ++ "/env",
|
||||||
|
Response = curl_put_text(Endpoint, Content),
|
||||||
|
io:format("~s~n", [Response]).
|
||||||
|
|
||||||
|
service_env_export(ServiceId) ->
|
||||||
|
ApiKey = get_api_key(),
|
||||||
|
Endpoint = "/services/" ++ ServiceId ++ "/env/export",
|
||||||
|
TmpFile = write_temp_file("{}"),
|
||||||
|
Response = curl_post(ApiKey, Endpoint, TmpFile),
|
||||||
|
file:delete(TmpFile),
|
||||||
|
case extract_json_field(Response, "content") of
|
||||||
|
"" -> io:format("~s~n", [Response]);
|
||||||
|
ContentStr -> io:format("~s", [ContentStr])
|
||||||
|
end.
|
||||||
|
|
||||||
|
service_env_delete(ServiceId) ->
|
||||||
|
ApiKey = get_api_key(),
|
||||||
|
_ = curl_delete(ApiKey, "/services/" ++ ServiceId ++ "/env"),
|
||||||
|
io:format("\033[32mVault deleted: ~s\033[0m~n", [ServiceId]).
|
||||||
|
|
||||||
%% Argument parsing
|
%% Argument parsing
|
||||||
parse_exec_args([], Opts) ->
|
parse_exec_args([], Opts) ->
|
||||||
{maps:get(file, Opts), Opts};
|
{maps:get(file, Opts), Opts};
|
||||||
|
|
@ -691,6 +790,16 @@ has_extend_flag([]) -> false;
|
||||||
has_extend_flag(["--extend" | _]) -> true;
|
has_extend_flag(["--extend" | _]) -> true;
|
||||||
has_extend_flag([_ | Rest]) -> has_extend_flag(Rest).
|
has_extend_flag([_ | Rest]) -> has_extend_flag(Rest).
|
||||||
|
|
||||||
|
get_env_vars(Args) -> get_env_vars(Args, []).
|
||||||
|
|
||||||
|
get_env_vars([], Acc) -> lists:reverse(Acc);
|
||||||
|
get_env_vars(["-e", EnvVar | Rest], Acc) -> get_env_vars(Rest, [EnvVar | Acc]);
|
||||||
|
get_env_vars([_ | Rest], Acc) -> get_env_vars(Rest, Acc).
|
||||||
|
|
||||||
|
get_env_file([]) -> undefined;
|
||||||
|
get_env_file(["--env-file", EnvFile | _]) -> EnvFile;
|
||||||
|
get_env_file([_ | Rest]) -> get_env_file(Rest).
|
||||||
|
|
||||||
%% Simple JSON field extraction (works for simple string fields)
|
%% Simple JSON field extraction (works for simple string fields)
|
||||||
extract_json_field(Json, Field) ->
|
extract_json_field(Json, Field) ->
|
||||||
Pattern = "\"" ++ Field ++ "\":\"",
|
Pattern = "\"" ++ Field ++ "\":\"",
|
||||||
|
|
|
||||||
141
un.ex
141
un.ex
|
|
@ -86,8 +86,12 @@ defmodule Un do
|
||||||
IO.puts("Usage: un.ex [options] <source_file>")
|
IO.puts("Usage: un.ex [options] <source_file>")
|
||||||
IO.puts(" un.ex session [options]")
|
IO.puts(" un.ex session [options]")
|
||||||
IO.puts(" un.ex service [options]")
|
IO.puts(" un.ex service [options]")
|
||||||
|
IO.puts(" un.ex service env <action> <service_id>")
|
||||||
IO.puts(" un.ex snapshot [options]")
|
IO.puts(" un.ex snapshot [options]")
|
||||||
IO.puts(" un.ex key [--extend]")
|
IO.puts(" un.ex key [--extend]")
|
||||||
|
IO.puts("")
|
||||||
|
IO.puts("Service options: --name, --ports, --bootstrap, -e KEY=VALUE, --env-file FILE")
|
||||||
|
IO.puts("Service env commands: status, set, export, delete")
|
||||||
System.halt(1)
|
System.halt(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -293,6 +297,56 @@ defmodule Un do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp service_command(["env", "status", service_id | _]) do
|
||||||
|
response = service_env_status(service_id)
|
||||||
|
has_vault = extract_json_value(response, "has_vault") == "true"
|
||||||
|
if has_vault do
|
||||||
|
IO.puts("#{@green}Vault: configured#{@reset}")
|
||||||
|
env_count = extract_json_value(response, "env_count")
|
||||||
|
if env_count, do: IO.puts("Variables: #{env_count}")
|
||||||
|
updated_at = extract_json_value(response, "updated_at")
|
||||||
|
if updated_at, do: IO.puts("Updated: #{updated_at}")
|
||||||
|
else
|
||||||
|
IO.puts("#{@yellow}Vault: not configured#{@reset}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp service_command(["env", "set", service_id | rest]) do
|
||||||
|
envs = get_all_opts(rest, "-e")
|
||||||
|
env_file = get_opt(rest, "--env-file", nil, nil)
|
||||||
|
if Enum.empty?(envs) and is_nil(env_file) do
|
||||||
|
IO.puts(:stderr, "#{@red}Error: service env set requires -e or --env-file#{@reset}")
|
||||||
|
System.halt(1)
|
||||||
|
end
|
||||||
|
env_content = build_env_content(envs, env_file)
|
||||||
|
if service_env_set(service_id, env_content) do
|
||||||
|
IO.puts("#{@green}Vault updated for service #{service_id}#{@reset}")
|
||||||
|
else
|
||||||
|
IO.puts(:stderr, "#{@red}Error: Failed to update vault#{@reset}")
|
||||||
|
System.halt(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp service_command(["env", "export", service_id | _]) do
|
||||||
|
response = service_env_export(service_id)
|
||||||
|
content = extract_json_value(response, "content")
|
||||||
|
if content, do: IO.write(content)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp service_command(["env", "delete", service_id | _]) do
|
||||||
|
if service_env_delete(service_id) do
|
||||||
|
IO.puts("#{@green}Vault deleted for service #{service_id}#{@reset}")
|
||||||
|
else
|
||||||
|
IO.puts(:stderr, "#{@red}Error: Failed to delete vault#{@reset}")
|
||||||
|
System.halt(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp service_command(["env" | _]) do
|
||||||
|
IO.puts(:stderr, "#{@red}Error: Usage: un.ex service env <status|set|export|delete> <service_id>#{@reset}")
|
||||||
|
System.halt(1)
|
||||||
|
end
|
||||||
|
|
||||||
defp service_command(args) do
|
defp service_command(args) do
|
||||||
name = get_opt(args, "--name", nil, nil)
|
name = get_opt(args, "--name", nil, nil)
|
||||||
|
|
||||||
|
|
@ -309,6 +363,8 @@ defmodule Un do
|
||||||
vcpu = get_opt(args, "-v", nil, nil)
|
vcpu = get_opt(args, "-v", nil, nil)
|
||||||
service_type = get_opt(args, "--type", nil, nil)
|
service_type = get_opt(args, "--type", nil, nil)
|
||||||
input_files = get_all_opts(args, "-f")
|
input_files = get_all_opts(args, "-f")
|
||||||
|
envs = get_all_opts(args, "-e")
|
||||||
|
env_file = get_opt(args, "--env-file", nil, nil)
|
||||||
|
|
||||||
ports_json = if ports, do: ",\"ports\":[#{ports}]", else: ""
|
ports_json = if ports, do: ",\"ports\":[#{ports}]", else: ""
|
||||||
bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: ""
|
bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: ""
|
||||||
|
|
@ -331,6 +387,19 @@ defmodule Un do
|
||||||
response = curl_post(api_key, "/services", json)
|
response = curl_post(api_key, "/services", json)
|
||||||
IO.puts("#{@green}Service created#{@reset}")
|
IO.puts("#{@green}Service created#{@reset}")
|
||||||
IO.puts(response)
|
IO.puts(response)
|
||||||
|
|
||||||
|
# Auto-set vault if env vars were provided
|
||||||
|
service_id = extract_json_value(response, "id")
|
||||||
|
if service_id and (not Enum.empty?(envs) or env_file) do
|
||||||
|
env_content = build_env_content(envs, env_file)
|
||||||
|
if String.length(env_content) > 0 do
|
||||||
|
if service_env_set(service_id, env_content) do
|
||||||
|
IO.puts("#{@green}Vault configured with environment variables#{@reset}")
|
||||||
|
else
|
||||||
|
IO.puts("#{@yellow}Warning: Failed to set vault#{@reset}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Snapshot command
|
# Snapshot command
|
||||||
|
|
@ -681,6 +750,78 @@ defmodule Un do
|
||||||
output
|
output
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp curl_put_text(endpoint, body) do
|
||||||
|
tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.txt"
|
||||||
|
File.write!(tmp_file, body)
|
||||||
|
|
||||||
|
{public_key, secret_key} = get_api_keys()
|
||||||
|
headers = build_auth_headers(public_key, secret_key, "PUT", endpoint, body)
|
||||||
|
|
||||||
|
args = [
|
||||||
|
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"-X", "PUT",
|
||||||
|
"https://api.unsandbox.com#{endpoint}",
|
||||||
|
"-H", "Content-Type: text/plain"
|
||||||
|
] ++ headers ++ ["-d", "@#{tmp_file}"]
|
||||||
|
|
||||||
|
{output, _exit} = System.cmd("curl", args, stderr_to_stdout: true)
|
||||||
|
|
||||||
|
File.rm(tmp_file)
|
||||||
|
status_code = String.trim(output) |> String.to_integer()
|
||||||
|
status_code >= 200 and status_code < 300
|
||||||
|
end
|
||||||
|
|
||||||
|
@max_env_content_size 65536
|
||||||
|
|
||||||
|
defp read_env_file(path) do
|
||||||
|
case File.read(path) do
|
||||||
|
{:ok, content} -> content
|
||||||
|
{:error, _} ->
|
||||||
|
IO.puts(:stderr, "#{@red}Error: Env file not found: #{path}#{@reset}")
|
||||||
|
System.halt(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_env_content(envs, env_file) do
|
||||||
|
file_lines = if env_file do
|
||||||
|
content = read_env_file(env_file)
|
||||||
|
content
|
||||||
|
|> String.split("\n")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.filter(fn line ->
|
||||||
|
String.length(line) > 0 and not String.starts_with?(line, "#")
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
(envs ++ file_lines) |> Enum.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp service_env_status(service_id) do
|
||||||
|
api_key = get_api_key()
|
||||||
|
curl_get(api_key, "/services/#{service_id}/env")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp service_env_set(service_id, env_content) do
|
||||||
|
if String.length(env_content) > @max_env_content_size do
|
||||||
|
IO.puts(:stderr, "#{@red}Error: Env content exceeds maximum size of 64KB#{@reset}")
|
||||||
|
false
|
||||||
|
else
|
||||||
|
curl_put_text("/services/#{service_id}/env", env_content)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp service_env_export(service_id) do
|
||||||
|
api_key = get_api_key()
|
||||||
|
curl_post(api_key, "/services/#{service_id}/env/export", "{}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp service_env_delete(service_id) do
|
||||||
|
api_key = get_api_key()
|
||||||
|
curl_delete(api_key, "/services/#{service_id}/env")
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
defp parse_exec_args(args) do
|
defp parse_exec_args(args) do
|
||||||
parse_exec_args(args, nil, %{})
|
parse_exec_args(args, nil, %{})
|
||||||
end
|
end
|
||||||
|
|
|
||||||
276
un.f90
276
un.f90
|
|
@ -317,7 +317,8 @@ contains
|
||||||
subroutine handle_service()
|
subroutine handle_service()
|
||||||
character(len=8192) :: full_cmd
|
character(len=8192) :: full_cmd
|
||||||
character(len=256) :: arg, service_id, operation, service_type, service_name
|
character(len=256) :: arg, service_id, operation, service_type, service_name
|
||||||
character(len=1024) :: input_files
|
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
|
||||||
logical :: list_mode
|
logical :: list_mode
|
||||||
|
|
||||||
|
|
@ -327,20 +328,49 @@ contains
|
||||||
service_type = ''
|
service_type = ''
|
||||||
service_name = ''
|
service_name = ''
|
||||||
input_files = ''
|
input_files = ''
|
||||||
|
svc_envs = ''
|
||||||
|
svc_env_file = ''
|
||||||
|
env_action = ''
|
||||||
|
env_target = ''
|
||||||
|
|
||||||
! Parse service arguments
|
! Parse service arguments
|
||||||
do i = 2, command_argument_count()
|
i = 2
|
||||||
|
do while (i <= command_argument_count())
|
||||||
call get_command_argument(i, arg)
|
call get_command_argument(i, arg)
|
||||||
if (trim(arg) == '-l' .or. trim(arg) == '--list') then
|
if (trim(arg) == '-l' .or. trim(arg) == '--list') then
|
||||||
list_mode = .true.
|
list_mode = .true.
|
||||||
|
else if (trim(arg) == 'env') then
|
||||||
|
! service env <action> <service_id>
|
||||||
|
if (i+2 <= command_argument_count()) then
|
||||||
|
call get_command_argument(i+1, env_action)
|
||||||
|
call get_command_argument(i+2, env_target)
|
||||||
|
i = i + 2
|
||||||
|
end if
|
||||||
else if (trim(arg) == '--name') then
|
else if (trim(arg) == '--name') then
|
||||||
operation = 'create'
|
operation = 'create'
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_name)
|
call get_command_argument(i+1, service_name)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '--type') then
|
else if (trim(arg) == '--type') then
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_type)
|
call get_command_argument(i+1, service_type)
|
||||||
|
i = i + 1
|
||||||
|
end if
|
||||||
|
else if (trim(arg) == '-e') then
|
||||||
|
if (i+1 <= command_argument_count()) then
|
||||||
|
call get_command_argument(i+1, arg)
|
||||||
|
if (len_trim(svc_envs) > 0) then
|
||||||
|
svc_envs = trim(svc_envs) // char(10) // trim(arg)
|
||||||
|
else
|
||||||
|
svc_envs = trim(arg)
|
||||||
|
end if
|
||||||
|
i = i + 1
|
||||||
|
end if
|
||||||
|
else if (trim(arg) == '--env-file') then
|
||||||
|
if (i+1 <= command_argument_count()) then
|
||||||
|
call get_command_argument(i+1, svc_env_file)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '-f') then
|
else if (trim(arg) == '-f') then
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
|
|
@ -350,101 +380,221 @@ contains
|
||||||
else
|
else
|
||||||
input_files = trim(arg)
|
input_files = trim(arg)
|
||||||
end if
|
end if
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '--info') then
|
else if (trim(arg) == '--info') then
|
||||||
operation = 'info'
|
operation = 'info'
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_id)
|
call get_command_argument(i+1, service_id)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '--logs') then
|
else if (trim(arg) == '--logs') then
|
||||||
operation = 'logs'
|
operation = 'logs'
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_id)
|
call get_command_argument(i+1, service_id)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '--freeze') then
|
else if (trim(arg) == '--freeze') then
|
||||||
operation = 'sleep'
|
operation = 'sleep'
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_id)
|
call get_command_argument(i+1, service_id)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '--unfreeze') then
|
else if (trim(arg) == '--unfreeze') then
|
||||||
operation = 'wake'
|
operation = 'wake'
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_id)
|
call get_command_argument(i+1, service_id)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '--destroy') then
|
else if (trim(arg) == '--destroy') then
|
||||||
operation = 'destroy'
|
operation = 'destroy'
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_id)
|
call get_command_argument(i+1, service_id)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '--dump-bootstrap') then
|
else if (trim(arg) == '--dump-bootstrap') then
|
||||||
operation = 'dump-bootstrap'
|
operation = 'dump-bootstrap'
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_id)
|
call get_command_argument(i+1, service_id)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
else if (trim(arg) == '--dump-file') then
|
else if (trim(arg) == '--dump-file') then
|
||||||
operation = 'dump-file'
|
operation = 'dump-file'
|
||||||
if (i+1 <= command_argument_count()) then
|
if (i+1 <= command_argument_count()) then
|
||||||
call get_command_argument(i+1, service_type)
|
call get_command_argument(i+1, service_type)
|
||||||
|
i = i + 1
|
||||||
end if
|
end if
|
||||||
end if
|
end if
|
||||||
|
i = i + 1
|
||||||
end do
|
end do
|
||||||
|
|
||||||
! Get API key
|
! Get API keys (try new format first, fall back to old)
|
||||||
call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat)
|
call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=stat)
|
||||||
if (stat /= 0 .or. len_trim(api_key) == 0) then
|
if (stat == 0 .and. len_trim(public_key) > 0) then
|
||||||
write(0, '(A)') 'Error: UNSANDBOX_API_KEY not set'
|
call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=stat)
|
||||||
stop 1
|
if (stat /= 0 .or. len_trim(secret_key) == 0) then
|
||||||
|
write(0, '(A)') 'Error: UNSANDBOX_SECRET_KEY not set'
|
||||||
|
stop 1
|
||||||
|
end if
|
||||||
|
else
|
||||||
|
call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat)
|
||||||
|
if (stat /= 0 .or. len_trim(api_key) == 0) then
|
||||||
|
write(0, '(A)') 'Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set'
|
||||||
|
stop 1
|
||||||
|
end if
|
||||||
|
public_key = api_key
|
||||||
|
secret_key = api_key
|
||||||
|
end if
|
||||||
|
|
||||||
|
! Handle env subcommand
|
||||||
|
if (len_trim(env_action) > 0) then
|
||||||
|
if (trim(env_action) == 'status') then
|
||||||
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:GET:/services/', trim(env_target), '/env:" | ', &
|
||||||
|
'openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
|
'curl -s -X GET "https://api.unsandbox.com/services/', trim(env_target), '/env" ', &
|
||||||
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" | jq .'
|
||||||
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
|
return
|
||||||
|
else if (trim(env_action) == 'set') then
|
||||||
|
! Build env content from -e flags and --env-file
|
||||||
|
write(full_cmd, '(50A)') &
|
||||||
|
'ENV_CONTENT=""; ', &
|
||||||
|
'ENV_LINES="', trim(svc_envs), '"; ', &
|
||||||
|
'if [ -n "$ENV_LINES" ]; then ', &
|
||||||
|
'ENV_CONTENT="$ENV_LINES"; ', &
|
||||||
|
'fi; ', &
|
||||||
|
'ENV_FILE="', trim(svc_env_file), '"; ', &
|
||||||
|
'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', &
|
||||||
|
'while IFS= read -r line || [ -n "$line" ]; do ', &
|
||||||
|
'case "$line" in "#"*|"") continue ;; esac; ', &
|
||||||
|
'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', &
|
||||||
|
'ENV_CONTENT="$ENV_CONTENT$line"; ', &
|
||||||
|
'done < "$ENV_FILE"; fi; ', &
|
||||||
|
'if [ -z "$ENV_CONTENT" ]; then ', &
|
||||||
|
'echo -e "\x1b[31mError: No environment variables to set\x1b[0m" >&2; exit 1; fi; ', &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:PUT:/services/', trim(env_target), '/env:$ENV_CONTENT" | ', &
|
||||||
|
'openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
|
'curl -s -X PUT "https://api.unsandbox.com/services/', trim(env_target), '/env" ', &
|
||||||
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" ', &
|
||||||
|
'-H "Content-Type: text/plain" ', &
|
||||||
|
'--data-binary "$ENV_CONTENT" | jq .'
|
||||||
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
|
return
|
||||||
|
else if (trim(env_action) == 'export') then
|
||||||
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:POST:/services/', trim(env_target), '/env/export:" | ', &
|
||||||
|
'openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
|
'curl -s -X POST "https://api.unsandbox.com/services/', trim(env_target), '/env/export" ', &
|
||||||
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" | jq -r ".content // empty"'
|
||||||
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
|
return
|
||||||
|
else if (trim(env_action) == 'delete') then
|
||||||
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:DELETE:/services/', trim(env_target), '/env:" | ', &
|
||||||
|
'openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
|
'curl -s -X DELETE "https://api.unsandbox.com/services/', trim(env_target), '/env" ', &
|
||||||
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" >/dev/null && ', &
|
||||||
|
'echo -e "\x1b[32mVault deleted for: ', trim(env_target), '\x1b[0m"'
|
||||||
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
|
return
|
||||||
|
else
|
||||||
|
write(0, '(A,A)') 'Error: Unknown env action: ', trim(env_action)
|
||||||
|
write(0, '(A)') 'Usage: un.f90 service env <status|set|export|delete> <service_id>'
|
||||||
|
stop 1
|
||||||
|
end if
|
||||||
end if
|
end if
|
||||||
|
|
||||||
if (list_mode) then
|
if (list_mode) then
|
||||||
! List services
|
! List services
|
||||||
write(full_cmd, '(10A)') &
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:GET:/services:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
'curl -s -X GET https://api.unsandbox.com/services ', &
|
'curl -s -X GET https://api.unsandbox.com/services ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" | ', &
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" | ', &
|
||||||
'jq -r ''.services[] | "\(.id) \(.name) \(.status)"'' ', &
|
'jq -r ''.services[] | "\(.id) \(.name) \(.status)"'' ', &
|
||||||
'2>/dev/null || echo "No services"'
|
'2>/dev/null || echo "No services"'
|
||||||
call execute_command_line(trim(full_cmd), wait=.true.)
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
else if (trim(operation) == 'info' .and. len_trim(service_id) > 0) then
|
else if (trim(operation) == 'info' .and. len_trim(service_id) > 0) then
|
||||||
write(full_cmd, '(10A)') &
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:GET:/services/', trim(service_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
'curl -s -X GET https://api.unsandbox.com/services/', &
|
'curl -s -X GET https://api.unsandbox.com/services/', &
|
||||||
trim(service_id), ' ', &
|
trim(service_id), ' ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" | jq .'
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" | jq .'
|
||||||
call execute_command_line(trim(full_cmd), wait=.true.)
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
else if (trim(operation) == 'logs' .and. len_trim(service_id) > 0) then
|
else if (trim(operation) == 'logs' .and. len_trim(service_id) > 0) then
|
||||||
write(full_cmd, '(10A)') &
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:GET:/services/', trim(service_id), '/logs:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
'curl -s -X GET https://api.unsandbox.com/services/', &
|
'curl -s -X GET https://api.unsandbox.com/services/', &
|
||||||
trim(service_id), '/logs ', &
|
trim(service_id), '/logs ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" | jq -r ".logs"'
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" | jq -r ".logs"'
|
||||||
call execute_command_line(trim(full_cmd), wait=.true.)
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
else if (trim(operation) == 'sleep' .and. len_trim(service_id) > 0) then
|
else if (trim(operation) == 'sleep' .and. len_trim(service_id) > 0) then
|
||||||
write(full_cmd, '(10A)') &
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:POST:/services/', trim(service_id), '/sleep:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
'curl -s -X POST https://api.unsandbox.com/services/', &
|
'curl -s -X POST https://api.unsandbox.com/services/', &
|
||||||
trim(service_id), '/sleep ', &
|
trim(service_id), '/sleep ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" >/dev/null && ', &
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" >/dev/null && ', &
|
||||||
'echo -e "\x1b[32mService sleeping: ', trim(service_id), '\x1b[0m"'
|
'echo -e "\x1b[32mService sleeping: ', trim(service_id), '\x1b[0m"'
|
||||||
call execute_command_line(trim(full_cmd), wait=.true.)
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
else if (trim(operation) == 'wake' .and. len_trim(service_id) > 0) then
|
else if (trim(operation) == 'wake' .and. len_trim(service_id) > 0) then
|
||||||
write(full_cmd, '(10A)') &
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:POST:/services/', trim(service_id), '/wake:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
'curl -s -X POST https://api.unsandbox.com/services/', &
|
'curl -s -X POST https://api.unsandbox.com/services/', &
|
||||||
trim(service_id), '/wake ', &
|
trim(service_id), '/wake ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" >/dev/null && ', &
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" >/dev/null && ', &
|
||||||
'echo -e "\x1b[32mService waking: ', trim(service_id), '\x1b[0m"'
|
'echo -e "\x1b[32mService waking: ', trim(service_id), '\x1b[0m"'
|
||||||
call execute_command_line(trim(full_cmd), wait=.true.)
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
else if (trim(operation) == 'destroy' .and. len_trim(service_id) > 0) then
|
else if (trim(operation) == 'destroy' .and. len_trim(service_id) > 0) then
|
||||||
write(full_cmd, '(10A)') &
|
write(full_cmd, '(20A)') &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:DELETE:/services/', trim(service_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
'curl -s -X DELETE https://api.unsandbox.com/services/', &
|
'curl -s -X DELETE https://api.unsandbox.com/services/', &
|
||||||
trim(service_id), ' ', &
|
trim(service_id), ' ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" >/dev/null && ', &
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" >/dev/null && ', &
|
||||||
'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"'
|
'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"'
|
||||||
call execute_command_line(trim(full_cmd), wait=.true.)
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
else if (trim(operation) == 'dump-bootstrap' .and. len_trim(service_id) > 0) then
|
else if (trim(operation) == 'dump-bootstrap' .and. len_trim(service_id) > 0) then
|
||||||
write(full_cmd, '(20A)') &
|
write(full_cmd, '(30A)') &
|
||||||
'echo "Fetching bootstrap script from ', trim(service_id), '..." >&2; ', &
|
'echo "Fetching bootstrap script from ', trim(service_id), '..." >&2; ', &
|
||||||
|
'BODY=''{"command":"cat /tmp/bootstrap.sh"}''; ', &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:POST:/services/', trim(service_id), '/execute:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
'RESP=$(curl -s -X POST https://api.unsandbox.com/services/', &
|
'RESP=$(curl -s -X POST https://api.unsandbox.com/services/', &
|
||||||
trim(service_id), '/execute ', &
|
trim(service_id), '/execute ', &
|
||||||
'-H "Content-Type: application/json" ', &
|
'-H "Content-Type: application/json" ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" ', &
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
'-d ''{"command":"cat /tmp/bootstrap.sh"}''); ', &
|
'-H "X-Timestamp: $TS" ', &
|
||||||
|
'-H "X-Signature: $SIG" ', &
|
||||||
|
'-d "$BODY"); ', &
|
||||||
'STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); ', &
|
'STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); ', &
|
||||||
'if [ -n "$STDOUT" ]; then ', &
|
'if [ -n "$STDOUT" ]; then ', &
|
||||||
'if [ -n "', trim(service_type), '" ]; then ', &
|
'if [ -n "', trim(service_type), '" ]; then ', &
|
||||||
|
|
@ -454,9 +604,9 @@ contains
|
||||||
'else echo -e "\x1b[31mError: Failed to fetch bootstrap\x1b[0m" >&2; exit 1; fi'
|
'else echo -e "\x1b[31mError: Failed to fetch bootstrap\x1b[0m" >&2; exit 1; fi'
|
||||||
call execute_command_line(trim(full_cmd), wait=.true.)
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
else if (trim(operation) == 'create' .and. len_trim(service_name) > 0) then
|
else if (trim(operation) == 'create' .and. len_trim(service_name) > 0) then
|
||||||
! Create service with optional input_files
|
! Create service with optional input_files and auto-vault
|
||||||
if (len_trim(input_files) > 0) then
|
if (len_trim(input_files) > 0) then
|
||||||
write(full_cmd, '(30A)') &
|
write(full_cmd, '(60A)') &
|
||||||
'INPUT_FILES=""; ', &
|
'INPUT_FILES=""; ', &
|
||||||
'IFS='','' read -ra FILES <<< "', trim(input_files), '"; ', &
|
'IFS='','' read -ra FILES <<< "', trim(input_files), '"; ', &
|
||||||
'for f in "${FILES[@]}"; do ', &
|
'for f in "${FILES[@]}"; do ', &
|
||||||
|
|
@ -465,24 +615,78 @@ contains
|
||||||
'if [ -n "$INPUT_FILES" ]; then INPUT_FILES="$INPUT_FILES,"; fi; ', &
|
'if [ -n "$INPUT_FILES" ]; then INPUT_FILES="$INPUT_FILES,"; fi; ', &
|
||||||
'INPUT_FILES="$INPUT_FILES{\"filename\":\"$name\",\"content\":\"$b64\"}"; ', &
|
'INPUT_FILES="$INPUT_FILES{\"filename\":\"$name\",\"content\":\"$b64\"}"; ', &
|
||||||
'done; ', &
|
'done; ', &
|
||||||
'curl -s -X POST https://api.unsandbox.com/services ', &
|
'BODY=''{"name":"', trim(service_name), '","input_files":[''"$INPUT_FILES"'']}''; ', &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:POST:/services:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
|
'RESP=$(curl -s -X POST https://api.unsandbox.com/services ', &
|
||||||
'-H "Content-Type: application/json" ', &
|
'-H "Content-Type: application/json" ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" ', &
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
'-d ''{"name":"', trim(service_name), '","input_files":[''"$INPUT_FILES"'']}'' | ', &
|
'-H "X-Timestamp: $TS" ', &
|
||||||
'jq -r ''.id + " created"'' && ', &
|
'-H "X-Signature: $SIG" ', &
|
||||||
'echo -e "\x1b[32mService created\x1b[0m"'
|
'-d "$BODY"); ', &
|
||||||
|
'SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); ', &
|
||||||
|
'if [ -n "$SVC_ID" ]; then ', &
|
||||||
|
'echo -e "\x1b[32m$SVC_ID created\x1b[0m"; ', &
|
||||||
|
'ENV_CONTENT=""; ', &
|
||||||
|
'ENV_LINES="', trim(svc_envs), '"; ', &
|
||||||
|
'if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ', &
|
||||||
|
'ENV_FILE="', trim(svc_env_file), '"; ', &
|
||||||
|
'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', &
|
||||||
|
'while IFS= read -r line || [ -n "$line" ]; do ', &
|
||||||
|
'case "$line" in "#"*|"") continue ;; esac; ', &
|
||||||
|
'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', &
|
||||||
|
'ENV_CONTENT="$ENV_CONTENT$line"; ', &
|
||||||
|
'done < "$ENV_FILE"; fi; ', &
|
||||||
|
'if [ -n "$ENV_CONTENT" ]; then ', &
|
||||||
|
'TS2=$(date +%s); ', &
|
||||||
|
'SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
|
'curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" ', &
|
||||||
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS2" ', &
|
||||||
|
'-H "X-Signature: $SIG2" ', &
|
||||||
|
'-H "Content-Type: text/plain" ', &
|
||||||
|
'--data-binary "$ENV_CONTENT" >/dev/null && ', &
|
||||||
|
'echo -e "\x1b[32mVault configured\x1b[0m"; fi; ', &
|
||||||
|
'else echo "$RESP" | jq .; fi'
|
||||||
else
|
else
|
||||||
write(full_cmd, '(15A)') &
|
write(full_cmd, '(60A)') &
|
||||||
'curl -s -X POST https://api.unsandbox.com/services ', &
|
'BODY=''{"name":"', trim(service_name), '"}''; ', &
|
||||||
|
'TS=$(date +%s); ', &
|
||||||
|
'SIG=$(echo -n "$TS:POST:/services:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
|
'RESP=$(curl -s -X POST https://api.unsandbox.com/services ', &
|
||||||
'-H "Content-Type: application/json" ', &
|
'-H "Content-Type: application/json" ', &
|
||||||
'-H "Authorization: Bearer ', trim(api_key), '" ', &
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
'-d ''{"name":"', trim(service_name), '"}'' | ', &
|
'-H "X-Timestamp: $TS" ', &
|
||||||
'jq -r ''.id + " created"'' && ', &
|
'-H "X-Signature: $SIG" ', &
|
||||||
'echo -e "\x1b[32mService created\x1b[0m"'
|
'-d "$BODY"); ', &
|
||||||
|
'SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); ', &
|
||||||
|
'if [ -n "$SVC_ID" ]; then ', &
|
||||||
|
'echo -e "\x1b[32m$SVC_ID created\x1b[0m"; ', &
|
||||||
|
'ENV_CONTENT=""; ', &
|
||||||
|
'ENV_LINES="', trim(svc_envs), '"; ', &
|
||||||
|
'if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ', &
|
||||||
|
'ENV_FILE="', trim(svc_env_file), '"; ', &
|
||||||
|
'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', &
|
||||||
|
'while IFS= read -r line || [ -n "$line" ]; do ', &
|
||||||
|
'case "$line" in "#"*|"") continue ;; esac; ', &
|
||||||
|
'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', &
|
||||||
|
'ENV_CONTENT="$ENV_CONTENT$line"; ', &
|
||||||
|
'done < "$ENV_FILE"; fi; ', &
|
||||||
|
'if [ -n "$ENV_CONTENT" ]; then ', &
|
||||||
|
'TS2=$(date +%s); ', &
|
||||||
|
'SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', &
|
||||||
|
'curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" ', &
|
||||||
|
'-H "Authorization: Bearer ', trim(public_key), '" ', &
|
||||||
|
'-H "X-Timestamp: $TS2" ', &
|
||||||
|
'-H "X-Signature: $SIG2" ', &
|
||||||
|
'-H "Content-Type: text/plain" ', &
|
||||||
|
'--data-binary "$ENV_CONTENT" >/dev/null && ', &
|
||||||
|
'echo -e "\x1b[32mVault configured\x1b[0m"; fi; ', &
|
||||||
|
'else echo "$RESP" | jq .; fi'
|
||||||
end if
|
end if
|
||||||
call execute_command_line(trim(full_cmd), wait=.true.)
|
call execute_command_line(trim(full_cmd), wait=.true.)
|
||||||
else
|
else
|
||||||
write(0, '(A)') 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --dump-bootstrap, or --name NAME'
|
write(0, '(A)') 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --dump-bootstrap, --name, or env'
|
||||||
stop 1
|
stop 1
|
||||||
end if
|
end if
|
||||||
end subroutine handle_service
|
end subroutine handle_service
|
||||||
|
|
|
||||||
183
un.forth
183
un.forth
|
|
@ -356,6 +356,112 @@
|
||||||
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
|
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
|
||||||
|
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
|
||||||
|
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" TIMESTAMP=$(date +%s)" r@ write-line throw
|
||||||
|
s" MESSAGE=\"$TIMESTAMP:GET:/services/$SERVICE_ID/env:\"" 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 GET \"https://api.unsandbox.com/services/$SERVICE_ID/env\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq ." r@ write-line throw
|
||||||
|
r> close-file throw
|
||||||
|
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
|
||||||
|
;
|
||||||
|
|
||||||
|
\ Service env set (with -e and --env-file support via shell script)
|
||||||
|
: service-env-set ( -- )
|
||||||
|
get-api-key
|
||||||
|
s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r
|
||||||
|
s" #!/bin/bash" 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" SERVICE_ID=''; ENV_CONTENT=''; ENV_FILE=''" r@ write-line throw
|
||||||
|
s" i=4" r@ write-line throw
|
||||||
|
s" SERVICE_ID=$3" r@ write-line throw
|
||||||
|
s" while [ $i -le $# ]; do" r@ write-line throw
|
||||||
|
s" arg=${!i}" r@ write-line throw
|
||||||
|
s" case \"$arg\" in" r@ write-line throw
|
||||||
|
s" -e) ((i++)); VAL=${!i}" r@ write-line throw
|
||||||
|
s" if [ -n \"$ENV_CONTENT\" ]; then ENV_CONTENT=\"$ENV_CONTENT\n$VAL\"; else ENV_CONTENT=\"$VAL\"; fi ;;" r@ write-line throw
|
||||||
|
s" --env-file) ((i++)); ENV_FILE=${!i} ;;" r@ write-line throw
|
||||||
|
s" esac" r@ write-line throw
|
||||||
|
s" ((i++))" r@ write-line throw
|
||||||
|
s" done" r@ write-line throw
|
||||||
|
s" if [ -n \"$ENV_FILE\" ] && [ -f \"$ENV_FILE\" ]; then" r@ write-line throw
|
||||||
|
s" while IFS= read -r line || [ -n \"$line\" ]; do" r@ write-line throw
|
||||||
|
s" case \"$line\" in \"#\"*|\"\") continue ;; esac" r@ write-line throw
|
||||||
|
s" if [ -n \"$ENV_CONTENT\" ]; then ENV_CONTENT=\"$ENV_CONTENT\n$line\"; else ENV_CONTENT=\"$line\"; fi" r@ write-line throw
|
||||||
|
s" done < \"$ENV_FILE\"" r@ write-line throw
|
||||||
|
s" fi" r@ write-line throw
|
||||||
|
s" if [ -z \"$ENV_CONTENT\" ]; then echo -e '\\x1b[31mError: No environment variables to set\\x1b[0m' >&2; exit 1; fi" r@ write-line throw
|
||||||
|
s" TIMESTAMP=$(date +%s)" r@ write-line throw
|
||||||
|
s" MESSAGE=\"$TIMESTAMP:PUT:/services/$SERVICE_ID/env:$ENV_CONTENT\"" r@ write-line throw
|
||||||
|
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw
|
||||||
|
s" echo -e \"$ENV_CONTENT\" | curl -s -X PUT \"https://api.unsandbox.com/services/$SERVICE_ID/env\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: text/plain' --data-binary @- | jq ." r@ write-line throw
|
||||||
|
r> close-file throw
|
||||||
|
s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh \"$@\" && rm -f /tmp/unsandbox_cmd.sh" system
|
||||||
|
;
|
||||||
|
|
||||||
|
\ Service env export
|
||||||
|
: service-env-export ( addr 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
|
||||||
|
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" TIMESTAMP=$(date +%s)" r@ write-line throw
|
||||||
|
s" MESSAGE=\"$TIMESTAMP:POST:/services/$SERVICE_ID/env/export:\"" 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 POST \"https://api.unsandbox.com/services/$SERVICE_ID/env/export\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq -r '.content // empty'" r@ write-line throw
|
||||||
|
r> close-file throw
|
||||||
|
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
|
||||||
|
;
|
||||||
|
|
||||||
|
\ Service env delete
|
||||||
|
: service-env-delete ( addr 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
|
||||||
|
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" TIMESTAMP=$(date +%s)" r@ write-line throw
|
||||||
|
s" MESSAGE=\"$TIMESTAMP:DELETE:/services/$SERVICE_ID/env:\"" 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 DELETE \"https://api.unsandbox.com/services/$SERVICE_ID/env\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mVault deleted for: " r@ write-file throw
|
||||||
|
r@ write-file throw
|
||||||
|
s" \\x1b[0m'" r@ write-line throw
|
||||||
|
r> close-file throw
|
||||||
|
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
|
||||||
|
;
|
||||||
|
|
||||||
\ Service dump bootstrap
|
\ Service dump bootstrap
|
||||||
: service-dump-bootstrap ( service-id-addr service-id-len file-addr file-len -- )
|
: service-dump-bootstrap ( service-id-addr service-id-len file-addr file-len -- )
|
||||||
get-api-key
|
get-api-key
|
||||||
|
|
@ -400,7 +506,7 @@
|
||||||
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
|
s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
|
||||||
;
|
;
|
||||||
|
|
||||||
\ Service create (requires --name, optional --ports, --domains, --type, --bootstrap, -f)
|
\ Service create (requires --name, optional --ports, --domains, --type, --bootstrap, -f, -e, --env-file)
|
||||||
: service-create ( -- )
|
: service-create ( -- )
|
||||||
get-api-key
|
get-api-key
|
||||||
\ Parse arguments (simplified - in real implementation would iterate through args)
|
\ Parse arguments (simplified - in real implementation would iterate through args)
|
||||||
|
|
@ -414,6 +520,7 @@
|
||||||
get-secret-key r@ write-file throw
|
get-secret-key r@ write-file throw
|
||||||
s" '" r@ write-line throw
|
s" '" r@ write-line throw
|
||||||
s" NAME=''; PORTS=''; DOMAINS=''; TYPE=''; BOOTSTRAP=''; BOOTSTRAP_FILE=''; INPUT_FILES=''" r@ write-line throw
|
s" NAME=''; PORTS=''; DOMAINS=''; TYPE=''; BOOTSTRAP=''; BOOTSTRAP_FILE=''; INPUT_FILES=''" r@ write-line throw
|
||||||
|
s" ENV_CONTENT=''; ENV_FILE=''" r@ write-line throw
|
||||||
s" i=3" r@ write-line throw
|
s" i=3" r@ write-line throw
|
||||||
s" while [ $i -lt $# ]; do" r@ write-line throw
|
s" while [ $i -lt $# ]; do" r@ write-line throw
|
||||||
s" arg=${!i}" r@ write-line throw
|
s" arg=${!i}" r@ write-line throw
|
||||||
|
|
@ -424,6 +531,9 @@
|
||||||
s" --type) ((i++)); TYPE=${!i} ;;" r@ write-line throw
|
s" --type) ((i++)); TYPE=${!i} ;;" r@ write-line throw
|
||||||
s" --bootstrap) ((i++)); BOOTSTRAP=${!i} ;;" r@ write-line throw
|
s" --bootstrap) ((i++)); BOOTSTRAP=${!i} ;;" r@ write-line throw
|
||||||
s" --bootstrap-file) ((i++)); BOOTSTRAP_FILE=${!i} ;;" r@ write-line throw
|
s" --bootstrap-file) ((i++)); BOOTSTRAP_FILE=${!i} ;;" r@ write-line throw
|
||||||
|
s" -e) ((i++)); VAL=${!i}" r@ write-line throw
|
||||||
|
s" if [ -n \"$ENV_CONTENT\" ]; then ENV_CONTENT=\"$ENV_CONTENT\n$VAL\"; else ENV_CONTENT=\"$VAL\"; fi ;;" r@ write-line throw
|
||||||
|
s" --env-file) ((i++)); ENV_FILE=${!i} ;;" r@ write-line throw
|
||||||
s" -f) ((i++)); FILE=${!i}" r@ write-line throw
|
s" -f) ((i++)); FILE=${!i}" r@ write-line throw
|
||||||
s" if [ -f \"$FILE\" ]; then" r@ write-line throw
|
s" if [ -f \"$FILE\" ]; then" r@ write-line throw
|
||||||
s" BASENAME=$(basename \"$FILE\")" r@ write-line throw
|
s" BASENAME=$(basename \"$FILE\")" r@ write-line throw
|
||||||
|
|
@ -440,6 +550,13 @@
|
||||||
s" esac" r@ write-line throw
|
s" esac" r@ write-line throw
|
||||||
s" ((i++))" r@ write-line throw
|
s" ((i++))" r@ write-line throw
|
||||||
s" done" r@ write-line throw
|
s" done" r@ write-line throw
|
||||||
|
s" # Parse env file if specified" r@ write-line throw
|
||||||
|
s" if [ -n \"$ENV_FILE\" ] && [ -f \"$ENV_FILE\" ]; then" r@ write-line throw
|
||||||
|
s" while IFS= read -r line || [ -n \"$line\" ]; do" r@ write-line throw
|
||||||
|
s" case \"$line\" in \"#\"*|\"\") continue ;; esac" r@ write-line throw
|
||||||
|
s" if [ -n \"$ENV_CONTENT\" ]; then ENV_CONTENT=\"$ENV_CONTENT\n$line\"; else ENV_CONTENT=\"$line\"; fi" r@ write-line throw
|
||||||
|
s" done < \"$ENV_FILE\"" r@ write-line throw
|
||||||
|
s" fi" r@ write-line throw
|
||||||
s" [ -z \"$NAME\" ] && echo 'Error: --name required' && exit 1" r@ write-line throw
|
s" [ -z \"$NAME\" ] && echo 'Error: --name required' && exit 1" r@ write-line throw
|
||||||
s" PAYLOAD='{\"name\":\"'\"$NAME\"'\"}'" r@ write-line throw
|
s" PAYLOAD='{\"name\":\"'\"$NAME\"'\"}'" r@ write-line throw
|
||||||
s" [ -n \"$PORTS\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg p \"$PORTS\" '. + {ports: ($p | split(\",\") | map(tonumber))}')" r@ write-line throw
|
s" [ -n \"$PORTS\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg p \"$PORTS\" '. + {ports: ($p | split(\",\") | map(tonumber))}')" r@ write-line throw
|
||||||
|
|
@ -456,7 +573,19 @@
|
||||||
s" TIMESTAMP=$(date +%s)" r@ write-line throw
|
s" TIMESTAMP=$(date +%s)" r@ write-line throw
|
||||||
s" MESSAGE=\"$TIMESTAMP:POST:/services:$PAYLOAD\"" r@ write-line throw
|
s" MESSAGE=\"$TIMESTAMP:POST:/services:$PAYLOAD\"" r@ write-line throw
|
||||||
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" 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 POST https://api.unsandbox.com/services -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$PAYLOAD\" | jq ." r@ write-line throw
|
s" RESP=$(curl -s -X POST https://api.unsandbox.com/services -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$PAYLOAD\")" r@ write-line throw
|
||||||
|
s" echo \"$RESP\" | jq ." r@ write-line throw
|
||||||
|
s" # Auto-set vault if env vars were provided" r@ write-line throw
|
||||||
|
s" if [ -n \"$ENV_CONTENT\" ]; then" r@ write-line throw
|
||||||
|
s" SERVICE_ID=$(echo \"$RESP\" | jq -r '.id // empty')" r@ write-line throw
|
||||||
|
s" if [ -n \"$SERVICE_ID\" ]; then" r@ write-line throw
|
||||||
|
s" echo -e '\\x1b[33mSetting vault for service...\\x1b[0m'" r@ write-line throw
|
||||||
|
s" TIMESTAMP=$(date +%s)" r@ write-line throw
|
||||||
|
s" MESSAGE=\"$TIMESTAMP:PUT:/services/$SERVICE_ID/env:$ENV_CONTENT\"" r@ write-line throw
|
||||||
|
s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw
|
||||||
|
s" echo -e \"$ENV_CONTENT\" | curl -s -X PUT \"https://api.unsandbox.com/services/$SERVICE_ID/env\" -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: text/plain' --data-binary @- | jq ." r@ write-line throw
|
||||||
|
s" fi" r@ write-line throw
|
||||||
|
s" fi" r@ write-line throw
|
||||||
r> close-file throw
|
r> close-file throw
|
||||||
s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
|
s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system
|
||||||
;
|
;
|
||||||
|
|
@ -738,8 +867,56 @@
|
||||||
0 (bye)
|
0 (bye)
|
||||||
then
|
then
|
||||||
|
|
||||||
|
\ Handle env subcommand: service env <action> <service_id> [options]
|
||||||
|
2dup s" env" compare 0= if
|
||||||
|
2drop
|
||||||
|
argc @ 4 < if
|
||||||
|
s" Usage: un.forth service env <status|set|export|delete> <service_id> [options]" type cr
|
||||||
|
1 (bye)
|
||||||
|
then
|
||||||
|
3 arg 2dup s" status" compare 0= if
|
||||||
|
2drop
|
||||||
|
argc @ 5 < if
|
||||||
|
s" Error: status requires service ID" type cr
|
||||||
|
1 (bye)
|
||||||
|
then
|
||||||
|
4 arg service-env-status
|
||||||
|
0 (bye)
|
||||||
|
then
|
||||||
|
2dup s" set" compare 0= if
|
||||||
|
2drop
|
||||||
|
argc @ 5 < if
|
||||||
|
s" Error: set requires service ID" type cr
|
||||||
|
1 (bye)
|
||||||
|
then
|
||||||
|
service-env-set
|
||||||
|
0 (bye)
|
||||||
|
then
|
||||||
|
2dup s" export" compare 0= if
|
||||||
|
2drop
|
||||||
|
argc @ 5 < if
|
||||||
|
s" Error: export requires service ID" type cr
|
||||||
|
1 (bye)
|
||||||
|
then
|
||||||
|
4 arg service-env-export
|
||||||
|
0 (bye)
|
||||||
|
then
|
||||||
|
2dup s" delete" compare 0= if
|
||||||
|
2drop
|
||||||
|
argc @ 5 < if
|
||||||
|
s" Error: delete requires service ID" type cr
|
||||||
|
1 (bye)
|
||||||
|
then
|
||||||
|
4 arg service-env-delete
|
||||||
|
0 (bye)
|
||||||
|
then
|
||||||
|
2drop
|
||||||
|
s" Error: Unknown env action. Use status, set, export, or delete" type cr
|
||||||
|
1 (bye)
|
||||||
|
then
|
||||||
|
|
||||||
2drop
|
2drop
|
||||||
s" Error: Use --name (create), --list, --info, --logs, --freeze, --unfreeze, --destroy, or --dump-bootstrap" type cr
|
s" Error: Use --name (create), --list, --info, --logs, --freeze, --unfreeze, --destroy, --dump-bootstrap, or env" type cr
|
||||||
1 (bye)
|
1 (bye)
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|
|
||||||
207
un.fs
207
un.fs
|
|
@ -118,6 +118,9 @@ type Args = {
|
||||||
mutable SnapshotName: string option
|
mutable SnapshotName: string option
|
||||||
mutable SnapshotShell: string option
|
mutable SnapshotShell: string option
|
||||||
mutable SnapshotPorts: string option
|
mutable SnapshotPorts: string option
|
||||||
|
mutable EnvFile: string option
|
||||||
|
mutable EnvAction: string option
|
||||||
|
mutable EnvTarget: string option
|
||||||
mutable KeyExtend: bool
|
mutable KeyExtend: bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,6 +314,164 @@ let apiRequest (endpoint: string) (method: string) (data: (string * obj) list op
|
||||||
|
|
||||||
failwithf "HTTP error - %s" errorMsg
|
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
|
||||||
|
|
||||||
|
let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest
|
||||||
|
request.Method <- method
|
||||||
|
request.ContentType <- "text/plain"
|
||||||
|
request.Timeout <- 300000
|
||||||
|
|
||||||
|
let bodyContent = if body = null then "" else body
|
||||||
|
|
||||||
|
// 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 method endpoint bodyContent
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not (String.IsNullOrEmpty(bodyContent)) then
|
||||||
|
let bytes = Encoding.UTF8.GetBytes(bodyContent)
|
||||||
|
request.ContentLength <- int64 bytes.Length
|
||||||
|
use stream = request.GetRequestStream()
|
||||||
|
stream.Write(bytes, 0, bytes.Length)
|
||||||
|
|
||||||
|
try
|
||||||
|
use response = request.GetResponse() :?> HttpWebResponse
|
||||||
|
use reader = new StreamReader(response.GetResponseStream())
|
||||||
|
reader.ReadToEnd()
|
||||||
|
with
|
||||||
|
| :? WebException as ex ->
|
||||||
|
let errorMsg =
|
||||||
|
if ex.Response <> null then
|
||||||
|
use reader = new StreamReader(ex.Response.GetResponseStream())
|
||||||
|
reader.ReadToEnd()
|
||||||
|
else
|
||||||
|
ex.Message
|
||||||
|
failwithf "HTTP error - %s" errorMsg
|
||||||
|
|
||||||
|
let readEnvFile (path: string) =
|
||||||
|
if not (File.Exists(path)) then
|
||||||
|
failwithf "Env file not found: %s" path
|
||||||
|
File.ReadAllText(path)
|
||||||
|
|
||||||
|
let buildEnvContent (envs: ResizeArray<string>) (envFile: string option) =
|
||||||
|
let lines = ResizeArray<string>()
|
||||||
|
|
||||||
|
// Add from -e flags
|
||||||
|
for env in envs do
|
||||||
|
lines.Add(env)
|
||||||
|
|
||||||
|
// Add from --env-file
|
||||||
|
match envFile with
|
||||||
|
| Some path ->
|
||||||
|
let content = readEnvFile path
|
||||||
|
for line in content.Split('\n') do
|
||||||
|
let trimmed = line.Trim()
|
||||||
|
if not (String.IsNullOrEmpty(trimmed)) && not (trimmed.StartsWith("#")) then
|
||||||
|
lines.Add(trimmed)
|
||||||
|
| None -> ()
|
||||||
|
|
||||||
|
String.Join("\n", lines)
|
||||||
|
|
||||||
|
let serviceEnvStatus (serviceId: string) (publicKey: string) (secretKey: string) =
|
||||||
|
apiRequest (sprintf "/services/%s/env" serviceId) "GET" None publicKey secretKey
|
||||||
|
|
||||||
|
let serviceEnvSet (serviceId: string) (envContent: string) (publicKey: string) (secretKey: string) =
|
||||||
|
let maxEnvContentSize = 65536
|
||||||
|
if envContent.Length > maxEnvContentSize then
|
||||||
|
eprintfn "%sError: Env content exceeds maximum size of 64KB%s" red reset
|
||||||
|
false
|
||||||
|
else
|
||||||
|
try
|
||||||
|
apiRequestText (sprintf "/services/%s/env" serviceId) "PUT" envContent publicKey secretKey |> ignore
|
||||||
|
true
|
||||||
|
with _ ->
|
||||||
|
false
|
||||||
|
|
||||||
|
let serviceEnvExport (serviceId: string) (publicKey: string) (secretKey: string) =
|
||||||
|
apiRequest (sprintf "/services/%s/env/export" serviceId) "POST" None publicKey secretKey
|
||||||
|
|
||||||
|
let serviceEnvDelete (serviceId: string) (publicKey: string) (secretKey: string) =
|
||||||
|
try
|
||||||
|
apiRequest (sprintf "/services/%s/env" serviceId) "DELETE" None publicKey secretKey |> ignore
|
||||||
|
true
|
||||||
|
with _ ->
|
||||||
|
false
|
||||||
|
|
||||||
|
let cmdServiceEnv (args: Args) (publicKey: string) (secretKey: string) =
|
||||||
|
match args.EnvAction with
|
||||||
|
| Some "status" ->
|
||||||
|
match args.EnvTarget with
|
||||||
|
| Some target ->
|
||||||
|
let result = serviceEnvStatus target publicKey secretKey
|
||||||
|
match result.TryFind "has_vault" with
|
||||||
|
| Some hasVault when hasVault.ToString() = "True" ->
|
||||||
|
printfn "%sVault: configured%s" green reset
|
||||||
|
match result.TryFind "env_count" with
|
||||||
|
| Some count -> printfn "Variables: %s" (count.ToString())
|
||||||
|
| None -> ()
|
||||||
|
match result.TryFind "updated_at" with
|
||||||
|
| Some updated -> printfn "Updated: %s" (updated.ToString())
|
||||||
|
| None -> ()
|
||||||
|
| _ ->
|
||||||
|
printfn "%sVault: not configured%s" yellow reset
|
||||||
|
| None ->
|
||||||
|
eprintfn "%sError: service env status requires service ID%s" red reset
|
||||||
|
exit 1
|
||||||
|
| Some "set" ->
|
||||||
|
match args.EnvTarget with
|
||||||
|
| Some target ->
|
||||||
|
if args.Env.Count = 0 && args.EnvFile.IsNone then
|
||||||
|
eprintfn "%sError: service env set requires -e or --env-file%s" red reset
|
||||||
|
exit 1
|
||||||
|
let envContent = buildEnvContent args.Env args.EnvFile
|
||||||
|
if serviceEnvSet target envContent publicKey secretKey then
|
||||||
|
printfn "%sVault updated for service %s%s" green target reset
|
||||||
|
else
|
||||||
|
eprintfn "%sError: Failed to update vault%s" red reset
|
||||||
|
exit 1
|
||||||
|
| None ->
|
||||||
|
eprintfn "%sError: service env set requires service ID%s" red reset
|
||||||
|
exit 1
|
||||||
|
| Some "export" ->
|
||||||
|
match args.EnvTarget with
|
||||||
|
| Some target ->
|
||||||
|
let result = serviceEnvExport target publicKey secretKey
|
||||||
|
match result.TryFind "content" with
|
||||||
|
| Some content -> printf "%s" (content.ToString())
|
||||||
|
| None -> ()
|
||||||
|
| None ->
|
||||||
|
eprintfn "%sError: service env export requires service ID%s" red reset
|
||||||
|
exit 1
|
||||||
|
| Some "delete" ->
|
||||||
|
match args.EnvTarget with
|
||||||
|
| Some target ->
|
||||||
|
if serviceEnvDelete target publicKey secretKey then
|
||||||
|
printfn "%sVault deleted for service %s%s" green target reset
|
||||||
|
else
|
||||||
|
eprintfn "%sError: Failed to delete vault%s" red reset
|
||||||
|
exit 1
|
||||||
|
| None ->
|
||||||
|
eprintfn "%sError: service env delete requires service ID%s" red reset
|
||||||
|
exit 1
|
||||||
|
| Some action ->
|
||||||
|
eprintfn "%sError: Unknown env action: %s%s" red action reset
|
||||||
|
eprintfn "Usage: un.fs service env <status|set|export|delete> <service_id>"
|
||||||
|
exit 1
|
||||||
|
| None ->
|
||||||
|
eprintfn "%sError: env action required%s" red reset
|
||||||
|
exit 1
|
||||||
|
|
||||||
let cmdExecute (args: Args) =
|
let cmdExecute (args: Args) =
|
||||||
let (publicKey, secretKey) = getApiKeys args.ApiKey
|
let (publicKey, secretKey) = getApiKeys args.ApiKey
|
||||||
let code = File.ReadAllText(args.SourceFile.Value)
|
let code = File.ReadAllText(args.SourceFile.Value)
|
||||||
|
|
@ -531,7 +692,10 @@ let cmdSnapshot (args: Args) =
|
||||||
let cmdService (args: Args) =
|
let cmdService (args: Args) =
|
||||||
let (publicKey, secretKey) = getApiKeys args.ApiKey
|
let (publicKey, secretKey) = getApiKeys args.ApiKey
|
||||||
|
|
||||||
if args.ServiceSnapshot.IsSome then
|
// Handle env subcommand
|
||||||
|
if args.EnvAction.IsSome then
|
||||||
|
cmdServiceEnv args publicKey secretKey
|
||||||
|
elif args.ServiceSnapshot.IsSome then
|
||||||
let mutable payload = []
|
let mutable payload = []
|
||||||
if args.ServiceSnapshotName.IsSome then
|
if args.ServiceSnapshotName.IsSome then
|
||||||
payload <- payload @ [("name", box args.ServiceSnapshotName.Value)]
|
payload <- payload @ [("name", box args.ServiceSnapshotName.Value)]
|
||||||
|
|
@ -630,8 +794,9 @@ let cmdService (args: Args) =
|
||||||
payload <- payload @ [("vcpu", box args.Vcpu)]
|
payload <- payload @ [("vcpu", box args.Vcpu)]
|
||||||
|
|
||||||
let result = apiRequest "/services" "POST" (Some payload) publicKey secretKey
|
let result = apiRequest "/services" "POST" (Some payload) publicKey secretKey
|
||||||
match result.TryFind "id" with
|
let serviceId = match result.TryFind "id" with | Some id -> Some (id.ToString()) | None -> None
|
||||||
| Some id -> printfn "%sService created: %s%s" green (id.ToString()) reset
|
match serviceId with
|
||||||
|
| Some id -> printfn "%sService created: %s%s" green id reset
|
||||||
| None -> printfn "%sService created%s" green reset
|
| None -> printfn "%sService created%s" green reset
|
||||||
match result.TryFind "name" with
|
match result.TryFind "name" with
|
||||||
| Some name -> printfn "Name: %s" (name.ToString())
|
| Some name -> printfn "Name: %s" (name.ToString())
|
||||||
|
|
@ -639,6 +804,17 @@ let cmdService (args: Args) =
|
||||||
match result.TryFind "url" with
|
match result.TryFind "url" with
|
||||||
| Some url -> printfn "URL: %s" (url.ToString())
|
| Some url -> printfn "URL: %s" (url.ToString())
|
||||||
| None -> ()
|
| None -> ()
|
||||||
|
|
||||||
|
// Auto-set vault if env vars were provided
|
||||||
|
match serviceId with
|
||||||
|
| Some id when args.Env.Count > 0 || args.EnvFile.IsSome ->
|
||||||
|
let envContent = buildEnvContent args.Env args.EnvFile
|
||||||
|
if not (String.IsNullOrEmpty(envContent)) then
|
||||||
|
if serviceEnvSet id envContent publicKey secretKey then
|
||||||
|
printfn "%sVault configured with environment variables%s" green reset
|
||||||
|
else
|
||||||
|
eprintfn "%sWarning: Failed to set vault%s" yellow reset
|
||||||
|
| _ -> ()
|
||||||
else
|
else
|
||||||
eprintfn "%sError: Specify --name to create a service, or use --list, --info, etc.%s" red reset
|
eprintfn "%sError: Specify --name to create a service, or use --list, --info, etc.%s" red reset
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -691,6 +867,9 @@ let parseArgs (argv: string[]) =
|
||||||
SnapshotName = None
|
SnapshotName = None
|
||||||
SnapshotShell = None
|
SnapshotShell = None
|
||||||
SnapshotPorts = None
|
SnapshotPorts = None
|
||||||
|
EnvFile = None
|
||||||
|
EnvAction = None
|
||||||
|
EnvTarget = None
|
||||||
KeyExtend = false
|
KeyExtend = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -701,10 +880,19 @@ let parseArgs (argv: string[]) =
|
||||||
| "service" -> args.Command <- Some "service"
|
| "service" -> args.Command <- Some "service"
|
||||||
| "snapshot" -> args.Command <- Some "snapshot"
|
| "snapshot" -> args.Command <- Some "snapshot"
|
||||||
| "key" -> args.Command <- Some "key"
|
| "key" -> args.Command <- Some "key"
|
||||||
|
| "env" when args.Command = Some "service" ->
|
||||||
|
// Parse: service env <action> <target>
|
||||||
|
if i + 1 < argv.Length && not (argv.[i + 1].StartsWith("-")) then
|
||||||
|
i <- i + 1
|
||||||
|
args.EnvAction <- Some argv.[i]
|
||||||
|
if i + 1 < argv.Length && not (argv.[i + 1].StartsWith("-")) then
|
||||||
|
i <- i + 1
|
||||||
|
args.EnvTarget <- Some argv.[i]
|
||||||
| "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i]
|
| "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i]
|
||||||
| "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i]
|
| "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i]
|
||||||
| "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int argv.[i]
|
| "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int argv.[i]
|
||||||
| "-e" | "--env" -> i <- i + 1; args.Env.Add(argv.[i])
|
| "-e" | "--env" -> i <- i + 1; args.Env.Add(argv.[i])
|
||||||
|
| "--env-file" -> i <- i + 1; args.EnvFile <- Some argv.[i]
|
||||||
| "-f" | "--files" -> i <- i + 1; args.Files.Add(argv.[i])
|
| "-f" | "--files" -> i <- i + 1; args.Files.Add(argv.[i])
|
||||||
| "-a" | "--artifacts" -> args.Artifacts <- true
|
| "-a" | "--artifacts" -> args.Artifacts <- true
|
||||||
| "-o" | "--output-dir" -> i <- i + 1; args.OutputDir <- Some argv.[i]
|
| "-o" | "--output-dir" -> i <- i + 1; args.OutputDir <- Some argv.[i]
|
||||||
|
|
@ -800,6 +988,7 @@ let printHelp () =
|
||||||
printfn "Usage: un [options] <source_file>"
|
printfn "Usage: un [options] <source_file>"
|
||||||
printfn " un session [options]"
|
printfn " un session [options]"
|
||||||
printfn " un service [options]"
|
printfn " un service [options]"
|
||||||
|
printfn " un service env <action> <service_id> [options]"
|
||||||
printfn " un key [options]"
|
printfn " un key [options]"
|
||||||
printfn ""
|
printfn ""
|
||||||
printfn "Execute options:"
|
printfn "Execute options:"
|
||||||
|
|
@ -825,13 +1014,21 @@ let printHelp () =
|
||||||
printfn " --info ID Get service details"
|
printfn " --info ID Get service details"
|
||||||
printfn " --logs ID Get all logs"
|
printfn " --logs ID Get all logs"
|
||||||
printfn " --tail ID Get last 9000 lines"
|
printfn " --tail ID Get last 9000 lines"
|
||||||
printfn " --freeze ID Freeze service"
|
printfn " --freeze ID Freeze service"
|
||||||
printfn " --unfreeze ID Unfreeze service"
|
printfn " --unfreeze ID Unfreeze service"
|
||||||
printfn " --destroy ID Destroy service"
|
printfn " --destroy ID Destroy service"
|
||||||
printfn " --execute ID Execute command in service"
|
printfn " --execute ID Execute command in service"
|
||||||
printfn " --command CMD Command to execute (with --execute)"
|
printfn " --command CMD Command to execute (with --execute)"
|
||||||
printfn " --dump-bootstrap ID Dump bootstrap script"
|
printfn " --dump-bootstrap ID Dump bootstrap script"
|
||||||
printfn " --dump-file FILE File to save bootstrap (with --dump-bootstrap)"
|
printfn " --dump-file FILE File to save bootstrap (with --dump-bootstrap)"
|
||||||
|
printfn " -e KEY=VALUE Set vault env var (with --name or env set)"
|
||||||
|
printfn " --env-file FILE Load vault vars from file"
|
||||||
|
printfn ""
|
||||||
|
printfn "Service env commands:"
|
||||||
|
printfn " env status ID Check vault status"
|
||||||
|
printfn " env set ID Set vault (use -e or --env-file)"
|
||||||
|
printfn " env export ID Export vault contents"
|
||||||
|
printfn " env delete ID Delete vault"
|
||||||
printfn ""
|
printfn ""
|
||||||
printfn "Key options:"
|
printfn "Key options:"
|
||||||
printfn " --extend Open browser to extend key"
|
printfn " --extend Open browser to extend key"
|
||||||
|
|
|
||||||
220
un.go
220
un.go
|
|
@ -231,6 +231,183 @@ func apiRequest(endpoint, method string, data map[string]interface{}, publicKey,
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func apiRequestText(endpoint, method, body, publicKey, secretKey string) (map[string]interface{}, error) {
|
||||||
|
url := APIBase + endpoint
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC authentication
|
||||||
|
timestamp := fmt.Sprintf("%d", time.Now().Unix())
|
||||||
|
signature := computeHMAC(secretKey, timestamp, method, endpoint, body)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+publicKey)
|
||||||
|
req.Header.Set("X-Timestamp", timestamp)
|
||||||
|
req.Header.Set("X-Signature", signature)
|
||||||
|
req.Header.Set("Content-Type", "text/plain")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d - %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Environment Secrets Vault Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const MaxEnvContentSize = 64 * 1024 // 64KB max env vault size
|
||||||
|
|
||||||
|
func serviceEnvStatus(serviceID, publicKey, secretKey string) {
|
||||||
|
result := apiRequest("/services/"+serviceID+"/env", "GET", nil, publicKey, secretKey)
|
||||||
|
hasVault, _ := result["has_vault"].(bool)
|
||||||
|
|
||||||
|
if !hasVault {
|
||||||
|
fmt.Println("Vault exists: no")
|
||||||
|
fmt.Println("Variable count: 0")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Vault exists: yes")
|
||||||
|
if count, ok := result["count"].(float64); ok {
|
||||||
|
fmt.Printf("Variable count: %d\n", int(count))
|
||||||
|
}
|
||||||
|
if updatedAt, ok := result["updated_at"].(float64); ok {
|
||||||
|
t := time.Unix(int64(updatedAt), 0)
|
||||||
|
fmt.Printf("Last updated: %s\n", t.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceEnvSet(serviceID, envContent, publicKey, secretKey string) bool {
|
||||||
|
if envContent == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: No environment content provided%s\n", Red, Reset)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(envContent) > MaxEnvContentSize {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: Environment content too large (max %d bytes)%s\n", Red, MaxEnvContentSize, Reset)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := apiRequestText("/services/"+serviceID+"/env", "PUT", envContent, publicKey, secretKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: %v%s\n", Red, err, Reset)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if count, ok := result["count"].(float64); ok {
|
||||||
|
plural := "s"
|
||||||
|
if int(count) == 1 {
|
||||||
|
plural = ""
|
||||||
|
}
|
||||||
|
fmt.Printf("%sEnvironment vault updated: %d variable%s%s\n", Green, int(count), plural, Reset)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%sEnvironment vault updated%s\n", Green, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if message, ok := result["message"].(string); ok && message != "" {
|
||||||
|
fmt.Println(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceEnvExport(serviceID, publicKey, secretKey string) {
|
||||||
|
result := apiRequest("/services/"+serviceID+"/env/export", "POST", map[string]interface{}{}, publicKey, secretKey)
|
||||||
|
if envContent, ok := result["env"].(string); ok && envContent != "" {
|
||||||
|
fmt.Print(envContent)
|
||||||
|
if !strings.HasSuffix(envContent, "\n") {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceEnvDelete(serviceID, publicKey, secretKey string) {
|
||||||
|
apiRequest("/services/"+serviceID+"/env", "DELETE", nil, publicKey, secretKey)
|
||||||
|
fmt.Printf("%sEnvironment vault deleted%s\n", Green, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEnvFile(filepath string) (string, error) {
|
||||||
|
content, err := os.ReadFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEnvContent(envs envVars, envFile string) string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
// Read from env file first
|
||||||
|
if envFile != "" {
|
||||||
|
content, err := readEnvFile(envFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: Env file not found: %s%s\n", Red, envFile, Reset)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
parts = append(parts, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add -e flags
|
||||||
|
for _, e := range envs {
|
||||||
|
if strings.Contains(e, "=") {
|
||||||
|
parts = append(parts, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdServiceEnv(action, target string, envs envVars, envFile, publicKey, secretKey string) {
|
||||||
|
if action == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: env action required (status, set, export, delete)%s\n", Red, Reset)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: Service ID required for env command%s\n", Red, Reset)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "status":
|
||||||
|
serviceEnvStatus(target, publicKey, secretKey)
|
||||||
|
case "set":
|
||||||
|
envContent := buildEnvContent(envs, envFile)
|
||||||
|
if envContent == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin%s\n", Red, Reset)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
serviceEnvSet(target, envContent, publicKey, secretKey)
|
||||||
|
case "export":
|
||||||
|
serviceEnvExport(target, publicKey, secretKey)
|
||||||
|
case "delete":
|
||||||
|
serviceEnvDelete(target, publicKey, secretKey)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: Unknown env action '%s'. Use: status, set, export, delete%s\n", Red, action, Reset)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts bool, outputDir, network string, vcpu int, publicKey, secretKey string) {
|
func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts bool, outputDir, network string, vcpu int, publicKey, secretKey string) {
|
||||||
code, err := os.ReadFile(sourceFile)
|
code, err := os.ReadFile(sourceFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -419,7 +596,7 @@ func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, session
|
||||||
fmt.Printf("%sSession created: %s%s\n", Green, result["id"], Reset)
|
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, publicKey, secretKey string) {
|
func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFiles, envs envVars, envFile, publicKey, secretKey string) {
|
||||||
if serviceSnapshot != "" {
|
if serviceSnapshot != "" {
|
||||||
payload := map[string]interface{}{}
|
payload := map[string]interface{}{}
|
||||||
if serviceSnapshotName != "" {
|
if serviceSnapshotName != "" {
|
||||||
|
|
@ -602,11 +779,18 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB
|
||||||
}
|
}
|
||||||
|
|
||||||
result := apiRequest("/services", "POST", payload, publicKey, secretKey)
|
result := apiRequest("/services", "POST", payload, publicKey, secretKey)
|
||||||
fmt.Printf("%sService created: %s%s\n", Green, result["id"], Reset)
|
serviceID := result["id"].(string)
|
||||||
|
fmt.Printf("%sService created: %s%s\n", Green, serviceID, Reset)
|
||||||
fmt.Printf("Name: %s\n", result["name"])
|
fmt.Printf("Name: %s\n", result["name"])
|
||||||
if url, ok := result["url"]; ok {
|
if url, ok := result["url"]; ok {
|
||||||
fmt.Printf("URL: %s\n", url)
|
fmt.Printf("URL: %s\n", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-set vault if -e or --env-file provided
|
||||||
|
envContent := buildEnvContent(envs, envFile)
|
||||||
|
if envContent != "" {
|
||||||
|
serviceEnvSet(serviceID, envContent, publicKey, secretKey)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -863,6 +1047,9 @@ func main() {
|
||||||
serviceBootstrapFile := serviceCmd.String("bootstrap-file", "", "Upload local file as bootstrap script")
|
serviceBootstrapFile := serviceCmd.String("bootstrap-file", "", "Upload local file as bootstrap script")
|
||||||
var serviceFiles inputFiles
|
var serviceFiles inputFiles
|
||||||
serviceCmd.Var(&serviceFiles, "f", "Input file")
|
serviceCmd.Var(&serviceFiles, "f", "Input file")
|
||||||
|
var serviceEnvs envVars
|
||||||
|
serviceCmd.Var(&serviceEnvs, "e", "Environment variable (KEY=VALUE)")
|
||||||
|
serviceEnvFile := serviceCmd.String("env-file", "", "Environment file (.env format)")
|
||||||
serviceList := serviceCmd.String("list", "", "List services")
|
serviceList := serviceCmd.String("list", "", "List services")
|
||||||
serviceInfo := serviceCmd.String("info", "", "Get service info")
|
serviceInfo := serviceCmd.String("info", "", "Get service info")
|
||||||
serviceLogs := serviceCmd.String("logs", "", "Get service logs")
|
serviceLogs := serviceCmd.String("logs", "", "Get service logs")
|
||||||
|
|
@ -919,6 +1106,33 @@ func main() {
|
||||||
return
|
return
|
||||||
|
|
||||||
case "service":
|
case "service":
|
||||||
|
// Check for "service env" subcommand
|
||||||
|
if len(os.Args) > 2 && os.Args[2] == "env" {
|
||||||
|
// Parse env subcommand: service env <action> <service_id> [options]
|
||||||
|
envCmd := flag.NewFlagSet("service env", flag.ExitOnError)
|
||||||
|
var envFlags envVars
|
||||||
|
envCmd.Var(&envFlags, "e", "Environment variable (KEY=VALUE)")
|
||||||
|
envFile := envCmd.String("env-file", "", "Environment file")
|
||||||
|
envKey := envCmd.String("k", "", "API key")
|
||||||
|
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sError: env action required (status, set, export, delete)%s\n", Red, Reset)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
action := os.Args[3]
|
||||||
|
target := ""
|
||||||
|
argsStart := 4
|
||||||
|
if len(os.Args) > 4 && !strings.HasPrefix(os.Args[4], "-") {
|
||||||
|
target = os.Args[4]
|
||||||
|
argsStart = 5
|
||||||
|
}
|
||||||
|
envCmd.Parse(os.Args[argsStart:])
|
||||||
|
|
||||||
|
publicKey, secretKey := getAPIKeys(*envKey)
|
||||||
|
cmdServiceEnv(action, target, envFlags, *envFile, publicKey, secretKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
serviceCmd.Parse(os.Args[2:])
|
serviceCmd.Parse(os.Args[2:])
|
||||||
publicKey, secretKey := getAPIKeys(*serviceKey)
|
publicKey, secretKey := getAPIKeys(*serviceKey)
|
||||||
net := *serviceNetwork
|
net := *serviceNetwork
|
||||||
|
|
@ -929,7 +1143,7 @@ func main() {
|
||||||
if vc == 0 {
|
if vc == 0 {
|
||||||
vc = *vcpu
|
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, publicKey, secretKey)
|
cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceBootstrapFile, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, *serviceSnapshot, *serviceRestore, *serviceSnapshotName, *serviceHot, net, vc, serviceFiles, serviceEnvs, *serviceEnvFile, publicKey, secretKey)
|
||||||
return
|
return
|
||||||
|
|
||||||
case "snapshot":
|
case "snapshot":
|
||||||
|
|
|
||||||
162
un.groovy
162
un.groovy
|
|
@ -55,6 +55,7 @@ def EXT_MAP = [
|
||||||
|
|
||||||
def API_BASE = 'https://api.unsandbox.com'
|
def API_BASE = 'https://api.unsandbox.com'
|
||||||
def PORTAL_BASE = 'https://unsandbox.com'
|
def PORTAL_BASE = 'https://unsandbox.com'
|
||||||
|
def MAX_ENV_CONTENT_SIZE = 65536
|
||||||
def BLUE = '\033[34m'
|
def BLUE = '\033[34m'
|
||||||
def RED = '\033[31m'
|
def RED = '\033[31m'
|
||||||
def GREEN = '\033[32m'
|
def GREEN = '\033[32m'
|
||||||
|
|
@ -109,6 +110,10 @@ class Args {
|
||||||
String snapshotShell = null
|
String snapshotShell = null
|
||||||
String snapshotPorts = null
|
String snapshotPorts = null
|
||||||
Boolean keyExtend = false
|
Boolean keyExtend = false
|
||||||
|
List<String> svcEnvs = []
|
||||||
|
String svcEnvFile = null
|
||||||
|
String envAction = null
|
||||||
|
String envTarget = null
|
||||||
}
|
}
|
||||||
|
|
||||||
import javax.crypto.Mac
|
import javax.crypto.Mac
|
||||||
|
|
@ -205,6 +210,117 @@ def apiRequest(endpoint, method, data, publicKey, secretKey) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def readEnvFile(filename) {
|
||||||
|
def file = new File(filename)
|
||||||
|
if (!file.exists()) {
|
||||||
|
System.err.println("${RED}Error: Cannot read env file: ${filename}${RESET}")
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return file.text
|
||||||
|
}
|
||||||
|
|
||||||
|
def buildEnvContent(envs, envFile) {
|
||||||
|
def result = new StringBuilder()
|
||||||
|
|
||||||
|
// Add -e flags
|
||||||
|
envs.each { env ->
|
||||||
|
result.append(env).append('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add content from env file
|
||||||
|
if (envFile) {
|
||||||
|
def content = readEnvFile(envFile)
|
||||||
|
content.split('\n').each { line ->
|
||||||
|
def trimmed = line.trim()
|
||||||
|
if (trimmed && !trimmed.startsWith('#')) {
|
||||||
|
result.append(trimmed).append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
def apiRequestText(endpoint, method, body, publicKey, secretKey) {
|
||||||
|
def tempFile = File.createTempFile('un_env_', '.txt')
|
||||||
|
try {
|
||||||
|
if (body) {
|
||||||
|
tempFile.text = body
|
||||||
|
}
|
||||||
|
|
||||||
|
def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}",
|
||||||
|
'-H', 'Content-Type: text/plain']
|
||||||
|
|
||||||
|
// Add HMAC authentication headers if secretKey is provided
|
||||||
|
if (secretKey) {
|
||||||
|
def timestamp = (System.currentTimeMillis() / 1000) as long
|
||||||
|
def message = "${timestamp}:${method}:${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 {
|
||||||
|
curlCmd += ['-H', "Authorization: Bearer ${publicKey}"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
curlCmd += ['--data-binary', "@${tempFile.absolutePath}"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def proc = curlCmd.execute()
|
||||||
|
def output = proc.text
|
||||||
|
proc.waitFor()
|
||||||
|
|
||||||
|
return proc.exitValue() == 0
|
||||||
|
} finally {
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def serviceEnvSet(serviceId, content, publicKey, secretKey) {
|
||||||
|
return apiRequestText("/services/${serviceId}/env", 'PUT', content, publicKey, secretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
def cmdServiceEnv(args) {
|
||||||
|
def (publicKey, secretKey) = getApiKeys(args.apiKey)
|
||||||
|
|
||||||
|
switch (args.envAction) {
|
||||||
|
case 'status':
|
||||||
|
def output = apiRequest("/services/${args.envTarget}/env", 'GET', null, publicKey, secretKey)
|
||||||
|
println(output)
|
||||||
|
break
|
||||||
|
case 'set':
|
||||||
|
if (!args.svcEnvs && !args.svcEnvFile) {
|
||||||
|
System.err.println("${RED}Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE${RESET}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
def content = buildEnvContent(args.svcEnvs, args.svcEnvFile)
|
||||||
|
if (content.length() > MAX_ENV_CONTENT_SIZE) {
|
||||||
|
System.err.println("${RED}Error: Environment content exceeds 64KB limit${RESET}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (serviceEnvSet(args.envTarget, content, publicKey, secretKey)) {
|
||||||
|
println("${GREEN}Vault updated for service ${args.envTarget}${RESET}")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'export':
|
||||||
|
def output = apiRequest("/services/${args.envTarget}/env/export", 'POST', null, publicKey, secretKey)
|
||||||
|
println(output)
|
||||||
|
break
|
||||||
|
case 'delete':
|
||||||
|
apiRequest("/services/${args.envTarget}/env", 'DELETE', null, publicKey, secretKey)
|
||||||
|
println("${GREEN}Vault deleted for service ${args.envTarget}${RESET}")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
System.err.println("${RED}Error: Unknown env action: ${args.envAction}${RESET}")
|
||||||
|
System.err.println("Usage: un service env <status|set|export|delete> <service_id>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def cmdExecute(args) {
|
def cmdExecute(args) {
|
||||||
def (publicKey, secretKey) = getApiKeys(args.apiKey)
|
def (publicKey, secretKey) = getApiKeys(args.apiKey)
|
||||||
def file = new File(args.sourceFile)
|
def file = new File(args.sourceFile)
|
||||||
|
|
@ -703,8 +819,10 @@ def cmdService(args) {
|
||||||
|
|
||||||
def output = apiRequest('/services', 'POST', json, publicKey, secretKey)
|
def output = apiRequest('/services', 'POST', json, publicKey, secretKey)
|
||||||
def idMatch = output =~ /"id":"([^"]+)"/
|
def idMatch = output =~ /"id":"([^"]+)"/
|
||||||
|
def serviceId = null
|
||||||
if (idMatch.find()) {
|
if (idMatch.find()) {
|
||||||
println("${GREEN}Service created: ${idMatch.group(1)}${RESET}")
|
serviceId = idMatch.group(1)
|
||||||
|
println("${GREEN}Service created: ${serviceId}${RESET}")
|
||||||
}
|
}
|
||||||
def nameMatch = output =~ /"name":"([^"]+)"/
|
def nameMatch = output =~ /"name":"([^"]+)"/
|
||||||
if (nameMatch.find()) {
|
if (nameMatch.find()) {
|
||||||
|
|
@ -714,6 +832,16 @@ def cmdService(args) {
|
||||||
if (urlMatch.find()) {
|
if (urlMatch.find()) {
|
||||||
println("URL: ${urlMatch.group(1)}")
|
println("URL: ${urlMatch.group(1)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-set vault if -e or --env-file provided
|
||||||
|
if (serviceId && (args.svcEnvs || args.svcEnvFile)) {
|
||||||
|
def envContent = buildEnvContent(args.svcEnvs, args.svcEnvFile)
|
||||||
|
if (envContent) {
|
||||||
|
if (serviceEnvSet(serviceId, envContent, publicKey, secretKey)) {
|
||||||
|
println("${GREEN}Vault configured for service ${serviceId}${RESET}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -732,6 +860,13 @@ def parseArgs(argv) {
|
||||||
case 'service':
|
case 'service':
|
||||||
args.command = 'service'
|
args.command = 'service'
|
||||||
break
|
break
|
||||||
|
case 'env':
|
||||||
|
// service env <action> <service_id>
|
||||||
|
if (args.command == 'service' && i + 2 < argv.size()) {
|
||||||
|
args.envAction = argv[++i]
|
||||||
|
args.envTarget = argv[++i]
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
args.command = 'snapshot'
|
args.command = 'snapshot'
|
||||||
break
|
break
|
||||||
|
|
@ -752,7 +887,14 @@ def parseArgs(argv) {
|
||||||
break
|
break
|
||||||
case '-e':
|
case '-e':
|
||||||
case '--env':
|
case '--env':
|
||||||
args.env << argv[++i]
|
def envVal = argv[++i]
|
||||||
|
args.env << envVal
|
||||||
|
if (args.command == 'service') {
|
||||||
|
args.svcEnvs << envVal
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case '--env-file':
|
||||||
|
args.svcEnvFile = argv[++i]
|
||||||
break
|
break
|
||||||
case '-f':
|
case '-f':
|
||||||
case '--files':
|
case '--files':
|
||||||
|
|
@ -877,6 +1019,7 @@ def printHelp() {
|
||||||
println '''Usage: groovy un.groovy [options] <source_file>
|
println '''Usage: groovy un.groovy [options] <source_file>
|
||||||
groovy un.groovy session [options]
|
groovy un.groovy session [options]
|
||||||
groovy un.groovy service [options]
|
groovy un.groovy service [options]
|
||||||
|
groovy un.groovy service env <action> <service_id> [options]
|
||||||
groovy un.groovy key [options]
|
groovy un.groovy key [options]
|
||||||
|
|
||||||
Execute options:
|
Execute options:
|
||||||
|
|
@ -899,6 +1042,8 @@ Service options:
|
||||||
--ports PORTS Comma-separated ports
|
--ports PORTS Comma-separated ports
|
||||||
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
|
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
|
||||||
--bootstrap CMD Bootstrap command
|
--bootstrap CMD Bootstrap command
|
||||||
|
-e KEY=VALUE Set env var in vault (when creating service)
|
||||||
|
--env-file FILE Load env vars from file
|
||||||
--info ID Get service details
|
--info ID Get service details
|
||||||
--logs ID Get all logs
|
--logs ID Get all logs
|
||||||
--tail ID Get last 9000 lines
|
--tail ID Get last 9000 lines
|
||||||
|
|
@ -910,6 +1055,12 @@ Service options:
|
||||||
--dump-bootstrap ID Dump bootstrap script
|
--dump-bootstrap ID Dump bootstrap script
|
||||||
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
|
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
|
||||||
|
|
||||||
|
Vault commands:
|
||||||
|
service env status <id> Check vault status
|
||||||
|
service env set <id> Set vault (-e KEY=VAL or --env-file FILE)
|
||||||
|
service env export <id> Export vault contents
|
||||||
|
service env delete <id> Delete vault
|
||||||
|
|
||||||
Key options:
|
Key options:
|
||||||
--extend Open browser to extend key
|
--extend Open browser to extend key
|
||||||
-k KEY API key to validate
|
-k KEY API key to validate
|
||||||
|
|
@ -923,7 +1074,12 @@ try {
|
||||||
if (args.command == 'session') {
|
if (args.command == 'session') {
|
||||||
cmdSession(args)
|
cmdSession(args)
|
||||||
} else if (args.command == 'service') {
|
} else if (args.command == 'service') {
|
||||||
cmdService(args)
|
// Check for env subcommand
|
||||||
|
if (args.envAction && args.envTarget) {
|
||||||
|
cmdServiceEnv(args)
|
||||||
|
} else {
|
||||||
|
cmdService(args)
|
||||||
|
}
|
||||||
} else if (args.command == 'snapshot') {
|
} else if (args.command == 'snapshot') {
|
||||||
cmdSnapshot(args)
|
cmdSnapshot(args)
|
||||||
} else if (args.command == 'key') {
|
} else if (args.command == 'key') {
|
||||||
|
|
|
||||||
159
un.hs
159
un.hs
|
|
@ -155,12 +155,15 @@ data ServiceOpts = ServiceOpts
|
||||||
, svcSnapshotName :: Maybe String
|
, svcSnapshotName :: Maybe String
|
||||||
, svcSnapshotFrom :: Maybe String
|
, svcSnapshotFrom :: Maybe String
|
||||||
, svcHot :: Bool
|
, svcHot :: Bool
|
||||||
|
, svcEnvs :: [(String, String)]
|
||||||
|
, svcEnvFile :: Maybe String
|
||||||
}
|
}
|
||||||
|
|
||||||
data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String
|
data ServiceAction = ServiceList | ServiceInfo String | ServiceLogs String
|
||||||
| ServiceSleep String | ServiceWake String | ServiceDestroy String
|
| ServiceSleep String | ServiceWake String | ServiceDestroy String
|
||||||
| ServiceExecute String String | ServiceDumpBootstrap String (Maybe String)
|
| ServiceExecute String String | ServiceDumpBootstrap String (Maybe String)
|
||||||
| ServiceCreate | ServiceSnapshot String | ServiceRestore String
|
| ServiceCreate | ServiceSnapshot String | ServiceRestore String
|
||||||
|
| ServiceEnv String (Maybe String) -- action, target
|
||||||
|
|
||||||
data SnapshotOpts = SnapshotOpts
|
data SnapshotOpts = SnapshotOpts
|
||||||
{ snapAction :: SnapshotAction
|
{ snapAction :: SnapshotAction
|
||||||
|
|
@ -229,7 +232,7 @@ parseSession args = return $ parseSessionArgs args defaultSessionOpts
|
||||||
parseService :: [String] -> IO ServiceOpts
|
parseService :: [String] -> IO ServiceOpts
|
||||||
parseService args = return $ parseServiceArgs args defaultServiceOpts
|
parseService args = return $ parseServiceArgs args defaultServiceOpts
|
||||||
where
|
where
|
||||||
defaultServiceOpts = ServiceOpts ServiceCreate Nothing Nothing Nothing Nothing Nothing Nothing Nothing [] Nothing Nothing False
|
defaultServiceOpts = ServiceOpts ServiceCreate Nothing Nothing Nothing Nothing Nothing Nothing Nothing [] Nothing Nothing False [] Nothing
|
||||||
parseServiceArgs [] opts = opts
|
parseServiceArgs [] opts = opts
|
||||||
parseServiceArgs ("--list":rest) opts = parseServiceArgs rest opts { svcAction = ServiceList }
|
parseServiceArgs ("--list":rest) opts = parseServiceArgs rest opts { svcAction = ServiceList }
|
||||||
parseServiceArgs ("--info":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceInfo id }
|
parseServiceArgs ("--info":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceInfo id }
|
||||||
|
|
@ -242,6 +245,8 @@ parseService args = return $ parseServiceArgs args defaultServiceOpts
|
||||||
parseServiceArgs ("--execute":id:"--command":cmd:rest) opts = parseServiceArgs rest opts { svcAction = ServiceExecute id cmd }
|
parseServiceArgs ("--execute":id:"--command":cmd:rest) opts = parseServiceArgs rest opts { svcAction = ServiceExecute id cmd }
|
||||||
parseServiceArgs ("--dump-bootstrap":id:file:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id (Just file) }
|
parseServiceArgs ("--dump-bootstrap":id:file:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id (Just file) }
|
||||||
parseServiceArgs ("--dump-bootstrap":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id Nothing }
|
parseServiceArgs ("--dump-bootstrap":id:rest) opts = parseServiceArgs rest opts { svcAction = ServiceDumpBootstrap id Nothing }
|
||||||
|
parseServiceArgs ("env":action:target:rest) opts = parseServiceArgs rest opts { svcAction = ServiceEnv action (Just target) }
|
||||||
|
parseServiceArgs ("env":action:rest) opts = parseServiceArgs rest opts { svcAction = ServiceEnv action Nothing }
|
||||||
parseServiceArgs ("--name":n:rest) opts = parseServiceArgs rest opts { svcName = Just n }
|
parseServiceArgs ("--name":n:rest) opts = parseServiceArgs rest opts { svcName = Just n }
|
||||||
parseServiceArgs ("--ports":p:rest) opts = parseServiceArgs rest opts { svcPorts = Just p }
|
parseServiceArgs ("--ports":p:rest) opts = parseServiceArgs rest opts { svcPorts = Just p }
|
||||||
parseServiceArgs ("--type":t:rest) opts = parseServiceArgs rest opts { svcType = Just t }
|
parseServiceArgs ("--type":t:rest) opts = parseServiceArgs rest opts { svcType = Just t }
|
||||||
|
|
@ -253,6 +258,10 @@ parseService args = return $ parseServiceArgs args defaultServiceOpts
|
||||||
parseServiceArgs ("-n":net:rest) opts = parseServiceArgs rest opts { svcNetwork = Just net }
|
parseServiceArgs ("-n":net:rest) opts = parseServiceArgs rest opts { svcNetwork = Just net }
|
||||||
parseServiceArgs ("-v":v:rest) opts = parseServiceArgs rest opts { svcVcpu = Just (read v) }
|
parseServiceArgs ("-v":v:rest) opts = parseServiceArgs rest opts { svcVcpu = Just (read v) }
|
||||||
parseServiceArgs ("-f":f:rest) opts = parseServiceArgs rest opts { svcFiles = svcFiles opts ++ [f] }
|
parseServiceArgs ("-f":f:rest) opts = parseServiceArgs rest opts { svcFiles = svcFiles opts ++ [f] }
|
||||||
|
parseServiceArgs ("-e":kv:rest) opts =
|
||||||
|
let (k, v) = span (/= '=') kv
|
||||||
|
in parseServiceArgs rest opts { svcEnvs = svcEnvs opts ++ [(k, drop 1 v)] }
|
||||||
|
parseServiceArgs ("--env-file":f:rest) opts = parseServiceArgs rest opts { svcEnvFile = Just f }
|
||||||
parseServiceArgs (_:rest) opts = parseServiceArgs rest opts
|
parseServiceArgs (_:rest) opts = parseServiceArgs rest opts
|
||||||
|
|
||||||
parseExecute :: [String] -> IO Command
|
parseExecute :: [String] -> IO Command
|
||||||
|
|
@ -298,6 +307,7 @@ printHelp = do
|
||||||
putStrLn " un.hs [options] <source_file> Execute code"
|
putStrLn " un.hs [options] <source_file> Execute code"
|
||||||
putStrLn " un.hs session [options] Manage sessions"
|
putStrLn " un.hs session [options] Manage sessions"
|
||||||
putStrLn " un.hs service [options] Manage services"
|
putStrLn " un.hs service [options] Manage services"
|
||||||
|
putStrLn " un.hs service env <action> <id> Manage service vault"
|
||||||
putStrLn " un.hs snapshot [options] Manage snapshots"
|
putStrLn " un.hs snapshot [options] Manage snapshots"
|
||||||
putStrLn " un.hs key [options] Validate/extend API key"
|
putStrLn " un.hs key [options] Validate/extend API key"
|
||||||
putStrLn ""
|
putStrLn ""
|
||||||
|
|
@ -309,6 +319,16 @@ printHelp = do
|
||||||
putStrLn " -n MODE Network mode (zerotrust|semitrusted)"
|
putStrLn " -n MODE Network mode (zerotrust|semitrusted)"
|
||||||
putStrLn " -v N vCPU count (1-8)"
|
putStrLn " -v N vCPU count (1-8)"
|
||||||
putStrLn ""
|
putStrLn ""
|
||||||
|
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 ""
|
||||||
|
putStrLn "Service env commands:"
|
||||||
|
putStrLn " env status ID Check vault status"
|
||||||
|
putStrLn " env set ID Set vault (use -e or --env-file)"
|
||||||
|
putStrLn " env export ID Export vault contents"
|
||||||
|
putStrLn " env delete ID Delete vault"
|
||||||
|
putStrLn ""
|
||||||
putStrLn "Session snapshot options:"
|
putStrLn "Session snapshot options:"
|
||||||
putStrLn " --snapshot ID Create snapshot of session"
|
putStrLn " --snapshot ID Create snapshot of session"
|
||||||
putStrLn " --restore ID Restore session from snapshot"
|
putStrLn " --restore ID Restore session from snapshot"
|
||||||
|
|
@ -483,6 +503,66 @@ serviceCommand opts = do
|
||||||
(_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/snapshots/" ++ snapshotId ++ "/restore") "{}"
|
(_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/snapshots/" ++ snapshotId ++ "/restore") "{}"
|
||||||
putStrLn $ green ++ "Service restored from snapshot" ++ reset
|
putStrLn $ green ++ "Service restored from snapshot" ++ reset
|
||||||
putStrLn stdout
|
putStrLn stdout
|
||||||
|
ServiceEnv action maybeTarget -> do
|
||||||
|
case action of
|
||||||
|
"status" -> case maybeTarget of
|
||||||
|
Just target -> do
|
||||||
|
result <- serviceEnvStatus target
|
||||||
|
let hasVault = "\"has_vault\":true" `isPrefixOf` dropWhile (/= 'h') result
|
||||||
|
if hasVault
|
||||||
|
then do
|
||||||
|
putStrLn $ green ++ "Vault: configured" ++ reset
|
||||||
|
case extractJsonString result "env_count" of
|
||||||
|
Just count -> putStrLn $ "Variables: " ++ count
|
||||||
|
Nothing -> return ()
|
||||||
|
case extractJsonString result "updated_at" of
|
||||||
|
Just updated -> putStrLn $ "Updated: " ++ updated
|
||||||
|
Nothing -> return ()
|
||||||
|
else putStrLn $ yellow ++ "Vault: not configured" ++ reset
|
||||||
|
Nothing -> do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: service env status requires service ID" ++ reset
|
||||||
|
exitFailure
|
||||||
|
"set" -> case maybeTarget of
|
||||||
|
Just target -> do
|
||||||
|
if null (svcEnvs opts) && svcEnvFile opts == Nothing
|
||||||
|
then do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: service env set requires -e or --env-file" ++ reset
|
||||||
|
exitFailure
|
||||||
|
else do
|
||||||
|
envContent <- buildEnvContent (svcEnvs opts) (svcEnvFile opts)
|
||||||
|
success <- serviceEnvSet target envContent
|
||||||
|
if success
|
||||||
|
then putStrLn $ green ++ "Vault updated for service " ++ target ++ reset
|
||||||
|
else do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: Failed to update vault" ++ reset
|
||||||
|
exitFailure
|
||||||
|
Nothing -> do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: service env set requires service ID" ++ reset
|
||||||
|
exitFailure
|
||||||
|
"export" -> case maybeTarget of
|
||||||
|
Just target -> do
|
||||||
|
result <- serviceEnvExport target
|
||||||
|
case extractJsonString result "content" of
|
||||||
|
Just content -> putStr content
|
||||||
|
Nothing -> return ()
|
||||||
|
Nothing -> do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: service env export requires service ID" ++ reset
|
||||||
|
exitFailure
|
||||||
|
"delete" -> case maybeTarget of
|
||||||
|
Just target -> do
|
||||||
|
success <- serviceEnvDelete target
|
||||||
|
if success
|
||||||
|
then putStrLn $ green ++ "Vault deleted for service " ++ target ++ reset
|
||||||
|
else do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: Failed to delete vault" ++ reset
|
||||||
|
exitFailure
|
||||||
|
Nothing -> do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: service env delete requires service ID" ++ reset
|
||||||
|
exitFailure
|
||||||
|
_ -> do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: Unknown env action: " ++ action ++ reset
|
||||||
|
hPutStrLn stderr "Usage: un.hs service env <status|set|export|delete> <service_id>"
|
||||||
|
exitFailure
|
||||||
ServiceCreate -> do
|
ServiceCreate -> do
|
||||||
case svcName opts of
|
case svcName opts of
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
|
|
@ -515,6 +595,18 @@ serviceCommand opts = do
|
||||||
putStrLn $ green ++ "Service created" ++ reset
|
putStrLn $ green ++ "Service created" ++ reset
|
||||||
putStrLn stdout
|
putStrLn stdout
|
||||||
|
|
||||||
|
-- Auto-set vault if env vars were provided
|
||||||
|
when (not (null (svcEnvs opts)) || svcEnvFile opts /= Nothing) $ do
|
||||||
|
case extractJsonString stdout "id" of
|
||||||
|
Just serviceId -> do
|
||||||
|
envContent <- buildEnvContent (svcEnvs opts) (svcEnvFile opts)
|
||||||
|
when (not (null envContent)) $ do
|
||||||
|
success <- serviceEnvSet serviceId envContent
|
||||||
|
if success
|
||||||
|
then putStrLn $ green ++ "Vault configured with environment variables" ++ reset
|
||||||
|
else hPutStrLn stderr $ yellow ++ "Warning: Failed to set vault" ++ reset
|
||||||
|
Nothing -> return ()
|
||||||
|
|
||||||
-- Check for clock drift error
|
-- Check for clock drift error
|
||||||
checkClockDriftError :: String -> IO ()
|
checkClockDriftError :: String -> IO ()
|
||||||
checkClockDriftError response = do
|
checkClockDriftError response = do
|
||||||
|
|
@ -575,6 +667,71 @@ curlDelete apiKey url = do
|
||||||
checkClockDriftError stdout
|
checkClockDriftError stdout
|
||||||
return (exitCode, stdout, stderr)
|
return (exitCode, stdout, stderr)
|
||||||
|
|
||||||
|
curlPut :: String -> String -> String -> IO (ExitCode, String, String)
|
||||||
|
curlPut apiKey url body = do
|
||||||
|
(publicKey, secretKey) <- getApiKeys
|
||||||
|
let path = drop (length "https://api.unsandbox.com") url
|
||||||
|
authHeaders <- buildAuthHeaders publicKey secretKey "PUT" path body
|
||||||
|
(exitCode, stdout, stderr) <- readProcessWithExitCode "curl"
|
||||||
|
([ "-s", "-X", "PUT"
|
||||||
|
, url
|
||||||
|
, "-H", "Content-Type: text/plain"
|
||||||
|
] ++ authHeaders ++ ["-d", body]) ""
|
||||||
|
checkClockDriftError stdout
|
||||||
|
return (exitCode, stdout, stderr)
|
||||||
|
|
||||||
|
-- Vault helper functions
|
||||||
|
maxEnvContentSize :: Int
|
||||||
|
maxEnvContentSize = 65536
|
||||||
|
|
||||||
|
readEnvFile :: String -> IO String
|
||||||
|
readEnvFile path = do
|
||||||
|
content <- readFile path
|
||||||
|
return content
|
||||||
|
|
||||||
|
buildEnvContent :: [(String, String)] -> Maybe String -> IO String
|
||||||
|
buildEnvContent envs maybeEnvFile = do
|
||||||
|
-- Add from -e flags
|
||||||
|
let envLines = map (\(k, v) -> k ++ "=" ++ v) envs
|
||||||
|
|
||||||
|
-- Add from --env-file
|
||||||
|
fileLines <- case maybeEnvFile of
|
||||||
|
Just path -> do
|
||||||
|
content <- readEnvFile path
|
||||||
|
return $ filter (not . null) $ filter (not . isPrefixOf "#") $ map (filter (/= '\r')) $ lines content
|
||||||
|
Nothing -> return []
|
||||||
|
|
||||||
|
return $ intercalate "\n" (envLines ++ fileLines)
|
||||||
|
|
||||||
|
serviceEnvStatus :: String -> IO String
|
||||||
|
serviceEnvStatus serviceId = do
|
||||||
|
apiKey <- getApiKey
|
||||||
|
(_, stdout, _) <- curlGet apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env")
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
serviceEnvSet :: String -> String -> IO Bool
|
||||||
|
serviceEnvSet serviceId envContent = do
|
||||||
|
if length envContent > maxEnvContentSize
|
||||||
|
then do
|
||||||
|
hPutStrLn stderr $ red ++ "Error: Env content exceeds maximum size of 64KB" ++ reset
|
||||||
|
return False
|
||||||
|
else do
|
||||||
|
apiKey <- getApiKey
|
||||||
|
(exitCode, _, _) <- curlPut apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env") envContent
|
||||||
|
return (exitCode == ExitSuccess)
|
||||||
|
|
||||||
|
serviceEnvExport :: String -> IO String
|
||||||
|
serviceEnvExport serviceId = do
|
||||||
|
apiKey <- getApiKey
|
||||||
|
(_, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env/export") "{}"
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
serviceEnvDelete :: String -> IO Bool
|
||||||
|
serviceEnvDelete serviceId = do
|
||||||
|
apiKey <- getApiKey
|
||||||
|
(exitCode, _, _) <- curlDelete apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env")
|
||||||
|
return (exitCode == ExitSuccess)
|
||||||
|
|
||||||
-- Get API keys from environment
|
-- Get API keys from environment
|
||||||
getApiKeys :: IO (String, Maybe String)
|
getApiKeys :: IO (String, Maybe String)
|
||||||
getApiKeys = do
|
getApiKeys = do
|
||||||
|
|
|
||||||
206
un.jl
206
un.jl
|
|
@ -159,6 +159,147 @@ function api_request(endpoint::String, public_key::String, secret_key::String; m
|
||||||
end
|
end
|
||||||
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()))
|
||||||
|
signature = compute_signature(secret_key, timestamp, "PUT", endpoint, body)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
"Authorization" => "Bearer $public_key",
|
||||||
|
"X-Timestamp" => string(timestamp),
|
||||||
|
"X-Signature" => signature,
|
||||||
|
"Content-Type" => "text/plain"
|
||||||
|
]
|
||||||
|
|
||||||
|
try
|
||||||
|
response = HTTP.put(url, headers, body, readtimeout=300)
|
||||||
|
return response.status >= 200 && response.status < 300
|
||||||
|
catch e
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
const MAX_ENV_CONTENT_SIZE = 65536
|
||||||
|
|
||||||
|
function read_env_file(path::String)::String
|
||||||
|
if !isfile(path)
|
||||||
|
println(stderr, "$(RED)Error: Env file not found: $path$(RESET)")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
return read(path, String)
|
||||||
|
end
|
||||||
|
|
||||||
|
function build_env_content(envs::Vector{String}, env_file::Union{String,Nothing})::String
|
||||||
|
lines = copy(envs)
|
||||||
|
if env_file !== nothing
|
||||||
|
content = read_env_file(env_file)
|
||||||
|
for line in split(content, '\n')
|
||||||
|
trimmed = strip(line)
|
||||||
|
if !isempty(trimmed) && !startswith(trimmed, "#")
|
||||||
|
push!(lines, trimmed)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return join(lines, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
function service_env_status(service_id::String, public_key::String, secret_key::String)
|
||||||
|
return api_request("/services/$service_id/env", public_key, secret_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
function service_env_set(service_id::String, env_content::String, public_key::String, secret_key::String)::Bool
|
||||||
|
if length(env_content) > MAX_ENV_CONTENT_SIZE
|
||||||
|
println(stderr, "$(RED)Error: Env content exceeds maximum size of 64KB$(RESET)")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return api_request_text("/services/$service_id/env", public_key, secret_key, env_content)
|
||||||
|
end
|
||||||
|
|
||||||
|
function service_env_export(service_id::String, public_key::String, secret_key::String)
|
||||||
|
return api_request("/services/$service_id/env/export", public_key, secret_key, method="POST", data=Dict())
|
||||||
|
end
|
||||||
|
|
||||||
|
function service_env_delete(service_id::String, public_key::String, secret_key::String)::Bool
|
||||||
|
try
|
||||||
|
api_request("/services/$service_id/env", public_key, secret_key, method="DELETE")
|
||||||
|
return true
|
||||||
|
catch
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function cmd_service_env(args)
|
||||||
|
(public_key, secret_key) = get_api_keys(args["api-key"])
|
||||||
|
|
||||||
|
action = get(args, "env-action", nothing)
|
||||||
|
target = get(args, "env-target", nothing)
|
||||||
|
|
||||||
|
if action == "status"
|
||||||
|
if target === nothing
|
||||||
|
println(stderr, "$(RED)Error: service env status requires service ID$(RESET)")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
result = service_env_status(target, public_key, secret_key)
|
||||||
|
has_vault = get(result, "has_vault", false)
|
||||||
|
if has_vault
|
||||||
|
println("$(GREEN)Vault: configured$(RESET)")
|
||||||
|
env_count = get(result, "env_count", nothing)
|
||||||
|
if env_count !== nothing
|
||||||
|
println("Variables: $env_count")
|
||||||
|
end
|
||||||
|
updated_at = get(result, "updated_at", nothing)
|
||||||
|
if updated_at !== nothing
|
||||||
|
println("Updated: $updated_at")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
println("$(YELLOW)Vault: not configured$(RESET)")
|
||||||
|
end
|
||||||
|
elseif action == "set"
|
||||||
|
if target === nothing
|
||||||
|
println(stderr, "$(RED)Error: service env set requires service ID$(RESET)")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
envs = something(args["vault-env"], String[])
|
||||||
|
env_file = get(args, "env-file", nothing)
|
||||||
|
if isempty(envs) && env_file === nothing
|
||||||
|
println(stderr, "$(RED)Error: service env set requires -e or --env-file$(RESET)")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
env_content = build_env_content(envs, env_file)
|
||||||
|
if service_env_set(target, env_content, public_key, secret_key)
|
||||||
|
println("$(GREEN)Vault updated for service $target$(RESET)")
|
||||||
|
else
|
||||||
|
println(stderr, "$(RED)Error: Failed to update vault$(RESET)")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
elseif action == "export"
|
||||||
|
if target === nothing
|
||||||
|
println(stderr, "$(RED)Error: service env export requires service ID$(RESET)")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
result = service_env_export(target, public_key, secret_key)
|
||||||
|
content = get(result, "content", nothing)
|
||||||
|
if content !== nothing
|
||||||
|
print(content)
|
||||||
|
end
|
||||||
|
elseif action == "delete"
|
||||||
|
if target === nothing
|
||||||
|
println(stderr, "$(RED)Error: service env delete requires service ID$(RESET)")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
if service_env_delete(target, public_key, secret_key)
|
||||||
|
println("$(GREEN)Vault deleted for service $target$(RESET)")
|
||||||
|
else
|
||||||
|
println(stderr, "$(RED)Error: Failed to delete vault$(RESET)")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
println(stderr, "$(RED)Error: Unknown env action: $action$(RESET)")
|
||||||
|
println(stderr, "Usage: un.jl service env <status|set|export|delete> <service_id>")
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function cmd_execute(args)
|
function cmd_execute(args)
|
||||||
(public_key, secret_key) = get_api_keys(args["api-key"])
|
(public_key, secret_key) = get_api_keys(args["api-key"])
|
||||||
|
|
||||||
|
|
@ -311,6 +452,12 @@ end
|
||||||
function cmd_service(args)
|
function cmd_service(args)
|
||||||
(public_key, secret_key) = get_api_keys(args["api-key"])
|
(public_key, secret_key) = get_api_keys(args["api-key"])
|
||||||
|
|
||||||
|
# Handle env subcommand
|
||||||
|
if get(args, "env-action", nothing) !== nothing
|
||||||
|
cmd_service_env(args)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if args["list"]
|
if args["list"]
|
||||||
result = api_request("/services", public_key, secret_key)
|
result = api_request("/services", public_key, secret_key)
|
||||||
services = get(result, "services", [])
|
services = get(result, "services", [])
|
||||||
|
|
@ -449,11 +596,26 @@ function cmd_service(args)
|
||||||
end
|
end
|
||||||
|
|
||||||
result = api_request("/services", public_key, secret_key, method="POST", data=payload)
|
result = api_request("/services", public_key, secret_key, method="POST", data=payload)
|
||||||
println("$(GREEN)Service created: $(get(result, "id", "N/A"))$(RESET)")
|
service_id = get(result, "id", nothing)
|
||||||
|
println("$(GREEN)Service created: $(something(service_id, "N/A"))$(RESET)")
|
||||||
println("Name: $(get(result, "name", "N/A"))")
|
println("Name: $(get(result, "name", "N/A"))")
|
||||||
if haskey(result, "url")
|
if haskey(result, "url")
|
||||||
println("URL: $(result["url"])")
|
println("URL: $(result["url"])")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Auto-set vault if env vars were provided
|
||||||
|
vault_envs = something(args["vault-env"], String[])
|
||||||
|
vault_env_file = get(args, "env-file", nothing)
|
||||||
|
if service_id !== nothing && (!isempty(vault_envs) || vault_env_file !== nothing)
|
||||||
|
env_content = build_env_content(vault_envs, vault_env_file)
|
||||||
|
if !isempty(env_content)
|
||||||
|
if service_env_set(service_id, env_content, public_key, secret_key)
|
||||||
|
println("$(GREEN)Vault configured with environment variables$(RESET)")
|
||||||
|
else
|
||||||
|
println("$(YELLOW)Warning: Failed to set vault$(RESET)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -674,6 +836,11 @@ function main()
|
||||||
"--files", "-f"
|
"--files", "-f"
|
||||||
help = "Add input file"
|
help = "Add input file"
|
||||||
action = :append_arg
|
action = :append_arg
|
||||||
|
"--vault-env", "-e"
|
||||||
|
help = "Environment variable for vault (KEY=VALUE)"
|
||||||
|
action = :append_arg
|
||||||
|
"--env-file"
|
||||||
|
help = "Load vault variables from file"
|
||||||
"--network", "-n"
|
"--network", "-n"
|
||||||
help = "Network mode"
|
help = "Network mode"
|
||||||
arg_type = String
|
arg_type = String
|
||||||
|
|
@ -699,6 +866,30 @@ function main()
|
||||||
help = "Dump bootstrap script from service"
|
help = "Dump bootstrap script from service"
|
||||||
"--dump-file"
|
"--dump-file"
|
||||||
help = "File to save bootstrap (with --dump-bootstrap)"
|
help = "File to save bootstrap (with --dump-bootstrap)"
|
||||||
|
"--env-action"
|
||||||
|
help = "Env action (status, set, export, delete)"
|
||||||
|
"--env-target"
|
||||||
|
help = "Service ID for env commands"
|
||||||
|
"--api-key", "-k"
|
||||||
|
help = "API key"
|
||||||
|
"env"
|
||||||
|
help = "Manage service environment vault"
|
||||||
|
action = :command
|
||||||
|
end
|
||||||
|
|
||||||
|
@add_arg_table! s["service"]["env"] begin
|
||||||
|
"action"
|
||||||
|
help = "Env action: status, set, export, delete"
|
||||||
|
required = true
|
||||||
|
"service_id"
|
||||||
|
help = "Service ID"
|
||||||
|
required = false
|
||||||
|
"-e"
|
||||||
|
help = "Environment variable (KEY=VALUE)"
|
||||||
|
action = :append_arg
|
||||||
|
dest_name = "vault-env"
|
||||||
|
"--env-file"
|
||||||
|
help = "Load vault variables from file"
|
||||||
"--api-key", "-k"
|
"--api-key", "-k"
|
||||||
help = "API key"
|
help = "API key"
|
||||||
end
|
end
|
||||||
|
|
@ -716,7 +907,18 @@ function main()
|
||||||
if args["%COMMAND%"] == "session"
|
if args["%COMMAND%"] == "session"
|
||||||
cmd_session(args["session"])
|
cmd_session(args["session"])
|
||||||
elseif args["%COMMAND%"] == "service"
|
elseif args["%COMMAND%"] == "service"
|
||||||
cmd_service(args["service"])
|
service_args = args["service"]
|
||||||
|
# Check if env subcommand was used
|
||||||
|
if get(service_args, "%COMMAND%", nothing) == "env"
|
||||||
|
env_args = service_args["env"]
|
||||||
|
# Copy env args to service args
|
||||||
|
service_args["env-action"] = get(env_args, "action", nothing)
|
||||||
|
service_args["env-target"] = get(env_args, "service_id", nothing)
|
||||||
|
service_args["vault-env"] = get(env_args, "vault-env", nothing)
|
||||||
|
service_args["env-file"] = get(env_args, "env-file", nothing)
|
||||||
|
service_args["api-key"] = get(env_args, "api-key", nothing)
|
||||||
|
end
|
||||||
|
cmd_service(service_args)
|
||||||
elseif args["%COMMAND%"] == "key"
|
elseif args["%COMMAND%"] == "key"
|
||||||
cmd_key(args["key"])
|
cmd_key(args["key"])
|
||||||
elseif args["source_file"] !== nothing
|
elseif args["source_file"] !== nothing
|
||||||
|
|
|
||||||
222
un.js
222
un.js
|
|
@ -189,6 +189,157 @@ function apiRequest(endpoint, method = "GET", data = null, publicKey = null, sec
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiRequestText(endpoint, method = "PUT", body = "", publicKey = null, secretKey = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(API_BASE + endpoint);
|
||||||
|
|
||||||
|
// Generate HMAC signature
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
const signatureInput = `${timestamp}:${method}:${endpoint}:${body}`;
|
||||||
|
const signature = crypto.createHmac('sha256', secretKey)
|
||||||
|
.update(signatureInput)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${publicKey}`,
|
||||||
|
'X-Timestamp': timestamp,
|
||||||
|
'X-Signature': signature,
|
||||||
|
'Content-Type': 'text/plain'
|
||||||
|
},
|
||||||
|
timeout: 300000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let responseBody = '';
|
||||||
|
res.on('data', chunk => responseBody += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(responseBody));
|
||||||
|
} catch (e) {
|
||||||
|
resolve(responseBody);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`${RED}Error: HTTP ${res.statusCode} - ${responseBody}${RESET}`);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error(`${RED}Error: ${e.message}${RESET}`);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
req.write(body);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Environment Secrets Vault Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const MAX_ENV_CONTENT_SIZE = 64 * 1024; // 64KB max env vault size
|
||||||
|
|
||||||
|
async function serviceEnvStatus(publicKey, secretKey, serviceId) {
|
||||||
|
const result = await apiRequest(`/services/${serviceId}/env`, "GET", null, publicKey, secretKey);
|
||||||
|
const hasVault = result.has_vault || false;
|
||||||
|
|
||||||
|
if (!hasVault) {
|
||||||
|
console.log("Vault exists: no");
|
||||||
|
console.log("Variable count: 0");
|
||||||
|
} else {
|
||||||
|
console.log("Vault exists: yes");
|
||||||
|
console.log(`Variable count: ${result.count || 0}`);
|
||||||
|
if (result.updated_at) {
|
||||||
|
const dt = new Date(result.updated_at * 1000);
|
||||||
|
console.log(`Last updated: ${dt.toISOString().replace('T', ' ').substring(0, 19)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serviceEnvSet(publicKey, secretKey, serviceId, envContent) {
|
||||||
|
if (!envContent || envContent.length === 0) {
|
||||||
|
console.error(`${RED}Error: No environment content provided${RESET}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envContent.length > MAX_ENV_CONTENT_SIZE) {
|
||||||
|
console.error(`${RED}Error: Environment content too large (max ${MAX_ENV_CONTENT_SIZE} bytes)${RESET}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiRequestText(`/services/${serviceId}/env`, "PUT", envContent, publicKey, secretKey);
|
||||||
|
if (result === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = result.count !== undefined ? result.count : -1;
|
||||||
|
if (count >= 0) {
|
||||||
|
console.log(`${GREEN}Environment vault updated: ${count} variable${count !== 1 ? 's' : ''}${RESET}`);
|
||||||
|
} else {
|
||||||
|
console.log(`${GREEN}Environment vault updated${RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.message) {
|
||||||
|
console.log(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serviceEnvExport(publicKey, secretKey, serviceId) {
|
||||||
|
const result = await apiRequest(`/services/${serviceId}/env/export`, "POST", {}, publicKey, secretKey);
|
||||||
|
const envContent = result.env || "";
|
||||||
|
if (envContent) {
|
||||||
|
process.stdout.write(envContent);
|
||||||
|
if (!envContent.endsWith('\n')) {
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serviceEnvDelete(publicKey, secretKey, serviceId) {
|
||||||
|
await apiRequest(`/services/${serviceId}/env`, "DELETE", null, publicKey, secretKey);
|
||||||
|
console.log(`${GREEN}Environment vault deleted${RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvFile(filepath) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filepath, 'utf-8');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${RED}Error: Env file not found: ${filepath}${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnvContent(envVars, envFile) {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Read from env file first
|
||||||
|
if (envFile) {
|
||||||
|
parts.push(readEnvFile(envFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add -e flags (these override/append to file)
|
||||||
|
if (envVars && envVars.length > 0) {
|
||||||
|
envVars.forEach(e => {
|
||||||
|
if (e.includes('=')) {
|
||||||
|
parts.push(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join('\n') : null;
|
||||||
|
}
|
||||||
|
|
||||||
function portalRequest(endpoint, method = "GET", data = null, publicKey = null, secretKey = null) {
|
function portalRequest(endpoint, method = "GET", data = null, publicKey = null, secretKey = null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = new URL(PORTAL_BASE + endpoint);
|
const url = new URL(PORTAL_BASE + endpoint);
|
||||||
|
|
@ -456,6 +607,43 @@ async function cmdSession(args) {
|
||||||
async function cmdService(args) {
|
async function cmdService(args) {
|
||||||
const { publicKey, secretKey } = getApiKeys(args.apiKey);
|
const { publicKey, secretKey } = getApiKeys(args.apiKey);
|
||||||
|
|
||||||
|
// Handle env subcommand: un.js service env <action> <id>
|
||||||
|
if (args.envSubcommand === 'env') {
|
||||||
|
const action = args.envAction;
|
||||||
|
const target = args.envTarget;
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
console.error(`${RED}Error: env action required (status, set, export, delete)${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
console.error(`${RED}Error: Service ID required for env command${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'status') {
|
||||||
|
await serviceEnvStatus(publicKey, secretKey, target);
|
||||||
|
return;
|
||||||
|
} else if (action === 'set') {
|
||||||
|
let envContent = buildEnvContent(args.env, args.envFile);
|
||||||
|
if (!envContent) {
|
||||||
|
console.error(`${RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await serviceEnvSet(publicKey, secretKey, target, envContent);
|
||||||
|
return;
|
||||||
|
} else if (action === 'export') {
|
||||||
|
await serviceEnvExport(publicKey, secretKey, target);
|
||||||
|
return;
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
await serviceEnvDelete(publicKey, secretKey, target);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.error(`${RED}Error: Unknown env action '${action}'. Use: status, set, export, delete${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (args.list) {
|
if (args.list) {
|
||||||
const result = await apiRequest("/services", "GET", null, publicKey, secretKey);
|
const result = await apiRequest("/services", "GET", null, publicKey, secretKey);
|
||||||
const services = result.services || [];
|
const services = result.services || [];
|
||||||
|
|
@ -578,9 +766,21 @@ async function cmdService(args) {
|
||||||
if (args.vcpu) payload.vcpu = args.vcpu;
|
if (args.vcpu) payload.vcpu = args.vcpu;
|
||||||
|
|
||||||
const result = await apiRequest("/services", "POST", payload, publicKey, secretKey);
|
const result = await apiRequest("/services", "POST", payload, publicKey, secretKey);
|
||||||
console.log(`${GREEN}Service created: ${result.id || 'N/A'}${RESET}`);
|
const createdId = result.id;
|
||||||
|
console.log(`${GREEN}Service created: ${createdId || 'N/A'}${RESET}`);
|
||||||
console.log(`Name: ${result.name || 'N/A'}`);
|
console.log(`Name: ${result.name || 'N/A'}`);
|
||||||
if (result.url) console.log(`URL: ${result.url}`);
|
if (result.url) console.log(`URL: ${result.url}`);
|
||||||
|
|
||||||
|
// Set environment vault if -e or --env-file provided
|
||||||
|
if (createdId) {
|
||||||
|
const envContent = buildEnvContent(args.env, args.envFile);
|
||||||
|
if (envContent) {
|
||||||
|
console.error(`${YELLOW}Setting environment vault...${RESET}`);
|
||||||
|
if (!await serviceEnvSet(publicKey, secretKey, createdId, envContent)) {
|
||||||
|
console.error(`${YELLOW}Warning: Failed to set environment vault${RESET}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -622,6 +822,10 @@ function parseArgs(argv) {
|
||||||
dumpFile: null,
|
dumpFile: null,
|
||||||
extend: false,
|
extend: false,
|
||||||
execShell: null,
|
execShell: null,
|
||||||
|
envFile: null,
|
||||||
|
envSubcommand: null,
|
||||||
|
envAction: null,
|
||||||
|
envTarget: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let i = 2;
|
let i = 2;
|
||||||
|
|
@ -631,6 +835,19 @@ function parseArgs(argv) {
|
||||||
if (arg === 'session' || arg === 'service' || arg === 'key') {
|
if (arg === 'session' || arg === 'service' || arg === 'key') {
|
||||||
args.command = arg;
|
args.command = arg;
|
||||||
i++;
|
i++;
|
||||||
|
// Check for env subcommand: service env <action> <target>
|
||||||
|
if (arg === 'service' && i < argv.length && argv[i] === 'env') {
|
||||||
|
args.envSubcommand = 'env';
|
||||||
|
i++;
|
||||||
|
if (i < argv.length && !argv[i].startsWith('-')) {
|
||||||
|
args.envAction = argv[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < argv.length && !argv[i].startsWith('-')) {
|
||||||
|
args.envTarget = argv[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (arg === '-e' && i + 1 < argv.length) {
|
} else if (arg === '-e' && i + 1 < argv.length) {
|
||||||
args.env.push(argv[++i]);
|
args.env.push(argv[++i]);
|
||||||
i++;
|
i++;
|
||||||
|
|
@ -726,6 +943,9 @@ function parseArgs(argv) {
|
||||||
} else if (arg === '--dump-file' && i + 1 < argv.length) {
|
} else if (arg === '--dump-file' && i + 1 < argv.length) {
|
||||||
args.dumpFile = argv[++i];
|
args.dumpFile = argv[++i];
|
||||||
i++;
|
i++;
|
||||||
|
} else if (arg === '--env-file' && i + 1 < argv.length) {
|
||||||
|
args.envFile = argv[++i];
|
||||||
|
i++;
|
||||||
} else if (arg === '--extend') {
|
} else if (arg === '--extend') {
|
||||||
args.extend = true;
|
args.extend = true;
|
||||||
i++;
|
i++;
|
||||||
|
|
|
||||||
194
un.kt
194
un.kt
|
|
@ -101,7 +101,10 @@ data class Args(
|
||||||
var serviceCommand: String? = null,
|
var serviceCommand: String? = null,
|
||||||
var serviceDumpBootstrap: String? = null,
|
var serviceDumpBootstrap: String? = null,
|
||||||
var serviceDumpFile: String? = null,
|
var serviceDumpFile: String? = null,
|
||||||
var keyExtend: Boolean = false
|
var keyExtend: Boolean = false,
|
||||||
|
var envFile: String? = null,
|
||||||
|
var envAction: String? = null,
|
||||||
|
var envTarget: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
|
@ -264,6 +267,12 @@ fun cmdSession(args: Args) {
|
||||||
fun cmdService(args: Args) {
|
fun cmdService(args: Args) {
|
||||||
val (publicKey, secretKey) = getApiKeys(args.apiKey)
|
val (publicKey, secretKey) = getApiKeys(args.apiKey)
|
||||||
|
|
||||||
|
// Handle env subcommand
|
||||||
|
if (args.envAction != null) {
|
||||||
|
cmdServiceEnv(args)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (args.serviceList) {
|
if (args.serviceList) {
|
||||||
val result = apiRequest("/services", "GET", null, publicKey, secretKey)
|
val result = apiRequest("/services", "GET", null, publicKey, secretKey)
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
|
@ -412,11 +421,24 @@ fun cmdService(args: Args) {
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = apiRequest("/services", "POST", payload, publicKey, secretKey)
|
val result = apiRequest("/services", "POST", payload, publicKey, secretKey)
|
||||||
println("${GREEN}Service created: ${result["id"] ?: "N/A"}${RESET}")
|
val serviceId = result["id"] as? String
|
||||||
|
println("${GREEN}Service created: ${serviceId ?: "N/A"}${RESET}")
|
||||||
println("Name: ${result["name"] ?: "N/A"}")
|
println("Name: ${result["name"] ?: "N/A"}")
|
||||||
if (result.containsKey("url")) {
|
if (result.containsKey("url")) {
|
||||||
println("URL: ${result["url"]}")
|
println("URL: ${result["url"]}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-set vault if env vars were provided
|
||||||
|
if (serviceId != null && (args.env.isNotEmpty() || args.envFile != null)) {
|
||||||
|
val envContent = buildEnvContent(args.env, args.envFile)
|
||||||
|
if (envContent.isNotEmpty()) {
|
||||||
|
if (serviceEnvSet(serviceId, envContent, publicKey, secretKey)) {
|
||||||
|
println("${GREEN}Vault configured with environment variables${RESET}")
|
||||||
|
} else {
|
||||||
|
println("${YELLOW}Warning: Failed to set vault${RESET}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -587,6 +609,153 @@ fun apiRequest(endpoint: String, method: String, data: Map<String, Any>?, public
|
||||||
return parseJson(response)
|
return parseJson(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun apiRequestText(endpoint: String, method: String, body: String, publicKey: String?, secretKey: String): Pair<Boolean, String> {
|
||||||
|
val timestamp = System.currentTimeMillis() / 1000
|
||||||
|
val signatureData = "$timestamp:$method:$endpoint:$body"
|
||||||
|
val signature = hmacSha256(secretKey, signatureData)
|
||||||
|
|
||||||
|
val url = URL(API_BASE + endpoint)
|
||||||
|
val connection = url.openConnection() as HttpURLConnection
|
||||||
|
|
||||||
|
connection.requestMethod = method
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer ${publicKey ?: secretKey}")
|
||||||
|
connection.setRequestProperty("X-Timestamp", timestamp.toString())
|
||||||
|
connection.setRequestProperty("X-Signature", signature)
|
||||||
|
connection.setRequestProperty("Content-Type", "text/plain")
|
||||||
|
connection.connectTimeout = 30000
|
||||||
|
connection.readTimeout = 300000
|
||||||
|
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.outputStream.use { it.write(body.toByteArray()) }
|
||||||
|
|
||||||
|
return if (connection.responseCode in 200..299) {
|
||||||
|
Pair(true, connection.inputStream.bufferedReader().readText())
|
||||||
|
} else {
|
||||||
|
Pair(false, connection.errorStream?.bufferedReader()?.readText() ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val MAX_ENV_CONTENT_SIZE = 65536
|
||||||
|
|
||||||
|
fun readEnvFile(path: String): String {
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.exists()) {
|
||||||
|
System.err.println("${RED}Error: Env file not found: $path${RESET}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
return file.readText()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildEnvContent(envs: List<String>, envFile: String?): String {
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
lines.addAll(envs)
|
||||||
|
if (envFile != null) {
|
||||||
|
val content = readEnvFile(envFile)
|
||||||
|
for (line in content.lines()) {
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) {
|
||||||
|
lines.add(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serviceEnvStatus(serviceId: String, publicKey: String?, secretKey: String): Map<String, Any> {
|
||||||
|
return apiRequest("/services/$serviceId/env", "GET", null, publicKey, secretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serviceEnvSet(serviceId: String, envContent: String, publicKey: String?, secretKey: String): Boolean {
|
||||||
|
if (envContent.length > MAX_ENV_CONTENT_SIZE) {
|
||||||
|
System.err.println("${RED}Error: Env content exceeds maximum size of 64KB${RESET}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val (success, _) = apiRequestText("/services/$serviceId/env", "PUT", envContent, publicKey, secretKey)
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serviceEnvExport(serviceId: String, publicKey: String?, secretKey: String): Map<String, Any> {
|
||||||
|
return apiRequest("/services/$serviceId/env/export", "POST", emptyMap(), publicKey, secretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serviceEnvDelete(serviceId: String, publicKey: String?, secretKey: String): Boolean {
|
||||||
|
return try {
|
||||||
|
apiRequest("/services/$serviceId/env", "DELETE", null, publicKey, secretKey)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cmdServiceEnv(args: Args) {
|
||||||
|
val (publicKey, secretKey) = getApiKeys(args.apiKey)
|
||||||
|
val action = args.envAction
|
||||||
|
val target = args.envTarget
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
"status" -> {
|
||||||
|
if (target == null) {
|
||||||
|
System.err.println("${RED}Error: service env status requires service ID${RESET}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
val result = serviceEnvStatus(target, publicKey, secretKey)
|
||||||
|
val hasVault = result["has_vault"] as? Boolean ?: false
|
||||||
|
if (hasVault) {
|
||||||
|
println("${GREEN}Vault: configured${RESET}")
|
||||||
|
val envCount = result["env_count"]
|
||||||
|
if (envCount != null) println("Variables: $envCount")
|
||||||
|
val updatedAt = result["updated_at"]
|
||||||
|
if (updatedAt != null) println("Updated: $updatedAt")
|
||||||
|
} else {
|
||||||
|
println("${YELLOW}Vault: not configured${RESET}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"set" -> {
|
||||||
|
if (target == null) {
|
||||||
|
System.err.println("${RED}Error: service env set requires service ID${RESET}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
if (args.env.isEmpty() && args.envFile == null) {
|
||||||
|
System.err.println("${RED}Error: service env set requires -e or --env-file${RESET}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
val envContent = buildEnvContent(args.env, args.envFile)
|
||||||
|
if (serviceEnvSet(target, envContent, publicKey, secretKey)) {
|
||||||
|
println("${GREEN}Vault updated for service $target${RESET}")
|
||||||
|
} else {
|
||||||
|
System.err.println("${RED}Error: Failed to update vault${RESET}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"export" -> {
|
||||||
|
if (target == null) {
|
||||||
|
System.err.println("${RED}Error: service env export requires service ID${RESET}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
val result = serviceEnvExport(target, publicKey, secretKey)
|
||||||
|
val content = result["content"] as? String
|
||||||
|
if (content != null) print(content)
|
||||||
|
}
|
||||||
|
"delete" -> {
|
||||||
|
if (target == null) {
|
||||||
|
System.err.println("${RED}Error: service env delete requires service ID${RESET}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
if (serviceEnvDelete(target, publicKey, secretKey)) {
|
||||||
|
println("${GREEN}Vault deleted for service $target${RESET}")
|
||||||
|
} else {
|
||||||
|
System.err.println("${RED}Error: Failed to delete vault${RESET}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
System.err.println("${RED}Error: Unknown env action: $action${RESET}")
|
||||||
|
System.err.println("Usage: kotlin UnKt service env <status|set|export|delete> <service_id>")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toJson(obj: Any?): String = when (obj) {
|
fun toJson(obj: Any?): String = when (obj) {
|
||||||
null -> "null"
|
null -> "null"
|
||||||
is String -> "\"${obj.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")}\""
|
is String -> "\"${obj.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")}\""
|
||||||
|
|
@ -748,6 +917,15 @@ fun parseArgs(args: Array<String>): Args {
|
||||||
"--dump-bootstrap" -> result.serviceDumpBootstrap = args[++i]
|
"--dump-bootstrap" -> result.serviceDumpBootstrap = args[++i]
|
||||||
"--dump-file" -> result.serviceDumpFile = args[++i]
|
"--dump-file" -> result.serviceDumpFile = args[++i]
|
||||||
"--extend" -> result.keyExtend = true
|
"--extend" -> result.keyExtend = true
|
||||||
|
"--env-file" -> result.envFile = args[++i]
|
||||||
|
"env" -> {
|
||||||
|
if (result.command == "service" && i + 1 < args.size) {
|
||||||
|
result.envAction = args[++i]
|
||||||
|
if (i + 1 < args.size && !args[i + 1].startsWith("-")) {
|
||||||
|
result.envTarget = args[++i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
if (args[i].startsWith("-")) {
|
if (args[i].startsWith("-")) {
|
||||||
System.err.println("${RED}Unknown option: ${args[i]}${RESET}")
|
System.err.println("${RED}Unknown option: ${args[i]}${RESET}")
|
||||||
|
|
@ -789,17 +967,25 @@ Service options:
|
||||||
--ports PORTS Comma-separated ports
|
--ports PORTS Comma-separated ports
|
||||||
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
|
--type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp)
|
||||||
--bootstrap CMD Bootstrap command
|
--bootstrap CMD Bootstrap command
|
||||||
|
-e KEY=VALUE Environment variable for vault
|
||||||
|
--env-file FILE Load vault variables from file
|
||||||
--info ID Get service details
|
--info ID Get service details
|
||||||
--logs ID Get all logs
|
--logs ID Get all logs
|
||||||
--tail ID Get last 9000 lines
|
--tail ID Get last 9000 lines
|
||||||
--freeze ID Freeze service
|
--freeze ID Freeze service
|
||||||
--unfreeze ID Unfreeze service
|
--unfreeze ID Unfreeze service
|
||||||
--destroy ID Destroy service
|
--destroy ID Destroy service
|
||||||
--execute ID Execute command in service
|
--execute ID Execute command in service
|
||||||
--command CMD Command to execute (with --execute)
|
--command CMD Command to execute (with --execute)
|
||||||
--dump-bootstrap ID Dump bootstrap script
|
--dump-bootstrap ID Dump bootstrap script
|
||||||
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
|
--dump-file FILE File to save bootstrap (with --dump-bootstrap)
|
||||||
|
|
||||||
|
Service env commands:
|
||||||
|
env status ID Show vault status
|
||||||
|
env set ID Set vault (-e KEY=VALUE or --env-file FILE)
|
||||||
|
env export ID Export vault contents
|
||||||
|
env delete ID Delete vault
|
||||||
|
|
||||||
Key options:
|
Key options:
|
||||||
--extend Open browser to extend key
|
--extend Open browser to extend key
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
|
|
|
||||||
131
un.lisp
131
un.lisp
|
|
@ -186,6 +186,52 @@
|
||||||
response))
|
response))
|
||||||
(delete-file tmp-file))))
|
(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)))
|
||||||
|
(unwind-protect
|
||||||
|
(destructuring-bind (public-key secret-key) (get-api-keys)
|
||||||
|
(let* ((auth-headers (build-auth-headers public-key secret-key "PUT" endpoint content))
|
||||||
|
(base-args (list "curl" "-s" "-X" "PUT"
|
||||||
|
(format nil "https://api.unsandbox.com~a" endpoint)
|
||||||
|
"-H" "Content-Type: text/plain"))
|
||||||
|
(response (run-curl (append base-args auth-headers (list "--data-binary" (format nil "@~a" tmp-file))))))
|
||||||
|
(check-clock-drift response)
|
||||||
|
response))
|
||||||
|
(delete-file tmp-file))))
|
||||||
|
|
||||||
|
(defun build-env-content (env-vars env-file)
|
||||||
|
"Build env content from list of env vars and env file"
|
||||||
|
(let ((lines '()))
|
||||||
|
;; Add env vars
|
||||||
|
(dolist (var env-vars)
|
||||||
|
(push var lines))
|
||||||
|
;; Add env file contents
|
||||||
|
(when (and env-file (probe-file env-file))
|
||||||
|
(with-open-file (stream env-file)
|
||||||
|
(loop for line = (read-line stream nil)
|
||||||
|
while line
|
||||||
|
do (let ((trimmed (string-trim '(#\Space #\Tab) line)))
|
||||||
|
(when (and (> (length trimmed) 0)
|
||||||
|
(not (char= (char trimmed 0) #\#)))
|
||||||
|
(push line lines))))))
|
||||||
|
(format nil "~{~a~^~%~}" (nreverse lines))))
|
||||||
|
|
||||||
|
(defun service-env-status (api-key service-id)
|
||||||
|
(format t "~a~%" (curl-get api-key (format nil "/services/~a/env" service-id))))
|
||||||
|
|
||||||
|
(defun service-env-set (api-key service-id content)
|
||||||
|
(format t "~a~%" (curl-put-text api-key (format nil "/services/~a/env" service-id) content)))
|
||||||
|
|
||||||
|
(defun service-env-export (api-key service-id)
|
||||||
|
(let* ((response (curl-post api-key (format nil "/services/~a/env/export" service-id) "{}"))
|
||||||
|
(content (parse-json-field response "content")))
|
||||||
|
(when content (format t "~a" content))))
|
||||||
|
|
||||||
|
(defun service-env-delete (api-key service-id)
|
||||||
|
(curl-delete api-key (format nil "/services/~a/env" service-id))
|
||||||
|
(format t "~aVault deleted: ~a~a~%" *green* service-id *reset*))
|
||||||
|
|
||||||
(defun get-api-keys ()
|
(defun get-api-keys ()
|
||||||
(let ((public-key (uiop:getenv "UNSANDBOX_PUBLIC_KEY"))
|
(let ((public-key (uiop:getenv "UNSANDBOX_PUBLIC_KEY"))
|
||||||
(secret-key (uiop:getenv "UNSANDBOX_SECRET_KEY"))
|
(secret-key (uiop:getenv "UNSANDBOX_SECRET_KEY"))
|
||||||
|
|
@ -252,7 +298,7 @@
|
||||||
(format t "~aSession created (WebSocket required)~a~%" *yellow* *reset*)
|
(format t "~aSession created (WebSocket required)~a~%" *yellow* *reset*)
|
||||||
(format t "~a~%" response))))))
|
(format t "~a~%" response))))))
|
||||||
|
|
||||||
(defun service-cmd (action id name ports bootstrap bootstrap-file service-type input-files)
|
(defun service-cmd (action id name ports bootstrap bootstrap-file service-type input-files env-vars env-file)
|
||||||
(let ((api-key (get-api-key)))
|
(let ((api-key (get-api-key)))
|
||||||
(cond
|
(cond
|
||||||
((string= action "list")
|
((string= action "list")
|
||||||
|
|
@ -294,6 +340,21 @@
|
||||||
(progn
|
(progn
|
||||||
(format *error-output* "~aError: Failed to fetch bootstrap (service not running or no bootstrap file)~a~%" *red* *reset*)
|
(format *error-output* "~aError: Failed to fetch bootstrap (service not running or no bootstrap file)~a~%" *red* *reset*)
|
||||||
(uiop:quit 1))))))
|
(uiop:quit 1))))))
|
||||||
|
;; Vault commands
|
||||||
|
((string= action "env-status")
|
||||||
|
(service-env-status api-key id))
|
||||||
|
((string= action "env-set")
|
||||||
|
(let ((content (build-env-content env-vars env-file)))
|
||||||
|
(if (> (length content) 0)
|
||||||
|
(service-env-set api-key id content)
|
||||||
|
(progn
|
||||||
|
(format *error-output* "~aError: No environment variables to set~a~%" *red* *reset*)
|
||||||
|
(uiop:quit 1)))))
|
||||||
|
((string= action "env-export")
|
||||||
|
(service-env-export api-key id))
|
||||||
|
((string= action "env-delete")
|
||||||
|
(service-env-delete api-key id))
|
||||||
|
;; Create service
|
||||||
((and (string= action "create") name)
|
((and (string= action "create") name)
|
||||||
(let* ((ports-json (if ports (format nil ",\"ports\":[~a]" ports) ""))
|
(let* ((ports-json (if ports (format nil ",\"ports\":[~a]" ports) ""))
|
||||||
(bootstrap-json (if bootstrap (format nil ",\"bootstrap\":\"~a\"" (escape-json bootstrap)) ""))
|
(bootstrap-json (if bootstrap (format nil ",\"bootstrap\":\"~a\"" (escape-json bootstrap)) ""))
|
||||||
|
|
@ -303,11 +364,17 @@
|
||||||
(type-json (if service-type (format nil ",\"service_type\":\"~a\"" service-type) ""))
|
(type-json (if service-type (format nil ",\"service_type\":\"~a\"" service-type) ""))
|
||||||
(input-files-json (build-input-files-json input-files))
|
(input-files-json (build-input-files-json input-files))
|
||||||
(json (format nil "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json))
|
(json (format nil "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json))
|
||||||
(response (curl-post api-key "/services" json)))
|
(response (curl-post api-key "/services" json))
|
||||||
|
(service-id (parse-json-field response "id")))
|
||||||
(format t "~aService created~a~%" *green* *reset*)
|
(format t "~aService created~a~%" *green* *reset*)
|
||||||
(format t "~a~%" response)))
|
(format t "~a~%" response)
|
||||||
|
;; Auto-set vault if env vars were provided
|
||||||
|
(let ((env-content (build-env-content env-vars env-file)))
|
||||||
|
(when (and service-id (> (length env-content) 0))
|
||||||
|
(format t "~aSetting vault for service...~a~%" *yellow* *reset*)
|
||||||
|
(service-env-set api-key service-id env-content)))))
|
||||||
(t
|
(t
|
||||||
(format t "Error: --name required to create service~%")
|
(format t "Error: --name required to create service, or use env subcommand~%")
|
||||||
(uiop:quit 1)))))
|
(uiop:quit 1)))))
|
||||||
|
|
||||||
(defun parse-json-field (json field)
|
(defun parse-json-field (json field)
|
||||||
|
|
@ -444,23 +511,53 @@
|
||||||
((string= (first args) "service")
|
((string= (first args) "service")
|
||||||
(cond
|
(cond
|
||||||
((and (> (length args) 1) (string= (second args) "--list"))
|
((and (> (length args) 1) (string= (second args) "--list"))
|
||||||
(service-cmd "list" nil nil nil nil nil nil nil))
|
(service-cmd "list" nil nil nil nil nil nil nil nil nil))
|
||||||
((and (> (length args) 2) (string= (second args) "--info"))
|
((and (> (length args) 2) (string= (second args) "--info"))
|
||||||
(service-cmd "info" (third args) nil nil nil nil nil nil))
|
(service-cmd "info" (third args) nil nil nil nil nil nil nil nil))
|
||||||
((and (> (length args) 2) (string= (second args) "--logs"))
|
((and (> (length args) 2) (string= (second args) "--logs"))
|
||||||
(service-cmd "logs" (third args) nil nil nil nil nil nil))
|
(service-cmd "logs" (third args) nil nil nil nil nil nil nil nil))
|
||||||
((and (> (length args) 2) (string= (second args) "--freeze"))
|
((and (> (length args) 2) (string= (second args) "--freeze"))
|
||||||
(service-cmd "sleep" (third args) nil nil nil nil nil nil))
|
(service-cmd "sleep" (third args) nil nil nil nil nil nil nil nil))
|
||||||
((and (> (length args) 2) (string= (second args) "--unfreeze"))
|
((and (> (length args) 2) (string= (second args) "--unfreeze"))
|
||||||
(service-cmd "wake" (third args) nil nil nil nil nil nil))
|
(service-cmd "wake" (third args) nil nil nil nil nil nil nil nil))
|
||||||
((and (> (length args) 2) (string= (second args) "--destroy"))
|
((and (> (length args) 2) (string= (second args) "--destroy"))
|
||||||
(service-cmd "destroy" (third args) nil nil nil nil nil nil))
|
(service-cmd "destroy" (third args) nil nil nil nil nil nil nil nil))
|
||||||
((and (> (length args) 3) (string= (second args) "--execute"))
|
((and (> (length args) 3) (string= (second args) "--execute"))
|
||||||
(service-cmd "execute" (third args) nil nil (fourth args) nil nil nil))
|
(service-cmd "execute" (third args) nil nil (fourth args) nil nil nil nil nil))
|
||||||
((and (> (length args) 3) (string= (second args) "--dump-bootstrap"))
|
((and (> (length args) 3) (string= (second args) "--dump-bootstrap"))
|
||||||
(service-cmd "dump-bootstrap" (third args) nil nil nil nil (fourth args) nil))
|
(service-cmd "dump-bootstrap" (third args) nil nil nil nil (fourth args) nil nil nil))
|
||||||
((and (> (length args) 2) (string= (second args) "--dump-bootstrap"))
|
((and (> (length args) 2) (string= (second args) "--dump-bootstrap"))
|
||||||
(service-cmd "dump-bootstrap" (third args) nil nil nil nil nil nil))
|
(service-cmd "dump-bootstrap" (third args) nil nil nil nil nil nil nil nil))
|
||||||
|
;; Service env subcommand: service env <action> <id> [options]
|
||||||
|
((and (> (length args) 1) (string= (second args) "env"))
|
||||||
|
(if (< (length args) 4)
|
||||||
|
(progn
|
||||||
|
(format *error-output* "Usage: un.lisp service env <status|set|export|delete> <service_id> [options]~%")
|
||||||
|
(uiop:quit 1))
|
||||||
|
(let* ((env-action (third args))
|
||||||
|
(service-id (fourth args))
|
||||||
|
(rest-args (if (> (length args) 4) (nthcdr 4 args) nil)))
|
||||||
|
(cond
|
||||||
|
((string= env-action "status")
|
||||||
|
(service-cmd "env-status" service-id nil nil nil nil nil nil nil nil))
|
||||||
|
((string= env-action "set")
|
||||||
|
;; Parse -e and --env-file from rest-args
|
||||||
|
(let ((env-vars nil)
|
||||||
|
(env-file nil))
|
||||||
|
(loop for i from 0 below (1- (length rest-args))
|
||||||
|
do (let ((opt (nth i rest-args))
|
||||||
|
(val (nth (1+ i) rest-args)))
|
||||||
|
(cond
|
||||||
|
((string= opt "-e") (push val env-vars))
|
||||||
|
((string= opt "--env-file") (setf env-file val)))))
|
||||||
|
(service-cmd "env-set" service-id nil nil nil nil nil nil (nreverse env-vars) env-file)))
|
||||||
|
((string= env-action "export")
|
||||||
|
(service-cmd "env-export" service-id nil nil nil nil nil nil nil nil))
|
||||||
|
((string= env-action "delete")
|
||||||
|
(service-cmd "env-delete" service-id nil nil nil nil nil nil nil nil))
|
||||||
|
(t
|
||||||
|
(format *error-output* "~aUnknown env action: ~a~a~%" *red* env-action *reset*)
|
||||||
|
(uiop:quit 1))))))
|
||||||
((and (> (length args) 2) (string= (second args) "--name"))
|
((and (> (length args) 2) (string= (second args) "--name"))
|
||||||
(let* ((name (third args))
|
(let* ((name (third args))
|
||||||
(rest-args (nthcdr 3 args))
|
(rest-args (nthcdr 3 args))
|
||||||
|
|
@ -468,6 +565,8 @@
|
||||||
(bootstrap nil)
|
(bootstrap nil)
|
||||||
(bootstrap-file nil)
|
(bootstrap-file nil)
|
||||||
(service-type nil)
|
(service-type nil)
|
||||||
|
(env-vars nil)
|
||||||
|
(env-file nil)
|
||||||
(input-files (parse-input-files rest-args)))
|
(input-files (parse-input-files rest-args)))
|
||||||
(loop for i from 0 below (1- (length rest-args))
|
(loop for i from 0 below (1- (length rest-args))
|
||||||
do (let ((opt (nth i rest-args))
|
do (let ((opt (nth i rest-args))
|
||||||
|
|
@ -476,8 +575,10 @@
|
||||||
((string= opt "--ports") (setf ports val))
|
((string= opt "--ports") (setf ports val))
|
||||||
((string= opt "--bootstrap") (setf bootstrap val))
|
((string= opt "--bootstrap") (setf bootstrap val))
|
||||||
((string= opt "--bootstrap-file") (setf bootstrap-file val))
|
((string= opt "--bootstrap-file") (setf bootstrap-file val))
|
||||||
((string= opt "--type") (setf service-type val)))))
|
((string= opt "--type") (setf service-type val))
|
||||||
(service-cmd "create" nil name ports bootstrap bootstrap-file service-type input-files)))
|
((string= opt "-e") (push val env-vars))
|
||||||
|
((string= opt "--env-file") (setf env-file val)))))
|
||||||
|
(service-cmd "create" nil name ports bootstrap bootstrap-file service-type input-files (nreverse env-vars) env-file)))
|
||||||
(t
|
(t
|
||||||
(format t "Error: Invalid service command~%")
|
(format t "Error: Invalid service command~%")
|
||||||
(uiop:quit 1))))
|
(uiop:quit 1))))
|
||||||
|
|
|
||||||
211
un.lua
211
un.lua
|
|
@ -201,6 +201,178 @@ local function api_request(endpoint, method, data, keys)
|
||||||
return json.decode(response)
|
return json.decode(response)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function api_request_text(endpoint, method, body, keys)
|
||||||
|
local url = API_BASE .. endpoint
|
||||||
|
local tmpfile = os.tmpname()
|
||||||
|
|
||||||
|
-- Generate timestamp and signature
|
||||||
|
local timestamp = tostring(os.time())
|
||||||
|
local path = endpoint
|
||||||
|
|
||||||
|
-- Create HMAC signature using openssl command
|
||||||
|
local message = timestamp .. ":" .. method .. ":" .. path .. ":" .. body
|
||||||
|
local msg_tmpfile = os.tmpname()
|
||||||
|
|
||||||
|
local f = io.open(msg_tmpfile, "w")
|
||||||
|
f:write(message)
|
||||||
|
f:close()
|
||||||
|
|
||||||
|
local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'"
|
||||||
|
local sig_handle = io.popen(hmac_cmd)
|
||||||
|
local signature = sig_handle:read("*a"):gsub("%s+$", "")
|
||||||
|
sig_handle:close()
|
||||||
|
os.remove(msg_tmpfile)
|
||||||
|
|
||||||
|
local data_file = os.tmpname()
|
||||||
|
local df = io.open(data_file, "w")
|
||||||
|
df:write(body)
|
||||||
|
df:close()
|
||||||
|
|
||||||
|
local cmd = "curl -s -X " .. method .. " " .. shell_escape(url) ..
|
||||||
|
" -H 'Authorization: Bearer " .. keys.public_key .. "'" ..
|
||||||
|
" -H 'X-Timestamp: " .. timestamp .. "'" ..
|
||||||
|
" -H 'X-Signature: " .. signature .. "'" ..
|
||||||
|
" -H 'Content-Type: text/plain'" ..
|
||||||
|
" -d @" .. shell_escape(data_file) ..
|
||||||
|
" -w '\\n%{http_code}' -o " .. shell_escape(tmpfile)
|
||||||
|
|
||||||
|
local handle = io.popen(cmd)
|
||||||
|
local http_code = handle:read("*a"):match("(%d+)$")
|
||||||
|
handle:close()
|
||||||
|
|
||||||
|
local file = io.open(tmpfile, "r")
|
||||||
|
local response = file:read("*all")
|
||||||
|
file:close()
|
||||||
|
os.remove(tmpfile)
|
||||||
|
os.remove(data_file)
|
||||||
|
|
||||||
|
if not http_code or tonumber(http_code) < 200 or tonumber(http_code) >= 300 then
|
||||||
|
return { error = "HTTP " .. (http_code or "000") .. " - " .. response }
|
||||||
|
end
|
||||||
|
|
||||||
|
return json.decode(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Environment Secrets Vault Functions
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local MAX_ENV_CONTENT_SIZE = 64 * 1024 -- 64KB max
|
||||||
|
|
||||||
|
local function service_env_status(service_id, keys)
|
||||||
|
local result = api_request("/services/" .. service_id .. "/env", "GET", nil, keys)
|
||||||
|
local has_vault = result.has_vault
|
||||||
|
|
||||||
|
if not has_vault then
|
||||||
|
print("Vault exists: no")
|
||||||
|
print("Variable count: 0")
|
||||||
|
else
|
||||||
|
print("Vault exists: yes")
|
||||||
|
print("Variable count: " .. (result.count or 0))
|
||||||
|
if result.updated_at then
|
||||||
|
print("Last updated: " .. os.date("%Y-%m-%d %H:%M:%S", result.updated_at))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function service_env_set(service_id, env_content, keys)
|
||||||
|
if not env_content or env_content == "" then
|
||||||
|
io.stderr:write(RED .. "Error: No environment content provided" .. RESET .. "\n")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if #env_content > MAX_ENV_CONTENT_SIZE then
|
||||||
|
io.stderr:write(RED .. "Error: Environment content too large (max " .. MAX_ENV_CONTENT_SIZE .. " bytes)" .. RESET .. "\n")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = api_request_text("/services/" .. service_id .. "/env", "PUT", env_content, keys)
|
||||||
|
|
||||||
|
if result.error then
|
||||||
|
io.stderr:write(RED .. "Error: " .. result.error .. RESET .. "\n")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local count = result.count or 0
|
||||||
|
local plural = count == 1 and "" or "s"
|
||||||
|
print(GREEN .. "Environment vault updated: " .. count .. " variable" .. plural .. RESET)
|
||||||
|
if result.message then print(result.message) end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function service_env_export(service_id, keys)
|
||||||
|
local result = api_request("/services/" .. service_id .. "/env/export", "POST", {}, keys)
|
||||||
|
local env_content = result.env
|
||||||
|
if env_content and env_content ~= "" then
|
||||||
|
io.write(env_content)
|
||||||
|
if not env_content:match("\n$") then print() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function service_env_delete(service_id, keys)
|
||||||
|
api_request("/services/" .. service_id .. "/env", "DELETE", nil, keys)
|
||||||
|
print(GREEN .. "Environment vault deleted" .. RESET)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function read_env_file_content(filepath)
|
||||||
|
local file = io.open(filepath, "r")
|
||||||
|
if not file then
|
||||||
|
io.stderr:write(RED .. "Error: Env file not found: " .. filepath .. RESET .. "\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
local content = file:read("*all")
|
||||||
|
file:close()
|
||||||
|
return content
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_env_content(envs, env_file)
|
||||||
|
local parts = {}
|
||||||
|
|
||||||
|
-- Read from env file first
|
||||||
|
if env_file and env_file ~= "" then
|
||||||
|
table.insert(parts, read_env_file_content(env_file))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add -e flags
|
||||||
|
for _, e in ipairs(envs) do
|
||||||
|
if e:find("=") then
|
||||||
|
table.insert(parts, e)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(parts, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cmd_service_env(action, target, envs, env_file, keys)
|
||||||
|
if not action or action == "" then
|
||||||
|
io.stderr:write(RED .. "Error: env action required (status, set, export, delete)" .. RESET .. "\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not target or target == "" then
|
||||||
|
io.stderr:write(RED .. "Error: Service ID required for env command" .. RESET .. "\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
if action == "status" then
|
||||||
|
service_env_status(target, keys)
|
||||||
|
elseif action == "set" then
|
||||||
|
local env_content = build_env_content(envs, env_file)
|
||||||
|
if env_content == "" then
|
||||||
|
io.stderr:write(RED .. "Error: No env content provided. Use -e KEY=VAL or --env-file" .. RESET .. "\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
service_env_set(target, env_content, keys)
|
||||||
|
elseif action == "export" then
|
||||||
|
service_env_export(target, keys)
|
||||||
|
elseif action == "delete" then
|
||||||
|
service_env_delete(target, keys)
|
||||||
|
else
|
||||||
|
io.stderr:write(RED .. "Error: Unknown env action '" .. action .. "'. Use: status, set, export, delete" .. RESET .. "\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function read_file(filename)
|
local function read_file(filename)
|
||||||
local file, err = io.open(filename, "rb")
|
local file, err = io.open(filename, "rb")
|
||||||
if not file then
|
if not file then
|
||||||
|
|
@ -648,9 +820,16 @@ local function cmd_service(options)
|
||||||
if options.vcpu then payload.vcpu = options.vcpu end
|
if options.vcpu then payload.vcpu = options.vcpu end
|
||||||
|
|
||||||
local result = api_request("/services", "POST", payload, keys)
|
local result = api_request("/services", "POST", payload, keys)
|
||||||
print(GREEN .. "Service created: " .. (result.id or "N/A") .. RESET)
|
local service_id = result.id
|
||||||
|
print(GREEN .. "Service created: " .. (service_id or "N/A") .. RESET)
|
||||||
print("Name: " .. (result.name or "N/A"))
|
print("Name: " .. (result.name or "N/A"))
|
||||||
if result.url then print("URL: " .. result.url) end
|
if result.url then print("URL: " .. result.url) end
|
||||||
|
|
||||||
|
-- Auto-set vault if -e or --env-file provided
|
||||||
|
local env_content = build_env_content(options.env or {}, options.env_file)
|
||||||
|
if env_content ~= "" and service_id then
|
||||||
|
service_env_set(service_id, env_content, keys)
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -693,7 +872,10 @@ local function main()
|
||||||
dump_bootstrap = nil,
|
dump_bootstrap = nil,
|
||||||
dump_file = nil,
|
dump_file = nil,
|
||||||
extend = false,
|
extend = false,
|
||||||
exec_shell = nil
|
exec_shell = nil,
|
||||||
|
env_file = nil,
|
||||||
|
env_action = nil,
|
||||||
|
env_target = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
local i = 1
|
local i = 1
|
||||||
|
|
@ -762,6 +944,23 @@ local function main()
|
||||||
elseif a == "--bootstrap-file" then
|
elseif a == "--bootstrap-file" then
|
||||||
i = i + 1
|
i = i + 1
|
||||||
options.bootstrap_file = arg[i]
|
options.bootstrap_file = arg[i]
|
||||||
|
elseif a == "--env-file" then
|
||||||
|
i = i + 1
|
||||||
|
options.env_file = arg[i]
|
||||||
|
elseif a == "env" then
|
||||||
|
-- Handle "service env <action> <target>" subcommand
|
||||||
|
if options.command == "service" then
|
||||||
|
i = i + 1
|
||||||
|
if i <= #arg then
|
||||||
|
options.env_action = arg[i]
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
if i <= #arg and not arg[i]:match("^%-") then
|
||||||
|
options.env_target = arg[i]
|
||||||
|
else
|
||||||
|
i = i - 1 -- back up if next arg is a flag
|
||||||
|
end
|
||||||
|
end
|
||||||
elseif a == "--info" then
|
elseif a == "--info" then
|
||||||
i = i + 1
|
i = i + 1
|
||||||
options.info = arg[i]
|
options.info = arg[i]
|
||||||
|
|
@ -807,7 +1006,13 @@ local function main()
|
||||||
if options.command == "session" then
|
if options.command == "session" then
|
||||||
cmd_session(options)
|
cmd_session(options)
|
||||||
elseif options.command == "service" then
|
elseif options.command == "service" then
|
||||||
cmd_service(options)
|
-- Check for "service env" subcommand
|
||||||
|
if options.env_action then
|
||||||
|
local keys = get_api_keys(options.api_key)
|
||||||
|
cmd_service_env(options.env_action, options.env_target, options.env, options.env_file, keys)
|
||||||
|
else
|
||||||
|
cmd_service(options)
|
||||||
|
end
|
||||||
elseif options.command == "key" then
|
elseif options.command == "key" then
|
||||||
cmd_key(options)
|
cmd_key(options)
|
||||||
elseif options.source_file then
|
elseif options.source_file then
|
||||||
|
|
|
||||||
142
un.m
142
un.m
|
|
@ -203,6 +203,93 @@ NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* dat
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API request with text/plain body (for vault)
|
||||||
|
NSDictionary* apiRequestPutText(NSString* endpoint, NSString* content, NSString* publicKey, NSString* secretKey) {
|
||||||
|
NSString* urlString = [API_BASE stringByAppendingString:endpoint];
|
||||||
|
NSURL* url = [NSURL URLWithString:urlString];
|
||||||
|
NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url];
|
||||||
|
[request setHTTPMethod:@"PUT"];
|
||||||
|
[request setTimeoutInterval:300];
|
||||||
|
[request setHTTPBody:[content dataUsingEncoding:NSUTF8StringEncoding]];
|
||||||
|
|
||||||
|
// Generate timestamp and signature
|
||||||
|
long timestamp = (long)[[NSDate date] timeIntervalSince1970];
|
||||||
|
NSString* signature = computeSignature(secretKey, timestamp, @"PUT", endpoint, content);
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
[request setValue:[@"Bearer " stringByAppendingString:publicKey] forHTTPHeaderField:@"Authorization"];
|
||||||
|
[request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"];
|
||||||
|
[request setValue:signature forHTTPHeaderField:@"X-Signature"];
|
||||||
|
[request setValue:@"text/plain" forHTTPHeaderField:@"Content-Type"];
|
||||||
|
|
||||||
|
NSHTTPURLResponse* response = nil;
|
||||||
|
NSError* error = nil;
|
||||||
|
NSData* responseData = [NSURLConnection sendSynchronousRequest:request
|
||||||
|
returningResponse:&response
|
||||||
|
error:&error];
|
||||||
|
|
||||||
|
if (error || ([response statusCode] != 200 && [response statusCode] != 201)) {
|
||||||
|
fprintf(stderr, "%sError: HTTP %ld%s\n",
|
||||||
|
[RED UTF8String], (long)[response statusCode], [RESET UTF8String]);
|
||||||
|
if (responseData) {
|
||||||
|
NSString* errMsg = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding];
|
||||||
|
fprintf(stderr, "%s\n", [errMsg UTF8String]);
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary* result = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build environment content from -e args and --env-file
|
||||||
|
NSString* buildEnvContent(NSArray* envVars, NSString* envFile) {
|
||||||
|
NSMutableArray* lines = [NSMutableArray array];
|
||||||
|
for (NSString* var in envVars) {
|
||||||
|
[lines addObject:var];
|
||||||
|
}
|
||||||
|
if (envFile && [[NSFileManager defaultManager] fileExistsAtPath:envFile]) {
|
||||||
|
NSString* fileContent = [NSString stringWithContentsOfFile:envFile encoding:NSUTF8StringEncoding error:nil];
|
||||||
|
for (NSString* line in [fileContent componentsSeparatedByString:@"\n"]) {
|
||||||
|
NSString* trimmed = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
|
||||||
|
if ([trimmed length] == 0 || [trimmed hasPrefix:@"#"]) continue;
|
||||||
|
[lines addObject:line];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [lines componentsJoinedByString:@"\n"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service vault functions
|
||||||
|
void serviceEnvStatus(NSString* serviceId, NSString* publicKey, NSString* secretKey) {
|
||||||
|
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId];
|
||||||
|
NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey);
|
||||||
|
NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
|
||||||
|
NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
|
||||||
|
printf("%s\n", [jsonString UTF8String]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void serviceEnvSet(NSString* serviceId, NSString* content, NSString* publicKey, NSString* secretKey) {
|
||||||
|
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId];
|
||||||
|
NSDictionary* result = apiRequestPutText(endpoint, content, publicKey, secretKey);
|
||||||
|
NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
|
||||||
|
NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
|
||||||
|
printf("%s\n", [jsonString UTF8String]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void serviceEnvExport(NSString* serviceId, NSString* publicKey, NSString* secretKey) {
|
||||||
|
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env/export", serviceId];
|
||||||
|
NSDictionary* result = apiRequest(endpoint, @"POST", nil, publicKey, secretKey);
|
||||||
|
if (result[@"content"]) {
|
||||||
|
printf("%s", [result[@"content"] UTF8String]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void serviceEnvDelete(NSString* serviceId, NSString* publicKey, NSString* secretKey) {
|
||||||
|
NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId];
|
||||||
|
apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey);
|
||||||
|
printf("%sVault deleted for: %s%s\n", [GREEN UTF8String], [serviceId UTF8String], [RESET UTF8String]);
|
||||||
|
}
|
||||||
|
|
||||||
void cmdExecute(NSArray* args) {
|
void cmdExecute(NSArray* args) {
|
||||||
NSString* publicKey, *secretKey;
|
NSString* publicKey, *secretKey;
|
||||||
getApiKeys(&publicKey, &secretKey);
|
getApiKeys(&publicKey, &secretKey);
|
||||||
|
|
@ -586,12 +673,54 @@ void cmdService(NSArray* args) {
|
||||||
NSString* network = nil;
|
NSString* network = nil;
|
||||||
int vcpu = 0;
|
int vcpu = 0;
|
||||||
NSMutableArray* inputFiles = [NSMutableArray array];
|
NSMutableArray* inputFiles = [NSMutableArray array];
|
||||||
|
NSMutableArray* envVars = [NSMutableArray array];
|
||||||
|
NSString* envFile = nil;
|
||||||
NSString* snapshotId = nil;
|
NSString* snapshotId = nil;
|
||||||
NSString* restoreId = nil;
|
NSString* restoreId = nil;
|
||||||
NSString* fromSnapshot = nil;
|
NSString* fromSnapshot = nil;
|
||||||
NSString* snapshotName = nil;
|
NSString* snapshotName = nil;
|
||||||
BOOL hotSnapshot = NO;
|
BOOL hotSnapshot = NO;
|
||||||
|
|
||||||
|
// Check for 'env' subcommand first
|
||||||
|
if ([args count] >= 1 && [args[0] isEqualToString:@"env"]) {
|
||||||
|
if ([args count] < 3) {
|
||||||
|
fprintf(stderr, "Usage: un.m service env <status|set|export|delete> <service_id> [options]\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
NSString* envAction = args[1];
|
||||||
|
NSString* envTarget = args[2];
|
||||||
|
|
||||||
|
// Parse remaining args for -e and --env-file
|
||||||
|
for (NSUInteger i = 3; i < [args count]; i++) {
|
||||||
|
NSString* arg = args[i];
|
||||||
|
if ([arg isEqualToString:@"-e"] && i + 1 < [args count]) {
|
||||||
|
[envVars addObject:args[++i]];
|
||||||
|
} else if ([arg isEqualToString:@"--env-file"] && i + 1 < [args count]) {
|
||||||
|
envFile = args[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([envAction isEqualToString:@"status"]) {
|
||||||
|
serviceEnvStatus(envTarget, publicKey, secretKey);
|
||||||
|
} else if ([envAction isEqualToString:@"set"]) {
|
||||||
|
NSString* content = buildEnvContent(envVars, envFile);
|
||||||
|
if ([content length] == 0) {
|
||||||
|
fprintf(stderr, "%sError: No environment variables to set%s\n", [RED UTF8String], [RESET UTF8String]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
serviceEnvSet(envTarget, content, publicKey, secretKey);
|
||||||
|
} else if ([envAction isEqualToString:@"export"]) {
|
||||||
|
serviceEnvExport(envTarget, publicKey, secretKey);
|
||||||
|
} else if ([envAction isEqualToString:@"delete"]) {
|
||||||
|
serviceEnvDelete(envTarget, publicKey, secretKey);
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "%sError: Unknown env action '%s'. Use status, set, export, or delete%s\n",
|
||||||
|
[RED UTF8String], [envAction UTF8String], [RESET UTF8String]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse arguments
|
// Parse arguments
|
||||||
for (NSUInteger i = 0; i < [args count]; i++) {
|
for (NSUInteger i = 0; i < [args count]; i++) {
|
||||||
NSString* arg = args[i];
|
NSString* arg = args[i];
|
||||||
|
|
@ -633,6 +762,10 @@ void cmdService(NSArray* args) {
|
||||||
bootstrapFile = args[++i];
|
bootstrapFile = args[++i];
|
||||||
} else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) {
|
} else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) {
|
||||||
[inputFiles addObject:args[++i]];
|
[inputFiles addObject:args[++i]];
|
||||||
|
} else if ([arg isEqualToString:@"-e"] && i + 1 < [args count]) {
|
||||||
|
[envVars addObject:args[++i]];
|
||||||
|
} else if ([arg isEqualToString:@"--env-file"] && i + 1 < [args count]) {
|
||||||
|
envFile = args[++i];
|
||||||
} else if ([arg isEqualToString:@"-n"] && i + 1 < [args count]) {
|
} else if ([arg isEqualToString:@"-n"] && i + 1 < [args count]) {
|
||||||
network = args[++i];
|
network = args[++i];
|
||||||
} else if ([arg isEqualToString:@"-v"] && i + 1 < [args count]) {
|
} else if ([arg isEqualToString:@"-v"] && i + 1 < [args count]) {
|
||||||
|
|
@ -815,10 +948,17 @@ void cmdService(NSArray* args) {
|
||||||
if (result[@"url"]) {
|
if (result[@"url"]) {
|
||||||
printf("URL: %s\n", [result[@"url"] UTF8String]);
|
printf("URL: %s\n", [result[@"url"] UTF8String]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-set vault if -e or --env-file were provided
|
||||||
|
NSString* envContent = buildEnvContent(envVars, envFile);
|
||||||
|
if ([envContent length] > 0 && result[@"id"]) {
|
||||||
|
printf("%sSetting vault for service...%s\n", [YELLOW UTF8String], [RESET UTF8String]);
|
||||||
|
serviceEnvSet(result[@"id"], envContent, publicKey, secretKey);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fprintf(stderr, "%sError: Specify --name to create a service, or use --list, --info, etc.%s\n",
|
fprintf(stderr, "%sError: Specify --name to create a service, or use --list, --info, env, etc.%s\n",
|
||||||
[RED UTF8String], [RESET UTF8String]);
|
[RED UTF8String], [RESET UTF8String]);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
226
un.ml
226
un.ml
|
|
@ -231,6 +231,151 @@ let curl_delete api_key endpoint =
|
||||||
check_clock_drift output;
|
check_clock_drift output;
|
||||||
output
|
output
|
||||||
|
|
||||||
|
let curl_put_text endpoint body =
|
||||||
|
let (public_key, secret_key) = get_api_keys () in
|
||||||
|
let auth_headers = build_auth_headers public_key secret_key "PUT" endpoint body in
|
||||||
|
let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.txt" (Random.int 999999) in
|
||||||
|
let oc = open_out tmp_file in
|
||||||
|
output_string oc body;
|
||||||
|
close_out oc;
|
||||||
|
let cmd = Printf.sprintf "curl -s -o /dev/null -w '%%{http_code}' -X PUT https://api.unsandbox.com%s -H 'Content-Type: text/plain'%s -d @%s"
|
||||||
|
endpoint auth_headers tmp_file in
|
||||||
|
let ic = Unix.open_process_in cmd in
|
||||||
|
let status = try input_line ic with End_of_file -> "0" in
|
||||||
|
let _ = Unix.close_process_in ic in
|
||||||
|
Sys.remove tmp_file;
|
||||||
|
let code = int_of_string (String.trim status) in
|
||||||
|
code >= 200 && code < 300
|
||||||
|
|
||||||
|
let max_env_content_size = 65536
|
||||||
|
|
||||||
|
let read_env_file path =
|
||||||
|
if not (Sys.file_exists path) then begin
|
||||||
|
Printf.fprintf stderr "%sError: Env file not found: %s%s\n" red path reset;
|
||||||
|
exit 1
|
||||||
|
end;
|
||||||
|
read_file path
|
||||||
|
|
||||||
|
let build_env_content envs env_file =
|
||||||
|
let lines = ref envs in
|
||||||
|
(match env_file with
|
||||||
|
| Some path ->
|
||||||
|
let content = read_env_file path in
|
||||||
|
let file_lines = String.split_on_char '\n' content in
|
||||||
|
List.iter (fun line ->
|
||||||
|
let trimmed = String.trim line in
|
||||||
|
if String.length trimmed > 0 && trimmed.[0] <> '#' then
|
||||||
|
lines := trimmed :: !lines
|
||||||
|
) file_lines
|
||||||
|
| None -> ());
|
||||||
|
String.concat "\n" (List.rev !lines)
|
||||||
|
|
||||||
|
let service_env_status service_id =
|
||||||
|
let api_key = get_api_key () in
|
||||||
|
curl_get api_key (Printf.sprintf "/services/%s/env" service_id)
|
||||||
|
|
||||||
|
let service_env_set service_id env_content =
|
||||||
|
if String.length env_content > max_env_content_size then begin
|
||||||
|
Printf.fprintf stderr "%sError: Env content exceeds maximum size of 64KB%s\n" red reset;
|
||||||
|
false
|
||||||
|
end else
|
||||||
|
curl_put_text (Printf.sprintf "/services/%s/env" service_id) env_content
|
||||||
|
|
||||||
|
let service_env_export service_id =
|
||||||
|
let api_key = get_api_key () in
|
||||||
|
let (public_key, secret_key) = get_api_keys () in
|
||||||
|
let endpoint = Printf.sprintf "/services/%s/env/export" service_id in
|
||||||
|
let auth_headers = build_auth_headers public_key secret_key "POST" endpoint "{}" 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 "{}";
|
||||||
|
close_out oc;
|
||||||
|
let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com%s -H 'Content-Type: application/json'%s -d @%s"
|
||||||
|
endpoint auth_headers tmp_file in
|
||||||
|
let ic = Unix.open_process_in cmd in
|
||||||
|
let rec read_all acc =
|
||||||
|
try let line = input_line ic in read_all (acc ^ line ^ "\n")
|
||||||
|
with End_of_file -> acc
|
||||||
|
in
|
||||||
|
let response = read_all "" in
|
||||||
|
let _ = Unix.close_process_in ic in
|
||||||
|
Sys.remove tmp_file;
|
||||||
|
response
|
||||||
|
|
||||||
|
let service_env_delete service_id =
|
||||||
|
let api_key = get_api_key () in
|
||||||
|
try
|
||||||
|
let _ = curl_delete api_key (Printf.sprintf "/services/%s/env" service_id) in
|
||||||
|
true
|
||||||
|
with _ -> false
|
||||||
|
|
||||||
|
let service_env_command action target envs env_file =
|
||||||
|
match action with
|
||||||
|
| "status" ->
|
||||||
|
(match target with
|
||||||
|
| Some sid ->
|
||||||
|
let response = service_env_status sid in
|
||||||
|
let has_vault = match extract_json_value response "has_vault" with
|
||||||
|
| Some "true" -> true
|
||||||
|
| _ -> false
|
||||||
|
in
|
||||||
|
if has_vault then begin
|
||||||
|
Printf.printf "%sVault: configured%s\n" green reset;
|
||||||
|
(match extract_json_value response "env_count" with
|
||||||
|
| Some c -> Printf.printf "Variables: %s\n" c
|
||||||
|
| None -> ());
|
||||||
|
(match extract_json_value response "updated_at" with
|
||||||
|
| Some u -> Printf.printf "Updated: %s\n" u
|
||||||
|
| None -> ())
|
||||||
|
end else
|
||||||
|
Printf.printf "%sVault: not configured%s\n" yellow reset
|
||||||
|
| None ->
|
||||||
|
Printf.fprintf stderr "%sError: service env status requires service ID%s\n" red reset;
|
||||||
|
exit 1)
|
||||||
|
| "set" ->
|
||||||
|
(match target with
|
||||||
|
| Some sid ->
|
||||||
|
if envs = [] && env_file = None then begin
|
||||||
|
Printf.fprintf stderr "%sError: service env set requires -e or --env-file%s\n" red reset;
|
||||||
|
exit 1
|
||||||
|
end;
|
||||||
|
let env_content = build_env_content envs env_file in
|
||||||
|
if service_env_set sid env_content then
|
||||||
|
Printf.printf "%sVault updated for service %s%s\n" green sid reset
|
||||||
|
else begin
|
||||||
|
Printf.fprintf stderr "%sError: Failed to update vault%s\n" red reset;
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
| None ->
|
||||||
|
Printf.fprintf stderr "%sError: service env set requires service ID%s\n" red reset;
|
||||||
|
exit 1)
|
||||||
|
| "export" ->
|
||||||
|
(match target with
|
||||||
|
| Some sid ->
|
||||||
|
let response = service_env_export sid in
|
||||||
|
(match extract_json_value response "content" with
|
||||||
|
| Some content -> Printf.printf "%s" (unescape_json content)
|
||||||
|
| None -> ())
|
||||||
|
| None ->
|
||||||
|
Printf.fprintf stderr "%sError: service env export requires service ID%s\n" red reset;
|
||||||
|
exit 1)
|
||||||
|
| "delete" ->
|
||||||
|
(match target with
|
||||||
|
| Some sid ->
|
||||||
|
if service_env_delete sid then
|
||||||
|
Printf.printf "%sVault deleted for service %s%s\n" green sid reset
|
||||||
|
else begin
|
||||||
|
Printf.fprintf stderr "%sError: Failed to delete vault%s\n" red reset;
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
| None ->
|
||||||
|
Printf.fprintf stderr "%sError: service env delete requires service ID%s\n" red reset;
|
||||||
|
exit 1)
|
||||||
|
| _ ->
|
||||||
|
Printf.fprintf stderr "%sError: Unknown env action: %s%s\n" red action reset;
|
||||||
|
Printf.fprintf stderr "Usage: un.ml service env <status|set|export|delete> <service_id>\n";
|
||||||
|
exit 1
|
||||||
|
|
||||||
(* Extract JSON value - simple regex-based parser *)
|
(* Extract JSON value - simple regex-based parser *)
|
||||||
let extract_json_value json_str key =
|
let extract_json_value json_str key =
|
||||||
let pattern = "\"" ^ key ^ "\"\\s*:\\s*\"\\([^\"]*\\)\"" in
|
let pattern = "\"" ^ key ^ "\"\\s*:\\s*\"\\([^\"]*\\)\"" in
|
||||||
|
|
@ -465,9 +610,17 @@ let session_command action shell network vcpu input_files =
|
||||||
| _ -> ()
|
| _ -> ()
|
||||||
|
|
||||||
(* Service command *)
|
(* Service command *)
|
||||||
let service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files =
|
let service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files envs env_file =
|
||||||
let api_key = get_api_key () in
|
let api_key = get_api_key () in
|
||||||
match action with
|
match action with
|
||||||
|
| "env" ->
|
||||||
|
service_env_command (match name with Some n -> n | None -> "") (match ports with Some p -> Some p | None -> None) envs env_file
|
||||||
|
| "env_cmd" ->
|
||||||
|
(match (name, ports) with
|
||||||
|
| (Some act, target) -> service_env_command act target envs env_file
|
||||||
|
| _ ->
|
||||||
|
Printf.fprintf stderr "Error: service env requires action\n";
|
||||||
|
exit 1)
|
||||||
| "list" ->
|
| "list" ->
|
||||||
let response = curl_get api_key "/services" in
|
let response = curl_get api_key "/services" in
|
||||||
Printf.printf "%s\n" response
|
Printf.printf "%s\n" response
|
||||||
|
|
@ -622,7 +775,17 @@ let service_command action name ports bootstrap bootstrap_file service_type netw
|
||||||
let _ = Unix.close_process_in ic in
|
let _ = Unix.close_process_in ic in
|
||||||
Sys.remove tmp_file;
|
Sys.remove tmp_file;
|
||||||
Printf.printf "%sService created%s\n" green reset;
|
Printf.printf "%sService created%s\n" green reset;
|
||||||
Printf.printf "%s\n" response
|
Printf.printf "%s\n" response;
|
||||||
|
(* Auto-set vault if env vars were provided *)
|
||||||
|
(match extract_json_value response "id" with
|
||||||
|
| Some service_id when envs <> [] || env_file <> None ->
|
||||||
|
let env_content = build_env_content envs env_file in
|
||||||
|
if String.length env_content > 0 then
|
||||||
|
if service_env_set service_id env_content then
|
||||||
|
Printf.printf "%sVault configured with environment variables%s\n" green reset
|
||||||
|
else
|
||||||
|
Printf.printf "%sWarning: Failed to set vault%s\n" yellow reset
|
||||||
|
| _ -> ())
|
||||||
| None ->
|
| None ->
|
||||||
Printf.fprintf stderr "Error: --name required to create service\n";
|
Printf.fprintf stderr "Error: --name required to create service\n";
|
||||||
exit 1)
|
exit 1)
|
||||||
|
|
@ -649,7 +812,10 @@ let () =
|
||||||
Printf.printf "Usage: un.ml [options] <source_file>\n";
|
Printf.printf "Usage: un.ml [options] <source_file>\n";
|
||||||
Printf.printf " un.ml session [options]\n";
|
Printf.printf " un.ml session [options]\n";
|
||||||
Printf.printf " un.ml service [options]\n";
|
Printf.printf " un.ml service [options]\n";
|
||||||
Printf.printf " un.ml key [--extend]\n";
|
Printf.printf " un.ml service env <action> <service_id>\n";
|
||||||
|
Printf.printf " un.ml key [--extend]\n\n";
|
||||||
|
Printf.printf "Service options: --name, --ports, --bootstrap, --bootstrap-file, -e KEY=VALUE, --env-file FILE\n";
|
||||||
|
Printf.printf "Service env commands: status, set, export, delete\n";
|
||||||
exit 1
|
exit 1
|
||||||
| "key" :: rest ->
|
| "key" :: rest ->
|
||||||
let extend = List.mem "--extend" rest in
|
let extend = List.mem "--extend" rest in
|
||||||
|
|
@ -675,28 +841,40 @@ let () =
|
||||||
parse_session "create" None None None rest
|
parse_session "create" None None None rest
|
||||||
| "service" :: rest ->
|
| "service" :: rest ->
|
||||||
let input_files = parse_input_files [] rest in
|
let input_files = parse_input_files [] rest in
|
||||||
let rec parse_service action name ports bootstrap bootstrap_file service_type network vcpu = function
|
let rec parse_envs acc = function
|
||||||
| [] -> service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files
|
| [] -> List.rev acc
|
||||||
| "--list" :: rest -> parse_service "list" name ports bootstrap bootstrap_file service_type network vcpu rest
|
| "-e" :: kv :: rest -> parse_envs (kv :: acc) rest
|
||||||
| "--info" :: id :: rest -> parse_service "info" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
|
| _ :: rest -> parse_envs acc rest
|
||||||
| "--logs" :: id :: rest -> parse_service "logs" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
|
|
||||||
| "--freeze" :: id :: rest -> parse_service "sleep" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
|
|
||||||
| "--unfreeze" :: id :: rest -> parse_service "wake" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
|
|
||||||
| "--destroy" :: id :: rest -> parse_service "destroy" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
|
|
||||||
| "--execute" :: id :: "--command" :: cmd :: rest -> parse_service "execute" (Some id) ports (Some cmd) bootstrap_file service_type network vcpu rest
|
|
||||||
| "--dump-bootstrap" :: id :: file :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap (Some file) service_type network vcpu rest
|
|
||||||
| "--dump-bootstrap" :: id :: rest -> parse_service "dump_bootstrap" (Some id) ports bootstrap bootstrap_file service_type network vcpu rest
|
|
||||||
| "--name" :: n :: rest -> parse_service "create" (Some n) ports bootstrap bootstrap_file service_type network vcpu rest
|
|
||||||
| "--ports" :: p :: rest -> parse_service action name (Some p) bootstrap bootstrap_file service_type network vcpu rest
|
|
||||||
| "--bootstrap" :: b :: rest -> parse_service action name ports (Some b) bootstrap_file service_type network vcpu rest
|
|
||||||
| "--bootstrap-file" :: f :: rest -> parse_service action name ports bootstrap (Some f) service_type network vcpu rest
|
|
||||||
| "--type" :: t :: rest -> parse_service action name ports bootstrap bootstrap_file (Some t) network vcpu rest
|
|
||||||
| "-n" :: net :: rest -> parse_service action name ports bootstrap bootstrap_file service_type (Some net) vcpu rest
|
|
||||||
| "-v" :: v :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network (Some (int_of_string v)) rest
|
|
||||||
| "-f" :: _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu rest (* skip -f, already parsed *)
|
|
||||||
| _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu rest
|
|
||||||
in
|
in
|
||||||
parse_service "create" None None None None None None None rest
|
let envs = parse_envs [] rest in
|
||||||
|
let rec parse_service action name ports bootstrap bootstrap_file service_type network vcpu env_file = function
|
||||||
|
| [] -> service_command action name ports bootstrap bootstrap_file service_type network vcpu input_files envs env_file
|
||||||
|
| "env" :: env_action :: target :: rest when not (String.length target > 0 && target.[0] = '-') ->
|
||||||
|
parse_service "env_cmd" (Some env_action) (Some target) bootstrap bootstrap_file service_type network vcpu env_file rest
|
||||||
|
| "env" :: env_action :: rest ->
|
||||||
|
parse_service "env_cmd" (Some env_action) None bootstrap bootstrap_file service_type network vcpu env_file rest
|
||||||
|
| "--list" :: rest -> parse_service "list" name ports bootstrap bootstrap_file service_type network vcpu env_file rest
|
||||||
|
| "--info" :: id :: rest -> parse_service "info" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest
|
||||||
|
| "--logs" :: id :: rest -> parse_service "logs" (Some id) ports bootstrap bootstrap_file service_type network vcpu env_file rest
|
||||||
|
| "--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
|
||||||
|
| "--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
|
||||||
|
| "--name" :: n :: rest -> parse_service "create" (Some n) ports bootstrap bootstrap_file service_type network vcpu env_file rest
|
||||||
|
| "--ports" :: p :: rest -> parse_service action name (Some p) bootstrap bootstrap_file service_type network vcpu env_file rest
|
||||||
|
| "--bootstrap" :: b :: rest -> parse_service action name ports (Some b) bootstrap_file service_type network vcpu env_file rest
|
||||||
|
| "--bootstrap-file" :: f :: rest -> parse_service action name ports bootstrap (Some f) service_type network vcpu env_file rest
|
||||||
|
| "--type" :: t :: rest -> parse_service action name ports bootstrap bootstrap_file (Some t) network vcpu env_file rest
|
||||||
|
| "-n" :: net :: rest -> parse_service action name ports bootstrap bootstrap_file service_type (Some net) vcpu env_file rest
|
||||||
|
| "-v" :: v :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network (Some (int_of_string v)) env_file rest
|
||||||
|
| "--env-file" :: f :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu (Some f) rest
|
||||||
|
| "-e" :: _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu env_file rest (* skip -e, already parsed *)
|
||||||
|
| "-f" :: _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu env_file rest (* skip -f, already parsed *)
|
||||||
|
| _ :: rest -> parse_service action name ports bootstrap bootstrap_file service_type network vcpu env_file rest
|
||||||
|
in
|
||||||
|
parse_service "create" None None None None None None None None rest
|
||||||
| args ->
|
| args ->
|
||||||
let rec parse_execute file env_vars artifacts out_dir network vcpu = function
|
let rec parse_execute file env_vars artifacts out_dir network vcpu = function
|
||||||
| [] -> execute_command file env_vars artifacts out_dir network vcpu
|
| [] -> execute_command file env_vars artifacts out_dir network vcpu
|
||||||
|
|
|
||||||
180
un.nim
180
un.nim
|
|
@ -123,6 +123,130 @@ proc execCurl(cmd: string): string =
|
||||||
stderr.writeLine(" Windows: w32tm /resync")
|
stderr.writeLine(" Windows: w32tm /resync")
|
||||||
quit(1)
|
quit(1)
|
||||||
|
|
||||||
|
proc execCurlPut(endpoint, body, publicKey, secretKey: string): bool =
|
||||||
|
let tmpFile = fmt"/tmp/un_nim_{epochTime().int mod 999999}.txt"
|
||||||
|
writeFile(tmpFile, body)
|
||||||
|
let authHeaders = buildAuthHeaders("PUT", endpoint, body, publicKey, secretKey)
|
||||||
|
let cmd = fmt"""curl -s -o /dev/null -w '%{{http_code}}' -X PUT '{API_BASE}{endpoint}' -H 'Content-Type: text/plain' {authHeaders} -d @{tmpFile}"""
|
||||||
|
let output = execProcess(cmd).strip()
|
||||||
|
removeFile(tmpFile)
|
||||||
|
try:
|
||||||
|
let status = parseInt(output)
|
||||||
|
return status >= 200 and status < 300
|
||||||
|
except:
|
||||||
|
return false
|
||||||
|
|
||||||
|
const MAX_ENV_CONTENT_SIZE = 65536
|
||||||
|
|
||||||
|
proc readEnvFile(path: string): string =
|
||||||
|
if not fileExists(path):
|
||||||
|
stderr.writeLine(RED & "Error: Env file not found: " & path & RESET)
|
||||||
|
quit(1)
|
||||||
|
return readFile(path)
|
||||||
|
|
||||||
|
proc buildEnvContent(envs: seq[string], envFile: string): string =
|
||||||
|
var lines: seq[string] = envs
|
||||||
|
if envFile != "":
|
||||||
|
let content = readEnvFile(envFile)
|
||||||
|
for line in content.splitLines():
|
||||||
|
let trimmed = line.strip()
|
||||||
|
if trimmed.len > 0 and not trimmed.startsWith("#"):
|
||||||
|
lines.add(trimmed)
|
||||||
|
return lines.join("\n")
|
||||||
|
|
||||||
|
proc serviceEnvStatus(serviceId, publicKey, secretKey: string): string =
|
||||||
|
let path = fmt"/services/{serviceId}/env"
|
||||||
|
let authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey)
|
||||||
|
let cmd = fmt"""curl -s -X GET '{API_BASE}/services/{serviceId}/env' {authHeaders}"""
|
||||||
|
return execCurl(cmd)
|
||||||
|
|
||||||
|
proc serviceEnvSet(serviceId, envContent, publicKey, secretKey: string): bool =
|
||||||
|
if envContent.len > MAX_ENV_CONTENT_SIZE:
|
||||||
|
stderr.writeLine(RED & "Error: Env content exceeds maximum size of 64KB" & RESET)
|
||||||
|
return false
|
||||||
|
return execCurlPut(fmt"/services/{serviceId}/env", envContent, publicKey, secretKey)
|
||||||
|
|
||||||
|
proc serviceEnvExport(serviceId, publicKey, secretKey: string): string =
|
||||||
|
let path = fmt"/services/{serviceId}/env/export"
|
||||||
|
let authHeaders = buildAuthHeaders("POST", path, "{}", publicKey, secretKey)
|
||||||
|
let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{serviceId}/env/export' -H 'Content-Type: application/json' {authHeaders} -d '{{}}'"""
|
||||||
|
return execCurl(cmd)
|
||||||
|
|
||||||
|
proc serviceEnvDelete(serviceId, publicKey, secretKey: string): bool =
|
||||||
|
let path = fmt"/services/{serviceId}/env"
|
||||||
|
let authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey)
|
||||||
|
let cmd = fmt"""curl -s -o /dev/null -w '%{{http_code}}' -X DELETE '{API_BASE}/services/{serviceId}/env' {authHeaders}"""
|
||||||
|
let output = execProcess(cmd).strip()
|
||||||
|
try:
|
||||||
|
let status = parseInt(output)
|
||||||
|
return status >= 200 and status < 300
|
||||||
|
except:
|
||||||
|
return false
|
||||||
|
|
||||||
|
proc extractJsonField(response, field: string): string =
|
||||||
|
let fieldStart = response.find("\"" & field & "\":\"")
|
||||||
|
if fieldStart >= 0:
|
||||||
|
let start = fieldStart + field.len + 4
|
||||||
|
var endPos = start
|
||||||
|
while endPos < response.len:
|
||||||
|
if response[endPos] == '"' and (endPos == 0 or response[endPos-1] != '\\'):
|
||||||
|
break
|
||||||
|
inc endPos
|
||||||
|
if endPos > start:
|
||||||
|
return response[start..<endPos]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
proc cmdServiceEnv(action, target: string, envs: seq[string], envFile, publicKey, secretKey: string) =
|
||||||
|
case action
|
||||||
|
of "status":
|
||||||
|
if target == "":
|
||||||
|
stderr.writeLine(RED & "Error: service env status requires service ID" & RESET)
|
||||||
|
quit(1)
|
||||||
|
let response = serviceEnvStatus(target, publicKey, secretKey)
|
||||||
|
if response.contains("\"has_vault\":true"):
|
||||||
|
echo GREEN & "Vault: configured" & RESET
|
||||||
|
let envCount = extractJsonField(response, "env_count")
|
||||||
|
if envCount != "": echo "Variables: " & envCount
|
||||||
|
let updatedAt = extractJsonField(response, "updated_at")
|
||||||
|
if updatedAt != "": echo "Updated: " & updatedAt
|
||||||
|
else:
|
||||||
|
echo YELLOW & "Vault: not configured" & RESET
|
||||||
|
of "set":
|
||||||
|
if target == "":
|
||||||
|
stderr.writeLine(RED & "Error: service env set requires service ID" & RESET)
|
||||||
|
quit(1)
|
||||||
|
if envs.len == 0 and envFile == "":
|
||||||
|
stderr.writeLine(RED & "Error: service env set requires -e or --env-file" & RESET)
|
||||||
|
quit(1)
|
||||||
|
let envContent = buildEnvContent(envs, envFile)
|
||||||
|
if serviceEnvSet(target, envContent, publicKey, secretKey):
|
||||||
|
echo GREEN & "Vault updated for service " & target & RESET
|
||||||
|
else:
|
||||||
|
stderr.writeLine(RED & "Error: Failed to update vault" & RESET)
|
||||||
|
quit(1)
|
||||||
|
of "export":
|
||||||
|
if target == "":
|
||||||
|
stderr.writeLine(RED & "Error: service env export requires service ID" & RESET)
|
||||||
|
quit(1)
|
||||||
|
let response = serviceEnvExport(target, publicKey, secretKey)
|
||||||
|
let content = extractJsonField(response, "content")
|
||||||
|
if content != "":
|
||||||
|
var output = content.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\\"", "\"").replace("\\\\", "\\")
|
||||||
|
stdout.write(output)
|
||||||
|
of "delete":
|
||||||
|
if target == "":
|
||||||
|
stderr.writeLine(RED & "Error: service env delete requires service ID" & RESET)
|
||||||
|
quit(1)
|
||||||
|
if serviceEnvDelete(target, publicKey, secretKey):
|
||||||
|
echo GREEN & "Vault deleted for service " & target & RESET
|
||||||
|
else:
|
||||||
|
stderr.writeLine(RED & "Error: Failed to delete vault" & RESET)
|
||||||
|
quit(1)
|
||||||
|
else:
|
||||||
|
stderr.writeLine(RED & "Error: Unknown env action: " & action & RESET)
|
||||||
|
stderr.writeLine("Usage: un.nim service env <status|set|export|delete> <service_id>")
|
||||||
|
quit(1)
|
||||||
|
|
||||||
proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: string, vcpu: int, publicKey: string, secretKey: string) =
|
proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: string, vcpu: int, publicKey: string, secretKey: string) =
|
||||||
let lang = detectLanguage(sourceFile)
|
let lang = detectLanguage(sourceFile)
|
||||||
if lang == "":
|
if lang == "":
|
||||||
|
|
@ -178,7 +302,12 @@ 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}'"""
|
let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions' -H 'Content-Type: application/json' {authHeaders} -d '{json}'"""
|
||||||
echo execCurl(cmd)
|
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], publicKey: string, secretKey: string) =
|
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) =
|
||||||
|
# Handle env subcommand
|
||||||
|
if envAction != "":
|
||||||
|
cmdServiceEnv(envAction, envTarget, svcEnvs, svcEnvFile, publicKey, secretKey)
|
||||||
|
return
|
||||||
|
|
||||||
if list:
|
if list:
|
||||||
let authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey)
|
let authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey)
|
||||||
let cmd = fmt"""curl -s -X GET '{API_BASE}/services' {authHeaders}"""
|
let cmd = fmt"""curl -s -X GET '{API_BASE}/services' {authHeaders}"""
|
||||||
|
|
@ -326,7 +455,18 @@ proc cmdService(name, ports, bootstrap, bootstrapFile, serviceType: string, list
|
||||||
echo YELLOW & "Creating service..." & RESET
|
echo YELLOW & "Creating service..." & RESET
|
||||||
let authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey)
|
let authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey)
|
||||||
let cmd = fmt"""curl -s -X POST '{API_BASE}/services' -H 'Content-Type: application/json' {authHeaders} -d '{json}'"""
|
let cmd = fmt"""curl -s -X POST '{API_BASE}/services' -H 'Content-Type: application/json' {authHeaders} -d '{json}'"""
|
||||||
echo execCurl(cmd)
|
let response = execCurl(cmd)
|
||||||
|
echo response
|
||||||
|
|
||||||
|
# Auto-set vault if -e or --env-file provided
|
||||||
|
if svcEnvs.len > 0 or svcEnvFile != "":
|
||||||
|
let serviceId = extractJsonField(response, "service_id")
|
||||||
|
if serviceId != "":
|
||||||
|
let envContent = buildEnvContent(svcEnvs, svcEnvFile)
|
||||||
|
if serviceEnvSet(serviceId, envContent, publicKey, secretKey):
|
||||||
|
echo GREEN & "Vault configured for service " & serviceId & RESET
|
||||||
|
else:
|
||||||
|
stderr.writeLine(YELLOW & "Warning: Failed to set vault" & RESET)
|
||||||
return
|
return
|
||||||
|
|
||||||
stderr.writeLine(RED & "Error: Specify --name to create a service" & RESET)
|
stderr.writeLine(RED & "Error: Specify --name to create a service" & RESET)
|
||||||
|
|
@ -422,7 +562,18 @@ proc main() =
|
||||||
stderr.writeLine("Usage: un.nim [options] <source_file>")
|
stderr.writeLine("Usage: un.nim [options] <source_file>")
|
||||||
stderr.writeLine(" un.nim session [options]")
|
stderr.writeLine(" un.nim session [options]")
|
||||||
stderr.writeLine(" un.nim service [options]")
|
stderr.writeLine(" un.nim service [options]")
|
||||||
|
stderr.writeLine(" un.nim service env <action> <service_id> [options]")
|
||||||
stderr.writeLine(" un.nim key [options]")
|
stderr.writeLine(" un.nim key [options]")
|
||||||
|
stderr.writeLine("")
|
||||||
|
stderr.writeLine("Service env commands:")
|
||||||
|
stderr.writeLine(" env status <id> Show vault status")
|
||||||
|
stderr.writeLine(" env set <id> Set vault (-e KEY=VALUE or --env-file FILE)")
|
||||||
|
stderr.writeLine(" env export <id> Export vault contents")
|
||||||
|
stderr.writeLine(" env delete <id> Delete vault")
|
||||||
|
stderr.writeLine("")
|
||||||
|
stderr.writeLine("Service options:")
|
||||||
|
stderr.writeLine(" -e KEY=VALUE Set environment variable (for vault)")
|
||||||
|
stderr.writeLine(" --env-file FILE Load env vars from file (for vault)")
|
||||||
quit(1)
|
quit(1)
|
||||||
|
|
||||||
if args[0] == "key":
|
if args[0] == "key":
|
||||||
|
|
@ -472,7 +623,28 @@ proc main() =
|
||||||
var info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network = ""
|
var info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network = ""
|
||||||
var vcpu = 0
|
var vcpu = 0
|
||||||
var inputFiles: seq[string] = @[]
|
var inputFiles: seq[string] = @[]
|
||||||
|
var svcEnvs: seq[string] = @[]
|
||||||
|
var svcEnvFile = ""
|
||||||
|
var envAction, envTarget = ""
|
||||||
var i = 1
|
var i = 1
|
||||||
|
|
||||||
|
# Check for env subcommand
|
||||||
|
if args.len > 1 and args[1] == "env":
|
||||||
|
if args.len > 2:
|
||||||
|
envAction = args[2]
|
||||||
|
if args.len > 3:
|
||||||
|
envTarget = args[3]
|
||||||
|
i = 4
|
||||||
|
while i < args.len:
|
||||||
|
case args[i]
|
||||||
|
of "-e": svcEnvs.add(args[i+1]); inc i
|
||||||
|
of "--env-file": svcEnvFile = args[i+1]; inc i
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
|
||||||
while i < args.len:
|
while i < args.len:
|
||||||
case args[i]
|
case args[i]
|
||||||
of "--name": name = args[i+1]; inc i
|
of "--name": name = args[i+1]; inc i
|
||||||
|
|
@ -494,6 +666,8 @@ proc main() =
|
||||||
of "-n": network = args[i+1]; inc i
|
of "-n": network = args[i+1]; inc i
|
||||||
of "-v": vcpu = parseInt(args[i+1]); inc i
|
of "-v": vcpu = parseInt(args[i+1]); inc i
|
||||||
of "-k": publicKey = args[i+1]; inc i
|
of "-k": publicKey = args[i+1]; inc i
|
||||||
|
of "-e": svcEnvs.add(args[i+1]); inc i
|
||||||
|
of "--env-file": svcEnvFile = args[i+1]; inc i
|
||||||
of "-f":
|
of "-f":
|
||||||
let file = args[i+1]
|
let file = args[i+1]
|
||||||
if fileExists(file):
|
if fileExists(file):
|
||||||
|
|
@ -504,7 +678,7 @@ proc main() =
|
||||||
inc i
|
inc i
|
||||||
else: discard
|
else: discard
|
||||||
inc i
|
inc i
|
||||||
cmdService(name, ports, bootstrap, bootstrapFile, serviceType, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, inputFiles, publicKey, secretKey)
|
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)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Execute mode
|
# Execute mode
|
||||||
|
|
|
||||||
202
un.php
202
un.php
|
|
@ -178,6 +178,172 @@ function api_request($endpoint, $method = 'GET', $data = null, $keys = null) {
|
||||||
return json_decode($response, true);
|
return json_decode($response, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function api_request_text($endpoint, $method, $body, $keys) {
|
||||||
|
$url = API_BASE . $endpoint;
|
||||||
|
$ch = curl_init($url);
|
||||||
|
|
||||||
|
$timestamp = (string)time();
|
||||||
|
|
||||||
|
// Parse URL to get path
|
||||||
|
$parsed_url = parse_url($url);
|
||||||
|
$path = $parsed_url['path'];
|
||||||
|
$message = "$timestamp:$method:$path:$body";
|
||||||
|
$signature = hash_hmac('sha256', $message, $keys['secret_key']);
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Authorization: Bearer ' . $keys['public_key'],
|
||||||
|
'X-Timestamp: ' . $timestamp,
|
||||||
|
'X-Signature: ' . $signature,
|
||||||
|
'Content-Type: text/plain'
|
||||||
|
];
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 300,
|
||||||
|
CURLOPT_POSTFIELDS => $body
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
|
||||||
|
if ($response === false) {
|
||||||
|
curl_close($ch);
|
||||||
|
return ['error' => curl_error($ch)];
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($http_code < 200 || $http_code >= 300) {
|
||||||
|
return ['error' => "HTTP $http_code - $response"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_decode($response, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Environment Secrets Vault Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const MAX_ENV_CONTENT_SIZE = 64 * 1024; // 64KB max
|
||||||
|
|
||||||
|
function service_env_status($service_id, $keys) {
|
||||||
|
$result = api_request("/services/$service_id/env", 'GET', null, $keys);
|
||||||
|
$has_vault = $result['has_vault'] ?? false;
|
||||||
|
|
||||||
|
if (!$has_vault) {
|
||||||
|
echo "Vault exists: no\n";
|
||||||
|
echo "Variable count: 0\n";
|
||||||
|
} else {
|
||||||
|
echo "Vault exists: yes\n";
|
||||||
|
echo "Variable count: " . ($result['count'] ?? 0) . "\n";
|
||||||
|
if (isset($result['updated_at'])) {
|
||||||
|
echo "Last updated: " . date('Y-m-d H:i:s', $result['updated_at']) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function service_env_set($service_id, $env_content, $keys) {
|
||||||
|
if (empty($env_content)) {
|
||||||
|
fwrite(STDERR, RED . "Error: No environment content provided" . RESET . "\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($env_content) > MAX_ENV_CONTENT_SIZE) {
|
||||||
|
fwrite(STDERR, RED . "Error: Environment content too large (max " . MAX_ENV_CONTENT_SIZE . " bytes)" . RESET . "\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = api_request_text("/services/$service_id/env", 'PUT', $env_content, $keys);
|
||||||
|
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
fwrite(STDERR, RED . "Error: " . $result['error'] . RESET . "\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = $result['count'] ?? 0;
|
||||||
|
$plural = $count === 1 ? '' : 's';
|
||||||
|
echo GREEN . "Environment vault updated: $count variable$plural" . RESET . "\n";
|
||||||
|
if (!empty($result['message'])) echo $result['message'] . "\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function service_env_export($service_id, $keys) {
|
||||||
|
$result = api_request("/services/$service_id/env/export", 'POST', [], $keys);
|
||||||
|
$env_content = $result['env'] ?? '';
|
||||||
|
if (!empty($env_content)) {
|
||||||
|
echo $env_content;
|
||||||
|
if (!str_ends_with($env_content, "\n")) echo "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function service_env_delete($service_id, $keys) {
|
||||||
|
api_request("/services/$service_id/env", 'DELETE', null, $keys);
|
||||||
|
echo GREEN . "Environment vault deleted" . RESET . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function read_env_file($filepath) {
|
||||||
|
if (!file_exists($filepath)) {
|
||||||
|
fwrite(STDERR, RED . "Error: Env file not found: $filepath" . RESET . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
return file_get_contents($filepath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_env_content($envs, $env_file) {
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
// Read from env file first
|
||||||
|
if (!empty($env_file)) {
|
||||||
|
$parts[] = read_env_file($env_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add -e flags
|
||||||
|
foreach ($envs as $e) {
|
||||||
|
if (str_contains($e, '=')) {
|
||||||
|
$parts[] = $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmd_service_env($action, $target, $envs, $env_file, $keys) {
|
||||||
|
if (empty($action)) {
|
||||||
|
fwrite(STDERR, RED . "Error: env action required (status, set, export, delete)" . RESET . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($target)) {
|
||||||
|
fwrite(STDERR, RED . "Error: Service ID required for env command" . RESET . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'status':
|
||||||
|
service_env_status($target, $keys);
|
||||||
|
break;
|
||||||
|
case 'set':
|
||||||
|
$env_content = build_env_content($envs, $env_file);
|
||||||
|
if (empty($env_content)) {
|
||||||
|
fwrite(STDERR, RED . "Error: No env content provided. Use -e KEY=VAL or --env-file" . RESET . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
service_env_set($target, $env_content, $keys);
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
service_env_export($target, $keys);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
service_env_delete($target, $keys);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
fwrite(STDERR, RED . "Error: Unknown env action '$action'. Use: status, set, export, delete" . RESET . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cmd_execute($options) {
|
function cmd_execute($options) {
|
||||||
$keys = get_api_keys($options['api_key']);
|
$keys = get_api_keys($options['api_key']);
|
||||||
|
|
||||||
|
|
@ -568,9 +734,16 @@ function cmd_service($options) {
|
||||||
if ($options['vcpu']) $payload['vcpu'] = $options['vcpu'];
|
if ($options['vcpu']) $payload['vcpu'] = $options['vcpu'];
|
||||||
|
|
||||||
$result = api_request('/services', 'POST', $payload, $keys);
|
$result = api_request('/services', 'POST', $payload, $keys);
|
||||||
echo GREEN . "Service created: " . ($result['id'] ?? 'N/A') . RESET . "\n";
|
$service_id = $result['id'] ?? null;
|
||||||
|
echo GREEN . "Service created: " . ($service_id ?? 'N/A') . RESET . "\n";
|
||||||
echo "Name: " . ($result['name'] ?? 'N/A') . "\n";
|
echo "Name: " . ($result['name'] ?? 'N/A') . "\n";
|
||||||
if (!empty($result['url'])) echo "URL: {$result['url']}\n";
|
if (!empty($result['url'])) echo "URL: {$result['url']}\n";
|
||||||
|
|
||||||
|
// Auto-set vault if -e or --env-file provided
|
||||||
|
$env_content = build_env_content($options['env'] ?? [], $options['env_file']);
|
||||||
|
if (!empty($env_content) && $service_id) {
|
||||||
|
service_env_set($service_id, $env_content, $keys);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -614,7 +787,10 @@ function main() {
|
||||||
'command' => null,
|
'command' => null,
|
||||||
'dump_bootstrap' => null,
|
'dump_bootstrap' => null,
|
||||||
'dump_file' => null,
|
'dump_file' => null,
|
||||||
'extend' => false
|
'extend' => false,
|
||||||
|
'env_file' => null,
|
||||||
|
'env_action' => null,
|
||||||
|
'env_target' => null
|
||||||
];
|
];
|
||||||
|
|
||||||
for ($i = 1; $i < count($argv); $i++) {
|
for ($i = 1; $i < count($argv); $i++) {
|
||||||
|
|
@ -688,6 +864,20 @@ function main() {
|
||||||
case '--bootstrap-file':
|
case '--bootstrap-file':
|
||||||
$options['bootstrap_file'] = $argv[++$i];
|
$options['bootstrap_file'] = $argv[++$i];
|
||||||
break;
|
break;
|
||||||
|
case '--env-file':
|
||||||
|
$options['env_file'] = $argv[++$i];
|
||||||
|
break;
|
||||||
|
case 'env':
|
||||||
|
// Handle "service env <action> <target>" subcommand
|
||||||
|
if ($options['command'] === 'service') {
|
||||||
|
if (isset($argv[$i + 1])) {
|
||||||
|
$options['env_action'] = $argv[++$i];
|
||||||
|
}
|
||||||
|
if (isset($argv[$i + 1]) && !str_starts_with($argv[$i + 1], '-')) {
|
||||||
|
$options['env_target'] = $argv[++$i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
case '--info':
|
case '--info':
|
||||||
$options['info'] = $argv[++$i];
|
$options['info'] = $argv[++$i];
|
||||||
break;
|
break;
|
||||||
|
|
@ -735,7 +925,13 @@ function main() {
|
||||||
if ($options['command'] === 'session') {
|
if ($options['command'] === 'session') {
|
||||||
cmd_session($options);
|
cmd_session($options);
|
||||||
} elseif ($options['command'] === 'service') {
|
} elseif ($options['command'] === 'service') {
|
||||||
cmd_service($options);
|
// Check for "service env" subcommand
|
||||||
|
if ($options['env_action']) {
|
||||||
|
$keys = get_api_keys($options['api_key']);
|
||||||
|
cmd_service_env($options['env_action'], $options['env_target'], $options['env'], $options['env_file'], $keys);
|
||||||
|
} else {
|
||||||
|
cmd_service($options);
|
||||||
|
}
|
||||||
} elseif ($options['command'] === 'key') {
|
} elseif ($options['command'] === 'key') {
|
||||||
cmd_key($options);
|
cmd_key($options);
|
||||||
} elseif ($options['source_file']) {
|
} elseif ($options['source_file']) {
|
||||||
|
|
|
||||||
190
un.pl
190
un.pl
|
|
@ -169,6 +169,164 @@ sub api_request {
|
||||||
return decode_json($response->content);
|
return decode_json($response->content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sub api_request_text {
|
||||||
|
my ($endpoint, $method, $body, $public_key, $secret_key) = @_;
|
||||||
|
|
||||||
|
my $url = "$API_BASE$endpoint";
|
||||||
|
my $ua = LWP::UserAgent->new(timeout => 300);
|
||||||
|
my $request = HTTP::Request->new($method => $url);
|
||||||
|
$request->header('Authorization' => "Bearer $public_key");
|
||||||
|
$request->header('Content-Type' => 'text/plain');
|
||||||
|
$request->content($body);
|
||||||
|
|
||||||
|
# Add HMAC signature if secret_key is present
|
||||||
|
if ($secret_key) {
|
||||||
|
my $timestamp = time();
|
||||||
|
my $sig_input = "${timestamp}:${method}:${endpoint}:${body}";
|
||||||
|
my $signature = hmac_sha256_hex($sig_input, $secret_key);
|
||||||
|
$request->header('X-Timestamp' => $timestamp);
|
||||||
|
$request->header('X-Signature' => $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
my $response = $ua->request($request);
|
||||||
|
|
||||||
|
unless ($response->is_success) {
|
||||||
|
return { error => "HTTP " . $response->code . " - " . $response->content };
|
||||||
|
}
|
||||||
|
|
||||||
|
return decode_json($response->content);
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Environment Secrets Vault Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
my $MAX_ENV_CONTENT_SIZE = 64 * 1024; # 64KB max
|
||||||
|
|
||||||
|
sub service_env_status {
|
||||||
|
my ($service_id, $public_key, $secret_key) = @_;
|
||||||
|
my $result = api_request("/services/$service_id/env", 'GET', undef, $public_key, $secret_key);
|
||||||
|
my $has_vault = $result->{has_vault};
|
||||||
|
|
||||||
|
if (!$has_vault) {
|
||||||
|
print "Vault exists: no\n";
|
||||||
|
print "Variable count: 0\n";
|
||||||
|
} else {
|
||||||
|
print "Vault exists: yes\n";
|
||||||
|
print "Variable count: ", ($result->{count} // 0), "\n";
|
||||||
|
if ($result->{updated_at}) {
|
||||||
|
my @t = localtime($result->{updated_at});
|
||||||
|
printf "Last updated: %04d-%02d-%02d %02d:%02d:%02d\n",
|
||||||
|
$t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub service_env_set {
|
||||||
|
my ($service_id, $env_content, $public_key, $secret_key) = @_;
|
||||||
|
|
||||||
|
unless ($env_content) {
|
||||||
|
print STDERR "${RED}Error: No environment content provided${RESET}\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length($env_content) > $MAX_ENV_CONTENT_SIZE) {
|
||||||
|
print STDERR "${RED}Error: Environment content too large (max $MAX_ENV_CONTENT_SIZE bytes)${RESET}\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
my $result = api_request_text("/services/$service_id/env", 'PUT', $env_content, $public_key, $secret_key);
|
||||||
|
|
||||||
|
if ($result->{error}) {
|
||||||
|
print STDERR "${RED}Error: $result->{error}${RESET}\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
my $count = $result->{count} // 0;
|
||||||
|
my $plural = $count == 1 ? '' : 's';
|
||||||
|
print "${GREEN}Environment vault updated: $count variable$plural${RESET}\n";
|
||||||
|
print "$result->{message}\n" if $result->{message};
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub service_env_export {
|
||||||
|
my ($service_id, $public_key, $secret_key) = @_;
|
||||||
|
my $result = api_request("/services/$service_id/env/export", 'POST', {}, $public_key, $secret_key);
|
||||||
|
my $env_content = $result->{env} // '';
|
||||||
|
if ($env_content) {
|
||||||
|
print $env_content;
|
||||||
|
print "\n" unless $env_content =~ /\n$/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub service_env_delete {
|
||||||
|
my ($service_id, $public_key, $secret_key) = @_;
|
||||||
|
api_request("/services/$service_id/env", 'DELETE', undef, $public_key, $secret_key);
|
||||||
|
print "${GREEN}Environment vault deleted${RESET}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub read_env_file {
|
||||||
|
my ($filepath) = @_;
|
||||||
|
unless (-e $filepath) {
|
||||||
|
print STDERR "${RED}Error: Env file not found: $filepath${RESET}\n";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
open my $fh, '<', $filepath or die "Cannot read file: $!";
|
||||||
|
local $/;
|
||||||
|
my $content = <$fh>;
|
||||||
|
close $fh;
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub build_env_content {
|
||||||
|
my ($envs, $env_file) = @_;
|
||||||
|
my @parts;
|
||||||
|
|
||||||
|
# Read from env file first
|
||||||
|
if ($env_file) {
|
||||||
|
push @parts, read_env_file($env_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add -e flags
|
||||||
|
foreach my $e (@$envs) {
|
||||||
|
push @parts, $e if $e =~ /=/;
|
||||||
|
}
|
||||||
|
|
||||||
|
return join("\n", @parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub cmd_service_env {
|
||||||
|
my ($action, $target, $envs, $env_file, $public_key, $secret_key) = @_;
|
||||||
|
|
||||||
|
unless ($action) {
|
||||||
|
print STDERR "${RED}Error: env action required (status, set, export, delete)${RESET}\n";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
unless ($target) {
|
||||||
|
print STDERR "${RED}Error: Service ID required for env command${RESET}\n";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action eq 'status') {
|
||||||
|
service_env_status($target, $public_key, $secret_key);
|
||||||
|
} elsif ($action eq 'set') {
|
||||||
|
my $env_content = build_env_content($envs, $env_file);
|
||||||
|
unless ($env_content) {
|
||||||
|
print STDERR "${RED}Error: No env content provided. Use -e KEY=VAL or --env-file${RESET}\n";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
service_env_set($target, $env_content, $public_key, $secret_key);
|
||||||
|
} elsif ($action eq 'export') {
|
||||||
|
service_env_export($target, $public_key, $secret_key);
|
||||||
|
} elsif ($action eq 'delete') {
|
||||||
|
service_env_delete($target, $public_key, $secret_key);
|
||||||
|
} else {
|
||||||
|
print STDERR "${RED}Error: Unknown env action '$action'. Use: status, set, export, delete${RESET}\n";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sub cmd_execute {
|
sub cmd_execute {
|
||||||
my ($options) = @_;
|
my ($options) = @_;
|
||||||
my ($public_key, $secret_key) = get_api_key($options->{api_key});
|
my ($public_key, $secret_key) = get_api_key($options->{api_key});
|
||||||
|
|
@ -451,9 +609,16 @@ sub cmd_service {
|
||||||
$payload->{vcpu} = $options->{vcpu} if $options->{vcpu};
|
$payload->{vcpu} = $options->{vcpu} if $options->{vcpu};
|
||||||
|
|
||||||
my $result = api_request('/services', 'POST', $payload, $public_key, $secret_key);
|
my $result = api_request('/services', 'POST', $payload, $public_key, $secret_key);
|
||||||
print "${GREEN}Service created: ", ($result->{id} // 'N/A'), "${RESET}\n";
|
my $service_id = $result->{id};
|
||||||
|
print "${GREEN}Service created: ", ($service_id // 'N/A'), "${RESET}\n";
|
||||||
print "Name: ", ($result->{name} // 'N/A'), "\n";
|
print "Name: ", ($result->{name} // 'N/A'), "\n";
|
||||||
print "URL: $result->{url}\n" if $result->{url};
|
print "URL: $result->{url}\n" if $result->{url};
|
||||||
|
|
||||||
|
# Auto-set vault if -e or --env-file provided
|
||||||
|
my $env_content = build_env_content($options->{env} || [], $options->{env_file});
|
||||||
|
if ($env_content && $service_id) {
|
||||||
|
service_env_set($service_id, $env_content, $public_key, $secret_key);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,7 +738,10 @@ sub main {
|
||||||
command => undef,
|
command => undef,
|
||||||
dump_bootstrap => undef,
|
dump_bootstrap => undef,
|
||||||
dump_file => undef,
|
dump_file => undef,
|
||||||
extend => 0
|
extend => 0,
|
||||||
|
env_file => undef,
|
||||||
|
env_action => undef,
|
||||||
|
env_target => undef
|
||||||
);
|
);
|
||||||
|
|
||||||
for (my $i = 0; $i < @ARGV; $i++) {
|
for (my $i = 0; $i < @ARGV; $i++) {
|
||||||
|
|
@ -621,6 +789,16 @@ sub main {
|
||||||
$options{bootstrap} = $ARGV[++$i];
|
$options{bootstrap} = $ARGV[++$i];
|
||||||
} elsif ($arg eq '--bootstrap-file') {
|
} elsif ($arg eq '--bootstrap-file') {
|
||||||
$options{bootstrap_file} = $ARGV[++$i];
|
$options{bootstrap_file} = $ARGV[++$i];
|
||||||
|
} elsif ($arg eq '--env-file') {
|
||||||
|
$options{env_file} = $ARGV[++$i];
|
||||||
|
} elsif ($arg eq 'env') {
|
||||||
|
# Handle "service env <action> <target>" subcommand
|
||||||
|
if ($options{command} && $options{command} eq 'service') {
|
||||||
|
$options{env_action} = $ARGV[++$i] if defined $ARGV[$i + 1];
|
||||||
|
if (defined $ARGV[$i + 1] && $ARGV[$i + 1] !~ /^-/) {
|
||||||
|
$options{env_target} = $ARGV[++$i];
|
||||||
|
}
|
||||||
|
}
|
||||||
} elsif ($arg eq '--info') {
|
} elsif ($arg eq '--info') {
|
||||||
$options{info} = $ARGV[++$i];
|
$options{info} = $ARGV[++$i];
|
||||||
} elsif ($arg eq '--logs') {
|
} elsif ($arg eq '--logs') {
|
||||||
|
|
@ -654,7 +832,13 @@ sub main {
|
||||||
if ($options{command} && $options{command} eq 'session') {
|
if ($options{command} && $options{command} eq 'session') {
|
||||||
cmd_session(\%options);
|
cmd_session(\%options);
|
||||||
} elsif ($options{command} && $options{command} eq 'service') {
|
} elsif ($options{command} && $options{command} eq 'service') {
|
||||||
cmd_service(\%options);
|
# Check for "service env" subcommand
|
||||||
|
if ($options{env_action}) {
|
||||||
|
my ($public_key, $secret_key) = get_api_key($options{api_key});
|
||||||
|
cmd_service_env($options{env_action}, $options{env_target}, $options{env}, $options{env_file}, $public_key, $secret_key);
|
||||||
|
} else {
|
||||||
|
cmd_service(\%options);
|
||||||
|
}
|
||||||
} elsif ($options{command} && $options{command} eq 'key') {
|
} elsif ($options{command} && $options{command} eq 'key') {
|
||||||
cmd_key(\%options);
|
cmd_key(\%options);
|
||||||
} elsif ($options{source_file}) {
|
} elsif ($options{source_file}) {
|
||||||
|
|
|
||||||
158
un.pro
158
un.pro
|
|
@ -230,6 +230,42 @@ service_destroy(ServiceId) :-
|
||||||
[ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]),
|
[ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]),
|
||||||
shell(Cmd, 0).
|
shell(Cmd, 0).
|
||||||
|
|
||||||
|
% Service env status
|
||||||
|
service_env_status(ServiceId) :-
|
||||||
|
get_public_key(PublicKey),
|
||||||
|
get_secret_key(SecretKey),
|
||||||
|
format(atom(Cmd),
|
||||||
|
'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/services/~w/env:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X GET "https://api.unsandbox.com/services/~w/env" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq .',
|
||||||
|
[ServiceId, SecretKey, ServiceId, PublicKey]),
|
||||||
|
shell(Cmd, 0).
|
||||||
|
|
||||||
|
% Service env set
|
||||||
|
service_env_set(ServiceId, Envs, EnvFile) :-
|
||||||
|
get_public_key(PublicKey),
|
||||||
|
get_secret_key(SecretKey),
|
||||||
|
format(atom(Cmd),
|
||||||
|
'ENV_CONTENT=""; ENV_LINES="~w"; if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ENV_FILE="~w"; if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then while IFS= read -r line || [ -n "$line" ]; do case "$line" in "#"*|"") continue ;; esac; if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT\\n"; fi; ENV_CONTENT="$ENV_CONTENT$line"; done < "$ENV_FILE"; fi; if [ -z "$ENV_CONTENT" ]; then echo -e "\\x1b[31mError: No environment variables to set\\x1b[0m" >&2; exit 1; fi; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:PUT:/services/~w/env:$ENV_CONTENT"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X PUT "https://api.unsandbox.com/services/~w/env" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -H "Content-Type: text/plain" --data-binary "$ENV_CONTENT" | jq .',
|
||||||
|
[Envs, EnvFile, ServiceId, SecretKey, ServiceId, PublicKey]),
|
||||||
|
shell(Cmd, 0).
|
||||||
|
|
||||||
|
% Service env export
|
||||||
|
service_env_export(ServiceId) :-
|
||||||
|
get_public_key(PublicKey),
|
||||||
|
get_secret_key(SecretKey),
|
||||||
|
format(atom(Cmd),
|
||||||
|
'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services/~w/env/export:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST "https://api.unsandbox.com/services/~w/env/export" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq -r ".content // empty"',
|
||||||
|
[ServiceId, SecretKey, ServiceId, PublicKey]),
|
||||||
|
shell(Cmd, 0).
|
||||||
|
|
||||||
|
% Service env delete
|
||||||
|
service_env_delete(ServiceId) :-
|
||||||
|
get_public_key(PublicKey),
|
||||||
|
get_secret_key(SecretKey),
|
||||||
|
format(atom(Cmd),
|
||||||
|
'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:DELETE:/services/~w/env:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X DELETE "https://api.unsandbox.com/services/~w/env" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mVault deleted for: ~w\\x1b[0m"',
|
||||||
|
[ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]),
|
||||||
|
shell(Cmd, 0).
|
||||||
|
|
||||||
% Service dump bootstrap
|
% Service dump bootstrap
|
||||||
service_dump_bootstrap(ServiceId, DumpFile) :-
|
service_dump_bootstrap(ServiceId, DumpFile) :-
|
||||||
get_public_key(PublicKey),
|
get_public_key(PublicKey),
|
||||||
|
|
@ -330,58 +366,126 @@ parse_session_args([Arg|Rest], Shell, Files, ShellOut, InputFiles) :-
|
||||||
).
|
).
|
||||||
|
|
||||||
% Handle service subcommand
|
% Handle service subcommand
|
||||||
|
handle_service(['env', Action, ServiceId|Rest]) :-
|
||||||
|
!,
|
||||||
|
parse_env_args(Rest, '', '', Envs, EnvFile),
|
||||||
|
handle_env_action(Action, ServiceId, Envs, EnvFile).
|
||||||
handle_service(Args) :-
|
handle_service(Args) :-
|
||||||
parse_service_args(Args, '', '', '', '', '', [], Action, InputFiles),
|
parse_service_args(Args, '', '', '', '', '', [], '', '', Action, InputFiles),
|
||||||
execute_service_action(Action, InputFiles).
|
execute_service_action(Action, InputFiles).
|
||||||
|
|
||||||
|
% Handle env action
|
||||||
|
handle_env_action('status', ServiceId, _, _) :- service_env_status(ServiceId).
|
||||||
|
handle_env_action('set', ServiceId, Envs, EnvFile) :- service_env_set(ServiceId, Envs, EnvFile).
|
||||||
|
handle_env_action('export', ServiceId, _, _) :- service_env_export(ServiceId).
|
||||||
|
handle_env_action('delete', ServiceId, _, _) :- service_env_delete(ServiceId).
|
||||||
|
handle_env_action(Action, _, _, _) :-
|
||||||
|
format(user_error, 'Error: Unknown env action: ~w~n', [Action]),
|
||||||
|
write(user_error, 'Usage: un.pro service env <status|set|export|delete> <service_id>\n'),
|
||||||
|
halt(1).
|
||||||
|
|
||||||
|
% Parse env arguments for -e and --env-file
|
||||||
|
parse_env_args([], Envs, EnvFile, Envs, EnvFile).
|
||||||
|
parse_env_args(['-e', EnvVal|Rest], Envs, EnvFile, EnvsOut, EnvFileOut) :-
|
||||||
|
( Envs \= ''
|
||||||
|
-> format(atom(NewEnvs), '~w\\n~w', [Envs, EnvVal])
|
||||||
|
; NewEnvs = EnvVal
|
||||||
|
),
|
||||||
|
parse_env_args(Rest, NewEnvs, EnvFile, EnvsOut, EnvFileOut).
|
||||||
|
parse_env_args(['--env-file', EnvFileVal|Rest], Envs, _, EnvsOut, EnvFileOut) :-
|
||||||
|
parse_env_args(Rest, Envs, EnvFileVal, EnvsOut, EnvFileOut).
|
||||||
|
parse_env_args([_|Rest], Envs, EnvFile, EnvsOut, EnvFileOut) :-
|
||||||
|
parse_env_args(Rest, Envs, EnvFile, EnvsOut, EnvFileOut).
|
||||||
|
|
||||||
% Parse service arguments
|
% Parse service arguments
|
||||||
parse_service_args([], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, create, InputFiles) :-
|
parse_service_args([], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, create, InputFiles) :-
|
||||||
( Name \= ''
|
( Name \= ''
|
||||||
-> service_create(Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles)
|
-> service_create_with_vault(Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile)
|
||||||
; write(user_error, 'Error: --name required for service creation\n'),
|
; write(user_error, 'Error: --name required for service creation\n'),
|
||||||
halt(1)
|
halt(1)
|
||||||
).
|
).
|
||||||
parse_service_args([], _, _, _, _, _, InputFiles, Action, InputFiles) :-
|
parse_service_args([], _, _, _, _, _, InputFiles, _, _, Action, InputFiles) :-
|
||||||
( Action = list
|
( Action = list
|
||||||
-> service_list
|
-> service_list
|
||||||
; write(user_error, 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, or --name\n'),
|
; write(user_error, 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --name, or env\n'),
|
||||||
halt(1)
|
halt(1)
|
||||||
).
|
).
|
||||||
parse_service_args(['--list'|_], _, _, _, _, _, _, _, _) :- service_list.
|
parse_service_args(['--list'|_], _, _, _, _, _, _, _, _, _, _) :- service_list.
|
||||||
parse_service_args(['-l'|_], _, _, _, _, _, _, _, _) :- service_list.
|
parse_service_args(['-l'|_], _, _, _, _, _, _, _, _, _, _) :- service_list.
|
||||||
parse_service_args(['--info', ServiceId|_], _, _, _, _, _, _, _, _) :- service_info(ServiceId).
|
parse_service_args(['--info', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_info(ServiceId).
|
||||||
parse_service_args(['--logs', ServiceId|_], _, _, _, _, _, _, _, _) :- service_logs(ServiceId).
|
parse_service_args(['--logs', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_logs(ServiceId).
|
||||||
parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _, _, _, _) :- service_sleep(ServiceId).
|
parse_service_args(['--freeze', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_sleep(ServiceId).
|
||||||
parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _, _, _, _) :- service_wake(ServiceId).
|
parse_service_args(['--unfreeze', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_wake(ServiceId).
|
||||||
parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _, _, _, _) :- service_destroy(ServiceId).
|
parse_service_args(['--destroy', ServiceId|_], _, _, _, _, _, _, _, _, _, _) :- service_destroy(ServiceId).
|
||||||
parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _, _, _, _) :-
|
parse_service_args(['--dump-bootstrap', ServiceId|Rest], _, _, _, _, _, _, _, _, _, _) :-
|
||||||
( Rest = ['--dump-file', DumpFile|_]
|
( Rest = ['--dump-file', DumpFile|_]
|
||||||
-> service_dump_bootstrap(ServiceId, DumpFile)
|
-> service_dump_bootstrap(ServiceId, DumpFile)
|
||||||
; service_dump_bootstrap(ServiceId, '')
|
; service_dump_bootstrap(ServiceId, '')
|
||||||
).
|
).
|
||||||
parse_service_args(['--name', Name|Rest], _, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, _, InputFilesOut) :-
|
parse_service_args(['--name', Name|Rest], _, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, _, InputFilesOut) :-
|
||||||
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, create, InputFilesOut).
|
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, create, InputFilesOut).
|
||||||
parse_service_args(['--ports', PortsList|Rest], Name, _, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :-
|
parse_service_args(['--ports', PortsList|Rest], Name, _, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
|
||||||
parse_service_args(Rest, Name, PortsList, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut).
|
parse_service_args(Rest, Name, PortsList, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut).
|
||||||
parse_service_args(['--bootstrap', BootstrapVal|Rest], Name, Ports, _, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :-
|
parse_service_args(['--bootstrap', BootstrapVal|Rest], Name, Ports, _, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
|
||||||
parse_service_args(Rest, Name, Ports, BootstrapVal, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut).
|
parse_service_args(Rest, Name, Ports, BootstrapVal, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut).
|
||||||
parse_service_args(['--bootstrap-file', BootstrapFileVal|Rest], Name, Ports, Bootstrap, _, ServiceType, InputFiles, Action, InputFilesOut) :-
|
parse_service_args(['--bootstrap-file', BootstrapFileVal|Rest], Name, Ports, Bootstrap, _, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
|
||||||
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFileVal, ServiceType, InputFiles, Action, InputFilesOut).
|
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFileVal, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut).
|
||||||
parse_service_args(['--type', Type|Rest], Name, Ports, Bootstrap, BootstrapFile, _, InputFiles, Action, InputFilesOut) :-
|
parse_service_args(['--type', Type|Rest], Name, Ports, Bootstrap, BootstrapFile, _, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
|
||||||
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, Type, InputFiles, Action, InputFilesOut).
|
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, Type, InputFiles, Envs, EnvFile, Action, InputFilesOut).
|
||||||
parse_service_args(['-f', FilePath|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :-
|
parse_service_args(['-e', EnvVal|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
|
||||||
|
( Envs \= ''
|
||||||
|
-> format(atom(NewEnvs), '~w\\n~w', [Envs, EnvVal])
|
||||||
|
; NewEnvs = EnvVal
|
||||||
|
),
|
||||||
|
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, NewEnvs, EnvFile, Action, InputFilesOut).
|
||||||
|
parse_service_args(['--env-file', EnvFileVal|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, _, Action, InputFilesOut) :-
|
||||||
|
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFileVal, Action, InputFilesOut).
|
||||||
|
parse_service_args(['-f', FilePath|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
|
||||||
( exists_file(FilePath)
|
( exists_file(FilePath)
|
||||||
-> append(InputFiles, [FilePath], NewInputFiles),
|
-> append(InputFiles, [FilePath], NewInputFiles),
|
||||||
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, NewInputFiles, Action, InputFilesOut)
|
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, NewInputFiles, Envs, EnvFile, Action, InputFilesOut)
|
||||||
; format(user_error, 'Error: File not found: ~w~n', [FilePath]),
|
; format(user_error, 'Error: File not found: ~w~n', [FilePath]),
|
||||||
halt(1)
|
halt(1)
|
||||||
).
|
).
|
||||||
parse_service_args([_|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut) :-
|
parse_service_args([_|Rest], Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut) :-
|
||||||
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Action, InputFilesOut).
|
parse_service_args(Rest, Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile, Action, InputFilesOut).
|
||||||
|
|
||||||
% Execute service action (not used, but kept for structure)
|
% Execute service action (not used, but kept for structure)
|
||||||
execute_service_action(_, _).
|
execute_service_action(_, _).
|
||||||
|
|
||||||
|
% Service create with auto-vault
|
||||||
|
service_create_with_vault(Name, Ports, Bootstrap, BootstrapFile, ServiceType, InputFiles, Envs, EnvFile) :-
|
||||||
|
get_public_key(PublicKey),
|
||||||
|
get_secret_key(SecretKey),
|
||||||
|
% Build JSON payload
|
||||||
|
( Ports \= ''
|
||||||
|
-> format(atom(PortsJson), ',"ports":[~w]', [Ports])
|
||||||
|
; PortsJson = ''
|
||||||
|
),
|
||||||
|
( Bootstrap \= ''
|
||||||
|
-> format(atom(BootstrapJson), ',"bootstrap":"~w"', [Bootstrap])
|
||||||
|
; BootstrapJson = ''
|
||||||
|
),
|
||||||
|
( BootstrapFile \= ''
|
||||||
|
-> ( exists_file(BootstrapFile)
|
||||||
|
-> read_file_content(BootstrapFile, BootstrapContent),
|
||||||
|
format(atom(BootstrapContentJson), ',"bootstrap_content":"~w"', [BootstrapContent])
|
||||||
|
; format(user_error, 'Error: Bootstrap file not found: ~w~n', [BootstrapFile]),
|
||||||
|
halt(1)
|
||||||
|
)
|
||||||
|
; BootstrapContentJson = ''
|
||||||
|
),
|
||||||
|
( ServiceType \= ''
|
||||||
|
-> format(atom(ServiceTypeJson), ',"service_type":"~w"', [ServiceType])
|
||||||
|
; ServiceTypeJson = ''
|
||||||
|
),
|
||||||
|
% Build file arguments for bash script
|
||||||
|
build_file_args(InputFiles, FileArgs),
|
||||||
|
format(atom(Cmd),
|
||||||
|
'echo -e "\\x1b[33mCreating service...\\x1b[0m"; INPUT_FILES=""; ~w if [ -n "$INPUT_FILES" ]; then INPUT_FILES_JSON=",\\\"input_files\\\":[$INPUT_FILES]"; else INPUT_FILES_JSON=""; fi; BODY="{\\\"name\\\":\\\"~w\\\"~w~w~w~w$INPUT_FILES_JSON}"; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP=$(curl -s -X POST https://api.unsandbox.com/services -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY"); SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); if [ -n "$SVC_ID" ]; then echo -e "\\x1b[32m$SVC_ID created\\x1b[0m"; ENV_CONTENT=""; ENV_LINES="~w"; if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ENV_FILE="~w"; if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then while IFS= read -r line || [ -n "$line" ]; do case "$line" in "#"*|"") continue ;; esac; if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT\\n"; fi; ENV_CONTENT="$ENV_CONTENT$line"; done < "$ENV_FILE"; fi; if [ -n "$ENV_CONTENT" ]; then TS2=$(date +%s); SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" -H "Content-Type: text/plain" --data-binary "$ENV_CONTENT" >/dev/null && echo -e "\\x1b[32mVault configured\\x1b[0m"; fi; else echo "$RESP" | jq .; fi',
|
||||||
|
[FileArgs, Name, PortsJson, BootstrapJson, BootstrapContentJson, ServiceTypeJson, SecretKey, PublicKey, Envs, EnvFile, SecretKey, PublicKey]),
|
||||||
|
shell(Cmd, 0).
|
||||||
|
|
||||||
% Main program
|
% Main program
|
||||||
main(Argv) :-
|
main(Argv) :-
|
||||||
% Check arguments
|
% Check arguments
|
||||||
|
|
|
||||||
233
un.ps1
233
un.ps1
|
|
@ -126,6 +126,181 @@ function Invoke-Api {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Invoke-ApiText {
|
||||||
|
param($Endpoint, $Method, $Body, $BaseUrl = $null)
|
||||||
|
|
||||||
|
$publicKey, $secretKey = Get-ApiKeys
|
||||||
|
$headers = @{
|
||||||
|
"Authorization" = "Bearer $publicKey"
|
||||||
|
"Content-Type" = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add HMAC signature if secret key exists
|
||||||
|
if ($secretKey) {
|
||||||
|
$timestamp = [int][double]::Parse((Get-Date -UFormat %s))
|
||||||
|
$bodyContent = if ($Body) { $Body } else { "" }
|
||||||
|
$sigInput = "${timestamp}:${Method}:${Endpoint}:${bodyContent}"
|
||||||
|
|
||||||
|
$hmac = New-Object System.Security.Cryptography.HMACSHA256
|
||||||
|
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($secretKey)
|
||||||
|
$hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($sigInput))
|
||||||
|
$signature = [System.BitConverter]::ToString($hash).Replace("-", "").ToLower()
|
||||||
|
|
||||||
|
$headers["X-Timestamp"] = $timestamp.ToString()
|
||||||
|
$headers["X-Signature"] = $signature
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = if ($BaseUrl) { $BaseUrl } else { $API_BASE }
|
||||||
|
$uri = "$base$Endpoint"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Invoke-RestMethod -Uri $uri -Method $Method -Headers $headers -Body $Body
|
||||||
|
return @{ Success = $true; Data = $response }
|
||||||
|
} catch {
|
||||||
|
return @{ Success = $false; Error = $_.Exception.Message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-EnvFile {
|
||||||
|
param($Path)
|
||||||
|
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
Write-Error "Error: Env file not found: $Path"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return Get-Content -Raw $Path
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-EnvContent {
|
||||||
|
param($Envs, $EnvFile)
|
||||||
|
|
||||||
|
$lines = @()
|
||||||
|
|
||||||
|
# Add from -e flags
|
||||||
|
foreach ($env in $Envs) {
|
||||||
|
$lines += $env
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add from --env-file
|
||||||
|
if ($EnvFile) {
|
||||||
|
$content = Read-EnvFile -Path $EnvFile
|
||||||
|
foreach ($line in ($content -split "`n")) {
|
||||||
|
$trimmed = $line.Trim()
|
||||||
|
if ($trimmed -and -not $trimmed.StartsWith("#")) {
|
||||||
|
$lines += $trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lines -join "`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAX_ENV_CONTENT_SIZE = 65536
|
||||||
|
|
||||||
|
function Invoke-ServiceEnvStatus {
|
||||||
|
param($ServiceId)
|
||||||
|
|
||||||
|
return Invoke-Api -Endpoint "/services/$ServiceId/env"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-ServiceEnvSet {
|
||||||
|
param($ServiceId, $EnvContent)
|
||||||
|
|
||||||
|
if ($EnvContent.Length -gt $MAX_ENV_CONTENT_SIZE) {
|
||||||
|
Write-Host "`e[31mError: Env content exceeds maximum size of 64KB`e[0m"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Invoke-ApiText -Endpoint "/services/$ServiceId/env" -Method "PUT" -Body $EnvContent
|
||||||
|
return $result.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-ServiceEnvExport {
|
||||||
|
param($ServiceId)
|
||||||
|
|
||||||
|
return Invoke-Api -Endpoint "/services/$ServiceId/env/export" -Method "POST" -Body "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-ServiceEnvDelete {
|
||||||
|
param($ServiceId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-Api -Endpoint "/services/$ServiceId/env" -Method "DELETE"
|
||||||
|
return $true
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-ServiceEnv {
|
||||||
|
param($Action, $Target, $Envs, $EnvFile)
|
||||||
|
|
||||||
|
switch ($Action) {
|
||||||
|
"status" {
|
||||||
|
if (-not $Target) {
|
||||||
|
Write-Error "Error: service env status requires service ID"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$result = Invoke-ServiceEnvStatus -ServiceId $Target
|
||||||
|
if ($result.has_vault) {
|
||||||
|
Write-Host "`e[32mVault: configured`e[0m"
|
||||||
|
if ($result.env_count) {
|
||||||
|
Write-Host "Variables: $($result.env_count)"
|
||||||
|
}
|
||||||
|
if ($result.updated_at) {
|
||||||
|
Write-Host "Updated: $($result.updated_at)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "`e[33mVault: not configured`e[0m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"set" {
|
||||||
|
if (-not $Target) {
|
||||||
|
Write-Error "Error: service env set requires service ID"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Envs.Count -eq 0 -and -not $EnvFile) {
|
||||||
|
Write-Error "Error: service env set requires -e or --env-file"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$envContent = Build-EnvContent -Envs $Envs -EnvFile $EnvFile
|
||||||
|
if (Invoke-ServiceEnvSet -ServiceId $Target -EnvContent $envContent) {
|
||||||
|
Write-Host "`e[32mVault updated for service $Target`e[0m"
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Failed to update vault"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"export" {
|
||||||
|
if (-not $Target) {
|
||||||
|
Write-Error "Error: service env export requires service ID"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$result = Invoke-ServiceEnvExport -ServiceId $Target
|
||||||
|
if ($result.content) {
|
||||||
|
Write-Host $result.content -NoNewline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"delete" {
|
||||||
|
if (-not $Target) {
|
||||||
|
Write-Error "Error: service env delete requires service ID"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (Invoke-ServiceEnvDelete -ServiceId $Target) {
|
||||||
|
Write-Host "`e[32mVault deleted for service $Target`e[0m"
|
||||||
|
} else {
|
||||||
|
Write-Error "Error: Failed to delete vault"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
Write-Error "Error: Unknown env action: $Action"
|
||||||
|
Write-Host "Usage: pwsh un.ps1 service env <status|set|export|delete> <service_id>"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-Execute {
|
function Invoke-Execute {
|
||||||
param($SourceFile, $EnvVars = @{}, $Network = $null)
|
param($SourceFile, $EnvVars = @{}, $Network = $null)
|
||||||
|
|
||||||
|
|
@ -283,6 +458,41 @@ function Invoke-Key {
|
||||||
function Invoke-Service {
|
function Invoke-Service {
|
||||||
param($Args)
|
param($Args)
|
||||||
|
|
||||||
|
# Parse env subcommand and -e/--env-file
|
||||||
|
$envAction = $null
|
||||||
|
$envTarget = $null
|
||||||
|
$envs = @()
|
||||||
|
$envFile = $null
|
||||||
|
|
||||||
|
for ($i = 0; $i -lt $Args.Count; $i++) {
|
||||||
|
if ($Args[$i] -eq "env" -and ($i + 1) -lt $Args.Count) {
|
||||||
|
$next = $Args[$i + 1]
|
||||||
|
if (-not $next.StartsWith("-")) {
|
||||||
|
$envAction = $next
|
||||||
|
$i++
|
||||||
|
if (($i + 1) -lt $Args.Count) {
|
||||||
|
$next2 = $Args[$i + 1]
|
||||||
|
if (-not $next2.StartsWith("-")) {
|
||||||
|
$envTarget = $next2
|
||||||
|
$i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($Args[$i] -eq "-e" -and ($i + 1) -lt $Args.Count) {
|
||||||
|
$envs += $Args[$i + 1]
|
||||||
|
$i++
|
||||||
|
} elseif ($Args[$i] -eq "--env-file" -and ($i + 1) -lt $Args.Count) {
|
||||||
|
$envFile = $Args[$i + 1]
|
||||||
|
$i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle env subcommand
|
||||||
|
if ($envAction) {
|
||||||
|
Invoke-ServiceEnv -Action $envAction -Target $envTarget -Envs $envs -EnvFile $envFile
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if ($Args -contains "--list" -or $Args -contains "-l") {
|
if ($Args -contains "--list" -or $Args -contains "-l") {
|
||||||
$result = Invoke-Api -Endpoint "/services"
|
$result = Invoke-Api -Endpoint "/services"
|
||||||
$result | ConvertTo-Json -Depth 5
|
$result | ConvertTo-Json -Depth 5
|
||||||
|
|
@ -417,8 +627,21 @@ function Invoke-Service {
|
||||||
|
|
||||||
$body = $payload | ConvertTo-Json -Depth 10
|
$body = $payload | ConvertTo-Json -Depth 10
|
||||||
$result = Invoke-Api -Endpoint "/services" -Method "POST" -Body $body
|
$result = Invoke-Api -Endpoint "/services" -Method "POST" -Body $body
|
||||||
Write-Host "`e[32mService created`e[0m"
|
$serviceId = $result.id
|
||||||
|
Write-Host "`e[32mService created: $serviceId`e[0m"
|
||||||
$result | ConvertTo-Json -Depth 5
|
$result | ConvertTo-Json -Depth 5
|
||||||
|
|
||||||
|
# Auto-set vault if env vars were provided
|
||||||
|
if ($envs.Count -gt 0 -or $envFile) {
|
||||||
|
$envContent = Build-EnvContent -Envs $envs -EnvFile $envFile
|
||||||
|
if ($envContent) {
|
||||||
|
if (Invoke-ServiceEnvSet -ServiceId $serviceId -EnvContent $envContent) {
|
||||||
|
Write-Host "`e[32mVault configured with environment variables`e[0m"
|
||||||
|
} else {
|
||||||
|
Write-Host "`e[33mWarning: Failed to set vault`e[0m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,6 +673,8 @@ Service options:
|
||||||
--type TYPE Service type (minecraft, mumble, teamspeak, source, tcp, udp)
|
--type TYPE Service type (minecraft, mumble, teamspeak, source, tcp, udp)
|
||||||
--bootstrap CMD Bootstrap command
|
--bootstrap CMD Bootstrap command
|
||||||
-f FILE Input file (can be repeated)
|
-f FILE Input file (can be repeated)
|
||||||
|
-e KEY=VALUE Environment variable for vault (can be repeated)
|
||||||
|
--env-file FILE Load vault variables from file
|
||||||
--list, -l List services
|
--list, -l List services
|
||||||
--info ID Get service info
|
--info ID Get service info
|
||||||
--logs ID Get logs
|
--logs ID Get logs
|
||||||
|
|
@ -459,6 +684,12 @@ Service options:
|
||||||
--dump-bootstrap ID Dump bootstrap script from service
|
--dump-bootstrap ID Dump bootstrap script from service
|
||||||
--dump-file FILE Save bootstrap to file (with --dump-bootstrap)
|
--dump-file FILE Save bootstrap to file (with --dump-bootstrap)
|
||||||
|
|
||||||
|
Service env commands:
|
||||||
|
env status ID Show vault status
|
||||||
|
env set ID Set vault (-e KEY=VALUE or --env-file FILE)
|
||||||
|
env export ID Export vault contents
|
||||||
|
env delete ID Delete vault
|
||||||
|
|
||||||
Key options:
|
Key options:
|
||||||
--extend Open browser to extend key
|
--extend Open browser to extend key
|
||||||
"@
|
"@
|
||||||
|
|
|
||||||
197
un.py
197
un.py
|
|
@ -196,6 +196,149 @@ def api_request(endpoint, method="GET", data=None, public_key=None, secret_key=N
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def api_request_text(endpoint, method="PUT", body="", public_key=None, secret_key=None):
|
||||||
|
"""Make API request with text/plain body and HMAC authentication"""
|
||||||
|
url = f"{API_BASE}{endpoint}"
|
||||||
|
|
||||||
|
# Generate HMAC signature
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
signature_input = f"{timestamp}:{method}:{endpoint}:{body}"
|
||||||
|
signature = hmac.new(
|
||||||
|
secret_key.encode('utf-8'),
|
||||||
|
signature_input.encode('utf-8'),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {public_key}",
|
||||||
|
"X-Timestamp": timestamp,
|
||||||
|
"X-Signature": signature,
|
||||||
|
"Content-Type": "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, method=method, headers=headers)
|
||||||
|
if body:
|
||||||
|
req.data = body.encode('utf-8')
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||||
|
return json.loads(resp.read().decode('utf-8'))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode('utf-8') if e.fp else str(e)
|
||||||
|
print(f"{RED}Error: HTTP {e.code} - {error_body}{RESET}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"{RED}Error: {e.reason}{RESET}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Environment Secrets Vault Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
MAX_ENV_CONTENT_SIZE = 64 * 1024 # 64KB max env vault size
|
||||||
|
|
||||||
|
|
||||||
|
def service_env_status(public_key, secret_key, service_id):
|
||||||
|
"""Get environment vault status for a service"""
|
||||||
|
result = api_request(f"/services/{service_id}/env", public_key=public_key, secret_key=secret_key)
|
||||||
|
|
||||||
|
has_vault = result.get("has_vault", False)
|
||||||
|
if not has_vault:
|
||||||
|
print("Vault exists: no")
|
||||||
|
print("Variable count: 0")
|
||||||
|
else:
|
||||||
|
print("Vault exists: yes")
|
||||||
|
count = result.get("count", 0)
|
||||||
|
print(f"Variable count: {count}")
|
||||||
|
updated_at = result.get("updated_at")
|
||||||
|
if updated_at:
|
||||||
|
from datetime import datetime
|
||||||
|
dt = datetime.fromtimestamp(updated_at)
|
||||||
|
print(f"Last updated: {dt.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
|
|
||||||
|
def service_env_set(public_key, secret_key, service_id, env_content):
|
||||||
|
"""Set environment vault for a service (PUT /services/:id/env)"""
|
||||||
|
if not env_content or len(env_content) == 0:
|
||||||
|
print(f"{RED}Error: No environment content provided{RESET}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(env_content) > MAX_ENV_CONTENT_SIZE:
|
||||||
|
print(f"{RED}Error: Environment content too large (max {MAX_ENV_CONTENT_SIZE} bytes){RESET}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = api_request_text(f"/services/{service_id}/env", method="PUT", body=env_content,
|
||||||
|
public_key=public_key, secret_key=secret_key)
|
||||||
|
if result is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
count = result.get("count", -1)
|
||||||
|
if count >= 0:
|
||||||
|
print(f"{GREEN}Environment vault updated: {count} variable{'s' if count != 1 else ''}{RESET}")
|
||||||
|
else:
|
||||||
|
print(f"{GREEN}Environment vault updated{RESET}")
|
||||||
|
|
||||||
|
message = result.get("message")
|
||||||
|
if message:
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def service_env_export(public_key, secret_key, service_id):
|
||||||
|
"""Export environment vault for a service (POST /services/:id/env/export)"""
|
||||||
|
result = api_request(f"/services/{service_id}/env/export", method="POST", data={},
|
||||||
|
public_key=public_key, secret_key=secret_key)
|
||||||
|
|
||||||
|
env_content = result.get("env", "")
|
||||||
|
if env_content:
|
||||||
|
print(env_content, end='')
|
||||||
|
if not env_content.endswith('\n'):
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def service_env_delete(public_key, secret_key, service_id):
|
||||||
|
"""Delete environment vault for a service (DELETE /services/:id/env)"""
|
||||||
|
result = api_request(f"/services/{service_id}/env", method="DELETE",
|
||||||
|
public_key=public_key, secret_key=secret_key)
|
||||||
|
|
||||||
|
print(f"{GREEN}Environment vault deleted{RESET}")
|
||||||
|
message = result.get("message")
|
||||||
|
if message:
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
|
||||||
|
def read_env_file(filepath):
|
||||||
|
"""Read .env file contents"""
|
||||||
|
try:
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
return f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"{RED}Error: Env file not found: {filepath}{RESET}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except IOError as e:
|
||||||
|
print(f"{RED}Error reading env file: {e}{RESET}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_env_content(env_vars, env_file=None):
|
||||||
|
"""Build .env format content from -e flags and/or --env-file"""
|
||||||
|
content_parts = []
|
||||||
|
|
||||||
|
# Read from env file first
|
||||||
|
if env_file:
|
||||||
|
content_parts.append(read_env_file(env_file))
|
||||||
|
|
||||||
|
# Add -e flags (these override/append to file)
|
||||||
|
if env_vars:
|
||||||
|
for e in env_vars:
|
||||||
|
if '=' in e:
|
||||||
|
content_parts.append(e)
|
||||||
|
|
||||||
|
return '\n'.join(content_parts) if content_parts else None
|
||||||
|
|
||||||
|
|
||||||
def cmd_execute(args):
|
def cmd_execute(args):
|
||||||
"""Execute source code"""
|
"""Execute source code"""
|
||||||
public_key, secret_key = get_api_keys(args.api_key)
|
public_key, secret_key = get_api_keys(args.api_key)
|
||||||
|
|
@ -521,6 +664,43 @@ def cmd_service(args):
|
||||||
"""Manage persistent services"""
|
"""Manage persistent services"""
|
||||||
public_key, secret_key = get_api_keys(args.api_key)
|
public_key, secret_key = get_api_keys(args.api_key)
|
||||||
|
|
||||||
|
# Handle env subcommand: un.py service env <action> <id>
|
||||||
|
if getattr(args, 'subcommand', None) == "env":
|
||||||
|
action = getattr(args, 'env_action', None)
|
||||||
|
target = getattr(args, 'env_target', None)
|
||||||
|
|
||||||
|
if not action:
|
||||||
|
print(f"{RED}Error: env action required (status, set, export, delete){RESET}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not target:
|
||||||
|
print(f"{RED}Error: Service ID required for env command{RESET}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if action == "status":
|
||||||
|
service_env_status(public_key, secret_key, target)
|
||||||
|
return
|
||||||
|
elif action == "set":
|
||||||
|
env_content = build_env_content(args.env, args.env_file)
|
||||||
|
if not env_content:
|
||||||
|
# Try reading from stdin
|
||||||
|
import select
|
||||||
|
if select.select([sys.stdin], [], [], 0.0)[0]:
|
||||||
|
env_content = sys.stdin.read()
|
||||||
|
if not env_content:
|
||||||
|
print(f"{RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin{RESET}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
service_env_set(public_key, secret_key, target, env_content)
|
||||||
|
return
|
||||||
|
elif action == "export":
|
||||||
|
service_env_export(public_key, secret_key, target)
|
||||||
|
return
|
||||||
|
elif action == "delete":
|
||||||
|
service_env_delete(public_key, secret_key, target)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(f"{RED}Error: Unknown env action '{action}'. Use: status, set, export, delete{RESET}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if args.list:
|
if args.list:
|
||||||
result = api_request("/services", public_key=public_key, secret_key=secret_key)
|
result = api_request("/services", public_key=public_key, secret_key=secret_key)
|
||||||
services = result.get("services", [])
|
services = result.get("services", [])
|
||||||
|
|
@ -658,10 +838,19 @@ def cmd_service(args):
|
||||||
payload["vcpu"] = args.vcpu
|
payload["vcpu"] = args.vcpu
|
||||||
|
|
||||||
result = api_request("/services", method="POST", data=payload, public_key=public_key, secret_key=secret_key)
|
result = api_request("/services", method="POST", data=payload, public_key=public_key, secret_key=secret_key)
|
||||||
print(f"{GREEN}Service created: {result.get('id', 'N/A')}{RESET}")
|
created_id = result.get('id')
|
||||||
|
print(f"{GREEN}Service created: {created_id or 'N/A'}{RESET}")
|
||||||
print(f"Name: {result.get('name', 'N/A')}")
|
print(f"Name: {result.get('name', 'N/A')}")
|
||||||
if result.get('url'):
|
if result.get('url'):
|
||||||
print(f"URL: {result.get('url')}")
|
print(f"URL: {result.get('url')}")
|
||||||
|
|
||||||
|
# Set environment vault if -e or --env-file provided
|
||||||
|
if created_id:
|
||||||
|
env_content = build_env_content(args.env, args.env_file)
|
||||||
|
if env_content:
|
||||||
|
print(f"{YELLOW}Setting environment vault...{RESET}", file=sys.stderr)
|
||||||
|
if not service_env_set(public_key, secret_key, created_id, env_content):
|
||||||
|
print(f"{YELLOW}Warning: Failed to set environment vault{RESET}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"{RED}Error: Specify --name to create a service, or use --list, --info, etc.{RESET}", file=sys.stderr)
|
print(f"{RED}Error: Specify --name to create a service, or use --list, --info, etc.{RESET}", file=sys.stderr)
|
||||||
|
|
@ -719,6 +908,10 @@ Examples:
|
||||||
|
|
||||||
# Service subcommand
|
# Service subcommand
|
||||||
service_parser = subparsers.add_parser("service", help="Persistent services")
|
service_parser = subparsers.add_parser("service", help="Persistent services")
|
||||||
|
# For env subcommand: un.py service env <action> <id>
|
||||||
|
service_parser.add_argument("subcommand", nargs="?", help="Subcommand (env)")
|
||||||
|
service_parser.add_argument("env_action", nargs="?", help="Env vault action (status, set, export, delete)")
|
||||||
|
service_parser.add_argument("env_target", nargs="?", help="Service ID for env command")
|
||||||
service_parser.add_argument("--name", help="Service name")
|
service_parser.add_argument("--name", help="Service name")
|
||||||
service_parser.add_argument("--ports", help="Comma-separated ports")
|
service_parser.add_argument("--ports", help="Comma-separated ports")
|
||||||
service_parser.add_argument("--domains", help="Comma-separated custom domains")
|
service_parser.add_argument("--domains", help="Comma-separated custom domains")
|
||||||
|
|
@ -726,6 +919,8 @@ Examples:
|
||||||
service_parser.add_argument("--bootstrap", help="Bootstrap command or URI")
|
service_parser.add_argument("--bootstrap", help="Bootstrap command or URI")
|
||||||
service_parser.add_argument("--bootstrap-file", dest="bootstrap_file", help="Upload local file as bootstrap script")
|
service_parser.add_argument("--bootstrap-file", dest="bootstrap_file", help="Upload local file as bootstrap script")
|
||||||
service_parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file")
|
service_parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file")
|
||||||
|
service_parser.add_argument("-e", "--env", action="append", metavar="KEY=VALUE", help="Set environment variable (stored in vault)")
|
||||||
|
service_parser.add_argument("--env-file", dest="env_file", metavar="FILE", help="Load env vars from .env file")
|
||||||
service_parser.add_argument("-l", "--list", action="store_true", help="List services")
|
service_parser.add_argument("-l", "--list", action="store_true", help="List services")
|
||||||
service_parser.add_argument("--info", metavar="ID", help="Get service details")
|
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("--tail", metavar="ID", help="Get last 9000 lines of logs")
|
||||||
|
|
|
||||||
192
un.r
192
un.r
|
|
@ -66,6 +66,7 @@ RESET <- "\033[0m"
|
||||||
|
|
||||||
API_BASE <- "https://api.unsandbox.com"
|
API_BASE <- "https://api.unsandbox.com"
|
||||||
PORTAL_BASE <- "https://unsandbox.com"
|
PORTAL_BASE <- "https://unsandbox.com"
|
||||||
|
MAX_ENV_CONTENT_SIZE <- 65536
|
||||||
|
|
||||||
detect_language <- function(filename) {
|
detect_language <- function(filename) {
|
||||||
ext <- tolower(sub(".*(\\..*)$", "\\1", filename))
|
ext <- tolower(sub(".*(\\..*)$", "\\1", filename))
|
||||||
|
|
@ -158,6 +159,138 @@ api_request <- function(endpoint, public_key, secret_key, method = "GET", data =
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api_request_text <- function(endpoint, public_key, secret_key, body) {
|
||||||
|
url <- paste0(API_BASE, endpoint)
|
||||||
|
headers <- add_headers(
|
||||||
|
`Content-Type` = "text/plain",
|
||||||
|
`Authorization` = paste("Bearer", public_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add HMAC signature if secret_key is present
|
||||||
|
if (secret_key != "") {
|
||||||
|
timestamp <- as.integer(Sys.time())
|
||||||
|
sig_input <- paste0(timestamp, ":PUT:", endpoint, ":", body)
|
||||||
|
signature <- hmac(sig_input, secret_key, algo = "sha256")
|
||||||
|
headers <- add_headers(
|
||||||
|
`Content-Type` = "text/plain",
|
||||||
|
`Authorization` = paste("Bearer", public_key),
|
||||||
|
`X-Timestamp` = as.character(timestamp),
|
||||||
|
`X-Signature` = signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
tryCatch({
|
||||||
|
response <- PUT(url, headers, body = body, encode = "raw", timeout(300))
|
||||||
|
status_code <- status_code(response)
|
||||||
|
return(status_code >= 200 && status_code < 300)
|
||||||
|
}, error = function(e) {
|
||||||
|
return(FALSE)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_file <- function(path) {
|
||||||
|
if (!file.exists(path)) {
|
||||||
|
cat(sprintf("%sError: Env file not found: %s%s\n", RED, path, RESET), file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
return(paste(readLines(path, warn = FALSE), collapse = "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
build_env_content <- function(envs, env_file) {
|
||||||
|
lines <- c()
|
||||||
|
if (!is.null(envs)) {
|
||||||
|
lines <- c(lines, envs)
|
||||||
|
}
|
||||||
|
if (!is.null(env_file) && env_file != "") {
|
||||||
|
content <- read_env_file(env_file)
|
||||||
|
for (line in strsplit(content, "\n")[[1]]) {
|
||||||
|
trimmed <- trimws(line)
|
||||||
|
if (nchar(trimmed) > 0 && !startsWith(trimmed, "#")) {
|
||||||
|
lines <- c(lines, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return(paste(lines, collapse = "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_service_env <- function(args) {
|
||||||
|
keys <- get_api_keys(args$api_key)
|
||||||
|
public_key <- keys$public_key
|
||||||
|
secret_key <- keys$secret_key
|
||||||
|
|
||||||
|
action <- args$env_action
|
||||||
|
target <- args$env_target
|
||||||
|
|
||||||
|
if (action == "status") {
|
||||||
|
if (is.null(target) || target == "") {
|
||||||
|
cat(sprintf("%sError: service env status requires service ID%s\n", RED, RESET), file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
result <- api_request(paste0("/services/", target, "/env"), public_key, secret_key)
|
||||||
|
if (!is.null(result$has_vault) && result$has_vault) {
|
||||||
|
cat(sprintf("%sVault: configured%s\n", GREEN, RESET))
|
||||||
|
if (!is.null(result$env_count)) {
|
||||||
|
cat(sprintf("Variables: %s\n", result$env_count))
|
||||||
|
}
|
||||||
|
if (!is.null(result$updated_at)) {
|
||||||
|
cat(sprintf("Updated: %s\n", result$updated_at))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cat(sprintf("%sVault: not configured%s\n", YELLOW, RESET))
|
||||||
|
}
|
||||||
|
return()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "set") {
|
||||||
|
if (is.null(target) || target == "") {
|
||||||
|
cat(sprintf("%sError: service env set requires service ID%s\n", RED, RESET), file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
if ((is.null(args$svc_envs) || length(args$svc_envs) == 0) && (is.null(args$svc_env_file) || args$svc_env_file == "")) {
|
||||||
|
cat(sprintf("%sError: service env set requires -e or --env-file%s\n", RED, RESET), file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
env_content <- build_env_content(args$svc_envs, args$svc_env_file)
|
||||||
|
if (nchar(env_content) > MAX_ENV_CONTENT_SIZE) {
|
||||||
|
cat(sprintf("%sError: Env content exceeds maximum size of 64KB%s\n", RED, RESET), file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
if (api_request_text(paste0("/services/", target, "/env"), public_key, secret_key, env_content)) {
|
||||||
|
cat(sprintf("%sVault updated for service %s%s\n", GREEN, target, RESET))
|
||||||
|
} else {
|
||||||
|
cat(sprintf("%sError: Failed to update vault%s\n", RED, RESET), file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
return()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "export") {
|
||||||
|
if (is.null(target) || target == "") {
|
||||||
|
cat(sprintf("%sError: service env export requires service ID%s\n", RED, RESET), file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
result <- api_request(paste0("/services/", target, "/env/export"), public_key, secret_key, method = "POST", data = list())
|
||||||
|
if (!is.null(result$content)) {
|
||||||
|
cat(result$content)
|
||||||
|
}
|
||||||
|
return()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == "delete") {
|
||||||
|
if (is.null(target) || target == "") {
|
||||||
|
cat(sprintf("%sError: service env delete requires service ID%s\n", RED, RESET), file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
result <- api_request(paste0("/services/", target, "/env"), public_key, secret_key, method = "DELETE")
|
||||||
|
cat(sprintf("%sVault deleted for service %s%s\n", GREEN, target, RESET))
|
||||||
|
return()
|
||||||
|
}
|
||||||
|
|
||||||
|
cat(sprintf("%sError: Unknown env action: %s%s\n", RED, action, RESET), file = stderr())
|
||||||
|
cat("Usage: un.r service env <status|set|export|delete> <service_id>\n", file = stderr())
|
||||||
|
quit(status = 1)
|
||||||
|
}
|
||||||
|
|
||||||
cmd_execute <- function(args) {
|
cmd_execute <- function(args) {
|
||||||
keys <- get_api_keys(args$api_key)
|
keys <- get_api_keys(args$api_key)
|
||||||
public_key <- keys$public_key
|
public_key <- keys$public_key
|
||||||
|
|
@ -517,6 +650,12 @@ cmd_snapshot <- function(args) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_service <- function(args) {
|
cmd_service <- function(args) {
|
||||||
|
# Handle env subcommand
|
||||||
|
if (!is.null(args$env_action) && args$env_action != "") {
|
||||||
|
cmd_service_env(args)
|
||||||
|
return()
|
||||||
|
}
|
||||||
|
|
||||||
keys <- get_api_keys(args$api_key)
|
keys <- get_api_keys(args$api_key)
|
||||||
public_key <- keys$public_key
|
public_key <- keys$public_key
|
||||||
secret_key <- keys$secret_key
|
secret_key <- keys$secret_key
|
||||||
|
|
@ -686,6 +825,19 @@ cmd_service <- function(args) {
|
||||||
if (!is.null(result$url)) {
|
if (!is.null(result$url)) {
|
||||||
cat(sprintf("URL: %s\n", result$url))
|
cat(sprintf("URL: %s\n", result$url))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Auto-set vault if -e or --env-file provided
|
||||||
|
if ((!is.null(args$svc_envs) && length(args$svc_envs) > 0) || (!is.null(args$svc_env_file) && args$svc_env_file != "")) {
|
||||||
|
service_id <- result$id
|
||||||
|
if (!is.null(service_id)) {
|
||||||
|
env_content <- build_env_content(args$svc_envs, args$svc_env_file)
|
||||||
|
if (api_request_text(paste0("/services/", service_id, "/env"), public_key, secret_key, env_content)) {
|
||||||
|
cat(sprintf("%sVault configured for service %s%s\n", GREEN, service_id, RESET))
|
||||||
|
} else {
|
||||||
|
cat(sprintf("%sWarning: Failed to set vault%s\n", YELLOW, RESET), file = stderr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return()
|
return()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -732,7 +884,11 @@ parse_args <- function() {
|
||||||
bootstrap = NULL,
|
bootstrap = NULL,
|
||||||
bootstrap_file = NULL,
|
bootstrap_file = NULL,
|
||||||
vcpu = NULL,
|
vcpu = NULL,
|
||||||
extend = FALSE
|
extend = FALSE,
|
||||||
|
svc_envs = NULL,
|
||||||
|
svc_env_file = NULL,
|
||||||
|
env_action = NULL,
|
||||||
|
env_target = NULL
|
||||||
)
|
)
|
||||||
|
|
||||||
i <- 1
|
i <- 1
|
||||||
|
|
@ -745,6 +901,18 @@ parse_args <- function() {
|
||||||
} else if (arg == "service") {
|
} else if (arg == "service") {
|
||||||
result$command <- "service"
|
result$command <- "service"
|
||||||
i <- i + 1
|
i <- i + 1
|
||||||
|
# Check for env subcommand
|
||||||
|
if (i <= length(args) && args[i] == "env") {
|
||||||
|
i <- i + 1
|
||||||
|
if (i <= length(args)) {
|
||||||
|
result$env_action <- args[i]
|
||||||
|
i <- i + 1
|
||||||
|
}
|
||||||
|
if (i <= length(args) && !startsWith(args[i], "-")) {
|
||||||
|
result$env_target <- args[i]
|
||||||
|
i <- i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (arg == "key") {
|
} else if (arg == "key") {
|
||||||
result$command <- "key"
|
result$command <- "key"
|
||||||
i <- i + 1
|
i <- i + 1
|
||||||
|
|
@ -761,7 +929,15 @@ parse_args <- function() {
|
||||||
i <- i + 1
|
i <- i + 1
|
||||||
} else if (arg %in% c("-e", "--env")) {
|
} else if (arg %in% c("-e", "--env")) {
|
||||||
i <- i + 1
|
i <- i + 1
|
||||||
result$env <- c(result$env, args[i])
|
if (!is.null(result$command) && result$command == "service") {
|
||||||
|
result$svc_envs <- c(result$svc_envs, args[i])
|
||||||
|
} else {
|
||||||
|
result$env <- c(result$env, args[i])
|
||||||
|
}
|
||||||
|
i <- i + 1
|
||||||
|
} else if (arg == "--env-file") {
|
||||||
|
i <- i + 1
|
||||||
|
result$svc_env_file <- args[i]
|
||||||
i <- i + 1
|
i <- i + 1
|
||||||
} else if (arg %in% c("-f", "--files")) {
|
} else if (arg %in% c("-f", "--files")) {
|
||||||
i <- i + 1
|
i <- i + 1
|
||||||
|
|
@ -887,8 +1063,14 @@ parse_args <- function() {
|
||||||
cat("Usage: un.r [options] <source_file>\n", file = stderr())
|
cat("Usage: un.r [options] <source_file>\n", file = stderr())
|
||||||
cat(" un.r session [options]\n", file = stderr())
|
cat(" un.r session [options]\n", file = stderr())
|
||||||
cat(" un.r service [options]\n", file = stderr())
|
cat(" un.r service [options]\n", file = stderr())
|
||||||
|
cat(" un.r service env <action> <service_id> [options]\n", file = stderr())
|
||||||
cat(" un.r snapshot [options]\n", file = stderr())
|
cat(" un.r snapshot [options]\n", file = stderr())
|
||||||
cat(" un.r key [options]\n", file = stderr())
|
cat(" un.r key [options]\n", file = stderr())
|
||||||
|
cat("\nService env commands:\n", file = stderr())
|
||||||
|
cat(" env status <id> Show vault status\n", file = stderr())
|
||||||
|
cat(" env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n", file = stderr())
|
||||||
|
cat(" env export <id> Export vault contents\n", file = stderr())
|
||||||
|
cat(" env delete <id> Delete vault\n", file = stderr())
|
||||||
quit(status = 1)
|
quit(status = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -913,8 +1095,14 @@ main <- function() {
|
||||||
cat("Usage: un.r [options] <source_file>\n", file = stderr())
|
cat("Usage: un.r [options] <source_file>\n", file = stderr())
|
||||||
cat(" un.r session [options]\n", file = stderr())
|
cat(" un.r session [options]\n", file = stderr())
|
||||||
cat(" un.r service [options]\n", file = stderr())
|
cat(" un.r service [options]\n", file = stderr())
|
||||||
|
cat(" un.r service env <action> <service_id> [options]\n", file = stderr())
|
||||||
cat(" un.r snapshot [options]\n", file = stderr())
|
cat(" un.r snapshot [options]\n", file = stderr())
|
||||||
cat(" un.r key [options]\n", file = stderr())
|
cat(" un.r key [options]\n", file = stderr())
|
||||||
|
cat("\nService env commands:\n", file = stderr())
|
||||||
|
cat(" env status <id> Show vault status\n", file = stderr())
|
||||||
|
cat(" env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n", file = stderr())
|
||||||
|
cat(" env export <id> Export vault contents\n", file = stderr())
|
||||||
|
cat(" env delete <id> Delete vault\n", file = stderr())
|
||||||
quit(status = 1)
|
quit(status = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
un.raku
138
un.raku
|
|
@ -139,7 +139,7 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$sec
|
||||||
@args.append: $url;
|
@args.append: $url;
|
||||||
|
|
||||||
my $proc = run |@args, :out, :err;
|
my $proc = run |@args, :out, :err;
|
||||||
my $body = $proc.out.slurp;
|
my $resp-body = $proc.out.slurp;
|
||||||
my $err = $proc.err.slurp;
|
my $err = $proc.err.slurp;
|
||||||
|
|
||||||
if $proc.exitcode != 0 {
|
if $proc.exitcode != 0 {
|
||||||
|
|
@ -149,7 +149,7 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$sec
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for clock drift errors
|
# Check for clock drift errors
|
||||||
if $body.contains('timestamp') && ($body.contains('401') || $body.contains('expired') || $body.contains('invalid')) {
|
if $resp-body.contains('timestamp') && ($resp-body.contains('401') || $resp-body.contains('expired') || $resp-body.contains('invalid')) {
|
||||||
note "{$RED}Error: Request timestamp expired (must be within 5 minutes of server time){$RESET}";
|
note "{$RED}Error: Request timestamp expired (must be within 5 minutes of server time){$RESET}";
|
||||||
note "{$YELLOW}Your computer's clock may have drifted.{$RESET}";
|
note "{$YELLOW}Your computer's clock may have drifted.{$RESET}";
|
||||||
note "{$YELLOW}Check your system time and sync with NTP if needed:{$RESET}";
|
note "{$YELLOW}Check your system time and sync with NTP if needed:{$RESET}";
|
||||||
|
|
@ -159,7 +159,32 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$sec
|
||||||
exit 1;
|
exit 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return from-json($body);
|
return from-json($resp-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
# API request for PUT with text/plain body (used for vault)
|
||||||
|
sub api-request-put-text(Str $endpoint, Str $content, Str :$public-key!, Str :$secret-key!) {
|
||||||
|
my $url = $API_BASE ~ $endpoint;
|
||||||
|
my @args = 'curl', '-s', '-X', 'PUT';
|
||||||
|
@args.append: '-H', 'Content-Type: text/plain';
|
||||||
|
@args.append: '-H', "Authorization: Bearer $public-key";
|
||||||
|
|
||||||
|
# Add HMAC signature if secret-key is present
|
||||||
|
if $secret-key {
|
||||||
|
my $timestamp = now.Int;
|
||||||
|
my $sig-input = "{$timestamp}:PUT:{$endpoint}:{$content}";
|
||||||
|
my $signature = hmac-hex($sig-input, $secret-key, &sha256);
|
||||||
|
@args.append: '-H', "X-Timestamp: $timestamp";
|
||||||
|
@args.append: '-H', "X-Signature: $signature";
|
||||||
|
}
|
||||||
|
|
||||||
|
@args.append: '--data-binary', $content;
|
||||||
|
@args.append: $url;
|
||||||
|
|
||||||
|
my $proc = run |@args, :out, :err;
|
||||||
|
my $resp-body = $proc.out.slurp;
|
||||||
|
|
||||||
|
return from-json($resp-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
sub cmd-execute(@args) {
|
sub cmd-execute(@args) {
|
||||||
|
|
@ -370,6 +395,41 @@ sub cmd-session(@args) {
|
||||||
say "{$YELLOW}(Interactive sessions require WebSocket - use un2 for full support){$RESET}";
|
say "{$YELLOW}(Interactive sessions require WebSocket - use un2 for full support){$RESET}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Service vault functions
|
||||||
|
sub service-env-status(Str $service-id, Str :$public-key!, Str :$secret-key!) {
|
||||||
|
my %result = api-request("/services/$service-id/env", 'GET', :$public-key, :$secret-key);
|
||||||
|
say to-json(%result, :pretty);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub service-env-set(Str $service-id, Str $content, Str :$public-key!, Str :$secret-key!) {
|
||||||
|
my %result = api-request-put-text("/services/$service-id/env", $content, :$public-key, :$secret-key);
|
||||||
|
say to-json(%result, :pretty);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub service-env-export(Str $service-id, Str :$public-key!, Str :$secret-key!) {
|
||||||
|
my %result = api-request("/services/$service-id/env/export", 'POST', :$public-key, :$secret-key);
|
||||||
|
say %result<content> if %result<content>;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub service-env-delete(Str $service-id, Str :$public-key!, Str :$secret-key!) {
|
||||||
|
api-request("/services/$service-id/env", 'DELETE', :$public-key, :$secret-key);
|
||||||
|
say "{$GREEN}Vault deleted for: $service-id{$RESET}";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub build-env-content(@env-vars, Str $env-file --> Str) {
|
||||||
|
my @lines;
|
||||||
|
for @env-vars -> $var {
|
||||||
|
@lines.push($var);
|
||||||
|
}
|
||||||
|
if $env-file && $env-file.IO.e {
|
||||||
|
for $env-file.IO.lines -> $line {
|
||||||
|
next if $line.starts-with('#') || $line.trim eq '';
|
||||||
|
@lines.push($line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return @lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
sub cmd-service(@args) {
|
sub cmd-service(@args) {
|
||||||
my ($public-key, $secret-key) = get-api-keys();
|
my ($public-key, $secret-key) = get-api-keys();
|
||||||
my $list-mode = False;
|
my $list-mode = False;
|
||||||
|
|
@ -388,6 +448,61 @@ sub cmd-service(@args) {
|
||||||
my $network = '';
|
my $network = '';
|
||||||
my $vcpu = 0;
|
my $vcpu = 0;
|
||||||
my @input-files;
|
my @input-files;
|
||||||
|
my @env-vars;
|
||||||
|
my $env-file = '';
|
||||||
|
my $env-action = '';
|
||||||
|
my $env-target = '';
|
||||||
|
|
||||||
|
# Check for 'env' subcommand first
|
||||||
|
if @args.elems >= 1 && @args[0] eq 'env' {
|
||||||
|
if @args.elems < 3 {
|
||||||
|
note "Usage: un.raku service env <status|set|export|delete> <service_id> [options]";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
$env-action = @args[1];
|
||||||
|
$env-target = @args[2];
|
||||||
|
|
||||||
|
# Parse remaining args for -e and --env-file
|
||||||
|
my $i = 3;
|
||||||
|
while $i < @args.elems {
|
||||||
|
given @args[$i] {
|
||||||
|
when '-e' {
|
||||||
|
$i++;
|
||||||
|
@env-vars.push(@args[$i]);
|
||||||
|
}
|
||||||
|
when '--env-file' {
|
||||||
|
$i++;
|
||||||
|
$env-file = @args[$i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
given $env-action {
|
||||||
|
when 'status' {
|
||||||
|
service-env-status($env-target, :$public-key, :$secret-key);
|
||||||
|
}
|
||||||
|
when 'set' {
|
||||||
|
my $content = build-env-content(@env-vars, $env-file);
|
||||||
|
if !$content {
|
||||||
|
note "{$RED}Error: No environment variables to set{$RESET}";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
service-env-set($env-target, $content, :$public-key, :$secret-key);
|
||||||
|
}
|
||||||
|
when 'export' {
|
||||||
|
service-env-export($env-target, :$public-key, :$secret-key);
|
||||||
|
}
|
||||||
|
when 'delete' {
|
||||||
|
service-env-delete($env-target, :$public-key, :$secret-key);
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
note "{$RED}Error: Unknown env action '$env-action'. Use status, set, export, or delete{$RESET}";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
my $i = 0;
|
my $i = 0;
|
||||||
|
|
@ -456,6 +571,14 @@ sub cmd-service(@args) {
|
||||||
$i++;
|
$i++;
|
||||||
@input-files.push(@args[$i]);
|
@input-files.push(@args[$i]);
|
||||||
}
|
}
|
||||||
|
when '-e' {
|
||||||
|
$i++;
|
||||||
|
@env-vars.push(@args[$i]);
|
||||||
|
}
|
||||||
|
when '--env-file' {
|
||||||
|
$i++;
|
||||||
|
$env-file = @args[$i];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$i++;
|
$i++;
|
||||||
}
|
}
|
||||||
|
|
@ -579,10 +702,17 @@ sub cmd-service(@args) {
|
||||||
say "{$GREEN}Service created: {%result<id>}{$RESET}";
|
say "{$GREEN}Service created: {%result<id>}{$RESET}";
|
||||||
say "Name: {%result<name>}";
|
say "Name: {%result<name>}";
|
||||||
say "URL: {%result<url>}" if %result<url>;
|
say "URL: {%result<url>}" if %result<url>;
|
||||||
|
|
||||||
|
# Auto-set vault if -e or --env-file were provided
|
||||||
|
my $env-content = build-env-content(@env-vars, $env-file);
|
||||||
|
if $env-content && %result<id> {
|
||||||
|
say "{$YELLOW}Setting vault for service...{$RESET}";
|
||||||
|
service-env-set(%result<id>, $env-content, :$public-key, :$secret-key);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
note "{$RED}Error: Specify --name to create a service, or use --list, --info, etc.{$RESET}";
|
note "{$RED}Error: Specify --name to create a service, or use --list, --info, env, etc.{$RESET}";
|
||||||
exit 1;
|
exit 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
183
un.rb
183
un.rb
|
|
@ -169,6 +169,152 @@ rescue => e
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def api_request_text(endpoint, method:, body:, keys:)
|
||||||
|
require 'openssl'
|
||||||
|
|
||||||
|
uri = URI("#{API_BASE}#{endpoint}")
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
http.use_ssl = true
|
||||||
|
http.read_timeout = 300
|
||||||
|
|
||||||
|
timestamp = Time.now.to_i.to_s
|
||||||
|
message = "#{timestamp}:#{method}:#{uri.path}:#{body}"
|
||||||
|
signature = OpenSSL::HMAC.hexdigest('SHA256', keys[:secret_key], message)
|
||||||
|
|
||||||
|
request = case method
|
||||||
|
when 'PUT' then Net::HTTP::Put.new(uri)
|
||||||
|
else raise "Unknown method: #{method}"
|
||||||
|
end
|
||||||
|
|
||||||
|
request['Authorization'] = "Bearer #{keys[:public_key]}"
|
||||||
|
request['X-Timestamp'] = timestamp
|
||||||
|
request['X-Signature'] = signature
|
||||||
|
request['Content-Type'] = 'text/plain'
|
||||||
|
request.body = body
|
||||||
|
|
||||||
|
response = http.request(request)
|
||||||
|
unless response.is_a?(Net::HTTPSuccess)
|
||||||
|
return { 'error' => "HTTP #{response.code} - #{response.body}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.parse(response.body)
|
||||||
|
rescue => e
|
||||||
|
{ 'error' => e.message }
|
||||||
|
end
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Environment Secrets Vault Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
MAX_ENV_CONTENT_SIZE = 64 * 1024 # 64KB max env vault size
|
||||||
|
|
||||||
|
def service_env_status(service_id, keys)
|
||||||
|
result = api_request("/services/#{service_id}/env", keys: keys)
|
||||||
|
has_vault = result['has_vault']
|
||||||
|
|
||||||
|
if !has_vault
|
||||||
|
puts "Vault exists: no"
|
||||||
|
puts "Variable count: 0"
|
||||||
|
else
|
||||||
|
puts "Vault exists: yes"
|
||||||
|
puts "Variable count: #{result['count'] || 0}"
|
||||||
|
if result['updated_at']
|
||||||
|
puts "Last updated: #{Time.at(result['updated_at']).strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def service_env_set(service_id, env_content, keys)
|
||||||
|
if env_content.nil? || env_content.empty?
|
||||||
|
warn "#{RED}Error: No environment content provided#{RESET}"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if env_content.bytesize > MAX_ENV_CONTENT_SIZE
|
||||||
|
warn "#{RED}Error: Environment content too large (max #{MAX_ENV_CONTENT_SIZE} bytes)#{RESET}"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
result = api_request_text("/services/#{service_id}/env", method: 'PUT', body: env_content, keys: keys)
|
||||||
|
|
||||||
|
if result['error']
|
||||||
|
warn "#{RED}Error: #{result['error']}#{RESET}"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
count = result['count'] || 0
|
||||||
|
plural = count == 1 ? '' : 's'
|
||||||
|
puts "#{GREEN}Environment vault updated: #{count} variable#{plural}#{RESET}"
|
||||||
|
puts result['message'] if result['message']
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def service_env_export(service_id, keys)
|
||||||
|
result = api_request("/services/#{service_id}/env/export", method: 'POST', data: {}, keys: keys)
|
||||||
|
env_content = result['env']
|
||||||
|
if env_content && !env_content.empty?
|
||||||
|
print env_content
|
||||||
|
puts unless env_content.end_with?("\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def service_env_delete(service_id, keys)
|
||||||
|
api_request("/services/#{service_id}/env", method: 'DELETE', keys: keys)
|
||||||
|
puts "#{GREEN}Environment vault deleted#{RESET}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_env_file(filepath)
|
||||||
|
File.read(filepath)
|
||||||
|
rescue => e
|
||||||
|
warn "#{RED}Error: Env file not found: #{filepath}#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_env_content(envs, env_file)
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Read from env file first
|
||||||
|
parts << read_env_file(env_file) if env_file && !env_file.empty?
|
||||||
|
|
||||||
|
# Add -e flags
|
||||||
|
envs.each do |e|
|
||||||
|
parts << e if e.include?('=')
|
||||||
|
end
|
||||||
|
|
||||||
|
parts.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def cmd_service_env(action, target, envs, env_file, keys)
|
||||||
|
if action.nil? || action.empty?
|
||||||
|
warn "#{RED}Error: env action required (status, set, export, delete)#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if target.nil? || target.empty?
|
||||||
|
warn "#{RED}Error: Service ID required for env command#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
case action
|
||||||
|
when 'status'
|
||||||
|
service_env_status(target, keys)
|
||||||
|
when 'set'
|
||||||
|
env_content = build_env_content(envs, env_file)
|
||||||
|
if env_content.empty?
|
||||||
|
warn "#{RED}Error: No env content provided. Use -e KEY=VAL or --env-file#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
service_env_set(target, env_content, keys)
|
||||||
|
when 'export'
|
||||||
|
service_env_export(target, keys)
|
||||||
|
when 'delete'
|
||||||
|
service_env_delete(target, keys)
|
||||||
|
else
|
||||||
|
warn "#{RED}Error: Unknown env action '#{action}'. Use: status, set, export, delete#{RESET}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def cmd_execute(options)
|
def cmd_execute(options)
|
||||||
keys = get_api_keys(options[:api_key])
|
keys = get_api_keys(options[:api_key])
|
||||||
|
|
||||||
|
|
@ -620,9 +766,16 @@ def cmd_service(options)
|
||||||
payload[:vcpu] = options[:vcpu] if options[:vcpu]
|
payload[:vcpu] = options[:vcpu] if options[:vcpu]
|
||||||
|
|
||||||
result = api_request('/services', method: 'POST', data: payload, keys: keys)
|
result = api_request('/services', method: 'POST', data: payload, keys: keys)
|
||||||
puts "#{GREEN}Service created: #{result['id'] || 'N/A'}#{RESET}"
|
service_id = result['id']
|
||||||
|
puts "#{GREEN}Service created: #{service_id || 'N/A'}#{RESET}"
|
||||||
puts "Name: #{result['name'] || 'N/A'}"
|
puts "Name: #{result['name'] || 'N/A'}"
|
||||||
puts "URL: #{result['url']}" if result['url']
|
puts "URL: #{result['url']}" if result['url']
|
||||||
|
|
||||||
|
# Auto-set vault if -e or --env-file provided
|
||||||
|
env_content = build_env_content(options[:env] || [], options[:env_file])
|
||||||
|
if !env_content.empty? && service_id
|
||||||
|
service_env_set(service_id, env_content, keys)
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -678,7 +831,10 @@ def main
|
||||||
dump_file: nil,
|
dump_file: nil,
|
||||||
extend: false,
|
extend: false,
|
||||||
bootstrap_file: nil,
|
bootstrap_file: nil,
|
||||||
exec_shell: nil
|
exec_shell: nil,
|
||||||
|
env_file: nil,
|
||||||
|
env_action: nil,
|
||||||
|
env_target: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
# Manual argument parsing
|
# Manual argument parsing
|
||||||
|
|
@ -749,6 +905,21 @@ def main
|
||||||
when '--bootstrap-file'
|
when '--bootstrap-file'
|
||||||
i += 1
|
i += 1
|
||||||
options[:bootstrap_file] = ARGV[i]
|
options[:bootstrap_file] = ARGV[i]
|
||||||
|
when '--env-file'
|
||||||
|
i += 1
|
||||||
|
options[:env_file] = ARGV[i]
|
||||||
|
when 'env'
|
||||||
|
# Handle "service env <action> <target>" subcommand
|
||||||
|
if options[:command] == 'service'
|
||||||
|
i += 1
|
||||||
|
options[:env_action] = ARGV[i] if i < ARGV.length
|
||||||
|
i += 1
|
||||||
|
if i < ARGV.length && !ARGV[i].start_with?('-')
|
||||||
|
options[:env_target] = ARGV[i]
|
||||||
|
else
|
||||||
|
i -= 1 # back up if next arg is a flag
|
||||||
|
end
|
||||||
|
end
|
||||||
when '--info'
|
when '--info'
|
||||||
i += 1
|
i += 1
|
||||||
options[:info] = ARGV[i]
|
options[:info] = ARGV[i]
|
||||||
|
|
@ -846,7 +1017,13 @@ def main
|
||||||
when 'session'
|
when 'session'
|
||||||
cmd_session(options)
|
cmd_session(options)
|
||||||
when 'service'
|
when 'service'
|
||||||
cmd_service(options)
|
# Check for "service env" subcommand
|
||||||
|
if options[:env_action]
|
||||||
|
keys = get_api_keys(options[:api_key])
|
||||||
|
cmd_service_env(options[:env_action], options[:env_target], options[:env], options[:env_file], keys)
|
||||||
|
else
|
||||||
|
cmd_service(options)
|
||||||
|
end
|
||||||
when 'snapshot'
|
when 'snapshot'
|
||||||
cmd_snapshot(options)
|
cmd_snapshot(options)
|
||||||
when 'key'
|
when 'key'
|
||||||
|
|
|
||||||
196
un.rs
196
un.rs
|
|
@ -244,6 +244,153 @@ fn api_request(endpoint: &str, method: &str, body: Option<&str>, public_key: &st
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn api_request_text(endpoint: &str, method: &str, body: &str, public_key: &str, secret_key: &str) -> String {
|
||||||
|
let url = format!("{}{}", API_BASE, endpoint);
|
||||||
|
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
.to_string();
|
||||||
|
let signature = compute_hmac(secret_key, ×tamp, method, endpoint, body);
|
||||||
|
|
||||||
|
let mut cmd = Command::new("curl");
|
||||||
|
cmd.arg("-s")
|
||||||
|
.arg("-X")
|
||||||
|
.arg(method)
|
||||||
|
.arg(&url)
|
||||||
|
.arg("-H")
|
||||||
|
.arg("Content-Type: text/plain")
|
||||||
|
.arg("-H")
|
||||||
|
.arg(format!("Authorization: Bearer {}", public_key))
|
||||||
|
.arg("-H")
|
||||||
|
.arg(format!("X-Timestamp: {}", timestamp))
|
||||||
|
.arg("-H")
|
||||||
|
.arg(format!("X-Signature: {}", signature));
|
||||||
|
|
||||||
|
if !body.is_empty() {
|
||||||
|
cmd.arg("-d").arg(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd.output().unwrap_or_else(|e| {
|
||||||
|
eprintln!("{}Error running curl: {}{}", RED, e, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
String::from_utf8_lossy(&output.stdout).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_env_file(path: &str) -> String {
|
||||||
|
fs::read_to_string(path).unwrap_or_else(|e| {
|
||||||
|
eprintln!("{}Error reading env file: {}{}", RED, e, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_env_content(envs: &[String], env_file: Option<&str>) -> String {
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
if let Some(path) = env_file {
|
||||||
|
parts.push(read_env_file(path).trim().to_string());
|
||||||
|
}
|
||||||
|
for e in envs {
|
||||||
|
if e.contains('=') {
|
||||||
|
parts.push(e.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_env_status(service_id: &str, public_key: &str, secret_key: &str) -> String {
|
||||||
|
api_request(&format!("/services/{}/env", service_id), "GET", None, public_key, secret_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_env_set(service_id: &str, env_content: &str, public_key: &str, secret_key: &str) -> bool {
|
||||||
|
api_request_text(&format!("/services/{}/env", service_id), "PUT", env_content, public_key, secret_key);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_env_export(service_id: &str, public_key: &str, secret_key: &str) -> String {
|
||||||
|
api_request(&format!("/services/{}/env/export", service_id), "POST", None, public_key, secret_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_env_delete(service_id: &str, public_key: &str, secret_key: &str) -> bool {
|
||||||
|
api_request(&format!("/services/{}/env", service_id), "DELETE", None, public_key, secret_key);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_service_env(
|
||||||
|
action: &str,
|
||||||
|
target: Option<&str>,
|
||||||
|
envs: &[String],
|
||||||
|
env_file: Option<&str>,
|
||||||
|
public_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) {
|
||||||
|
match action {
|
||||||
|
"status" => {
|
||||||
|
let id = target.unwrap_or_else(|| {
|
||||||
|
eprintln!("{}Error: Usage: service env status <service_id>{}", RED, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
let result = service_env_status(id, public_key, secret_key);
|
||||||
|
let has_env = result.contains("\"has_env\":true");
|
||||||
|
let size = extract_json_int(&result, "size");
|
||||||
|
let updated_at = extract_json_string(&result, "updated_at");
|
||||||
|
println!("Service: {}", id);
|
||||||
|
println!("Has Vault: {}", if has_env { "Yes" } else { "No" });
|
||||||
|
if has_env {
|
||||||
|
println!("Size: {} bytes", size);
|
||||||
|
println!("Updated: {}", updated_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"set" => {
|
||||||
|
let id = target.unwrap_or_else(|| {
|
||||||
|
eprintln!("{}Error: Usage: service env set <service_id> [-e KEY=VAL] [--env-file FILE]{}", RED, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
let env_content = build_env_content(envs, env_file);
|
||||||
|
if env_content.is_empty() {
|
||||||
|
eprintln!("{}Error: No environment variables specified. Use -e KEY=VAL or --env-file FILE{}", RED, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
if env_content.len() > 65536 {
|
||||||
|
eprintln!("{}Error: Environment content exceeds 64KB limit{}", RED, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
service_env_set(id, &env_content, public_key, secret_key);
|
||||||
|
println!("{}Vault updated for service: {}{}", GREEN, id, RESET);
|
||||||
|
}
|
||||||
|
"export" => {
|
||||||
|
let id = target.unwrap_or_else(|| {
|
||||||
|
eprintln!("{}Error: Usage: service env export <service_id>{}", RED, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
let result = service_env_export(id, public_key, secret_key);
|
||||||
|
let content = extract_json_string(&result, "content");
|
||||||
|
if !content.is_empty() {
|
||||||
|
print!("{}", content);
|
||||||
|
if !content.ends_with('\n') {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("{}Vault is empty{}", YELLOW, RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"delete" => {
|
||||||
|
let id = target.unwrap_or_else(|| {
|
||||||
|
eprintln!("{}Error: Usage: service env delete <service_id>{}", RED, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
service_env_delete(id, public_key, secret_key);
|
||||||
|
println!("{}Vault deleted for service: {}{}", GREEN, id, RESET);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("{}Error: Unknown env action: {}. Use status, set, export, or delete{}", RED, action, RESET);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_execute(
|
fn cmd_execute(
|
||||||
source_file: &str,
|
source_file: &str,
|
||||||
envs: Vec<String>,
|
envs: Vec<String>,
|
||||||
|
|
@ -430,9 +577,19 @@ fn cmd_service(
|
||||||
dump_file: Option<&str>,
|
dump_file: Option<&str>,
|
||||||
network: Option<&str>,
|
network: Option<&str>,
|
||||||
vcpu: Option<i32>,
|
vcpu: Option<i32>,
|
||||||
|
envs: &[String],
|
||||||
|
env_file: Option<&str>,
|
||||||
|
env_action: Option<&str>,
|
||||||
|
env_target: Option<&str>,
|
||||||
public_key: &str,
|
public_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) {
|
) {
|
||||||
|
// Handle service env subcommand
|
||||||
|
if let Some(action) = env_action {
|
||||||
|
cmd_service_env(action, env_target, envs, env_file, public_key, secret_key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if list {
|
if list {
|
||||||
let result = api_request("/services", "GET", None, public_key, secret_key);
|
let result = api_request("/services", "GET", None, public_key, secret_key);
|
||||||
println!("{}", result);
|
println!("{}", result);
|
||||||
|
|
@ -601,6 +758,16 @@ fn cmd_service(
|
||||||
let result = api_request("/services", "POST", Some(&json), public_key, secret_key);
|
let result = api_request("/services", "POST", Some(&json), public_key, secret_key);
|
||||||
let id = extract_json_string(&result, "id");
|
let id = extract_json_string(&result, "id");
|
||||||
println!("{}Service created: {}{}", GREEN, id, RESET);
|
println!("{}Service created: {}{}", GREEN, id, RESET);
|
||||||
|
|
||||||
|
// Auto-set vault if env vars provided
|
||||||
|
if !id.is_empty() && (!envs.is_empty() || env_file.is_some()) {
|
||||||
|
let env_content = build_env_content(envs, env_file);
|
||||||
|
if !env_content.is_empty() && env_content.len() <= 65536 {
|
||||||
|
if service_env_set(&id, &env_content, public_key, secret_key) {
|
||||||
|
println!("{}Vault configured with environment variables{}", GREEN, RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -778,13 +945,36 @@ fn main() {
|
||||||
}
|
}
|
||||||
"service" => {
|
"service" => {
|
||||||
let (public_key, secret_key) = get_api_keys(api_key.as_deref());
|
let (public_key, secret_key) = get_api_keys(api_key.as_deref());
|
||||||
// Collect -f files for service
|
// Collect -f files and -e envs for service
|
||||||
let mut service_files: Vec<String> = Vec::new();
|
let mut service_files: Vec<String> = Vec::new();
|
||||||
|
let mut service_envs: Vec<String> = Vec::new();
|
||||||
|
let mut env_file_opt: Option<String> = None;
|
||||||
|
let mut env_action: Option<String> = None;
|
||||||
|
let mut env_target: Option<String> = None;
|
||||||
let mut j = i + 1;
|
let mut j = i + 1;
|
||||||
while j < args.len() {
|
while j < args.len() {
|
||||||
if args[j] == "-f" && j + 1 < args.len() {
|
if args[j] == "-f" && j + 1 < args.len() {
|
||||||
service_files.push(args[j + 1].clone());
|
service_files.push(args[j + 1].clone());
|
||||||
j += 2;
|
j += 2;
|
||||||
|
} else if args[j] == "-e" && j + 1 < args.len() {
|
||||||
|
service_envs.push(args[j + 1].clone());
|
||||||
|
j += 2;
|
||||||
|
} else if args[j] == "--env-file" && j + 1 < args.len() {
|
||||||
|
env_file_opt = Some(args[j + 1].clone());
|
||||||
|
j += 2;
|
||||||
|
} else if args[j] == "env" && env_action.is_none() {
|
||||||
|
// service env <action> <target>
|
||||||
|
if j + 1 < args.len() && !args[j + 1].starts_with('-') {
|
||||||
|
env_action = Some(args[j + 1].clone());
|
||||||
|
if j + 2 < args.len() && !args[j + 2].starts_with('-') {
|
||||||
|
env_target = Some(args[j + 2].clone());
|
||||||
|
j += 3;
|
||||||
|
} else {
|
||||||
|
j += 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -810,6 +1000,10 @@ fn main() {
|
||||||
args.iter().position(|x| x == "--dump-file").and_then(|p| args.get(p + 1)).map(|s| s.as_str()),
|
args.iter().position(|x| x == "--dump-file").and_then(|p| args.get(p + 1)).map(|s| s.as_str()),
|
||||||
network.as_deref(),
|
network.as_deref(),
|
||||||
vcpu,
|
vcpu,
|
||||||
|
&service_envs,
|
||||||
|
env_file_opt.as_deref(),
|
||||||
|
env_action.as_deref(),
|
||||||
|
env_target.as_deref(),
|
||||||
&public_key,
|
&public_key,
|
||||||
&secret_key,
|
&secret_key,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
137
un.scm
137
un.scm
|
|
@ -243,6 +243,58 @@
|
||||||
(exit 1))
|
(exit 1))
|
||||||
output))
|
output))
|
||||||
|
|
||||||
|
(define (curl-put-text api-key endpoint content)
|
||||||
|
"PUT request with text/plain content type (for vault)"
|
||||||
|
(let* ((tmp-file (write-temp-file content))
|
||||||
|
(keys (get-api-keys))
|
||||||
|
(public-key (car keys))
|
||||||
|
(secret-key (cadr keys))
|
||||||
|
(auth-headers (build-auth-headers public-key secret-key "PUT" endpoint content))
|
||||||
|
(cmd (string-append "curl -s -X PUT https://api.unsandbox.com" endpoint
|
||||||
|
" -H 'Content-Type: text/plain' "
|
||||||
|
(string-join auth-headers " ")
|
||||||
|
" --data-binary @" 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)
|
||||||
|
output))
|
||||||
|
|
||||||
|
(define (build-env-content env-vars env-file)
|
||||||
|
"Build env content from -e args and --env-file"
|
||||||
|
(let* ((var-lines env-vars)
|
||||||
|
(file-lines (if (and env-file (file-exists? env-file))
|
||||||
|
(let ((content (read-file env-file)))
|
||||||
|
(filter (lambda (line)
|
||||||
|
(let ((trimmed (string-trim-both line)))
|
||||||
|
(and (> (string-length trimmed) 0)
|
||||||
|
(not (char=? (string-ref trimmed 0) #\#)))))
|
||||||
|
(string-split content #\newline)))
|
||||||
|
'())))
|
||||||
|
(string-join (append var-lines file-lines) "\n")))
|
||||||
|
|
||||||
|
;; Service vault functions
|
||||||
|
(define (service-env-status api-key service-id)
|
||||||
|
(display (curl-get api-key (format #f "/services/~a/env" service-id)))
|
||||||
|
(newline))
|
||||||
|
|
||||||
|
(define (service-env-set api-key service-id content)
|
||||||
|
(display (curl-put-text api-key (format #f "/services/~a/env" service-id) content))
|
||||||
|
(newline))
|
||||||
|
|
||||||
|
(define (service-env-export api-key service-id)
|
||||||
|
(let* ((response (curl-post api-key (format #f "/services/~a/env/export" service-id) "{}"))
|
||||||
|
(content (json-extract-string response "content")))
|
||||||
|
(when content (display content))))
|
||||||
|
|
||||||
|
(define (service-env-delete api-key service-id)
|
||||||
|
(curl-delete api-key (format #f "/services/~a/env" service-id))
|
||||||
|
(format #t "~aVault deleted for: ~a~a\n" green service-id reset))
|
||||||
|
|
||||||
(define (get-api-keys)
|
(define (get-api-keys)
|
||||||
(let ((public-key (getenv "UNSANDBOX_PUBLIC_KEY"))
|
(let ((public-key (getenv "UNSANDBOX_PUBLIC_KEY"))
|
||||||
(secret-key (getenv "UNSANDBOX_SECRET_KEY"))
|
(secret-key (getenv "UNSANDBOX_SECRET_KEY"))
|
||||||
|
|
@ -382,7 +434,7 @@
|
||||||
(display response)
|
(display response)
|
||||||
(newline))))))
|
(newline))))))
|
||||||
|
|
||||||
(define (service-cmd action id name ports bootstrap bootstrap-file type input-files)
|
(define (service-cmd action id name ports bootstrap bootstrap-file type input-files env-vars env-file)
|
||||||
(let ((api-key (get-api-key)))
|
(let ((api-key (get-api-key)))
|
||||||
(cond
|
(cond
|
||||||
((equal? action "list")
|
((equal? action "list")
|
||||||
|
|
@ -403,6 +455,19 @@
|
||||||
((equal? action "destroy")
|
((equal? action "destroy")
|
||||||
(curl-delete api-key (format #f "/services/~a" id))
|
(curl-delete api-key (format #f "/services/~a" id))
|
||||||
(format #t "~aService destroyed: ~a~a\n" green id reset))
|
(format #t "~aService destroyed: ~a~a\n" green id reset))
|
||||||
|
((equal? action "env-status")
|
||||||
|
(service-env-status api-key id))
|
||||||
|
((equal? action "env-set")
|
||||||
|
(let ((content (build-env-content env-vars env-file)))
|
||||||
|
(if (> (string-length content) 0)
|
||||||
|
(service-env-set api-key id content)
|
||||||
|
(begin
|
||||||
|
(format (current-error-port) "~aError: No environment variables to set~a\n" red reset)
|
||||||
|
(exit 1)))))
|
||||||
|
((equal? action "env-export")
|
||||||
|
(service-env-export api-key id))
|
||||||
|
((equal? action "env-delete")
|
||||||
|
(service-env-delete api-key id))
|
||||||
((equal? action "execute")
|
((equal? action "execute")
|
||||||
(when (and id bootstrap)
|
(when (and id bootstrap)
|
||||||
(let* ((json (format #f "{\"command\":\"~a\"}" (escape-json bootstrap)))
|
(let* ((json (format #f "{\"command\":\"~a\"}" (escape-json bootstrap)))
|
||||||
|
|
@ -436,12 +501,18 @@
|
||||||
(type-json (if type (format #f ",\"service_type\":\"~a\"" type) ""))
|
(type-json (if type (format #f ",\"service_type\":\"~a\"" type) ""))
|
||||||
(input-files-json (build-input-files-json input-files))
|
(input-files-json (build-input-files-json input-files))
|
||||||
(json (format #f "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json))
|
(json (format #f "{\"name\":\"~a\"~a~a~a~a~a}" name ports-json bootstrap-json bootstrap-content-json type-json input-files-json))
|
||||||
(response (curl-post api-key "/services" json)))
|
(response (curl-post api-key "/services" json))
|
||||||
|
(service-id (json-extract-string response "id")))
|
||||||
(format #t "~aService created~a\n" green reset)
|
(format #t "~aService created~a\n" green reset)
|
||||||
(display response)
|
(display response)
|
||||||
(newline)))
|
(newline)
|
||||||
|
;; Auto-set vault if env vars were provided
|
||||||
|
(let ((env-content (build-env-content env-vars env-file)))
|
||||||
|
(when (and service-id (> (string-length env-content) 0))
|
||||||
|
(format #t "~aSetting vault for service...~a\n" yellow reset)
|
||||||
|
(service-env-set api-key service-id env-content)))))
|
||||||
(else
|
(else
|
||||||
(display "Error: --name required to create service\n" (current-error-port))
|
(display "Error: --name required to create service, or use env subcommand\n" (current-error-port))
|
||||||
(exit 1)))))
|
(exit 1)))))
|
||||||
|
|
||||||
(define (parse-input-files args)
|
(define (parse-input-files args)
|
||||||
|
|
@ -497,23 +568,53 @@
|
||||||
((equal? (car args) "service")
|
((equal? (car args) "service")
|
||||||
(cond
|
(cond
|
||||||
((and (> (length args) 1) (equal? (cadr args) "--list"))
|
((and (> (length args) 1) (equal? (cadr args) "--list"))
|
||||||
(service-cmd "list" #f #f #f #f #f #f '()))
|
(service-cmd "list" #f #f #f #f #f #f '() '() #f))
|
||||||
((and (> (length args) 2) (equal? (cadr args) "--info"))
|
((and (> (length args) 2) (equal? (cadr args) "--info"))
|
||||||
(service-cmd "info" (caddr args) #f #f #f #f #f '()))
|
(service-cmd "info" (caddr args) #f #f #f #f #f '() '() #f))
|
||||||
((and (> (length args) 2) (equal? (cadr args) "--logs"))
|
((and (> (length args) 2) (equal? (cadr args) "--logs"))
|
||||||
(service-cmd "logs" (caddr args) #f #f #f #f #f '()))
|
(service-cmd "logs" (caddr args) #f #f #f #f #f '() '() #f))
|
||||||
((and (> (length args) 2) (equal? (cadr args) "--freeze"))
|
((and (> (length args) 2) (equal? (cadr args) "--freeze"))
|
||||||
(service-cmd "sleep" (caddr args) #f #f #f #f #f '()))
|
(service-cmd "sleep" (caddr args) #f #f #f #f #f '() '() #f))
|
||||||
((and (> (length args) 2) (equal? (cadr args) "--unfreeze"))
|
((and (> (length args) 2) (equal? (cadr args) "--unfreeze"))
|
||||||
(service-cmd "wake" (caddr args) #f #f #f #f #f '()))
|
(service-cmd "wake" (caddr args) #f #f #f #f #f '() '() #f))
|
||||||
((and (> (length args) 2) (equal? (cadr args) "--destroy"))
|
((and (> (length args) 2) (equal? (cadr args) "--destroy"))
|
||||||
(service-cmd "destroy" (caddr args) #f #f #f #f #f '()))
|
(service-cmd "destroy" (caddr args) #f #f #f #f #f '() '() #f))
|
||||||
((and (> (length args) 3) (equal? (cadr args) "--execute"))
|
((and (> (length args) 3) (equal? (cadr args) "--execute"))
|
||||||
(service-cmd "execute" (caddr args) #f #f (list-ref args 3) #f #f '()))
|
(service-cmd "execute" (caddr args) #f #f (list-ref args 3) #f #f '() '() #f))
|
||||||
((and (> (length args) 3) (equal? (cadr args) "--dump-bootstrap"))
|
((and (> (length args) 3) (equal? (cadr args) "--dump-bootstrap"))
|
||||||
(service-cmd "dump-bootstrap" (caddr args) #f #f #f #f (list-ref args 3) '()))
|
(service-cmd "dump-bootstrap" (caddr args) #f #f #f #f (list-ref args 3) '() '() #f))
|
||||||
((and (> (length args) 2) (equal? (cadr args) "--dump-bootstrap"))
|
((and (> (length args) 2) (equal? (cadr args) "--dump-bootstrap"))
|
||||||
(service-cmd "dump-bootstrap" (caddr args) #f #f #f #f #f '()))
|
(service-cmd "dump-bootstrap" (caddr args) #f #f #f #f #f '() '() #f))
|
||||||
|
;; Service env subcommand: service env <action> <id> [options]
|
||||||
|
((and (> (length args) 1) (equal? (cadr args) "env"))
|
||||||
|
(if (< (length args) 4)
|
||||||
|
(begin
|
||||||
|
(display "Usage: un.scm service env <status|set|export|delete> <service_id> [options]\n" (current-error-port))
|
||||||
|
(exit 1))
|
||||||
|
(let* ((env-action (caddr args))
|
||||||
|
(service-id (list-ref args 3))
|
||||||
|
(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))
|
||||||
|
((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)
|
||||||
|
(cond
|
||||||
|
((and (equal? (car args) "-e") (pair? (cdr args)))
|
||||||
|
(loop (cddr args) (cons (cadr args) env-vars) env-file))
|
||||||
|
((and (equal? (car args) "--env-file") (pair? (cdr args)))
|
||||||
|
(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))
|
||||||
|
((equal? env-action "delete")
|
||||||
|
(service-cmd "env-delete" service-id #f #f #f #f #f '() '() #f))
|
||||||
|
(else
|
||||||
|
(format (current-error-port) "~aUnknown env action: ~a~a\n" red env-action reset)
|
||||||
|
(exit 1))))))
|
||||||
((and (> (length args) 2) (equal? (cadr args) "--name"))
|
((and (> (length args) 2) (equal? (cadr args) "--name"))
|
||||||
(let* ((name (caddr args))
|
(let* ((name (caddr args))
|
||||||
(rest-args (cdddr args))
|
(rest-args (cdddr args))
|
||||||
|
|
@ -521,6 +622,8 @@
|
||||||
(bootstrap #f)
|
(bootstrap #f)
|
||||||
(bootstrap-file #f)
|
(bootstrap-file #f)
|
||||||
(type #f)
|
(type #f)
|
||||||
|
(env-vars '())
|
||||||
|
(env-file #f)
|
||||||
(input-files (parse-input-files rest-args)))
|
(input-files (parse-input-files rest-args)))
|
||||||
;; Parse remaining args
|
;; Parse remaining args
|
||||||
(let loop ((args rest-args))
|
(let loop ((args rest-args))
|
||||||
|
|
@ -538,10 +641,16 @@
|
||||||
((equal? (car args) "--type")
|
((equal? (car args) "--type")
|
||||||
(set! type (cadr args))
|
(set! type (cadr args))
|
||||||
(loop (cddr args)))
|
(loop (cddr args)))
|
||||||
|
((equal? (car args) "-e")
|
||||||
|
(set! env-vars (cons (cadr args) env-vars))
|
||||||
|
(loop (cddr args)))
|
||||||
|
((equal? (car args) "--env-file")
|
||||||
|
(set! env-file (cadr args))
|
||||||
|
(loop (cddr args)))
|
||||||
((equal? (car args) "-f")
|
((equal? (car args) "-f")
|
||||||
(loop (cddr args))) ; skip -f, already parsed
|
(loop (cddr args))) ; skip -f, already parsed
|
||||||
(else (loop (cdr args))))))
|
(else (loop (cdr args))))))
|
||||||
(service-cmd "create" #f name ports bootstrap bootstrap-file type input-files)))
|
(service-cmd "create" #f name ports bootstrap bootstrap-file type input-files env-vars env-file)))
|
||||||
(else
|
(else
|
||||||
(display "Error: Invalid service command\n" (current-error-port))
|
(display "Error: Invalid service command\n" (current-error-port))
|
||||||
(exit 1))))
|
(exit 1))))
|
||||||
|
|
|
||||||
265
un.sh
265
un.sh
|
|
@ -206,6 +206,177 @@ api_request() {
|
||||||
echo "$body"
|
echo "$body"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api_request_text() {
|
||||||
|
local endpoint="$1"
|
||||||
|
local method="${2:-PUT}"
|
||||||
|
local body="${3:-}"
|
||||||
|
local public_key="${4:-${UNSANDBOX_PUBLIC_KEY:-}}"
|
||||||
|
local secret_key="${5:-${UNSANDBOX_SECRET_KEY:-}}"
|
||||||
|
|
||||||
|
# Fallback to old UNSANDBOX_API_KEY for backwards compat
|
||||||
|
if [[ -z "$public_key" ]] && [[ -n "${UNSANDBOX_API_KEY:-}" ]]; then
|
||||||
|
public_key="${UNSANDBOX_API_KEY}"
|
||||||
|
secret_key=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$public_key" ]]; then
|
||||||
|
echo -e "${RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${RESET}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local url="${API_BASE}${endpoint}"
|
||||||
|
local timestamp=$(date +%s)
|
||||||
|
|
||||||
|
# Build HMAC signature: timestamp:METHOD:path:body
|
||||||
|
local sig_input="${timestamp}:${method}:${endpoint}:${body}"
|
||||||
|
local signature=""
|
||||||
|
|
||||||
|
if [[ -n "$secret_key" ]]; then
|
||||||
|
signature=$(echo -n "$sig_input" | openssl dgst -sha256 -hmac "$secret_key" | sed 's/^.* //')
|
||||||
|
fi
|
||||||
|
|
||||||
|
local response
|
||||||
|
if [[ -n "$signature" ]]; then
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \
|
||||||
|
-H "Authorization: Bearer $public_key" \
|
||||||
|
-H "X-Timestamp: $timestamp" \
|
||||||
|
-H "X-Signature: $signature" \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
-d "$body" 2>&1)
|
||||||
|
else
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \
|
||||||
|
-H "Authorization: Bearer $public_key" \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
-d "$body" 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
local http_code=$(echo "$response" | tail -n1)
|
||||||
|
local resp_body=$(echo "$response" | head -n-1)
|
||||||
|
|
||||||
|
if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then
|
||||||
|
echo -e "${RED}Error: HTTP $http_code - $resp_body${RESET}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$resp_body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Environment Secrets Vault Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
MAX_ENV_CONTENT_SIZE=65536 # 64KB max env vault size
|
||||||
|
|
||||||
|
service_env_status() {
|
||||||
|
local service_id="$1"
|
||||||
|
local api_key="${2:-${UNSANDBOX_API_KEY:-}}"
|
||||||
|
|
||||||
|
local result=$(api_request "/services/$service_id/env" "GET" "" "$api_key")
|
||||||
|
local has_vault=$(echo "$result" | jq -r '.has_vault // false')
|
||||||
|
|
||||||
|
if [[ "$has_vault" != "true" ]]; then
|
||||||
|
echo "Vault exists: no"
|
||||||
|
echo "Variable count: 0"
|
||||||
|
else
|
||||||
|
echo "Vault exists: yes"
|
||||||
|
local count=$(echo "$result" | jq -r '.count // 0')
|
||||||
|
echo "Variable count: $count"
|
||||||
|
local updated_at=$(echo "$result" | jq -r '.updated_at // ""')
|
||||||
|
if [[ -n "$updated_at" ]] && [[ "$updated_at" != "null" ]]; then
|
||||||
|
local dt=$(date -d "@$updated_at" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r "$updated_at" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$updated_at")
|
||||||
|
echo "Last updated: $dt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
service_env_set() {
|
||||||
|
local service_id="$1"
|
||||||
|
local env_content="$2"
|
||||||
|
local api_key="${3:-${UNSANDBOX_API_KEY:-}}"
|
||||||
|
|
||||||
|
if [[ -z "$env_content" ]]; then
|
||||||
|
echo -e "${RED}Error: No environment content provided${RESET}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local content_size=${#env_content}
|
||||||
|
if [[ $content_size -gt $MAX_ENV_CONTENT_SIZE ]]; then
|
||||||
|
echo -e "${RED}Error: Environment content too large (max $MAX_ENV_CONTENT_SIZE bytes)${RESET}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local result
|
||||||
|
result=$(api_request_text "/services/$service_id/env" "PUT" "$env_content" "$api_key")
|
||||||
|
if [[ $? -ne 0 ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local count=$(echo "$result" | jq -r '.count // -1')
|
||||||
|
if [[ "$count" != "-1" ]]; then
|
||||||
|
local plural="s"
|
||||||
|
[[ "$count" == "1" ]] && plural=""
|
||||||
|
echo -e "${GREEN}Environment vault updated: $count variable$plural${RESET}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}Environment vault updated${RESET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local message=$(echo "$result" | jq -r '.message // ""')
|
||||||
|
[[ -n "$message" ]] && echo "$message"
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
service_env_export() {
|
||||||
|
local service_id="$1"
|
||||||
|
local api_key="${2:-${UNSANDBOX_API_KEY:-}}"
|
||||||
|
|
||||||
|
local result=$(api_request "/services/$service_id/env/export" "POST" "{}" "$api_key")
|
||||||
|
local env_content=$(echo "$result" | jq -r '.env // ""')
|
||||||
|
if [[ -n "$env_content" ]]; then
|
||||||
|
echo -n "$env_content"
|
||||||
|
[[ "${env_content: -1}" != $'\n' ]] && echo
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
service_env_delete() {
|
||||||
|
local service_id="$1"
|
||||||
|
local api_key="${2:-${UNSANDBOX_API_KEY:-}}"
|
||||||
|
|
||||||
|
api_request "/services/$service_id/env" "DELETE" "" "$api_key" > /dev/null
|
||||||
|
echo -e "${GREEN}Environment vault deleted${RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_file() {
|
||||||
|
local filepath="$1"
|
||||||
|
if [[ ! -f "$filepath" ]]; then
|
||||||
|
echo -e "${RED}Error: Env file not found: $filepath${RESET}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cat "$filepath"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_env_content() {
|
||||||
|
local env_file="$1"
|
||||||
|
shift
|
||||||
|
local -a env_vars=("$@")
|
||||||
|
local parts=""
|
||||||
|
|
||||||
|
# Read from env file first
|
||||||
|
if [[ -n "$env_file" ]]; then
|
||||||
|
parts+=$(read_env_file "$env_file")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add -e flags (these override/append to file)
|
||||||
|
for e in "${env_vars[@]}"; do
|
||||||
|
if [[ "$e" == *"="* ]]; then
|
||||||
|
[[ -n "$parts" ]] && parts+=$'\n'
|
||||||
|
parts+="$e"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$parts"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_execute() {
|
cmd_execute() {
|
||||||
local source_file=""
|
local source_file=""
|
||||||
local -a env_vars=()
|
local -a env_vars=()
|
||||||
|
|
@ -523,6 +694,71 @@ cmd_session() {
|
||||||
echo -e "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}"
|
echo -e "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd_service_env() {
|
||||||
|
local action="$1"
|
||||||
|
local target="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
local api_key="${UNSANDBOX_API_KEY:-}"
|
||||||
|
local env_file=""
|
||||||
|
local -a env_vars=()
|
||||||
|
|
||||||
|
# Parse remaining args for -e and --env-file
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-e)
|
||||||
|
env_vars+=("$2")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--env-file)
|
||||||
|
env_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-k)
|
||||||
|
api_key="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$action" ]]; then
|
||||||
|
echo -e "${RED}Error: env action required (status, set, export, delete)${RESET}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
echo -e "${RED}Error: Service ID required for env command${RESET}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
status)
|
||||||
|
service_env_status "$target" "$api_key"
|
||||||
|
;;
|
||||||
|
set)
|
||||||
|
local env_content=$(build_env_content "$env_file" "${env_vars[@]}")
|
||||||
|
if [[ -z "$env_content" ]]; then
|
||||||
|
echo -e "${RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin${RESET}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
service_env_set "$target" "$env_content" "$api_key"
|
||||||
|
;;
|
||||||
|
export)
|
||||||
|
service_env_export "$target" "$api_key"
|
||||||
|
;;
|
||||||
|
delete)
|
||||||
|
service_env_delete "$target" "$api_key"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Error: Unknown env action '$action'. Use: status, set, export, delete${RESET}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
cmd_service() {
|
cmd_service() {
|
||||||
local name=""
|
local name=""
|
||||||
local ports=""
|
local ports=""
|
||||||
|
|
@ -543,6 +779,8 @@ cmd_service() {
|
||||||
local vcpu=""
|
local vcpu=""
|
||||||
local api_key="${UNSANDBOX_API_KEY:-}"
|
local api_key="${UNSANDBOX_API_KEY:-}"
|
||||||
local -a input_files=()
|
local -a input_files=()
|
||||||
|
local -a env_vars=()
|
||||||
|
local env_file=""
|
||||||
local snapshot=""
|
local snapshot=""
|
||||||
local restore=""
|
local restore=""
|
||||||
local from_snapshot=""
|
local from_snapshot=""
|
||||||
|
|
@ -551,6 +789,14 @@ cmd_service() {
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
-e)
|
||||||
|
env_vars+=("$2")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--env-file)
|
||||||
|
env_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--name)
|
--name)
|
||||||
name="$2"
|
name="$2"
|
||||||
shift 2
|
shift 2
|
||||||
|
|
@ -826,6 +1072,17 @@ cmd_service() {
|
||||||
echo -e "${GREEN}Service created: $service_id${RESET}"
|
echo -e "${GREEN}Service created: $service_id${RESET}"
|
||||||
echo "Name: $service_name"
|
echo "Name: $service_name"
|
||||||
[[ -n "$service_url" ]] && echo "URL: $service_url"
|
[[ -n "$service_url" ]] && echo "URL: $service_url"
|
||||||
|
|
||||||
|
# Set environment vault if -e or --env-file provided
|
||||||
|
if [[ "$service_id" != "N/A" ]]; then
|
||||||
|
local env_content=$(build_env_content "$env_file" "${env_vars[@]}")
|
||||||
|
if [[ -n "$env_content" ]]; then
|
||||||
|
echo -e "${YELLOW}Setting environment vault...${RESET}" >&2
|
||||||
|
if ! service_env_set "$service_id" "$env_content" "$api_key"; then
|
||||||
|
echo -e "${YELLOW}Warning: Failed to set environment vault${RESET}" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -1198,7 +1455,13 @@ if [[ "$1" == "session" ]]; then
|
||||||
cmd_session "$@"
|
cmd_session "$@"
|
||||||
elif [[ "$1" == "service" ]]; then
|
elif [[ "$1" == "service" ]]; then
|
||||||
shift
|
shift
|
||||||
cmd_service "$@"
|
# Check for env subcommand: service env <action> <target>
|
||||||
|
if [[ "${1:-}" == "env" ]]; then
|
||||||
|
shift
|
||||||
|
cmd_service_env "$@"
|
||||||
|
else
|
||||||
|
cmd_service "$@"
|
||||||
|
fi
|
||||||
elif [[ "$1" == "snapshot" ]]; then
|
elif [[ "$1" == "snapshot" ]]; then
|
||||||
shift
|
shift
|
||||||
cmd_snapshot "$@"
|
cmd_snapshot "$@"
|
||||||
|
|
|
||||||
213
un.tcl
213
un.tcl
|
|
@ -168,6 +168,158 @@ proc api_request {endpoint method data public_key secret_key} {
|
||||||
return [::json::json2dict $body]
|
return [::json::json2dict $body]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proc api_request_text {endpoint method body public_key secret_key} {
|
||||||
|
set url "${::API_BASE}${endpoint}"
|
||||||
|
set headers [list Authorization "Bearer $public_key" Content-Type "text/plain"]
|
||||||
|
|
||||||
|
# Add HMAC signature if secret_key is present
|
||||||
|
if {$secret_key ne ""} {
|
||||||
|
set timestamp [clock seconds]
|
||||||
|
set sig_input "${timestamp}:${method}:${endpoint}:${body}"
|
||||||
|
set signature [::sha2::hmac -hex -key $secret_key $sig_input]
|
||||||
|
lappend headers X-Timestamp $timestamp
|
||||||
|
lappend headers X-Signature $signature
|
||||||
|
}
|
||||||
|
|
||||||
|
set token [::http::geturl $url -method $method -headers $headers -query $body -timeout 300000]
|
||||||
|
set status [::http::status $token]
|
||||||
|
set ncode [::http::ncode $token]
|
||||||
|
set response [::http::data $token]
|
||||||
|
::http::cleanup $token
|
||||||
|
|
||||||
|
return [list $ncode $response]
|
||||||
|
}
|
||||||
|
|
||||||
|
proc read_env_file {path} {
|
||||||
|
if {![file exists $path]} {
|
||||||
|
puts stderr "${::RED}Error: Env file not found: $path${::RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
set fp [open $path r]
|
||||||
|
set content [read $fp]
|
||||||
|
close $fp
|
||||||
|
return $content
|
||||||
|
}
|
||||||
|
|
||||||
|
proc build_env_content {envs env_file} {
|
||||||
|
set lines [list]
|
||||||
|
|
||||||
|
# Add from -e flags
|
||||||
|
foreach env $envs {
|
||||||
|
lappend lines $env
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add from --env-file
|
||||||
|
if {$env_file ne ""} {
|
||||||
|
set content [read_env_file $env_file]
|
||||||
|
foreach line [split $content "\n"] {
|
||||||
|
set line [string trim $line]
|
||||||
|
if {$line ne "" && [string index $line 0] ne "#"} {
|
||||||
|
lappend lines $line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [join $lines "\n"]
|
||||||
|
}
|
||||||
|
|
||||||
|
set MAX_ENV_CONTENT_SIZE 65536
|
||||||
|
|
||||||
|
proc service_env_status {service_id public_key secret_key} {
|
||||||
|
return [api_request "/services/$service_id/env" "GET" {} $public_key $secret_key]
|
||||||
|
}
|
||||||
|
|
||||||
|
proc service_env_set {service_id env_content public_key secret_key} {
|
||||||
|
if {[string length $env_content] > $::MAX_ENV_CONTENT_SIZE} {
|
||||||
|
puts stderr "${::RED}Error: Env content exceeds maximum size of 64KB${::RESET}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
lassign [api_request_text "/services/$service_id/env" "PUT" $env_content $public_key $secret_key] ncode response
|
||||||
|
if {$ncode == 200 || $ncode == 201} {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
proc service_env_export {service_id public_key secret_key} {
|
||||||
|
return [api_request "/services/$service_id/env/export" "POST" {} $public_key $secret_key]
|
||||||
|
}
|
||||||
|
|
||||||
|
proc service_env_delete {service_id public_key secret_key} {
|
||||||
|
if {[catch {api_request "/services/$service_id/env" "DELETE" {} $public_key $secret_key}]} {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
proc cmd_service_env {action target envs env_file public_key secret_key} {
|
||||||
|
switch -exact -- $action {
|
||||||
|
status {
|
||||||
|
if {$target eq ""} {
|
||||||
|
puts stderr "${::RED}Error: service env status requires service ID${::RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
set result [service_env_status $target $public_key $secret_key]
|
||||||
|
if {[dict exists $result has_vault] && [dict get $result has_vault]} {
|
||||||
|
puts "${::GREEN}Vault: configured${::RESET}"
|
||||||
|
if {[dict exists $result env_count]} {
|
||||||
|
puts "Variables: [dict get $result env_count]"
|
||||||
|
}
|
||||||
|
if {[dict exists $result updated_at]} {
|
||||||
|
puts "Updated: [dict get $result updated_at]"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
puts "${::YELLOW}Vault: not configured${::RESET}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if {$target eq ""} {
|
||||||
|
puts stderr "${::RED}Error: service env set requires service ID${::RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if {[llength $envs] == 0 && $env_file eq ""} {
|
||||||
|
puts stderr "${::RED}Error: service env set requires -e or --env-file${::RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
set env_content [build_env_content $envs $env_file]
|
||||||
|
if {[service_env_set $target $env_content $public_key $secret_key]} {
|
||||||
|
puts "${::GREEN}Vault updated for service $target${::RESET}"
|
||||||
|
} else {
|
||||||
|
puts stderr "${::RED}Error: Failed to update vault${::RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
if {$target eq ""} {
|
||||||
|
puts stderr "${::RED}Error: service env export requires service ID${::RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
set result [service_env_export $target $public_key $secret_key]
|
||||||
|
if {[dict exists $result content]} {
|
||||||
|
puts -nonewline [dict get $result content]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete {
|
||||||
|
if {$target eq ""} {
|
||||||
|
puts stderr "${::RED}Error: service env delete requires service ID${::RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if {[service_env_delete $target $public_key $secret_key]} {
|
||||||
|
puts "${::GREEN}Vault deleted for service $target${::RESET}"
|
||||||
|
} else {
|
||||||
|
puts stderr "${::RED}Error: Failed to delete vault${::RESET}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
puts stderr "${::RED}Error: Unknown env action: $action${::RESET}"
|
||||||
|
puts stderr "Usage: un.tcl service env <status|set|export|delete> <service_id>"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
proc cmd_execute {args} {
|
proc cmd_execute {args} {
|
||||||
lassign [get_api_keys] public_key secret_key
|
lassign [get_api_keys] public_key secret_key
|
||||||
set source_file ""
|
set source_file ""
|
||||||
|
|
@ -533,11 +685,32 @@ proc cmd_service {args} {
|
||||||
set network ""
|
set network ""
|
||||||
set vcpu 0
|
set vcpu 0
|
||||||
set input_files [list]
|
set input_files [list]
|
||||||
|
set envs [list]
|
||||||
|
set env_file ""
|
||||||
|
set env_action ""
|
||||||
|
set env_target ""
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
for {set i 0} {$i < [llength $args]} {incr i} {
|
for {set i 0} {$i < [llength $args]} {incr i} {
|
||||||
set arg [lindex $args $i]
|
set arg [lindex $args $i]
|
||||||
switch -exact -- $arg {
|
switch -exact -- $arg {
|
||||||
|
env {
|
||||||
|
# Parse: env <action> [target]
|
||||||
|
if {$i + 1 < [llength $args]} {
|
||||||
|
set next [lindex $args [expr {$i + 1}]]
|
||||||
|
if {[string index $next 0] ne "-"} {
|
||||||
|
incr i
|
||||||
|
set env_action $next
|
||||||
|
if {$i + 1 < [llength $args]} {
|
||||||
|
set next2 [lindex $args [expr {$i + 1}]]
|
||||||
|
if {[string index $next2 0] ne "-"} {
|
||||||
|
incr i
|
||||||
|
set env_target $next2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
--list {
|
--list {
|
||||||
set list_mode 1
|
set list_mode 1
|
||||||
}
|
}
|
||||||
|
|
@ -601,9 +774,23 @@ proc cmd_service {args} {
|
||||||
incr i
|
incr i
|
||||||
lappend input_files [lindex $args $i]
|
lappend input_files [lindex $args $i]
|
||||||
}
|
}
|
||||||
|
-e {
|
||||||
|
incr i
|
||||||
|
lappend envs [lindex $args $i]
|
||||||
|
}
|
||||||
|
--env-file {
|
||||||
|
incr i
|
||||||
|
set env_file [lindex $args $i]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Handle env subcommand
|
||||||
|
if {$env_action ne ""} {
|
||||||
|
cmd_service_env $env_action $env_target $envs $env_file $public_key $secret_key
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if {$list_mode} {
|
if {$list_mode} {
|
||||||
set result [api_request "/services" "GET" {} $public_key $secret_key]
|
set result [api_request "/services" "GET" {} $public_key $secret_key]
|
||||||
set services [dict get $result services]
|
set services [dict get $result services]
|
||||||
|
|
@ -740,11 +927,24 @@ proc cmd_service {args} {
|
||||||
}
|
}
|
||||||
|
|
||||||
set result [api_request "/services" "POST" $payload $public_key $secret_key]
|
set result [api_request "/services" "POST" $payload $public_key $secret_key]
|
||||||
puts "${::GREEN}Service created: [dict get $result id]${::RESET}"
|
set service_id [dict get $result id]
|
||||||
|
puts "${::GREEN}Service created: $service_id${::RESET}"
|
||||||
puts "Name: [dict get $result name]"
|
puts "Name: [dict get $result name]"
|
||||||
if {[dict exists $result url]} {
|
if {[dict exists $result url]} {
|
||||||
puts "URL: [dict get $result url]"
|
puts "URL: [dict get $result url]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Auto-set vault if env vars were provided
|
||||||
|
if {[llength $envs] > 0 || $env_file ne ""} {
|
||||||
|
set env_content [build_env_content $envs $env_file]
|
||||||
|
if {$env_content ne ""} {
|
||||||
|
if {[service_env_set $service_id $env_content $public_key $secret_key]} {
|
||||||
|
puts "${::GREEN}Vault configured with environment variables${::RESET}"
|
||||||
|
} else {
|
||||||
|
puts stderr "${::YELLOW}Warning: Failed to set vault${::RESET}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -757,7 +957,18 @@ proc main {argv} {
|
||||||
puts stderr "Usage: un.tcl \[options\] <source_file>"
|
puts stderr "Usage: un.tcl \[options\] <source_file>"
|
||||||
puts stderr " un.tcl session \[options\]"
|
puts stderr " un.tcl session \[options\]"
|
||||||
puts stderr " un.tcl service \[options\]"
|
puts stderr " un.tcl service \[options\]"
|
||||||
|
puts stderr " un.tcl service env <action> <service_id> \[options\]"
|
||||||
puts stderr " un.tcl key \[--extend\]"
|
puts stderr " un.tcl key \[--extend\]"
|
||||||
|
puts stderr ""
|
||||||
|
puts stderr "Service env commands:"
|
||||||
|
puts stderr " env status ID Check vault status"
|
||||||
|
puts stderr " env set ID Set vault (use -e or --env-file)"
|
||||||
|
puts stderr " env export ID Export vault contents"
|
||||||
|
puts stderr " env delete ID Delete vault"
|
||||||
|
puts stderr ""
|
||||||
|
puts stderr "Service vault options:"
|
||||||
|
puts stderr " -e KEY=VALUE Set vault env var (with --name or env set)"
|
||||||
|
puts stderr " --env-file FILE Load vault vars from file"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
206
un.ts
206
un.ts
|
|
@ -125,6 +125,9 @@ interface Args {
|
||||||
clonePorts: string | null;
|
clonePorts: string | null;
|
||||||
dumpBootstrap: string | null;
|
dumpBootstrap: string | null;
|
||||||
dumpFile: string | null;
|
dumpFile: string | null;
|
||||||
|
envFile: string | null;
|
||||||
|
envAction: string | null;
|
||||||
|
envTarget: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiKeys {
|
interface ApiKeys {
|
||||||
|
|
@ -277,6 +280,175 @@ function portalRequest(endpoint: string, method: string = "GET", data: any = nul
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiRequestText(endpoint: string, method: string, body: string, keys: ApiKeys): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(API_BASE + endpoint);
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
const message = `${timestamp}:${method}:${url.pathname}:${body}`;
|
||||||
|
const signature = crypto.createHmac('sha256', keys.secretKey).update(message).digest('hex');
|
||||||
|
|
||||||
|
const options: https.RequestOptions = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
path: url.pathname,
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${keys.publicKey}`,
|
||||||
|
'X-Timestamp': timestamp,
|
||||||
|
'X-Signature': signature,
|
||||||
|
'Content-Type': 'text/plain'
|
||||||
|
},
|
||||||
|
timeout: 300000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let responseBody = '';
|
||||||
|
res.on('data', chunk => responseBody += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(responseBody));
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ error: responseBody });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve({ error: `HTTP ${res.statusCode} - ${responseBody}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (e) => {
|
||||||
|
resolve({ error: e.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Environment Secrets Vault Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const MAX_ENV_CONTENT_SIZE = 64 * 1024; // 64KB max
|
||||||
|
|
||||||
|
async function serviceEnvStatus(serviceId: string, keys: ApiKeys): Promise<void> {
|
||||||
|
const result = await apiRequest(`/services/${serviceId}/env`, "GET", null, keys);
|
||||||
|
const hasVault = result.has_vault;
|
||||||
|
|
||||||
|
if (!hasVault) {
|
||||||
|
console.log("Vault exists: no");
|
||||||
|
console.log("Variable count: 0");
|
||||||
|
} else {
|
||||||
|
console.log("Vault exists: yes");
|
||||||
|
console.log(`Variable count: ${result.count || 0}`);
|
||||||
|
if (result.updated_at) {
|
||||||
|
const date = new Date(result.updated_at * 1000);
|
||||||
|
console.log(`Last updated: ${date.toISOString().replace('T', ' ').split('.')[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serviceEnvSet(serviceId: string, envContent: string, keys: ApiKeys): Promise<boolean> {
|
||||||
|
if (!envContent) {
|
||||||
|
console.error(`${RED}Error: No environment content provided${RESET}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envContent.length > MAX_ENV_CONTENT_SIZE) {
|
||||||
|
console.error(`${RED}Error: Environment content too large (max ${MAX_ENV_CONTENT_SIZE} bytes)${RESET}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiRequestText(`/services/${serviceId}/env`, "PUT", envContent, keys);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`${RED}Error: ${result.error}${RESET}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = result.count || 0;
|
||||||
|
const plural = count === 1 ? '' : 's';
|
||||||
|
console.log(`${GREEN}Environment vault updated: ${count} variable${plural}${RESET}`);
|
||||||
|
if (result.message) console.log(result.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serviceEnvExport(serviceId: string, keys: ApiKeys): Promise<void> {
|
||||||
|
const result = await apiRequest(`/services/${serviceId}/env/export`, "POST", {}, keys);
|
||||||
|
const envContent = result.env || '';
|
||||||
|
if (envContent) {
|
||||||
|
process.stdout.write(envContent);
|
||||||
|
if (!envContent.endsWith('\n')) console.log();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serviceEnvDelete(serviceId: string, keys: ApiKeys): Promise<void> {
|
||||||
|
await apiRequest(`/services/${serviceId}/env`, "DELETE", null, keys);
|
||||||
|
console.log(`${GREEN}Environment vault deleted${RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvFile(filepath: string): string {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filepath, 'utf-8');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${RED}Error: Env file not found: ${filepath}${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnvContent(envs: string[], envFile: string | null): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Read from env file first
|
||||||
|
if (envFile) {
|
||||||
|
parts.push(readEnvFile(envFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add -e flags
|
||||||
|
envs.forEach(e => {
|
||||||
|
if (e.includes('=')) {
|
||||||
|
parts.push(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdServiceEnv(action: string, target: string, envs: string[], envFile: string | null, keys: ApiKeys): Promise<void> {
|
||||||
|
if (!action) {
|
||||||
|
console.error(`${RED}Error: env action required (status, set, export, delete)${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
console.error(`${RED}Error: Service ID required for env command${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'status':
|
||||||
|
await serviceEnvStatus(target, keys);
|
||||||
|
break;
|
||||||
|
case 'set':
|
||||||
|
const envContent = buildEnvContent(envs, envFile);
|
||||||
|
if (!envContent) {
|
||||||
|
console.error(`${RED}Error: No env content provided. Use -e KEY=VAL or --env-file${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await serviceEnvSet(target, envContent, keys);
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
await serviceEnvExport(target, keys);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
await serviceEnvDelete(target, keys);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error(`${RED}Error: Unknown env action '${action}'. Use: status, set, export, delete${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cmdExecute(args: Args): Promise<void> {
|
async function cmdExecute(args: Args): Promise<void> {
|
||||||
const keys = getApiKeys(args.apiKey);
|
const keys = getApiKeys(args.apiKey);
|
||||||
|
|
||||||
|
|
@ -524,9 +696,16 @@ async function cmdService(args: Args): Promise<void> {
|
||||||
if (args.vcpu) payload.vcpu = args.vcpu;
|
if (args.vcpu) payload.vcpu = args.vcpu;
|
||||||
|
|
||||||
const result = await apiRequest("/services", "POST", payload, keys);
|
const result = await apiRequest("/services", "POST", payload, keys);
|
||||||
console.log(`${GREEN}Service created: ${result.id || 'N/A'}${RESET}`);
|
const serviceId = result.id;
|
||||||
|
console.log(`${GREEN}Service created: ${serviceId || 'N/A'}${RESET}`);
|
||||||
console.log(`Name: ${result.name || 'N/A'}`);
|
console.log(`Name: ${result.name || 'N/A'}`);
|
||||||
if (result.url) console.log(`URL: ${result.url}`);
|
if (result.url) console.log(`URL: ${result.url}`);
|
||||||
|
|
||||||
|
// Auto-set vault if -e or --env-file provided
|
||||||
|
const envContent = buildEnvContent(args.env || [], args.envFile);
|
||||||
|
if (envContent && serviceId) {
|
||||||
|
await serviceEnvSet(serviceId, envContent, keys);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -639,6 +818,9 @@ function parseArgs(argv: string[]): Args {
|
||||||
dumpBootstrap: null,
|
dumpBootstrap: null,
|
||||||
dumpFile: null,
|
dumpFile: null,
|
||||||
extend: false,
|
extend: false,
|
||||||
|
envFile: null,
|
||||||
|
envAction: null,
|
||||||
|
envTarget: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let i = 2;
|
let i = 2;
|
||||||
|
|
@ -708,6 +890,20 @@ function parseArgs(argv: string[]): Args {
|
||||||
} else if (arg === '--bootstrap-file' && i + 1 < argv.length) {
|
} else if (arg === '--bootstrap-file' && i + 1 < argv.length) {
|
||||||
args.bootstrapFile = argv[++i];
|
args.bootstrapFile = argv[++i];
|
||||||
i++;
|
i++;
|
||||||
|
} else if (arg === '--env-file' && i + 1 < argv.length) {
|
||||||
|
args.envFile = argv[++i];
|
||||||
|
i++;
|
||||||
|
} else if (arg === 'env') {
|
||||||
|
// Handle "service env <action> <target>" subcommand
|
||||||
|
if (args.command === 'service') {
|
||||||
|
if (i + 1 < argv.length) {
|
||||||
|
args.envAction = argv[++i];
|
||||||
|
}
|
||||||
|
if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
|
||||||
|
args.envTarget = argv[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
} else if (arg === '--info' && i + 1 < argv.length) {
|
} else if (arg === '--info' && i + 1 < argv.length) {
|
||||||
args.info = argv[++i];
|
args.info = argv[++i];
|
||||||
i++;
|
i++;
|
||||||
|
|
@ -759,7 +955,13 @@ async function main(): Promise<void> {
|
||||||
if (args.command === 'session') {
|
if (args.command === 'session') {
|
||||||
await cmdSession(args);
|
await cmdSession(args);
|
||||||
} else if (args.command === 'service') {
|
} else if (args.command === 'service') {
|
||||||
await cmdService(args);
|
// Check for "service env" subcommand
|
||||||
|
if (args.envAction) {
|
||||||
|
const keys = getApiKeys(args.apiKey);
|
||||||
|
await cmdServiceEnv(args.envAction, args.envTarget!, args.env, args.envFile, keys);
|
||||||
|
} else {
|
||||||
|
await cmdService(args);
|
||||||
|
}
|
||||||
} else if (args.command === 'key') {
|
} else if (args.command === 'key') {
|
||||||
await cmdKey(args);
|
await cmdKey(args);
|
||||||
} else if (args.sourceFile) {
|
} else if (args.sourceFile) {
|
||||||
|
|
|
||||||
146
un.v
146
un.v
|
|
@ -47,6 +47,7 @@ import os
|
||||||
|
|
||||||
const api_base = 'https://api.unsandbox.com'
|
const api_base = 'https://api.unsandbox.com'
|
||||||
const portal_base = 'https://unsandbox.com'
|
const portal_base = 'https://unsandbox.com'
|
||||||
|
const max_env_content_size = 65536
|
||||||
const blue = '\x1b[34m'
|
const blue = '\x1b[34m'
|
||||||
const red = '\x1b[31m'
|
const red = '\x1b[31m'
|
||||||
const green = '\x1b[32m'
|
const green = '\x1b[32m'
|
||||||
|
|
@ -156,6 +157,97 @@ fn extract_json_string(json string, key string) string {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_env_file(filename string) string {
|
||||||
|
content := os.read_file(filename) or {
|
||||||
|
eprintln('${red}Error: Cannot read env file: ${filename}${reset}')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_env_content(envs []string, env_file string) string {
|
||||||
|
mut result := ''
|
||||||
|
|
||||||
|
// Add -e flags
|
||||||
|
for env in envs {
|
||||||
|
result += env + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add content from env file
|
||||||
|
if env_file != '' {
|
||||||
|
file_content := read_env_file(env_file)
|
||||||
|
for line in file_content.split('\n') {
|
||||||
|
trimmed := line.trim_space()
|
||||||
|
if trimmed.len == 0 || trimmed.starts_with('#') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result += trimmed + '\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_curl_put(endpoint string, body string, public_key string, secret_key string) bool {
|
||||||
|
// Write body to temp file to avoid shell escaping issues
|
||||||
|
body_file := '/tmp/unsandbox_env_body.txt'
|
||||||
|
os.write_file(body_file, body) or {
|
||||||
|
eprintln('${red}Error: Cannot write temp file${reset}')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
os.rm(body_file) or {}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:PUT:${endpoint}:${body}\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X PUT '${api_base}${endpoint}' -H 'Content-Type: text/plain' -H 'Authorization: Bearer ${public_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" --data-binary @${body_file}"
|
||||||
|
result := os.execute(cmd)
|
||||||
|
return result.exit_code == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_env_set(service_id string, content string, public_key string, secret_key string) bool {
|
||||||
|
endpoint := '/services/${service_id}/env'
|
||||||
|
return exec_curl_put(endpoint, content, public_key, secret_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_service_env(action string, target string, svc_envs []string, svc_env_file string, api_key string) {
|
||||||
|
pub_key := get_public_key()
|
||||||
|
secret_key := get_secret_key()
|
||||||
|
|
||||||
|
match action {
|
||||||
|
'status' {
|
||||||
|
cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:GET:/services/${target}/env:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/services/${target}/env' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\""
|
||||||
|
println(exec_curl(cmd))
|
||||||
|
}
|
||||||
|
'set' {
|
||||||
|
if svc_envs.len == 0 && svc_env_file == '' {
|
||||||
|
eprintln('${red}Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE${reset}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content := build_env_content(svc_envs, svc_env_file)
|
||||||
|
if content.len > max_env_content_size {
|
||||||
|
eprintln('${red}Error: Environment content exceeds 64KB limit${reset}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if service_env_set(target, content, pub_key, secret_key) {
|
||||||
|
println('${green}Vault updated for service ${target}${reset}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'export' {
|
||||||
|
cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/services/${target}/env/export:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services/${target}/env/export' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\""
|
||||||
|
println(exec_curl(cmd))
|
||||||
|
}
|
||||||
|
'delete' {
|
||||||
|
cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:DELETE:/services/${target}/env:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X DELETE '${api_base}/services/${target}/env' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\""
|
||||||
|
exec_curl(cmd)
|
||||||
|
println('${green}Vault deleted for service ${target}${reset}')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
eprintln('${red}Error: Unknown env action: ${action}${reset}')
|
||||||
|
eprintln('Usage: un service env <status|set|export|delete> <service_id>')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_key(extend bool, api_key string) {
|
fn cmd_key(extend bool, api_key string) {
|
||||||
pub_key := get_public_key()
|
pub_key := get_public_key()
|
||||||
secret_key := get_secret_key()
|
secret_key := get_secret_key()
|
||||||
|
|
@ -318,7 +410,7 @@ fn cmd_session(list bool, kill string, shell string, network string, vcpu int, t
|
||||||
println(exec_curl(cmd))
|
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, 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, 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()
|
pub_key := get_public_key()
|
||||||
secret_key := get_secret_key()
|
secret_key := get_secret_key()
|
||||||
|
|
||||||
|
|
@ -442,7 +534,21 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string,
|
||||||
|
|
||||||
println('${yellow}Creating service...${reset}')
|
println('${yellow}Creating service...${reset}')
|
||||||
cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/services:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\""
|
cmd := "BODY='${json}'; TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:POST:/services:\$BODY\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\" -d \"\$BODY\""
|
||||||
println(exec_curl(cmd))
|
result := exec_curl(cmd)
|
||||||
|
println(result)
|
||||||
|
|
||||||
|
// Auto-set vault if -e or --env-file provided
|
||||||
|
if svc_envs.len > 0 || svc_env_file != '' {
|
||||||
|
service_id := extract_json_string(result, 'service_id')
|
||||||
|
if service_id != '' {
|
||||||
|
env_content := build_env_content(svc_envs, svc_env_file)
|
||||||
|
if env_content.len > 0 {
|
||||||
|
if service_env_set(service_id, env_content, pub_key, secret_key) {
|
||||||
|
println('${green}Vault configured for service ${service_id}${reset}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -482,7 +588,14 @@ fn main() {
|
||||||
eprintln('Usage: ${os.args[0]} [options] <source_file>')
|
eprintln('Usage: ${os.args[0]} [options] <source_file>')
|
||||||
eprintln(' ${os.args[0]} session [options]')
|
eprintln(' ${os.args[0]} session [options]')
|
||||||
eprintln(' ${os.args[0]} service [options]')
|
eprintln(' ${os.args[0]} service [options]')
|
||||||
|
eprintln(' ${os.args[0]} service env <action> <service_id> [options]')
|
||||||
eprintln(' ${os.args[0]} key [--extend]')
|
eprintln(' ${os.args[0]} key [--extend]')
|
||||||
|
eprintln('')
|
||||||
|
eprintln('Vault commands:')
|
||||||
|
eprintln(' service env status <id> Check vault status')
|
||||||
|
eprintln(' service env set <id> Set vault (-e KEY=VAL or --env-file FILE)')
|
||||||
|
eprintln(' service env export <id> Export vault contents')
|
||||||
|
eprintln(' service env delete <id> Delete vault')
|
||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -561,10 +674,23 @@ fn main() {
|
||||||
mut network := ''
|
mut network := ''
|
||||||
mut vcpu := 0
|
mut vcpu := 0
|
||||||
mut input_files := []string{}
|
mut input_files := []string{}
|
||||||
|
mut svc_envs := []string{}
|
||||||
|
mut svc_env_file := ''
|
||||||
|
mut env_action := ''
|
||||||
|
mut env_target := ''
|
||||||
|
|
||||||
mut i := 2
|
mut i := 2
|
||||||
for i < os.args.len {
|
for i < os.args.len {
|
||||||
match os.args[i] {
|
match os.args[i] {
|
||||||
|
'env' {
|
||||||
|
// service env <action> <service_id>
|
||||||
|
if i + 2 < os.args.len {
|
||||||
|
i++
|
||||||
|
env_action = os.args[i]
|
||||||
|
i++
|
||||||
|
env_target = os.args[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
'--name' {
|
'--name' {
|
||||||
i++
|
i++
|
||||||
name = os.args[i]
|
name = os.args[i]
|
||||||
|
|
@ -626,6 +752,14 @@ fn main() {
|
||||||
i++
|
i++
|
||||||
dump_file = os.args[i]
|
dump_file = os.args[i]
|
||||||
}
|
}
|
||||||
|
'-e' {
|
||||||
|
i++
|
||||||
|
svc_envs << os.args[i]
|
||||||
|
}
|
||||||
|
'--env-file' {
|
||||||
|
i++
|
||||||
|
svc_env_file = os.args[i]
|
||||||
|
}
|
||||||
'-n' {
|
'-n' {
|
||||||
i++
|
i++
|
||||||
network = os.args[i]
|
network = os.args[i]
|
||||||
|
|
@ -653,8 +787,14 @@ fn main() {
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle env subcommand
|
||||||
|
if env_action != '' && env_target != '' {
|
||||||
|
cmd_service_env(env_action, env_target, svc_envs, svc_env_file, api_key)
|
||||||
|
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, execute, command, dump_bootstrap, dump_file, network,
|
||||||
vcpu, input_files, api_key)
|
vcpu, input_files, svc_envs, svc_env_file, api_key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
212
un.zig
212
un.zig
|
|
@ -54,6 +54,11 @@ const time = std.time;
|
||||||
|
|
||||||
const API_BASE = "https://api.unsandbox.com";
|
const API_BASE = "https://api.unsandbox.com";
|
||||||
const PORTAL_BASE = "https://unsandbox.com";
|
const PORTAL_BASE = "https://unsandbox.com";
|
||||||
|
const MAX_ENV_CONTENT_SIZE: usize = 65536;
|
||||||
|
const GREEN = "\x1b[32m";
|
||||||
|
const RED = "\x1b[31m";
|
||||||
|
const YELLOW = "\x1b[33m";
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
|
||||||
fn computeHmacCmd(allocator: std.mem.Allocator, secret_key: []const u8, message: []const u8) ![]const u8 {
|
fn computeHmacCmd(allocator: std.mem.Allocator, secret_key: []const u8, message: []const u8) ![]const u8 {
|
||||||
return try std.fmt.allocPrint(allocator, "echo -n '{s}' | openssl dgst -sha256 -hmac '{s}' -hex 2>/dev/null | sed 's/.*= //'", .{ message, secret_key });
|
return try std.fmt.allocPrint(allocator, "echo -n '{s}' | openssl dgst -sha256 -hmac '{s}' -hex 2>/dev/null | sed 's/.*= //'", .{ message, secret_key });
|
||||||
|
|
@ -116,6 +121,136 @@ fn base64EncodeFile(allocator: std.mem.Allocator, filename: []const u8) ![]u8 {
|
||||||
return try allocator.dupe(u8, trimmed);
|
return try allocator.dupe(u8, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn readEnvFile(allocator: std.mem.Allocator, filename: []const u8) ![]u8 {
|
||||||
|
const content = fs.cwd().readFileAlloc(allocator, filename, MAX_ENV_CONTENT_SIZE) catch |err| {
|
||||||
|
std.debug.print("{s}Error: Cannot read env file: {s} ({s}){s}\n", .{ RED, filename, @errorName(err), RESET });
|
||||||
|
return try allocator.dupe(u8, "");
|
||||||
|
};
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buildEnvContent(allocator: std.mem.Allocator, envs: std.ArrayList([]const u8), env_file: ?[]const u8) ![]u8 {
|
||||||
|
var list = std.ArrayList(u8).init(allocator);
|
||||||
|
errdefer list.deinit();
|
||||||
|
|
||||||
|
// Add environment variables from -e flags
|
||||||
|
for (envs.items) |env| {
|
||||||
|
try list.appendSlice(env);
|
||||||
|
try list.append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add content from env file
|
||||||
|
if (env_file) |ef| {
|
||||||
|
const file_content = try readEnvFile(allocator, ef);
|
||||||
|
defer allocator.free(file_content);
|
||||||
|
|
||||||
|
// Process line by line, skip comments and empty lines
|
||||||
|
var lines = mem.splitScalar(u8, file_content, '\n');
|
||||||
|
while (lines.next()) |line| {
|
||||||
|
const trimmed = mem.trim(u8, line, &std.ascii.whitespace);
|
||||||
|
if (trimmed.len == 0) continue;
|
||||||
|
if (trimmed[0] == '#') continue;
|
||||||
|
try list.appendSlice(trimmed);
|
||||||
|
try list.append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.toOwnedSlice();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extractJsonField(json: []const u8, field: []const u8) ?[]const u8 {
|
||||||
|
// Build search pattern: "field":"
|
||||||
|
var pattern_buf: [256]u8 = undefined;
|
||||||
|
const pattern = std.fmt.bufPrint(&pattern_buf, "\"{s}\":\"", .{field}) catch return null;
|
||||||
|
|
||||||
|
if (mem.indexOf(u8, json, pattern)) |start_idx| {
|
||||||
|
const value_start = start_idx + pattern.len;
|
||||||
|
if (mem.indexOfPos(u8, json, value_start, "\"")) |end_idx| {
|
||||||
|
return json[value_start..end_idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execCurlPut(allocator: std.mem.Allocator, endpoint: []const u8, body: []const u8, public_key: []const u8, secret_key: []const u8) !bool {
|
||||||
|
const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ API_BASE, endpoint });
|
||||||
|
defer allocator.free(url);
|
||||||
|
|
||||||
|
const auth_headers = try buildAuthCmd(allocator, "PUT", endpoint, body, public_key, secret_key);
|
||||||
|
defer allocator.free(auth_headers);
|
||||||
|
|
||||||
|
// Write body to temp file to avoid shell escaping issues
|
||||||
|
const body_file = "/tmp/unsandbox_env_body.txt";
|
||||||
|
const file = try fs.cwd().createFile(body_file, .{});
|
||||||
|
try file.writeAll(body);
|
||||||
|
file.close();
|
||||||
|
defer fs.cwd().deleteFile(body_file) catch {};
|
||||||
|
|
||||||
|
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X PUT '{s}' -H 'Content-Type: text/plain' {s} --data-binary @{s}", .{ url, auth_headers, body_file });
|
||||||
|
defer allocator.free(cmd);
|
||||||
|
|
||||||
|
const ret = std.c.system(cmd.ptr);
|
||||||
|
return ret == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmdServiceEnv(allocator: std.mem.Allocator, action: []const u8, target: []const u8, envs: std.ArrayList([]const u8), env_file: ?[]const u8, public_key: []const u8, secret_key: []const u8) !void {
|
||||||
|
if (mem.eql(u8, action, "status")) {
|
||||||
|
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{target});
|
||||||
|
defer allocator.free(path);
|
||||||
|
const auth_headers = try buildAuthCmd(allocator, "GET", path, "", public_key, secret_key);
|
||||||
|
defer allocator.free(auth_headers);
|
||||||
|
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}{s}' {s}", .{ API_BASE, path, auth_headers });
|
||||||
|
defer allocator.free(cmd);
|
||||||
|
_ = std.c.system(cmd.ptr);
|
||||||
|
std.debug.print("\n", .{});
|
||||||
|
} else if (mem.eql(u8, action, "set")) {
|
||||||
|
if (envs.items.len == 0 and env_file == null) {
|
||||||
|
std.debug.print("{s}Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE{s}\n", .{ RED, RESET });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = try buildEnvContent(allocator, envs, env_file);
|
||||||
|
defer allocator.free(content);
|
||||||
|
|
||||||
|
if (content.len > MAX_ENV_CONTENT_SIZE) {
|
||||||
|
std.debug.print("{s}Error: Environment content exceeds 64KB limit{s}\n", .{ RED, RESET });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{target});
|
||||||
|
defer allocator.free(path);
|
||||||
|
|
||||||
|
_ = try execCurlPut(allocator, path, content, public_key, secret_key);
|
||||||
|
std.debug.print("\n{s}Vault updated for service {s}{s}\n", .{ GREEN, target, RESET });
|
||||||
|
} else if (mem.eql(u8, action, "export")) {
|
||||||
|
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env/export", .{target});
|
||||||
|
defer allocator.free(path);
|
||||||
|
const auth_headers = try buildAuthCmd(allocator, "POST", path, "", public_key, secret_key);
|
||||||
|
defer allocator.free(auth_headers);
|
||||||
|
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}{s}' {s}", .{ API_BASE, path, auth_headers });
|
||||||
|
defer allocator.free(cmd);
|
||||||
|
_ = std.c.system(cmd.ptr);
|
||||||
|
std.debug.print("\n", .{});
|
||||||
|
} else if (mem.eql(u8, action, "delete")) {
|
||||||
|
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{target});
|
||||||
|
defer allocator.free(path);
|
||||||
|
const auth_headers = try buildAuthCmd(allocator, "DELETE", path, "", public_key, secret_key);
|
||||||
|
defer allocator.free(auth_headers);
|
||||||
|
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X DELETE '{s}{s}' {s}", .{ API_BASE, path, auth_headers });
|
||||||
|
defer allocator.free(cmd);
|
||||||
|
_ = std.c.system(cmd.ptr);
|
||||||
|
std.debug.print("\n{s}Vault deleted for service {s}{s}\n", .{ GREEN, target, RESET });
|
||||||
|
} else {
|
||||||
|
std.debug.print("{s}Error: Unknown env action: {s}{s}\n", .{ RED, action, RESET });
|
||||||
|
std.debug.print("Usage: un service env <status|set|export|delete> <service_id>\n", .{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serviceEnvSet(allocator: std.mem.Allocator, service_id: []const u8, content: []const u8, public_key: []const u8, secret_key: []const u8) !bool {
|
||||||
|
const path = try std.fmt.allocPrint(allocator, "/services/{s}/env", .{service_id});
|
||||||
|
defer allocator.free(path);
|
||||||
|
return try execCurlPut(allocator, path, content, public_key, secret_key);
|
||||||
|
}
|
||||||
|
|
||||||
fn buildInputFilesJson(allocator: std.mem.Allocator, files: std.ArrayList([]const u8)) ![]u8 {
|
fn buildInputFilesJson(allocator: std.mem.Allocator, files: std.ArrayList([]const u8)) ![]u8 {
|
||||||
if (files.items.len == 0) {
|
if (files.items.len == 0) {
|
||||||
return try allocator.dupe(u8, "");
|
return try allocator.dupe(u8, "");
|
||||||
|
|
@ -162,7 +297,13 @@ pub fn main() !u8 {
|
||||||
std.debug.print("Usage: {s} [options] <source_file>\n", .{args[0]});
|
std.debug.print("Usage: {s} [options] <source_file>\n", .{args[0]});
|
||||||
std.debug.print(" {s} session [options]\n", .{args[0]});
|
std.debug.print(" {s} session [options]\n", .{args[0]});
|
||||||
std.debug.print(" {s} service [options]\n", .{args[0]});
|
std.debug.print(" {s} service [options]\n", .{args[0]});
|
||||||
|
std.debug.print(" {s} service env <action> <service_id> [options]\n", .{args[0]});
|
||||||
std.debug.print(" {s} key [--extend]\n", .{args[0]});
|
std.debug.print(" {s} key [--extend]\n", .{args[0]});
|
||||||
|
std.debug.print("\nVault commands:\n", .{});
|
||||||
|
std.debug.print(" service env status <id> Check vault status\n", .{});
|
||||||
|
std.debug.print(" service env set <id> Set vault (-e KEY=VAL or --env-file FILE)\n", .{});
|
||||||
|
std.debug.print(" service env export <id> Export vault contents\n", .{});
|
||||||
|
std.debug.print(" service env delete <id> Delete vault\n", .{});
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,10 +399,21 @@ pub fn main() !u8 {
|
||||||
var dump_file: ?[]const u8 = null;
|
var dump_file: ?[]const u8 = null;
|
||||||
var input_files = std.ArrayList([]const u8).init(allocator);
|
var input_files = std.ArrayList([]const u8).init(allocator);
|
||||||
defer input_files.deinit();
|
defer input_files.deinit();
|
||||||
|
var svc_envs = std.ArrayList([]const u8).init(allocator);
|
||||||
|
defer svc_envs.deinit();
|
||||||
|
var svc_env_file: ?[]const u8 = null;
|
||||||
|
var env_action: ?[]const u8 = null;
|
||||||
|
var env_target: ?[]const u8 = null;
|
||||||
var i: usize = 2;
|
var i: usize = 2;
|
||||||
while (i < args.len) : (i += 1) {
|
while (i < args.len) : (i += 1) {
|
||||||
if (mem.eql(u8, args[i], "--list")) {
|
if (mem.eql(u8, args[i], "--list")) {
|
||||||
list = true;
|
list = true;
|
||||||
|
} else if (mem.eql(u8, args[i], "env") and i + 2 < args.len) {
|
||||||
|
// service env <action> <service_id>
|
||||||
|
i += 1;
|
||||||
|
env_action = args[i];
|
||||||
|
i += 1;
|
||||||
|
env_target = args[i];
|
||||||
} else if (mem.eql(u8, args[i], "--name") and i + 1 < args.len) {
|
} else if (mem.eql(u8, args[i], "--name") and i + 1 < args.len) {
|
||||||
i += 1;
|
i += 1;
|
||||||
name = args[i];
|
name = args[i];
|
||||||
|
|
@ -292,6 +444,12 @@ pub fn main() !u8 {
|
||||||
} else if (mem.eql(u8, args[i], "--dump-file") and i + 1 < args.len) {
|
} else if (mem.eql(u8, args[i], "--dump-file") and i + 1 < args.len) {
|
||||||
i += 1;
|
i += 1;
|
||||||
dump_file = args[i];
|
dump_file = args[i];
|
||||||
|
} else if (mem.eql(u8, args[i], "-e") and i + 1 < args.len) {
|
||||||
|
i += 1;
|
||||||
|
try svc_envs.append(args[i]);
|
||||||
|
} else if (mem.eql(u8, args[i], "--env-file") and i + 1 < args.len) {
|
||||||
|
i += 1;
|
||||||
|
svc_env_file = args[i];
|
||||||
} else if (mem.eql(u8, args[i], "-k") and i + 1 < args.len) {
|
} else if (mem.eql(u8, args[i], "-k") and i + 1 < args.len) {
|
||||||
i += 1;
|
i += 1;
|
||||||
allocator.free(public_key);
|
allocator.free(public_key);
|
||||||
|
|
@ -308,6 +466,14 @@ pub fn main() !u8 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle env subcommand
|
||||||
|
if (env_action) |action| {
|
||||||
|
if (env_target) |target| {
|
||||||
|
try cmdServiceEnv(allocator, action, target, svc_envs, svc_env_file, public_key, secret_key);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (list) {
|
if (list) {
|
||||||
const auth_headers = try buildAuthCmd(allocator, "GET", "/services", "", public_key, secret_key);
|
const auth_headers = try buildAuthCmd(allocator, "GET", "/services", "", public_key, secret_key);
|
||||||
defer allocator.free(auth_headers);
|
defer allocator.free(auth_headers);
|
||||||
|
|
@ -441,11 +607,47 @@ pub fn main() !u8 {
|
||||||
|
|
||||||
const auth_headers = try buildAuthCmd(allocator, "POST", "/services", json_str, public_key, secret_key);
|
const auth_headers = try buildAuthCmd(allocator, "POST", "/services", json_str, public_key, secret_key);
|
||||||
defer allocator.free(auth_headers);
|
defer allocator.free(auth_headers);
|
||||||
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services' -H 'Content-Type: application/json' {s} -d '{s}'", .{ API_BASE, auth_headers, json_str });
|
|
||||||
defer allocator.free(cmd);
|
// Check if we need auto-vault
|
||||||
std.debug.print("\x1b[33mCreating service...\x1b[0m\n", .{});
|
const has_env = svc_envs.items.len > 0 or svc_env_file != null;
|
||||||
_ = std.c.system(cmd.ptr);
|
|
||||||
std.debug.print("\n", .{});
|
if (has_env) {
|
||||||
|
// Capture response to temp file to extract service_id
|
||||||
|
const response_file = "/tmp/unsandbox_service_create.json";
|
||||||
|
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services' -H 'Content-Type: application/json' {s} -d '{s}' -o {s}", .{ API_BASE, auth_headers, json_str, response_file });
|
||||||
|
defer allocator.free(cmd);
|
||||||
|
std.debug.print("{s}Creating service...{s}\n", .{ YELLOW, RESET });
|
||||||
|
_ = std.c.system(cmd.ptr);
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
const response_content = fs.cwd().readFileAlloc(allocator, response_file, 1024 * 1024) catch {
|
||||||
|
std.debug.print("{s}Error: Failed to read service creation response{s}\n", .{ RED, RESET });
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
defer allocator.free(response_content);
|
||||||
|
fs.cwd().deleteFile(response_file) catch {};
|
||||||
|
|
||||||
|
// Print the response
|
||||||
|
std.debug.print("{s}\n", .{response_content});
|
||||||
|
|
||||||
|
// Extract service_id and auto-set vault
|
||||||
|
if (extractJsonField(response_content, "service_id")) |service_id| {
|
||||||
|
const env_content = try buildEnvContent(allocator, svc_envs, svc_env_file);
|
||||||
|
defer allocator.free(env_content);
|
||||||
|
|
||||||
|
if (env_content.len > 0) {
|
||||||
|
if (try serviceEnvSet(allocator, service_id, env_content, public_key, secret_key)) {
|
||||||
|
std.debug.print("\n{s}Vault configured for service {s}{s}\n", .{ GREEN, service_id, RESET });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services' -H 'Content-Type: application/json' {s} -d '{s}'", .{ API_BASE, auth_headers, json_str });
|
||||||
|
defer allocator.free(cmd);
|
||||||
|
std.debug.print("{s}Creating service...{s}\n", .{ YELLOW, RESET });
|
||||||
|
_ = std.c.system(cmd.ptr);
|
||||||
|
std.debug.print("\n", .{});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue