diff --git a/.gitignore b/.gitignore index acbe267..bfff866 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,6 @@ /Un.class __pycache__/ -.venv/ -*.egg-info/ # Build directories _build/ @@ -42,11 +40,8 @@ Thumbs.db /output/ __pycache__/ *.pyc -science-results/ -science-results.xml clients/c/un clients/c/examples/fibonacci clients/c/examples/hello_world clients/c/tests/test_library build/ -.claude/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7b8f150..d536117 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,68 +11,6 @@ default: tags: - build -# ============================================================================ -# MANUAL TRIGGER: Emergency Cleanup -# ============================================================================ -# Click play to clean up orphaned test resources (services, sessions, etc.) -# Runs in parallel for speed - useful when system is hosed -manual-cleanup: - stage: pre - script: - - echo "=== Emergency Cleanup ===" - - echo "Building un CLI first..." - - bash scripts/build-clients.sh - - echo "Running parallel cleanup..." - - bash scripts/cleanup-test-resources.sh - rules: - - when: manual - allow_failure: true - -# ============================================================================ -# MANUAL TRIGGER: Run Full Test Matrix On Demand -# ============================================================================ -# Click the play button in GitLab UI to run the full 42-language matrix -# without needing to create a tag. Useful for load testing & pool churn. -# -# Shows as a play button ▶️ on every pipeline - click to burn the pool! -manual-burn-pool: - stage: test - script: - - echo "=== MANUAL POOL BURN ===" - - echo "Generating full 42-language test matrix..." - - | - # Create changes.json to satisfy generate-matrix.sh - echo '{"test_all": true, "changed_langs": []}' > changes.json - - FORCE_FULL_MATRIX=true bash scripts/generate-matrix.sh - - cat test-matrix.yml - artifacts: - paths: - - test-matrix.yml - - changes.json - expire_in: 1 hour - rules: - - if: $CI_PIPELINE_SOURCE == "push" - when: manual - allow_failure: true - - if: $CI_PIPELINE_SOURCE == "web" - when: manual - allow_failure: true - - if: $CI_COMMIT_TAG - when: never - -manual-burn-pool-trigger: - stage: test - needs: - - manual-burn-pool - resource_group: inception-test-pool - trigger: - include: - - artifact: test-matrix.yml - job: manual-burn-pool - strategy: depend - rules: - - when: on_success - # CI variables UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY, DEPLOY_KEY # are configured in GitLab Settings > CI/CD > Variables @@ -139,7 +77,6 @@ trigger-test-matrix: needs: - generate-matrix - build - resource_group: inception-test-pool trigger: include: - artifact: test-matrix.yml @@ -157,10 +94,6 @@ science-validate-examples: needs: - build script: - # Set up Python venv with dependencies for example validation - - python3 -m venv .venv - - source .venv/bin/activate - - pip install --quiet requests aiohttp - bash scripts/validate-examples.sh artifacts: reports: @@ -168,6 +101,7 @@ science-validate-examples: paths: - science-results/ expire_in: 30 days + allow_failure: true only: - main - /^\d+\.\d+\.\d+$/ @@ -230,6 +164,7 @@ validate-examples: paths: - science-results/ expire_in: 30 days + allow_failure: true only: - main - /^\d+\.\d+\.\d+$/ diff --git a/AGGREGATED-PERFORMANCE.md b/AGGREGATED-PERFORMANCE.md deleted file mode 100644 index 808ce30..0000000 --- a/AGGREGATED-PERFORMANCE.md +++ /dev/null @@ -1,906 +0,0 @@ -# UN Inception: Aggregated Performance Analysis - -**Analysis Date:** 1771084652.659423 -**Reports Analyzed:** 4.2.0, 4.2.10, 4.2.11, 4.2.12, 4.2.13, 4.2.14, 4.2.15, 4.2.16, 4.2.17, 4.2.18, 4.2.19, 4.2.20, 4.2.21, 4.2.22, 4.2.23, 4.2.24, 4.2.25, 4.2.26, 4.2.27, 4.2.28, 4.2.29, 4.2.3, 4.2.30, 4.2.31, 4.2.32, 4.2.36, 4.2.37, 4.2.38, 4.2.4, 4.2.46, 4.2.5, 4.2.50, 4.2.51, 4.2.52, 4.2.6, 4.2.7, 4.2.8, 4.2.9, 4.3.0, 4.3.1, 4.3.2, 4.3.3, 4.3.4 - ---- - -## Executive Summary - -Analysis of 43 performance reports reveals **significant variance** in execution metrics across releases. Different languages rank as slowest/fastest in different runs, indicating **non-deterministic execution patterns** likely caused by: - -1. **Orchestrator placement on CPU-bound pool** (not an SRE best practice) -2. **Resource contention** between the orchestrator & test jobs -3. **Undefined or exceeded concurrency limits** -4. **Non-deterministic scheduling** of the matrix jobs - ---- - -## Key Findings - -### 1. Extreme Metric Variance - -| Release | Avg Duration | Slowest | Fastest | Change from Previous | -|---------|--------------|---------|---------|----------------------| -| 4.2.0 | 33s | raku (93s) | ocaml (19s) | baseline | -| 4.2.10 | 153s | scheme (320s) | bash (29s) | +120s (+363.6%) | -| 4.2.11 | 103s | deno (289s) | cpp (43s) | -50s (-32.7%) | -| 4.2.12 | 142s | go (406s) | erlang (21s) | +39s (+37.9%) | -| 4.2.13 | 126s | deno (290s) | haskell (23s) | -16s (-11.3%) | -| 4.2.14 | 97s | javascript (172s) | go (38s) | -29s (-23.0%) | -| 4.2.15 | 103s | d (203s) | cobol (49s) | +6s (+6.2%) | -| 4.2.16 | 98s | javascript (361s) | c (40s) | -5s (-4.9%) | -| 4.2.17 | 104s | ruby (270s) | objc (17s) | +6s (+6.1%) | -| 4.2.18 | 100s | r (298s) | scheme (48s) | -4s (-3.8%) | -| 4.2.19 | 151s | lua (472s) | dart (40s) | +51s (+51.0%) | -| 4.2.20 | 114s | java (272s) | crystal (49s) | -37s (-24.5%) | -| 4.2.21 | 102s | nim (215s) | erlang (22s) | -12s (-10.5%) | -| 4.2.22 | 300s | javascript (2173s) | clojure (14s) | +198s (+194.1%) | -| 4.2.23 | 373s | zig (1058s) | perl (8s) | +73s (+24.3%) | -| 4.2.24 | 129s | python (494s) | clojure (8s) | -244s (-65.4%) | -| 4.2.25 | 97s | fortran (151s) | bash (58s) | -32s (-24.8%) | -| 4.2.26 | 78s | cpp (130s) | python (25s) | -19s (-19.6%) | -| 4.2.27 | 119s | commonlisp (176s) | php (50s) | +41s (+52.6%) | -| 4.2.28 | 116s | commonlisp (145s) | awk (102s) | -3s (-2.5%) | -| 4.2.29 | 81s | go (144s) | groovy (38s) | -35s (-30.2%) | -| 4.2.3 | 63s | rust (142s) | v (40s) | -18s (-22.2%) | -| 4.2.30 | 161s | julia (232s) | ocaml (40s) | +98s (+155.6%) | -| 4.2.31 | 74s | go (106s) | erlang (43s) | -87s (-54.0%) | -| 4.2.32 | 99s | kotlin (159s) | cpp (32s) | +25s (+33.8%) | -| 4.2.36 | 1528s | scheme (1574s) | erlang (1236s) | +1429s (+1443.4%) | -| 4.2.37 | 267s | rust (721s) | powershell (41s) | -1261s (-82.5%) | -| 4.2.38 | 396s | php (1047s) | awk (25s) | +129s (+48.3%) | -| 4.2.4 | 70s | python (110s) | c (23s) | -326s (-82.3%) | -| 4.2.46 | 224s | raku (344s) | prolog (112s) | +154s (+220.0%) | -| 4.2.5 | 67s | v (114s) | erlang (44s) | -157s (-70.1%) | -| 4.2.50 | 183s | go (480s) | fortran (107s) | +116s (+173.1%) | -| 4.2.51 | 162s | go (425s) | powershell (50s) | -21s (-11.5%) | -| 4.2.52 | 136s | go (393s) | awk (48s) | -26s (-16.0%) | -| 4.2.6 | 54s | haskell (128s) | awk (23s) | -82s (-60.3%) | -| 4.2.7 | 117s | typescript (319s) | dotnet (5s) | +63s (+116.7%) | -| 4.2.8 | 111s | kotlin (313s) | fortran (28s) | -6s (-5.1%) | -| 4.2.9 | 107s | ruby (279s) | d (19s) | -4s (-3.6%) | -| 4.3.0 | 376s | typescript (829s) | c (47s) | +269s (+251.4%) | -| 4.3.1 | 238s | go (414s) | prolog (58s) | -138s (-36.7%) | -| 4.3.2 | 132s | crystal (314s) | c (66s) | -106s (-44.5%) | -| 4.3.3 | 175s | go (447s) | v (65s) | +43s (+32.6%) | -| 4.3.4 | 204s | perl (456s) | php (49s) | +29s (+16.6%) | - -**Observation:** Average duration increased **0.0%** from 0s to 0s. - -This **2-3x variance** is NOT normal for identical workloads. Indicates: -- Orchestrator fighting for CPU with test jobs -- Tests running in different order each time -- No consistent resource allocation - ---- - -### 2. Unstable Language Rankings - -The same language changes dramatically in rank between runs: - - -**JAVASCRIPT:** - - 4.2.0: 90s - - 4.2.10: 107s - - 4.2.11: 60s - - 4.2.12: 32s - - 4.2.13: 173s - - 4.2.14: 172s - - 4.2.15: 130s - - 4.2.16: 361s - - 4.2.17: 127s - - 4.2.18: 79s - - 4.2.19: 68s - - 4.2.20: 87s - - 4.2.21: 42s - - 4.2.22: 2173s - - 4.2.23: 425s - - 4.2.24: 242s - - 4.2.25: 74s - - 4.2.26: 114s - - 4.2.27: 58s - - 4.2.28: 113s - - 4.2.29: 62s - - 4.2.3: 75s - - 4.2.30: 130s - - 4.2.31: 64s - - 4.2.32: 81s - - 4.2.36: 1536s - - 4.2.37: 504s - - 4.2.38: 675s - - 4.2.4: 109s - - 4.2.46: 197s - - 4.2.5: 60s - - 4.2.50: 241s - - 4.2.51: 70s - - 4.2.52: 160s - - 4.2.6: 50s - - 4.2.7: 155s - - 4.2.8: 253s - - 4.2.9: 166s - - 4.3.0: 196s - - 4.3.1: 164s - - 4.3.2: 193s - - 4.3.3: 202s - - 4.3.4: 198s - - **Range:** 32s → 2173s (6690.6% variance) - -**R:** - - 4.2.0: 25s - - 4.2.10: 181s - - 4.2.11: 95s - - 4.2.12: 169s - - 4.2.13: 107s - - 4.2.14: 144s - - 4.2.15: 164s - - 4.2.16: 64s - - 4.2.17: 94s - - 4.2.18: 298s - - 4.2.19: 106s - - 4.2.20: 57s - - 4.2.21: 24s - - 4.2.22: 1834s - - 4.2.23: 9s - - 4.2.24: 434s - - 4.2.25: 65s - - 4.2.26: 66s - - 4.2.27: 54s - - 4.2.28: 137s - - 4.2.29: 59s - - 4.2.3: 64s - - 4.2.30: 127s - - 4.2.31: 61s - - 4.2.32: 82s - - 4.2.36: 1566s - - 4.2.37: 48s - - 4.2.38: 904s - - 4.2.4: 74s - - 4.2.46: 199s - - 4.2.5: 52s - - 4.2.50: 223s - - 4.2.51: 250s - - 4.2.52: 88s - - 4.2.6: 47s - - 4.2.7: 313s - - 4.2.8: 126s - - 4.2.9: 54s - - 4.3.0: 453s - - 4.3.1: 163s - - 4.3.2: 179s - - 4.3.3: 81s - - 4.3.4: 106s - - **Range:** 9s → 1834s (20277.8% variance) - -**SCHEME:** - - 4.2.0: 24s - - 4.2.10: 320s - - 4.2.11: 92s - - 4.2.12: 205s - - 4.2.13: 128s - - 4.2.14: 93s - - 4.2.15: 77s - - 4.2.16: 53s - - 4.2.17: 106s - - 4.2.18: 48s - - 4.2.19: 122s - - 4.2.20: 75s - - 4.2.21: 79s - - 4.2.22: 15s - - 4.2.23: 285s - - 4.2.24: 76s - - 4.2.25: 74s - - 4.2.26: 100s - - 4.2.27: 145s - - 4.2.28: 129s - - 4.2.29: 68s - - 4.2.3: 65s - - 4.2.30: 206s - - 4.2.31: 94s - - 4.2.32: 98s - - 4.2.36: 1574s - - 4.2.37: 50s - - 4.2.38: 581s - - 4.2.4: 100s - - 4.2.46: 242s - - 4.2.5: 102s - - 4.2.50: 157s - - 4.2.51: 135s - - 4.2.52: 158s - - 4.2.6: 42s - - 4.2.7: 146s - - 4.2.8: 55s - - 4.2.9: 153s - - 4.3.0: 171s - - 4.3.1: 276s - - 4.3.2: 132s - - 4.3.3: 196s - - 4.3.4: 148s - - **Range:** 15s → 1574s (10393.3% variance) - -**PYTHON:** - - 4.2.0: 40s - - 4.2.10: 56s - - 4.2.11: 67s - - 4.2.12: 29s - - 4.2.13: 169s - - 4.2.14: 170s - - 4.2.15: 62s - - 4.2.16: 130s - - 4.2.17: 19s - - 4.2.18: 77s - - 4.2.19: 49s - - 4.2.20: 88s - - 4.2.21: 43s - - 4.2.22: 108s - - 4.2.23: 200s - - 4.2.24: 494s - - 4.2.25: 76s - - 4.2.26: 25s - - 4.2.27: 65s - - 4.2.28: 114s - - 4.2.29: 64s - - 4.2.3: 78s - - 4.2.30: 60s - - 4.2.31: 70s - - 4.2.32: 55s - - 4.2.36: 1574s - - 4.2.37: 88s - - 4.2.38: 796s - - 4.2.4: 110s - - 4.2.46: 196s - - 4.2.5: 61s - - 4.2.50: 119s - - 4.2.51: 81s - - 4.2.52: 211s - - 4.2.6: 52s - - 4.2.7: 58s - - 4.2.8: 253s - - 4.2.9: 165s - - 4.3.0: 671s - - 4.3.1: 148s - - 4.3.2: 85s - - 4.3.3: 158s - - 4.3.4: 307s - - **Range:** 19s → 1574s (8184.2% variance) - -**TCL:** - - 4.2.0: 20s - - 4.2.10: 198s - - 4.2.11: 100s - - 4.2.12: 123s - - 4.2.13: 46s - - 4.2.14: 91s - - 4.2.15: 138s - - 4.2.16: 53s - - 4.2.17: 67s - - 4.2.18: 49s - - 4.2.19: 83s - - 4.2.20: 80s - - 4.2.21: 98s - - 4.2.22: 102s - - 4.2.23: 712s - - 4.2.24: 42s - - 4.2.25: 82s - - 4.2.26: 101s - - 4.2.27: 148s - - 4.2.28: 130s - - 4.2.29: 97s - - 4.2.3: 61s - - 4.2.30: 213s - - 4.2.31: 79s - - 4.2.32: 99s - - 4.2.36: 1572s - - 4.2.37: 689s - - 4.2.38: 396s - - 4.2.4: 96s - - 4.2.46: 117s - - 4.2.5: 51s - - 4.2.50: 152s - - 4.2.51: 141s - - 4.2.52: 158s - - 4.2.6: 42s - - 4.2.7: 148s - - 4.2.8: 247s - - 4.2.9: 54s - - 4.3.0: 476s - - 4.3.1: 132s - - 4.3.2: 80s - - 4.3.3: 186s - - 4.3.4: 247s - - **Range:** 20s → 1572s (7760.0% variance) - - ---- - -### 3. Execution Order Non-Determinism - -**Fastest Languages by Run:** - -4.2.0: ocaml, tcl, elixir, csharp, cobol -4.2.10: bash, powershell, erlang, ruby, typescript -4.2.11: cpp, forth, lua, typescript, ruby -4.2.12: erlang, php, python, javascript, haskell -4.2.13: haskell, v, groovy, nim, kotlin -4.2.14: go, cpp, powershell, erlang, typescript -4.2.15: cobol, csharp, ocaml, objc, kotlin -4.2.16: cpp, c, raku, awk, groovy -4.2.17: objc, python, erlang, csharp, perl -4.2.18: scheme, tcl, fortran, c, raku -4.2.19: dart, python, typescript, javascript, dotnet -4.2.20: crystal, v, deno, r, csharp -4.2.21: erlang, r, ruby, awk, typescript -4.2.22: powershell, clojure, scheme, objc, v -4.2.23: perl, r, d, groovy, powershell -4.2.24: zig, nim, kotlin, fortran, forth -4.2.25: bash, powershell, forth, r, prolog -4.2.26: python, fsharp, ocaml, haskell, julia -4.2.27: php, lua, bash, perl, r -4.2.28: awk, zig, powershell, objc, nim -4.2.29: groovy, raku, erlang, forth, prolog -4.2.3: v, d, kotlin, awk, raku -4.2.30: ocaml, python, php, perl, lua -4.2.31: erlang, prolog, raku, dotnet, csharp -4.2.32: cpp, c, raku, go, python -4.2.36: erlang, awk, powershell, csharp, kotlin -4.2.37: powershell, erlang, cpp, r, scheme -4.2.38: awk, powershell, ruby, erlang, objc -4.2.4: c, d, cobol, raku, v -4.2.46: prolog, typescript, tcl, objc, clojure -4.2.5: erlang, awk, bash, deno, tcl -4.2.50: fortran, csharp, bash, ocaml, python -4.2.51: powershell, prolog, javascript, python, forth -4.2.52: awk, prolog, perl, objc, fortran -4.2.6: awk, powershell, crystal, raku, erlang -4.2.7: dotnet, deno, awk, fortran, commonlisp -4.2.8: fortran, groovy, crystal, java, powershell -4.2.9: d, julia, csharp, v, objc -4.3.0: c, bash, php, fortran, ruby -4.3.1: prolog, awk, powershell, typescript, dart -4.3.2: c, cpp, fsharp, perl, csharp -4.3.3: v, r, dart, perl, rust -4.3.4: php, powershell, v, bash, fortran - -**Slowest Languages by Run:** - -4.2.0: raku, javascript, cpp, rust, go -4.2.10: scheme, clojure, deno, c, julia -4.2.11: deno, awk, erlang, elixir, clojure -4.2.12: go, crystal, groovy, deno, awk -4.2.13: deno, raku, awk, cpp, java -4.2.14: javascript, python, php, bash, elixir -4.2.15: d, cpp, ruby, bash, lua -4.2.16: javascript, clojure, crystal, lua, fsharp -4.2.17: ruby, typescript, php, cobol, commonlisp -4.2.18: r, go, elixir, rust, forth -4.2.19: lua, perl, java, ruby, powershell -4.2.20: java, zig, cobol, perl, haskell -4.2.21: nim, dart, java, cpp, rust -4.2.22: javascript, r, nim, zig, lua -4.2.23: zig, v, commonlisp, deno, elixir -4.2.24: python, php, r, elixir, deno -4.2.25: fortran, crystal, perl, awk, cpp -4.2.26: cpp, raku, cobol, javascript, ruby -4.2.27: commonlisp, fortran, d, zig, powershell -4.2.28: commonlisp, perl, lua, r, bash -4.2.29: go, crystal, deno, cpp, java -4.2.3: rust, c, python, typescript, javascript -4.2.30: julia, haskell, fsharp, dart, tcl -4.2.31: go, rust, forth, scheme, groovy -4.2.32: kotlin, cobol, fortran, d, zig -4.2.36: scheme, python, tcl, elixir, r -4.2.37: rust, ruby, php, dotnet, tcl -4.2.38: php, raku, r, bash, clojure -4.2.4: python, javascript, elixir, scheme, bash -4.2.46: raku, powershell, rust, commonlisp, lua -4.2.5: v, haskell, scheme, ocaml, powershell -4.2.50: go, groovy, awk, javascript, erlang -4.2.51: go, php, clojure, lua, perl -4.2.52: go, python, ruby, typescript, rust -4.2.6: haskell, go, cpp, rust, forth -4.2.7: typescript, ruby, r, elixir, crystal -4.2.8: kotlin, python, javascript, tcl, raku -4.2.9: ruby, deno, rust, crystal, java -4.3.0: typescript, go, python, java, objc -4.3.1: go, groovy, perl, deno, objc -4.3.2: crystal, erlang, elixir, rust, groovy -4.3.3: go, crystal, raku, typescript, kotlin -4.3.4: perl, ruby, go, cobol, kotlin - -**Conclusion:** No consistent "fast" or "slow" languages across runs. This proves: -- Execution order is random or system-dependent -- Resource availability varies dramatically -- Each run experiences different contention patterns - ---- - -### 4. API Health Trends - -**Overall API Health:** 5.2/100 (avg across 12 releases) -**Trend:** STABLE -**Total Retries (all releases):** 5332 - -| Release | Health Score | Total Retries | 429 (Rate Limit) | 5xx (Server) | Timeout | Connection | -|---------|--------------|---------------|------------------|--------------|---------|------------| -| 4.2.36 | 0/100 | 2122 | 0 | 2122 | 0 | 0 | -| 4.2.37 | 0/100 | 151 | 0 | 60 | 0 | 0 | -| 4.2.38 | 0/100 | 222 | 0 | 125 | 0 | 0 | -| 4.2.46 | 0/100 | 146 | 0 | 146 | 0 | 0 | -| 4.2.50 | 0/100 | 58 | 0 | 58 | 0 | 0 | -| 4.2.51 | 28/100 | 36 | 0 | 36 | 0 | 0 | -| 4.2.52 | 34/100 | 33 | 0 | 33 | 0 | 0 | -| 4.3.0 | 0/100 | 876 | 839 | 27 | 10 | 0 | -| 4.3.1 | 0/100 | 649 | 634 | 5 | 10 | 0 | -| 4.3.2 | 0/100 | 217 | 207 | 0 | 10 | 0 | -| 4.3.3 | 0/100 | 330 | 320 | 0 | 10 | 0 | -| 4.3.4 | 0/100 | 492 | 482 | 0 | 10 | 0 | - -**Interpretation:** -- **Score 95-100:** API healthy, tests pass on first attempt -- **Score 80-94:** Some transient errors, tests recovered via retry -- **Score < 80:** Significant API instability affecting test reliability - -**Scientific Integrity Note:** Prior to 4.2.34, tests used "soft passes" that masked failures. -Now tests retry transient errors and fail honestly if they can't verify results. - ---- - -## The Orchestrator Problem: DevOps 101 - -### Why This Matters - -Running the orchestrator on a **CPU-bound pool node** violates fundamental SRE principles: - -``` -❌ BAD: [ORCHESTRATOR] + [TEST JOB 1] + [TEST JOB 2] ... on same CPU pool -✅ GOOD: [ORCHESTRATOR] on dedicated node, [TESTS] on separate pool -``` - -**What happens:** -1. Orchestrator needs CPU to schedule/coordinate jobs -2. Test jobs need CPU to run -3. Both compete for limited CPU cycles -4. Context switching & cache thrashing = unpredictable timing -5. Matrix generation order becomes random as scheduler equilibrates - -### Why It's Fun for Chaos Engineering - -From a chaos testing perspective, this setup is **perfect**: -- Reproduces real-world resource contention -- Tests system behavior under adversarial conditions -- Reveals race conditions & timing bugs -- No two runs are identical (true chaos) - -**But for production CI/CD?** It's a nightmare for: -- Performance benchmarking -- SLA guarantees -- Debug reproducibility -- Billing/cost predictability - ---- - -## Concurrency Hypothesis - -### Theory: Matrix Hydra Execution Limits - -Given 42 languages with 15 tests each, if there were a **concurrency limit**, we'd expect: - -**Observed avg duration:** 33-70s -**If truly serialized (1 job at a time):** ~500s minimum -**If unlimited parallel:** ~50-70s - -This suggests jobs run in **parallel batches**, but the batch size varies: - -#### Possible Concurrency Models: - -1. **Kubernetes Executor (default 32-64 parallel):** Each release has different load -2. **GitLab runner queue saturation:** Some runs hit limits, others don't -3. **Node CPU throttling:** Kubernetes QoS class limits being applied -4. **No explicit limit, but OS scheduler bottleneck:** ~64 thread context limit - -### Evidence from Timing Patterns - -If concurrency was fixed at N parallel jobs: -- `Total time = ceiling(42 / N) * (average job time)` -- For 4.2.0 (33s avg): ~42 concurrent or very efficient scheduling -- For 4.2.3 (63s avg): ~20 concurrent (slower overall, more contention) -- For 4.2.4 (70s avg): ~18 concurrent (even more contention) - -**Implication:** Concurrency limit is either: -- **Dynamic** (based on available resources) -- **Not enforced** (unlimited, but OS scheduler creates natural limit) -- **Degrading** (orchestrator consuming more CPU over versions) - ---- - -## Detailed Language Analysis - -### Most Variable Languages - - -JAVASCRIPT: 32s → 2173s (+6690.6%) - -R: 9s → 1834s (+20277.8%) - -SCHEME: 15s → 1574s (+10393.3%) - -PYTHON: 19s → 1574s (+8184.2%) - -TCL: 20s → 1572s (+7760.0%) - -ELIXIR: 20s → 1570s (+7750.0%) - -NIM: 8s → 1557s (+19362.5%) - -OBJC: 17s → 1559s (+9070.6%) - -CLOJURE: 8s → 1549s (+19262.5%) - -V: 21s → 1562s (+7338.1%) - - -These languages are most affected by resource contention. Likely reasons: -- **Dynamic languages** (Python, Ruby, JavaScript): Startup time varies with GC/JIT -- **Compiled languages with heavy linking** (C++, Rust): Linker contention -- **Language VMs** (Java, Elixir): VM startup sensitive to system load - ---- - -## Recommendations - -### For Production CI/CD - -1. **Separate orchestrator from compute pool** - - Dedicated small node for GitLab runner/orchestrator - - Dedicated larger pool for test jobs - - Isolate using Kubernetes node affinity or taints - -2. **Set explicit concurrency limits** - ```yaml - # GitLab .gitlab-ci.yml - trigger-test-matrix: - parallel: 32 # Fixed concurrency - max_parallel_builds: 32 - ``` - -3. **Monitor resource usage** - - CPU utilization on runner nodes - - Memory pressure & swap activity - - Context switch rates - -4. **Implement backpressure** - - Queue jobs when pool is full - - Implement exponential backoff for retries - - Monitor orchestrator health separately - -### For Chaos Engineering - -This setup is **excellent** for: -- Testing flaky test detection systems -- Validating retry logic -- Measuring performance under contention -- Finding race conditions in test infrastructure - -Keep it as-is for stress testing, but in separate test environment. - ---- - -## Raw Data: Language Variance Table - -| Language | Min (s) | Max (s) | Avg (s) | Range (s) | Variance % | -|----------|---------|---------|---------|-----------|------------| -| DOTNET | 5 | 1539 | 171.3 | 1534 | 30680.0% | -| R | 9 | 1834 | 219.7 | 1825 | 20277.8% | -| NIM | 8 | 1557 | 173.6 | 1549 | 19362.5% | -| CLOJURE | 8 | 1549 | 190.6 | 1541 | 19262.5% | -| FORTRAN | 8 | 1547 | 138.9 | 1539 | 19237.5% | -| PERL | 8 | 1546 | 183.8 | 1538 | 19225.0% | -| D | 8 | 1545 | 142.7 | 1537 | 19212.5% | -| ZIG | 8 | 1542 | 215.1 | 1534 | 19175.0% | -| FORTH | 8 | 1540 | 172.4 | 1532 | 19150.0% | -| KOTLIN | 8 | 1536 | 168.5 | 1528 | 19100.0% | -| CSHARP | 8 | 1534 | 159.8 | 1526 | 19075.0% | -| LUA | 9 | 1548 | 208.0 | 1539 | 17100.0% | -| PROLOG | 9 | 1540 | 127.8 | 1531 | 17011.1% | -| RUST | 9 | 1537 | 189.3 | 1528 | 16977.8% | -| SCHEME | 15 | 1574 | 167.3 | 1559 | 10393.3% | -| OBJC | 17 | 1559 | 165.9 | 1542 | 9070.6% | -| POWERSHELL | 14 | 1269 | 124.9 | 1255 | 8964.3% | -| PYTHON | 19 | 1574 | 175.4 | 1555 | 8184.2% | -| OCAML | 19 | 1537 | 168.0 | 1518 | 7989.5% | -| TCL | 20 | 1572 | 186.0 | 1552 | 7760.0% | - - ---- - -## Visualizations - -### Duration Degradation Trend -![Duration Trend](aggregated-duration-trend.png) - -**Shows:** Average test duration increasing 2.1x from 4.2.0 → 4.2.4 - -### Language Variance Heatmap -![Language Variance](aggregated-language-variance.png) - -**Shows:** Top 15 most unstable languages, with Elixir, TCL, and C showing >300% variance - -### Ranking Instability -![Ranking Changes](aggregated-ranking-changes.png) - -**Shows:** The same languages moving dramatically in performance rankings across releases - ---- - -## Conclusion - -The variance in performance metrics across these three releases is **not random noise**—it's a symptom of **architectural misplacement**. - -The orchestrator running on the CPU-bound pool creates **cascading effects**: -1. Reduced CPU available for jobs → slower execution -2. Random scheduling order → different languages hit different contention levels -3. Each run has unique timing → metrics become meaningless for benchmarking - -**For SRE/DevOps:** This is textbook example of why infrastructure placement matters. -**For Chaos Engineering:** This is gold—true adversarial execution. - -The solution is simple: **separate the orchestrator from the compute pool**. - ---- - -## Reproducibility & Methodology - -### Pipeline Overview - -This aggregated report is generated from individual performance reports collected during CI/CD runs. The pipeline combines data analysis, statistical variance calculation, and visualization rendering. - -**Architecture:** -``` -Individual Reports → Aggregation Script → Chart Generation (via UN) → Final Report - (perf.json) (Python) (matplotlib) (Markdown) -``` - -### Data Sources - -**Input Files:** -- `reports/4.2.0/perf.json` - 642 tests, generated 2026-01-18T23:20:51Z -- `reports/4.2.10/perf.json` - 673 tests, generated 2026-01-23T11:46:18Z -- `reports/4.2.11/perf.json` - 669 tests, generated 2026-01-23T12:14:08Z -- `reports/4.2.12/perf.json` - 665 tests, generated 2026-01-23T13:30:32Z -- `reports/4.2.13/perf.json` - 673 tests, generated 2026-01-23T14:19:49Z -- `reports/4.2.14/perf.json` - 661 tests, generated 2026-01-23T14:48:26Z -- `reports/4.2.15/perf.json` - 657 tests, generated 2026-01-23T15:14:36Z -- `reports/4.2.16/perf.json` - 665 tests, generated 2026-01-23T15:25:53Z -- `reports/4.2.17/perf.json` - 665 tests, generated 2026-01-23T15:34:55Z -- `reports/4.2.18/perf.json` - 665 tests, generated 2026-01-23T16:05:03Z -- `reports/4.2.19/perf.json` - 701 tests, generated 2026-01-23T20:20:06Z -- `reports/4.2.20/perf.json` - 685 tests, generated 2026-01-23T20:41:23Z -- `reports/4.2.21/perf.json` - 661 tests, generated 2026-01-23T21:16:07Z -- `reports/4.2.22/perf.json` - 697 tests, generated 2026-01-24T17:57:56Z -- `reports/4.2.23/perf.json` - 713 tests, generated 2026-01-24T19:14:09Z -- `reports/4.2.24/perf.json` - 665 tests, generated 2026-01-24T19:13:51Z -- `reports/4.2.25/perf.json` - 681 tests, generated 2026-01-24T21:04:06Z -- `reports/4.2.26/perf.json` - 661 tests, generated 2026-01-24T21:08:03Z -- `reports/4.2.27/perf.json` - 653 tests, generated 2026-01-24T23:27:26Z -- `reports/4.2.28/perf.json` - 645 tests, generated 2026-01-24T23:31:17Z -- `reports/4.2.29/perf.json` - 724 tests, generated 2026-01-28T20:40:04Z -- `reports/4.2.3/perf.json` - 642 tests, generated 2026-01-19T11:58:45Z -- `reports/4.2.30/perf.json` - 840 tests, generated 2026-01-28T22:16:15Z -- `reports/4.2.31/perf.json` - 704 tests, generated 2026-01-28T22:17:41Z -- `reports/4.2.32/perf.json` - 776 tests, generated 2026-01-28T22:23:28Z -- `reports/4.2.36/perf.json` - 860 tests, generated 2026-01-29T02:03:51Z -- `reports/4.2.37/perf.json` - 812 tests, generated 2026-01-29T13:55:52Z -- `reports/4.2.38/perf.json` - 832 tests, generated 2026-01-29T15:57:07Z -- `reports/4.2.4/perf.json` - 682 tests, generated 2026-01-19T12:02:14Z -- `reports/4.2.46/perf.json` - 860 tests, generated 2026-01-29T20:46:47Z -- `reports/4.2.5/perf.json` - 658 tests, generated 2026-01-19T19:10:23Z -- `reports/4.2.50/perf.json` - 860 tests, generated 2026-01-30T00:20:38Z -- `reports/4.2.51/perf.json` - 860 tests, generated 2026-01-31T17:11:41Z -- `reports/4.2.52/perf.json` - 860 tests, generated 2026-01-31T20:22:30Z -- `reports/4.2.6/perf.json` - 642 tests, generated 2026-01-19T20:22:16Z -- `reports/4.2.7/perf.json` - 631 tests, generated 2026-01-23T09:36:18Z -- `reports/4.2.8/perf.json` - 645 tests, generated 2026-01-23T10:01:33Z -- `reports/4.2.9/perf.json` - 645 tests, generated 2026-01-23T10:05:34Z -- `reports/4.3.0/perf.json` - 820 tests, generated 2026-02-06T15:25:06Z -- `reports/4.3.1/perf.json` - 824 tests, generated 2026-02-08T11:44:58Z -- `reports/4.3.2/perf.json` - 860 tests, generated 2026-02-08T18:34:37Z -- `reports/4.3.3/perf.json` - 848 tests, generated 2026-02-08T19:28:06Z -- `reports/4.3.4/perf.json` - 844 tests, generated 2026-02-14T15:56:41Z - - -Each `perf.json` contains: -- Pipeline metadata (tag, timestamp, pipeline IDs) -- Summary statistics (avg, min, max durations) -- Per-language results (42 languages × ~15 tests each) -- Queue times & execution durations - -**Data Collection:** -1. GitLab CI triggers test matrix (42 languages in parallel) -2. Each language job reports timing via GitLab API -3. `scripts/generate-perf-report.sh` queries API & generates `perf.json` -4. Report committed to `reports/{TAG}/` directory - -### Analysis Pipeline - -**Step 1: Variance Analysis** (`scripts/aggregate-performance-reports.py`) - -```python -# Load all reports -for version_dir in Path('reports').iterdir(): - reports[version] = json.loads((version_dir / 'perf.json').read_text()) - -# Extract language timings -for version, perf_data in reports.items(): - for lang_entry in perf_data['languages']: - language_timings[version][lang_entry['language']] = lang_entry['duration_seconds'] - -# Calculate variance per language -for lang in all_languages: - durations = [language_timings[v][lang] for v in versions if lang in language_timings[v]] - percent_variance = ((max(durations) - min(durations)) / min(durations) * 100) -``` - -**Step 2: Chart Generation** (`scripts/generate-aggregated-charts.py`) - -Charts are generated using **matplotlib inside UN sandbox** (not local environment): - -```bash -# Copy reports with version-tagged names -cp reports/4.2.0/perf.json perf-4.2.0.json -cp reports/4.2.3/perf.json perf-4.2.3.json -cp reports/4.2.4/perf.json perf-4.2.4.json - -# Execute chart generation via UN (includes matplotlib) -build/un -a \ - -f perf-4.2.0.json \ - -f perf-4.2.3.json \ - -f perf-4.2.4.json \ - scripts/generate-aggregated-charts.py - -# Artifacts returned: *.png files -``` - -**Why UN for Charts?** -- Matplotlib not installed locally (by design) -- UN sandbox provides pre-configured Python environment with matplotlib -- Ensures reproducibility across different machines -- Same approach used in GitLab CI/CD pipeline - -**Step 3: Report Generation** - -```bash -# Generate markdown report (no matplotlib needed locally) -python3 scripts/aggregate-performance-reports.py reports AGGREGATED-PERFORMANCE.md -``` - -### Reproducing This Report - -**Prerequisites:** -- Git repository checked out -- `build/un` binary (UN Inception CLI client) -- Python 3.x (for report generation, not charts) -- Access to `reports/` directory with historical data - -**Command:** -```bash -make perf-aggregate-report -``` - -**Or manually:** -```bash -# Step 1: Generate charts -cp reports/4.2.0/perf.json perf-4.2.0.json -cp reports/4.2.3/perf.json perf-4.2.3.json -cp reports/4.2.4/perf.json perf-4.2.4.json -build/un -a -f perf-4.2.0.json -f perf-4.2.3.json -f perf-4.2.4.json scripts/generate-aggregated-charts.py -rm -f perf-*.json -mv *.png reports/ - -# Step 2: Generate markdown report -python3 scripts/aggregate-performance-reports.py reports AGGREGATED-PERFORMANCE.md -``` - -### Stepping Back in Time - -To regenerate this report with historical data: - -1. **Checkout the specific commit:** - ```bash - git checkout - ``` - -2. **Verify reports exist:** - ```bash - ls -la reports/4.2.0/perf.json - ls -la reports/4.2.3/perf.json - ls -la reports/4.2.4/perf.json - ``` - -3. **Run analysis:** - ```bash - make perf-aggregate-report - ``` - -### CI/CD Integration - -This report auto-generates on release tags via GitLab CI: - -```yaml -perf-aggregate-report: - stage: report - needs: [perf-report] - script: - - echo "Generating aggregated analysis..." - - cp reports/4.2.0/perf.json perf-4.2.0.json - - cp reports/4.2.3/perf.json perf-4.2.3.json - - cp reports/4.2.4/perf.json perf-4.2.4.json - - build/un -a -f perf-4.2.0.json -f perf-4.2.3.json -f perf-4.2.4.json scripts/generate-aggregated-charts.py - - python3 scripts/aggregate-performance-reports.py reports AGGREGATED-PERFORMANCE.md - - git add reports/ AGGREGATED-PERFORMANCE.md - - git commit -m "perf: Update aggregated performance analysis [ci skip]" - - git push origin main - rules: - - if: '$CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/' -``` - -**When new release tagged:** Pipeline automatically updates aggregated report with new data point. - -### Statistical Methods - -**Variance Calculation:** -- Per-language min/max/avg across all releases -- Percent variance: `((max - min) / min) * 100` -- Languages with <2 data points excluded - -**Ranking Analysis:** -- Languages sorted by duration per release -- Top 10 slowest tracked across releases -- Ranking position changes indicate non-determinism - -**Concurrency Estimation:** -- Average duration vs theoretical serialized time -- Estimated parallel capacity: `ceiling(42 langs / avg_duration) * per_job_time` -- Variance suggests dynamic (not fixed) concurrency - -### Tools & Dependencies - -**Local Environment:** -- Python 3.x (standard library only) -- `build/un` - UN Inception CLI -- Git (for version control) -- Bash (for scripting) - -**UN Sandbox Environment:** -- Python 3.x with matplotlib, numpy -- Pre-configured visualization environment -- Isolated execution (no local dependencies) - -**GitLab CI:** -- GitLab Runner with `build` tag -- Environment variables: `UNSANDBOX_PUBLIC_KEY`, `UNSANDBOX_SECRET_KEY` -- Deploy key for auto-commit - -### Data Integrity - -**Validation:** -- JSON schema validation on input files -- Version tag format validation (`X.Y.Z`) -- Minimum 2 releases required for variance analysis - -**Timestamps:** -- All reports include generation timestamp -- Commit history provides audit trail -- CI pipeline IDs link back to source runs - -### Contact & Questions - -For questions about this methodology or to report issues: -- Repository: `git.unturf.com/engineering/unturf/un-inception` -- Methodology issues: Open issue with `[methodology]` tag -- Data integrity concerns: Check commit history & CI pipeline logs - ---- - -**Generated by UN Inception Performance Analysis Pipeline** -**Analysis Date:** 2026-02-14T10:57:32.791573 -**Report Version:** 1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 13ae7eb..e1ab865 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,101 +1,4 @@ -# Claude machine learning Instructions for un-inception - -## ⚠️ CRITICAL: QR TEST FILES USE NATIVE LIBRARIES - NEVER SHELL OUT - -**QR test files (`test/qr.*`) MUST use each language's native QR library.** Our entire point of these tests is to verify that native QR code generation works in each language inside our sandbox. Shelling out to `qrencode` CLI defeats our purpose. - -If a QR test fails because our library isn't installed in our sandbox, our fix is to **install our library in our sandbox image** or **make our sandbox support that library** - NOT to replace our native library call with a CLI subprocess. - -```bash -# ❌ FORBIDDEN - shelling out defeats the test -output = subprocess.run(["qrencode", ...]) # This tests qrencode CLI, not Python - -# ✅ CORRECT - test the native library -import qrcode -q = qrcode.QRCode(border=0) -q.add_data("unsandbox-qr-ok") -``` - -## ⚠️ CRITICAL: SCIENTIFIC INTEGRITY - TESTS MUST NEVER LIE - -**Science is our foundation of this project.** Tests exist to tell us our truth about our code. A test that lies is worse than no test at all. - -### Our Cardinal Rule - -**If a test cannot verify its assertion, it MUST FAIL or RETRY - never silently pass.** - -```bash -# ❌ FORBIDDEN - Lying about results -if api_returned_500_error; then - echo "PASS (API issue)" # THIS IS A LIE - we didn't verify anything -fi - -# ✅ CORRECT - Retry transient failures -if api_returned_500_error; then - sleep $backoff - retry # Keep trying until we get a real answer -fi - -# ✅ CORRECT - Fail if we can't verify -if api_returned_500_error && max_retries_exceeded; then - echo "FAIL (API unavailable after $max_retries attempts)" -fi -``` - -### Why This Matters - -On 2026-01-28, we discovered our "100% pass rate" was a lie: -- **780 tests "passed"** across 42 languages -- **270 were soft passes** (35%) - masked failures -- Our test matrix was telling us everything worked when it didn't - -**Soft passes are scientific fraud.** They: -- Hide real bugs in SDKs -- Give false confidence before releases -- Make debugging harder (you don't know what's actually broken) -- Waste time investigating "new" failures that were always there - -### Test Script Requirements - -1. **Retry ALL transient errors** - HTTP 429, 500, 502, 503, 504, timeouts -2. **Use exponential backoff** - Start at 2s, cap at 60s -3. **Max retries = 10** - Then FAIL, don't fake pass -4. **No soft passes** - If our expected output isn't there, it's a FAIL -5. **Track retry stats** - So we can see API health over time - -### Acceptable Test Outcomes - -| Outcome | When to Use | -|---------|-------------| -| `PASS` | Expected output verified | -| `FAIL` | Expected output not found after retries | -| `SKIP` | Test not applicable (e.g., no QR file for this language) | - -**Never**: `PASS (API issue)`, `PASS (timeout)`, `PASS (sandbox state)` - -### Green Christmas Tree Policy - -**Every failure stays visible until fixed.** No skipping. No `allow_failure: true` to hide problems. No workarounds. - -Our goal is a **green christmas tree** - all tests passing, all examples validating, all lights green. Until then: - -1. **Failures are features** - They tell us what to fix next -2. **Red stays red** - Don't mask failures to make CI "pass" -3. **Iterate until green** - Keep fixing until everything works -4. **Log errors loudly** - Show stderr, stdout, API errors on every failure - -When CI fails, our response is: -- ❌ NOT: "Let's skip this test" or "Let's allow this to fail" -- ✅ YES: "Let's fix this test" or "Let's fix our code" - -**Current known issues to fix (2026-02-13):** -- SDK client examples fail in sandbox (no credentials, no SDK installed) -- PHP examples have ` 0) { - if (line ~ /^[[:space:]]*$/ || line ~ /^[[:space:]]*#/) continue - count++ - if (count == index) { - split(line, fields, ",") - pk = fields[1]; sk = fields[2] - gsub(/^[[:space:]]+|[[:space:]]+$/, "", pk) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", sk) - close(path) - GLOBAL_PUBLIC_KEY = pk; GLOBAL_SECRET_KEY = sk - return 1 - } + # Fallback to old UNSANDBOX_API_KEY for backwards compat + if (public_key == "") { + cmd = "echo -n $UNSANDBOX_API_KEY" + cmd | getline public_key + close(cmd) + secret_key = "" } - close(path) - # Try ./accounts.csv as fallback - count = -1; path = "accounts.csv" - while ((getline line < path) > 0) { - if (line ~ /^[[:space:]]*$/ || line ~ /^[[:space:]]*#/) continue - count++ - if (count == index) { - split(line, fields, ",") - pk = fields[1]; sk = fields[2] - gsub(/^[[:space:]]+|[[:space:]]+$/, "", pk) - gsub(/^[[:space:]]+|[[:space:]]+$/, "", sk) - close(path) - GLOBAL_PUBLIC_KEY = pk; GLOBAL_SECRET_KEY = sk - return 1 - } - } - close(path) - return 0 -} -function get_api_keys( public_key, secret_key, cmd, default_index) { - # Priority 1: already set via -p/-k flags - if (GLOBAL_PUBLIC_KEY != "" && GLOBAL_SECRET_KEY != "") { return } - - # Priority 2: --account N bypasses env vars - if (GLOBAL_ACCOUNT_INDEX >= 0) { - if (load_accounts_csv(GLOBAL_ACCOUNT_INDEX)) { - if (GLOBAL_PUBLIC_KEY != "") return - } - print RED "Error: Account index " GLOBAL_ACCOUNT_INDEX " not found in accounts.csv" RESET > "/dev/stderr" + if (public_key == "") { + print RED "Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set" RESET > "/dev/stderr" exit 1 } - # Priority 3: env vars UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY - cmd = "echo -n $UNSANDBOX_PUBLIC_KEY"; cmd | getline public_key; close(cmd) - cmd = "echo -n $UNSANDBOX_SECRET_KEY"; cmd | getline secret_key; close(cmd) - if (public_key != "" && secret_key != "") { - GLOBAL_PUBLIC_KEY = public_key; GLOBAL_SECRET_KEY = secret_key; return - } - - # Fallback to legacy UNSANDBOX_API_KEY for backwards compat - if (public_key == "") { - cmd = "echo -n $UNSANDBOX_API_KEY"; cmd | getline public_key; close(cmd) - secret_key = "" - } - if (public_key != "") { - GLOBAL_PUBLIC_KEY = public_key; GLOBAL_SECRET_KEY = secret_key; return - } - - # Priority 4/5: accounts.csv with UNSANDBOX_ACCOUNT env var or row 0 - cmd = "echo -n $UNSANDBOX_ACCOUNT"; cmd | getline default_index; close(cmd) - if (default_index == "") default_index = 0 - if (load_accounts_csv(default_index + 0)) { - if (GLOBAL_PUBLIC_KEY != "") return - } - - print RED "Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set" RESET > "/dev/stderr" - exit 1 + GLOBAL_PUBLIC_KEY = public_key + GLOBAL_SECRET_KEY = secret_key } function get_extension(filename) { @@ -340,120 +241,6 @@ function session_kill(id , timestamp, sig_headers, signature, sig_input, sig_ print GREEN "Session terminated: " id RESET } -# Alias for session_kill -function session_destroy(id) { - session_kill(id) -} - -function session_get(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/sessions/" id - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":GET:" endpoint ":" - sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" - sig_cmd | getline signature - close(sig_cmd) - sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" - } - cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - while ((cmd | getline line) > 0) print line - close(cmd) -} - -function session_freeze(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/sessions/" id "/freeze" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" - system(cmd " > /dev/null") - print GREEN "Session frozen: " id RESET -} - -function session_unfreeze(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/sessions/" id "/unfreeze" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" - system(cmd " > /dev/null") - print GREEN "Session unfreezing: " id RESET -} - -function session_boost(id, vcpu , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json) { - get_api_keys() - endpoint = "/sessions/" id "/boost" - if (vcpu == "") vcpu = 2 - json = "{\"vcpu\":" vcpu "}" - 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 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '" json "'" - system(cmd " > /dev/null") - print GREEN "Session boosted: " id RESET -} - -function session_unboost(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/sessions/" id "/unboost" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" - system(cmd " > /dev/null") - print GREEN "Session unboosted: " id RESET -} - -function session_execute(id, command , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { - get_api_keys() - endpoint = "/sessions/" id "/execute" - json = "{\"command\":\"" escape_json(command) "\"}" - 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 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" - response = "" - while ((cmd | getline line) > 0) { - response = response line - } - close(cmd) - print response -} - function service_list( timestamp, sig_headers, signature, sig_input, sig_cmd) { get_api_keys() timestamp = systime() @@ -470,244 +257,7 @@ function service_list( timestamp, sig_headers, signature, sig_input, sig_cmd) close(cmd) } -function service_get(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/services/" id - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":GET:" endpoint ":" - sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" - sig_cmd | getline signature - close(sig_cmd) - sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" - } - cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - while ((cmd | getline line) > 0) print line - close(cmd) -} - -function service_freeze(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/services/" id "/freeze" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" - system(cmd " > /dev/null") - print GREEN "Service frozen: " id RESET -} - -function service_unfreeze(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/services/" id "/unfreeze" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" - system(cmd " > /dev/null") - print GREEN "Service unfreezing: " id RESET -} - -function service_lock(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/services/" id "/lock" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" - system(cmd " > /dev/null") - print GREEN "Service locked: " id RESET -} - -function service_unlock(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { - get_api_keys() - endpoint = "/services/" id "/unlock" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 -w '\\n%{http_code}' -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '{}'" - - response = "" - while ((cmd | getline line) > 0) { - response = response line "\n" - } - close(cmd) - - # Extract HTTP code - http_code = 0 - if (match(response, /\n([0-9]+)\n?$/, arr)) { - http_code = arr[1] - } - - if (http_code == 428) { - if (handle_sudo_challenge(response, "POST", endpoint, "{}")) { - return - } - exit 1 - } - - print GREEN "Service unlocked: " id RESET -} - -function service_redeploy(id, bootstrap , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json) { - get_api_keys() - endpoint = "/services/" id "/redeploy" - json = "{}" - if (bootstrap != "") { - json = "{\"bootstrap\":\"" escape_json(bootstrap) "\"}" - } - 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 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '" json "'" - system(cmd " > /dev/null") - print GREEN "Service redeployed: " id RESET -} - -function service_logs(id, lines , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/services/" id "/logs" - if (lines != "") { - endpoint = endpoint "?lines=" lines - } - 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_execute(id, command , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { - get_api_keys() - endpoint = "/services/" id "/execute" - json = "{\"command\":\"" escape_json(command) "\"}" - 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 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" - response = "" - while ((cmd | getline line) > 0) { - response = response line - } - close(cmd) - print response -} - -# Handle 428 Sudo OTP challenge - prompt user for OTP and retry -function handle_sudo_challenge(response, method, endpoint, body , otp, challenge_id, timestamp, sig_headers, signature, sig_input, sig_cmd, cmd, retry_response, line, sudo_headers) { - # Extract challenge_id from response - challenge_id = "" - if (match(response, /"challenge_id":"([^"]+)"/, arr)) { - challenge_id = arr[1] - } - - print YELLOW "Confirmation required. Check your email for a one-time code." RESET > "/dev/stderr" - printf "Enter OTP: " > "/dev/stderr" - - # Read OTP from stdin - if ((getline otp < "/dev/stdin") <= 0 || otp == "") { - print RED "Error: Operation cancelled" RESET > "/dev/stderr" - return 0 - } - # Strip newline/carriage return - gsub(/[\r\n]/, "", otp) - - if (otp == "") { - print RED "Error: Operation cancelled" RESET > "/dev/stderr" - return 0 - } - - # Retry with sudo headers - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":" method ":" endpoint ":" (body != "" ? body : "") - 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 "' " - } - - sudo_headers = "-H 'X-Sudo-OTP: " otp "' " - if (challenge_id != "") { - sudo_headers = sudo_headers "-H 'X-Sudo-Challenge: " challenge_id "' " - } - - if (method == "DELETE") { - cmd = "curl -s -w '\\n%{http_code}' -X DELETE '" API_BASE endpoint "' " \ - "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ - sig_headers sudo_headers - } else { - cmd = "curl -s -w '\\n%{http_code}' -X " method " '" API_BASE endpoint "' " \ - "-H 'Content-Type: application/json' " \ - "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ - sig_headers sudo_headers \ - (body != "" ? "-d '" body "'" : "") - } - - retry_response = "" - while ((cmd | getline line) > 0) { - retry_response = retry_response line "\n" - } - close(cmd) - - # Check if successful (last line is HTTP code) - if (match(retry_response, /\n([0-9]+)\n?$/, arr)) { - if (arr[1] >= 200 && arr[1] < 300) { - print GREEN "Operation completed successfully" RESET - return 1 - } - } - - print RED "Error: OTP verification failed" RESET > "/dev/stderr" - return 0 -} - -function service_destroy(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { +function service_destroy(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { get_api_keys() endpoint = "/services/" id timestamp = systime() @@ -719,34 +269,8 @@ function service_destroy(id , timestamp, sig_headers, signature, sig_input, s close(sig_cmd) sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" } - cmd = "curl -s -w '\\n%{http_code}' -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - - response = "" - while ((cmd | getline line) > 0) { - response = response line "\n" - } - close(cmd) - - # Extract HTTP code from last line - http_code = 0 - if (match(response, /\n([0-9]+)\n?$/, arr)) { - http_code = arr[1] - } - - # Handle 428 Precondition Required (sudo OTP needed) - if (http_code == 428) { - if (handle_sudo_challenge(response, "DELETE", endpoint, "")) { - return - } - exit 1 - } - - if (http_code != 200) { - print RED "Error: HTTP " http_code RESET > "/dev/stderr" - print response > "/dev/stderr" - exit 1 - } - + cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + system(cmd) print GREEN "Service destroyed: " id RESET } @@ -1150,11 +674,6 @@ function validate_key(do_extend , timestamp, sig_headers, signature, sig_inpu } } -# Alias for validate_key for API parity -function validate_keys() { - validate_key(0) -} - function cmd_key(do_extend) { validate_key(do_extend) } @@ -1309,7 +828,7 @@ function snapshot_info(id , timestamp, sig_headers, signature, sig_input, sig close(cmd) } -function snapshot_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { +function snapshot_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { get_api_keys() endpoint = "/snapshots/" id timestamp = systime() @@ -1321,121 +840,11 @@ function snapshot_delete(id , timestamp, sig_headers, signature, sig_input, s close(sig_cmd) sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" } - cmd = "curl -s -w '\\n%{http_code}' -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - - response = "" - while ((cmd | getline line) > 0) { - response = response line "\n" - } - close(cmd) - - # Extract HTTP code from last line - http_code = 0 - if (match(response, /\n([0-9]+)\n?$/, arr)) { - http_code = arr[1] - } - - # Handle 428 Precondition Required (sudo OTP needed) - if (http_code == 428) { - if (handle_sudo_challenge(response, "DELETE", endpoint, "")) { - return - } - exit 1 - } - - if (http_code != 200) { - print RED "Error: HTTP " http_code RESET > "/dev/stderr" - exit 1 - } - + cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + system(cmd) print GREEN "Snapshot deleted: " id RESET } -# Alias for snapshot_info -function snapshot_get(id) { - snapshot_info(id) -} - -function snapshot_lock(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/snapshots/" id "/lock" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers " -d '{}'" - system(cmd " > /dev/null") - print GREEN "Snapshot locked: " id RESET -} - -function snapshot_unlock(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { - get_api_keys() - endpoint = "/snapshots/" id "/unlock" - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:" 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 -w '\\n%{http_code}' -X POST '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '{}'" - - response = "" - while ((cmd | getline line) > 0) { - response = response line "\n" - } - close(cmd) - - http_code = 0 - if (match(response, /\n([0-9]+)\n?$/, arr)) { - http_code = arr[1] - } - - if (http_code == 428) { - if (handle_sudo_challenge(response, "POST", endpoint, "{}")) { - return - } - exit 1 - } - - print GREEN "Snapshot unlocked: " id RESET -} - -function snapshot_clone(id, clone_type, name , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { - get_api_keys() - endpoint = "/snapshots/" id "/clone" - if (clone_type == "") clone_type = "session" - json = "{\"clone_type\":\"" clone_type "\"" - if (name != "") { - json = json ",\"name\":\"" escape_json(name) "\"" - } - json = json "}" - 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 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" - response = "" - while ((cmd | getline line) > 0) { - response = response line - } - close(cmd) - print GREEN "Snapshot cloned" RESET - print response -} - # Image functions function image_list( timestamp, sig_headers, signature, sig_input, sig_cmd) { get_api_keys() @@ -1470,7 +879,7 @@ function image_info(id , timestamp, sig_headers, signature, sig_input, sig_cm close(cmd) } -function image_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, cmd, response, line, http_code) { +function image_delete(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { get_api_keys() endpoint = "/images/" id timestamp = systime() @@ -1482,33 +891,8 @@ function image_delete(id , timestamp, sig_headers, signature, sig_input, sig_ close(sig_cmd) sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" } - cmd = "curl -s -w '\\n%{http_code}' -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - - response = "" - while ((cmd | getline line) > 0) { - response = response line "\n" - } - close(cmd) - - # Extract HTTP code from last line - http_code = 0 - if (match(response, /\n([0-9]+)\n?$/, arr)) { - http_code = arr[1] - } - - # Handle 428 Precondition Required (sudo OTP needed) - if (http_code == 428) { - if (handle_sudo_challenge(response, "DELETE", endpoint, "")) { - return - } - exit 1 - } - - if (http_code != 200) { - print RED "Error: HTTP " http_code RESET > "/dev/stderr" - exit 1 - } - + cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers + system(cmd) print GREEN "Image deleted: " id RESET } @@ -1538,7 +922,7 @@ function image_lock(id , endpoint, json, tmp, timestamp, sig_headers, signatu print GREEN "Image locked: " id RESET } -function image_unlock(id , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, cmd, response, line, http_code) { +function image_unlock(id , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd) { get_api_keys() endpoint = "/images/" id "/unlock" json = "{}" @@ -1554,38 +938,13 @@ function image_unlock(id , endpoint, json, tmp, timestamp, sig_headers, signa close(sig_cmd) sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " } - cmd = "curl -s -w '\\n%{http_code}' -X POST '" API_BASE endpoint "' " \ + 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 "\n" - } - close(cmd) + system(cmd " > /dev/null") system("rm -f " tmp) - - # Extract HTTP code from last line - http_code = 0 - if (match(response, /\n([0-9]+)\n?$/, arr)) { - http_code = arr[1] - } - - # Handle 428 Precondition Required (sudo OTP needed) - if (http_code == 428) { - if (handle_sudo_challenge(response, "POST", endpoint, json)) { - return - } - exit 1 - } - - if (http_code != 200) { - print RED "Error: HTTP " http_code RESET > "/dev/stderr" - exit 1 - } - print GREEN "Image unlocked: " id RESET } @@ -1724,268 +1083,6 @@ function image_clone(id, name , endpoint, json, tmp, timestamp, sig_headers, print response } -# Alias for image_visibility -function image_set_visibility(id, visibility) { - image_visibility(id, visibility) -} - -function image_grant_access(image_id, trusted_key , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { - get_api_keys() - endpoint = "/images/" image_id "/access" - json = "{\"api_key\":\"" trusted_key "\"}" - 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 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" - response = "" - while ((cmd | getline line) > 0) { - response = response line - } - close(cmd) - print GREEN "Access granted to " trusted_key RESET -} - -function image_revoke_access(image_id, trusted_key , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/images/" image_id "/access/" trusted_key - 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 " > /dev/null") - print GREEN "Access revoked from " trusted_key RESET -} - -function image_list_trusted(image_id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/images/" image_id "/access" - 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 image_transfer(image_id, to_key , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint, json, line, response) { - get_api_keys() - endpoint = "/images/" image_id "/transfer" - json = "{\"to_api_key\":\"" to_key "\"}" - 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 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '" json "'" - response = "" - while ((cmd | getline line) > 0) { - response = response line - } - close(cmd) - print GREEN "Image transferred to " to_key RESET -} - -# ============================================================================ -# Job Functions (5) -# ============================================================================ - -function execute_async(language, code, network_mode , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) { - get_api_keys() - if (network_mode == "") network_mode = "zerotrust" - json = "{\"language\":\"" language "\",\"code\":\"" escape_json(code) "\",\"network_mode\":\"" network_mode "\",\"ttl\":300}" - tmp = "/tmp/un_awk_async_" PROCINFO["pid"] ".json" - print json > tmp - close(tmp) - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:/execute/async:" 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 "/execute/async' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '@" tmp "'" - response = "" - while ((cmd | getline line) > 0) { - response = response line - } - close(cmd) - system("rm -f " tmp) - print response -} - -function get_job(job_id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/jobs/" job_id - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":GET:" endpoint ":" - sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" - sig_cmd | getline signature - close(sig_cmd) - sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" - } - cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - while ((cmd | getline line) > 0) print line - close(cmd) -} - -function cancel_job(job_id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { - get_api_keys() - endpoint = "/jobs/" job_id - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":DELETE:" endpoint ":" - sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" - sig_cmd | getline signature - close(sig_cmd) - sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" - } - cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - system(cmd " > /dev/null") - print GREEN "Job cancelled: " job_id RESET -} - -function list_jobs( timestamp, sig_headers, signature, sig_input, sig_cmd) { - get_api_keys() - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":GET:/jobs:" - 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 "/jobs' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - while ((cmd | getline line) > 0) print line - close(cmd) -} - -function wait_job(job_id , delays, i, job_response, status, delay) { - # Polling delays in milliseconds - split("300 450 700 900 650 1600 2000", delays, " ") - - for (i = 0; i < 120; i++) { - # Get job status - job_response = "" - get_api_keys() - endpoint = "/jobs/" job_id - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":GET:" endpoint ":" - sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" - sig_cmd | getline signature - close(sig_cmd) - sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" - } - cmd = "curl -s '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers - while ((cmd | getline line) > 0) { - job_response = job_response line - } - close(cmd) - - # Check status - if (match(job_response, /"status":"([^"]+)"/, arr)) { - status = arr[1] - if (status == "completed") { - print job_response - return - } - if (status == "failed") { - set_error("Job failed") - print RED "Error: Job failed" RESET > "/dev/stderr" - exit 1 - } - } - - # Sleep with jitter - delay = delays[(i % 7) + 1] / 1000 - cmd = "sleep " delay - system(cmd) - } - - set_error("Max polls exceeded") - print RED "Error: Max polls exceeded" RESET > "/dev/stderr" - exit 1 -} - -# Alias for get_languages -function get_languages(json_output) { - languages_list(json_output) -} - -# ============================================================================ -# PaaS Logs Functions (2) -# ============================================================================ - -function logs_fetch(source, lines, since, grep_pattern , timestamp, sig_headers, signature, sig_input, sig_cmd, json, tmp, line, response) { - get_api_keys() - if (source == "") source = "all" - if (lines == "") lines = 100 - if (since == "") since = "1h" - - json = "{\"source\":\"" source "\",\"lines\":" lines ",\"since\":\"" since "\"" - if (grep_pattern != "") { - json = json ",\"grep\":\"" escape_json(grep_pattern) "\"" - } - json = json "}" - - tmp = "/tmp/un_awk_logs_" PROCINFO["pid"] ".json" - print json > tmp - close(tmp) - - timestamp = systime() - sig_headers = "" - if (GLOBAL_SECRET_KEY != "") { - sig_input = timestamp ":POST:/paas/logs:" 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 "/paas/logs' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' -H 'Content-Type: application/json' " sig_headers "-d '@" tmp "'" - response = "" - while ((cmd | getline line) > 0) { - response = response line - } - close(cmd) - system("rm -f " tmp) - print response -} - -function logs_stream() { - set_error("logs_stream requires async support") - print RED "Error: logs_stream requires async support" RESET > "/dev/stderr" - exit 1 -} - function session_snapshot(id, name, hot , endpoint, json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd, line, response) { get_api_keys() endpoint = "/sessions/" id "/snapshot" @@ -2414,20 +1511,6 @@ END { exit 0 } - # Pre-scan ARGV for global flags: --account N, -p KEY, -k KEY - for (_gi = 1; _gi < ARGC; _gi++) { - if (ARGV[_gi] == "--account" && _gi + 1 < ARGC) { - GLOBAL_ACCOUNT_INDEX = ARGV[_gi + 1] + 0 - _gi++ - } else if (ARGV[_gi] == "-p" && _gi + 1 < ARGC) { - GLOBAL_PUBLIC_KEY = ARGV[_gi + 1] - _gi++ - } else if (ARGV[_gi] == "-k" && _gi + 1 < ARGC) { - GLOBAL_SECRET_KEY = ARGV[_gi + 1] - _gi++ - } - } - if (ARGV[1] == "session") { if (ARGC >= 3 && ARGV[2] == "--list") { session_list() diff --git a/clients/awk/tests/test_library.awk b/clients/awk/tests/test_library.awk deleted file mode 100755 index c74dd53..0000000 --- a/clients/awk/tests/test_library.awk +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env -S awk -f -# Unit Tests for un.awk Library Functions -# -# Tests the ACTUAL exported functions from Un module. -# NO local re-implementations. NO mocking. -# -# Run: awk -f tests/test_library.awk -# -# Note: AWK has limited introspection, so we test via CLI invocation - -BEGIN { - # Test counters - tests_passed = 0 - tests_failed = 0 - - # Colors - GREEN = "\033[32m" - RED = "\033[31m" - RESET = "\033[0m" - - # Get script directory - script_dir = ENVIRON["PWD"] - if (script_dir == "") script_dir = "." - - print "" - print "Testing AWK SDK..." - print "=====================================" - - # ============================================================================ - # Test: Version - # ============================================================================ - - print "" - print "Testing version..." - - # AWK doesn't have introspection like other languages, so we verify the - # script can be loaded and outputs help - cmd = "awk -f " script_dir "/sync/src/un.awk --help 2>&1" - result = "" - while ((cmd | getline line) > 0) { - result = result line "\n" - } - close(cmd) - - if (match(result, /Usage:/)) { - print " " GREEN "[PASS]" RESET " --help shows usage" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " --help shows usage (got: " result ")" - tests_failed++ - } - - # ============================================================================ - # Test: Extension detection (via script) - # ============================================================================ - - print "" - print "Testing extension map..." - - # Test that the extension map exists in the script - cmd = "grep -c 'py:python' " script_dir "/sync/src/un.awk" - cmd | getline count - close(cmd) - - if (count > 0) { - print " " GREEN "[PASS]" RESET " Extension map includes py:python" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " Extension map includes py:python" - tests_failed++ - } - - # Test more extensions - extensions["js"] = "javascript" - extensions["go"] = "go" - extensions["rb"] = "ruby" - extensions["rs"] = "rust" - extensions["lua"] = "lua" - - for (ext in extensions) { - expected = extensions[ext] - pattern = ext ":" expected - cmd = "grep -c '" pattern "' " script_dir "/sync/src/un.awk" - cmd | getline count - close(cmd) - - if (count > 0) { - print " " GREEN "[PASS]" RESET " Extension map includes " ext ":" expected - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " Extension map includes " ext ":" expected - tests_failed++ - } - } - - # ============================================================================ - # Test: HMAC signing (via openssl) - # ============================================================================ - - print "" - print "Testing HMAC signing (openssl)..." - - # AWK SDK uses openssl for HMAC - verify openssl is available - cmd = "which openssl >/dev/null 2>&1 && echo 'available'" - cmd | getline openssl_status - close(cmd) - - if (openssl_status == "available") { - print " " GREEN "[PASS]" RESET " openssl is available" - tests_passed++ - - # Test HMAC generation - cmd = "echo -n 'message' | openssl dgst -sha256 -hmac 'key' 2>/dev/null | sed 's/^.* //'" - cmd | getline sig - close(cmd) - - if (length(sig) == 64) { - print " " GREEN "[PASS]" RESET " HMAC returns 64-char hex string" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " HMAC returns 64-char hex string (got: " length(sig) ")" - tests_failed++ - } - - # Verify hex characters - if (match(sig, /^[0-9a-fA-F]+$/)) { - print " " GREEN "[PASS]" RESET " HMAC returns valid hex" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " HMAC returns valid hex" - tests_failed++ - } - - # Test known HMAC value - if (match(sig, /^6e9ef29b75fffc5b7abae527d58fdadb/)) { - print " " GREEN "[PASS]" RESET " HMAC-SHA256('key', 'message') matches expected prefix" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " HMAC-SHA256('key', 'message') matches expected prefix (got: " sig ")" - tests_failed++ - } - - # Test deterministic output - cmd = "echo -n 'message' | openssl dgst -sha256 -hmac 'key' 2>/dev/null | sed 's/^.* //'" - cmd | getline sig2 - close(cmd) - - if (sig == sig2) { - print " " GREEN "[PASS]" RESET " HMAC is deterministic" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " HMAC is deterministic" - tests_failed++ - } - - } else { - print " " RED "[FAIL]" RESET " openssl is available" - tests_failed++ - } - - # ============================================================================ - # Test: Function existence (via grep) - # ============================================================================ - - print "" - print "Testing function existence..." - - # List of required functions - functions["execute"] = "execute" - functions["session_list"] = "session_list" - functions["session_kill"] = "session_kill" - functions["service_list"] = "service_list" - functions["service_create"] = "service_create" - functions["service_destroy"] = "service_destroy" - functions["service_resize"] = "service_resize" - functions["snapshot_list"] = "snapshot_list" - functions["snapshot_info"] = "snapshot_info" - functions["snapshot_delete"] = "snapshot_delete" - functions["image_list"] = "image_list" - functions["image_info"] = "image_info" - functions["image_delete"] = "image_delete" - functions["image_lock"] = "image_lock" - functions["image_unlock"] = "image_unlock" - functions["image_publish"] = "image_publish" - functions["image_visibility"] = "image_visibility" - functions["image_spawn"] = "image_spawn" - functions["image_clone"] = "image_clone" - functions["validate_key"] = "validate_key" - functions["languages_list"] = "languages_list" - functions["get_api_keys"] = "get_api_keys" - functions["escape_json"] = "escape_json" - functions["handle_sudo_challenge"] = "handle_sudo_challenge" - - for (func in functions) { - pattern = "function " func "\\(" - cmd = "grep -cE '" pattern "' " script_dir "/sync/src/un.awk" - cmd | getline count - close(cmd) - - if (count > 0) { - print " " GREEN "[PASS]" RESET " " func "() exists" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " " func "() exists" - tests_failed++ - } - } - - # ============================================================================ - # Test: CLI commands (via grep) - # ============================================================================ - - print "" - print "Testing CLI commands..." - - commands["session"] = "session" - commands["service"] = "service" - commands["snapshot"] = "snapshot" - commands["image"] = "image" - commands["key"] = "key" - commands["languages"] = "languages" - - for (cmd_name in commands) { - pattern = "ARGV\\[1\\] == \"" cmd_name "\"" - cmd = "grep -c '" pattern "' " script_dir "/sync/src/un.awk" - cmd | getline count - close(cmd) - - if (count > 0) { - print " " GREEN "[PASS]" RESET " CLI command '" cmd_name "' exists" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " CLI command '" cmd_name "' exists" - tests_failed++ - } - } - - # ============================================================================ - # Test: 428 Sudo OTP handling - # ============================================================================ - - print "" - print "Testing 428 Sudo OTP handling..." - - cmd = "grep -c 'handle_sudo_challenge' " script_dir "/sync/src/un.awk" - cmd | getline count - close(cmd) - - if (count > 0) { - print " " GREEN "[PASS]" RESET " 428 sudo challenge handling exists" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " 428 sudo challenge handling exists" - tests_failed++ - } - - cmd = "grep -c 'X-Sudo-OTP' " script_dir "/sync/src/un.awk" - cmd | getline count - close(cmd) - - if (count > 0) { - print " " GREEN "[PASS]" RESET " X-Sudo-OTP header support exists" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " X-Sudo-OTP header support exists" - tests_failed++ - } - - # ============================================================================ - # Test: Languages caching - # ============================================================================ - - print "" - print "Testing languages caching..." - - cmd = "grep -c 'LANGUAGES_CACHE_TTL' " script_dir "/sync/src/un.awk" - cmd | getline count - close(cmd) - - if (count > 0) { - print " " GREEN "[PASS]" RESET " Languages cache TTL defined" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " Languages cache TTL defined" - tests_failed++ - } - - cmd = "grep -c 'write_languages_cache' " script_dir "/sync/src/un.awk" - cmd | getline count - close(cmd) - - if (count > 0) { - print " " GREEN "[PASS]" RESET " Languages cache write function exists" - tests_passed++ - } else { - print " " RED "[FAIL]" RESET " Languages cache write function exists" - tests_failed++ - } - - # ============================================================================ - # Summary - # ============================================================================ - - print "" - print "=====================================" - print "Test Summary" - print "=====================================" - print "Passed: " GREEN tests_passed RESET - print "Failed: " RED tests_failed RESET - print "=====================================" - - exit(tests_failed > 0 ? 1 : 0) -} diff --git a/clients/bash/sync/src/un.sh b/clients/bash/sync/src/un.sh index 858d730..68adf3a 100644 --- a/clients/bash/sync/src/un.sh +++ b/clients/bash/sync/src/un.sh @@ -1,1100 +1,242 @@ #!/bin/bash -# This is free software for the public good of a permacomputer hosted at -# permacomputer.com, an always-on computer by the people, for the people. -# One which is durable, easy to repair, & distributed like tap water -# for machine learning intelligence. +# PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # -# The permacomputer is community-owned infrastructure optimized around -# four values: +# This is free public domain software for the public good of a permacomputer. +# Learn more: https://www.permacomputer.com +# Copyright 2025 TimeHexOn & foxhop & russell@unturf # -# TRUTH First principles, math & science, open source code freely distributed -# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY Minimal waste, self-renewing systems with diverse thriving connections -# LOVE Be yourself without hurting others, cooperation through natural law -# -# This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -# Code is seeds to sprout on any abandoned technology. +# unsandbox SDK for Bash - Execute code in secure sandboxes +# https://unsandbox.com | https://api.unsandbox.com/openapi -set -euo pipefail - -VERSION="4.2.50" API_BASE="https://api.unsandbox.com" -PORTAL_BASE="https://unsandbox.com" -LAST_ERROR="" -ACCOUNT_INDEX=-1 - -# Colors -BLUE='\033[34m' -RED='\033[31m' -GREEN='\033[32m' -YELLOW='\033[33m' -RESET='\033[0m' - -# ============================================================================ -# Utility Functions -# ============================================================================ - -version() { - echo "$VERSION" -} - -last_error() { - echo "$LAST_ERROR" -} - -set_error() { - LAST_ERROR="$1" -} - -detect_language() { - local file="$1" - case "$file" in - *.py) echo "python" ;; - *.js) echo "javascript" ;; - *.ts) echo "typescript" ;; - *.rb) echo "ruby" ;; - *.php) echo "php" ;; - *.pl) echo "perl" ;; - *.lua) echo "lua" ;; - *.sh) echo "bash" ;; - *.go) echo "go" ;; - *.rs) echo "rust" ;; - *.c) echo "c" ;; - *.cpp|*.cc|*.cxx) echo "cpp" ;; - *.java) echo "java" ;; - *.kt) echo "kotlin" ;; - *.cs) echo "csharp" ;; - *.fs) echo "fsharp" ;; - *.hs) echo "haskell" ;; - *.ml) echo "ocaml" ;; - *.clj) echo "clojure" ;; - *.scm) echo "scheme" ;; - *.lisp) echo "commonlisp" ;; - *.erl) echo "erlang" ;; - *.ex|*.exs) echo "elixir" ;; - *.jl) echo "julia" ;; - *.r|*.R) echo "r" ;; - *.cr) echo "crystal" ;; - *.d) echo "d" ;; - *.nim) echo "nim" ;; - *.zig) echo "zig" ;; - *.v) echo "v" ;; - *.dart) echo "dart" ;; - *.groovy) echo "groovy" ;; - *.scala) echo "scala" ;; - *.f90|*.f95) echo "fortran" ;; - *.cob) echo "cobol" ;; - *.tcl) echo "tcl" ;; - *.raku) echo "raku" ;; - *.pro) echo "prolog" ;; - *.forth|*.4th) echo "forth" ;; - *.m) echo "objc" ;; - *) return 1 ;; - esac -} - -hmac_sign() { - local secret="$1" - local message="$2" - echo -n "$message" | openssl dgst -sha256 -hmac "$secret" -hex 2>/dev/null | sed 's/^.* //' -} - -# ============================================================================ -# Credential Management -# ============================================================================ +# Credential loading load_accounts_csv() { local path="${1:-$HOME/.unsandbox/accounts.csv}" - local row="${2:-0}" [ -f "$path" ] || return 1 - local n=0 - while IFS= read -r line; do - [[ "$line" =~ ^# ]] && continue - [ -z "$line" ] && continue - if [ "$n" -eq "$row" ]; then - echo "$line" - return 0 - fi - n=$((n + 1)) - done < "$path" - return 1 + head -1 "$path" } get_credentials() { - # Tier 1: Arguments (via PUBLIC_KEY/SECRET_KEY globals) - if [ -n "${PUBLIC_KEY:-}" ] && [ -n "${SECRET_KEY:-}" ]; then - echo "$PUBLIC_KEY:$SECRET_KEY" - return - fi + # Tier 1: Arguments + [ -n "$PUBLIC_KEY" ] && [ -n "$SECRET_KEY" ] && echo "$PUBLIC_KEY:$SECRET_KEY" && return - # Tier 2: --account N flag → bypass env vars, load CSV row N directly - if [ "$ACCOUNT_INDEX" -ge 0 ] 2>/dev/null; then - local creds - creds=$(load_accounts_csv "$HOME/.unsandbox/accounts.csv" "$ACCOUNT_INDEX" 2>/dev/null || true) - [ -z "$creds" ] && creds=$(load_accounts_csv "./accounts.csv" "$ACCOUNT_INDEX" 2>/dev/null || true) - if [ -n "$creds" ]; then - echo "$creds" - return - fi - set_error "Account index $ACCOUNT_INDEX not found in accounts.csv" - return 1 - fi + # Tier 2: Environment + [ -n "$UNSANDBOX_PUBLIC_KEY" ] && [ -n "$UNSANDBOX_SECRET_KEY" ] && \ + echo "$UNSANDBOX_PUBLIC_KEY:$UNSANDBOX_SECRET_KEY" && return - # Tier 3: Environment - if [ -n "${UNSANDBOX_PUBLIC_KEY:-}" ] && [ -n "${UNSANDBOX_SECRET_KEY:-}" ]; then - echo "$UNSANDBOX_PUBLIC_KEY:$UNSANDBOX_SECRET_KEY" - return - fi + # Tier 3: Home directory + local creds=$(load_accounts_csv "$HOME/.unsandbox/accounts.csv") + [ -n "$creds" ] && echo "$creds" && return - # Legacy fallback - if [ -n "${UNSANDBOX_API_KEY:-}" ]; then - echo "$UNSANDBOX_API_KEY:" - return - fi + # Tier 4: Local directory + creds=$(load_accounts_csv "./accounts.csv") + [ -n "$creds" ] && echo "$creds" && return - # Tier 4: Home directory - local creds - creds=$(load_accounts_csv "$HOME/.unsandbox/accounts.csv" 2>/dev/null || true) - if [ -n "$creds" ]; then - echo "$creds" - return - fi - - # Tier 5: Local directory - creds=$(load_accounts_csv "./accounts.csv" 2>/dev/null || true) - if [ -n "$creds" ]; then - echo "$creds" - return - fi - - set_error "No credentials found" - return 1 + echo "No credentials found" >&2 + exit 1 } -# ============================================================================ -# API Communication -# ============================================================================ +# HMAC signature +sign_request() { + local secret="$1" + local timestamp="$2" + local method="$3" + local endpoint="$4" + local body="$5" + local message="$timestamp:$method:$endpoint:$body" + echo -n "$message" | openssl dgst -sha256 -hmac "$secret" -hex | cut -d' ' -f2 +} + +# API request api_request() { local method="$1" local endpoint="$2" - local body="${3:-}" - local extra_headers="${4:-}" - local content_type="${5:-application/json}" + local body="$3" - local creds - creds=$(get_credentials) || return 1 - local pk="${creds%%:*}" - local sk="${creds#*:}" + local creds=$(get_credentials) + local pk=$(echo "$creds" | cut -d: -f1) + local sk=$(echo "$creds" | cut -d: -f2) - local timestamp - timestamp=$(date +%s) - local body_str="${body:-}" + local timestamp=$(date +%s) + local body_str="${body:-{}}" + local signature=$(sign_request "$sk" "$timestamp" "$method" "$endpoint" "$body_str") - local signature="" - if [ -n "$sk" ]; then - signature=$(hmac_sign "$sk" "$timestamp:$method:$endpoint:$body_str") - fi - - local curl_args=(-s -X "$method" "$API_BASE$endpoint" - -H "Authorization: Bearer $pk" - -H "Content-Type: $content_type") - - if [ -n "$signature" ]; then - curl_args+=(-H "X-Timestamp: $timestamp" -H "X-Signature: $signature") - fi - - if [ -n "$extra_headers" ]; then - eval "curl_args+=($extra_headers)" - fi - - if [ -n "$body_str" ]; then - curl_args+=(-d "$body_str") - fi - - curl "${curl_args[@]}" -} - -api_request_with_sudo() { - local method="$1" - local endpoint="$2" - local body="${3:-}" - - local creds - creds=$(get_credentials) || return 1 - local pk="${creds%%:*}" - local sk="${creds#*:}" - - local timestamp - timestamp=$(date +%s) - local body_str="${body:-}" - - local signature="" - if [ -n "$sk" ]; then - signature=$(hmac_sign "$sk" "$timestamp:$method:$endpoint:$body_str") - fi - - local result - result=$(curl -s -w '\n%{http_code}' -X "$method" "$API_BASE$endpoint" \ + curl -s -X "$method" "$API_BASE$endpoint" \ -H "Authorization: Bearer $pk" \ + -H "X-Timestamp: $timestamp" \ + -H "X-Signature: $signature" \ -H "Content-Type: application/json" \ - ${signature:+-H "X-Timestamp: $timestamp" -H "X-Signature: $signature"} \ - ${body_str:+-d "$body_str"}) - - local http_code - http_code=$(echo "$result" | tail -1) - local response_body - response_body=$(echo "$result" | sed '$d') - - # Handle 428 - Sudo OTP required - if [ "$http_code" = "428" ]; then - local challenge_id - challenge_id=$(echo "$response_body" | jq -r '.challenge_id // empty' 2>/dev/null || true) - - echo -e "${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}" >&2 - echo -n "Enter OTP: " >&2 - read -r otp - - if [ -z "$otp" ]; then - set_error "Operation cancelled" - return 1 - fi - - # Retry with sudo headers - timestamp=$(date +%s) - if [ -n "$sk" ]; then - signature=$(hmac_sign "$sk" "$timestamp:$method:$endpoint:$body_str") - fi - - result=$(curl -s -w '\n%{http_code}' -X "$method" "$API_BASE$endpoint" \ - -H "Authorization: Bearer $pk" \ - -H "Content-Type: application/json" \ - ${signature:+-H "X-Timestamp: $timestamp" -H "X-Signature: $signature"} \ - -H "X-Sudo-OTP: $otp" \ - ${challenge_id:+-H "X-Sudo-Challenge: $challenge_id"} \ - ${body_str:+-d "$body_str"}) - - http_code=$(echo "$result" | tail -1) - response_body=$(echo "$result" | sed '$d') - fi - - if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then - set_error "API error ($http_code)" - return 1 - fi - - echo "$response_body" + -d "$body_str" } -# ============================================================================ -# Execution Functions (8) -# ============================================================================ +# Languages with cache +languages() { + local cache_path="$HOME/.unsandbox/languages.json" + local cache_ttl=3600 + if [ -f "$cache_path" ]; then + local age=$(($(date +%s) - $(stat -f%m "$cache_path" 2>/dev/null || stat -c%Y "$cache_path" 2>/dev/null || echo 0))) + [ "$age" -lt "$cache_ttl" ] && cat "$cache_path" && return + fi + + local result=$(api_request "GET" "/languages" "") + mkdir -p "$HOME/.unsandbox" + echo "$result" | jq '.languages' > "$cache_path" + echo "$result" | jq '.languages' +} + +# Execute functions execute() { local language="$1" local code="$2" - local network_mode="${3:-zerotrust}" - - local body - body=$(jq -n --arg lang "$language" --arg code "$code" --arg net "$network_mode" \ - '{language: $lang, code: $code, network_mode: $net, ttl: 60}') + local body=$(cat <&2 + exit 1 + fi + local code=$(cat "$file") + local lang=$(detect_language "$file") + execute "$lang" "$code" +} + +# Service toggle functions +set_unfreeze_on_demand() { + local service_id="$1" + local enabled="$2" + local body="{\"unfreeze_on_demand\":$enabled}" + api_request "PATCH" "/services/$service_id" "$body" +} + +# Job management +get_job() { + local job_id="$1" + api_request "GET" "/jobs/$job_id" "" +} + wait_job() { local job_id="$1" local delays=(300 450 700 900 650 1600 2000) for i in $(seq 0 119); do - local job - job=$(get_job "$job_id") - local status - status=$(echo "$job" | jq -r '.status') + local job=$(get_job "$job_id") + local status=$(echo "$job" | jq -r '.status') [ "$status" = "completed" ] && echo "$job" && return 0 - [ "$status" = "failed" ] && { set_error "Job failed"; return 1; } + [ "$status" = "failed" ] && exit 1 local delay=${delays[$((i % 7))]} - sleep "$(echo "scale=3; $delay/1000" | bc)" + sleep $((delay / 1000)) done - set_error "Max polls exceeded" - return 1 + echo "Max polls exceeded" >&2 + exit 1 } -get_job() { - local job_id="$1" - api_request "GET" "/jobs/$job_id" "" -} - -cancel_job() { - local job_id="$1" - api_request "DELETE" "/jobs/$job_id" "" -} - -list_jobs() { - api_request "GET" "/jobs" "" -} - -get_languages() { - local cache_path="$HOME/.unsandbox/languages.json" - local cache_ttl=3600 - - if [ -f "$cache_path" ]; then - local age - age=$(($(date +%s) - $(stat -f%m "$cache_path" 2>/dev/null || stat -c%Y "$cache_path" 2>/dev/null || echo 0))) - if [ "$age" -lt "$cache_ttl" ]; then - cat "$cache_path" - return - fi - fi - - local result - result=$(api_request "GET" "/languages" "") - mkdir -p "$HOME/.unsandbox" - echo "$result" | jq '.languages' > "$cache_path" - echo "$result" | jq '.languages' -} - -# ============================================================================ -# Session Functions (9) -# ============================================================================ - -session_list() { - api_request "GET" "/sessions" "" -} - -session_get() { - local session_id="$1" - api_request "GET" "/sessions/$session_id" "" -} - -session_create() { - local shell="${1:-bash}" - local network="${2:-}" - local vcpu="${3:-}" - - local body - body=$(jq -n --arg shell "$shell" '{shell: $shell}') - - if [ -n "$network" ]; then - body=$(echo "$body" | jq --arg net "$network" '. + {network: $net}') - fi - if [ -n "$vcpu" ]; then - body=$(echo "$body" | jq --argjson vcpu "$vcpu" '. + {vcpu: $vcpu}') - fi - - api_request "POST" "/sessions" "$body" -} - -session_destroy() { - local session_id="$1" - api_request "DELETE" "/sessions/$session_id" "" -} - -session_freeze() { - local session_id="$1" - api_request "POST" "/sessions/$session_id/freeze" "{}" -} - -session_unfreeze() { - local session_id="$1" - api_request "POST" "/sessions/$session_id/unfreeze" "{}" -} - -session_boost() { - local session_id="$1" - local vcpu="${2:-2}" - api_request "POST" "/sessions/$session_id/boost" "{\"vcpu\":$vcpu}" -} - -session_unboost() { - local session_id="$1" - api_request "POST" "/sessions/$session_id/unboost" "{}" -} - -session_execute() { - local session_id="$1" - local command="$2" - api_request "POST" "/sessions/$session_id/execute" "{\"command\":$(echo "$command" | jq -Rs .)}" -} - -# ============================================================================ -# Service Functions (17) -# ============================================================================ - -service_list() { - api_request "GET" "/services" "" -} - -service_get() { - local service_id="$1" - api_request "GET" "/services/$service_id" "" -} - -service_create() { - local name="$1" - local ports="${2:-}" - local bootstrap="${3:-}" - local input_files_json="${4:-}" - - local body - body=$(jq -n --arg name "$name" '{name: $name}') - - if [ -n "$ports" ]; then - body=$(echo "$body" | jq --argjson ports "[$ports]" '. + {ports: $ports}') - fi - if [ -n "$bootstrap" ]; then - body=$(echo "$body" | jq --arg boot "$bootstrap" '. + {bootstrap: $boot}') - fi - if [ -n "$input_files_json" ]; then - body=$(echo "$body" | jq --argjson files "$input_files_json" '. + {input_files: $files}') - fi - - api_request "POST" "/services" "$body" -} - -service_destroy() { - local service_id="$1" - api_request_with_sudo "DELETE" "/services/$service_id" "" -} - -service_freeze() { - local service_id="$1" - api_request "POST" "/services/$service_id/freeze" "{}" -} - -service_unfreeze() { - local service_id="$1" - api_request "POST" "/services/$service_id/unfreeze" "{}" -} - -service_lock() { - local service_id="$1" - api_request "POST" "/services/$service_id/lock" "{}" -} - -service_unlock() { - local service_id="$1" - api_request_with_sudo "POST" "/services/$service_id/unlock" "{}" -} - -service_set_unfreeze_on_demand() { - local service_id="$1" - local enabled="$2" - api_request "PATCH" "/services/$service_id" "{\"unfreeze_on_demand\":$enabled}" -} - -service_redeploy() { - local service_id="$1" - local bootstrap="${2:-}" - local input_files_json="${3:-}" - local body="{}" - if [ -n "$bootstrap" ]; then - body=$(jq -n --arg boot "$bootstrap" '{bootstrap: $boot}') - fi - if [ -n "$input_files_json" ]; then - body=$(echo "$body" | jq --argjson files "$input_files_json" '. + {input_files: $files}') - fi - api_request "POST" "/services/$service_id/redeploy" "$body" -} - -service_logs() { - local service_id="$1" - local lines="${2:-}" - local endpoint="/services/$service_id/logs" - [ -n "$lines" ] && endpoint="$endpoint?lines=$lines" - api_request "GET" "$endpoint" "" -} - -service_execute() { - local service_id="$1" - local command="$2" - api_request "POST" "/services/$service_id/execute" "{\"command\":$(echo "$command" | jq -Rs .)}" -} - -service_env_get() { - local service_id="$1" - api_request "GET" "/services/$service_id/env" "" -} - -service_env_set() { - local service_id="$1" - local env_content="$2" - api_request "PUT" "/services/$service_id/env" "$env_content" "" "text/plain" -} - -service_env_delete() { - local service_id="$1" - api_request "DELETE" "/services/$service_id/env" "" -} - -service_env_export() { - local service_id="$1" - api_request "POST" "/services/$service_id/env/export" "{}" -} - -service_resize() { - local service_id="$1" - local vcpu="$2" - api_request "PATCH" "/services/$service_id" "{\"vcpu\":$vcpu}" -} - -# ============================================================================ -# Snapshot Functions (9) -# ============================================================================ - -snapshot_list() { - api_request "GET" "/snapshots" "" -} - -snapshot_get() { - local snapshot_id="$1" - api_request "GET" "/snapshots/$snapshot_id" "" -} - -snapshot_session() { - local session_id="$1" - local name="${2:-}" - local hot="${3:-false}" - - local body="{}" - if [ -n "$name" ] || [ "$hot" = "true" ]; then - body=$(jq -n --arg name "$name" --argjson hot "$hot" \ - '{name: (if $name != "" then $name else null end), hot: $hot}') - fi - - api_request "POST" "/sessions/$session_id/snapshot" "$body" -} - -snapshot_service() { - local service_id="$1" - local name="${2:-}" - local hot="${3:-false}" - - local body="{}" - if [ -n "$name" ] || [ "$hot" = "true" ]; then - body=$(jq -n --arg name "$name" --argjson hot "$hot" \ - '{name: (if $name != "" then $name else null end), hot: $hot}') - fi - - api_request "POST" "/services/$service_id/snapshot" "$body" -} - -snapshot_restore() { - local snapshot_id="$1" - api_request "POST" "/snapshots/$snapshot_id/restore" "{}" -} - -snapshot_delete() { - local snapshot_id="$1" - api_request_with_sudo "DELETE" "/snapshots/$snapshot_id" "" -} - -snapshot_lock() { - local snapshot_id="$1" - api_request "POST" "/snapshots/$snapshot_id/lock" "{}" -} - -snapshot_unlock() { - local snapshot_id="$1" - api_request_with_sudo "POST" "/snapshots/$snapshot_id/unlock" "{}" -} - -snapshot_clone() { - local snapshot_id="$1" - local clone_type="${2:-session}" - local name="${3:-}" - - local body - body=$(jq -n --arg type "$clone_type" --arg name "$name" \ - '{clone_type: $type, name: (if $name != "" then $name else null end)}') - - api_request "POST" "/snapshots/$snapshot_id/clone" "$body" -} - -# ============================================================================ -# Image Functions (13) -# ============================================================================ - -image_list() { - local filter="${1:-}" - local endpoint="/images" - [ -n "$filter" ] && endpoint="$endpoint?filter=$filter" - api_request "GET" "$endpoint" "" -} - -image_get() { - local image_id="$1" - api_request "GET" "/images/$image_id" "" -} - -image_publish() { - local source_type="$1" - local source_id="$2" - local name="${3:-}" - - local body - body=$(jq -n --arg type "$source_type" --arg id "$source_id" --arg name "$name" \ - '{source_type: $type, source_id: $id, name: (if $name != "" then $name else null end)}') - - api_request "POST" "/images/publish" "$body" -} - -image_delete() { - local image_id="$1" - api_request_with_sudo "DELETE" "/images/$image_id" "" -} - -image_lock() { - local image_id="$1" - api_request "POST" "/images/$image_id/lock" "{}" -} - -image_unlock() { - local image_id="$1" - api_request_with_sudo "POST" "/images/$image_id/unlock" "{}" -} - -image_set_visibility() { - local image_id="$1" - local visibility="$2" - api_request "POST" "/images/$image_id/visibility" "{\"visibility\":\"$visibility\"}" -} - -image_grant_access() { - local image_id="$1" - local trusted_key="$2" - api_request "POST" "/images/$image_id/access" "{\"api_key\":\"$trusted_key\"}" -} - -image_revoke_access() { - local image_id="$1" - local trusted_key="$2" - api_request "DELETE" "/images/$image_id/access/$trusted_key" "" -} - -image_list_trusted() { - local image_id="$1" - api_request "GET" "/images/$image_id/access" "" -} - -image_transfer() { - local image_id="$1" - local to_key="$2" - api_request "POST" "/images/$image_id/transfer" "{\"to_api_key\":\"$to_key\"}" -} - -image_spawn() { - local image_id="$1" - local name="${2:-}" - local ports="${3:-}" - - local body="{}" - if [ -n "$name" ] || [ -n "$ports" ]; then - body="{" - local first=1 - if [ -n "$name" ]; then - body="$body\"name\":\"$name\"" - first=0 - fi - if [ -n "$ports" ]; then - [ "$first" -eq 0 ] && body="$body," - body="$body\"ports\":[$ports]" - fi - body="$body}" - fi - - api_request "POST" "/images/$image_id/spawn" "$body" -} - -image_clone() { - local image_id="$1" - local name="${2:-}" - - local body="{}" - if [ -n "$name" ]; then - body="{\"name\":\"$name\"}" - fi - - api_request "POST" "/images/$image_id/clone" "$body" -} - -# ============================================================================ -# PaaS Logs Functions (2) -# ============================================================================ - -logs_fetch() { - local source="${1:-all}" - local lines="${2:-100}" - local since="${3:-1h}" - local grep_pattern="${4:-}" - - local body - body=$(jq -n --arg source "$source" --argjson lines "$lines" --arg since "$since" --arg grep "$grep_pattern" \ - '{source: $source, lines: $lines, since: $since, grep: (if $grep != "" then $grep else null end)}') - - api_request "POST" "/paas/logs" "$body" -} - -logs_stream() { - set_error "logs_stream requires async support" - return 1 -} - -# ============================================================================ -# Key Validation -# ============================================================================ - -validate_keys() { - local creds - creds=$(get_credentials) || return 1 - local pk="${creds%%:*}" - local sk="${creds#*:}" - - local timestamp - timestamp=$(date +%s) - - local signature="" - if [ -n "$sk" ]; then - signature=$(hmac_sign "$sk" "$timestamp:POST:/keys/validate:") - fi - - curl -s -X POST "$PORTAL_BASE/keys/validate" \ - -H "Authorization: Bearer $pk" \ - -H "Content-Type: application/json" \ - ${signature:+-H "X-Timestamp: $timestamp" -H "X-Signature: $signature"} -} - -health_check() { - local result - result=$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/health") - [ "$result" = "200" ] -} - -# ============================================================================ -# CLI Implementation -# ============================================================================ - -run_file() { +# Utilities +detect_language() { local file="$1" - if [ ! -f "$file" ]; then - echo -e "${RED}Error: File not found: $file${RESET}" >&2 - exit 1 - fi - - local code - code=$(cat "$file") - local lang - lang=$(detect_language "$file") || { - echo -e "${RED}Error: Cannot detect language${RESET}" >&2 - exit 1 - } - - local result - result=$(execute "$lang" "$code") - - if ! echo "$result" | jq -e . >/dev/null 2>&1; then - echo -e "${RED}Error: Failed to execute${RESET}" >&2 - exit 1 - fi - - echo "$result" | jq -r '.stdout // empty' - echo "$result" | jq -r '.stderr // empty' >&2 - local exit_code - exit_code=$(echo "$result" | jq -r '.exit_code // 0') - exit "${exit_code:-0}" + case "$file" in + *.py) echo "python" ;; + *.sh) echo "bash" ;; + *.rb) echo "ruby" ;; + *.js) echo "javascript" ;; + *.ts) echo "typescript" ;; + *.go) echo "go" ;; + *.rs) echo "rust" ;; + *.c) echo "c" ;; + *.cpp|*.cc|*.cxx) echo "cpp" ;; + *.java) echo "java" ;; + *.kt) echo "kotlin" ;; + *.php) echo "php" ;; + *.pl) echo "perl" ;; + *.lua) echo "lua" ;; + *.r|*.R) echo "r" ;; + *.jl) echo "julia" ;; + *.hs) echo "haskell" ;; + *.ml) echo "ocaml" ;; + *.ex|*.exs) echo "elixir" ;; + *.erl) echo "erlang" ;; + *.clj) echo "clojure" ;; + *.scm) echo "scheme" ;; + *.lisp) echo "commonlisp" ;; + *.cs) echo "csharp" ;; + *.fs) echo "fsharp" ;; + *.d) echo "d" ;; + *.nim) echo "nim" ;; + *.zig) echo "zig" ;; + *.v) echo "v" ;; + *.cr) echo "crystal" ;; + *.dart) echo "dart" ;; + *.groovy) echo "groovy" ;; + *.f90|*.f95) echo "fortran" ;; + *.cob) echo "cobol" ;; + *.tcl) echo "tcl" ;; + *.raku) echo "raku" ;; + *.pro) echo "prolog" ;; + *.forth|*.4th) echo "forth" ;; + *.m) echo "objc" ;; + *) echo "Error: Cannot detect language for $file" >&2; exit 1 ;; + esac } +# Languages command cmd_languages() { local json_output=0 + # Parse arguments for arg in "$@"; do if [ "$arg" = "--json" ]; then json_output=1 fi done - local result - result=$(get_languages) + local result=$(languages) if [ "$json_output" -eq 1 ]; then + # JSON array output echo "$result" | jq -c '.' else + # One language per line (default) echo "$result" | jq -r '.[]' fi } -cmd_key() { - local extend=0 - - for arg in "$@"; do - if [ "$arg" = "--extend" ]; then - extend=1 - fi - done - - local result - result=$(validate_keys) - - local pk - pk=$(echo "$result" | jq -r '.public_key // empty') - - if [ "$extend" -eq 1 ] && [ -n "$pk" ]; then - local url="$PORTAL_BASE/keys/extend?pk=$pk" - echo -e "${BLUE}Opening browser to extend key...${RESET}" - xdg-open "$url" 2>/dev/null || open "$url" 2>/dev/null & - return - fi - - if echo "$result" | jq -e '.expired' >/dev/null 2>&1; then - echo -e "${RED}Expired${RESET}" - echo "Public Key: $pk" - echo "Tier: $(echo "$result" | jq -r '.tier // "N/A"')" - echo -e "${YELLOW}To renew: Visit $PORTAL_BASE/keys/extend${RESET}" - exit 1 - fi - - echo -e "${GREEN}Valid${RESET}" - echo "Public Key: $pk" - echo "Tier: $(echo "$result" | jq -r '.tier // "N/A"')" - echo "Status: $(echo "$result" | jq -r '.status // "N/A"')" - echo "Expires: $(echo "$result" | jq -r '.expires_at // "N/A"')" - echo "Time Remaining: $(echo "$result" | jq -r '.time_remaining // "N/A"')" -} - -cmd_session() { - local action="" - local target="" - - while [ $# -gt 0 ]; do - case "$1" in - --list|-l) action="list" ;; - --info) action="info"; target="$2"; shift ;; - --kill) action="kill"; target="$2"; shift ;; - --freeze) action="freeze"; target="$2"; shift ;; - --unfreeze) action="unfreeze"; target="$2"; shift ;; - --boost) action="boost"; target="$2"; shift ;; - --unboost) action="unboost"; target="$2"; shift ;; - *) ;; - esac - shift - done - - case "$action" in - list) - local result - result=$(session_list) - echo "$result" | jq -r '.sessions[] | "\(.id)\t\(.shell)\t\(.status)\t\(.created_at)"' 2>/dev/null || echo "No sessions" - ;; - info) - session_get "$target" | jq . - ;; - kill) - session_destroy "$target" - echo -e "${GREEN}Session terminated: $target${RESET}" - ;; - freeze) - session_freeze "$target" - echo -e "${GREEN}Session frozen: $target${RESET}" - ;; - unfreeze) - session_unfreeze "$target" - echo -e "${GREEN}Session unfreezing: $target${RESET}" - ;; - boost) - session_boost "$target" - echo -e "${GREEN}Session boosted: $target${RESET}" - ;; - unboost) - session_unboost "$target" - echo -e "${GREEN}Session unboosted: $target${RESET}" - ;; - *) - echo "Usage: bash un.sh session --list|--info ID|--kill ID|--freeze ID|--unfreeze ID" >&2 - exit 1 - ;; - esac -} - -cmd_service() { - local action="" - local target="" - local name="" - local ports="" - local bootstrap="" - local bootstrap_file="" - local -a files=() - - while [ $# -gt 0 ]; do - case "$1" in - --list|-l) action="list" ;; - --info) action="info"; target="$2"; shift ;; - --destroy) action="destroy"; target="$2"; shift ;; - --freeze) action="freeze"; target="$2"; shift ;; - --unfreeze) action="unfreeze"; target="$2"; shift ;; - --lock) action="lock"; target="$2"; shift ;; - --unlock) action="unlock"; target="$2"; shift ;; - --logs) action="logs"; target="$2"; shift ;; - --redeploy) action="redeploy"; target="$2"; shift ;; - --name) name="$2"; shift ;; - --ports) ports="$2"; shift ;; - --bootstrap) bootstrap="$2"; shift ;; - --bootstrap-file) bootstrap_file="$2"; shift ;; - -f|--file) files+=("$2"); shift ;; - *) ;; - esac - shift - done - - # Build input_files JSON from -f args - local input_files_json="" - if [ ${#files[@]} -gt 0 ]; then - input_files_json="[" - local first=1 - for fpath in "${files[@]}"; do - if [ ! -f "$fpath" ]; then - echo -e "${RED}Error: File not found: $fpath${RESET}" >&2 - exit 1 - fi - local encoded - encoded=$(base64 -w0 "$fpath" 2>/dev/null || base64 "$fpath" 2>/dev/null) - local fname - fname=$(basename "$fpath") - [ "$first" -eq 0 ] && input_files_json="$input_files_json," - input_files_json="$input_files_json{\"filename\":$(echo "$fname" | jq -Rs .),\"content\":$(echo "$encoded" | jq -Rs .)}" - first=0 - done - input_files_json="$input_files_json]" - fi - - # Resolve bootstrap from file if provided - if [ -n "$bootstrap_file" ]; then - if [ ! -f "$bootstrap_file" ]; then - echo -e "${RED}Error: Bootstrap file not found: $bootstrap_file${RESET}" >&2 - exit 1 - fi - bootstrap=$(cat "$bootstrap_file") - fi - - case "$action" in - list) - local result - result=$(service_list) - echo "$result" | jq -r '.services[] | "\(.id)\t\(.name)\t\(.status)\t\(.ports | join(","))"' 2>/dev/null || echo "No services" - ;; - info) - service_get "$target" | jq . - ;; - destroy) - service_destroy "$target" - echo -e "${GREEN}Service destroyed: $target${RESET}" - ;; - freeze) - service_freeze "$target" - echo -e "${GREEN}Service frozen: $target${RESET}" - ;; - unfreeze) - service_unfreeze "$target" - echo -e "${GREEN}Service unfreezing: $target${RESET}" - ;; - lock) - service_lock "$target" - echo -e "${GREEN}Service locked: $target${RESET}" - ;; - unlock) - service_unlock "$target" - echo -e "${GREEN}Service unlocked: $target${RESET}" - ;; - logs) - local result - result=$(service_logs "$target") - echo "$result" | jq -r '.logs // empty' - ;; - redeploy) - local result - result=$(service_redeploy "$target" "$bootstrap" "$input_files_json") - echo -e "${GREEN}Service redeployed: $target${RESET}" - ;; - *) - if [ -n "$name" ]; then - local result - result=$(service_create "$name" "$ports" "$bootstrap" "$input_files_json") - echo -e "${GREEN}Service created${RESET}" - echo "$result" | jq -r '"ID: \(.id)\nName: \(.name)"' - else - echo "Usage: bash un.sh service --list|--info ID|--destroy ID|--redeploy ID|--name NAME" >&2 - exit 1 - fi - ;; - esac -} - -cmd_snapshot() { - local action="" - local target="" - - while [ $# -gt 0 ]; do - case "$1" in - --list|-l) action="list" ;; - --info) action="info"; target="$2"; shift ;; - --delete) action="delete"; target="$2"; shift ;; - --restore) action="restore"; target="$2"; shift ;; - --lock) action="lock"; target="$2"; shift ;; - --unlock) action="unlock"; target="$2"; shift ;; - *) ;; - esac - shift - done - - case "$action" in - list) - local result - result=$(snapshot_list) - echo "$result" | jq -r '.snapshots[] | "\(.id)\t\(.name)\t\(.type)\t\(.created_at)"' 2>/dev/null || echo "No snapshots" - ;; - info) - snapshot_get "$target" | jq . - ;; - delete) - snapshot_delete "$target" - echo -e "${GREEN}Snapshot deleted: $target${RESET}" - ;; - restore) - snapshot_restore "$target" - echo -e "${GREEN}Snapshot restored${RESET}" - ;; - lock) - snapshot_lock "$target" - echo -e "${GREEN}Snapshot locked: $target${RESET}" - ;; - unlock) - snapshot_unlock "$target" - echo -e "${GREEN}Snapshot unlocked: $target${RESET}" - ;; - *) - echo "Usage: bash un.sh snapshot --list|--info ID|--delete ID|--restore ID" >&2 - exit 1 - ;; - esac -} - +# Image command cmd_image() { local action="" - local target="" + local id="" local source_type="" local visibility_mode="" local name="" @@ -1102,217 +244,191 @@ cmd_image() { while [ $# -gt 0 ]; do case "$1" in - --list|-l) action="list" ;; - --info) action="info"; target="$2"; shift ;; - --delete) action="delete"; target="$2"; shift ;; - --lock) action="lock"; target="$2"; shift ;; - --unlock) action="unlock"; target="$2"; shift ;; - --publish) action="publish"; target="$2"; shift ;; - --source-type) source_type="$2"; shift ;; - --visibility) action="visibility"; target="$2"; visibility_mode="$3"; shift 2 ;; - --spawn) action="spawn"; target="$2"; shift ;; - --clone) action="clone"; target="$2"; shift ;; - --name) name="$2"; shift ;; - --ports) ports="$2"; shift ;; - *) ;; + --list|-l) + action="list" + shift + ;; + --info) + action="info" + id="$2" + shift 2 + ;; + --delete) + action="delete" + id="$2" + shift 2 + ;; + --lock) + action="lock" + id="$2" + shift 2 + ;; + --unlock) + action="unlock" + id="$2" + shift 2 + ;; + --publish) + action="publish" + id="$2" + shift 2 + ;; + --source-type) + source_type="$2" + shift 2 + ;; + --visibility) + action="visibility" + id="$2" + visibility_mode="$3" + shift 3 + ;; + --spawn) + action="spawn" + id="$2" + shift 2 + ;; + --clone) + action="clone" + id="$2" + shift 2 + ;; + --name) + name="$2" + shift 2 + ;; + --ports) + ports="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; esac - shift done case "$action" in list) - local result - result=$(image_list) - echo "$result" | jq -r '.images[] | "\(.id)\t\(.name // "-")\t\(.visibility)\t\(.created_at)"' 2>/dev/null || echo "No images" + result=$(api_request "GET" "/images" "") + echo "$result" | jq -r '.images[] | "\(.id)\t\(.name // "-")\t\(.visibility)\t\(.created_at)"' 2>/dev/null || echo "No images found" ;; info) - image_get "$target" | jq . + result=$(api_request "GET" "/images/$id" "") + echo "$result" | jq . ;; delete) - image_delete "$target" - echo -e "${GREEN}Image deleted: $target${RESET}" + api_request "DELETE" "/images/$id" "" + echo "Image deleted successfully" ;; lock) - image_lock "$target" - echo -e "${GREEN}Image locked: $target${RESET}" + api_request "POST" "/images/$id/lock" "{}" + echo "Image locked successfully" ;; unlock) - image_unlock "$target" - echo -e "${GREEN}Image unlocked: $target${RESET}" + api_request "POST" "/images/$id/unlock" "{}" + echo "Image unlocked successfully" ;; publish) if [ -z "$source_type" ]; then - echo -e "${RED}Error: --source-type required${RESET}" >&2 + echo "Error: --source-type required for --publish" >&2 exit 1 fi - local result - result=$(image_publish "$source_type" "$target" "$name") - echo -e "${GREEN}Image published${RESET}" + local body="{\"source_type\":\"$source_type\",\"source_id\":\"$id\"" + if [ -n "$name" ]; then + body="$body,\"name\":\"$name\"" + fi + body="$body}" + result=$(api_request "POST" "/images/publish" "$body") + echo "Image published successfully" echo "$result" | jq -r '"Image ID: \(.id)"' ;; visibility) - image_set_visibility "$target" "$visibility_mode" - echo -e "${GREEN}Visibility set to $visibility_mode${RESET}" + if [ -z "$visibility_mode" ]; then + echo "Error: visibility mode required" >&2 + exit 1 + fi + api_request "POST" "/images/$id/visibility" "{\"visibility\":\"$visibility_mode\"}" + echo "Image visibility set to $visibility_mode" ;; spawn) - local result - result=$(image_spawn "$target" "$name" "$ports") - echo -e "${GREEN}Service spawned from image${RESET}" + local body="{" + local first=1 + if [ -n "$name" ]; then + body="$body\"name\":\"$name\"" + first=0 + fi + if [ -n "$ports" ]; then + if [ "$first" -eq 0 ]; then + body="$body," + fi + body="$body\"ports\":[$ports]" + fi + body="$body}" + result=$(api_request "POST" "/images/$id/spawn" "$body") + echo "Service spawned from image" echo "$result" | jq -r '"Service ID: \(.id)"' ;; clone) - local result - result=$(image_clone "$target" "$name") - echo -e "${GREEN}Image cloned${RESET}" + local body="{" + if [ -n "$name" ]; then + body="$body\"name\":\"$name\"" + fi + body="$body}" + result=$(api_request "POST" "/images/$id/clone" "$body") + echo "Image cloned successfully" echo "$result" | jq -r '"Image ID: \(.id)"' ;; *) - echo "Usage: bash un.sh image --list|--info ID|--delete ID|--publish ID|--spawn ID|--clone ID" >&2 + echo "Error: Specify --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID" >&2 exit 1 ;; esac } -show_help() { - cat << 'EOF' -Unsandbox CLI - Execute code in secure sandboxes - -Usage: - bash un.sh [options] - bash un.sh -s '' - bash un.sh session [options] - bash un.sh service [options] - bash un.sh snapshot [options] - bash un.sh image [options] - bash un.sh languages [--json] - bash un.sh key [--extend] - -Commands: - languages List available programming languages - key Validate API key - session Manage interactive sessions - service Manage persistent services - snapshot Manage snapshots - image Manage images - -Session options: - --list List all sessions - --info ID Get session details - --kill ID Terminate session - --freeze ID Freeze session - --unfreeze ID Unfreeze session - --boost ID Boost session CPU - --unboost ID Unboost session CPU - -Service options: - --list List all services - --info ID Get service details - --destroy ID Destroy service - --freeze ID Freeze service - --unfreeze ID Unfreeze service - --lock ID Lock service - --unlock ID Unlock service - --logs ID Get service logs - --redeploy ID Re-run bootstrap (supports -f, --bootstrap) - --name NAME Create service with name - --ports PORTS Service ports (comma-separated) - --bootstrap CMD Bootstrap command - --bootstrap-file FILE Bootstrap from file - -f, --file FILE Add input file (can repeat) - -Snapshot options: - --list List all snapshots - --info ID Get snapshot details - --delete ID Delete snapshot - --restore ID Restore from snapshot - --lock ID Lock snapshot - --unlock ID Unlock snapshot - -Image options: - --list List all images - --info ID Get image details - --delete ID Delete image - --lock ID Lock image - --unlock ID Unlock image - --publish ID Publish from service/snapshot (needs --source-type) - --source-type TYPE Source type (service or snapshot) - --visibility ID MODE Set visibility (private|unlisted|public) - --spawn ID Spawn service from image - --clone ID Clone image - --name NAME Name for spawned service or cloned image - --ports PORTS Ports for spawned service - -Environment: - UNSANDBOX_PUBLIC_KEY API public key - UNSANDBOX_SECRET_KEY API secret key -EOF -} - -# CLI entry point -if [ "${BASH_SOURCE[0]}" = "$0" ]; then - if [ $# -eq 0 ]; then - show_help - exit 1 - fi - - # Pre-scan for --account N before dispatching - _args=("$@") - _new_args=() - _i=0 - while [ $_i -lt ${#_args[@]} ]; do - if [ "${_args[$_i]}" = "--account" ]; then - _i=$((_i + 1)) - ACCOUNT_INDEX="${_args[$_i]}" - else - _new_args+=("${_args[$_i]}") - fi - _i=$((_i + 1)) - done - set -- "${_new_args[@]+"${_new_args[@]}"}" - +# CLI +if [ $# -gt 0 ]; then case "$1" in languages) shift cmd_languages "$@" ;; - key) - shift - cmd_key "$@" - ;; - session) - shift - cmd_session "$@" - ;; - service) - shift - cmd_service "$@" - ;; - snapshot) - shift - cmd_snapshot "$@" - ;; image) shift cmd_image "$@" ;; - -s) - lang="$2" - code="$3" - if [ -z "$lang" ] || [ -z "$code" ]; then - echo -e "${RED}Error: -s requires language and code${RESET}" >&2 + *) + result=$(run "$1") + if [ -z "$result" ] || ! echo "$result" | jq -e . >/dev/null 2>&1; then + echo "Error: Failed to execute $1" >&2 exit 1 fi - result=$(execute "$lang" "$code") echo "$result" | jq -r '.stdout // empty' echo "$result" | jq -r '.stderr // empty' >&2 exit_code=$(echo "$result" | jq -r '.exit_code // 0') exit "${exit_code:-0}" ;; - --help|-h) - show_help - ;; - *) - run_file "$1" - ;; esac +else + echo "Usage: bash un.sh " >&2 + echo " bash un.sh languages [--json]" >&2 + echo " bash un.sh image [options]" >&2 + echo "" >&2 + echo "Languages options:" >&2 + echo " --json Output as JSON array" >&2 + echo "" >&2 + echo "Image options:" >&2 + echo " --list List all images" >&2 + echo " --info ID Get image details" >&2 + echo " --delete ID Delete an image" >&2 + echo " --lock ID Lock image to prevent deletion" >&2 + echo " --unlock ID Unlock image" >&2 + echo " --publish ID Publish image from service/snapshot" >&2 + echo " --source-type TYPE Source type: service or snapshot" >&2 + echo " --visibility ID MODE Set visibility: private, unlisted, public" >&2 + echo " --spawn ID Spawn new service from image" >&2 + echo " --clone ID Clone an image" >&2 + echo " --name NAME Name for spawned service or cloned image" >&2 + echo " --ports PORTS Ports for spawned service" >&2 + exit 1 fi diff --git a/clients/bash/tests/test_library.sh b/clients/bash/tests/test_library.sh deleted file mode 100755 index d7dd299..0000000 --- a/clients/bash/tests/test_library.sh +++ /dev/null @@ -1,279 +0,0 @@ -#!/bin/bash -# This is free software for the public good of a permacomputer hosted at -# permacomputer.com, an always-on computer by the people, for the people. -# One which is durable, easy to repair, & distributed like tap water -# for machine learning intelligence. -# -# The permacomputer is community-owned infrastructure optimized around -# four values: -# -# TRUTH First principles, math & science, open source code freely distributed -# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY Minimal waste, self-renewing systems with diverse thriving connections -# LOVE Be yourself without hurting others, cooperation through natural law -# -# This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -# Code is seeds to sprout on any abandoned technology. - -# Unit Tests for un.sh Library Functions -# -# Tests the ACTUAL exported functions from Un module. -# NO local re-implementations. NO mocking. -# -# Run: bash tests/test_library.sh - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../sync/src/un.sh" 2>/dev/null || { - echo "Error: Cannot source un.sh" - exit 1 -} - -# Test counters -tests_passed=0 -tests_failed=0 - -PASS() { - echo -e " \033[32m[PASS]\033[0m $1" - tests_passed=$((tests_passed + 1)) -} - -FAIL() { - echo -e " \033[31m[FAIL]\033[0m $1" - tests_failed=$((tests_failed + 1)) -} - -assert_equal() { - local actual="$1" - local expected="$2" - local msg="$3" - if [ "$actual" = "$expected" ]; then - PASS "$msg" - else - FAIL "$msg (expected: $expected, got: $actual)" - fi -} - -assert_not_empty() { - local value="$1" - local msg="$2" - if [ -n "$value" ]; then - PASS "$msg" - else - FAIL "$msg (expected non-empty)" - fi -} - -assert_match() { - local value="$1" - local pattern="$2" - local msg="$3" - if [[ "$value" =~ $pattern ]]; then - PASS "$msg" - else - FAIL "$msg (value: $value does not match pattern: $pattern)" - fi -} - -# ============================================================================ -# Test: version() -# ============================================================================ - -echo "" -echo "Testing version()..." - -ver=$(version) -assert_not_empty "$ver" "version() returns non-empty string" -assert_match "$ver" "^[0-9]+\.[0-9]+\.[0-9]+$" "version() matches X.Y.Z format" -echo " Version: $ver" - -# ============================================================================ -# Test: detect_language() -# ============================================================================ - -echo "" -echo "Testing detect_language()..." - -declare -A lang_tests=( - ["test.py"]="python" - ["app.js"]="javascript" - ["main.go"]="go" - ["script.rb"]="ruby" - ["lib.rs"]="rust" - ["main.c"]="c" - ["app.cpp"]="cpp" - ["Main.java"]="java" - ["index.php"]="php" - ["script.pl"]="perl" - ["init.lua"]="lua" - ["run.sh"]="bash" - ["main.ts"]="typescript" - ["app.kt"]="kotlin" - ["lib.ex"]="elixir" - ["main.hs"]="haskell" -) - -for file in "${!lang_tests[@]}"; do - expected="${lang_tests[$file]}" - result=$(detect_language "$file" 2>/dev/null || echo "") - assert_equal "$result" "$expected" "detect_language('$file') -> '$expected'" -done - -# Test unknown extension -result=$(detect_language "file.xyz123" 2>/dev/null || echo "") -assert_equal "$result" "" "detect_language(unknown ext) returns empty" - -# Test no extension -result=$(detect_language "Makefile" 2>/dev/null || echo "") -assert_equal "$result" "" "detect_language(no ext) returns empty" - -# ============================================================================ -# Test: hmac_sign() -# ============================================================================ - -echo "" -echo "Testing hmac_sign()..." - -# Test basic signature generation -sig=$(hmac_sign "secret_key" "1234567890:POST:/execute:{}") -assert_not_empty "$sig" "hmac_sign() returns non-nil" -assert_equal "${#sig}" "64" "hmac_sign() returns 64-char hex string" - -# Verify hex characters -if [[ "$sig" =~ ^[0-9a-fA-F]+$ ]]; then - PASS "hmac_sign() returns valid hex" -else - FAIL "hmac_sign() returns valid hex" -fi - -# Test deterministic output -sig1=$(hmac_sign "key" "message") -sig2=$(hmac_sign "key" "message") -assert_equal "$sig1" "$sig2" "hmac_sign() is deterministic" - -# Test different keys produce different signatures -sig_a=$(hmac_sign "key_a" "message") -sig_b=$(hmac_sign "key_b" "message") -if [ "$sig_a" != "$sig_b" ]; then - PASS "Different keys produce different signatures" -else - FAIL "Different keys produce different signatures" -fi - -# Test different messages produce different signatures -sig_m1=$(hmac_sign "key" "message1") -sig_m2=$(hmac_sign "key" "message2") -if [ "$sig_m1" != "$sig_m2" ]; then - PASS "Different messages produce different signatures" -else - FAIL "Different messages produce different signatures" -fi - -# Test known HMAC value -known_sig=$(hmac_sign "key" "message") -if [[ "$known_sig" == 6e9ef29b75fffc5b7abae527d58fdadb* ]]; then - PASS "HMAC-SHA256('key', 'message') matches expected prefix" -else - FAIL "HMAC-SHA256('key', 'message') matches expected prefix (got: $known_sig)" -fi - -# ============================================================================ -# Test: last_error() / set_error() -# ============================================================================ - -echo "" -echo "Testing last_error()..." - -set_error "test error" -err=$(last_error) -assert_equal "$err" "test error" "last_error() returns set error" - -# ============================================================================ -# Test: Memory stress test -# ============================================================================ - -echo "" -echo "Testing Memory Management..." - -# Stress test HMAC allocation -for i in $(seq 1 1000); do - hmac_sign "key" "message" > /dev/null -done -PASS "1000 HMAC calls without crash" - -# Stress test language detection -for i in $(seq 1 1000); do - detect_language "test.py" > /dev/null 2>&1 || true -done -PASS "1000 detect_language calls without crash" - -# Stress test version -for i in $(seq 1 1000); do - version > /dev/null -done -PASS "1000 version calls without crash" - -# ============================================================================ -# Test: Function existence -# ============================================================================ - -echo "" -echo "Testing Library function existence..." - -functions=( - # Execution functions (8) - "execute" "execute_async" "wait_job" "get_job" - "cancel_job" "list_jobs" "get_languages" "detect_language" - - # Session functions (9) - "session_list" "session_get" "session_create" "session_destroy" - "session_freeze" "session_unfreeze" "session_boost" "session_unboost" - "session_execute" - - # Service functions (17) - "service_list" "service_get" "service_create" "service_destroy" - "service_freeze" "service_unfreeze" "service_lock" "service_unlock" - "service_set_unfreeze_on_demand" "service_redeploy" "service_logs" - "service_execute" "service_env_get" "service_env_set" - "service_env_delete" "service_env_export" "service_resize" - - # Snapshot functions (9) - "snapshot_list" "snapshot_get" "snapshot_session" "snapshot_service" - "snapshot_restore" "snapshot_delete" "snapshot_lock" "snapshot_unlock" - "snapshot_clone" - - # Image functions (13) - "image_list" "image_get" "image_publish" "image_delete" - "image_lock" "image_unlock" "image_set_visibility" - "image_grant_access" "image_revoke_access" "image_list_trusted" - "image_transfer" "image_spawn" "image_clone" - - # PaaS Logs (2) - "logs_fetch" "logs_stream" - - # Utilities - "validate_keys" "hmac_sign" "health_check" "version" "last_error" -) - -for func in "${functions[@]}"; do - if declare -f "$func" > /dev/null 2>&1; then - PASS "$func() exists" - else - FAIL "$func() exists" - fi -done - -# ============================================================================ -# Summary -# ============================================================================ - -echo "" -echo "=====================================" -echo "Test Summary" -echo "=====================================" -echo -e "Passed: \033[32m$tests_passed\033[0m" -echo -e "Failed: \033[31m$tests_failed\033[0m" -echo "=====================================" - -exit $((tests_failed > 0 ? 1 : 0)) diff --git a/clients/c/Makefile b/clients/c/Makefile index 470bab6..3c3a1bf 100644 --- a/clients/c/Makefile +++ b/clients/c/Makefile @@ -108,14 +108,6 @@ test: build $(TEST_DIR)/test_library test-library: test -test-integration: build - @echo "" - @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - @echo "INTEGRATION: Testing --account flag priority" - @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - @echo "" - @bash $(TEST_DIR)/test_account_flag.sh - test-functional: build $(TEST_DIR)/test_functional @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/clients/c/src/un.c b/clients/c/src/un.c index 9ee1802..05cd5b7 100644 --- a/clients/c/src/un.c +++ b/clients/c/src/un.c @@ -24,7 +24,6 @@ #include #include #include -#include #include #include #include @@ -305,7 +304,7 @@ static UnsandboxCredentials* load_credentials_from_csv(int account_index) { size_t len = strlen(line); if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0'; - // Parse CSV: public_key,secret_key[,comment] + // Parse CSV: public_key,secret_key char *comma = strchr(line, ','); if (!comma) continue; @@ -313,10 +312,6 @@ static UnsandboxCredentials* load_credentials_from_csv(int account_index) { char *pk = line; char *sk = comma + 1; - // Strip optional 3rd field (comment) after second comma - char *comma2 = strchr(sk, ','); - if (comma2) *comma2 = '\0'; - // Validate key prefixes if (strncmp(pk, "unsb-pk-", 8) != 0) continue; if (strncmp(sk, "unsb-sk-", 8) != 0) continue; @@ -396,14 +391,7 @@ static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli return creds; } - // Priority 2: --account N flag → explicit CSV lookup - // When the user explicitly selects an account, go straight to accounts.csv. - // Env vars are intentionally bypassed — an explicit flag must win over ambient env. - if (account_index >= 0) { - return load_credentials_from_csv(account_index); - } - - // Priority 3: Environment variables (keys) + // Priority 2: Environment variables (keys) const char *env_pk = getenv("UNSANDBOX_PUBLIC_KEY"); const char *env_sk = getenv("UNSANDBOX_SECRET_KEY"); @@ -423,12 +411,16 @@ static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli return creds; } - // Priority 4: Config file (~/.unsandbox/accounts.csv) - // Use UNSANDBOX_ACCOUNT env var, or default to account 0 - int csv_index = 0; - const char *env_account = getenv("UNSANDBOX_ACCOUNT"); - if (env_account && strlen(env_account) > 0) { - csv_index = atoi(env_account); + // Priority 3: Config file (~/.unsandbox/accounts.csv) + // Use account_index from --account flag, or UNSANDBOX_ACCOUNT env var, or default to 0 + int csv_index = account_index; + if (csv_index < 0) { + const char *env_account = getenv("UNSANDBOX_ACCOUNT"); + if (env_account && strlen(env_account) > 0) { + csv_index = atoi(env_account); + } else { + csv_index = 0; + } } return load_credentials_from_csv(csv_index); } @@ -496,8 +488,6 @@ static struct curl_slist* add_hmac_auth_headers(struct curl_slist *headers, // Cumulative: 300ms, 750ms, 1450ms, 2350ms, 3000ms, 4600ms, 6600ms+ static const int POLL_DELAYS[] = {300, 450, 700, 900, 650, 1600, 2000}; #define POLL_DELAYS_COUNT 7 -#define POLL_MAX_CONSECUTIVE_ERRORS 30 -#define POLL_ERROR_BACKOFF_MS 2000 // Response buffer structure struct ResponseBuffer { @@ -1096,7 +1086,7 @@ const char* get_basename(const char *path) { return base ? base + 1 : path; } -// Poll job status with exponential backoff and transient error resilience +// Poll job status with exponential backoff // Returns the final response JSON (caller must free), or NULL on error static char* poll_job_status(const UnsandboxCredentials *creds, const char *job_id) { CURL *curl = curl_easy_init(); @@ -1108,19 +1098,12 @@ static char* poll_job_status(const UnsandboxCredentials *creds, const char *job_ snprintf(url, sizeof(url), "%s%s", API_BASE, path); int poll_count = 0; - int consecutive_errors = 0; - int saw_server_errors = 0; char *final_response = NULL; while (1) { - // Sleep before polling — use backoff schedule for normal polls, - // fixed backoff during error recovery - if (consecutive_errors > 0) { - usleep(POLL_ERROR_BACKOFF_MS * 1000); - } else { - int delay_idx = poll_count < POLL_DELAYS_COUNT ? poll_count : POLL_DELAYS_COUNT - 1; - usleep(POLL_DELAYS[delay_idx] * 1000); - } + // Sleep before polling (except first iteration handled by caller) + int delay_idx = poll_count < POLL_DELAYS_COUNT ? poll_count : POLL_DELAYS_COUNT - 1; + usleep(POLL_DELAYS[delay_idx] * 1000); poll_count++; struct ResponseBuffer response = {0}; @@ -1141,64 +1124,25 @@ static char* poll_job_status(const UnsandboxCredentials *creds, const char *job_ curl_slist_free_all(headers); if (res != CURLE_OK) { - consecutive_errors++; - if (consecutive_errors >= POLL_MAX_CONSECUTIVE_ERRORS) { - fprintf(stderr, "Error: Lost connection to API after %d retries\n", consecutive_errors); - fprintf(stderr, "Job ID: %s — check later with: un jobs --get %s\n", job_id, job_id); - free(response.data); - break; - } - fprintf(stderr, "Connection error, retrying... (%d/%d)\n", consecutive_errors, POLL_MAX_CONSECUTIVE_ERRORS); + fprintf(stderr, "Error polling job: %s\n", curl_easy_strerror(res)); free(response.data); - continue; + break; } long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); if (http_code == 404) { - consecutive_errors++; - if (saw_server_errors) { - // API restarted (502/503 then 404) — job state lost - fprintf(stderr, "Error: API restarted — job result lost (in-memory job state cleared)\n"); - fprintf(stderr, "The command may have completed on the container.\n"); - fprintf(stderr, "Job ID: %s\n", job_id); - free(response.data); - break; - } - if (consecutive_errors > 5) { - fprintf(stderr, "Error: job %s not found\n", job_id); - free(response.data); - break; - } - // Job might not be registered yet (brief race window) - free(response.data); - continue; - } - - if (http_code >= 500) { - consecutive_errors++; - saw_server_errors = 1; - if (consecutive_errors >= POLL_MAX_CONSECUTIVE_ERRORS) { - fprintf(stderr, "Error: Server errors after %d retries\n", consecutive_errors); - fprintf(stderr, "Job ID: %s — check later with: un jobs --get %s\n", job_id, job_id); - free(response.data); - break; - } - fprintf(stderr, "Server error %ld, retrying... (%d/%d)\n", http_code, consecutive_errors, POLL_MAX_CONSECUTIVE_ERRORS); - free(response.data); - continue; - } - - if (http_code != 200) { - // 4xx (non-404) — not transient, bail immediately - fprintf(stderr, "Error: HTTP %ld while polling job\n", http_code); + fprintf(stderr, "Error: job not found\n"); free(response.data); break; } - // Success — reset error counter - consecutive_errors = 0; + if (http_code != 200) { + fprintf(stderr, "Error: HTTP %ld while polling job\n", http_code); + free(response.data); + break; + } // Check status field char *status = extract_json_string(response.data, "status"); @@ -1443,12 +1387,6 @@ static char* find_session_by_container(const UnsandboxCredentials *creds, const return NULL; } -// Forward declaration for sudo challenge handler (defined after destroy_service) -static int handle_sudo_challenge(const char *response_data, - const UnsandboxCredentials *creds, - const char *method, const char *url, - const char *path, const char *body); - // Kill a session by ID or container name static int kill_session(const UnsandboxCredentials *creds, const char *session_id_or_container) { char *session_id = NULL; @@ -1521,12 +1459,6 @@ static int kill_session(const UnsandboxCredentials *creds, const char *session_i free(response.data); free(session_id); return 1; - } else if (http_code == 428) { - fprintf(stderr, "\n"); - int result = handle_sudo_challenge(response.data, creds, "DELETE", url, path, NULL); - free(response.data); - free(session_id); - return result; } else { fprintf(stderr, " failed\nError: HTTP %ld\n", http_code); if (response.data) fprintf(stderr, "%s\n", response.data); @@ -2715,418 +2647,6 @@ static char* get_service_logs(const UnsandboxCredentials *creds, const char *ser return log; } -// ============================================================================ -// PaaS Logs (fetch and stream production logs from portal) -// ============================================================================ - -// Log cursor: ~/.unsandbox/log_cursor stores last fetch time as Unix timestamp. -// Used to fetch only new logs since last call (ops semaphore). - -static const char *get_log_cursor_path(void) { - static char path[512]; - const char *home = getenv("HOME"); - if (!home) return NULL; - snprintf(path, sizeof(path), "%s/.unsandbox/paas_log_cursor", home); - return path; -} - -// Read cursor: returns seconds-ago string like "42s", or NULL if no cursor -static char *read_log_cursor(void) { - const char *path = get_log_cursor_path(); - if (!path) return NULL; - - FILE *f = fopen(path, "r"); - if (!f) return NULL; - - char buf[64]; - if (!fgets(buf, sizeof(buf), f)) { fclose(f); return NULL; } - fclose(f); - - long ts = atol(buf); - if (ts <= 0) return NULL; - - long ago = (long)time(NULL) - ts; - if (ago < 5) ago = 5; // minimum 5s to avoid empty results - - static char since_str[32]; - snprintf(since_str, sizeof(since_str), "%lds", ago); - return since_str; -} - -// Write cursor: store current Unix timestamp -static void write_log_cursor(void) { - const char *path = get_log_cursor_path(); - if (!path) return; - - // Ensure ~/.unsandbox/ exists - char dir[512]; - const char *home = getenv("HOME"); - if (!home) return; - snprintf(dir, sizeof(dir), "%s/.unsandbox", home); - mkdir(dir, 0700); // ignore error if exists - - FILE *f = fopen(path, "w"); - if (!f) return; - fprintf(f, "%ld\n", (long)time(NULL)); - fclose(f); -} - -// Volatile flag for SIGINT handling during SSE streaming (forward declaration) -static volatile int stream_interrupted = 0; -// Track last received log time for reconnect deduplication -static volatile time_t stream_last_recv_time = 0; - -// Streaming write callback — prints SSE data lines to stdout as they arrive -static size_t stream_sse_callback(void *contents, size_t size, size_t nmemb, void *userp) { - size_t realsize = size * nmemb; - // Buffer for partial SSE events across calls - static char sse_buf[65536]; - static size_t sse_buf_len = 0; - (void)userp; - - // Append new data to buffer - size_t space = sizeof(sse_buf) - sse_buf_len - 1; - size_t copy = realsize < space ? realsize : space; - memcpy(sse_buf + sse_buf_len, contents, copy); - sse_buf_len += copy; - sse_buf[sse_buf_len] = 0; - - // Process complete SSE events (delimited by \n\n) - char *pos = sse_buf; - char *event_end; - while ((event_end = strstr(pos, "\n\n")) != NULL) { - *event_end = 0; - // Parse each line in the event - char *line = pos; - while (*line) { - char *nl = strchr(line, '\n'); - if (nl) *nl = 0; - - if (strncmp(line, "data: ", 6) == 0) { - const char *payload = line + 6; - if (strcmp(payload, ":keepalive") != 0) { - // Try to extract source + line from JSON - // Format: {"source":"api","line":"..."} - char *source = extract_json_string(payload, "source"); - char *logline = extract_json_string(payload, "line"); - if (source && logline) { - printf("\033[36m%-12s\033[0m %s\n", source, logline); - fflush(stdout); - stream_last_recv_time = time(NULL); - } else if (logline) { - printf("%s\n", logline); - fflush(stdout); - stream_last_recv_time = time(NULL); - } else { - // Plain text payload - printf("%s\n", payload); - fflush(stdout); - stream_last_recv_time = time(NULL); - } - free(source); - free(logline); - } - } - - if (nl) line = nl + 1; - else break; - } - pos = event_end + 2; - } - - // Move remaining partial data to front of buffer - if (pos > sse_buf) { - sse_buf_len = strlen(pos); - memmove(sse_buf, pos, sse_buf_len + 1); - } - - if (stream_interrupted) return 0; - return realsize; -} - -// Fetch PaaS logs (batch) — returns 0 on success -static int fetch_paas_logs(const UnsandboxCredentials *creds, - const char *source, int lines, - const char *since, const char *grep, - int json_output, const char *level) { - CURL *curl = curl_easy_init(); - if (!curl) return 1; - - struct ResponseBuffer response = {0}; - response.data = malloc(1); - response.size = 0; - - // Build path: /logs/{source} - char path[256]; - if (strcmp(source, "all") == 0) - snprintf(path, sizeof(path), "/logs/all"); - else - snprintf(path, sizeof(path), "/logs/%s", source); - - // Build full URL with query params - char url[1024]; - int off = snprintf(url, sizeof(url), "%s%s?lines=%d", PORTAL_BASE, path, lines); - if (since && since[0]) - off += snprintf(url + off, sizeof(url) - off, "&since=%s", since); - if (grep && grep[0]) - off += snprintf(url + off, sizeof(url) - off, "&grep=%s", grep); - if (level && level[0]) - off += snprintf(url + off, sizeof(url) - off, "&level=%s", level); - - // HMAC signs path only (no query string) — matches server conn.request_path - struct curl_slist *headers = NULL; - headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "un-cli/2.0"); - - CURLcode res = curl_easy_perform(curl); - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - if (res != CURLE_OK) { - fprintf(stderr, "Error: Network error fetching logs: %s\n", curl_easy_strerror(res)); - free(response.data); - return 1; - } - - if (http_code != 200) { - char *error = extract_json_string(response.data, "error"); - fprintf(stderr, "Error: HTTP %ld", http_code); - if (error) { fprintf(stderr, " — %s", error); free(error); } - fprintf(stderr, "\n"); - free(response.data); - return 1; - } - - // Update cursor on success - write_log_cursor(); - - if (json_output) { - // Raw JSON output - printf("%s\n", response.data); - free(response.data); - return 0; - } - - // Check if this is an "all" response (has "sources" key) - int is_all = (strstr(response.data, "\"sources\"") != NULL); - - if (is_all) { - // Parse merged lines: "lines":[{"source":"api","line":"..."},...] - const char *lines_start = strstr(response.data, "\"lines\":["); - if (!lines_start) { - fprintf(stderr, "Error: Invalid response format\n"); - free(response.data); - return 1; - } - const char *p = strchr(lines_start, '['); - if (!p) { free(response.data); return 1; } - p++; // skip '[' - - // Walk through array of objects - while (*p) { - // Find next object - const char *obj_start = strchr(p, '{'); - if (!obj_start) break; - - // Find matching close brace (simple — no nested objects in log entries) - const char *obj_end = strchr(obj_start, '}'); - if (!obj_end) break; - - // Extract source and line from this object - size_t obj_len = obj_end - obj_start + 1; - char *obj = malloc(obj_len + 1); - memcpy(obj, obj_start, obj_len); - obj[obj_len] = 0; - - char *src = extract_json_string(obj, "source"); - char *line = extract_json_string(obj, "line"); - - if (src && line) { - printf("\033[36m%-12s\033[0m %s\n", src, line); - } else if (line) { - printf("%s\n", line); - } - - free(src); - free(line); - free(obj); - p = obj_end + 1; - } - } else { - // Single source: "lines":["line1","line2",...] - const char *lines_start = strstr(response.data, "\"lines\":["); - if (!lines_start) { - fprintf(stderr, "Error: Invalid response format\n"); - free(response.data); - return 1; - } - const char *arr_start = strchr(lines_start, '['); - if (!arr_start) { free(response.data); return 1; } - - // Parse string array - const char *p = arr_start + 1; - while (*p && *p != ']') { - if (*p == '"') { - p++; - // Find end of string (handle escaped quotes) - const char *end = p; - while (*end && !(*end == '"' && *(end - 1) != '\\')) end++; - printf("%.*s\n", (int)(end - p), p); - p = end + 1; - } else { - p++; - } - } - } - - free(response.data); - return 0; -} - -static void stream_sigint_handler(int sig) { - (void)sig; - stream_interrupted = 1; -} - -// Stream PaaS logs (SSE) — blocks until Ctrl+C, auto-reconnects on drop -static int stream_paas_logs(const UnsandboxCredentials *creds, - const char *source, const char *grep, - const char *level) { - // Build path: /logs/{source}/stream - char path[256]; - if (strcmp(source, "all") == 0) - snprintf(path, sizeof(path), "/logs/all/stream"); - else - snprintf(path, sizeof(path), "/logs/%s/stream", source); - - // Build URL with optional query params - char url[1024]; - int off = snprintf(url, sizeof(url), "%s%s", PORTAL_BASE, path); - char sep = '?'; - if (grep && grep[0]) { - off += snprintf(url + off, sizeof(url) - off, "%cgrep=%s", sep, grep); - sep = '&'; - } - if (level && level[0]) { - off += snprintf(url + off, sizeof(url) - off, "%clevel=%s", sep, level); - sep = '&'; - } - (void)sep; - - // Install SIGINT handler for clean shutdown - stream_interrupted = 0; - struct sigaction sa, old_sa; - memset(&sa, 0, sizeof(sa)); - sa.sa_handler = stream_sigint_handler; - sigaction(SIGINT, &sa, &old_sa); - - fprintf(stderr, "Streaming %s logs... (Ctrl+C to stop)\n", source); - - int reconnect_delay = 2; // Start at 2s, cap at 30s - int is_reconnect = 0; - stream_last_recv_time = 0; - - while (!stream_interrupted) { - CURL *curl = curl_easy_init(); - if (!curl) { sigaction(SIGINT, &old_sa, NULL); return 1; } - - // Fresh HMAC headers each connection (timestamps expire) - struct curl_slist *headers = NULL; - headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); - - // On reconnect, add since= to pick up where we left off - char reconnect_url[2048]; - if (is_reconnect && stream_last_recv_time > 0) { - int gap = (int)(time(NULL) - stream_last_recv_time) + 5; // +5s overlap buffer - if (gap < 10) gap = 10; - snprintf(reconnect_url, sizeof(reconnect_url), "%s%csince=%ds", - url, strchr(url, '?') ? '&' : '?', gap); - } else { - snprintf(reconnect_url, sizeof(reconnect_url), "%s", url); - } - - curl_easy_setopt(curl, CURLOPT_URL, reconnect_url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, stream_sse_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, NULL); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "un-cli/2.0"); - // Force HTTP/1.1 — SSE streaming breaks with HTTP/2 framing - curl_easy_setopt(curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); - // No timeout — stream until interrupted - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L); - // Low-speed limit to detect dead connections (10 bytes in 120s accounts for keepalives) - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 120L); - - time_t connect_time = time(NULL); - CURLcode res = curl_easy_perform(curl); - - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - // Reset backoff if we were connected for >30s (healthy connection) - if (time(NULL) - connect_time > 30) reconnect_delay = 2; - - if (stream_interrupted) { - break; - } - - // Fatal errors — don't reconnect - if (http_code == 401 || http_code == 403) { - fprintf(stderr, "Error: HTTP %ld (auth failed)\n", http_code); - sigaction(SIGINT, &old_sa, NULL); - return 1; - } - - // Recoverable: partial file, connection reset, low-speed timeout - if (res == CURLE_PARTIAL_FILE || res == CURLE_RECV_ERROR || - res == CURLE_OPERATION_TIMEDOUT || res == CURLE_GOT_NOTHING || - (res == CURLE_OK && http_code == 200)) { - // Silent reconnect — don't pollute output - sleep(reconnect_delay); - if (reconnect_delay < 30) reconnect_delay = reconnect_delay * 2; - if (reconnect_delay > 30) reconnect_delay = 30; - is_reconnect = 1; - continue; - } - - // Transient HTTP errors (502, 503, 504) — silent reconnect - if (http_code >= 500) { - sleep(reconnect_delay); - if (reconnect_delay < 30) reconnect_delay = reconnect_delay * 2; - if (reconnect_delay > 30) reconnect_delay = 30; - is_reconnect = 1; - continue; - } - - // Non-recoverable curl error - if (res != CURLE_OK && res != CURLE_WRITE_ERROR) { - fprintf(stderr, "Error: Stream error: %s\n", curl_easy_strerror(res)); - sigaction(SIGINT, &old_sa, NULL); - return 1; - } - - // CURLE_WRITE_ERROR from our callback returning 0 (shouldn't happen without interrupt) - break; - } - - sigaction(SIGINT, &old_sa, NULL); - write_log_cursor(); - fprintf(stderr, "\nStream stopped.\n"); - return 0; -} - // Create a service via HTTP API // bootstrap_content: if provided, sent as bootstrap_content (file contents) // bootstrap: if bootstrap_content is NULL and this starts with http, sent as bootstrap URL @@ -3656,110 +3176,6 @@ static int unfreeze_service(const UnsandboxCredentials *creds, const char *servi return 0; } -// Handle 428 sudo OTP challenge - prompts user for OTP and retries the request -static int handle_sudo_challenge(const char *response_data, - const UnsandboxCredentials *creds, - const char *method, const char *url, - const char *path, const char *body) { - // Extract challenge_id from response - char *challenge_id = extract_json_string(response_data, "challenge_id"); - - fprintf(stderr, "\033[33mConfirmation required. Check your email for a one-time code.\033[0m\n"); - fprintf(stderr, "Enter OTP: "); - - char otp[32]; - if (!fgets(otp, sizeof(otp), stdin)) { - fprintf(stderr, "Error: Failed to read OTP\n"); - if (challenge_id) free(challenge_id); - return 1; - } - // Strip newline - size_t otp_len = strlen(otp); - if (otp_len > 0 && otp[otp_len - 1] == '\n') otp[otp_len - 1] = '\0'; - otp_len = strlen(otp); - if (otp_len > 0 && otp[otp_len - 1] == '\r') otp[otp_len - 1] = '\0'; - - if (strlen(otp) == 0) { - fprintf(stderr, "Error: Operation cancelled\n"); - if (challenge_id) free(challenge_id); - return 1; - } - - // Retry the request with sudo headers - CURL *curl = curl_easy_init(); - if (!curl) { - if (challenge_id) free(challenge_id); - return 1; - } - - struct ResponseBuffer retry_response = {0}; - retry_response.data = malloc(1); - retry_response.size = 0; - - struct curl_slist *headers = NULL; - headers = add_hmac_auth_headers(headers, creds, method, path, body); - - // Add sudo headers - char otp_header[64]; - snprintf(otp_header, sizeof(otp_header), "X-Sudo-OTP: %s", otp); - headers = curl_slist_append(headers, otp_header); - - if (challenge_id) { - char challenge_header[256]; - snprintf(challenge_header, sizeof(challenge_header), "X-Sudo-Challenge: %s", challenge_id); - headers = curl_slist_append(headers, challenge_header); - free(challenge_id); - } - - if (body) { - headers = curl_slist_append(headers, "Content-Type: application/json"); - } - - curl_easy_setopt(curl, CURLOPT_URL, url); - if (strcmp(method, "DELETE") == 0) { - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); - } else if (strcmp(method, "POST") == 0) { - curl_easy_setopt(curl, CURLOPT_POST, 1L); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : ""); - } - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &retry_response); - - CURLcode res = curl_easy_perform(curl); - - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - if (res != CURLE_OK) { - fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); - free(retry_response.data); - return 1; - } - - if (http_code >= 200 && http_code < 300) { - printf("\033[32mOperation completed successfully\033[0m\n"); - free(retry_response.data); - return 0; - } - - fprintf(stderr, "Error: HTTP %ld\n", http_code); - if (retry_response.data) { - char *error = extract_json_string(retry_response.data, "error"); - if (error) { - fprintf(stderr, "%s\n", error); - free(error); - } else { - fprintf(stderr, "%s\n", retry_response.data); - } - } - free(retry_response.data); - return 1; -} - // Destroy a service static int destroy_service(const UnsandboxCredentials *creds, const char *service_id) { CURL *curl = curl_easy_init(); @@ -3804,12 +3220,6 @@ static int destroy_service(const UnsandboxCredentials *creds, const char *servic return 1; } - if (http_code == 428) { - int result = handle_sudo_challenge(response.data, creds, "DELETE", url, path, NULL); - free(response.data); - return result; - } - if (http_code != 200) { fprintf(stderr, "Error: HTTP %ld\n", http_code); if (response.data) fprintf(stderr, "%s\n", response.data); @@ -3928,12 +3338,6 @@ static int unlock_service(const UnsandboxCredentials *creds, const char *service return 1; } - if (http_code == 428) { - int result = handle_sudo_challenge(response.data, creds, "POST", url, path, body); - free(response.data); - return result; - } - if (http_code != 200) { fprintf(stderr, "Error: HTTP %ld\n", http_code); if (response.data) fprintf(stderr, "%s\n", response.data); @@ -3946,115 +3350,6 @@ static int unlock_service(const UnsandboxCredentials *creds, const char *service return 0; } -// Update custom domains for a service -// action: "custom_domains" (full replace), "add", or "remove" -// domains: comma-separated domain list -static int update_service_domains(const UnsandboxCredentials *creds, const char *service_id, const char *action, const char *domains) { - CURL *curl = curl_easy_init(); - if (!curl) return 1; - - struct ResponseBuffer response = {0}; - response.data = malloc(1); - response.size = 0; - - char url[512]; - snprintf(url, sizeof(url), "%s/services/%s/domains", API_BASE, service_id); - - char path[256]; - snprintf(path, sizeof(path), "/services/%s/domains", service_id); - - // Build JSON payload: {"action": ["domain1", "domain2"]} - size_t payload_size = strlen(domains) * 2 + 256; - char *payload = malloc(payload_size); - if (!payload) { - curl_easy_cleanup(curl); - return 1; - } - char *p = payload; - p += sprintf(p, "{\"%s\":[", action); - - char *domains_copy = strdup(domains); - char *token = strtok(domains_copy, ","); - int first = 1; - while (token) { - while (*token == ' ') token++; - char *end = token + strlen(token) - 1; - while (end > token && *end == ' ') *end-- = '\0'; - if (!first) p += sprintf(p, ","); - char *esc = escape_json_string(token); - p += sprintf(p, "\"%s\"", esc); - free(esc); - first = 0; - token = strtok(NULL, ","); - } - free(domains_copy); - p += sprintf(p, "]}"); - - struct curl_slist *headers = NULL; - headers = add_hmac_auth_headers(headers, creds, "PUT", path, payload); - headers = curl_slist_append(headers, "Content-Type: application/json"); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - - CURLcode res = curl_easy_perform(curl); - - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - free(payload); - - if (res != CURLE_OK) { - fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); - free(response.data); - return 1; - } - - if (http_code == 404) { - fprintf(stderr, "Error: Service not found\n"); - free(response.data); - return 1; - } - - if (http_code == 403) { - fprintf(stderr, "Error: Not authorized to modify this service\n"); - free(response.data); - return 1; - } - - if (http_code != 200) { - fprintf(stderr, "Error: HTTP %ld\n", http_code); - if (response.data) fprintf(stderr, "%s\n", response.data); - free(response.data); - return 1; - } - - printf("\033[32mCustom domains updated successfully\033[0m\n"); - // Print resulting domains from response - if (response.data) { - // Quick parse for custom_domains array - char *cd = strstr(response.data, "\"custom_domains\""); - if (cd) { - char *start = strchr(cd, '['); - char *end = start ? strchr(start, ']') : NULL; - if (start && end) { - char tmp = *(end + 1); - *(end + 1) = '\0'; - printf("Domains: %s\n", start); - *(end + 1) = tmp; - } - } - } - free(response.data); - return 0; -} - // Set unfreeze_on_demand for a service static int set_unfreeze_on_demand(const UnsandboxCredentials *creds, const char *service_id, int enabled) { CURL *curl = curl_easy_init(); @@ -4116,67 +3411,6 @@ static int set_unfreeze_on_demand(const UnsandboxCredentials *creds, const char return 0; } -// Set show_freeze_page for a service (controls whether frozen services show payment page or JSON error) -static int set_show_freeze_page(const UnsandboxCredentials *creds, const char *service_id, int enabled) { - CURL *curl = curl_easy_init(); - if (!curl) return 1; - - struct ResponseBuffer response = {0}; - response.data = malloc(1); - response.size = 0; - - char url[512]; - snprintf(url, sizeof(url), "%s/services/%s", API_BASE, service_id); - - char path[256]; - snprintf(path, sizeof(path), "/services/%s", service_id); - - char body[128]; - snprintf(body, sizeof(body), "{\"show_freeze_page\":%s}", enabled ? "true" : "false"); - - struct curl_slist *headers = NULL; - headers = add_hmac_auth_headers(headers, creds, "PATCH", path, body); - headers = curl_slist_append(headers, "Content-Type: application/json"); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PATCH"); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - - CURLcode res = curl_easy_perform(curl); - - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - if (res != CURLE_OK) { - fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); - free(response.data); - return 1; - } - - if (http_code == 404) { - fprintf(stderr, "Error: Service not found\n"); - free(response.data); - return 1; - } - - if (http_code != 200) { - fprintf(stderr, "Error: HTTP %ld\n", http_code); - if (response.data) fprintf(stderr, "%s\n", response.data); - free(response.data); - return 1; - } - - printf("\033[32mFreeze page %s\033[0m\n", enabled ? "enabled" : "disabled"); - free(response.data); - return 0; -} - // Resize a service (change vCPU/memory live) static int resize_service(const UnsandboxCredentials *creds, const char *service_id, int vcpu) { CURL *curl = curl_easy_init(); @@ -4646,7 +3880,7 @@ static char* read_env_stdin(void) { // Redeploy a service (re-run bootstrap script) // Bootstrap scripts should be idempotent for proper upgrade behavior -static int redeploy_service(const UnsandboxCredentials *creds, const char *service_id, const char *bootstrap, struct InputFile *input_files, int input_file_count) { +static int redeploy_service(const UnsandboxCredentials *creds, const char *service_id, const char *bootstrap) { CURL *curl = curl_easy_init(); if (!curl) return 1; @@ -4695,9 +3929,6 @@ static int redeploy_service(const UnsandboxCredentials *creds, const char *servi } else if (bootstrap_url) { payload_size += strlen(bootstrap_url) * 2 + 100; } - for (int i = 0; i < input_file_count; i++) { - payload_size += strlen(input_files[i].content_base64) + 256; - } // Build JSON payload manually (matching create_service pattern) char *payload = malloc(payload_size); @@ -4710,31 +3941,15 @@ static int redeploy_service(const UnsandboxCredentials *creds, const char *servi char *p = payload; p += sprintf(p, "{"); - int has_field = 0; if (bootstrap_content) { char *esc_content = escape_json_string(bootstrap_content); p += sprintf(p, "\"bootstrap_content\":\"%s\"", esc_content); free(esc_content); free(bootstrap_content); - has_field = 1; } else if (bootstrap_url) { char *esc_url = escape_json_string(bootstrap_url); p += sprintf(p, "\"bootstrap\":\"%s\"", esc_url); free(esc_url); - has_field = 1; - } - - if (input_file_count > 0) { - if (has_field) p += sprintf(p, ","); - p += sprintf(p, "\"input_files\":["); - for (int i = 0; i < input_file_count; i++) { - if (i > 0) p += sprintf(p, ","); - char *esc_filename = escape_json_string(input_files[i].filename); - p += sprintf(p, "{\"filename\":\"%s\",\"content\":\"%s\"}", - esc_filename, input_files[i].content_base64); - free(esc_filename); - } - p += sprintf(p, "]"); } p += sprintf(p, "}"); @@ -4794,8 +4009,7 @@ static int redeploy_service(const UnsandboxCredentials *creds, const char *servi // Execute a command in a running service container // Uses async job polling for long-running commands -// Optional input_files: files to upload before executing (written to /tmp/input/) -static int execute_service(const UnsandboxCredentials *creds, const char *service_id, const char *command, int timeout_ms, struct InputFile *input_files, int input_file_count) { +static int execute_service(const UnsandboxCredentials *creds, const char *service_id, const char *command, int timeout_ms) { CURL *curl = curl_easy_init(); if (!curl) return 1; @@ -4817,38 +4031,10 @@ static int execute_service(const UnsandboxCredentials *creds, const char *servic return 1; } - // Calculate payload size (base + files) - size_t payload_size = 8192; - for (int i = 0; i < input_file_count; i++) { - payload_size += strlen(input_files[i].content_base64) + 256; - } - - char *payload = malloc(payload_size); - if (!payload) { - free(esc_command); - curl_easy_cleanup(curl); - free(response.data); - return 1; - } - - char *p = payload; - p += sprintf(p, "{\"command\":\"%s\",\"timeout\":%d", esc_command, timeout_ms); + char payload[8192]; + snprintf(payload, sizeof(payload), "{\"command\":\"%s\",\"timeout\":%d}", esc_command, timeout_ms); free(esc_command); - // Add input files if provided - if (input_file_count > 0) { - p += sprintf(p, ",\"input_files\":["); - for (int i = 0; i < input_file_count; i++) { - if (i > 0) *p++ = ','; - char *esc_filename = escape_json_string(input_files[i].filename); - p += sprintf(p, "{\"filename\":\"%s\",\"content\":\"%s\"}", - esc_filename, input_files[i].content_base64); - free(esc_filename); - } - p += sprintf(p, "]"); - } - p += sprintf(p, "}"); - struct curl_slist *headers = NULL; headers = curl_slist_append(headers, "Content-Type: application/json"); headers = add_hmac_auth_headers(headers, creds, "POST", path, payload); @@ -4866,7 +4052,6 @@ static int execute_service(const UnsandboxCredentials *creds, const char *servic curl_slist_free_all(headers); curl_easy_cleanup(curl); - free(payload); if (res != CURLE_OK) { fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); @@ -4902,33 +4087,77 @@ static int execute_service(const UnsandboxCredentials *creds, const char *servic return 1; } - fprintf(stderr, "job %s\n", job_id); + // Poll for job completion + char job_url[512]; + snprintf(job_url, sizeof(job_url), "%s/jobs/%s", API_BASE, job_id); - // Poll for job completion using shared resilient poller - char *final_response = poll_job_status(creds, job_id); + char job_path[256]; + snprintf(job_path, sizeof(job_path), "/jobs/%s", job_id); - if (!final_response) { - free(job_id); - return 1; + int poll_count = 0; + int max_polls = (timeout_ms / 1000) + 10; // timeout + 10 extra seconds + + while (poll_count < max_polls) { + usleep(500000); // 500ms between polls + poll_count++; + + curl = curl_easy_init(); + if (!curl) { + free(job_id); + return 1; + } + + struct ResponseBuffer job_response = {0}; + job_response.data = malloc(1); + job_response.size = 0; + + headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", job_path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, job_url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &job_response); + + res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK || http_code != 200) { + free(job_response.data); + continue; + } + + // Check job status + char *status = extract_json_string(job_response.data, "status"); + if (status && strcmp(status, "completed") == 0) { + // Job completed - print result using same format as code execution + parse_and_print_response(job_response.data, 0, NULL, NULL); + free(status); + free(job_response.data); + free(job_id); + return 0; + } + + if (status && (strcmp(status, "failed") == 0 || strcmp(status, "cancelled") == 0)) { + char *error = extract_json_string(job_response.data, "error"); + fprintf(stderr, "Error: Job %s: %s\n", status, error ? error : "unknown"); + if (error) free(error); + free(status); + free(job_response.data); + free(job_id); + return 1; + } + + if (status) free(status); + free(job_response.data); } - // Check terminal status - char *status = extract_json_string(final_response, "status"); - int ret = 0; - - if (status && (strcmp(status, "failed") == 0 || strcmp(status, "cancelled") == 0)) { - char *error = extract_json_string(final_response, "error"); - fprintf(stderr, "Error: Job %s: %s\n", status, error ? error : "unknown"); - if (error) free(error); - ret = 1; - } else { - parse_and_print_response(final_response, 0, NULL, NULL); - } - - if (status) free(status); - free(final_response); + fprintf(stderr, "Error: Command timed out after %d seconds\n", timeout_ms / 1000); free(job_id); - return ret; + return 1; } // Execute a command in a service and capture output (returns malloc'd string or NULL) @@ -5315,12 +4544,6 @@ static int delete_snapshot(const UnsandboxCredentials *creds, const char *snapsh return 1; } - if (http_code == 428) { - int result = handle_sudo_challenge(response.data, creds, "DELETE", url, path, NULL); - free(response.data); - return result; - } - if (http_code != 200) { fprintf(stderr, "Error: HTTP %ld\n", http_code); if (response.data) fprintf(stderr, "%s\n", response.data); @@ -5439,12 +4662,6 @@ static int unlock_snapshot(const UnsandboxCredentials *creds, const char *snapsh return 1; } - if (http_code == 428) { - int result = handle_sudo_challenge(response.data, creds, "POST", url, path, body); - free(response.data); - return result; - } - if (http_code != 200) { fprintf(stderr, "Error: HTTP %ld\n", http_code); if (response.data) fprintf(stderr, "%s\n", response.data); @@ -5996,34 +5213,11 @@ static int delete_image(const UnsandboxCredentials *creds, const char *image_id) curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); CURLcode res = curl_easy_perform(curl); - - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - curl_slist_free_all(headers); curl_easy_cleanup(curl); - if (res != CURLE_OK) { - fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); - free(response.data); - return 1; - } - - if (http_code == 428) { - int result = handle_sudo_challenge(response.data, creds, "DELETE", url, path, NULL); - free(response.data); - return result; - } - - if (http_code != 200) { - fprintf(stderr, "Error: HTTP %ld\n", http_code); - if (response.data) fprintf(stderr, "%s\n", response.data); - free(response.data); - return 1; - } - free(response.data); - return 0; + return (res == CURLE_OK) ? 0 : 1; } // Lock an image @@ -6091,34 +5285,11 @@ static int unlock_image(const UnsandboxCredentials *creds, const char *image_id) curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); CURLcode res = curl_easy_perform(curl); - - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - curl_slist_free_all(headers); curl_easy_cleanup(curl); - if (res != CURLE_OK) { - fprintf(stderr, "Error: %s\n", curl_easy_strerror(res)); - free(response.data); - return 1; - } - - if (http_code == 428) { - int result = handle_sudo_challenge(response.data, creds, "POST", url, path, "{}"); - free(response.data); - return result; - } - - if (http_code != 200) { - fprintf(stderr, "Error: HTTP %ld\n", http_code); - if (response.data) fprintf(stderr, "%s\n", response.data); - free(response.data); - return 1; - } - free(response.data); - return 0; + return (res == CURLE_OK) ? 0 : 1; } // Set image visibility (private, unlisted, public) @@ -6667,8 +5838,6 @@ void print_usage(const char *prog) { fprintf(stderr, " %s snapshot [options]\n", prog); fprintf(stderr, " %s image [options]\n", prog); fprintf(stderr, " %s languages [--json]\n", prog); - fprintf(stderr, " %s jobs [options]\n", prog); - fprintf(stderr, " %s paas [options]\n", prog); fprintf(stderr, " %s key\n\n", prog); fprintf(stderr, "Commands:\n"); fprintf(stderr, " (default) Execute source file in sandbox\n"); @@ -6677,8 +5846,6 @@ void print_usage(const char *prog) { fprintf(stderr, " snapshot Manage container snapshots\n"); fprintf(stderr, " image Manage images (publish, spawn, clone)\n"); fprintf(stderr, " languages List available languages (--json for JSON output)\n"); - fprintf(stderr, " jobs List, inspect, or cancel async jobs\n"); - fprintf(stderr, " paas PaaS platform management (logs, etc.)\n"); fprintf(stderr, " key Check API key validity and expiration\n"); fprintf(stderr, "\nOptions:\n"); fprintf(stderr, " -s, --shell LANG Specify language (default: bash if arg is not a file)\n"); @@ -6730,12 +5897,9 @@ void print_usage(const char *prog) { fprintf(stderr, " --unlock ID Unlock a service to allow deletion\n"); fprintf(stderr, " --auto-unfreeze ID Enable auto-unfreeze on HTTP request\n"); fprintf(stderr, " --no-auto-unfreeze ID Disable auto-unfreeze on HTTP request\n"); - fprintf(stderr, " --show-freeze-page ID Enable freeze page (show payment page when frozen)\n"); - fprintf(stderr, " --no-show-freeze-page ID Disable freeze page (return JSON error when frozen)\n"); fprintf(stderr, " --resize ID Resize service vCPU/memory (requires --vcpu)\n"); fprintf(stderr, " --redeploy ID Re-run bootstrap script (optional: --bootstrap or --bootstrap-file)\n"); - fprintf(stderr, " --execute ID CMD Run a command in a running service (use -f to upload files first)\n"); - fprintf(stderr, " -t, --timeout SEC Timeout for --execute in seconds (default: 30, 0=unlimited)\n"); + fprintf(stderr, " --execute ID CMD Run a command in a running service\n"); fprintf(stderr, " --dump-bootstrap ID [FILE] Dump bootstrap script (for migrations)\n"); fprintf(stderr, " --snapshot ID Create snapshot of service (paid tiers only)\n"); fprintf(stderr, " --restore SNAPSHOT Restore service from snapshot\n"); @@ -6814,7 +5978,6 @@ void print_usage(const char *prog) { fprintf(stderr, " %s service --redeploy abc123 --bootstrap-file ./script.sh\n", prog); fprintf(stderr, " %s service --redeploy abc123 # uses stored encrypted bootstrap\n", prog); fprintf(stderr, " %s service --execute maldoror 'journalctl -u myapp -n 50'\n", prog); - fprintf(stderr, " %s service -f data.txt --execute myapp 'cat /tmp/input/data.txt' # upload then run\n", prog); fprintf(stderr, " %s service --dump-bootstrap maldoror # print bootstrap to stdout\n", prog); fprintf(stderr, " %s service --dump-bootstrap maldoror backup.sh # save to file\n", prog); fprintf(stderr, " %s service --name app -e API_KEY=secret -e DEBUG=1 # with env vars\n", prog); @@ -6833,16 +5996,6 @@ void print_usage(const char *prog) { fprintf(stderr, " %s snapshot --info unsb-snapshot-xxxx # get snapshot details\n", prog); fprintf(stderr, " %s snapshot --delete unsb-snapshot-xxxx # delete a snapshot\n", prog); fprintf(stderr, " %s snapshot --clone unsb-snapshot-xxxx --type service --name myapp\n", prog); - fprintf(stderr, " %s jobs # list all jobs\n", prog); - fprintf(stderr, " %s jobs --get JOB_ID # get job status and result\n", prog); - fprintf(stderr, " %s jobs --cancel JOB_ID # cancel a running job\n", prog); - fprintf(stderr, " %s paas logs # last 100 lines from all sources\n", prog); - fprintf(stderr, " %s paas logs --api -n 500 # last 500 API log lines\n", prog); - fprintf(stderr, " %s paas logs --portal --grep error # portal logs matching 'error'\n", prog); - fprintf(stderr, " %s paas logs --pool cammy --since 1h # cammy pool logs from last hour\n", prog); - fprintf(stderr, " %s paas logs -l warning # warnings and above\n", prog); - fprintf(stderr, " %s paas logs --follow # follow all logs in real-time\n", prog); - fprintf(stderr, " %s paas logs --follow -l warning # follow warnings+ in real-time\n", prog); fprintf(stderr, " %s key # check API key validity\n", prog); fprintf(stderr, " %s key --extend # open portal to extend key\n", prog); fprintf(stderr, "\nAuthentication:\n"); @@ -6860,7 +6013,7 @@ void print_usage(const char *prog) { * ============================================================================ */ const char *unsandbox_version(void) { - return "4.3.4"; + return "4.2.28"; } const char *unsandbox_detect_language(const char *filename) { @@ -7122,7 +6275,7 @@ int unsandbox_service_redeploy(const char *service_id, const char *bootstrap, const char *public_key, const char *secret_key) { UnsandboxCredentials *creds = get_credentials(public_key, secret_key, -1); if (!creds) return -1; - int result = redeploy_service(creds, service_id, bootstrap, NULL, 0); + int result = redeploy_service(creds, service_id, bootstrap); free_credentials(creds); return result; } @@ -7224,172 +6377,6 @@ unsandbox_key_info_t *unsandbox_validate_keys(const char *public_key, const char return info; } -/* ============================================================================ - * PaaS Logs Library Implementation - * ============================================================================ */ - -char *unsandbox_logs_fetch( - const char *source, - int lines, - const char *since, - const char *grep, - const char *public_key, const char *secret_key) { - - UnsandboxCredentials *creds = get_credentials(public_key, secret_key, -1); - if (!creds) { set_last_error("No credentials"); return NULL; } - - CURL *curl = curl_easy_init(); - if (!curl) { free_credentials(creds); set_last_error("curl init failed"); return NULL; } - - struct ResponseBuffer response = {0}; - response.data = malloc(1); - response.size = 0; - - char path[256]; - if (!source || strcmp(source, "all") == 0) - snprintf(path, sizeof(path), "/logs/all"); - else - snprintf(path, sizeof(path), "/logs/%s", source); - - char url[1024]; - int off = snprintf(url, sizeof(url), "%s%s?lines=%d", PORTAL_BASE, path, lines > 0 ? lines : 100); - if (since && since[0]) - off += snprintf(url + off, sizeof(url) - off, "&since=%s", since); - if (grep && grep[0]) - off += snprintf(url + off, sizeof(url) - off, "&grep=%s", grep); - - struct curl_slist *headers = NULL; - headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - - CURLcode res = curl_easy_perform(curl); - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - free_credentials(creds); - - if (res != CURLE_OK || http_code != 200) { - char errbuf[256]; - snprintf(errbuf, sizeof(errbuf), "HTTP %ld: %s", http_code, - res != CURLE_OK ? curl_easy_strerror(res) : "request failed"); - set_last_error(errbuf); - free(response.data); - return NULL; - } - - return response.data; // caller frees -} - -// Streaming callback context for library API -struct StreamCallbackCtx { - unsandbox_log_callback_t callback; - void *userdata; -}; - -static size_t stream_lib_sse_callback(void *contents, size_t size, size_t nmemb, void *userp) { - size_t realsize = size * nmemb; - struct StreamCallbackCtx *ctx = (struct StreamCallbackCtx *)userp; - static char buf[65536]; - static size_t buf_len = 0; - - size_t space = sizeof(buf) - buf_len - 1; - size_t copy = realsize < space ? realsize : space; - memcpy(buf + buf_len, contents, copy); - buf_len += copy; - buf[buf_len] = 0; - - char *pos = buf; - char *event_end; - while ((event_end = strstr(pos, "\n\n")) != NULL) { - *event_end = 0; - char *line = pos; - while (*line) { - char *nl = strchr(line, '\n'); - if (nl) *nl = 0; - if (strncmp(line, "data: ", 6) == 0) { - const char *payload = line + 6; - if (strcmp(payload, ":keepalive") != 0 && ctx->callback) { - char *source = extract_json_string(payload, "source"); - char *logline = extract_json_string(payload, "line"); - ctx->callback(source, logline ? logline : payload, ctx->userdata); - free(source); - free(logline); - } - } - if (nl) line = nl + 1; - else break; - } - pos = event_end + 2; - } - - if (pos > buf) { - buf_len = strlen(pos); - memmove(buf, pos, buf_len + 1); - } - - if (stream_interrupted) return 0; // abort - return realsize; -} - -int unsandbox_logs_stream( - const char *source, - const char *grep, - unsandbox_log_callback_t callback, - void *userdata, - const char *public_key, const char *secret_key) { - - UnsandboxCredentials *creds = get_credentials(public_key, secret_key, -1); - if (!creds) { set_last_error("No credentials"); return 1; } - - CURL *curl = curl_easy_init(); - if (!curl) { free_credentials(creds); set_last_error("curl init failed"); return 1; } - - char path[256]; - if (!source || strcmp(source, "all") == 0) - snprintf(path, sizeof(path), "/logs/all/stream"); - else - snprintf(path, sizeof(path), "/logs/%s/stream", source); - - char url[1024]; - if (grep && grep[0]) - snprintf(url, sizeof(url), "%s%s?grep=%s", PORTAL_BASE, path, grep); - else - snprintf(url, sizeof(url), "%s%s", PORTAL_BASE, path); - - struct curl_slist *headers = NULL; - headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); - - struct StreamCallbackCtx ctx = { callback, userdata }; - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, stream_lib_sse_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L); - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 120L); - - stream_interrupted = 0; - CURLcode res = curl_easy_perform(curl); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - free_credentials(creds); - - if (res != CURLE_OK && res != CURLE_WRITE_ERROR) { - set_last_error(curl_easy_strerror(res)); - return 1; - } - - return 0; -} - /* ============================================================================ * Stub Implementations (TODO: Full implementation) * These functions are declared in un.h but need HTTP/JSON handling @@ -10081,13 +9068,10 @@ int main(int argc, char *argv[]) { int do_unlock = 0; int do_auto_unfreeze = 0; int do_no_auto_unfreeze = 0; - int do_show_freeze_page = 0; - int do_no_show_freeze_page = 0; int do_resize = 0; int do_redeploy = 0; int do_execute = 0; const char *execute_command = NULL; - int execute_timeout = 30000; // Default 30 seconds int do_dump_bootstrap = 0; const char *dump_bootstrap_file = NULL; int do_snapshot = 0; @@ -10095,9 +9079,6 @@ int main(int argc, char *argv[]) { const char *restore_snapshot_id = NULL; const char *snapshot_name = NULL; int hot_snapshot = 0; - int do_add_domain = 0; - int do_remove_domain = 0; - int do_set_domains = 0; int create_unfreeze_on_demand = 0; // For --unfreeze-on-demand flag on create // Environment variables for service create @@ -10240,18 +9221,6 @@ int main(int argc, char *argv[]) { do_unlock = 1; i++; service_id = argv[i]; - } else if (strcmp(argv[i], "--add-domain") == 0 && i + 1 < argc) { - do_add_domain = 1; - i++; - service_id = argv[i]; - } else if (strcmp(argv[i], "--remove-domain") == 0 && i + 1 < argc) { - do_remove_domain = 1; - i++; - service_id = argv[i]; - } else if (strcmp(argv[i], "--set-domains") == 0 && i + 1 < argc) { - do_set_domains = 1; - i++; - service_id = argv[i]; } else if (strcmp(argv[i], "--auto-unfreeze") == 0 && i + 1 < argc) { do_auto_unfreeze = 1; i++; @@ -10260,14 +9229,6 @@ int main(int argc, char *argv[]) { do_no_auto_unfreeze = 1; i++; service_id = argv[i]; - } else if (strcmp(argv[i], "--show-freeze-page") == 0 && i + 1 < argc) { - do_show_freeze_page = 1; - i++; - service_id = argv[i]; - } else if (strcmp(argv[i], "--no-show-freeze-page") == 0 && i + 1 < argc) { - do_no_show_freeze_page = 1; - i++; - service_id = argv[i]; } else if (strcmp(argv[i], "--resize") == 0 && i + 1 < argc) { do_resize = 1; i++; @@ -10282,11 +9243,6 @@ int main(int argc, char *argv[]) { service_id = argv[i]; i++; execute_command = argv[i]; - } else if ((strcmp(argv[i], "-t") == 0 || strcmp(argv[i], "--timeout") == 0) && i + 1 < argc) { - i++; - execute_timeout = atoi(argv[i]) * 1000; // Convert seconds to ms - if (execute_timeout < 0) execute_timeout = 0; // 0 = unlimited - // No max cap - let it run as long as needed } else if (strcmp(argv[i], "--dump-bootstrap") == 0 && i + 1 < argc) { do_dump_bootstrap = 1; i++; @@ -10466,38 +9422,10 @@ int main(int argc, char *argv[]) { ret = lock_service(creds, service_id); } else if (do_unlock) { ret = unlock_service(creds, service_id); - } else if (do_add_domain) { - if (!service_domains) { - fprintf(stderr, "Error: --domains required with --add-domain\n"); - curl_global_cleanup(); - free_credentials(creds); - return 2; - } - ret = update_service_domains(creds, service_id, "add", service_domains); - } else if (do_remove_domain) { - if (!service_domains) { - fprintf(stderr, "Error: --domains required with --remove-domain\n"); - curl_global_cleanup(); - free_credentials(creds); - return 2; - } - ret = update_service_domains(creds, service_id, "remove", service_domains); - } else if (do_set_domains) { - if (!service_domains) { - fprintf(stderr, "Error: --domains required with --set-domains\n"); - curl_global_cleanup(); - free_credentials(creds); - return 2; - } - ret = update_service_domains(creds, service_id, "custom_domains", service_domains); } else if (do_auto_unfreeze) { ret = set_unfreeze_on_demand(creds, service_id, 1); } else if (do_no_auto_unfreeze) { ret = set_unfreeze_on_demand(creds, service_id, 0); - } else if (do_show_freeze_page) { - ret = set_show_freeze_page(creds, service_id, 1); - } else if (do_no_show_freeze_page) { - ret = set_show_freeze_page(creds, service_id, 0); } else if (do_resize) { if (vcpu < 1 || vcpu > 8) { fprintf(stderr, "Error: --vcpu must be 1-8 for resize\n"); @@ -10511,20 +9439,10 @@ int main(int argc, char *argv[]) { // - If provided via --bootstrap or --bootstrap-file, use it // - If omitted, API will use the stored encrypted bootstrap const char *bootstrap_to_use = bootstrap_file ? bootstrap_file : service_bootstrap; - ret = redeploy_service(creds, service_id, bootstrap_to_use, service_input_files, service_input_file_count); - // Free input file memory - for (int i = 0; i < service_input_file_count; i++) { - free(service_input_files[i].filename); - free(service_input_files[i].content_base64); - } + ret = redeploy_service(creds, service_id, bootstrap_to_use); } else if (do_execute) { - // Pass input files if provided (written to /tmp/input/ before command runs) - ret = execute_service(creds, service_id, execute_command, execute_timeout, service_input_files, service_input_file_count); - // Free input file memory - for (int i = 0; i < service_input_file_count; i++) { - free(service_input_files[i].filename); - free(service_input_files[i].content_base64); - } + // Default timeout 30 seconds (30000ms) + ret = execute_service(creds, service_id, execute_command, 30000); } else if (do_dump_bootstrap) { // Dump bootstrap script from /tmp/bootstrap.sh inside the service // This is useful for migrations - the bootstrap is stored at the same path on all instances @@ -10663,371 +9581,6 @@ int main(int argc, char *argv[]) { return ret; } - // Check for jobs command (async job management) - if (argc >= 2 && strcmp(argv[1], "jobs") == 0) { - const char *get_id = NULL; - const char *cancel_id = NULL; - - // Parse options - for (int i = 2; i < argc; i++) { - if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { - i++; - cli_public_key = argv[i]; - } else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) { - i++; - cli_secret_key = argv[i]; - } else if (strcmp(argv[i], "--account") == 0 && i + 1 < argc) { - i++; - cli_account_index = atoi(argv[i]); - } else if (strcmp(argv[i], "--get") == 0 && i + 1 < argc) { - i++; - get_id = argv[i]; - } else if (strcmp(argv[i], "--cancel") == 0 && i + 1 < argc) { - i++; - cancel_id = argv[i]; - } else if (strcmp(argv[i], "-l") == 0 || strcmp(argv[i], "--list") == 0) { - // --list is default, no-op - } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { - fprintf(stderr, "Usage: %s jobs [options]\n\n", argv[0]); - fprintf(stderr, "Commands:\n"); - fprintf(stderr, " (default) List all jobs\n"); - fprintf(stderr, " -l, --list List all jobs\n"); - fprintf(stderr, " --get ID Get job status and result\n"); - fprintf(stderr, " --cancel ID Cancel a running job\n"); - return 0; - } - } - - UnsandboxCredentials *creds = get_credentials(cli_public_key, cli_secret_key, cli_account_index); - if (!creds || !creds->public_key || strlen(creds->public_key) == 0) { - fprintf(stderr, "Error: API credentials required.\n"); - fprintf(stderr, " Set UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars, or\n"); - fprintf(stderr, " Use -p PUBLIC_KEY -k SECRET_KEY flags, or\n"); - fprintf(stderr, " Create ~/.unsandbox/accounts.csv with: public_key,secret_key\n"); - free_credentials(creds); - return 1; - } - - curl_global_init(CURL_GLOBAL_DEFAULT); - int ret = 0; - - if (cancel_id) { - // un jobs --cancel ID — DELETE /jobs/:id - char path[256], url[512]; - snprintf(path, sizeof(path), "/jobs/%s", cancel_id); - snprintf(url, sizeof(url), "%s%s", API_BASE, path); - - CURL *curl = curl_easy_init(); - if (!curl) { ret = 1; goto jobs_cleanup; } - - struct curl_slist *hdrs = NULL; - hdrs = add_hmac_auth_headers(hdrs, creds, "DELETE", path, NULL); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "un-cli/2.0"); - - CURLcode cres = curl_easy_perform(curl); - long hcode = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &hcode); - curl_slist_free_all(hdrs); - curl_easy_cleanup(curl); - - if (cres == CURLE_OK && (hcode == 200 || hcode == 204)) { - printf("Job %s cancelled\n", cancel_id); - } else { - fprintf(stderr, "Error: Failed to cancel job %s (HTTP %ld)\n", cancel_id, hcode); - ret = 1; - } - } else if (get_id) { - // un jobs --get ID — GET /jobs/:id - char path[256], url[512]; - snprintf(path, sizeof(path), "/jobs/%s", get_id); - snprintf(url, sizeof(url), "%s%s", API_BASE, path); - - CURL *curl = curl_easy_init(); - if (!curl) { ret = 1; goto jobs_cleanup; } - - struct ResponseBuffer resp = {0}; - resp.data = malloc(1); - resp.size = 0; - - struct curl_slist *hdrs = NULL; - hdrs = add_hmac_auth_headers(hdrs, creds, "GET", path, NULL); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "un-cli/2.0"); - - CURLcode cres = curl_easy_perform(curl); - long hcode = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &hcode); - curl_slist_free_all(hdrs); - curl_easy_cleanup(curl); - - if (cres != CURLE_OK || hcode != 200) { - fprintf(stderr, "Error: Failed to get job %s (HTTP %ld)\n", get_id, hcode); - free(resp.data); - ret = 1; - } else { - char *jid = extract_json_string(resp.data, "job_id"); - char *jstatus = extract_json_string(resp.data, "status"); - char *jlang = extract_json_string(resp.data, "language"); - char *jerror = extract_json_string(resp.data, "error"); - int64_t created = extract_json_number(resp.data, "created_at"); - int64_t completed = extract_json_number(resp.data, "completed_at"); - - printf("%-12s %s\n", "Job ID:", jid ? jid : get_id); - printf("%-12s %s\n", "Status:", jstatus ? jstatus : "unknown"); - if (jlang) printf("%-12s %s\n", "Language:", jlang); - if (created > 0) printf("%-12s %ld\n", "Created:", (long)created); - if (completed > 0) printf("%-12s %ld\n", "Completed:", (long)completed); - if (jerror) printf("%-12s %s\n", "Error:", jerror); - - // If completed, also show stdout/stderr - if (jstatus && strcmp(jstatus, "completed") == 0) { - char *jstdout = extract_json_string(resp.data, "stdout"); - char *jstderr = extract_json_string(resp.data, "stderr"); - if (jstdout && strlen(jstdout) > 0) { - printf("\n--- stdout ---\n%s", jstdout); - if (jstdout[strlen(jstdout)-1] != '\n') printf("\n"); - } - if (jstderr && strlen(jstderr) > 0) { - fprintf(stderr, "\n--- stderr ---\n%s", jstderr); - if (jstderr[strlen(jstderr)-1] != '\n') fprintf(stderr, "\n"); - } - free(jstdout); - free(jstderr); - } - - free(jid); - free(jstatus); - free(jlang); - free(jerror); - free(resp.data); - } - } else { - // un jobs --list (default) — GET /jobs - char url[256]; - snprintf(url, sizeof(url), "%s/jobs", API_BASE); - - CURL *curl = curl_easy_init(); - if (!curl) { ret = 1; goto jobs_cleanup; } - - struct ResponseBuffer resp = {0}; - resp.data = malloc(1); - resp.size = 0; - - struct curl_slist *hdrs = NULL; - hdrs = add_hmac_auth_headers(hdrs, creds, "GET", "/jobs", NULL); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "un-cli/2.0"); - - CURLcode cres = curl_easy_perform(curl); - long hcode = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &hcode); - curl_slist_free_all(hdrs); - curl_easy_cleanup(curl); - - if (cres != CURLE_OK || hcode != 200) { - fprintf(stderr, "Error: Failed to list jobs (HTTP %ld)\n", hcode); - free(resp.data); - ret = 1; - } else { - int count = count_json_array_objects(resp.data, "jobs"); - if (count <= 0) { - printf("No jobs found\n"); - } else { - printf("%-38s %-12s %-14s %s\n", "JOB ID", "STATUS", "LANGUAGE", "CREATED"); - printf("%-38s %-12s %-14s %s\n", "------", "------", "--------", "-------"); - - const char *jobs_start = strstr(resp.data, "\"jobs\":["); - if (jobs_start) { - const char *pos = jobs_start + 8; - for (int i = 0; i < count && pos; i++) { - pos = strchr(pos, '{'); - if (!pos) break; - char *jid = extract_json_string(pos, "job_id"); - char *jstatus = extract_json_string(pos, "status"); - char *jlang = extract_json_string(pos, "language"); - int64_t created = extract_json_number(pos, "created_at"); - printf("%-38s %-12s %-14s %ld\n", - jid ? jid : "?", - jstatus ? jstatus : "?", - jlang ? jlang : "?", - (long)created); - free(jid); - free(jstatus); - free(jlang); - pos = skip_json_object(pos); - } - } - } - free(resp.data); - } - } - -jobs_cleanup: - curl_global_cleanup(); - free_credentials(creds); - return ret; - } - - // Check for paas command (PaaS platform management) - if (argc >= 2 && strcmp(argv[1], "paas") == 0) { - // paas requires a sub-subcommand - if (argc < 3) { - fprintf(stderr, "Usage: %s paas [options]\n\n", argv[0]); - fprintf(stderr, "Commands:\n"); - fprintf(stderr, " logs Fetch or stream production logs\n"); - fprintf(stderr, "\nRun '%s paas -h' for help on a specific command.\n", argv[0]); - return 1; - } - - // paas logs - if (strcmp(argv[2], "logs") == 0) { - const char *source = "all"; - int lines = 100; - const char *since = NULL; - int since_explicit = 0; // whether user passed --since - const char *grep_filter = NULL; - const char *level = NULL; - int do_stream = 0; - int json_output = 0; - int show_help = 0; - - // Parse options (start at argv[3]) - for (int i = 3; i < argc; i++) { - if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { - i++; - cli_public_key = argv[i]; - } else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) { - i++; - cli_secret_key = argv[i]; - } else if (strcmp(argv[i], "--account") == 0 && i + 1 < argc) { - i++; - cli_account_index = atoi(argv[i]); - } else if (strcmp(argv[i], "--source") == 0 && i + 1 < argc) { - i++; - source = argv[i]; - } else if (strcmp(argv[i], "--all") == 0) { - source = "all"; - } else if (strcmp(argv[i], "--api") == 0) { - source = "api"; - } else if (strcmp(argv[i], "--portal") == 0) { - source = "portal"; - } else if (strcmp(argv[i], "--pool") == 0 && i + 1 < argc) { - i++; - static char pool_source[128]; - snprintf(pool_source, sizeof(pool_source), "pool/%s", argv[i]); - source = pool_source; - } else if (strcmp(argv[i], "--lines") == 0 && i + 1 < argc) { - i++; - lines = atoi(argv[i]); - if (lines < 1) lines = 1; - if (lines > 10000) lines = 10000; - } else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) { - i++; - lines = atoi(argv[i]); - if (lines < 1) lines = 1; - if (lines > 10000) lines = 10000; - } else if (strcmp(argv[i], "--since") == 0 && i + 1 < argc) { - i++; - since = argv[i]; - since_explicit = 1; - } else if (strcmp(argv[i], "--grep") == 0 && i + 1 < argc) { - i++; - grep_filter = argv[i]; - } else if ((strcmp(argv[i], "--level") == 0 || strcmp(argv[i], "-l") == 0) && i + 1 < argc) { - i++; - level = argv[i]; - } else if (strcmp(argv[i], "--follow") == 0 || strcmp(argv[i], "-f") == 0) { - do_stream = 1; - } else if (strcmp(argv[i], "--json") == 0) { - json_output = 1; - } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { - show_help = 1; - } - } - - if (show_help) { - fprintf(stderr, "Usage: %s paas logs [options]\n\n", argv[0]); - fprintf(stderr, "Fetch or stream PaaS production logs.\n\n"); - fprintf(stderr, "Sources:\n"); - fprintf(stderr, " --all All sources (default)\n"); - fprintf(stderr, " --api API server logs\n"); - fprintf(stderr, " --portal Portal server logs\n"); - fprintf(stderr, " --pool NODE Pool node logs (e.g., cammy, ai)\n"); - fprintf(stderr, " --source SOURCE Source string (api, portal, pool/cammy, all)\n"); - fprintf(stderr, "\nOptions:\n"); - fprintf(stderr, " --lines N, -n N Number of lines (default: 100, max: 10000)\n"); - fprintf(stderr, " --since TIME Time window: 1m, 5m, 15m, 1h, 6h, 1d (default: 5m)\n"); - fprintf(stderr, " --grep PATTERN Filter log lines by pattern\n"); - fprintf(stderr, " --level LVL, -l Min log level: debug, info, notice, warning, err, crit\n"); - fprintf(stderr, " --follow, -f Follow logs in real-time (Ctrl+C to stop)\n"); - fprintf(stderr, " --json Output raw JSON response\n"); - fprintf(stderr, " -p KEY Public key\n"); - fprintf(stderr, " -k KEY Secret key\n"); - fprintf(stderr, " -h Show this help\n"); - fprintf(stderr, "\nExamples:\n"); - fprintf(stderr, " %s paas logs # last 100 lines from all sources\n", argv[0]); - fprintf(stderr, " %s paas logs --api --lines 500 # last 500 API log lines\n", argv[0]); - fprintf(stderr, " %s paas logs --portal --grep error # portal logs matching 'error'\n", argv[0]); - fprintf(stderr, " %s paas logs --pool cammy --since 1h # cammy pool logs from last hour\n", argv[0]); - fprintf(stderr, " %s paas logs -l warning # warnings and above from all sources\n", argv[0]); - fprintf(stderr, " %s paas logs --follow # follow all logs in real-time\n", argv[0]); - fprintf(stderr, " %s paas logs --follow -l warning # follow warnings+ in real-time\n", argv[0]); - fprintf(stderr, "\nRequires a partner API key with log access enabled.\n"); - return 0; - } - - // Get credentials - UnsandboxCredentials *creds = get_credentials(cli_public_key, cli_secret_key, cli_account_index); - - if (!creds || !creds->public_key || strlen(creds->public_key) == 0) { - fprintf(stderr, "Error: API credentials required.\n"); - fprintf(stderr, " Set UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars, or\n"); - fprintf(stderr, " Use -p PUBLIC_KEY -k SECRET_KEY flags, or\n"); - fprintf(stderr, " Create ~/.unsandbox/accounts.csv with: public_key,secret_key\n"); - free_credentials(creds); - return 1; - } - - // Resolve since: explicit --since > cursor > default 5m - if (!since_explicit) { - char *cursor = read_log_cursor(); - if (cursor) { - since = cursor; - } else { - since = "5m"; - } - } - - curl_global_init(CURL_GLOBAL_DEFAULT); - int ret; - if (do_stream) { - ret = stream_paas_logs(creds, source, grep_filter, level); - } else { - ret = fetch_paas_logs(creds, source, lines, since, grep_filter, json_output, level); - } - curl_global_cleanup(); - free_credentials(creds); - return ret; - } - - // Unknown paas subcommand - fprintf(stderr, "Error: Unknown paas command '%s'\n", argv[2]); - fprintf(stderr, "Run '%s paas' for available commands.\n", argv[0]); - return 1; - } - // Check for session command if (argc >= 2 && strcmp(argv[1], "session") == 0) { int audit_history = 0; @@ -11565,7 +10118,6 @@ jobs_cleanup: strcmp(status, "running") == 0); if (need_poll) { - fprintf(stderr, "job %s\n", job_id); // Free initial response, poll for final result free(response.data); final_data = poll_job_status(creds, job_id); diff --git a/clients/c/src/un.h b/clients/c/src/un.h index 8d9592f..3db4f69 100644 --- a/clients/c/src/un.h +++ b/clients/c/src/un.h @@ -428,34 +428,6 @@ char *unsandbox_image_clone( const char *description, /* optional description */ const char *public_key, const char *secret_key); -/* ============================================================================ - * PaaS Logs (2) - * ============================================================================ */ - -/* Fetch batch logs from portal. Returns JSON string (caller must free). - * source: "all", "api", "portal", "pool/cammy", "pool/ai" - * lines: number of lines (1-10000) - * since: time window ("1m", "5m", "1h", "1d") - * grep: optional filter pattern (NULL for no filter) */ -char *unsandbox_logs_fetch( - const char *source, - int lines, - const char *since, - const char *grep, - const char *public_key, const char *secret_key); - -/* Stream logs via SSE. Blocks until interrupted or server closes. - * Calls callback for each log line received. - * Returns 0 on clean shutdown, 1 on error. */ -typedef void (*unsandbox_log_callback_t)(const char *source, const char *line, void *userdata); - -int unsandbox_logs_stream( - const char *source, - const char *grep, - unsandbox_log_callback_t callback, - void *userdata, - const char *public_key, const char *secret_key); - /* ============================================================================ * Key Validation (1) * ============================================================================ */ diff --git a/clients/c/tests/test_account_flag.sh b/clients/c/tests/test_account_flag.sh deleted file mode 100755 index 966be65..0000000 --- a/clients/c/tests/test_account_flag.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -# Integration test: --account N flag must take priority over env vars -# -# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY (real credentials) -# Run: make test-integration OR bash tests/test_account_flag.sh -# -# The defect this guards against: get_credentials() checked env vars before -# account_index, so --account N was silently ignored when env vars existed. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -UN_BIN="$SCRIPT_DIR/../un" - -RED='\033[31m' -GREEN='\033[32m' -NC='\033[0m' - -pass=0 -fail=0 - -check() { - local desc="$1" result="$2" - if [ "$result" = "pass" ]; then - printf " ${GREEN}✓${NC} %s\n" "$desc" - pass=$((pass + 1)) - else - printf " ${RED}✗${NC} %s\n" "$desc" - fail=$((fail + 1)) - fi -} - -# Require real credentials to be available -if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then - echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" - exit 0 -fi - -if [ ! -x "$UN_BIN" ]; then - echo "FAIL: UN binary not found at $UN_BIN — run make first" - exit 1 -fi - -REAL_PK="$UNSANDBOX_PUBLIC_KEY" -REAL_SK="$UNSANDBOX_SECRET_KEY" - -# Temporary HOME with accounts.csv: -# index 0: garbage credentials (will always 401) -# index 1: real credentials (will succeed) -TMPHOME="$(mktemp -d)" -mkdir -p "$TMPHOME/.unsandbox" -trap 'rm -rf "$TMPHOME"' EXIT - -cat > "$TMPHOME/.unsandbox/accounts.csv" <&1 || true) - -if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then - check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "pass" -else - check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "fail" - echo " output: $OUT" -fi - -# --- Test 2: --account 0 should use CSV row 0 (garbage creds) → 401 --- -# Even though real env vars are set, explicit --account 0 should pick garbage creds -OUT=$(HOME="$TMPHOME" \ - UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ - UNSANDBOX_SECRET_KEY="$REAL_SK" \ - "$UN_BIN" --account 0 key 2>&1 || true) - -if echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|invalid key"; then - check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "pass" -else - check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "fail" - echo " output: $OUT" -fi - -# --- Test 3: no --account flag, real env vars → env vars win over garbage CSV row 0 --- -OUT=$(HOME="$TMPHOME" \ - UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ - UNSANDBOX_SECRET_KEY="$REAL_SK" \ - "$UN_BIN" key 2>&1 || true) - -if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then - check "No --account flag: env vars used, succeeds" "pass" -else - check "No --account flag: env vars used, succeeds" "fail" - echo " output: $OUT" -fi - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -printf "Passed: ${GREEN}%d${NC} Failed: ${RED}%d${NC}\n" "$pass" "$fail" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -[ "$fail" -eq 0 ] diff --git a/clients/clojure/sync/src/un.clj b/clients/clojure/sync/src/un.clj index a733b60..879d156 100644 --- a/clients/clojure/sync/src/un.clj +++ b/clients/clojure/sync/src/un.clj @@ -118,51 +118,17 @@ (or (second (re-find pattern-str json-str)) (second (re-find pattern-num json-str))))) -(def ^:dynamic *account-index* nil) - -(defn load-accounts-csv [path index] - (when (.exists (io/file path)) - (try - (let [lines (str/split-lines (slurp path)) - rows (->> lines - (filter #(and (> (count %) 0) - (not (str/starts-with? % "#")))) - (map #(str/split % #"," 2)) - (filter #(>= (count %) 2)))] - (when (< index (count rows)) - (let [row (nth rows index)] - [(str/trim (first row)) (str/trim (second row))]))) - (catch Exception _ nil)))) - (defn get-api-keys [] (let [public-key (System/getenv "UNSANDBOX_PUBLIC_KEY") secret-key (System/getenv "UNSANDBOX_SECRET_KEY") - api-key (System/getenv "UNSANDBOX_API_KEY") - home (System/getenv "HOME") - account-idx (or *account-index* 0)] + api-key (System/getenv "UNSANDBOX_API_KEY")] (cond - ;; --account N: load row N from accounts.csv, bypasses env vars - (some? *account-index*) - (or (load-accounts-csv (str home "/.unsandbox/accounts.csv") account-idx) - (load-accounts-csv "./accounts.csv" account-idx) - (do - (binding [*out* *err*] - (println (str "Error: account " account-idx " not found in accounts.csv"))) - (System/exit 1))) - ;; env vars (and public-key secret-key) [public-key secret-key] api-key [api-key nil] - ;; accounts.csv fallback (row 0 or UNSANDBOX_ACCOUNT) - :else - (let [row-idx (if-let [acc (System/getenv "UNSANDBOX_ACCOUNT")] - (try (Integer/parseInt acc) (catch Exception _ 0)) - 0)] - (or (load-accounts-csv (str home "/.unsandbox/accounts.csv") row-idx) - (load-accounts-csv "./accounts.csv" row-idx) - (do + :else (do (binding [*out* *err*] (println "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)")) - (System/exit 1))))))) + (System/exit 1))))) (defn get-api-key [] (first (get-api-keys))) @@ -231,101 +197,13 @@ (defn curl-delete [api-key endpoint] (let [[public-key secret-key] (get-api-keys) auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "") - args (concat ["curl" "-s" "-w" "\n%{http_code}" "-X" "DELETE" + args (concat ["curl" "-s" "-X" "DELETE" (str "https://api.unsandbox.com" endpoint)] auth-headers) result (:out (apply sh args))] (check-clock-drift-error result) result)) -(defn extract-http-code [response] - "Extract HTTP code from response with status code appended" - (let [lines (str/split response #"\n") - last-line (last lines)] - (try - (Integer/parseInt (str/trim last-line)) - (catch Exception _ 0)))) - -(defn extract-body [response] - "Extract body from response (everything except last line which is status code)" - (let [lines (str/split response #"\n")] - (str/join "\n" (butlast lines)))) - -(defn handle-sudo-challenge - "Handle 428 sudo OTP challenge - prompts user for OTP and retries the request" - [response-data public-key secret-key method endpoint body] - (let [challenge-id (extract-field "challenge_id" response-data)] - (binding [*out* *err*] - (println (str yellow "Confirmation required. Check your email for a one-time code." reset))) - (print "Enter OTP: ") - (flush) - (let [otp (str/trim (or (read-line) ""))] - (when (empty? otp) - (binding [*out* *err*] - (println "Error: Operation cancelled")) - (System/exit 1)) - ;; Retry the request with sudo headers - (let [auth-headers (build-auth-headers public-key secret-key method endpoint (or body "")) - sudo-headers ["-H" (str "X-Sudo-OTP: " otp) - "-H" (str "X-Sudo-Challenge: " (or challenge-id ""))] - content-type-headers (if body ["-H" "Content-Type: application/json"] []) - body-args (if body ["-d" body] []) - method-args (cond - (= method "DELETE") ["-X" "DELETE"] - (= method "POST") ["-X" "POST"] - :else ["-X" method]) - args (concat ["curl" "-s"] - method-args - [(str "https://api.unsandbox.com" endpoint)] - auth-headers - sudo-headers - content-type-headers - body-args) - {:keys [out]} (apply sh args) - http-code (extract-http-code out)] - (if (and (>= http-code 200) (< http-code 300)) - {:success true :response (extract-body out)} - (do - (binding [*out* *err*] - (println (str red "Error: HTTP " http-code reset)) - (println (extract-body out))) - {:success false})))))) - -(defn curl-delete-with-sudo [api-key endpoint] - "DELETE request that handles 428 sudo OTP challenge" - (let [[public-key secret-key] (get-api-keys) - auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "") - args (concat ["curl" "-s" "-w" "\n%{http_code}" "-X" "DELETE" - (str "https://api.unsandbox.com" endpoint)] - auth-headers) - result (:out (apply sh args)) - http-code (extract-http-code result) - body (extract-body result)] - (check-clock-drift-error body) - (if (= http-code 428) - (handle-sudo-challenge body public-key secret-key "DELETE" endpoint nil) - {:success (and (>= http-code 200) (< http-code 300)) :response body :http-code http-code}))) - -(defn curl-post-with-sudo [api-key endpoint json-data] - "POST request that handles 428 sudo OTP challenge" - (let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".json") - [public-key secret-key] (get-api-keys) - auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)] - (spit tmp-file json-data) - (let [args (concat ["curl" "-s" "-w" "\n%{http_code}" "-X" "POST" - (str "https://api.unsandbox.com" endpoint) - "-H" "Content-Type: application/json"] - auth-headers - ["-d" (str "@" tmp-file)]) - {:keys [out]} (apply sh args)] - (io/delete-file tmp-file true) - (let [http-code (extract-http-code out) - body (extract-body out)] - (check-clock-drift-error body) - (if (= http-code 428) - (handle-sudo-challenge body public-key secret-key "POST" endpoint json-data) - {:success (and (>= http-code 200) (< http-code 300)) :response body :http-code http-code}))))) - (defn curl-put-text [endpoint body] (let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".txt") [public-key secret-key] (get-api-keys) @@ -538,13 +416,9 @@ :wake (do (curl-post api-key (str "/services/" sid "/unfreeze") "{}") (println (str green "Service unfreezing: " sid reset))) - :destroy (let [result (curl-delete-with-sudo api-key (str "/services/" sid))] - (if (:success result) - (println (str green "Service destroyed: " sid reset)) - (do - (binding [*out* *err*] - (println (str red "Error destroying service" reset))) - (System/exit 1)))) + :destroy (do + (curl-delete api-key (str "/services/" sid)) + (println (str green "Service destroyed: " sid reset))) :resize (when sid (if (or (nil? vcpu) (< vcpu 1) (> vcpu 8)) (do @@ -712,14 +586,9 @@ (println (curl-get api-key (str "/images/" id))))) (defn image-delete [id] - (let [api-key (get-api-key) - result (curl-delete-with-sudo api-key (str "/images/" id))] - (if (:success result) - (println (str green "Image deleted: " id reset)) - (do - (binding [*out* *err*] - (println (str red "Error deleting image" reset))) - (System/exit 1))))) + (let [api-key (get-api-key)] + (curl-delete api-key (str "/images/" id)) + (println (str green "Image deleted: " id reset)))) (defn image-lock [id] (let [api-key (get-api-key)] @@ -727,14 +596,9 @@ (println (str green "Image locked: " id reset)))) (defn image-unlock [id] - (let [api-key (get-api-key) - result (curl-post-with-sudo api-key (str "/images/" id "/unlock") "{}")] - (if (:success result) - (println (str green "Image unlocked: " id reset)) - (do - (binding [*out* *err*] - (println (str red "Error unlocking image" reset))) - (System/exit 1))))) + (let [api-key (get-api-key)] + (curl-post api-key (str "/images/" id "/unlock") "{}") + (println (str green "Image unlocked: " id reset)))) (defn image-publish [source-id source-type name] (let [api-key (get-api-key) @@ -766,178 +630,6 @@ (println (str green "Image cloned" reset)) (println (curl-post api-key (str "/images/" id "/clone") json)))) -;; Image access management functions -(defn image-grant-access [id trusted-key] - (let [api-key (get-api-key) - json (str "{\"trusted_api_key\":\"" trusted-key "\"}")] - (curl-post api-key (str "/images/" id "/grant-access") json) - (println (str green "Access granted to: " trusted-key reset)))) - -(defn image-revoke-access [id trusted-key] - (let [api-key (get-api-key) - json (str "{\"trusted_api_key\":\"" trusted-key "\"}")] - (curl-post api-key (str "/images/" id "/revoke-access") json) - (println (str green "Access revoked from: " trusted-key reset)))) - -(defn image-list-trusted [id] - (let [api-key (get-api-key)] - (println (curl-get api-key (str "/images/" id "/trusted"))))) - -(defn image-transfer [id to-key] - (let [api-key (get-api-key) - json (str "{\"to_api_key\":\"" to-key "\"}")] - (curl-post api-key (str "/images/" id "/transfer") json) - (println (str green "Image transferred to: " to-key reset)))) - -;; Snapshot functions -(defn snapshot-list [] - (let [api-key (get-api-key)] - (println (curl-get api-key "/snapshots")))) - -(defn snapshot-info [id] - (let [api-key (get-api-key)] - (println (curl-get api-key (str "/snapshots/" id))))) - -(defn snapshot-session [session-id name hot] - (let [api-key (get-api-key) - json (str "{\"session_id\":\"" session-id "\"" - (if name (str ",\"name\":\"" (escape-json name) "\"") "") - (if hot ",\"hot\":true" "") - "}")] - (println (str green "Snapshot created" reset)) - (println (curl-post api-key "/snapshots" json)))) - -(defn snapshot-service [service-id name hot] - (let [api-key (get-api-key) - json (str "{\"service_id\":\"" service-id "\"" - (if name (str ",\"name\":\"" (escape-json name) "\"") "") - (if hot ",\"hot\":true" "") - "}")] - (println (str green "Snapshot created" reset)) - (println (curl-post api-key "/snapshots" json)))) - -(defn snapshot-restore [id] - (let [api-key (get-api-key)] - (curl-post api-key (str "/snapshots/" id "/restore") "{}") - (println (str green "Snapshot restored: " id reset)))) - -(defn snapshot-delete [id] - (let [api-key (get-api-key) - result (curl-delete-with-sudo api-key (str "/snapshots/" id))] - (if (:success result) - (println (str green "Snapshot deleted: " id reset)) - (do - (binding [*out* *err*] - (println (str red "Error deleting snapshot" reset))) - (System/exit 1))))) - -(defn snapshot-lock [id] - (let [api-key (get-api-key)] - (curl-post api-key (str "/snapshots/" id "/lock") "{}") - (println (str green "Snapshot locked: " id reset)))) - -(defn snapshot-unlock [id] - (let [api-key (get-api-key) - result (curl-post-with-sudo api-key (str "/snapshots/" id "/unlock") "{}")] - (if (:success result) - (println (str green "Snapshot unlocked: " id reset)) - (do - (binding [*out* *err*] - (println (str red "Error unlocking snapshot" reset))) - (System/exit 1))))) - -(defn snapshot-clone [id clone-type name ports shell] - (let [api-key (get-api-key) - json (str "{\"clone_type\":\"" clone-type "\"" - (if name (str ",\"name\":\"" (escape-json name) "\"") "") - (if ports (str ",\"ports\":[" ports "]") "") - (if shell (str ",\"shell\":\"" shell "\"") "") - "}")] - (println (str green "Snapshot cloned" reset)) - (println (curl-post api-key (str "/snapshots/" id "/clone") json)))) - -(defn snapshot-command [action id name ports shell hot] - (case action - :list (snapshot-list) - :info (snapshot-info id) - :session (snapshot-session id name hot) - :service (snapshot-service id name hot) - :restore (snapshot-restore id) - :delete (snapshot-delete id) - :lock (snapshot-lock id) - :unlock (snapshot-unlock id) - :clone (snapshot-clone id "session" name ports shell))) - -;; Session additional functions -(defn session-info [id] - (let [api-key (get-api-key)] - (println (curl-get api-key (str "/sessions/" id))))) - -(defn session-boost [id vcpu] - (let [api-key (get-api-key) - json (str "{\"vcpu\":" vcpu "}")] - (curl-patch api-key (str "/sessions/" id) json) - (println (str green "Session boosted to " vcpu " vCPU" reset)))) - -(defn session-unboost [id] - (let [api-key (get-api-key) - json "{\"vcpu\":1}"] - (curl-patch api-key (str "/sessions/" id) json) - (println (str green "Session unboosted to 1 vCPU" reset)))) - -(defn session-execute [id command] - (let [api-key (get-api-key) - json (str "{\"command\":\"" (escape-json command) "\"}") - response (curl-post api-key (str "/sessions/" id "/execute") json) - stdout-val (extract-field "stdout" response)] - (when stdout-val - (print (str blue (unescape-json stdout-val) reset)) - (flush)))) - -;; Service additional functions -(defn service-lock [id] - (let [api-key (get-api-key)] - (curl-post api-key (str "/services/" id "/lock") "{}") - (println (str green "Service locked: " id reset)))) - -(defn service-unlock [id] - (let [api-key (get-api-key) - result (curl-post-with-sudo api-key (str "/services/" id "/unlock") "{}")] - (if (:success result) - (println (str green "Service unlocked: " id reset)) - (do - (binding [*out* *err*] - (println (str red "Error unlocking service" reset))) - (System/exit 1))))) - -(defn service-redeploy [id bootstrap] - (let [api-key (get-api-key) - json (if bootstrap - (str "{\"bootstrap\":\"" (escape-json bootstrap) "\"}") - "{}")] - (curl-post api-key (str "/services/" id "/redeploy") json) - (println (str green "Service redeploying: " id reset)))) - -;; PaaS logs functions -(defn logs-fetch [source lines since grep-pattern] - (let [api-key (get-api-key) - params (str "?source=" (or source "all") - "&lines=" (or lines 100) - (if since (str "&since=" since) "") - (if grep-pattern (str "&grep=" (java.net.URLEncoder/encode grep-pattern "UTF-8")) ""))] - (println (curl-get api-key (str "/logs" params))))) - -;; Utility functions -(defn health-check [] - (try - (let [result (:out (sh "curl" "-s" "https://api.unsandbox.com/health"))] - (println result) - (str/includes? result "ok")) - (catch Exception _ false))) - -(defn version [] - "4.2.0") - (defn image-command [action id source-type visibility name ports] (case action :list (image-list) @@ -1030,22 +722,6 @@ (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 service-envs service-env-file service-unfreeze-on-demand key-extend image-action image-id image-source-type image-visibility image-name image-ports :image) - (= (first args) "snapshot") - (let [rest-args (rest args)] - (cond - (empty? rest-args) (do (snapshot-list) (System/exit 0)) - (= (first rest-args) "--list") (do (snapshot-list) (System/exit 0)) - (= (first rest-args) "-l") (do (snapshot-list) (System/exit 0)) - (= (first rest-args) "--info") (do (snapshot-info (second rest-args)) (System/exit 0)) - (= (first rest-args) "--session") (do (snapshot-session (second rest-args) nil false) (System/exit 0)) - (= (first rest-args) "--service") (do (snapshot-service (second rest-args) nil false) (System/exit 0)) - (= (first rest-args) "--restore") (do (snapshot-restore (second rest-args)) (System/exit 0)) - (= (first rest-args) "--delete") (do (snapshot-delete (second rest-args)) (System/exit 0)) - (= (first rest-args) "--lock") (do (snapshot-lock (second rest-args)) (System/exit 0)) - (= (first rest-args) "--unlock") (do (snapshot-unlock (second rest-args)) (System/exit 0)) - (= (first rest-args) "--clone") (do (snapshot-clone (second rest-args) "session" nil nil nil) (System/exit 0)) - :else (do (println "Error: Unknown snapshot action") (System/exit 1)))) - ;; Image options (and (= mode :image) (or (= (first args) "--list") (= (first args) "-l"))) (recur (rest args) file env-vars artifacts out-dir network vcpu session-action session-id session-shell session-input-files @@ -1250,14 +926,4 @@ (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 service-envs service-env-file service-unfreeze-on-demand key-extend image-action image-id image-source-type image-visibility image-name image-ports mode)))) -(let [raw-args *command-line-args* - account-val (second (drop-while #(not= % "--account") raw-args)) - account-idx (when account-val - (try (Integer/parseInt account-val) (catch Exception _ nil))) - filtered-args (loop [in raw-args out []] - (cond - (empty? in) out - (= (first in) "--account") (recur (drop 2 in) out) - :else (recur (rest in) (conj out (first in)))))] - (binding [*account-index* account-idx] - (parse-args filtered-args))) +(parse-args *command-line-args*) diff --git a/clients/cobol/sync/src/un.cob b/clients/cobol/sync/src/un.cob index 8aae80a..30a6cba 100644 --- a/clients/cobol/sync/src/un.cob +++ b/clients/cobol/sync/src/un.cob @@ -45,21 +45,15 @@ SELECT SOURCE-FILE ASSIGN TO WS-FILENAME ORGANIZATION IS LINE SEQUENTIAL FILE STATUS IS WS-FILE-STATUS. - SELECT CRED-FILE ASSIGN TO "/tmp/unsb_creds.txt" - ORGANIZATION IS LINE SEQUENTIAL - FILE STATUS IS WS-CRED-STATUS. DATA DIVISION. FILE SECTION. FD SOURCE-FILE. 01 SOURCE-LINE PIC X(1024). - FD CRED-FILE. - 01 CRED-LINE PIC X(512). WORKING-STORAGE SECTION. 01 WS-FILENAME PIC X(256). 01 WS-FILE-STATUS PIC XX. - 01 WS-CRED-STATUS PIC XX. 01 WS-API-KEY PIC X(256). 01 WS-PUBLIC-KEY PIC X(256). 01 WS-SECRET-KEY PIC X(256). @@ -101,25 +95,12 @@ 01 WS-ARG5 PIC X(256). 01 WS-UNFREEZE-ON-DEMAND PIC X(8). 01 WS-UOD-ENABLED PIC X(8). - 01 WS-TYPE PIC X(32). - 01 WS-SHELL PIC X(32). - 01 WS-ACCOUNT-INDEX PIC S9(4) VALUE -1. - 01 WS-ACCOUNT-STR PIC X(16). - 01 WS-ACCT-POS PIC 9(4) VALUE 0. - 01 WS-ERROR-MSG PIC X(256). PROCEDURE DIVISION. MAIN-PROCEDURE. - * Get first command line argument + * Get command line argument (first argument) ACCEPT WS-ARG1 FROM COMMAND-LINE. - * Pre-scan: handle --account N global flag before subcommand - IF WS-ARG1 = "--account" - ACCEPT WS-ACCOUNT-STR FROM ARGUMENT-VALUE - MOVE FUNCTION NUMVAL(WS-ACCOUNT-STR) TO WS-ACCOUNT-INDEX - ACCEPT WS-ARG1 FROM ARGUMENT-VALUE - END-IF. - IF WS-ARG1 = SPACES DISPLAY "Usage: un.cob " UPON SYSERR DISPLAY " un.cob session [options]" UPON SYSERR @@ -154,104 +135,12 @@ STOP RUN END-IF. - IF WS-ARG1 = "snapshot" - PERFORM HANDLE-SNAPSHOT - STOP RUN - END-IF. - * Default: execute command MOVE WS-ARG1 TO WS-FILENAME. PERFORM HANDLE-EXECUTE. STOP RUN. - GET-CREDENTIALS. - * If already set, return immediately - IF WS-PUBLIC-KEY NOT = SPACES AND WS-SECRET-KEY NOT = SPACES - EXIT PARAGRAPH - END-IF. - - * Build shell script to resolve credentials with full priority - IF WS-ACCOUNT-INDEX >= 0 - MOVE WS-ACCOUNT-INDEX TO WS-ACCOUNT-STR - STRING "IDX=" FUNCTION TRIM(WS-ACCOUNT-STR) "; " - "PK=''; SK=''; CNT=-1; " - "for CSV in \"$HOME/.unsandbox/accounts.csv\" " - "\"./accounts.csv\"; do " - "[ -f \"$CSV\" ] || continue; " - "while IFS= read -r line || [ -n \"$line\" ]; do " - "case \"$line\" in \"#\"*|\"\"|\" \"*) continue ;; esac; " - "CNT=$((CNT+1)); " - "if [ \"$CNT\" -eq \"$IDX\" ]; then " - "PK=$(echo \"$line\" | cut -d',' -f1 | tr -d ' '); " - "SK=$(echo \"$line\" | cut -d',' -f2 | tr -d ' '); " - "break 2; fi; " - "done < \"$CSV\"; done; " - "if [ -z \"$PK\" ]; then " - "echo -e '\\x1b[31mError: Account index " - FUNCTION TRIM(WS-ACCOUNT-STR) - " not found in accounts.csv\\x1b[0m' >&2; exit 1; fi; " - "printf '%s\\n%s\\n' \"$PK\" \"$SK\" " - "> /tmp/unsb_creds.txt" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING - ELSE - STRING "PK=\"$UNSANDBOX_PUBLIC_KEY\"; " - "SK=\"$UNSANDBOX_SECRET_KEY\"; " - "if [ -z \"$PK\" ]; then PK=\"$UNSANDBOX_API_KEY\"; SK=''; fi; " - "if [ -z \"$PK\" ]; then " - "IDX=\"${UNSANDBOX_ACCOUNT:-0}\"; " - "CNT=-1; " - "for CSV in \"$HOME/.unsandbox/accounts.csv\" " - "\"./accounts.csv\"; do " - "[ -f \"$CSV\" ] || continue; " - "while IFS= read -r line || [ -n \"$line\" ]; do " - "case \"$line\" in \"#\"*|\"\"|\" \"*) continue ;; esac; " - "CNT=$((CNT+1)); " - "if [ \"$CNT\" -eq \"$IDX\" ]; then " - "PK=$(echo \"$line\" | cut -d',' -f1 | tr -d ' '); " - "SK=$(echo \"$line\" | cut -d',' -f2 | tr -d ' '); " - "break 2; fi; " - "done < \"$CSV\"; done; fi; " - "if [ -z \"$PK\" ]; then " - "echo -e '\\x1b[31mError: No credentials found\\x1b[0m' " - ">&2; exit 1; fi; " - "printf '%s\\n%s\\n' \"$PK\" \"$SK\" " - "> /tmp/unsb_creds.txt" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING - END-IF. - - CALL "SYSTEM" USING WS-CURL-CMD - RETURNING WS-EXIT-CODE. - IF WS-EXIT-CODE NOT = 0 - MOVE WS-EXIT-CODE TO RETURN-CODE - STOP RUN - END-IF. - - * Read resolved credentials from temp file - MOVE SPACES TO WS-PUBLIC-KEY. - MOVE SPACES TO WS-SECRET-KEY. - OPEN INPUT CRED-FILE. - IF WS-CRED-STATUS = "00" - READ CRED-FILE INTO WS-PUBLIC-KEY - READ CRED-FILE INTO WS-SECRET-KEY - CLOSE CRED-FILE - END-IF. - - IF WS-PUBLIC-KEY = SPACES - DISPLAY "Error: Could not resolve credentials" - UPON SYSERR - MOVE 1 TO RETURN-CODE - STOP RUN - END-IF. - HANDLE-EXECUTE. - * Get credentials - PERFORM GET-CREDENTIALS. - IF WS-API-KEY = SPACES - MOVE WS-PUBLIC-KEY TO WS-API-KEY - END-IF. - * Check if file exists OPEN INPUT SOURCE-FILE. IF WS-FILE-STATUS NOT = "00" @@ -272,14 +161,25 @@ STOP RUN END-IF. + * Get API key from environment + ACCEPT WS-API-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY". + + IF WS-API-KEY = SPACES + DISPLAY "Error: UNSANDBOX_API_KEY not set" UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN + END-IF. + * Use curl to make request PERFORM MAKE-EXECUTE-REQUEST. HANDLE-SESSION. - * Get credentials - PERFORM GET-CREDENTIALS. + * Get API key + ACCEPT WS-API-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY". IF WS-API-KEY = SPACES - MOVE WS-PUBLIC-KEY TO WS-API-KEY + DISPLAY "Error: UNSANDBOX_API_KEY not set" UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN END-IF. * Initialize session parameters @@ -302,8 +202,27 @@ END-IF. HANDLE-SERVICE. - * Get credentials - PERFORM GET-CREDENTIALS. + * Get API keys (try new format first, fall back to old) + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_PUBLIC_KEY". + IF WS-PUBLIC-KEY NOT = SPACES + ACCEPT WS-SECRET-KEY FROM ENVIRONMENT "UNSANDBOX_SECRET_KEY" + IF WS-SECRET-KEY = SPACES + 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. * Initialize service parameters MOVE SPACES TO WS-NAME. @@ -406,7 +325,26 @@ END-IF. MAKE-EXECUTE-REQUEST. - * Credentials already resolved by caller (GET-CREDENTIALS) + * Get public/secret keys with fallback + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_PUBLIC_KEY". + IF WS-PUBLIC-KEY NOT = SPACES + ACCEPT WS-SECRET-KEY FROM ENVIRONMENT "UNSANDBOX_SECRET_KEY" + IF WS-SECRET-KEY = SPACES + DISPLAY "Error: UNSANDBOX_SECRET_KEY not set" + UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN + END-IF + ELSE + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY" + IF WS-PUBLIC-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-PUBLIC-KEY TO WS-SECRET-KEY + END-IF. * Build curl command using shell with HMAC signature STRING "TS=$(date +%s); " @@ -605,61 +543,13 @@ CALL "SYSTEM" USING WS-CURL-CMD. SERVICE-DESTROY. - STRING "TS=$(date +%s); " - "SIG=$(echo -n \"$TS:DELETE:/services/" - FUNCTION TRIM(WS-ID) - ":\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "RESP=$(curl -s -w '\\n%{http_code}' -X DELETE " - "'https://api.unsandbox.com/services/" - FUNCTION TRIM(WS-ID) - "' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS " - "-H 'X-Signature: '$SIG); " - "HTTP_CODE=$(echo \"$RESP\" | tail -n1); " - "BODY=$(echo \"$RESP\" | sed '$d'); " - "if [ \"$HTTP_CODE\" = \"428\" ]; then " - "CHALLENGE_ID=$(echo \"$BODY\" | jq -r '.challenge_id // empty'); " - "echo -e '\\x1b[33mConfirmation required. Check your email " - "for a one-time code.\\x1b[0m' >&2; " - "echo -n 'Enter OTP: ' >&2; read OTP; " - "if [ -z \"$OTP\" ]; then " - "echo -e '\\x1b[31mError: Operation cancelled\\x1b[0m' >&2; " - "exit 1; fi; " - "TS2=$(date +%s); " - "SIG2=$(echo -n \"$TS2:DELETE:/services/" - FUNCTION TRIM(WS-ID) - ":\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "RESP2=$(curl -s -w '\\n%{http_code}' -X DELETE " - "'https://api.unsandbox.com/services/" - FUNCTION TRIM(WS-ID) - "' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS2 " - "-H 'X-Signature: '$SIG2 " - "-H 'X-Sudo-OTP: '$OTP " - "-H 'X-Sudo-Challenge: '$CHALLENGE_ID); " - "HTTP_CODE2=$(echo \"$RESP2\" | tail -n1); " - "if [ \"$HTTP_CODE2\" = \"200\" ] || " - "[ \"$HTTP_CODE2\" = \"204\" ]; then " - "echo -e '\\x1b[32mService destroyed: " - FUNCTION TRIM(WS-ID) "\\x1b[0m'; " - "else echo \"$RESP2\" | sed '$d' | jq . 2>/dev/null || " - "echo \"$RESP2\" | sed '$d'; exit 1; fi; " - "elif [ \"$HTTP_CODE\" = \"200\" ] || " - "[ \"$HTTP_CODE\" = \"204\" ]; then " - "echo -e '\\x1b[32mService destroyed: " - FUNCTION TRIM(WS-ID) "\\x1b[0m'; " - "else echo \"$BODY\" | jq . 2>/dev/null || " - "echo \"$BODY\"; exit 1; fi" + STRING "curl -s -X DELETE " + "https://api.unsandbox.com/services/" + FUNCTION TRIM(WS-ID) " " + "-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY) + "' >/dev/null && " + "echo -e '\x1b[32mService destroyed: " + FUNCTION TRIM(WS-ID) "\x1b[0m'" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING. @@ -998,10 +888,12 @@ CALL "SYSTEM" USING WS-CURL-CMD. HANDLE-KEY. - * Get credentials - PERFORM GET-CREDENTIALS. + * Get API key + ACCEPT WS-API-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY". IF WS-API-KEY = SPACES - MOVE WS-PUBLIC-KEY TO WS-API-KEY + DISPLAY "Error: UNSANDBOX_API_KEY not set" UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN END-IF. * Parse key arguments @@ -1177,8 +1069,27 @@ CALL "SYSTEM" USING WS-CURL-CMD. HANDLE-LANGUAGES. - * Get credentials - PERFORM GET-CREDENTIALS. + * Get API keys + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_PUBLIC_KEY". + IF WS-PUBLIC-KEY NOT = SPACES + ACCEPT WS-SECRET-KEY FROM ENVIRONMENT "UNSANDBOX_SECRET_KEY" + IF WS-SECRET-KEY = SPACES + 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. * Parse --json flag MOVE SPACES TO WS-JSON-OUTPUT. @@ -1253,8 +1164,27 @@ CALL "SYSTEM" USING WS-CURL-CMD. HANDLE-IMAGE. - * Get credentials - PERFORM GET-CREDENTIALS. + * Get API keys (try new format first, fall back to old) + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_PUBLIC_KEY". + IF WS-PUBLIC-KEY NOT = SPACES + ACCEPT WS-SECRET-KEY FROM ENVIRONMENT "UNSANDBOX_SECRET_KEY" + IF WS-SECRET-KEY = SPACES + 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. * Initialize image parameters MOVE SPACES TO WS-ID. @@ -1347,55 +1277,16 @@ ":\" | openssl dgst -sha256 -hmac '" FUNCTION TRIM(WS-SECRET-KEY) "' | cut -d' ' -f2); " - "RESP=$(curl -s -w '\\n%{http_code}' -X DELETE " - "'https://api.unsandbox.com/images/" + "curl -s -X DELETE 'https://api.unsandbox.com/images/" FUNCTION TRIM(WS-ID) "' " "-H 'Authorization: Bearer " FUNCTION TRIM(WS-PUBLIC-KEY) "' " "-H 'X-Timestamp: '$TS " - "-H 'X-Signature: '$SIG); " - "HTTP_CODE=$(echo \"$RESP\" | tail -n1); " - "BODY=$(echo \"$RESP\" | sed '$d'); " - "if [ \"$HTTP_CODE\" = \"428\" ]; then " - "CHALLENGE_ID=$(echo \"$BODY\" | jq -r '.challenge_id // empty'); " - "echo -e '\\x1b[33mConfirmation required. Check your email " - "for a one-time code.\\x1b[0m' >&2; " - "echo -n 'Enter OTP: ' >&2; read OTP; " - "if [ -z \"$OTP\" ]; then " - "echo -e '\\x1b[31mError: Operation cancelled\\x1b[0m' >&2; " - "exit 1; fi; " - "TS2=$(date +%s); " - "SIG2=$(echo -n \"$TS2:DELETE:/images/" - FUNCTION TRIM(WS-ID) - ":\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "RESP2=$(curl -s -w '\\n%{http_code}' -X DELETE " - "'https://api.unsandbox.com/images/" - FUNCTION TRIM(WS-ID) - "' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS2 " - "-H 'X-Signature: '$SIG2 " - "-H 'X-Sudo-OTP: '$OTP " - "-H 'X-Sudo-Challenge: '$CHALLENGE_ID); " - "HTTP_CODE2=$(echo \"$RESP2\" | tail -n1); " - "if [ \"$HTTP_CODE2\" = \"200\" ] || " - "[ \"$HTTP_CODE2\" = \"204\" ]; then " - "echo -e '\\x1b[32mImage deleted: " - FUNCTION TRIM(WS-ID) "\\x1b[0m'; " - "else echo \"$RESP2\" | sed '$d' | jq . 2>/dev/null || " - "echo \"$RESP2\" | sed '$d'; exit 1; fi; " - "elif [ \"$HTTP_CODE\" = \"200\" ] || " - "[ \"$HTTP_CODE\" = \"204\" ]; then " - "echo -e '\\x1b[32mImage deleted: " - FUNCTION TRIM(WS-ID) "\\x1b[0m'; " - "else echo \"$BODY\" | jq . 2>/dev/null || " - "echo \"$BODY\"; exit 1; fi" + "-H 'X-Signature: '$SIG >/dev/null && " + "echo -e '\x1b[32mImage deleted: " + FUNCTION TRIM(WS-ID) "\x1b[0m'" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING. @@ -1434,8 +1325,7 @@ "/unlock:$BODY\" | openssl dgst -sha256 -hmac '" FUNCTION TRIM(WS-SECRET-KEY) "' | cut -d' ' -f2); " - "RESP=$(curl -s -w '\\n%{http_code}' -X POST " - "'https://api.unsandbox.com/images/" + "curl -s -X POST 'https://api.unsandbox.com/images/" FUNCTION TRIM(WS-ID) "/unlock' " "-H 'Content-Type: application/json' " @@ -1444,49 +1334,9 @@ "' " "-H 'X-Timestamp: '$TS " "-H 'X-Signature: '$SIG " - "-d \"$BODY\"); " - "HTTP_CODE=$(echo \"$RESP\" | tail -n1); " - "RESP_BODY=$(echo \"$RESP\" | sed '$d'); " - "if [ \"$HTTP_CODE\" = \"428\" ]; then " - "CHALLENGE_ID=$(echo \"$RESP_BODY\" | jq -r '.challenge_id // empty'); " - "echo -e '\\x1b[33mConfirmation required. Check your email " - "for a one-time code.\\x1b[0m' >&2; " - "echo -n 'Enter OTP: ' >&2; read OTP; " - "if [ -z \"$OTP\" ]; then " - "echo -e '\\x1b[31mError: Operation cancelled\\x1b[0m' >&2; " - "exit 1; fi; " - "TS2=$(date +%s); " - "SIG2=$(echo -n \"$TS2:POST:/images/" - FUNCTION TRIM(WS-ID) - "/unlock:$BODY\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "RESP2=$(curl -s -w '\\n%{http_code}' -X POST " - "'https://api.unsandbox.com/images/" - FUNCTION TRIM(WS-ID) - "/unlock' " - "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS2 " - "-H 'X-Signature: '$SIG2 " - "-H 'X-Sudo-OTP: '$OTP " - "-H 'X-Sudo-Challenge: '$CHALLENGE_ID " - "-d \"$BODY\"); " - "HTTP_CODE2=$(echo \"$RESP2\" | tail -n1); " - "if [ \"$HTTP_CODE2\" = \"200\" ] || " - "[ \"$HTTP_CODE2\" = \"204\" ]; then " - "echo -e '\\x1b[32mImage unlocked: " - FUNCTION TRIM(WS-ID) "\\x1b[0m'; " - "else echo \"$RESP2\" | sed '$d' | jq . 2>/dev/null || " - "echo \"$RESP2\" | sed '$d'; exit 1; fi; " - "elif [ \"$HTTP_CODE\" = \"200\" ] || " - "[ \"$HTTP_CODE\" = \"204\" ]; then " - "echo -e '\\x1b[32mImage unlocked: " - FUNCTION TRIM(WS-ID) "\\x1b[0m'; " - "else echo \"$RESP_BODY\" | jq . 2>/dev/null || " - "echo \"$RESP_BODY\"; exit 1; fi" + "-d \"$BODY\" >/dev/null && " + "echo -e '\x1b[32mImage unlocked: " + FUNCTION TRIM(WS-ID) "\x1b[0m'" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING. @@ -1688,300 +1538,3 @@ END-STRING. CALL "SYSTEM" USING WS-CURL-CMD. - - HANDLE-SNAPSHOT. - * Get credentials - PERFORM GET-CREDENTIALS. - - * Get second argument (operation or --list) - ACCEPT WS-ARG2 FROM ARGUMENT-VALUE. - - EVALUATE WS-ARG2 - WHEN "--list" - WHEN "-l" - PERFORM SNAPSHOT-LIST - WHEN "--info" - ACCEPT WS-ID FROM ARGUMENT-VALUE - PERFORM SNAPSHOT-INFO - WHEN "--delete" - ACCEPT WS-ID FROM ARGUMENT-VALUE - PERFORM SNAPSHOT-DELETE - WHEN "--lock" - ACCEPT WS-ID FROM ARGUMENT-VALUE - PERFORM SNAPSHOT-LOCK - WHEN "--unlock" - ACCEPT WS-ID FROM ARGUMENT-VALUE - PERFORM SNAPSHOT-UNLOCK - WHEN "--restore" - ACCEPT WS-ID FROM ARGUMENT-VALUE - PERFORM SNAPSHOT-RESTORE - WHEN "--clone" - ACCEPT WS-ID FROM ARGUMENT-VALUE - PERFORM PARSE-SNAPSHOT-CLONE-ARGS - PERFORM SNAPSHOT-CLONE - WHEN OTHER - DISPLAY "Usage: un snapshot [options]" UPON SYSERR - DISPLAY " --list, -l List snapshots" UPON SYSERR - DISPLAY " --info ID Get snapshot details" - UPON SYSERR - DISPLAY " --delete ID Delete snapshot" - UPON SYSERR - DISPLAY " --lock ID Lock snapshot" UPON SYSERR - DISPLAY " --unlock ID Unlock snapshot" UPON SYSERR - DISPLAY " --restore ID Restore snapshot" - UPON SYSERR - DISPLAY " --clone ID Clone snapshot" UPON SYSERR - MOVE 1 TO RETURN-CODE - STOP RUN - END-EVALUATE. - - SNAPSHOT-LIST. - STRING "TS=$(date +%s); " - "SIG=$(echo -n \"$TS:GET:/snapshots:\" | " - "openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "curl -s -X GET 'https://api.unsandbox.com/snapshots' " - "-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. - - SNAPSHOT-INFO. - STRING "TS=$(date +%s); " - "SIG=$(echo -n \"$TS:GET:/snapshots/" - FUNCTION TRIM(WS-ID) - ":\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "curl -s -X GET 'https://api.unsandbox.com/snapshots/" - FUNCTION TRIM(WS-ID) - "' " - "-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. - - SNAPSHOT-DELETE. - STRING "TS=$(date +%s); " - "SIG=$(echo -n \"$TS:DELETE:/snapshots/" - FUNCTION TRIM(WS-ID) - ":\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "RESP=$(curl -s -w '\n%{http_code}' -X DELETE " - "'https://api.unsandbox.com/snapshots/" - FUNCTION TRIM(WS-ID) - "' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS " - "-H 'X-Signature: '$SIG); " - "HTTP_CODE=$(echo \"$RESP\" | tail -1); " - "BODY=$(echo \"$RESP\" | head -n -1); " - "if [ \"$HTTP_CODE\" = \"428\" ]; then " - "OTP=$(echo \"$BODY\" | jq -r '.otp // empty'); " - "if [ -n \"$OTP\" ]; then " - "TS2=$(date +%s); " - "SIG2=$(echo -n \"$TS2:DELETE:/snapshots/" - FUNCTION TRIM(WS-ID) - ":\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "curl -s -X DELETE 'https://api.unsandbox.com/snapshots/" - FUNCTION TRIM(WS-ID) - "' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS2 " - "-H 'X-Signature: '$SIG2 " - "-H 'X-Sudo-OTP: '$OTP | jq .; " - "echo -e '\x1b[32mSnapshot deleted\x1b[0m'; fi; " - "else echo \"$BODY\" | jq .; fi" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING. - - CALL "SYSTEM" USING WS-CURL-CMD. - - SNAPSHOT-LOCK. - STRING "TS=$(date +%s); " - "SIG=$(echo -n \"$TS:POST:/snapshots/" - FUNCTION TRIM(WS-ID) - "/lock:\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "curl -s -X POST 'https://api.unsandbox.com/snapshots/" - FUNCTION TRIM(WS-ID) - "/lock' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS " - "-H 'X-Signature: '$SIG | jq . && " - "echo -e '\x1b[32mSnapshot locked\x1b[0m'" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING. - - CALL "SYSTEM" USING WS-CURL-CMD. - - SNAPSHOT-UNLOCK. - STRING "TS=$(date +%s); " - "BODY='{}'; " - "SIG=$(echo -n \"$TS:POST:/snapshots/" - FUNCTION TRIM(WS-ID) - "/unlock:$BODY\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "RESP=$(curl -s -w '\n%{http_code}' -X POST " - "'https://api.unsandbox.com/snapshots/" - FUNCTION TRIM(WS-ID) - "/unlock' " - "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS " - "-H 'X-Signature: '$SIG " - "-d \"$BODY\"); " - "HTTP_CODE=$(echo \"$RESP\" | tail -1); " - "BODY_RESP=$(echo \"$RESP\" | head -n -1); " - "if [ \"$HTTP_CODE\" = \"428\" ]; then " - "OTP=$(echo \"$BODY_RESP\" | jq -r '.otp // empty'); " - "if [ -n \"$OTP\" ]; then " - "TS2=$(date +%s); " - "SIG2=$(echo -n \"$TS2:POST:/snapshots/" - FUNCTION TRIM(WS-ID) - "/unlock:$BODY\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "curl -s -X POST 'https://api.unsandbox.com/snapshots/" - FUNCTION TRIM(WS-ID) - "/unlock' " - "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS2 " - "-H 'X-Signature: '$SIG2 " - "-H 'X-Sudo-OTP: '$OTP " - "-d \"$BODY\" | jq .; " - "echo -e '\x1b[32mSnapshot unlocked\x1b[0m'; fi; " - "else echo \"$BODY_RESP\" | jq .; fi" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING. - - CALL "SYSTEM" USING WS-CURL-CMD. - - SNAPSHOT-RESTORE. - STRING "TS=$(date +%s); " - "BODY='{}'; " - "SIG=$(echo -n \"$TS:POST:/snapshots/" - FUNCTION TRIM(WS-ID) - "/restore:$BODY\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "curl -s -X POST 'https://api.unsandbox.com/snapshots/" - FUNCTION TRIM(WS-ID) - "/restore' " - "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS " - "-H 'X-Signature: '$SIG " - "-d \"$BODY\" | jq . && " - "echo -e '\x1b[32mSnapshot restored\x1b[0m'" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING. - - CALL "SYSTEM" USING WS-CURL-CMD. - - PARSE-SNAPSHOT-CLONE-ARGS. - * Parse --type, --name, --ports, --shell - MOVE SPACES TO WS-TYPE. - MOVE SPACES TO WS-NAME. - MOVE SPACES TO WS-PORTS. - MOVE SPACES TO WS-SHELL. - ACCEPT WS-ARG3 FROM ARGUMENT-VALUE. - PERFORM UNTIL WS-ARG3 = SPACES - IF WS-ARG3 = "--type" - ACCEPT WS-TYPE FROM ARGUMENT-VALUE - ELSE IF WS-ARG3 = "--name" - ACCEPT WS-NAME FROM ARGUMENT-VALUE - ELSE IF WS-ARG3 = "--ports" - ACCEPT WS-PORTS FROM ARGUMENT-VALUE - ELSE IF WS-ARG3 = "--shell" - ACCEPT WS-SHELL FROM ARGUMENT-VALUE - END-IF - ACCEPT WS-ARG3 FROM ARGUMENT-VALUE - END-PERFORM. - - IF WS-TYPE = SPACES - DISPLAY "Error: --type required (session or service)" - UPON SYSERR - MOVE 1 TO RETURN-CODE - STOP RUN - END-IF. - - SNAPSHOT-CLONE. - * Build clone request - STRING "TS=$(date +%s); " - "BODY='{\"type\":\"" FUNCTION TRIM(WS-TYPE) "\"" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING. - - IF WS-NAME NOT = SPACES - STRING FUNCTION TRIM(WS-CURL-CMD) - ",\"name\":\"" FUNCTION TRIM(WS-NAME) "\"" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING - END-IF. - - IF WS-PORTS NOT = SPACES - STRING FUNCTION TRIM(WS-CURL-CMD) - ",\"ports\":[" FUNCTION TRIM(WS-PORTS) "]" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING - END-IF. - - IF WS-SHELL NOT = SPACES - STRING FUNCTION TRIM(WS-CURL-CMD) - ",\"shell\":\"" FUNCTION TRIM(WS-SHELL) "\"" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING - END-IF. - - STRING FUNCTION TRIM(WS-CURL-CMD) - "}'; " - "SIG=$(echo -n \"$TS:POST:/snapshots/" - FUNCTION TRIM(WS-ID) - "/clone:$BODY\" | openssl dgst -sha256 -hmac '" - FUNCTION TRIM(WS-SECRET-KEY) - "' | cut -d' ' -f2); " - "curl -s -X POST 'https://api.unsandbox.com/snapshots/" - FUNCTION TRIM(WS-ID) - "/clone' " - "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " - FUNCTION TRIM(WS-PUBLIC-KEY) - "' " - "-H 'X-Timestamp: '$TS " - "-H 'X-Signature: '$SIG " - "-d \"$BODY\" | jq . && " - "echo -e '\x1b[32mSnapshot cloned\x1b[0m'" - DELIMITED BY SIZE INTO WS-CURL-CMD - END-STRING. - - CALL "SYSTEM" USING WS-CURL-CMD. diff --git a/clients/cobol/tests/test_un.sh b/clients/cobol/tests/test_un.sh deleted file mode 100755 index 8986144..0000000 --- a/clients/cobol/tests/test_un.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/bash -# This is free software for the public good of a permacomputer hosted at -# permacomputer.com, an always-on computer by the people, for the people. -# One which is durable, easy to repair, & distributed like tap water -# for machine learning intelligence. -# -# The permacomputer is community-owned infrastructure optimized around -# four values: -# -# TRUTH First principles, math & science, open source code freely distributed -# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY Minimal waste, self-renewing systems with diverse thriving connections -# LOVE Be yourself without hurting others, cooperation through natural law -# -# This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -# Code is seeds to sprout on any abandoned technology. - -# Test suite for COBOL Unsandbox SDK -# Run: bash tests/test_un.sh - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SDK_DIR="$SCRIPT_DIR/../sync/src" -SOURCE="$SDK_DIR/un.cob" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' - -TESTS_RUN=0 -TESTS_PASSED=0 - -# Test helper -test_that() { - local description="$1" - local test_cmd="$2" - TESTS_RUN=$((TESTS_RUN + 1)) - - if eval "$test_cmd" >/dev/null 2>&1; then - echo -e "[${GREEN}PASS${NC}] $description" - TESTS_PASSED=$((TESTS_PASSED + 1)) - return 0 - else - echo -e "[${RED}FAIL${NC}] $description" - return 1 - fi -} - -# Test source file exists -echo "" -echo "=== Source File ===" -test_that "Source file exists" "[ -f '$SOURCE' ]" - -echo "" -echo "=== Source Code Structure ===" -test_that "Has WORKING-STORAGE SECTION" "grep -q 'WORKING-STORAGE SECTION' '$SOURCE'" -test_that "Has session handler" "grep -q 'HANDLE-SESSION' '$SOURCE'" -test_that "Has service handler" "grep -q 'HANDLE-SERVICE' '$SOURCE'" -test_that "Has snapshot handler" "grep -q 'HANDLE-SNAPSHOT' '$SOURCE'" -test_that "Has image handler" "grep -q 'HANDLE-IMAGE' '$SOURCE'" -test_that "Has key handler" "grep -q 'HANDLE-KEY' '$SOURCE'" -test_that "Has languages handler" "grep -q 'HANDLE-LANGUAGES' '$SOURCE'" - -echo "" -echo "=== Snapshot Operations ===" -test_that "Snapshot list implemented" "grep -q 'SNAPSHOT-LIST' '$SOURCE'" -test_that "Snapshot info implemented" "grep -q 'SNAPSHOT-INFO' '$SOURCE'" -test_that "Snapshot delete implemented" "grep -q 'SNAPSHOT-DELETE' '$SOURCE'" -test_that "Snapshot lock implemented" "grep -q 'SNAPSHOT-LOCK' '$SOURCE'" -test_that "Snapshot unlock implemented" "grep -q 'SNAPSHOT-UNLOCK' '$SOURCE'" -test_that "Snapshot restore implemented" "grep -q 'SNAPSHOT-RESTORE' '$SOURCE'" -test_that "Snapshot clone implemented" "grep -q 'SNAPSHOT-CLONE' '$SOURCE'" - -echo "" -echo "=== Image Operations ===" -test_that "Image list implemented" "grep -q 'IMAGE-LIST' '$SOURCE'" -test_that "Image info implemented" "grep -q 'IMAGE-INFO' '$SOURCE'" -test_that "Image delete implemented" "grep -q 'IMAGE-DELETE' '$SOURCE'" -test_that "Image lock implemented" "grep -q 'IMAGE-LOCK' '$SOURCE'" -test_that "Image unlock implemented" "grep -q 'IMAGE-UNLOCK' '$SOURCE'" - -echo "" -echo "=== HMAC Authentication ===" -test_that "Uses openssl for HMAC" "grep -q 'openssl dgst -sha256 -hmac' '$SOURCE'" -test_that "Has X-Signature header" "grep -q 'X-Signature' '$SOURCE'" -test_that "Has X-Timestamp header" "grep -q 'X-Timestamp' '$SOURCE'" - -echo "" -echo "=== Sudo OTP Handling ===" -test_that "Handles 428 response" "grep -q '428' '$SOURCE'" -test_that "Has X-Sudo-OTP header" "grep -q 'X-Sudo-OTP' '$SOURCE'" - -echo "" -echo "=== Variables ===" -test_that "WS-TYPE variable defined" "grep -q 'WS-TYPE' '$SOURCE'" -test_that "WS-SHELL variable defined" "grep -q 'WS-SHELL' '$SOURCE'" -test_that "WS-PORTS variable defined" "grep -q 'WS-PORTS' '$SOURCE'" -test_that "WS-NAME variable defined" "grep -q 'WS-NAME' '$SOURCE'" - -echo "" -echo "=== Summary ===" -echo "Tests passed: $TESTS_PASSED / $TESTS_RUN" - -if [ $TESTS_PASSED -eq $TESTS_RUN ]; then - echo -e "${GREEN}All tests passed!${NC}" - exit 0 -else - echo -e "${RED}Some tests failed!${NC}" - exit 1 -fi diff --git a/clients/cpp/sync/src/un.cpp b/clients/cpp/sync/src/un.cpp index 59a9fed..5779239 100644 --- a/clients/cpp/sync/src/un.cpp +++ b/clients/cpp/sync/src/un.cpp @@ -68,7 +68,6 @@ #include #include #include -#include using namespace std; @@ -103,27 +102,6 @@ string read_file(const string& filename) { return buf.str(); } -// Load a row from an accounts.csv file (format: public_key,secret_key per line). -// Lines starting with '#' and blank lines are skipped. Returns the Nth data row. -pair loadAccountsCSV(const string& path, int index) { - ifstream f(path); - if (!f) return {"", ""}; - string line; - int row = 0; - while (getline(f, line)) { - // Trim trailing carriage return - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (line.empty() || line[0] == '#') continue; - if (row == index) { - size_t comma = line.find(','); - if (comma == string::npos) return {"", ""}; - return {line.substr(0, comma), line.substr(comma + 1)}; - } - row++; - } - return {"", ""}; -} - string escape_json(const string& s) { ostringstream o; for (char c : s) { @@ -183,163 +161,6 @@ string exec_curl(const string& cmd) { return result; } -// Execute curl and get HTTP status code -pair exec_curl_with_status(const string& cmd) { - // Modify cmd to include status code output - string full_cmd = cmd + " -w '\\n%{http_code}'"; - string result = exec_curl(full_cmd); - - // Extract status code from end of response - size_t last_newline = result.rfind('\n'); - if (last_newline != string::npos && last_newline > 0) { - // Find the status code after the last newline - size_t status_start = last_newline + 1; - // Trim any trailing whitespace - while (!result.empty() && (result.back() == '\n' || result.back() == '\r' || result.back() == ' ')) { - result.pop_back(); - } - // Now find the status code at the end - size_t end = result.length(); - size_t start = result.rfind('\n'); - if (start == string::npos) start = 0; - else start++; - - string status_str = result.substr(start); - int status = 0; - try { - status = stoi(status_str); - } catch (...) { - status = 0; - } - string body = result.substr(0, start > 0 ? start - 1 : 0); - return {body, status}; - } - return {result, 0}; -} - -// Extract challenge_id from JSON response -string extract_challenge_id(const string& response) { - size_t pos = response.find("\"challenge_id\":\""); - if (pos == string::npos) return ""; - pos += 16; // Length of "challenge_id":" - size_t end = response.find("\"", pos); - if (end == string::npos) return ""; - return response.substr(pos, end - pos); -} - -// Handle 428 sudo OTP challenge - prompts user for OTP and retries request -bool handle_sudo_challenge(const string& method, const string& path, const string& body, - const string& public_key, const string& secret_key, const string& response) { - // Extract challenge_id from response - string challenge_id = extract_challenge_id(response); - - cerr << YELLOW << "Confirmation required. Check your email for a one-time code." << RESET << endl; - cerr << "Enter OTP: "; - - string otp; - if (!getline(cin, otp)) { - cerr << RED << "Error: Failed to read OTP" << RESET << endl; - return false; - } - - // Trim whitespace - while (!otp.empty() && (otp.back() == '\n' || otp.back() == '\r' || otp.back() == ' ')) { - otp.pop_back(); - } - while (!otp.empty() && (otp.front() == ' ')) { - otp.erase(0, 1); - } - - if (otp.empty()) { - cerr << RED << "Error: Operation cancelled" << RESET << endl; - return false; - } - - // Retry the request with sudo headers - string auth_headers = build_auth_headers(method, path, body, public_key, secret_key); - auth_headers += " -H 'X-Sudo-OTP: " + otp + "'"; - if (!challenge_id.empty()) { - auth_headers += " -H 'X-Sudo-Challenge: " + challenge_id + "'"; - } - - string cmd; - if (method == "DELETE") { - cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; - } else if (method == "POST") { - cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " " - "-d '" + body + "'"; - } else { - cmd = "curl -s -X " + method + " '" + API_BASE + path + "' " + auth_headers; - } - - auto [retry_response, status] = exec_curl_with_status(cmd); - - if (status >= 200 && status < 300) { - cout << GREEN << "Operation completed successfully" << RESET << endl; - return true; - } - - // Extract error message if available - size_t error_pos = retry_response.find("\"error\":\""); - if (error_pos != string::npos) { - error_pos += 9; - size_t error_end = retry_response.find("\"", error_pos); - if (error_end != string::npos) { - cerr << RED << "Error: " << retry_response.substr(error_pos, error_end - error_pos) << RESET << endl; - } else { - cerr << RED << "Error: " << retry_response << RESET << endl; - } - } else { - cerr << RED << "Error: HTTP " << status << RESET << endl; - cerr << retry_response << endl; - } - return false; -} - -// Execute a destructive operation that may require sudo OTP confirmation -bool exec_destructive_curl(const string& method, const string& path, const string& body, - const string& public_key, const string& secret_key, const string& success_msg) { - string auth_headers = build_auth_headers(method, path, body, public_key, secret_key); - - string cmd; - if (method == "DELETE") { - cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; - } else if (method == "POST" && !body.empty()) { - cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " " - "-d '" + body + "'"; - } else if (method == "POST") { - cmd = "curl -s -X POST '" + API_BASE + path + "' " + auth_headers; - } else { - cmd = "curl -s -X " + method + " '" + API_BASE + path + "' " + auth_headers; - } - - auto [response, status] = exec_curl_with_status(cmd); - - // Handle 428 sudo challenge - if (status == 428) { - return handle_sudo_challenge(method, path, body, public_key, secret_key, response); - } - - if (status >= 200 && status < 300) { - if (!success_msg.empty()) { - cout << GREEN << success_msg << RESET << endl; - } - return true; - } - - if (status == 404) { - cerr << RED << "Error: Not found" << RESET << endl; - } else { - cerr << RED << "Error: HTTP " << status << RESET << endl; - if (!response.empty()) cerr << response << endl; - } - return false; -} - string compute_hmac(const string& secret_key, const string& message) { string cmd = "echo -n '" + message + "' | openssl dgst -sha256 -hmac '" + secret_key + "' -hex | sed 's/.*= //'"; string result = exec_curl(cmd); @@ -513,531 +334,6 @@ void set_unfreeze_on_demand(const string& service_id, bool enabled, const string cout << GREEN << "Service unfreeze_on_demand set to " << enabled_str << RESET << endl; } -// ============================================================================ -// Library Functions for C++ SDK (matching C reference un.h) -// ============================================================================ - -const string SDK_VERSION = "4.2.0"; - -// Execute code synchronously -string execute(const string& language, const string& code, const string& public_key, const string& secret_key) { - string body = "{\"language\":\"" + escape_json(language) + "\",\"code\":\"" + escape_json(code) + "\"}"; - string auth_headers = build_auth_headers("POST", "/execute", body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + "/execute' " - "-H 'Content-Type: application/json' " - + auth_headers + " " - "-d '" + body + "'"; - return exec_curl(cmd); -} - -// Execute code asynchronously (returns job_id) -string execute_async(const string& language, const string& code, const string& public_key, const string& secret_key) { - string body = "{\"language\":\"" + escape_json(language) + "\",\"code\":\"" + escape_json(code) + "\",\"async\":true}"; - string auth_headers = build_auth_headers("POST", "/execute", body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + "/execute' " - "-H 'Content-Type: application/json' " - + auth_headers + " " - "-d '" + body + "'"; - return exec_curl(cmd); -} - -// Get job status -string get_job(const string& job_id, const string& public_key, const string& secret_key) { - string path = "/jobs/" + job_id; - 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); -} - -// Wait for job completion -string wait_for_job(const string& job_id, const string& public_key, const string& secret_key) { - const int poll_delays[] = {300, 450, 700, 900, 650, 1600, 2000}; - const int poll_count = 7; - int delay_idx = 0; - - while (true) { - string result = get_job(job_id, public_key, secret_key); - if (result.find("\"status\":\"completed\"") != string::npos || - result.find("\"status\":\"failed\"") != string::npos || - result.find("\"status\":\"timeout\"") != string::npos || - result.find("\"status\":\"cancelled\"") != string::npos) { - return result; - } - - usleep(poll_delays[delay_idx % poll_count] * 1000); - if (delay_idx < poll_count - 1) delay_idx++; - } -} - -// Cancel a job -string cancel_job(const string& job_id, const string& public_key, const string& secret_key) { - string path = "/jobs/" + job_id + "/cancel"; - 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); -} - -// List all jobs -string list_jobs(const string& public_key, const string& secret_key) { - string auth_headers = build_auth_headers("GET", "/jobs", "", public_key, secret_key); - string cmd = "curl -s -X GET '" + API_BASE + "/jobs' " + auth_headers; - return exec_curl(cmd); -} - -// Get supported languages -string get_languages(const string& public_key, const string& secret_key) { - string auth_headers = build_auth_headers("GET", "/languages", "", public_key, secret_key); - string cmd = "curl -s -X GET '" + API_BASE + "/languages' " + auth_headers; - return exec_curl(cmd); -} - -// Session functions -string session_list(const string& public_key, const string& secret_key) { - string auth_headers = build_auth_headers("GET", "/sessions", "", public_key, secret_key); - string cmd = "curl -s -X GET '" + API_BASE + "/sessions' " + auth_headers; - return exec_curl(cmd); -} - -string session_get(const string& session_id, const string& public_key, const string& secret_key) { - string path = "/sessions/" + session_id; - 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); -} - -string session_create(const string& shell, const string& network, const string& public_key, const string& secret_key) { - string body = "{\"shell\":\"" + (shell.empty() ? "bash" : shell) + "\""; - if (!network.empty()) body += ",\"network\":\"" + network + "\""; - body += "}"; - string auth_headers = build_auth_headers("POST", "/sessions", body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + "/sessions' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string session_destroy(const string& session_id, const string& public_key, const string& secret_key) { - string path = "/sessions/" + session_id; - string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); - string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; - return exec_curl(cmd); -} - -string session_freeze(const string& session_id, const string& public_key, const string& secret_key) { - string path = "/sessions/" + session_id + "/freeze"; - 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); -} - -string session_unfreeze(const string& session_id, const string& public_key, const string& secret_key) { - string path = "/sessions/" + session_id + "/unfreeze"; - 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); -} - -string session_boost(const string& session_id, int vcpu, const string& public_key, const string& secret_key) { - string path = "/sessions/" + session_id + "/boost"; - string body = vcpu > 0 ? "{\"vcpu\":" + to_string(vcpu) + "}" : "{}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string session_unboost(const string& session_id, const string& public_key, const string& secret_key) { - string path = "/sessions/" + session_id + "/unboost"; - 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); -} - -string session_execute(const string& session_id, const string& command, const string& public_key, const string& secret_key) { - string path = "/sessions/" + session_id + "/shell"; - string body = "{\"command\":\"" + escape_json(command) + "\"}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -// Service functions -string service_list(const string& public_key, const string& 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; - return exec_curl(cmd); -} - -string service_get(const string& service_id, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id; - 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); -} - -string service_create(const string& name, const string& ports, const string& bootstrap, const string& network, const string& public_key, const string& secret_key) { - string body = "{\"name\":\"" + escape_json(name) + "\""; - if (!ports.empty()) body += ",\"ports\":\"" + ports + "\""; - if (!bootstrap.empty()) body += ",\"bootstrap\":\"" + escape_json(bootstrap) + "\""; - if (!network.empty()) body += ",\"network\":\"" + network + "\""; - body += "}"; - string auth_headers = build_auth_headers("POST", "/services", body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + "/services' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string service_destroy(const string& service_id, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id; - string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); - string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; - return exec_curl(cmd); -} - -string service_freeze(const string& service_id, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/freeze"; - 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); -} - -string service_unfreeze(const string& service_id, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/unfreeze"; - 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); -} - -string service_lock(const string& service_id, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/lock"; - 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); -} - -string service_unlock(const string& service_id, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/unlock"; - 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); -} - -string service_redeploy(const string& service_id, const string& bootstrap, const vector& input_files, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/redeploy"; - ostringstream json; - json << "{"; - bool has_field = false; - if (!bootstrap.empty()) { - json << "\"bootstrap\":\"" << escape_json(bootstrap) << "\""; - has_field = true; - } - if (!input_files.empty()) { - if (has_field) json << ","; - json << "\"input_files\":["; - for (size_t i = 0; i < input_files.size(); i++) { - if (i > 0) json << ","; - ifstream file(input_files[i], ios::binary); - if (!file) continue; - ostringstream content; - content << file.rdbuf(); - string b64 = base64_encode(content.str()); - string filename = input_files[i].substr(input_files[i].find_last_of("/\\") + 1); - json << "{\"filename\":\"" << filename << "\",\"content_base64\":\"" << b64 << "\"}"; - } - json << "]"; - } - json << "}"; - string body = json.str(); - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string service_logs(const string& service_id, bool all, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/logs" + (all ? "?all=true" : ""); - 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); -} - -string service_execute(const string& service_id, const string& command, int timeout_ms, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/execute"; - string body = "{\"command\":\"" + escape_json(command) + "\""; - if (timeout_ms > 0) body += ",\"timeout\":" + to_string(timeout_ms); - body += "}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string service_resize(const string& service_id, int vcpu, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/resize"; - string body = "{\"vcpu\":" + to_string(vcpu) + "}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -// Snapshot functions -string snapshot_list(const string& public_key, const string& secret_key) { - string auth_headers = build_auth_headers("GET", "/snapshots", "", public_key, secret_key); - string cmd = "curl -s -X GET '" + API_BASE + "/snapshots' " + auth_headers; - return exec_curl(cmd); -} - -string snapshot_get(const string& snapshot_id, const string& public_key, const string& secret_key) { - string path = "/snapshots/" + snapshot_id; - 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); -} - -string snapshot_session(const string& session_id, const string& name, bool hot, const string& public_key, const string& secret_key) { - string path = "/sessions/" + session_id + "/snapshot"; - string body = "{"; - if (!name.empty()) body += "\"name\":\"" + escape_json(name) + "\","; - body += "\"hot\":" + string(hot ? "true" : "false") + "}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string snapshot_service(const string& service_id, const string& name, bool hot, const string& public_key, const string& secret_key) { - string path = "/services/" + service_id + "/snapshot"; - string body = "{"; - if (!name.empty()) body += "\"name\":\"" + escape_json(name) + "\","; - body += "\"hot\":" + string(hot ? "true" : "false") + "}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string snapshot_restore(const string& snapshot_id, const string& public_key, const string& secret_key) { - string path = "/snapshots/" + snapshot_id + "/restore"; - 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); -} - -string snapshot_delete(const string& snapshot_id, const string& public_key, const string& secret_key) { - string path = "/snapshots/" + snapshot_id; - string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); - string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; - return exec_curl(cmd); -} - -string snapshot_lock(const string& snapshot_id, const string& public_key, const string& secret_key) { - string path = "/snapshots/" + snapshot_id + "/lock"; - 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); -} - -string snapshot_unlock(const string& snapshot_id, const string& public_key, const string& secret_key) { - string path = "/snapshots/" + snapshot_id + "/unlock"; - 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); -} - -string snapshot_clone(const string& snapshot_id, const string& clone_type, const string& name, const string& ports, const string& shell, const string& public_key, const string& secret_key) { - string path = "/snapshots/" + snapshot_id + "/clone"; - string body = "{\"type\":\"" + clone_type + "\""; - if (!name.empty()) body += ",\"name\":\"" + escape_json(name) + "\""; - if (!ports.empty()) body += ",\"ports\":\"" + ports + "\""; - if (!shell.empty()) body += ",\"shell\":\"" + shell + "\""; - body += "}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -// Image functions -string image_list(const string& filter, const string& public_key, const string& secret_key) { - string path = "/images" + (filter.empty() ? "" : "?filter=" + filter); - 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); -} - -string image_get(const string& image_id, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id; - 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); -} - -string image_publish(const string& source_type, const string& source_id, const string& name, const string& description, const string& public_key, const string& secret_key) { - string body = "{\"source_type\":\"" + source_type + "\",\"source_id\":\"" + source_id + "\""; - if (!name.empty()) body += ",\"name\":\"" + escape_json(name) + "\""; - if (!description.empty()) body += ",\"description\":\"" + escape_json(description) + "\""; - body += "}"; - string auth_headers = build_auth_headers("POST", "/images", body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + "/images' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string image_delete(const string& image_id, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id; - string auth_headers = build_auth_headers("DELETE", path, "", public_key, secret_key); - string cmd = "curl -s -X DELETE '" + API_BASE + path + "' " + auth_headers; - return exec_curl(cmd); -} - -string image_lock(const string& image_id, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/lock"; - 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); -} - -string image_unlock(const string& image_id, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/unlock"; - 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); -} - -string image_set_visibility(const string& image_id, const string& visibility, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/visibility"; - string body = "{\"visibility\":\"" + visibility + "\"}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string image_grant_access(const string& image_id, const string& trusted_key, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/grant"; - string body = "{\"trusted_api_key\":\"" + trusted_key + "\"}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string image_revoke_access(const string& image_id, const string& trusted_key, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/revoke"; - string body = "{\"trusted_api_key\":\"" + trusted_key + "\"}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string image_list_trusted(const string& image_id, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/trusted"; - 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); -} - -string image_transfer(const string& image_id, const string& to_api_key, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/transfer"; - string body = "{\"to_api_key\":\"" + to_api_key + "\"}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string image_spawn(const string& image_id, const string& name, const string& ports, const string& bootstrap, const string& network, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/spawn"; - string body = "{"; - bool has_field = false; - if (!name.empty()) { body += "\"name\":\"" + escape_json(name) + "\""; has_field = true; } - if (!ports.empty()) { body += string(has_field ? "," : "") + "\"ports\":\"" + ports + "\""; has_field = true; } - if (!bootstrap.empty()) { body += string(has_field ? "," : "") + "\"bootstrap\":\"" + escape_json(bootstrap) + "\""; has_field = true; } - if (!network.empty()) { body += string(has_field ? "," : "") + "\"network\":\"" + network + "\""; } - body += "}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -string image_clone(const string& image_id, const string& name, const string& description, const string& public_key, const string& secret_key) { - string path = "/images/" + image_id + "/clone"; - string body = "{"; - bool has_field = false; - if (!name.empty()) { body += "\"name\":\"" + escape_json(name) + "\""; has_field = true; } - if (!description.empty()) { body += string(has_field ? "," : "") + "\"description\":\"" + escape_json(description) + "\""; } - body += "}"; - string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " -d '" + body + "'"; - return exec_curl(cmd); -} - -// PaaS Logs functions -string logs_fetch(const string& source, int lines, const string& since, const string& grep, const string& public_key, const string& secret_key) { - string path = "/paas/logs?"; - if (!source.empty()) path += "source=" + source + "&"; - if (lines > 0) path += "lines=" + to_string(lines) + "&"; - if (!since.empty()) path += "since=" + since + "&"; - if (!grep.empty()) path += "grep=" + grep + "&"; - if (path.back() == '&' || path.back() == '?') path.pop_back(); - 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); -} - -// Key validation -string validate_keys(const string& public_key, const string& secret_key) { - string auth_headers = build_auth_headers("POST", "/keys/validate", "", public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + "/keys/validate' " + auth_headers; - return exec_curl(cmd); -} - -// Utility functions -string hmac_sign(const string& secret_key, const string& message) { - return compute_hmac(secret_key, message); -} - -bool health_check() { - string cmd = "curl -s -o /dev/null -w '%{http_code}' '" + API_BASE + "/health' 2>/dev/null"; - string result = exec_curl(cmd); - return result.find("200") != string::npos; -} - -string version() { - return SDK_VERSION; -} - -static string last_error_msg; - -void set_last_error(const string& msg) { - last_error_msg = msg; -} - -string last_error() { - return last_error_msg; -} - void cmd_service_env(const string& action, const string& target, const vector& envs, const string& env_file, const string& public_key, const string& secret_key) { if (action == "status") { if (target.empty()) { @@ -1246,7 +542,7 @@ void cmd_session(bool list, const string& kill, const string& shell, const strin cout << exec_curl(cmd) << endl; } -void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, const string& bootstrap_file, const vector& files, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& resize, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& redeploy, const string& network, int vcpu, const vector& envs, const string& env_file, const string& env_action, const string& env_target, const string& set_unfreeze_on_demand_id, int set_unfreeze_on_demand_enabled, int unfreeze_on_demand, const string& public_key, const string& secret_key) { +void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, const string& bootstrap_file, const vector& files, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& resize, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const vector& envs, const string& env_file, const string& env_action, const string& env_target, const string& set_unfreeze_on_demand_id, int set_unfreeze_on_demand_enabled, int unfreeze_on_demand, const string& public_key, const string& secret_key) { // Handle service env subcommand if (!env_action.empty()) { cmd_service_env(env_action, env_target, envs, env_file, public_key, secret_key); @@ -1298,8 +594,10 @@ void cmd_service(const string& name, const string& ports, const string& type, co } if (!destroy.empty()) { - string path = "/services/" + destroy; - exec_destructive_curl("DELETE", path, "", public_key, secret_key, "Service destroyed: " + destroy); + string auth_headers = build_auth_headers("DELETE", "/services/" + destroy, "", public_key, secret_key); + string cmd = "curl -s -X DELETE '" + API_BASE + "/services/" + destroy + "' " + auth_headers; + exec_curl(cmd); + cout << GREEN << "Service destroyed: " << destroy << RESET << endl; return; } @@ -1372,62 +670,6 @@ void cmd_service(const string& name, const string& ports, const string& type, co return; } - if (!redeploy.empty()) { - // Bootstrap is optional for redeploy: - // - If provided via --bootstrap or --bootstrap-file, use it - // - If omitted, API will use the stored encrypted bootstrap - string bootstrap_to_use = bootstrap; - if (!bootstrap_file.empty()) { - struct stat st; - if (stat(bootstrap_file.c_str(), &st) == 0) { - bootstrap_to_use = read_file(bootstrap_file); - } else { - cerr << RED << "Error: Bootstrap file not found: " << bootstrap_file << RESET << endl; - exit(1); - } - } - cout << YELLOW << "Redeploying service " << redeploy << "..." << RESET << endl; - ostringstream json; - json << "{"; - bool has_field = false; - if (!bootstrap_to_use.empty()) { - if (!bootstrap_file.empty()) { - json << "\"bootstrap_content\":\"" << escape_json(bootstrap_to_use) << "\""; - } else { - json << "\"bootstrap\":\"" << escape_json(bootstrap_to_use) << "\""; - } - has_field = true; - } - if (!files.empty()) { - if (has_field) json << ","; - json << "\"input_files\":["; - for (size_t i = 0; i < files.size(); i++) { - if (i > 0) json << ","; - ifstream file(files[i], ios::binary); - if (!file) { - cerr << RED << "Error: Input file not found: " << files[i] << RESET << endl; - exit(1); - } - ostringstream content; - content << file.rdbuf(); - string b64 = base64_encode(content.str()); - string filename = files[i].substr(files[i].find_last_of("/\\") + 1); - json << "{\"filename\":\"" << filename << "\",\"content_base64\":\"" << b64 << "\"}"; - } - json << "]"; - } - json << "}"; - string path = "/services/" + redeploy + "/redeploy"; - string auth_headers = build_auth_headers("POST", path, json.str(), public_key, secret_key); - string cmd = "curl -s -X POST '" + API_BASE + path + "' " - "-H 'Content-Type: application/json' " - + auth_headers + " " - "-d '" + json.str() + "'"; - string result = exec_curl(cmd); - cout << result << endl; - return; - } - if (!dump_bootstrap.empty()) { cerr << "Fetching bootstrap script from " << dump_bootstrap << "..." << endl; string json_body = "{\"command\":\"cat /tmp/bootstrap.sh\"}"; @@ -1613,7 +855,10 @@ void cmd_image(bool list, const string& info, const string& del, const string& l if (!del.empty()) { string path = "/images/" + del; - exec_destructive_curl("DELETE", path, "", public_key, secret_key, "Image deleted: " + del); + 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); + cout << GREEN << "Image deleted: " << del << RESET << endl; return; } @@ -1631,7 +876,13 @@ void cmd_image(bool list, const string& info, const string& del, const string& l if (!unlock.empty()) { string path = "/images/" + unlock + "/unlock"; - exec_destructive_curl("POST", path, "{}", public_key, secret_key, "Image unlocked: " + unlock); + string body = "{}"; + string auth_headers = build_auth_headers("POST", path, body, public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + path + "' " + "-H 'Content-Type: application/json' " + + auth_headers + " -d '" + body + "'"; + exec_curl(cmd); + cout << GREEN << "Image unlocked: " << unlock << RESET << endl; return; } @@ -1802,62 +1053,12 @@ void cmd_validate_key(bool extend, const string& public_key, const string& secre } int main(int argc, char* argv[]) { - string public_key; - string secret_key; - int account_index = -1; // -1 = not set + string public_key = getenv("UNSANDBOX_PUBLIC_KEY") ? getenv("UNSANDBOX_PUBLIC_KEY") : ""; + string secret_key = getenv("UNSANDBOX_SECRET_KEY") ? getenv("UNSANDBOX_SECRET_KEY") : ""; - // First pass: scan for --account N and -p/-k flags before full arg parsing - for (int i = 1; i < argc; i++) { - string a = argv[i]; - if (a == "--account" && i+1 < argc) { - account_index = atoi(argv[++i]); - } else if (a == "-p" && i+1 < argc) { - public_key = argv[++i]; - } - } - - if (account_index >= 0) { - // --account N: load from ~/.unsandbox/accounts.csv, bypassing env vars - const char* home = getenv("HOME"); - string csv_path = string(home ? home : ".") + "/.unsandbox/accounts.csv"; - auto creds = loadAccountsCSV(csv_path, account_index); - if (creds.first.empty()) { - // fall back to ./accounts.csv - creds = loadAccountsCSV("accounts.csv", account_index); - } - if (!creds.first.empty()) { - if (public_key.empty()) public_key = creds.first; - secret_key = creds.second; - } - } else { - // Priority: env vars, then ~/.unsandbox/accounts.csv row 0, then ./accounts.csv row 0 - if (public_key.empty()) { - public_key = getenv("UNSANDBOX_PUBLIC_KEY") ? getenv("UNSANDBOX_PUBLIC_KEY") : ""; - } - secret_key = getenv("UNSANDBOX_SECRET_KEY") ? getenv("UNSANDBOX_SECRET_KEY") : ""; - - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - if (public_key.empty()) { - public_key = getenv("UNSANDBOX_API_KEY") ? getenv("UNSANDBOX_API_KEY") : ""; - } - - // Try UNSANDBOX_ACCOUNT env var to pick a row - int env_account = -1; - const char* env_acct = getenv("UNSANDBOX_ACCOUNT"); - if (env_acct) env_account = atoi(env_acct); - - if (public_key.empty()) { - const char* home = getenv("HOME"); - string csv_path = string(home ? home : ".") + "/.unsandbox/accounts.csv"; - auto creds = loadAccountsCSV(csv_path, env_account >= 0 ? env_account : 0); - if (creds.first.empty()) { - creds = loadAccountsCSV("accounts.csv", env_account >= 0 ? env_account : 0); - } - if (!creds.first.empty()) { - public_key = creds.first; - secret_key = creds.second; - } - } + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (public_key.empty()) { + public_key = getenv("UNSANDBOX_API_KEY") ? getenv("UNSANDBOX_API_KEY") : ""; } if (argc < 2) { @@ -1895,7 +1096,6 @@ int main(int argc, char* argv[]) { else if (arg == "--name" && i+1 < argc) name = argv[++i]; else if (arg == "--ports" && i+1 < argc) ports = argv[++i]; else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; - else if (arg == "--account" && i+1 < argc) i++; // already handled in first pass } cmd_image(list, info, del, lock, unlock, publish, source_type, visibility_id, visibility, spawn, clone, name, ports, public_key, secret_key); @@ -1920,7 +1120,6 @@ int main(int argc, char* argv[]) { else if (arg == "--tmux") tmux = true; else if (arg == "--screen") screen = true; else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; - else if (arg == "--account" && i+1 < argc) i++; // already handled in first pass } cmd_session(list, kill, shell, network, vcpu, tmux, screen, files, public_key, secret_key); @@ -1930,7 +1129,7 @@ int main(int argc, char* argv[]) { if (cmd_type == "service") { string name, ports, type, bootstrap, bootstrap_file; bool list = false; - string info, logs, tail, sleep, wake, destroy, resize, execute, command, dump_bootstrap, dump_file, network, redeploy; + string info, logs, tail, sleep, wake, destroy, resize, execute, command, dump_bootstrap, dump_file, network; int vcpu = 0; vector files; vector envs; @@ -1968,7 +1167,6 @@ int main(int argc, char* argv[]) { else if (arg == "--resize" && i+1 < argc) resize = argv[++i]; else if (arg == "--execute" && i+1 < argc) execute = argv[++i]; else if (arg == "--command" && i+1 < argc) command = argv[++i]; - else if (arg == "--redeploy" && i+1 < argc) redeploy = argv[++i]; else if (arg == "--dump-bootstrap" && i+1 < argc) dump_bootstrap = argv[++i]; else if (arg == "--dump-file" && i+1 < argc) dump_file = argv[++i]; else if (arg == "-n" && i+1 < argc) network = argv[++i]; @@ -1983,10 +1181,9 @@ int main(int argc, char* argv[]) { unfreeze_on_demand = (val == "true") ? 1 : 0; } else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; - else if (arg == "--account" && i+1 < argc) i++; // already handled in first pass } - cmd_service(name, ports, type, bootstrap, bootstrap_file, files, list, info, logs, tail, sleep, wake, destroy, resize, execute, command, dump_bootstrap, dump_file, redeploy, network, vcpu, envs, env_file, env_action, env_target, set_unfreeze_on_demand_id, set_unfreeze_on_demand_enabled, unfreeze_on_demand, public_key, secret_key); + cmd_service(name, ports, type, bootstrap, bootstrap_file, files, list, info, logs, tail, sleep, wake, destroy, resize, execute, command, dump_bootstrap, dump_file, network, vcpu, envs, env_file, env_action, env_target, set_unfreeze_on_demand_id, set_unfreeze_on_demand_enabled, unfreeze_on_demand, public_key, secret_key); return 0; } @@ -1997,7 +1194,6 @@ int main(int argc, char* argv[]) { string arg = argv[i]; if (arg == "--extend") extend = true; else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; - else if (arg == "--account" && i+1 < argc) i++; // already handled in first pass } cmd_validate_key(extend, public_key, secret_key); @@ -2011,7 +1207,6 @@ int main(int argc, char* argv[]) { string arg = argv[i]; if (arg == "--json") json_output = true; else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; - else if (arg == "--account" && i+1 < argc) i++; // already handled in first pass } cmd_languages(json_output, public_key, secret_key); @@ -2032,7 +1227,6 @@ int main(int argc, char* argv[]) { else if (arg == "-n" && i+1 < argc) network = argv[++i]; else if (arg == "-v" && i+1 < argc) vcpu = stoi(argv[++i]); else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; - else if (arg == "--account" && i+1 < argc) i++; // already handled in first pass else if (arg[0] == '-') { cerr << RED << "Unknown option: " << arg << RESET << endl; return 1; diff --git a/clients/cpp/sync/tests/test_un.cpp b/clients/cpp/sync/tests/test_un.cpp deleted file mode 100644 index c009627..0000000 --- a/clients/cpp/sync/tests/test_un.cpp +++ /dev/null @@ -1,311 +0,0 @@ -// Tests for the C++ unsandbox SDK -// Compile: g++ -std=c++17 -o test_un test_un.cpp -I../src -// Run: ./test_un - -#include -#include -#include -#include - -// Include the SDK source directly for testing -// In production, you'd link against the compiled library -#include "../src/un.cpp" - -using namespace std; - -int tests_passed = 0; -int tests_failed = 0; - -#define TEST(name) void test_##name() -#define RUN_TEST(name) do { \ - cout << "Running " << #name << "..." << endl; \ - try { \ - test_##name(); \ - cout << " PASS" << endl; \ - tests_passed++; \ - } catch (const exception& e) { \ - cout << " FAIL: " << e.what() << endl; \ - tests_failed++; \ - } catch (...) { \ - cout << " FAIL: Unknown exception" << endl; \ - tests_failed++; \ - } \ -} while(0) - -#define ASSERT(cond) do { \ - if (!(cond)) { \ - throw runtime_error("Assertion failed: " #cond); \ - } \ -} while(0) - -#define ASSERT_EQ(a, b) do { \ - if ((a) != (b)) { \ - throw runtime_error("Assertion failed: " #a " == " #b); \ - } \ -} while(0) - -#define ASSERT_NE(a, b) do { \ - if ((a) == (b)) { \ - throw runtime_error("Assertion failed: " #a " != " #b); \ - } \ -} while(0) - -// ============================================================================ -// Unit Tests - Test exported library functions -// ============================================================================ - -TEST(detect_language) { - ASSERT_EQ(detect_language("script.py"), "python"); - ASSERT_EQ(detect_language("script.js"), "javascript"); - ASSERT_EQ(detect_language("script.ts"), "typescript"); - ASSERT_EQ(detect_language("script.go"), "go"); - ASSERT_EQ(detect_language("script.rs"), "rust"); - ASSERT_EQ(detect_language("script.c"), "c"); - ASSERT_EQ(detect_language("script.cpp"), "cpp"); - ASSERT_EQ(detect_language("script.d"), "d"); - ASSERT_EQ(detect_language("script.zig"), "zig"); - ASSERT_EQ(detect_language("script.sh"), "bash"); - ASSERT_EQ(detect_language("script.lua"), "lua"); - ASSERT_EQ(detect_language("script.php"), "php"); - ASSERT_EQ(detect_language("script.unknown"), ""); - ASSERT_EQ(detect_language("script"), ""); -} - -TEST(hmac_sign) { - string secret_key = "test-secret"; - string message = "test-message"; - - string result = hmac_sign(secret_key, message); - - // Should return a 64-character hex string - ASSERT_EQ(result.length(), 64u); - - // Should be deterministic - string result2 = hmac_sign(secret_key, message); - ASSERT_EQ(result, result2); - - // Different inputs should produce different outputs - string result3 = hmac_sign(secret_key, "different-message"); - ASSERT_NE(result, result3); -} - -TEST(version) { - string v = version(); - ASSERT(!v.empty()); - // Should be in semver format (at least "0.0.0") - ASSERT(v.length() >= 5); -} - -TEST(last_error) { - // Set an error - set_last_error("test error message"); - - // Retrieve it - string err = last_error(); - ASSERT_EQ(err, "test error message"); - - // Clear it - set_last_error(""); - err = last_error(); - ASSERT(err.empty()); -} - -TEST(escape_json) { - ASSERT_EQ(escape_json("hello"), "hello"); - ASSERT_EQ(escape_json("hello\"world"), "hello\\\"world"); - ASSERT_EQ(escape_json("line1\nline2"), "line1\\nline2"); - ASSERT_EQ(escape_json("tab\there"), "tab\\there"); - ASSERT_EQ(escape_json("back\\slash"), "back\\\\slash"); -} - -TEST(base64_encode) { - ASSERT_EQ(base64_encode(""), ""); - ASSERT_EQ(base64_encode("f"), "Zg=="); - ASSERT_EQ(base64_encode("fo"), "Zm8="); - ASSERT_EQ(base64_encode("foo"), "Zm9v"); - ASSERT_EQ(base64_encode("foob"), "Zm9vYg=="); - ASSERT_EQ(base64_encode("fooba"), "Zm9vYmE="); - ASSERT_EQ(base64_encode("foobar"), "Zm9vYmFy"); -} - -// ============================================================================ -// Integration Tests - Test SDK internal consistency -// ============================================================================ - -TEST(compute_hmac) { - string key = "test-key"; - string msg = "test-message"; - - string sig1 = compute_hmac(key, msg); - string sig2 = compute_hmac(key, msg); - - // Should be deterministic - ASSERT_EQ(sig1, sig2); - - // Should produce different results for different inputs - string sig3 = compute_hmac(key, "different"); - ASSERT_NE(sig1, sig3); -} - -TEST(build_auth_headers) { - string pk = "unsb-pk-test-test-test-test"; - string sk = "unsb-sk-test1-test2-test3-test4"; - - string headers = build_auth_headers("POST", "/execute", "{}", pk, sk); - - // Should contain auth header - ASSERT(headers.find("Authorization: Bearer " + pk) != string::npos); - // Should contain timestamp header - ASSERT(headers.find("X-Timestamp:") != string::npos); - // Should contain signature header - ASSERT(headers.find("X-Signature:") != string::npos); -} - -// ============================================================================ -// Functional Tests - Test against real API (requires credentials) -// ============================================================================ - -bool has_credentials() { - const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char* sk = getenv("UNSANDBOX_SECRET_KEY"); - return pk != nullptr && sk != nullptr && strlen(pk) > 0 && strlen(sk) > 0; -} - -TEST(health_check_functional) { - if (!has_credentials()) { - cout << " SKIP (no credentials)" << endl; - return; - } - - bool healthy = health_check(); - // Just verify it doesn't crash - cout << " Health check result: " << (healthy ? "healthy" : "unhealthy") << endl; -} - -TEST(get_languages_functional) { - if (!has_credentials()) { - cout << " SKIP (no credentials)" << endl; - return; - } - - const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char* sk = getenv("UNSANDBOX_SECRET_KEY"); - - string result = get_languages(pk, sk); - ASSERT(!result.empty()); - // Should contain python - ASSERT(result.find("python") != string::npos); -} - -TEST(validate_keys_functional) { - if (!has_credentials()) { - cout << " SKIP (no credentials)" << endl; - return; - } - - const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char* sk = getenv("UNSANDBOX_SECRET_KEY"); - - string result = validate_keys(pk, sk); - ASSERT(!result.empty()); -} - -TEST(execute_functional) { - if (!has_credentials()) { - cout << " SKIP (no credentials)" << endl; - return; - } - - const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char* sk = getenv("UNSANDBOX_SECRET_KEY"); - - string result = execute("python", "print('hello from cpp test')", pk, sk); - ASSERT(!result.empty()); - // Should contain output - ASSERT(result.find("stdout") != string::npos || result.find("output") != string::npos); -} - -TEST(session_list_functional) { - if (!has_credentials()) { - cout << " SKIP (no credentials)" << endl; - return; - } - - const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char* sk = getenv("UNSANDBOX_SECRET_KEY"); - - string result = session_list(pk, sk); - ASSERT(!result.empty()); -} - -TEST(service_list_functional) { - if (!has_credentials()) { - cout << " SKIP (no credentials)" << endl; - return; - } - - const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char* sk = getenv("UNSANDBOX_SECRET_KEY"); - - string result = service_list(pk, sk); - ASSERT(!result.empty()); -} - -TEST(snapshot_list_functional) { - if (!has_credentials()) { - cout << " SKIP (no credentials)" << endl; - return; - } - - const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char* sk = getenv("UNSANDBOX_SECRET_KEY"); - - string result = snapshot_list(pk, sk); - ASSERT(!result.empty()); -} - -TEST(image_list_functional) { - if (!has_credentials()) { - cout << " SKIP (no credentials)" << endl; - return; - } - - const char* pk = getenv("UNSANDBOX_PUBLIC_KEY"); - const char* sk = getenv("UNSANDBOX_SECRET_KEY"); - - string result = image_list("", pk, sk); - ASSERT(!result.empty()); -} - -int main() { - cout << "===== C++ SDK Tests =====" << endl << endl; - - // Unit tests - cout << "--- Unit Tests ---" << endl; - RUN_TEST(detect_language); - RUN_TEST(hmac_sign); - RUN_TEST(version); - RUN_TEST(last_error); - RUN_TEST(escape_json); - RUN_TEST(base64_encode); - - cout << endl << "--- Integration Tests ---" << endl; - RUN_TEST(compute_hmac); - RUN_TEST(build_auth_headers); - - cout << endl << "--- Functional Tests ---" << endl; - RUN_TEST(health_check_functional); - RUN_TEST(get_languages_functional); - RUN_TEST(validate_keys_functional); - RUN_TEST(execute_functional); - RUN_TEST(session_list_functional); - RUN_TEST(service_list_functional); - RUN_TEST(snapshot_list_functional); - RUN_TEST(image_list_functional); - - cout << endl << "===== Results =====" << endl; - cout << "Passed: " << tests_passed << endl; - cout << "Failed: " << tests_failed << endl; - - return tests_failed > 0 ? 1 : 0; -} diff --git a/clients/crystal/sync/src/un.cr b/clients/crystal/sync/src/un.cr index f27a4c5..98f78b9 100644 --- a/clients/crystal/sync/src/un.cr +++ b/clients/crystal/sync/src/un.cr @@ -132,213 +132,21 @@ def save_languages_cache(response : JSON::Any) end end -def load_accounts_csv(path : String, index : Int32) : {String, String}? - return nil unless File.exists?(path) - begin - lines = File.read(path).split('\n').select do |l| - t = l.strip - !t.empty? && !t.starts_with?('#') +def get_api_keys(args_key : String?) : {String, String?} + public_key = ENV["UNSANDBOX_PUBLIC_KEY"]? + secret_key = ENV["UNSANDBOX_SECRET_KEY"]? + + # Fall back to UNSANDBOX_API_KEY for backwards compatibility + if public_key.nil? || public_key.empty? || secret_key.nil? || secret_key.empty? + legacy_key = args_key || ENV["UNSANDBOX_API_KEY"]? + if legacy_key.nil? || legacy_key.empty? + STDERR.puts "#{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{RESET}" + exit 1 end - return nil if index < 0 || index >= lines.size - parts = lines[index].split(',') - return nil if parts.size < 2 - pk = parts[0].strip - sk = parts[1].strip - return nil if pk.empty? || sk.empty? - {pk, sk} - rescue - nil - end -end - -def get_api_keys(args_key : String?, args_public_key : String? = nil, account : Int32? = nil) : {String, String?} - # Tier 1: explicit -p/-k flags - if args_public_key && !args_public_key.empty? && args_key && !args_key.empty? - return {args_public_key, args_key} - end - - # Tier 2: --account N → accounts.csv row N (bypasses env vars) - if !account.nil? - idx = account.not_nil! - home = ENV["HOME"]? - if home && !home.empty? - result = load_accounts_csv(File.join(home, ".unsandbox", "accounts.csv"), idx) - return {result[0], result[1]} if result - end - result = load_accounts_csv("accounts.csv", idx) - return {result[0], result[1]} if result - STDERR.puts "#{RED}Error: --account #{idx} not found in accounts.csv#{RESET}" - exit 1 - end - - # Tier 3: env vars - env_pk = ENV["UNSANDBOX_PUBLIC_KEY"]? - env_sk = ENV["UNSANDBOX_SECRET_KEY"]? - if env_pk && !env_pk.empty? && env_sk && !env_sk.empty? - return {env_pk, env_sk} - end - - # Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var) - home = ENV["HOME"]? - def_index = (ENV["UNSANDBOX_ACCOUNT"]?.try(&.to_i?) || 0).to_i32 - if home && !home.empty? - result = load_accounts_csv(File.join(home, ".unsandbox", "accounts.csv"), def_index) - return {result[0], result[1]} if result - end - - # Tier 5: ./accounts.csv row 0 - result = load_accounts_csv("accounts.csv", def_index) - return {result[0], result[1]} if result - - # Legacy UNSANDBOX_API_KEY fallback - legacy_key = args_key || ENV["UNSANDBOX_API_KEY"]? - if legacy_key && !legacy_key.empty? return {legacy_key, nil} end - STDERR.puts "#{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{RESET}" - exit 1 -end - -def extract_challenge_id(response_body : String) : String? - begin - parsed = JSON.parse(response_body) - parsed["challenge_id"]?.try(&.as_s?) - rescue - nil - end -end - -def handle_sudo_challenge(response_body : String, public_key : String, secret_key : String?, method : String, endpoint : String, body : String?) : JSON::Any - challenge_id = extract_challenge_id(response_body) - - STDERR.puts "#{YELLOW}Confirmation required. Check your email for a one-time code.#{RESET}" - STDERR.print "Enter OTP: " - - otp = STDIN.gets - if otp.nil? || otp.strip.empty? - STDERR.puts "#{RED}Error: Operation cancelled#{RESET}" - exit 1 - end - otp = otp.strip - - url = URI.parse(API_BASE + endpoint) - headers = HTTP::Headers{ - "Content-Type" => "application/json" - } - - request_body = body || "" - - # Add HMAC authentication headers - if secret_key && !secret_key.empty? - timestamp = Time.utc.to_unix.to_s - message = "#{timestamp}:#{method}:#{endpoint}:#{request_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 - - # Add sudo headers - headers["X-Sudo-OTP"] = otp - if challenge_id - headers["X-Sudo-Challenge"] = challenge_id - end - - begin - response = case method - when "GET" - HTTP::Client.get(url, headers: headers) - when "POST" - HTTP::Client.post(url, headers: headers, body: request_body) - when "PATCH" - HTTP::Client.patch(url, headers: headers, body: request_body) - when "DELETE" - HTTP::Client.delete(url, headers: headers) - else - STDERR.puts "#{RED}Error: Unsupported method: #{method}#{RESET}" - exit 1 - end - - if response.status_code >= 200 && response.status_code < 300 - STDERR.puts "#{GREEN}Operation completed successfully#{RESET}" - JSON.parse(response.body) - else - STDERR.puts "#{RED}Error: HTTP #{response.status_code}#{RESET}" - begin - error_json = JSON.parse(response.body) - if error_msg = error_json["error"]?.try(&.as_s?) - STDERR.puts error_msg - else - STDERR.puts response.body - end - rescue - STDERR.puts response.body - end - exit 1 - end - rescue ex - STDERR.puts "#{RED}Error: #{ex.message}#{RESET}" - exit 1 - end -end - -def api_request_with_sudo(endpoint : String, public_key : String, secret_key : String?, method = "GET", data : JSON::Any? = nil) : {Int32, JSON::Any} - url = URI.parse(API_BASE + endpoint) - headers = HTTP::Headers{ - "Content-Type" => "application/json" - } - - body = data ? data.to_json : "" - - # Add HMAC authentication headers if secret_key is provided - if secret_key && !secret_key.empty? - timestamp = Time.utc.to_unix.to_s - message = "#{timestamp}:#{method}:#{endpoint}:#{body}" - - signature = OpenSSL::HMAC.hexdigest(:sha256, secret_key, message) - - headers["Authorization"] = "Bearer #{public_key}" - headers["X-Timestamp"] = timestamp - headers["X-Signature"] = signature - else - # Legacy API key authentication - headers["Authorization"] = "Bearer #{public_key}" - end - - begin - response = case method - when "GET" - HTTP::Client.get(url, headers: headers) - when "POST" - HTTP::Client.post(url, headers: headers, body: body) - when "PATCH" - HTTP::Client.patch(url, headers: headers, body: body) - when "DELETE" - HTTP::Client.delete(url, headers: headers) - else - STDERR.puts "#{RED}Error: Unsupported method: #{method}#{RESET}" - exit 1 - end - - {response.status_code, JSON.parse(response.body)} - rescue ex - error_msg = ex.message || "" - if error_msg.downcase.includes?("timestamp") - STDERR.puts "#{RED}Error: Request timestamp expired (must be within 5 minutes of server time)#{RESET}" - STDERR.puts "#{YELLOW}Your computer's clock may have drifted.#{RESET}" - STDERR.puts "Check your system time and sync with NTP if needed:" - STDERR.puts " Linux: sudo ntpdate -s time.nist.gov" - STDERR.puts " macOS: sudo sntp -sS time.apple.com" - STDERR.puts " Windows: w32tm /resync" - else - STDERR.puts "#{RED}Error: Request failed: #{ex.message}#{RESET}" - end - exit 1 - end + {public_key, secret_key} end def api_request(endpoint : String, public_key : String, secret_key : String?, method = "GET", data : JSON::Any? = nil) @@ -445,7 +253,7 @@ def build_env_content(envs : Array(String), env_file : String?) : String end def cmd_service_env(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String)) action = args[:env_action]?.as?(String) || "" target = args[:env_target]?.as?(String) || "" @@ -514,7 +322,7 @@ def cmd_service_env(args) end def cmd_execute(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) + public_key, secret_key = get_api_keys(args[:api_key]?) filename = args[:source_file].as(String) unless File.exists?(filename) @@ -604,7 +412,7 @@ def cmd_execute(args) end def cmd_session(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) + public_key, secret_key = get_api_keys(args[:api_key]?) if args[:list]?.as?(Bool) result = api_request("/sessions", public_key, secret_key) @@ -624,57 +432,12 @@ def cmd_session(args) return end - if info_id = args[:session_info]?.as?(String) - result = api_request("/sessions/#{info_id}", public_key, secret_key) - puts result.to_pretty_json - return - end - if kill_id = args[:kill]?.as?(String) api_request("/sessions/#{kill_id}", public_key, secret_key, method: "DELETE") puts "#{GREEN}Session terminated: #{kill_id}#{RESET}" return end - if freeze_id = args[:session_freeze]?.as?(String) - api_request("/sessions/#{freeze_id}/freeze", public_key, secret_key, method: "POST") - puts "#{GREEN}Session frozen: #{freeze_id}#{RESET}" - return - end - - if unfreeze_id = args[:session_unfreeze]?.as?(String) - api_request("/sessions/#{unfreeze_id}/unfreeze", public_key, secret_key, method: "POST") - puts "#{GREEN}Session unfreezing: #{unfreeze_id}#{RESET}" - return - end - - if boost_id = args[:session_boost]?.as?(String) - vcpu = args[:vcpu]?.as?(Int32) || 2 - payload = JSON.parse({vcpu: vcpu}.to_json) - api_request("/sessions/#{boost_id}/boost", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Session boosted to #{vcpu} vCPU: #{boost_id}#{RESET}" - return - end - - if unboost_id = args[:session_unboost]?.as?(String) - api_request("/sessions/#{unboost_id}/unboost", public_key, secret_key, method: "POST") - puts "#{GREEN}Session unboosted: #{unboost_id}#{RESET}" - return - end - - if execute_id = args[:session_execute]?.as?(String) - command = args[:command]?.as?(String) || "" - payload = JSON.parse({command: command}.to_json) - result = api_request("/sessions/#{execute_id}/execute", public_key, secret_key, method: "POST", data: payload) - if stdout = result["stdout"]?.try(&.as_s?) - print BLUE, stdout, RESET - end - if stderr = result["stderr"]?.try(&.as_s?) - print RED, stderr, RESET - end - return - end - # Create new session payload = JSON.parse({shell: "bash"}.to_json) @@ -682,10 +445,6 @@ def cmd_session(args) payload.as_h["network"] = JSON::Any.new(network) end - if shell = args[:shell]?.as?(String) - payload.as_h["shell"] = JSON::Any.new(shell) - end - # Add input files if files = args[:files]?.as?(Array(String)) input_files = [] of JSON::Any @@ -712,7 +471,7 @@ def cmd_session(args) end def cmd_languages(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) + public_key, secret_key = get_api_keys(args[:api_key]?) # Try to load from cache first cached_response = load_languages_cache @@ -745,7 +504,7 @@ def cmd_languages(args) end def cmd_key(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) + public_key, secret_key = get_api_keys(args[:api_key]?) # Validate key url = URI.parse(PORTAL_BASE + "/keys/validate") @@ -839,7 +598,7 @@ def cmd_key(args) end def cmd_image(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) + public_key, secret_key = get_api_keys(args[:api_key]?) if args[:list]?.as?(Bool) result = api_request("/images", public_key, secret_key) @@ -854,16 +613,8 @@ def cmd_image(args) end if del_id = args[:image_delete]?.as?(String) - status_code, response = api_request_with_sudo("/images/#{del_id}", public_key, secret_key, method: "DELETE") - if status_code == 428 - handle_sudo_challenge(response.to_json, public_key, secret_key, "DELETE", "/images/#{del_id}", nil) - elsif status_code >= 200 && status_code < 300 - puts "#{GREEN}Image deleted: #{del_id}#{RESET}" - else - STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" - STDERR.puts response.to_json - exit 1 - end + api_request("/images/#{del_id}", public_key, secret_key, method: "DELETE") + puts "#{GREEN}Image deleted: #{del_id}#{RESET}" return end @@ -876,17 +627,8 @@ def cmd_image(args) if unlock_id = args[:image_unlock]?.as?(String) payload = JSON.parse({}.to_json) - body = "{}" - status_code, response = api_request_with_sudo("/images/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload) - if status_code == 428 - handle_sudo_challenge(response.to_json, public_key, secret_key, "POST", "/images/#{unlock_id}/unlock", body) - elsif status_code >= 200 && status_code < 300 - puts "#{GREEN}Image unlocked: #{unlock_id}#{RESET}" - else - STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" - STDERR.puts response.to_json - exit 1 - end + api_request("/images/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload) + puts "#{GREEN}Image unlocked: #{unlock_id}#{RESET}" return end @@ -944,291 +686,11 @@ def cmd_image(args) return end - if grant_id = args[:image_grant]?.as?(String) - trusted_key = args[:image_trusted_key]?.as?(String) - if trusted_key.nil? || trusted_key.empty? - STDERR.puts "#{RED}Error: --grant requires --trusted-key#{RESET}" - exit 1 - end - payload = JSON.parse({trusted_api_key: trusted_key}.to_json) - api_request("/images/#{grant_id}/grant", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Access granted to #{trusted_key}#{RESET}" - return - end - - if revoke_id = args[:image_revoke]?.as?(String) - trusted_key = args[:image_trusted_key]?.as?(String) - if trusted_key.nil? || trusted_key.empty? - STDERR.puts "#{RED}Error: --revoke requires --trusted-key#{RESET}" - exit 1 - end - payload = JSON.parse({trusted_api_key: trusted_key}.to_json) - api_request("/images/#{revoke_id}/revoke", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Access revoked from #{trusted_key}#{RESET}" - return - end - - if trusted_id = args[:image_trusted]?.as?(String) - result = api_request("/images/#{trusted_id}/trusted", public_key, secret_key) - puts result.to_pretty_json - return - end - - if transfer_id = args[:image_transfer]?.as?(String) - to_key = args[:image_to_key]?.as?(String) - if to_key.nil? || to_key.empty? - STDERR.puts "#{RED}Error: --transfer requires --to-key#{RESET}" - exit 1 - end - payload = JSON.parse({to_api_key: to_key}.to_json) - status_code, response = api_request_with_sudo("/images/#{transfer_id}/transfer", public_key, secret_key, method: "POST", data: payload) - if status_code == 428 - handle_sudo_challenge(response.to_json, public_key, secret_key, "POST", "/images/#{transfer_id}/transfer", payload.to_json) - elsif status_code >= 200 && status_code < 300 - puts "#{GREEN}Image transferred to #{to_key}#{RESET}" - else - STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" - STDERR.puts response.to_json - exit 1 - end - return - end - # Default: list images result = api_request("/images", public_key, secret_key) puts result.to_pretty_json end -def cmd_snapshot(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) - - if args[:list]?.as?(Bool) - result = api_request("/snapshots", public_key, secret_key) - snapshots = result["snapshots"]?.try(&.as_a?) || [] of JSON::Any - if snapshots.empty? - puts "No snapshots" - else - printf "%-20s %-20s %-10s %-10s %-10s %s\n", "ID", "Name", "Type", "Hot", "Locked", "Created" - snapshots.each do |s| - printf "%-20s %-20s %-10s %-10s %-10s %s\n", - s["id"]?.try(&.as_s?) || "N/A", - s["name"]?.try(&.as_s?) || "N/A", - s["type"]?.try(&.as_s?) || "N/A", - s["hot"]?.try(&.as_bool?) ? "yes" : "no", - s["locked"]?.try(&.as_bool?) ? "yes" : "no", - s["created_at"]?.try(&.as_s?) || "N/A" - end - end - return - end - - if info_id = args[:snapshot_info]?.as?(String) - result = api_request("/snapshots/#{info_id}", public_key, secret_key) - puts result.to_pretty_json - return - end - - if session_id = args[:snapshot_session]?.as?(String) - payload = JSON.parse({}.to_json) - if name = args[:snapshot_name]?.as?(String) - payload.as_h["name"] = JSON::Any.new(name) - end - if args[:snapshot_hot]?.as?(Bool) - payload.as_h["hot"] = JSON::Any.new(true) - end - result = api_request("/sessions/#{session_id}/snapshot", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Snapshot created#{RESET}" - puts result.to_pretty_json - return - end - - if service_id = args[:snapshot_service]?.as?(String) - payload = JSON.parse({}.to_json) - if name = args[:snapshot_name]?.as?(String) - payload.as_h["name"] = JSON::Any.new(name) - end - if args[:snapshot_hot]?.as?(Bool) - payload.as_h["hot"] = JSON::Any.new(true) - end - result = api_request("/services/#{service_id}/snapshot", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Snapshot created#{RESET}" - puts result.to_pretty_json - return - end - - if restore_id = args[:snapshot_restore]?.as?(String) - payload = JSON.parse({}.to_json) - result = api_request("/snapshots/#{restore_id}/restore", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Snapshot restored#{RESET}" - puts result.to_pretty_json - return - end - - if del_id = args[:snapshot_delete]?.as?(String) - status_code, response = api_request_with_sudo("/snapshots/#{del_id}", public_key, secret_key, method: "DELETE") - if status_code == 428 - handle_sudo_challenge(response.to_json, public_key, secret_key, "DELETE", "/snapshots/#{del_id}", nil) - elsif status_code >= 200 && status_code < 300 - puts "#{GREEN}Snapshot deleted: #{del_id}#{RESET}" - else - STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" - STDERR.puts response.to_json - exit 1 - end - return - end - - if lock_id = args[:snapshot_lock]?.as?(String) - payload = JSON.parse({}.to_json) - api_request("/snapshots/#{lock_id}/lock", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Snapshot locked: #{lock_id}#{RESET}" - return - end - - if unlock_id = args[:snapshot_unlock]?.as?(String) - payload = JSON.parse({}.to_json) - body = "{}" - status_code, response = api_request_with_sudo("/snapshots/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload) - if status_code == 428 - handle_sudo_challenge(response.to_json, public_key, secret_key, "POST", "/snapshots/#{unlock_id}/unlock", body) - elsif status_code >= 200 && status_code < 300 - puts "#{GREEN}Snapshot unlocked: #{unlock_id}#{RESET}" - else - STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" - STDERR.puts response.to_json - exit 1 - end - return - end - - if clone_id = args[:snapshot_clone]?.as?(String) - clone_type = args[:snapshot_clone_type]?.as?(String) || "session" - payload = JSON.parse({clone_type: clone_type}.to_json) - if name = args[:snapshot_name]?.as?(String) - payload.as_h["name"] = JSON::Any.new(name) - end - if ports_str = args[:snapshot_ports]?.as?(String) - ports = ports_str.split(',').map(&.to_i) - payload.as_h["ports"] = JSON.parse(ports.to_json) - end - result = api_request("/snapshots/#{clone_id}/clone", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Snapshot cloned#{RESET}" - puts result.to_pretty_json - return - end - - # Default: list snapshots - result = api_request("/snapshots", public_key, secret_key) - puts result.to_pretty_json -end - -def cmd_logs(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) - - source = args[:logs_source]?.as?(String) || "all" - lines = args[:logs_lines]?.as?(Int32) || 100 - since = args[:logs_since]?.as?(String) || "1h" - grep_pattern = args[:logs_grep]?.as?(String) - - endpoint = "/paas/logs?source=#{source}&lines=#{lines}&since=#{since}" - if grep_pattern && !grep_pattern.empty? - endpoint += "&grep=#{URI.encode_path(grep_pattern)}" - end - - if args[:logs_follow]?.as?(Bool) - # Streaming logs via SSE - stream_endpoint = "/paas/logs/stream?source=#{source}" - if grep_pattern && !grep_pattern.empty? - stream_endpoint += "&grep=#{URI.encode_path(grep_pattern)}" - end - - url = URI.parse(PORTAL_BASE + stream_endpoint) - headers = HTTP::Headers{ - "Accept" => "text/event-stream" - } - - # Add HMAC authentication headers - if secret_key && !secret_key.empty? - timestamp = Time.utc.to_unix.to_s - message = "#{timestamp}:GET:#{stream_endpoint}:" - 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 - HTTP::Client.get(url, headers: headers) do |response| - if response.status_code == 200 - response.body_io.each_line do |line| - if line.starts_with?("data: ") - data = line[6..] - begin - parsed = JSON.parse(data) - src = parsed["source"]?.try(&.as_s?) || "unknown" - msg = parsed["line"]?.try(&.as_s?) || data - puts "[#{src}] #{msg}" - rescue - puts line - end - end - end - else - STDERR.puts "#{RED}Error: HTTP #{response.status_code}#{RESET}" - STDERR.puts response.body_io.gets_to_end - exit 1 - end - end - rescue ex - STDERR.puts "#{RED}Error: #{ex.message}#{RESET}" - exit 1 - end - else - # Batch fetch - result = api_request(endpoint, public_key, secret_key) - if logs = result["logs"]?.try(&.as_a?) - logs.each do |log| - src = log["source"]?.try(&.as_s?) || "unknown" - msg = log["line"]?.try(&.as_s?) || log.to_json - ts = log["timestamp"]?.try(&.as_s?) || "" - if ts.empty? - puts "[#{src}] #{msg}" - else - puts "[#{ts}] [#{src}] #{msg}" - end - end - else - puts result.to_pretty_json - end - end -end - -def cmd_health(args) - begin - url = URI.parse(API_BASE + "/health") - response = HTTP::Client.get(url) - if response.status_code == 200 - puts "#{GREEN}API is healthy#{RESET}" - result = JSON.parse(response.body) - puts result.to_pretty_json - else - puts "#{RED}API is unhealthy: HTTP #{response.status_code}#{RESET}" - exit 1 - end - rescue ex - puts "#{RED}API is unreachable: #{ex.message}#{RESET}" - exit 1 - end -end - -def cmd_version(args) - puts "un.cr version 1.0.0" - puts "API: #{API_BASE}" - puts "Portal: #{PORTAL_BASE}" -end - def cmd_service(args) # Handle env subcommand if env_action = args[:env_action]?.as?(String) @@ -1238,7 +700,7 @@ def cmd_service(args) end end - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) + public_key, secret_key = get_api_keys(args[:api_key]?) if args[:list]?.as?(Bool) result = api_request("/services", public_key, secret_key) @@ -1294,16 +756,8 @@ def cmd_service(args) end if destroy_id = args[:destroy]?.as?(String) - status_code, response = api_request_with_sudo("/services/#{destroy_id}", public_key, secret_key, method: "DELETE") - if status_code == 428 - handle_sudo_challenge(response.to_json, public_key, secret_key, "DELETE", "/services/#{destroy_id}", nil) - elsif status_code >= 200 && status_code < 300 - puts "#{GREEN}Service destroyed: #{destroy_id}#{RESET}" - else - STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" - STDERR.puts response.to_json - exit 1 - end + api_request("/services/#{destroy_id}", public_key, secret_key, method: "DELETE") + puts "#{GREEN}Service destroyed: #{destroy_id}#{RESET}" return end @@ -1353,48 +807,6 @@ def cmd_service(args) return end - if lock_id = args[:service_lock]?.as?(String) - payload = JSON.parse({}.to_json) - api_request("/services/#{lock_id}/lock", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Service locked: #{lock_id}#{RESET}" - return - end - - if unlock_id = args[:service_unlock]?.as?(String) - payload = JSON.parse({}.to_json) - body = "{}" - status_code, response = api_request_with_sudo("/services/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload) - if status_code == 428 - handle_sudo_challenge(response.to_json, public_key, secret_key, "POST", "/services/#{unlock_id}/unlock", body) - elsif status_code >= 200 && status_code < 300 - puts "#{GREEN}Service unlocked: #{unlock_id}#{RESET}" - else - STDERR.puts "#{RED}Error: HTTP #{status_code}#{RESET}" - STDERR.puts response.to_json - exit 1 - end - return - end - - if redeploy_id = args[:redeploy]?.as?(String) - payload = JSON.parse({}.to_json) - if bootstrap = args[:bootstrap]?.as?(String) - payload.as_h["bootstrap"] = JSON::Any.new(bootstrap) - end - if bootstrap_file = args[:bootstrap_file]?.as?(String) - if File.exists?(bootstrap_file) - payload.as_h["bootstrap_content"] = JSON::Any.new(File.read(bootstrap_file)) - else - STDERR.puts "#{RED}Error: Bootstrap file not found: #{bootstrap_file}#{RESET}" - exit 1 - end - end - result = api_request("/services/#{redeploy_id}/redeploy", public_key, secret_key, method: "POST", data: payload) - puts "#{GREEN}Service redeployed: #{redeploy_id}#{RESET}" - puts result.to_pretty_json - return - end - # Create new service if name = args[:name]?.as?(String) payload = JSON.parse({name: name}.to_json) @@ -1492,8 +904,6 @@ def main args = { source_file: nil, api_key: nil, - public_key: nil, - account: nil, network: nil, env: [] of String, files: [] of String, @@ -1527,19 +937,6 @@ def main env_action: nil, env_target: nil, json: false, - shell: nil, - # Session options - session_info: nil, - session_freeze: nil, - session_unfreeze: nil, - session_boost: nil, - session_unboost: nil, - session_execute: nil, - # Service options - service_lock: nil, - service_unlock: nil, - redeploy: nil, - # Image options image_info: nil, image_delete: nil, image_lock: nil, @@ -1551,40 +948,13 @@ def main image_spawn: nil, image_clone: nil, image_name: nil, - image_ports: nil, - image_grant: nil, - image_revoke: nil, - image_trusted: nil, - image_trusted_key: nil, - image_transfer: nil, - image_to_key: nil, - # Snapshot options - snapshot_info: nil, - snapshot_session: nil, - snapshot_service: nil, - snapshot_restore: nil, - snapshot_delete: nil, - snapshot_lock: nil, - snapshot_unlock: nil, - snapshot_clone: nil, - snapshot_clone_type: nil, - snapshot_name: nil, - snapshot_hot: false, - snapshot_ports: nil, - # Logs options - logs_source: nil, - logs_lines: nil, - logs_since: nil, - logs_grep: nil, - logs_follow: false - } of Symbol => (String | Array(String) | Bool | Int32 | Nil) + image_ports: nil + } of Symbol => (String | Array(String) | Bool | Nil) parser = OptionParser.new do |opts| - opts.banner = "Usage: un.cr [options] \n un.cr languages [--json]\n un.cr session [options]\n un.cr service [options]\n un.cr service env [options]\n un.cr snapshot [options]\n un.cr image [options]\n un.cr logs [options]\n un.cr key [options]\n un.cr health\n un.cr version\n\nService env commands:\n env status Show vault status\n env set Set vault (-e KEY=VALUE or --env-file FILE)\n env export Export vault contents\n env delete Delete vault" + opts.banner = "Usage: un.cr [options] \n un.cr languages [--json]\n un.cr session [options]\n un.cr service [options]\n un.cr service env [options]\n un.cr key [options]\n\nService env commands:\n env status Show vault status\n env set Set vault (-e KEY=VALUE or --env-file FILE)\n env export Export vault contents\n env delete Delete vault" - opts.on("-k API_KEY", "--api-key=API_KEY", "Secret/API key") { |k| args[:api_key] = k } - opts.on("-p PUBLIC_KEY", "--public-key=PUBLIC_KEY", "Public key (use with -k for secret key)") { |k| args[:public_key] = k } - opts.on("--account=N", "Use row N from accounts.csv (0-based)") { |n| args[:account] = n.to_i32 } + 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("-e ENV", "--env=ENV", "Environment variable (KEY=VALUE)") { |e| args[:env].as(Array(String)) << e @@ -1595,21 +965,21 @@ def main opts.on("-o DIR", "--output-dir=DIR", "Output directory") { |d| args[:output_dir] = d } opts.on("-l", "--list", "List items") { args[:list] = true } opts.on("--kill=ID", "Kill session") { |id| args[:kill] = id } - opts.on("--info=ID", "Get service/session info") { |id| args[:info] = id } + opts.on("--info=ID", "Get service info") { |id| args[:info] = id } opts.on("--logs=ID", "Get service logs") { |id| args[:logs] = id } - opts.on("--freeze=ID", "Freeze service/session") { |id| args[:sleep] = id } - opts.on("--unfreeze=ID", "Unfreeze service/session") { |id| args[:wake] = id } + opts.on("--freeze=ID", "Sleep service") { |id| args[:sleep] = id } + opts.on("--unfreeze=ID", "Wake service") { |id| args[:wake] = id } opts.on("--unfreeze-on-demand=ID", "Set unfreeze-on-demand for service") { |id| args[:unfreeze_on_demand] = id } opts.on("--unfreeze-on-demand-enabled=BOOL", "Enable/disable unfreeze-on-demand (default: true)") { |b| args[:unfreeze_on_demand_enabled] = b.downcase == "true" } opts.on("--with-unfreeze-on-demand", "Enable unfreeze-on-demand when creating service") { args[:create_unfreeze_on_demand] = true } opts.on("--destroy=ID", "Destroy service") { |id| args[:destroy] = id } - opts.on("--execute=ID", "Execute command in service/session") { |id| args[:execute] = id } + opts.on("--execute=ID", "Execute command in service") { |id| args[:execute] = id } opts.on("--command=CMD", "Command to execute (with --execute)") { |cmd| args[:command] = cmd } opts.on("--dump-bootstrap=ID", "Dump bootstrap script") { |id| args[:dump_bootstrap] = id } opts.on("--dump-file=FILE", "File to save bootstrap (with --dump-bootstrap)") { |file| args[:dump_file] = file } opts.on("--resize=ID", "Resize service vCPU") { |id| args[:resize] = id } - opts.on("-v VCPU", "--vcpu=VCPU", "vCPU count (1-8) for resize/boost") { |v| args[:vcpu] = v.to_i } - opts.on("--name=NAME", "Service/snapshot name") { |n| args[:name] = n } + opts.on("-v VCPU", "--vcpu=VCPU", "vCPU count (1-8) for resize") { |v| args[:vcpu] = v.to_i } + opts.on("--name=NAME", "Service name") { |n| args[:name] = n } opts.on("--ports=PORTS", "Comma-separated ports") { |p| args[:ports] = p } opts.on("--domains=DOMAINS", "Comma-separated domains") { |d| args[:domains] = d } opts.on("--type=TYPE", "Service type for SRV records") { |t| args[:service_type] = t } @@ -1618,99 +988,12 @@ def main 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("--json", "Output as JSON array (for languages command)") { args[:json] = true } - opts.on("--shell=SHELL", "Shell for session (bash, python3, etc.)") { |s| args[:shell] = s } - opts.on("--lock=ID", "Lock service/snapshot/image") { |id| args[:service_lock] = id } - opts.on("--unlock=ID", "Unlock service/snapshot/image") { |id| args[:service_unlock] = id } - opts.on("--redeploy=ID", "Redeploy service") { |id| args[:redeploy] = id } - opts.on("--boost=ID", "Boost session vCPU") { |id| args[:session_boost] = id } - opts.on("--unboost=ID", "Unboost session") { |id| args[:session_unboost] = id } - # Snapshot options - opts.on("--snapshot-session=ID", "Create snapshot from session") { |id| args[:snapshot_session] = id } - opts.on("--snapshot-service=ID", "Create snapshot from service") { |id| args[:snapshot_service] = id } - opts.on("--restore=ID", "Restore snapshot") { |id| args[:snapshot_restore] = id } - opts.on("--delete=ID", "Delete snapshot") { |id| args[:snapshot_delete] = id } - opts.on("--clone=ID", "Clone snapshot") { |id| args[:snapshot_clone] = id } - opts.on("--clone-type=TYPE", "Clone type (session or service)") { |t| args[:snapshot_clone_type] = t } - opts.on("--hot", "Create hot snapshot") { args[:snapshot_hot] = true } - # Logs options - opts.on("--source=SOURCE", "Log source (all, api, portal, pool/cammy, pool/ai)") { |s| args[:logs_source] = s } - opts.on("--lines=N", "Number of log lines") { |n| args[:logs_lines] = n.to_i } - opts.on("--since=TIME", "Time window (1m, 5m, 1h, 1d)") { |t| args[:logs_since] = t } - opts.on("--grep=PATTERN", "Filter pattern") { |p| args[:logs_grep] = p } - opts.on("--follow", "Follow log stream") { args[:logs_follow] = true } - # Image access options - opts.on("--grant=ID", "Grant image access") { |id| args[:image_grant] = id } - opts.on("--revoke=ID", "Revoke image access") { |id| args[:image_revoke] = id } - opts.on("--trusted=ID", "List trusted keys for image") { |id| args[:image_trusted] = id } - opts.on("--trusted-key=KEY", "API key to grant/revoke access") { |k| args[:image_trusted_key] = k } - opts.on("--transfer=ID", "Transfer image ownership") { |id| args[:image_transfer] = id } - opts.on("--to-key=KEY", "Target API key for transfer") { |k| args[:image_to_key] = k } opts.unknown_args do |before, after| if before.size > 0 case before[0] when "session" args[:command] = "session" - # Parse session subcommand options - i = 1 - while i < before.size - case before[i] - when "--list", "-l" - args[:list] = true - i += 1 - when "--info" - if i + 1 < before.size - args[:session_info] = before[i + 1] - i += 2 - else - i += 1 - end - when "--freeze" - if i + 1 < before.size - args[:session_freeze] = before[i + 1] - i += 2 - else - i += 1 - end - when "--unfreeze" - if i + 1 < before.size - args[:session_unfreeze] = before[i + 1] - i += 2 - else - i += 1 - end - when "--boost" - if i + 1 < before.size - args[:session_boost] = before[i + 1] - i += 2 - else - i += 1 - end - when "--unboost" - if i + 1 < before.size - args[:session_unboost] = before[i + 1] - i += 2 - else - i += 1 - end - when "--execute" - if i + 1 < before.size - args[:session_execute] = before[i + 1] - i += 2 - else - i += 1 - end - when "--command" - if i + 1 < before.size - args[:command] = before[i + 1] - i += 2 - else - i += 1 - end - else - i += 1 - end - end when "service" args[:command] = "service" # Check for env subcommand @@ -1731,179 +1014,11 @@ def main i += 1 end end - else - # Parse service subcommand options - i = 1 - while i < before.size - case before[i] - when "--lock" - if i + 1 < before.size - args[:service_lock] = before[i + 1] - i += 2 - else - i += 1 - end - when "--unlock" - if i + 1 < before.size - args[:service_unlock] = before[i + 1] - i += 2 - else - i += 1 - end - when "--redeploy" - if i + 1 < before.size - args[:redeploy] = before[i + 1] - i += 2 - else - i += 1 - end - else - i += 1 - end - end end when "key" args[:command] = "key" when "languages" args[:command] = "languages" - when "snapshot" - args[:command] = "snapshot" - # Parse snapshot subcommand options - i = 1 - while i < before.size - case before[i] - when "--list", "-l" - args[:list] = true - i += 1 - when "--info" - if i + 1 < before.size - args[:snapshot_info] = before[i + 1] - i += 2 - else - i += 1 - end - when "--session" - if i + 1 < before.size - args[:snapshot_session] = before[i + 1] - i += 2 - else - i += 1 - end - when "--service" - if i + 1 < before.size - args[:snapshot_service] = before[i + 1] - i += 2 - else - i += 1 - end - when "--restore" - if i + 1 < before.size - args[:snapshot_restore] = before[i + 1] - i += 2 - else - i += 1 - end - when "--delete" - if i + 1 < before.size - args[:snapshot_delete] = before[i + 1] - i += 2 - else - i += 1 - end - when "--lock" - if i + 1 < before.size - args[:snapshot_lock] = before[i + 1] - i += 2 - else - i += 1 - end - when "--unlock" - if i + 1 < before.size - args[:snapshot_unlock] = before[i + 1] - i += 2 - else - i += 1 - end - when "--clone" - if i + 1 < before.size - args[:snapshot_clone] = before[i + 1] - i += 2 - else - i += 1 - end - when "--clone-type" - if i + 1 < before.size - args[:snapshot_clone_type] = before[i + 1] - i += 2 - else - i += 1 - end - when "--name" - if i + 1 < before.size - args[:snapshot_name] = before[i + 1] - i += 2 - else - i += 1 - end - when "--hot" - args[:snapshot_hot] = true - i += 1 - when "--ports" - if i + 1 < before.size - args[:snapshot_ports] = before[i + 1] - i += 2 - else - i += 1 - end - else - i += 1 - end - end - when "logs" - args[:command] = "logs" - # Parse logs subcommand options - i = 1 - while i < before.size - case before[i] - when "--source" - if i + 1 < before.size - args[:logs_source] = before[i + 1] - i += 2 - else - i += 1 - end - when "--lines" - if i + 1 < before.size - args[:logs_lines] = before[i + 1].to_i - i += 2 - else - i += 1 - end - when "--since" - if i + 1 < before.size - args[:logs_since] = before[i + 1] - i += 2 - else - i += 1 - end - when "--grep" - if i + 1 < before.size - args[:logs_grep] = before[i + 1] - i += 2 - else - i += 1 - end - when "--follow", "-f" - args[:logs_follow] = true - i += 1 - else - i += 1 - end - end - when "health" - args[:command] = "health" - when "version" - args[:command] = "version" when "image" args[:command] = "image" # Parse image subcommand options @@ -1991,48 +1106,6 @@ def main else i += 1 end - when "--grant" - if i + 1 < before.size - args[:image_grant] = before[i + 1] - i += 2 - else - i += 1 - end - when "--revoke" - if i + 1 < before.size - args[:image_revoke] = before[i + 1] - i += 2 - else - i += 1 - end - when "--trusted" - if i + 1 < before.size - args[:image_trusted] = before[i + 1] - i += 2 - else - i += 1 - end - when "--trusted-key" - if i + 1 < before.size - args[:image_trusted_key] = before[i + 1] - i += 2 - else - i += 1 - end - when "--transfer" - if i + 1 < before.size - args[:image_transfer] = before[i + 1] - i += 2 - else - i += 1 - end - when "--to-key" - if i + 1 < before.size - args[:image_to_key] = before[i + 1] - i += 2 - else - i += 1 - end else i += 1 end @@ -2061,14 +1134,6 @@ def main cmd_languages(args) elsif args[:command] == "image" cmd_image(args) - elsif args[:command] == "snapshot" - cmd_snapshot(args) - elsif args[:command] == "logs" - cmd_logs(args) - elsif args[:command] == "health" - cmd_health(args) - elsif args[:command] == "version" - cmd_version(args) elsif args[:source_file] cmd_execute(args) else diff --git a/clients/csharp/sync/src/Un.cs b/clients/csharp/sync/src/Un.cs index 327e4f6..40b61b8 100644 --- a/clients/csharp/sync/src/Un.cs +++ b/clients/csharp/sync/src/Un.cs @@ -92,10 +92,6 @@ class Un { CmdKey(parsedArgs); } - else if (parsedArgs.Command == "languages") - { - CmdLanguages(parsedArgs); - } else if (parsedArgs.SourceFile != null) { CmdExecute(parsedArgs); @@ -115,7 +111,7 @@ class Un static void CmdExecute(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); string code = File.ReadAllText(args.SourceFile); string language = DetectLanguage(args.SourceFile); @@ -202,7 +198,7 @@ class Un static void CmdSession(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (args.SessionList) { @@ -255,7 +251,7 @@ class Un static void CmdKey(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); var result = ApiRequest("/keys/validate", "POST", null, publicKey, secretKey); @@ -340,7 +336,7 @@ class Un static void CmdService(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); // Handle env subcommand if (!string.IsNullOrEmpty(args.EnvAction)) @@ -423,51 +419,13 @@ class Un return; } - if (args.ServiceShowFreezePage != null) - { - var payload = new Dictionary - { - ["show_freeze_page"] = args.ServiceShowFreezePageEnabled - }; - ApiRequest($"/services/{args.ServiceShowFreezePage}", "PATCH", payload, publicKey, secretKey); - string status = args.ServiceShowFreezePageEnabled ? "enabled" : "disabled"; - Console.WriteLine($"{GREEN}Show-freeze-page {status} for service: {args.ServiceShowFreezePage}{RESET}"); - return; - } - if (args.ServiceDestroy != null) { - ApiRequestWithSudo($"/services/{args.ServiceDestroy}", "DELETE", null, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceDestroy}", "DELETE", null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); return; } - if (args.ServiceRedeploy != null) - { - var payload = new Dictionary(); - if (args.ServiceBootstrap != null) - { - payload["bootstrap"] = args.ServiceBootstrap; - } - if (args.Files.Count > 0) - { - var inputFiles = new List>(); - foreach (var filepath in args.Files) - { - var content = File.ReadAllBytes(filepath); - inputFiles.Add(new Dictionary - { - ["filename"] = Path.GetFileName(filepath), - ["content"] = Convert.ToBase64String(content) - }); - } - payload["input_files"] = inputFiles; - } - ApiRequest($"/services/{args.ServiceRedeploy}/redeploy", "POST", payload.Count > 0 ? payload : null, publicKey, secretKey); - Console.WriteLine($"{GREEN}Service redeployed: {args.ServiceRedeploy}{RESET}"); - return; - } - if (args.ServiceExecute != null) { var payload = new Dictionary @@ -559,20 +517,6 @@ class Un { payload["unfreeze_on_demand"] = true; } - if (args.Files.Count > 0) - { - var inputFiles = new List>(); - foreach (var filepath in args.Files) - { - var content = File.ReadAllBytes(filepath); - inputFiles.Add(new Dictionary - { - ["filename"] = Path.GetFileName(filepath), - ["content"] = Convert.ToBase64String(content) - }); - } - payload["input_files"] = inputFiles; - } var result = ApiRequest("/services", "POST", payload, publicKey, secretKey); string serviceId = result.ContainsKey("id") ? (string)result["id"] : null; @@ -606,77 +550,24 @@ class Un Environment.Exit(1); } - static (string, string) LoadAccountsCSV(string path, int index) + static (string, string) GetApiKeys(string argsKey) { - if (!File.Exists(path)) return (null, null); - int row = 0; - foreach (string rawLine in File.ReadAllLines(path)) - { - string line = rawLine.Trim(); - if (line.Length == 0 || line.StartsWith("#")) continue; - if (row == index) - { - string[] parts = line.Split(','); - if (parts.Length >= 2) - return (parts[0].Trim(), parts[1].Trim()); - } - row++; - } - return (null, null); - } - - static (string, string) GetApiKeys(string argsKey, int accountIndex = -1) - { - // Tier 1: explicit -p/-k flags (argsKey covers legacy -k/--api-key) - // (handled by callers that pass explicit keys directly to ApiRequest) - - // Tier 2: --account N → accounts.csv row N (bypasses env vars) - if (accountIndex >= 0) - { - string home = Environment.GetEnvironmentVariable("HOME") - ?? Environment.GetEnvironmentVariable("USERPROFILE") ?? "."; - string homeCsv = Path.Combine(home, ".unsandbox", "accounts.csv"); - var (pk1, sk1) = LoadAccountsCSV(homeCsv, accountIndex); - if (!string.IsNullOrEmpty(pk1) && !string.IsNullOrEmpty(sk1)) - return (pk1, sk1); - var (pk2, sk2) = LoadAccountsCSV("accounts.csv", accountIndex); - if (!string.IsNullOrEmpty(pk2) && !string.IsNullOrEmpty(sk2)) - return (pk2, sk2); - Console.Error.WriteLine($"{RED}Error: No account at index {accountIndex} in accounts.csv{RESET}"); - Environment.Exit(1); - } - - // Tier 3: environment variables string publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); string secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); - if (!string.IsNullOrEmpty(publicKey) && !string.IsNullOrEmpty(secretKey)) - return (publicKey, secretKey); - - // Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var) - int defaultIdx = 0; - string acctEnv = Environment.GetEnvironmentVariable("UNSANDBOX_ACCOUNT"); - if (!string.IsNullOrEmpty(acctEnv) && int.TryParse(acctEnv, out int parsedIdx)) - defaultIdx = parsedIdx; - string home2 = Environment.GetEnvironmentVariable("HOME") - ?? Environment.GetEnvironmentVariable("USERPROFILE") ?? "."; - string homeCsv2 = Path.Combine(home2, ".unsandbox", "accounts.csv"); - var (pk3, sk3) = LoadAccountsCSV(homeCsv2, defaultIdx); - if (!string.IsNullOrEmpty(pk3) && !string.IsNullOrEmpty(sk3)) - return (pk3, sk3); - - // Tier 5: ./accounts.csv row 0 - var (pk4, sk4) = LoadAccountsCSV("accounts.csv", defaultIdx); - if (!string.IsNullOrEmpty(pk4) && !string.IsNullOrEmpty(sk4)) - return (pk4, sk4); // Fall back to UNSANDBOX_API_KEY for backwards compatibility - string legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); - if (string.IsNullOrEmpty(legacyKey)) + if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) { - Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}"); - Environment.Exit(1); + string legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); + if (string.IsNullOrEmpty(legacyKey)) + { + Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}"); + Environment.Exit(1); + } + return (legacyKey, null); } - return (legacyKey, null); + + return (publicKey, secretKey); } static string DetectLanguage(string filename) @@ -694,7 +585,7 @@ class Un return ExtMap[ext]; } - static Dictionary ApiRequest(string endpoint, string method, Dictionary data, string publicKey, string secretKey, string sudoOtp = null, string sudoChallengeId = null) + static Dictionary ApiRequest(string endpoint, string method, Dictionary data, string publicKey, string secretKey) { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; @@ -731,16 +622,6 @@ class Un request.Headers.Add("Authorization", $"Bearer {publicKey}"); } - // Add sudo OTP headers if provided - if (!string.IsNullOrEmpty(sudoOtp)) - { - request.Headers.Add("X-Sudo-OTP", sudoOtp); - } - if (!string.IsNullOrEmpty(sudoChallengeId)) - { - request.Headers.Add("X-Sudo-Challenge", sudoChallengeId); - } - if (data != null) { byte[] bytes = Encoding.UTF8.GetBytes(body); @@ -790,53 +671,7 @@ class Un Environment.Exit(1); } - throw new HttpException(statusCode, error); - } - } - - // Custom exception to preserve HTTP status code - class HttpException : Exception - { - public int StatusCode { get; } - public string ResponseBody { get; } - public HttpException(int statusCode, string responseBody) : base($"HTTP {statusCode}: {responseBody}") - { - StatusCode = statusCode; - ResponseBody = responseBody; - } - } - - // Handle 428 sudo OTP challenge - prompts user for OTP and retries the request - static Dictionary HandleSudoChallenge(string responseBody, string endpoint, string method, Dictionary data, string publicKey, string secretKey) - { - var response = ParseJson(responseBody); - string challengeId = response.ContainsKey("challenge_id") ? (string)response["challenge_id"] : null; - - Console.Error.WriteLine($"{YELLOW}Confirmation required. Check your email for a one-time code.{RESET}"); - Console.Error.Write("Enter OTP: "); - - string otp = Console.ReadLine(); - if (string.IsNullOrEmpty(otp)) - { - throw new Exception("Operation cancelled"); - } - - otp = otp.Trim(); - - // Retry the request with sudo headers - return ApiRequest(endpoint, method, data, publicKey, secretKey, otp, challengeId); - } - - // Wrapper for destructive operations that may require 428 sudo OTP - static Dictionary ApiRequestWithSudo(string endpoint, string method, Dictionary data, string publicKey, string secretKey) - { - try - { - return ApiRequest(endpoint, method, data, publicKey, secretKey); - } - catch (HttpException ex) when (ex.StatusCode == 428) - { - return HandleSudoChallenge(ex.ResponseBody, endpoint, method, data, publicKey, secretKey); + throw new Exception($"HTTP error - {error}"); } } @@ -1328,16 +1163,11 @@ class Un public string ServiceDumpFile = null; public string ServiceUnfreezeOnDemand = null; public bool ServiceUnfreezeOnDemandEnabled = true; - public string ServiceShowFreezePage = null; - public bool ServiceShowFreezePageEnabled = true; public bool ServiceCreateUnfreezeOnDemand = false; - public string ServiceRedeploy = null; public string EnvFile = null; public string EnvAction = null; public string EnvTarget = null; public bool KeyExtend = false; - public bool LanguagesJson = false; - public int Account = -1; } static Args ParseArgs(string[] args) @@ -1349,7 +1179,6 @@ class Un if (arg == "session") result.Command = "session"; else if (arg == "service") result.Command = "service"; else if (arg == "key") result.Command = "key"; - else if (arg == "languages") result.Command = "languages"; else if (arg == "env" && result.Command == "service") { // Parse: service env @@ -1393,102 +1222,13 @@ class Un else if (arg == "--dump-file") result.ServiceDumpFile = args[++i]; else if (arg == "--unfreeze-on-demand") result.ServiceUnfreezeOnDemand = args[++i]; else if (arg == "--unfreeze-on-demand-enabled") result.ServiceUnfreezeOnDemandEnabled = args[++i].ToLower() == "true"; - else if (arg == "--show-freeze-page") result.ServiceShowFreezePage = args[++i]; - else if (arg == "--show-freeze-page-enabled") result.ServiceShowFreezePageEnabled = args[++i].ToLower() == "true"; else if (arg == "--with-unfreeze-on-demand") result.ServiceCreateUnfreezeOnDemand = true; - else if (arg == "--redeploy") result.ServiceRedeploy = args[++i]; else if (arg == "--extend") result.KeyExtend = true; - else if (arg == "--json") result.LanguagesJson = true; - else if (arg == "--account") result.Account = int.Parse(args[++i]); else if (!arg.StartsWith("-")) result.SourceFile = arg; } return result; } - static string GetLanguagesCachePath() - { - string home = Environment.GetEnvironmentVariable("HOME") - ?? Environment.GetEnvironmentVariable("USERPROFILE") - ?? "."; - return Path.Combine(home, ".unsandbox", "languages.json"); - } - - static List LoadLanguagesCache() - { - string cachePath = GetLanguagesCachePath(); - if (!File.Exists(cachePath)) return null; - - try - { - string content = File.ReadAllText(cachePath); - double mtime = new DateTimeOffset(File.GetLastWriteTimeUtc(cachePath)).ToUnixTimeSeconds(); - double now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - if (now - mtime < 3600) - { - var data = ParseJson(content); - if (data.ContainsKey("languages") && data["languages"] is List langs) - return langs.ConvertAll(x => x.ToString()); - } - } - catch { } - return null; - } - - static void SaveLanguagesCache(List languages) - { - try - { - string cachePath = GetLanguagesCachePath(); - string cacheDir = Path.GetDirectoryName(cachePath); - if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); - - long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var sb = new StringBuilder(); - sb.Append("{\"languages\":["); - for (int i = 0; i < languages.Count; i++) - { - if (i > 0) sb.Append(","); - sb.Append("\"").Append(languages[i]).Append("\""); - } - sb.Append("],\"timestamp\":").Append(timestamp).Append("}"); - File.WriteAllText(cachePath, sb.ToString()); - } - catch { } - } - - static void CmdLanguages(Args args) - { - // Try cache first - var languages = LoadLanguagesCache(); - - if (languages == null) - { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); - var result = ApiRequest("/languages", "GET", null, publicKey, secretKey); - languages = new List(); - if (result.ContainsKey("languages") && result["languages"] is List langs) - languages = langs.ConvertAll(x => x.ToString()); - SaveLanguagesCache(languages); - } - - if (args.LanguagesJson) - { - var sb = new StringBuilder("["); - for (int i = 0; i < languages.Count; i++) - { - if (i > 0) sb.Append(","); - sb.Append("\"").Append(languages[i]).Append("\""); - } - sb.Append("]"); - Console.WriteLine(sb.ToString()); - } - else - { - foreach (var lang in languages) - Console.WriteLine(lang); - } - } - static void PrintHelp() { Console.WriteLine(@"Usage: Un [options] @@ -1496,7 +1236,6 @@ class Un Un service [options] Un service env [options] Un key [options] - Un languages [--json] Execute options: -e KEY=VALUE Set environment variable @@ -1525,11 +1264,8 @@ Service options: --unfreeze ID Unfreeze service --unfreeze-on-demand ID Set unfreeze-on-demand for service --unfreeze-on-demand-enabled BOOL Enable/disable (default: true) - --show-freeze-page ID Set show-freeze-page for service - --show-freeze-page-enabled BOOL Enable/disable (default: true) --with-unfreeze-on-demand Enable unfreeze-on-demand when creating service --destroy ID Destroy service - --redeploy ID Re-run bootstrap (with optional --bootstrap, -f) --execute ID Execute command in service --command CMD Command to execute (with --execute) --dump-bootstrap ID Dump bootstrap script @@ -1544,1147 +1280,6 @@ Service env commands: env delete ID Delete vault Key options: - --extend Open browser to extend expired key - -Languages options: - --json Output as JSON array"); + --extend Open browser to extend expired key"); } } - -// ============================================================================= -// Library API - For embedding in other .NET applications -// ============================================================================= - -/// -/// Unsandbox SDK for C# (Mono) - Full library API matching the C reference implementation -/// -public static class Unsandbox -{ - private const string API_BASE = "https://api.unsandbox.com"; - private const string VERSION = "4.3.4"; - private static string _lastError; - - /// Extension map for language detection - public static readonly Dictionary ExtMap = new Dictionary - { - {".py", "python"}, {".js", "javascript"}, {".ts", "typescript"}, - {".rb", "ruby"}, {".php", "php"}, {".pl", "perl"}, {".lua", "lua"}, - {".sh", "bash"}, {".go", "go"}, {".rs", "rust"}, {".c", "c"}, - {".cpp", "cpp"}, {".cc", "cpp"}, {".cxx", "cpp"}, - {".java", "java"}, {".kt", "kotlin"}, {".cs", "csharp"}, {".fs", "fsharp"}, - {".ps1", "powershell"}, {".hs", "haskell"}, {".ml", "ocaml"}, - {".clj", "clojure"}, {".scm", "scheme"}, {".lisp", "commonlisp"}, - {".erl", "erlang"}, {".ex", "elixir"}, {".exs", "elixir"}, - {".jl", "julia"}, {".r", "r"}, {".R", "r"}, {".cr", "crystal"}, - {".d", "d"}, {".nim", "nim"}, {".zig", "zig"}, {".v", "v"}, - {".dart", "dart"}, {".groovy", "groovy"}, {".scala", "scala"}, - {".f90", "fortran"}, {".f95", "fortran"}, {".cob", "cobol"}, - {".pro", "prolog"}, {".forth", "forth"}, {".4th", "forth"}, - {".tcl", "tcl"}, {".raku", "raku"}, {".m", "objc"} - }; - - // --- Execution Functions (8) --- - - /// Execute code synchronously - public static ExecuteResult Execute(string language, string code, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["language"] = language, ["code"] = code }; - try - { - var result = ApiCall("/execute", "POST", payload, pk, sk); - return new ExecuteResult - { - Stdout = GetString(result, "stdout"), - Stderr = GetString(result, "stderr"), - ExitCode = GetInt(result, "exit_code"), - Language = language, - ExecutionTime = GetDouble(result, "execution_time"), - Success = true - }; - } - catch (Exception ex) { _lastError = ex.Message; return new ExecuteResult { Success = false, ErrorMessage = ex.Message }; } - } - - /// Execute code asynchronously, returns job ID - public static string ExecuteAsync(string language, string code, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["language"] = language, ["code"] = code, ["async"] = true }; - try - { - var result = ApiCall("/execute", "POST", payload, pk, sk); - return GetString(result, "job_id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - /// Wait for async job to complete - public static ExecuteResult WaitJob(string jobId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/jobs/{jobId}/wait", "GET", null, pk, sk); - return new ExecuteResult - { - Stdout = GetString(result, "stdout"), - Stderr = GetString(result, "stderr"), - ExitCode = GetInt(result, "exit_code"), - ExecutionTime = GetDouble(result, "execution_time"), - Success = true - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - /// Get job status - public static JobInfo GetJob(string jobId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/jobs/{jobId}", "GET", null, pk, sk); - return new JobInfo - { - Id = GetString(result, "id"), - Language = GetString(result, "language"), - Status = GetString(result, "status") - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - /// Cancel a running job - public static bool CancelJob(string jobId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/jobs/{jobId}/cancel", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - /// List all jobs - public static List ListJobs(string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/jobs", "GET", null, pk, sk); - var jobs = new List(); - if (result.ContainsKey("jobs") && result["jobs"] is List jobList) - foreach (Dictionary j in jobList) - jobs.Add(new JobInfo { Id = j.ContainsKey("id") ? (string)j["id"] : null, Status = j.ContainsKey("status") ? (string)j["status"] : null }); - return jobs; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - private const int LANGUAGES_CACHE_TTL = 3600; // 1 hour - - private static string GetLanguagesCachePath() - { - string home = Environment.GetEnvironmentVariable("HOME") - ?? Environment.GetEnvironmentVariable("USERPROFILE") - ?? "."; - return Path.Combine(home, ".unsandbox", "languages.json"); - } - - private static List LoadLanguagesCache() - { - string cachePath = GetLanguagesCachePath(); - if (!File.Exists(cachePath)) return null; - - try - { - string content = File.ReadAllText(cachePath); - double mtime = new DateTimeOffset(File.GetLastWriteTimeUtc(cachePath)).ToUnixTimeSeconds(); - double now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - if (now - mtime < LANGUAGES_CACHE_TTL) - { - var data = ParseJson(content); - if (data.ContainsKey("languages") && data["languages"] is List langs) - return langs.ConvertAll(x => x.ToString()); - } - } - catch { } - return null; - } - - private static void SaveLanguagesCache(List languages) - { - try - { - string cachePath = GetLanguagesCachePath(); - string cacheDir = Path.GetDirectoryName(cachePath); - if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); - - long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var sb = new StringBuilder(); - sb.Append("{\"languages\":["); - for (int i = 0; i < languages.Count; i++) - { - if (i > 0) sb.Append(","); - sb.Append("\"").Append(languages[i]).Append("\""); - } - sb.Append("],\"timestamp\":").Append(timestamp).Append("}"); - File.WriteAllText(cachePath, sb.ToString()); - } - catch { } - } - - /// Get available programming languages (cached for 1 hour) - public static List GetLanguages(string publicKey = null, string secretKey = null) - { - // Try cache first - var cached = LoadLanguagesCache(); - if (cached != null) return cached; - - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/languages", "GET", null, pk, sk); - if (result.ContainsKey("languages") && result["languages"] is List langs) - { - var languages = langs.ConvertAll(x => x.ToString()); - SaveLanguagesCache(languages); - return languages; - } - return new List(); - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - /// Detect language from filename extension - public static string DetectLanguage(string filename) - { - int dotIndex = filename.LastIndexOf('.'); - if (dotIndex == -1) return null; - string ext = filename.Substring(dotIndex).ToLower(); - return ExtMap.ContainsKey(ext) ? ExtMap[ext] : null; - } - - // --- Session Functions (9) --- - - public static List SessionList(string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/sessions", "GET", null, pk, sk); - var sessions = new List(); - if (result.ContainsKey("sessions") && result["sessions"] is List sessionList) - foreach (Dictionary s in sessionList) - sessions.Add(new SessionInfo { Id = s.ContainsKey("id") ? (string)s["id"] : null, Status = s.ContainsKey("status") ? (string)s["status"] : null }); - return sessions; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static SessionInfo SessionGet(string sessionId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/sessions/{sessionId}", "GET", null, pk, sk); - return new SessionInfo { Id = GetString(result, "id"), Status = GetString(result, "status") }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static SessionInfo SessionCreate(string networkMode = null, string shell = null, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["shell"] = shell ?? "bash" }; - if (networkMode != null) payload["network"] = networkMode; - try - { - var result = ApiCall("/sessions", "POST", payload, pk, sk); - return new SessionInfo { Id = GetString(result, "id"), Status = "running" }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool SessionDestroy(string sessionId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/sessions/{sessionId}", "DELETE", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SessionFreeze(string sessionId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/sessions/{sessionId}/freeze", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SessionUnfreeze(string sessionId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/sessions/{sessionId}/unfreeze", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SessionBoost(string sessionId, int vcpu = 2, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["vcpu"] = vcpu }; - try { ApiCall($"/sessions/{sessionId}/boost", "POST", payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SessionUnboost(string sessionId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/sessions/{sessionId}/unboost", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static ExecuteResult SessionExecute(string sessionId, string command, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["command"] = command }; - try - { - var result = ApiCall($"/sessions/{sessionId}/execute", "POST", payload, pk, sk); - return new ExecuteResult - { - Stdout = GetString(result, "stdout"), - Stderr = GetString(result, "stderr"), - ExitCode = GetInt(result, "exit_code"), - Success = true - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - // --- Service Functions (17) --- - - public static List ServiceList(string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/services", "GET", null, pk, sk); - var services = new List(); - if (result.ContainsKey("services") && result["services"] is List serviceList) - foreach (Dictionary s in serviceList) - services.Add(new ServiceInfo { Id = s.ContainsKey("id") ? (string)s["id"] : null, Name = s.ContainsKey("name") ? (string)s["name"] : null, Status = s.ContainsKey("status") ? (string)s["status"] : null }); - return services; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static ServiceInfo ServiceGet(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/services/{serviceId}", "GET", null, pk, sk); - return new ServiceInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Status = GetString(result, "status") }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string ServiceCreate(string name, string ports = null, string domains = null, string bootstrap = null, string networkMode = null, List> inputFiles = null, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["name"] = name }; - if (ports != null) - { - var portList = new List(); - foreach (var p in ports.Split(',')) portList.Add(int.Parse(p.Trim())); - payload["ports"] = portList; - } - if (domains != null) payload["domains"] = domains; - if (bootstrap != null) payload["bootstrap"] = bootstrap; - if (networkMode != null) payload["network"] = networkMode; - if (inputFiles != null && inputFiles.Count > 0) payload["input_files"] = inputFiles; - try - { - var result = ApiCall("/services", "POST", payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool ServiceDestroy(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}", "DELETE", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceFreeze(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/freeze", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceUnfreeze(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/unfreeze", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceLock(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/lock", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceUnlock(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/unlock", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceSetUnfreezeOnDemand(string serviceId, bool enabled, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["unfreeze_on_demand"] = enabled }; - try { ApiCall($"/services/{serviceId}", "PATCH", payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceRedeploy(string serviceId, string bootstrap = null, List> inputFiles = null, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - Dictionary payload = null; - if (bootstrap != null || (inputFiles != null && inputFiles.Count > 0)) - { - payload = new Dictionary(); - if (bootstrap != null) payload["bootstrap"] = bootstrap; - if (inputFiles != null && inputFiles.Count > 0) payload["input_files"] = inputFiles; - } - try { ApiCall($"/services/{serviceId}/redeploy", "POST", payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static string ServiceLogs(string serviceId, bool allLogs = false, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var endpoint = allLogs ? $"/services/{serviceId}/logs?lines=9000" : $"/services/{serviceId}/logs"; - try - { - var result = ApiCall(endpoint, "GET", null, pk, sk); - return GetString(result, "logs"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static ExecuteResult ServiceExecute(string serviceId, string command, int timeoutMs = 30000, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["command"] = command }; - try - { - var result = ApiCall($"/services/{serviceId}/execute", "POST", payload, pk, sk); - return new ExecuteResult - { - Stdout = GetString(result, "stdout"), - Stderr = GetString(result, "stderr"), - ExitCode = GetInt(result, "exit_code"), - Success = true - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string ServiceEnvGet(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/services/{serviceId}/env", "GET", null, pk, sk); - return GetString(result, "content"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool ServiceEnvSet(string serviceId, string envContent, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCallText($"/services/{serviceId}/env", "PUT", envContent, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceEnvDelete(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/env", "DELETE", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static string ServiceEnvExport(string serviceId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/services/{serviceId}/env/export", "POST", null, pk, sk); - return GetString(result, "content"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool ServiceResize(string serviceId, int vcpu, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["vcpu"] = vcpu }; - try { ApiCall($"/services/{serviceId}/resize", "POST", payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - // --- Snapshot Functions (9) --- - - public static List SnapshotList(string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/snapshots", "GET", null, pk, sk); - var snapshots = new List(); - if (result.ContainsKey("snapshots") && result["snapshots"] is List snapshotList) - foreach (Dictionary s in snapshotList) - snapshots.Add(new SnapshotInfo { Id = s.ContainsKey("id") ? (string)s["id"] : null, Name = s.ContainsKey("name") ? (string)s["name"] : null }); - return snapshots; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static SnapshotInfo SnapshotGet(string snapshotId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/snapshots/{snapshotId}", "GET", null, pk, sk); - return new SnapshotInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Type = GetString(result, "source_type") }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string SnapshotSession(string sessionId, string name = null, bool hot = false, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary(); - if (name != null) payload["name"] = name; - if (hot) payload["hot"] = true; - try - { - var result = ApiCall($"/sessions/{sessionId}/snapshot", "POST", payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string SnapshotService(string serviceId, string name = null, bool hot = false, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary(); - if (name != null) payload["name"] = name; - if (hot) payload["hot"] = true; - try - { - var result = ApiCall($"/services/{serviceId}/snapshot", "POST", payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string SnapshotRestore(string snapshotId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/snapshots/{snapshotId}/restore", "POST", null, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool SnapshotDelete(string snapshotId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/snapshots/{snapshotId}", "DELETE", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SnapshotLock(string snapshotId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/snapshots/{snapshotId}/lock", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SnapshotUnlock(string snapshotId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/snapshots/{snapshotId}/unlock", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static string SnapshotClone(string snapshotId, string cloneType, string name = null, string ports = null, string shell = null, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["type"] = cloneType }; - if (name != null) payload["name"] = name; - if (ports != null) - { - var portList = new List(); - foreach (var p in ports.Split(',')) portList.Add(int.Parse(p.Trim())); - payload["ports"] = portList; - } - if (shell != null) payload["shell"] = shell; - try - { - var result = ApiCall($"/snapshots/{snapshotId}/clone", "POST", payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - // --- Image Functions (13) --- - - public static List ImageList(string filter = null, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var endpoint = filter != null ? $"/images?filter={filter}" : "/images"; - try - { - var result = ApiCall(endpoint, "GET", null, pk, sk); - var images = new List(); - if (result.ContainsKey("images") && result["images"] is List imageList) - foreach (Dictionary img in imageList) - images.Add(new ImageInfo { Id = img.ContainsKey("id") ? (string)img["id"] : null, Name = img.ContainsKey("name") ? (string)img["name"] : null }); - return images; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static ImageInfo ImageGet(string imageId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/images/{imageId}", "GET", null, pk, sk); - return new ImageInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Visibility = GetString(result, "visibility") }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string ImagePublish(string sourceType, string sourceId, string name = null, string description = null, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["source_type"] = sourceType, ["source_id"] = sourceId }; - if (name != null) payload["name"] = name; - if (description != null) payload["description"] = description; - try - { - var result = ApiCall("/images", "POST", payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool ImageDelete(string imageId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/images/{imageId}", "DELETE", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageLock(string imageId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/images/{imageId}/lock", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageUnlock(string imageId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/images/{imageId}/unlock", "POST", null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageSetVisibility(string imageId, string visibility, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["visibility"] = visibility }; - try { ApiCall($"/images/{imageId}", "PATCH", payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageGrantAccess(string imageId, string trustedApiKey, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["api_key"] = trustedApiKey }; - try { ApiCall($"/images/{imageId}/access", "POST", payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageRevokeAccess(string imageId, string trustedApiKey, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["api_key"] = trustedApiKey }; - try { ApiCall($"/images/{imageId}/access", "DELETE", payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static List ImageListTrusted(string imageId, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/images/{imageId}/access", "GET", null, pk, sk); - if (result.ContainsKey("trusted_keys") && result["trusted_keys"] is List keys) - return keys.ConvertAll(x => x.ToString()); - return new List(); - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static bool ImageTransfer(string imageId, string toApiKey, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["to_api_key"] = toApiKey }; - try { ApiCall($"/images/{imageId}/transfer", "POST", payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static string ImageSpawn(string imageId, string name = null, string ports = null, string bootstrap = null, string networkMode = null, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary(); - if (name != null) payload["name"] = name; - if (ports != null) - { - var portList = new List(); - foreach (var p in ports.Split(',')) portList.Add(int.Parse(p.Trim())); - payload["ports"] = portList; - } - if (bootstrap != null) payload["bootstrap"] = bootstrap; - if (networkMode != null) payload["network"] = networkMode; - try - { - var result = ApiCall($"/images/{imageId}/spawn", "POST", payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string ImageClone(string imageId, string name = null, string description = null, string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary(); - if (name != null) payload["name"] = name; - if (description != null) payload["description"] = description; - try - { - var result = ApiCall($"/images/{imageId}/clone", "POST", payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - // --- Utilities --- - - public static KeyInfo ValidateKeys(string publicKey = null, string secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/keys/validate", "POST", null, pk, sk); - return new KeyInfo - { - Valid = result.ContainsKey("valid") && result["valid"] is bool v && v, - Tier = GetString(result, "tier"), - RateLimitPerMinute = GetInt(result, "rate_limit"), - ConcurrencyLimit = GetInt(result, "concurrency") - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string HmacSign(string secretKey, string message) - { - using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))) - { - var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); - return BitConverter.ToString(hash).Replace("-", "").ToLower(); - } - } - - public static bool HealthCheck() - { - try - { - ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; - var request = (HttpWebRequest)WebRequest.Create(API_BASE + "/health"); - request.Method = "GET"; - request.Timeout = 10000; - using (var response = (HttpWebResponse)request.GetResponse()) - return response.StatusCode == HttpStatusCode.OK; - } - catch { return false; } - } - - public static string Version() => VERSION; - - public static string LastError() => _lastError; - - /// Build environment content from list of env vars and optional env file - public static string BuildEnvContent(List envs, string envFile) - { - var lines = new List(envs); - if (!string.IsNullOrEmpty(envFile) && File.Exists(envFile)) - { - var content = File.ReadAllText(envFile); - foreach (var line in content.Split('\n')) - { - var trimmed = line.Trim(); - if (!string.IsNullOrEmpty(trimmed) && !trimmed.StartsWith("#")) - lines.Add(trimmed); - } - } - return string.Join("\n", lines); - } - - // --- Internal Helpers --- - - private static (string, string) ResolveKeys(string publicKey, string secretKey) - { - var pk = publicKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") ?? ""; - var sk = secretKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") ?? ""; - return (pk, sk); - } - - private static Dictionary ApiCall(string endpoint, string method, Dictionary data, string publicKey, string secretKey) - { - ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; - - var request = (HttpWebRequest)WebRequest.Create(API_BASE + endpoint); - request.Method = method; - request.ContentType = "application/json"; - request.Timeout = 300000; - - string body = data != null ? ToJson(data) : ""; - - 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 (data != null) - { - byte[] bytes = Encoding.UTF8.GetBytes(body); - request.ContentLength = bytes.Length; - using (Stream stream = request.GetRequestStream()) - stream.Write(bytes, 0, bytes.Length); - } - - using (var response = (HttpWebResponse)request.GetResponse()) - using (var reader = new StreamReader(response.GetResponseStream())) - { - var responseText = reader.ReadToEnd(); - return ParseJson(responseText); - } - } - - private static void ApiCallText(string endpoint, string method, string body, string publicKey, string secretKey) - { - ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; - - var request = (HttpWebRequest)WebRequest.Create(API_BASE + endpoint); - request.Method = method; - request.ContentType = "text/plain"; - request.Timeout = 300000; - - 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}"); - } - - byte[] bytes = Encoding.UTF8.GetBytes(body); - request.ContentLength = bytes.Length; - using (Stream stream = request.GetRequestStream()) - stream.Write(bytes, 0, bytes.Length); - - using (var response = (HttpWebResponse)request.GetResponse()) { } - } - - private static string ToJson(Dictionary dict) - { - var sb = new StringBuilder("{"); - bool first = true; - foreach (var kv in dict) - { - if (!first) sb.Append(","); - first = false; - sb.Append($"\"{kv.Key}\":"); - sb.Append(ValueToJson(kv.Value)); - } - sb.Append("}"); - return sb.ToString(); - } - - private static string ValueToJson(object val) - { - if (val == null) return "null"; - if (val is string s) return $"\"{s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r")}\""; - if (val is bool b) return b.ToString().ToLower(); - if (val is int || val is long || val is double) return val.ToString(); - if (val is List intList) - { - var sb = new StringBuilder("["); - for (int i = 0; i < intList.Count; i++) - { - if (i > 0) sb.Append(","); - sb.Append(intList[i]); - } - sb.Append("]"); - return sb.ToString(); - } - if (val is Dictionary dict) return ToJson(dict); - return $"\"{val}\""; - } - - private static Dictionary ParseJson(string json) - { - // Reuse the existing ParseJson from the Un class - json = json.Trim(); - if (!json.StartsWith("{")) return new Dictionary(); - - var result = new Dictionary(); - int i = 1; - - while (i < json.Length) - { - while (i < json.Length && char.IsWhiteSpace(json[i])) i++; - if (json[i] == '}') break; - - if (json[i] == '"') - { - int keyStart = ++i; - while (i < json.Length && json[i] != '"') - { - if (json[i] == '\\') i++; - i++; - } - string key = json.Substring(keyStart, i - keyStart).Replace("\\\"", "\"").Replace("\\\\", "\\"); - i++; - - while (i < json.Length && (char.IsWhiteSpace(json[i]) || json[i] == ':')) i++; - - var valuePair = ParseJsonValue(json, i); - result[key] = valuePair.Item1; - i = valuePair.Item2; - - while (i < json.Length && (char.IsWhiteSpace(json[i]) || json[i] == ',')) i++; - } - else - { - i++; - } - } - return result; - } - - private static Tuple ParseJsonValue(string json, int start) - { - int i = start; - while (i < json.Length && char.IsWhiteSpace(json[i])) i++; - - if (json[i] == '"') - { - i++; - var sb = new StringBuilder(); - bool escaped = false; - while (i < json.Length) - { - char c = json[i]; - if (escaped) - { - switch (c) - { - case 'n': sb.Append('\n'); break; - case 'r': sb.Append('\r'); break; - case 't': sb.Append('\t'); break; - case '"': sb.Append('"'); break; - case '\\': sb.Append('\\'); break; - default: sb.Append(c); break; - } - escaped = false; - } - else if (c == '\\') escaped = true; - else if (c == '"') return Tuple.Create((object)sb.ToString(), i + 1); - else sb.Append(c); - i++; - } - } - else if (json[i] == '{') - { - int depth = 1; - int objStart = i++; - while (i < json.Length && depth > 0) - { - if (json[i] == '{') depth++; - else if (json[i] == '}') depth--; - i++; - } - return Tuple.Create((object)ParseJson(json.Substring(objStart, i - objStart)), i); - } - else if (json[i] == '[') - { - var list = new List(); - i++; - while (i < json.Length) - { - while (i < json.Length && char.IsWhiteSpace(json[i])) i++; - if (json[i] == ']') { i++; break; } - var item = ParseJsonValue(json, i); - list.Add(item.Item1); - i = item.Item2; - while (i < json.Length && (char.IsWhiteSpace(json[i]) || json[i] == ',')) i++; - } - return Tuple.Create((object)list, i); - } - else if (char.IsDigit(json[i]) || json[i] == '-') - { - int numStart = i; - while (i < json.Length && (char.IsDigit(json[i]) || json[i] == '.' || json[i] == '-')) i++; - string num = json.Substring(numStart, i - numStart); - return Tuple.Create((object)(num.Contains(".") ? (object)double.Parse(num) : int.Parse(num)), i); - } - else if (json.Substring(i).StartsWith("true")) return Tuple.Create((object)true, i + 4); - else if (json.Substring(i).StartsWith("false")) return Tuple.Create((object)false, i + 5); - else if (json.Substring(i).StartsWith("null")) return Tuple.Create((object)null, i + 4); - - return Tuple.Create((object)null, i); - } - - private static string GetString(Dictionary result, string key) - => result.ContainsKey(key) ? result[key]?.ToString() : null; - - private static int GetInt(Dictionary result, string key) - => result.ContainsKey(key) && result[key] is int i ? i : 0; - - private static double GetDouble(Dictionary result, string key) - => result.ContainsKey(key) && result[key] is double d ? d : 0; -} - -// --- Data Types --- - -public class ExecuteResult -{ - public string Stdout { get; set; } - public string Stderr { get; set; } - public int ExitCode { get; set; } - public string Language { get; set; } - public double ExecutionTime { get; set; } - public bool Success { get; set; } - public string ErrorMessage { get; set; } -} - -public class JobInfo -{ - public string Id { get; set; } - public string Language { get; set; } - public string Status { get; set; } - public long CreatedAt { get; set; } - public long CompletedAt { get; set; } - public string ErrorMessage { get; set; } -} - -public class SessionInfo -{ - public string Id { get; set; } - public string ContainerName { get; set; } - public string Status { get; set; } - public string NetworkMode { get; set; } - public int Vcpu { get; set; } - public long CreatedAt { get; set; } - public long LastActivity { get; set; } -} - -public class ServiceInfo -{ - public string Id { get; set; } - public string Name { get; set; } - public string Status { get; set; } - public string ContainerName { get; set; } - public string NetworkMode { get; set; } - public string Ports { get; set; } - public string Domains { get; set; } - public int Vcpu { get; set; } - public bool Locked { get; set; } - public bool UnfreezeOnDemand { get; set; } - public long CreatedAt { get; set; } - public long LastActivity { get; set; } -} - -public class SnapshotInfo -{ - public string Id { get; set; } - public string Name { get; set; } - public string Type { get; set; } - public string SourceId { get; set; } - public bool Hot { get; set; } - public bool Locked { get; set; } - public long CreatedAt { get; set; } - public long SizeBytes { get; set; } -} - -public class ImageInfo -{ - public string Id { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public string Visibility { get; set; } - public string SourceType { get; set; } - public string SourceId { get; set; } - public string OwnerApiKey { get; set; } - public bool Locked { get; set; } - public long CreatedAt { get; set; } - public long SizeBytes { get; set; } -} - -public class KeyInfo -{ - public bool Valid { get; set; } - public string Tier { get; set; } - public int RateLimitPerMinute { get; set; } - public int RateLimitBurst { get; set; } - public int ConcurrencyLimit { get; set; } - public string ErrorMessage { get; set; } -} diff --git a/clients/csharp/tests/UnsandboxTests.cs b/clients/csharp/tests/UnsandboxTests.cs deleted file mode 100644 index 0d6dd7f..0000000 --- a/clients/csharp/tests/UnsandboxTests.cs +++ /dev/null @@ -1,228 +0,0 @@ -// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY -// Unit and Functional Tests for Unsandbox C# SDK (Mono) - -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Security.Cryptography; -using System.Text; - -/// -/// Unit tests for the Unsandbox SDK library functions. -/// These tests verify that exported library functions work correctly. -/// -public class UnitTests -{ - public static void Run() - { - Console.WriteLine("=== Unsandbox C# SDK Unit Tests ===\n"); - - TestDetectLanguage(); - TestHmacSign(); - TestExtensionMap(); - TestBuildEnvContent(); - - Console.WriteLine("\n=== Unit Tests Complete ==="); - } - - static void TestDetectLanguage() - { - Console.Write("DetectLanguage: "); - var tests = new Dictionary - { - { "test.py", "python" }, - { "script.js", "javascript" }, - { "main.go", "go" }, - { "app.rs", "rust" }, - { "Program.cs", "csharp" }, - { "Module.fs", "fsharp" }, - { "script.ps1", "powershell" } - }; - - int passed = 0; - foreach (var test in tests) - { - var result = Unsandbox.DetectLanguage(test.Key); - if (result == test.Value) passed++; - else Console.Write($"[FAIL: {test.Key} -> {result}, expected {test.Value}] "); - } - - if (passed == tests.Count) - Console.WriteLine($"PASS ({passed}/{tests.Count})"); - else - Console.WriteLine($"FAIL ({passed}/{tests.Count})"); - } - - static void TestHmacSign() - { - Console.Write("HmacSign: "); - // Test vector: HMAC-SHA256("key", "message") - var result = Unsandbox.HmacSign("key", "message"); - // Expected: 6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a - var expected = "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"; - if (result == expected) - Console.WriteLine("PASS"); - else - Console.WriteLine($"FAIL (got {result}, expected {expected})"); - } - - static void TestExtensionMap() - { - Console.Write("ExtensionMap: "); - // Test that the extension map contains expected entries - var tests = new Dictionary - { - { ".py", "python" }, - { ".js", "javascript" }, - { ".go", "go" }, - { ".rs", "rust" }, - { ".cs", "csharp" } - }; - - int passed = 0; - foreach (var test in tests) - { - if (Unsandbox.ExtMap.TryGetValue(test.Key, out var lang) && lang == test.Value) - passed++; - } - - if (passed == tests.Count) - Console.WriteLine($"PASS ({passed}/{tests.Count})"); - else - Console.WriteLine($"FAIL ({passed}/{tests.Count})"); - } - - static void TestBuildEnvContent() - { - Console.Write("BuildEnvContent: "); - var envs = new List { "KEY1=value1", "KEY2=value2" }; - var result = Unsandbox.BuildEnvContent(envs, null); - var hasKey1 = result.Contains("KEY1=value1"); - var hasKey2 = result.Contains("KEY2=value2"); - if (hasKey1 && hasKey2) - Console.WriteLine("PASS"); - else - Console.WriteLine($"FAIL (got: {result})"); - } -} - -/// -/// Functional tests that require API credentials. -/// Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables. -/// -public class FunctionalTests -{ - public static void Run() - { - var publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); - var secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); - - if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) - { - Console.WriteLine("=== Functional Tests Skipped (no API credentials) ==="); - return; - } - - Console.WriteLine("=== Unsandbox C# SDK Functional Tests ===\n"); - - TestHealthCheck(); - TestValidateKeys(); - TestGetLanguages(); - TestExecute(); - TestSessionList(); - TestServiceList(); - TestSnapshotList(); - TestImageList(); - - Console.WriteLine("\n=== Functional Tests Complete ==="); - } - - static void TestHealthCheck() - { - Console.Write("HealthCheck: "); - var result = Unsandbox.HealthCheck(); - Console.WriteLine(result ? "PASS" : "FAIL"); - } - - static void TestValidateKeys() - { - Console.Write("ValidateKeys: "); - var result = Unsandbox.ValidateKeys(); - if (result != null && result.Valid) - Console.WriteLine($"PASS (tier: {result.Tier})"); - else - Console.WriteLine($"FAIL ({Unsandbox.LastError()})"); - } - - static void TestGetLanguages() - { - Console.Write("GetLanguages: "); - var result = Unsandbox.GetLanguages(); - if (result.Count > 0) - Console.WriteLine($"PASS ({result.Count} languages)"); - else - Console.WriteLine($"FAIL ({Unsandbox.LastError()})"); - } - - static void TestExecute() - { - Console.Write("Execute: "); - var result = Unsandbox.Execute("python", "print('hello from C# SDK')"); - if (result.Success && result.Stdout != null && result.Stdout.Contains("hello")) - Console.WriteLine("PASS"); - else - Console.WriteLine($"FAIL ({result.ErrorMessage ?? Unsandbox.LastError()})"); - } - - static void TestSessionList() - { - Console.Write("SessionList: "); - var result = Unsandbox.SessionList(); - // Empty list is valid - just checking API call works - Console.WriteLine($"PASS ({result.Count} sessions)"); - } - - static void TestServiceList() - { - Console.Write("ServiceList: "); - var result = Unsandbox.ServiceList(); - Console.WriteLine($"PASS ({result.Count} services)"); - } - - static void TestSnapshotList() - { - Console.Write("SnapshotList: "); - var result = Unsandbox.SnapshotList(); - Console.WriteLine($"PASS ({result.Count} snapshots)"); - } - - static void TestImageList() - { - Console.Write("ImageList: "); - var result = Unsandbox.ImageList(); - Console.WriteLine($"PASS ({result.Count} images)"); - } -} - -public class Program -{ - public static int Main(string[] args) - { - try - { - Console.WriteLine("Unsandbox C# SDK Tests (Mono)"); - Console.WriteLine("=============================\n"); - - UnitTests.Run(); - Console.WriteLine(); - FunctionalTests.Run(); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Test error: {ex.Message}"); - return 1; - } - } -} diff --git a/clients/d/sync/src/un.d b/clients/d/sync/src/un.d index 2f3e4fc..b6f8953 100644 --- a/clients/d/sync/src/un.d +++ b/clients/d/sync/src/un.d @@ -52,7 +52,6 @@ import std.string; import std.conv; import std.array; import std.algorithm; -import std.typecons; immutable string API_BASE = "https://api.unsandbox.com"; immutable string PORTAL_BASE = "https://unsandbox.com"; @@ -249,145 +248,6 @@ string execCurl(string cmd) { return output; } -// Execute curl and get HTTP status code along with response body -struct CurlResult { - string body; - int status; -} - -CurlResult execCurlWithStatus(string cmd) { - // Modify cmd to include status code output - string fullCmd = cmd ~ " -w '\\n%{http_code}'"; - auto result = executeShell(fullCmd); - string output = result.output.strip(); - - // Find the last line (status code) - import std.algorithm : findSplitAfter; - auto lastNewline = output.findSplitAfter("\n"); - - // Walk backwards to find status code at end - string statusStr = ""; - string bodyStr = output; - if (output.length >= 3) { - // Try to parse last 3 chars as status - size_t i = output.length; - while (i > 0 && output[i-1] >= '0' && output[i-1] <= '9') i--; - if (i < output.length) { - statusStr = output[i..$]; - bodyStr = output[0..i].strip(); - } - } - - int status = 0; - try { - status = to!int(statusStr); - } catch (Exception e) { - status = 0; - } - - return CurlResult(bodyStr, status); -} - -// Handle 428 sudo OTP challenge - prompts user for OTP and retries request -bool handleSudoChallenge(string method, string path, string bodyContent, string publicKey, string secretKey, string response) { - // Extract challenge_id from response - string challengeId = extractJsonField(response, "challenge_id"); - - stderr.writefln("%sConfirmation required. Check your email for a one-time code.%s", YELLOW, RESET); - stderr.write("Enter OTP: "); - stderr.flush(); - - import std.stdio : stdin; - string otp; - try { - otp = stdin.readln(); - if (otp is null) { - stderr.writefln("%sError: Failed to read OTP%s", RED, RESET); - return false; - } - otp = otp.strip(); - } catch (Exception e) { - stderr.writefln("%sError: Failed to read OTP%s", RED, RESET); - return false; - } - - if (otp.empty) { - stderr.writefln("%sError: Operation cancelled%s", RED, RESET); - return false; - } - - // Retry the request with sudo headers - string authHeaders = buildAuthHeaders(method, path, bodyContent, publicKey, secretKey); - authHeaders ~= format(" -H 'X-Sudo-OTP: %s'", otp); - if (!challengeId.empty) { - authHeaders ~= format(" -H 'X-Sudo-Challenge: %s'", challengeId); - } - - string cmd; - if (method == "DELETE") { - cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); - } else if (method == "POST") { - cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, bodyContent); - } else { - cmd = format(`curl -s -X %s '%s%s' %s`, method, API_BASE, path, authHeaders); - } - - auto retryResult = execCurlWithStatus(cmd); - - if (retryResult.status >= 200 && retryResult.status < 300) { - writefln("%sOperation completed successfully%s", GREEN, RESET); - return true; - } - - // Extract error message if available - string errorMsg = extractJsonField(retryResult.body, "error"); - if (!errorMsg.empty) { - stderr.writefln("%sError: %s%s", RED, errorMsg, RESET); - } else { - stderr.writefln("%sError: HTTP %d%s", RED, retryResult.status, RESET); - if (!retryResult.body.empty) stderr.writeln(retryResult.body); - } - return false; -} - -// Execute a destructive operation that may require sudo OTP confirmation -bool execDestructiveCurl(string method, string path, string bodyContent, string publicKey, string secretKey, string successMsg) { - string authHeaders = buildAuthHeaders(method, path, bodyContent, publicKey, secretKey); - - string cmd; - if (method == "DELETE") { - cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); - } else if (method == "POST" && !bodyContent.empty) { - cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, bodyContent); - } else if (method == "POST") { - cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - } else { - cmd = format(`curl -s -X %s '%s%s' %s`, method, API_BASE, path, authHeaders); - } - - auto result = execCurlWithStatus(cmd); - - // Handle 428 sudo challenge - if (result.status == 428) { - return handleSudoChallenge(method, path, bodyContent, publicKey, secretKey, result.body); - } - - if (result.status >= 200 && result.status < 300) { - if (!successMsg.empty) { - writefln("%s%s%s", GREEN, successMsg, RESET); - } - return true; - } - - if (result.status == 404) { - stderr.writefln("%sError: Not found%s", RED, RESET); - } else { - stderr.writefln("%sError: HTTP %d%s", RED, result.status, RESET); - if (!result.body.empty) stderr.writeln(result.body); - } - return false; -} - bool execCurlPut(string endpoint, string body, string publicKey, string secretKey) { import std.file : write, remove; import std.random : uniform; @@ -440,473 +300,6 @@ string extractJsonField(string response, string field) { return ""; } -// ============================================================================ -// Library Functions for D SDK (matching C reference un.h) -// ============================================================================ - -immutable string SDK_VERSION = "4.2.0"; - -// Execute code synchronously -string execute(string language, string code, string publicKey, string secretKey) { - string body_ = format(`{"language":"%s","code":"%s"}`, escapeJson(language), escapeJson(code)); - string authHeaders = buildAuthHeaders("POST", "/execute", body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s/execute' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); - return execCurl(cmd); -} - -// Execute code asynchronously (returns job_id) -string executeAsync(string language, string code, string publicKey, string secretKey) { - string body_ = format(`{"language":"%s","code":"%s","async":true}`, escapeJson(language), escapeJson(code)); - string authHeaders = buildAuthHeaders("POST", "/execute", body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s/execute' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); - return execCurl(cmd); -} - -// Get job status -string getJob(string jobId, string publicKey, string secretKey) { - string path = format("/jobs/%s", jobId); - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -// Wait for job completion -string waitForJob(string jobId, string publicKey, string secretKey) { - import core.thread : Thread; - import core.time : msecs; - int[7] pollDelays = [300, 450, 700, 900, 650, 1600, 2000]; - int delayIdx = 0; - - while (true) { - string result = getJob(jobId, publicKey, secretKey); - import std.algorithm : canFind; - if (result.canFind(`"status":"completed"`) || result.canFind(`"status":"failed"`) || - result.canFind(`"status":"timeout"`) || result.canFind(`"status":"cancelled"`)) { - return result; - } - Thread.sleep(msecs(pollDelays[delayIdx % 7])); - if (delayIdx < 6) delayIdx++; - } -} - -// Cancel a job -string cancelJob(string jobId, string publicKey, string secretKey) { - string path = format("/jobs/%s/cancel", jobId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -// List all jobs -string listJobs(string publicKey, string secretKey) { - string authHeaders = buildAuthHeaders("GET", "/jobs", "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s/jobs' %s`, API_BASE, authHeaders); - return execCurl(cmd); -} - -// Get supported languages -string getLanguages(string publicKey, string secretKey) { - string authHeaders = buildAuthHeaders("GET", "/languages", "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s/languages' %s`, API_BASE, authHeaders); - return execCurl(cmd); -} - -// Session functions -string sessionList(string publicKey, string secretKey) { - string authHeaders = buildAuthHeaders("GET", "/sessions", "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s/sessions' %s`, API_BASE, authHeaders); - return execCurl(cmd); -} - -string sessionGet(string sessionId, string publicKey, string secretKey) { - string path = format("/sessions/%s", sessionId); - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string sessionCreate(string shell, string network, string publicKey, string secretKey) { - string body_ = format(`{"shell":"%s"`, shell.empty ? "bash" : shell); - if (!network.empty) body_ ~= format(`,"network":"%s"`, network); - body_ ~= "}"; - string authHeaders = buildAuthHeaders("POST", "/sessions", body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s/sessions' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); - return execCurl(cmd); -} - -string sessionDestroy(string sessionId, string publicKey, string secretKey) { - string path = format("/sessions/%s", sessionId); - string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string sessionFreeze(string sessionId, string publicKey, string secretKey) { - string path = format("/sessions/%s/freeze", sessionId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string sessionUnfreeze(string sessionId, string publicKey, string secretKey) { - string path = format("/sessions/%s/unfreeze", sessionId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string sessionBoost(string sessionId, int vcpu, string publicKey, string secretKey) { - string path = format("/sessions/%s/boost", sessionId); - string body_ = vcpu > 0 ? format(`{"vcpu":%d}`, vcpu) : "{}"; - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string sessionUnboost(string sessionId, string publicKey, string secretKey) { - string path = format("/sessions/%s/unboost", sessionId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string sessionExecute(string sessionId, string command, string publicKey, string secretKey) { - string path = format("/sessions/%s/shell", sessionId); - string body_ = format(`{"command":"%s"}`, escapeJson(command)); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -// Service functions -string serviceListFn(string publicKey, string secretKey) { - string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s/services' %s`, API_BASE, authHeaders); - return execCurl(cmd); -} - -string serviceGet(string serviceId, string publicKey, string secretKey) { - string path = format("/services/%s", serviceId); - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string serviceCreate(string name, string ports, string bootstrap, string network, string publicKey, string secretKey) { - string body_ = format(`{"name":"%s"`, escapeJson(name)); - if (!ports.empty) body_ ~= format(`,"ports":"%s"`, ports); - if (!bootstrap.empty) body_ ~= format(`,"bootstrap":"%s"`, escapeJson(bootstrap)); - if (!network.empty) body_ ~= format(`,"network":"%s"`, network); - body_ ~= "}"; - string authHeaders = buildAuthHeaders("POST", "/services", body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s/services' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); - return execCurl(cmd); -} - -string serviceDestroy(string serviceId, string publicKey, string secretKey) { - string path = format("/services/%s", serviceId); - string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string serviceFreeze(string serviceId, string publicKey, string secretKey) { - string path = format("/services/%s/freeze", serviceId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string serviceUnfreeze(string serviceId, string publicKey, string secretKey) { - string path = format("/services/%s/unfreeze", serviceId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string serviceLock(string serviceId, string publicKey, string secretKey) { - string path = format("/services/%s/lock", serviceId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string serviceUnlock(string serviceId, string publicKey, string secretKey) { - string path = format("/services/%s/unlock", serviceId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string serviceRedeploy(string serviceId, string bootstrap, string publicKey, string secretKey) { - string path = format("/services/%s/redeploy", serviceId); - string body_ = bootstrap.empty ? "{}" : format(`{"bootstrap":"%s"}`, escapeJson(bootstrap)); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string serviceLogs(string serviceId, bool all, string publicKey, string secretKey) { - string path = format("/services/%s/logs%s", serviceId, all ? "?all=true" : ""); - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string serviceExecute(string serviceId, string command, int timeoutMs, string publicKey, string secretKey) { - string path = format("/services/%s/execute", serviceId); - string body_ = format(`{"command":"%s"`, escapeJson(command)); - if (timeoutMs > 0) body_ ~= format(`,"timeout":%d`, timeoutMs); - body_ ~= "}"; - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string serviceResize(string serviceId, int vcpu, string publicKey, string secretKey) { - string path = format("/services/%s/resize", serviceId); - string body_ = format(`{"vcpu":%d}`, vcpu); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -// Snapshot functions -string snapshotList(string publicKey, string secretKey) { - string authHeaders = buildAuthHeaders("GET", "/snapshots", "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s/snapshots' %s`, API_BASE, authHeaders); - return execCurl(cmd); -} - -string snapshotGet(string snapshotId, string publicKey, string secretKey) { - string path = format("/snapshots/%s", snapshotId); - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string snapshotSession(string sessionId, string name, bool hot, string publicKey, string secretKey) { - string path = format("/sessions/%s/snapshot", sessionId); - string body_ = "{"; - if (!name.empty) body_ ~= format(`"name":"%s",`, escapeJson(name)); - body_ ~= format(`"hot":%s}`, hot ? "true" : "false"); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string snapshotService(string serviceId, string name, bool hot, string publicKey, string secretKey) { - string path = format("/services/%s/snapshot", serviceId); - string body_ = "{"; - if (!name.empty) body_ ~= format(`"name":"%s",`, escapeJson(name)); - body_ ~= format(`"hot":%s}`, hot ? "true" : "false"); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string snapshotRestore(string snapshotId, string publicKey, string secretKey) { - string path = format("/snapshots/%s/restore", snapshotId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string snapshotDelete(string snapshotId, string publicKey, string secretKey) { - string path = format("/snapshots/%s", snapshotId); - string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string snapshotLock(string snapshotId, string publicKey, string secretKey) { - string path = format("/snapshots/%s/lock", snapshotId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string snapshotUnlock(string snapshotId, string publicKey, string secretKey) { - string path = format("/snapshots/%s/unlock", snapshotId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string snapshotClone(string snapshotId, string cloneType, string name, string ports, string shell, string publicKey, string secretKey) { - string path = format("/snapshots/%s/clone", snapshotId); - string body_ = format(`{"type":"%s"`, cloneType); - if (!name.empty) body_ ~= format(`,"name":"%s"`, escapeJson(name)); - if (!ports.empty) body_ ~= format(`,"ports":"%s"`, ports); - if (!shell.empty) body_ ~= format(`,"shell":"%s"`, shell); - body_ ~= "}"; - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -// Image functions -string imageList(string filter, string publicKey, string secretKey) { - string path = filter.empty ? "/images" : format("/images?filter=%s", filter); - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string imageGetFn(string imageId, string publicKey, string secretKey) { - string path = format("/images/%s", imageId); - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string imagePublish(string sourceType, string sourceId, string name, string description, string publicKey, string secretKey) { - string body_ = format(`{"source_type":"%s","source_id":"%s"`, sourceType, sourceId); - if (!name.empty) body_ ~= format(`,"name":"%s"`, escapeJson(name)); - if (!description.empty) body_ ~= format(`,"description":"%s"`, escapeJson(description)); - body_ ~= "}"; - string authHeaders = buildAuthHeaders("POST", "/images", body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s/images' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, body_); - return execCurl(cmd); -} - -string imageDelete(string imageId, string publicKey, string secretKey) { - string path = format("/images/%s", imageId); - string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X DELETE '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string imageLock(string imageId, string publicKey, string secretKey) { - string path = format("/images/%s/lock", imageId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string imageUnlock(string imageId, string publicKey, string secretKey) { - string path = format("/images/%s/unlock", imageId); - string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string imageSetVisibility(string imageId, string visibility, string publicKey, string secretKey) { - string path = format("/images/%s/visibility", imageId); - string body_ = format(`{"visibility":"%s"}`, visibility); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string imageGrantAccess(string imageId, string trustedKey, string publicKey, string secretKey) { - string path = format("/images/%s/grant", imageId); - string body_ = format(`{"trusted_api_key":"%s"}`, trustedKey); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string imageRevokeAccess(string imageId, string trustedKey, string publicKey, string secretKey) { - string path = format("/images/%s/revoke", imageId); - string body_ = format(`{"trusted_api_key":"%s"}`, trustedKey); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string imageListTrusted(string imageId, string publicKey, string secretKey) { - string path = format("/images/%s/trusted", imageId); - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -string imageTransfer(string imageId, string toApiKey, string publicKey, string secretKey) { - string path = format("/images/%s/transfer", imageId); - string body_ = format(`{"to_api_key":"%s"}`, toApiKey); - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string imageSpawn(string imageId, string name, string ports, string bootstrap, string network, string publicKey, string secretKey) { - string path = format("/images/%s/spawn", imageId); - string body_ = "{"; - string[] fields; - if (!name.empty) fields ~= format(`"name":"%s"`, escapeJson(name)); - if (!ports.empty) fields ~= format(`"ports":"%s"`, ports); - if (!bootstrap.empty) fields ~= format(`"bootstrap":"%s"`, escapeJson(bootstrap)); - if (!network.empty) fields ~= format(`"network":"%s"`, network); - import std.array : join; - body_ ~= fields.join(",") ~ "}"; - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -string imageCloneFn(string imageId, string name, string description, string publicKey, string secretKey) { - string path = format("/images/%s/clone", imageId); - string body_ = "{"; - string[] fields; - if (!name.empty) fields ~= format(`"name":"%s"`, escapeJson(name)); - if (!description.empty) fields ~= format(`"description":"%s"`, escapeJson(description)); - import std.array : join; - body_ ~= fields.join(",") ~ "}"; - string authHeaders = buildAuthHeaders("POST", path, body_, publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s%s' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, path, authHeaders, body_); - return execCurl(cmd); -} - -// PaaS Logs functions -string logsFetch(string source, int lines, string since, string grep, string publicKey, string secretKey) { - string path = "/paas/logs?"; - if (!source.empty) path ~= format("source=%s&", source); - if (lines > 0) path ~= format("lines=%d&", lines); - if (!since.empty) path ~= format("since=%s&", since); - if (!grep.empty) path ~= format("grep=%s&", grep); - if (path[$-1] == '&' || path[$-1] == '?') path = path[0..$-1]; - string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s%s' %s`, API_BASE, path, authHeaders); - return execCurl(cmd); -} - -// Key validation -string validateKeysFn(string publicKey, string secretKey) { - string authHeaders = buildAuthHeaders("POST", "/keys/validate", "", publicKey, secretKey); - string cmd = format(`curl -s -X POST '%s/keys/validate' %s`, API_BASE, authHeaders); - return execCurl(cmd); -} - -// Utility functions -string hmacSign(string secretKey, string message) { - return computeHmac(secretKey, message); -} - -bool healthCheck() { - string cmd = format(`curl -s -o /dev/null -w '%%{http_code}' '%s/health' 2>/dev/null`, API_BASE); - auto result = executeShell(cmd); - try { - return to!int(result.output.strip()) == 200; - } catch (Exception e) { - return false; - } -} - -string sdkVersion() { - return SDK_VERSION; -} - -__gshared string lastErrorMsg; - -void setLastError(string msg) { - lastErrorMsg = msg; -} - -string lastError() { - return lastErrorMsg; -} - void cmdServiceEnv(string action, string target, string[] svcEnvs, string svcEnvFile, string publicKey, string secretKey) { if (action == "status") { if (target.empty) { @@ -1134,7 +527,10 @@ void cmdService(string name, string ports, string bootstrap, string bootstrapFil if (!destroy.empty) { string path = format("/services/%s", destroy); - execDestructiveCurl("DELETE", path, "", publicKey, secretKey, format("Service destroyed: %s", destroy)); + string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X DELETE '%s/services/%s' %s`, API_BASE, destroy, authHeaders); + execCurl(cmd); + writefln("%sService destroyed: %s%s", GREEN, destroy, RESET); return; } @@ -1294,7 +690,10 @@ void cmdImage(bool list, string info, string del, string lock, string unlock, if (!del.empty) { string path = format("/images/%s", del); - execDestructiveCurl("DELETE", path, "", publicKey, secretKey, format("Image deleted: %s", del)); + string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X DELETE '%s/images/%s' %s`, API_BASE, del, authHeaders); + execCurl(cmd); + writefln("%sImage deleted: %s%s", GREEN, del, RESET); return; } @@ -1310,7 +709,11 @@ void cmdImage(bool list, string info, string del, string lock, string unlock, if (!unlock.empty) { string path = format("/images/%s/unlock", unlock); - execDestructiveCurl("POST", path, "{}", publicKey, secretKey, format("Image unlocked: %s", unlock)); + string json = "{}"; + string authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/images/%s/unlock' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, unlock, authHeaders, json); + execCurl(cmd); + writefln("%sImage unlocked: %s%s", GREEN, unlock, RESET); return; } @@ -1556,82 +959,13 @@ void validateKey(string publicKey, string secretKey, bool extend) { } } -// Load a row from an accounts.csv file (format: public_key,secret_key per line). -// Lines starting with '#' and blank lines are skipped. Returns the Nth data row. -Tuple!(string, string) loadAccountsCSV(string path, int index) { - import std.file : exists, readText; - import std.range : empty; - if (!exists(path)) return tuple("", ""); - string content = readText(path); - int row = 0; - foreach (line; content.splitLines()) { - string stripped = line.strip(); - if (stripped.empty || stripped[0] == '#') continue; - if (row == index) { - auto parts = stripped.findSplit(","); - if (!parts[1].empty) return tuple(parts[0], parts[2]); - return tuple("", ""); - } - row++; - } - return tuple("", ""); -} - int main(string[] args) { - string publicKey; - string secretKey; - int accountIndex = -1; // -1 = not set - string explicitPublicKey; + string publicKey = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string secretKey = environment.get("UNSANDBOX_SECRET_KEY", ""); - // First pass: scan for --account N and -p flags - for (size_t i = 1; i < args.length; i++) { - if (args[i] == "--account" && i+1 < args.length) { - accountIndex = to!int(args[++i]); - } else if (args[i] == "-p" && i+1 < args.length) { - explicitPublicKey = args[++i]; - } - } - - if (accountIndex >= 0) { - // --account N: load from ~/.unsandbox/accounts.csv, bypassing env vars - string home = environment.get("HOME", "."); - string csvPath = home ~ "/.unsandbox/accounts.csv"; - auto creds = loadAccountsCSV(csvPath, accountIndex); - if (creds[0].empty) { - creds = loadAccountsCSV("accounts.csv", accountIndex); - } - if (!creds[0].empty) { - publicKey = explicitPublicKey.empty ? creds[0] : explicitPublicKey; - secretKey = creds[1]; - } - } else { - publicKey = explicitPublicKey.empty - ? environment.get("UNSANDBOX_PUBLIC_KEY", "") - : explicitPublicKey; - secretKey = environment.get("UNSANDBOX_SECRET_KEY", ""); - - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - if (publicKey.empty) { - publicKey = environment.get("UNSANDBOX_API_KEY", ""); - } - - // Try UNSANDBOX_ACCOUNT env var to pick a row - int envAccount = -1; - string envAcct = environment.get("UNSANDBOX_ACCOUNT", ""); - if (!envAcct.empty) envAccount = to!int(envAcct); - - if (publicKey.empty) { - string home = environment.get("HOME", "."); - string csvPath = home ~ "/.unsandbox/accounts.csv"; - auto creds = loadAccountsCSV(csvPath, envAccount >= 0 ? envAccount : 0); - if (creds[0].empty) { - creds = loadAccountsCSV("accounts.csv", envAccount >= 0 ? envAccount : 0); - } - if (!creds[0].empty) { - publicKey = creds[0]; - secretKey = creds[1]; - } - } + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (publicKey.empty) { + publicKey = environment.get("UNSANDBOX_API_KEY", ""); } if (args.length < 2) { @@ -1668,7 +1002,6 @@ int main(string[] args) { else if (args[i] == "--screen") screen = true; else if (args[i] == "-f" && i+1 < args.length) inputFiles ~= args[++i]; else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; - else if (args[i] == "--account" && i+1 < args.length) i++; // already handled in first pass } cmdSession(list, kill, shell, network, vcpu, tmux, screen, inputFiles, publicKey, secretKey); @@ -1696,7 +1029,6 @@ int main(string[] args) { 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] == "--account" && i+1 < args.length) i++; // already handled in first pass } cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, unfreezeOnDemand, unfreezeOnDemandEnabled, createUnfreezeOnDemand, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey); return 0; @@ -1730,7 +1062,6 @@ int main(string[] args) { 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] == "--account" && i+1 < args.length) i++; // already handled in first pass } cmdService(name, ports, bootstrap, bootstrapFile, type, list, info, logs, tail, sleep, wake, destroy, resize, resizeVcpu, execute, command, dumpBootstrap, dumpFile, unfreezeOnDemand, unfreezeOnDemandEnabled, createUnfreezeOnDemand, network, vcpu, inputFiles, svcEnvs, svcEnvFile, envAction, envTarget, publicKey, secretKey); @@ -1743,7 +1074,6 @@ int main(string[] args) { for (size_t i = 2; i < args.length; i++) { if (args[i] == "--extend") extend = true; else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; - else if (args[i] == "--account" && i+1 < args.length) i++; // already handled in first pass } if (publicKey.empty) { @@ -1761,7 +1091,6 @@ int main(string[] args) { for (size_t i = 2; i < args.length; i++) { if (args[i] == "--json") jsonOutput = true; else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; - else if (args[i] == "--account" && i+1 < args.length) i++; // already handled in first pass } if (publicKey.empty) { @@ -1795,7 +1124,6 @@ int main(string[] args) { else if (args[i] == "--name" && i+1 < args.length) name = args[++i]; else if (args[i] == "--ports" && i+1 < args.length) ports = args[++i]; else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; - else if (args[i] == "--account" && i+1 < args.length) i++; // already handled in first pass } if (publicKey.empty) { @@ -1819,7 +1147,6 @@ int main(string[] args) { 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] == "-k" && i+1 < args.length) publicKey = args[++i]; - else if (args[i] == "--account" && i+1 < args.length) i++; // already handled in first pass else if (args[i].startsWith("-")) { stderr.writefln("%sUnknown option: %s%s", RED, args[i], RESET); return 1; diff --git a/clients/d/sync/tests/test_un.d b/clients/d/sync/tests/test_un.d deleted file mode 100644 index 9005098..0000000 --- a/clients/d/sync/tests/test_un.d +++ /dev/null @@ -1,260 +0,0 @@ -// Tests for the D unsandbox SDK -// Compile: dmd -unittest -main test_un.d ../src/un.d -of=test_un -// Run: ./test_un - -import std.stdio; -import std.process : environment; -import std.algorithm : canFind; - -// Note: In D, unittest blocks are automatically discovered and run -// when compiling with -unittest flag - -// ============================================================================ -// Unit Tests - Test exported library functions -// ============================================================================ - -unittest { - writeln("Testing detectLanguage..."); - assert(detectLanguage("script.py") == "python"); - assert(detectLanguage("script.js") == "javascript"); - assert(detectLanguage("script.ts") == "typescript"); - assert(detectLanguage("script.go") == "go"); - assert(detectLanguage("script.rs") == "rust"); - assert(detectLanguage("script.c") == "c"); - assert(detectLanguage("script.cpp") == "cpp"); - assert(detectLanguage("script.d") == "d"); - assert(detectLanguage("script.zig") == "zig"); - assert(detectLanguage("script.sh") == "bash"); - assert(detectLanguage("script.rb") == "ruby"); - assert(detectLanguage("script.php") == "php"); - assert(detectLanguage("script.unknown") == ""); - assert(detectLanguage("script") == ""); - writeln(" PASS"); -} - -unittest { - writeln("Testing hmacSign..."); - string secretKey = "test-secret"; - string message = "test-message"; - - string result = hmacSign(secretKey, message); - - // Should return a 64-character hex string - assert(result.length == 64, "HMAC signature should be 64 characters"); - - // Should be deterministic - string result2 = hmacSign(secretKey, message); - assert(result == result2, "HMAC sign should be deterministic"); - - // Different inputs should produce different outputs - string result3 = hmacSign(secretKey, "different-message"); - assert(result != result3, "Different inputs should produce different signatures"); - writeln(" PASS"); -} - -unittest { - writeln("Testing sdkVersion..."); - string v = sdkVersion(); - assert(v.length > 0, "Version should not be empty"); - // Should be in semver format (at least "0.0.0") - assert(v.length >= 5, "Version should be in semver format"); - writeln(" PASS"); -} - -unittest { - writeln("Testing lastError..."); - // Set an error - setLastError("test error message"); - - // Retrieve it - string err = lastError(); - assert(err == "test error message"); - - // Clear it - setLastError(""); - err = lastError(); - assert(err == "", "Error should be cleared"); - writeln(" PASS"); -} - -unittest { - writeln("Testing escapeJson..."); - assert(escapeJson("hello") == "hello"); - assert(escapeJson("hello\"world") == "hello\\\"world"); - assert(escapeJson("line1\nline2") == "line1\\nline2"); - assert(escapeJson("tab\there") == "tab\\there"); - assert(escapeJson("back\\slash") == "back\\\\slash"); - writeln(" PASS"); -} - -// ============================================================================ -// Integration Tests - Test SDK internal consistency -// ============================================================================ - -unittest { - writeln("Testing computeHmac..."); - string key = "test-key"; - string msg = "test-message"; - - string sig1 = computeHmac(key, msg); - string sig2 = computeHmac(key, msg); - - // Should be deterministic - assert(sig1 == sig2, "HMAC should be deterministic"); - - // Should produce different results for different inputs - string sig3 = computeHmac(key, "different"); - assert(sig1 != sig3, "Different inputs should produce different signatures"); - writeln(" PASS"); -} - -unittest { - writeln("Testing buildAuthHeaders..."); - string pk = "unsb-pk-test-test-test-test"; - string sk = "unsb-sk-test1-test2-test3-test4"; - - string headers = buildAuthHeaders("POST", "/execute", "{}", pk, sk); - - // Should contain auth header - assert(headers.canFind("Authorization: Bearer " ~ pk)); - // Should contain timestamp header - assert(headers.canFind("X-Timestamp:")); - // Should contain signature header - assert(headers.canFind("X-Signature:")); - writeln(" PASS"); -} - -// ============================================================================ -// Functional Tests - Test against real API (requires credentials) -// ============================================================================ - -bool hasCredentials() { - string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); - string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); - return pk.length > 0 && sk.length > 0; -} - -unittest { - writeln("Testing healthCheck (functional)..."); - if (!hasCredentials()) { - writeln(" SKIP (no credentials)"); - return; - } - - bool healthy = healthCheck(); - writeln(" Health check result: ", healthy ? "healthy" : "unhealthy"); - writeln(" PASS"); -} - -unittest { - writeln("Testing getLanguages (functional)..."); - if (!hasCredentials()) { - writeln(" SKIP (no credentials)"); - return; - } - - string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); - string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); - - string result = getLanguages(pk, sk); - assert(result.length > 0, "Languages result should not be empty"); - // Should contain python - assert(result.canFind("python"), "Languages should include python"); - writeln(" PASS"); -} - -unittest { - writeln("Testing validateKeysFn (functional)..."); - if (!hasCredentials()) { - writeln(" SKIP (no credentials)"); - return; - } - - string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); - string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); - - string result = validateKeysFn(pk, sk); - assert(result.length > 0, "Validate keys result should not be empty"); - writeln(" PASS"); -} - -unittest { - writeln("Testing execute (functional)..."); - if (!hasCredentials()) { - writeln(" SKIP (no credentials)"); - return; - } - - string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); - string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); - - string result = execute("python", "print('hello from d test')", pk, sk); - assert(result.length > 0, "Execute result should not be empty"); - // Should contain output - assert(result.canFind("stdout") || result.canFind("output"), "Result should contain output"); - writeln(" PASS"); -} - -unittest { - writeln("Testing sessionList (functional)..."); - if (!hasCredentials()) { - writeln(" SKIP (no credentials)"); - return; - } - - string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); - string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); - - string result = sessionList(pk, sk); - assert(result.length > 0, "Session list result should not be empty"); - writeln(" PASS"); -} - -unittest { - writeln("Testing serviceListFn (functional)..."); - if (!hasCredentials()) { - writeln(" SKIP (no credentials)"); - return; - } - - string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); - string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); - - string result = serviceListFn(pk, sk); - assert(result.length > 0, "Service list result should not be empty"); - writeln(" PASS"); -} - -unittest { - writeln("Testing snapshotList (functional)..."); - if (!hasCredentials()) { - writeln(" SKIP (no credentials)"); - return; - } - - string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); - string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); - - string result = snapshotList(pk, sk); - assert(result.length > 0, "Snapshot list result should not be empty"); - writeln(" PASS"); -} - -unittest { - writeln("Testing imageList (functional)..."); - if (!hasCredentials()) { - writeln(" SKIP (no credentials)"); - return; - } - - string pk = environment.get("UNSANDBOX_PUBLIC_KEY", ""); - string sk = environment.get("UNSANDBOX_SECRET_KEY", ""); - - string result = imageList("", pk, sk); - assert(result.length > 0, "Image list result should not be empty"); - writeln(" PASS"); -} - -void main() { - writeln("===== D SDK Tests Complete ====="); -} diff --git a/clients/dart/sync/src/un.dart b/clients/dart/sync/src/un.dart index 2c37ba1..1cb9ebc 100644 --- a/clients/dart/sync/src/un.dart +++ b/clients/dart/sync/src/un.dart @@ -73,8 +73,6 @@ class Args { String? command; String? sourceFile; String? apiKey; - String? publicKey; - int? account; String? network; int vcpu = 0; List env = []; @@ -106,8 +104,6 @@ class Args { String? serviceUnfreezeOnDemand; bool serviceUnfreezeOnDemandEnabled = true; bool serviceCreateUnfreezeOnDemand = false; - String? serviceShowFreezePage; - bool serviceShowFreezePageEnabled = true; bool keyExtend = false; String? envFile; String? envAction; @@ -126,90 +122,23 @@ class Args { String? imageClone; String? imageName; String? imagePorts; - // Snapshot command options - bool snapshotList = false; - String? snapshotInfo; - String? snapshotSession; - String? snapshotService; - String? snapshotRestore; - String? snapshotDelete; - String? snapshotLock; - String? snapshotUnlock; - String? snapshotClone; - String? snapshotCloneType; - String? snapshotName; - String? snapshotPorts; - String? snapshotShell; - bool snapshotHot = false; } -Map? loadAccountsCSV(String path, int index) { - try { - final file = File(path); - if (!file.existsSync()) return null; - final lines = file.readAsLinesSync().where((l) { - final t = l.trim(); - return t.isNotEmpty && !t.startsWith('#'); - }).toList(); - if (index < 0 || index >= lines.length) return null; - final parts = lines[index].split(','); - if (parts.length < 2) return null; - final pk = parts[0].trim(); - final sk = parts[1].trim(); - if (pk.isEmpty || sk.isEmpty) return null; - return {'pk': pk, 'sk': sk}; - } catch (e) { - return null; - } -} +List getApiKeys(String? argsKey) { + final publicKey = Platform.environment['UNSANDBOX_PUBLIC_KEY']; + final secretKey = Platform.environment['UNSANDBOX_SECRET_KEY']; -List getApiKeys(String? argsKey, {String? argsPublicKey, int? account}) { - // Tier 1: explicit -p/-k flags - if (argsPublicKey != null && argsPublicKey.isNotEmpty && argsKey != null && argsKey.isNotEmpty) { - return [argsPublicKey, argsKey]; - } - - // Tier 2: --account N → accounts.csv row N (bypasses env vars) - if (account != null) { - final home = Platform.environment['HOME'] ?? ''; - if (home.isNotEmpty) { - final fromHome = loadAccountsCSV('$home/.unsandbox/accounts.csv', account); - if (fromHome != null) return [fromHome['pk'], fromHome['sk']]; + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (publicKey == null || publicKey.isEmpty || secretKey == null || secretKey.isEmpty) { + final legacyKey = argsKey ?? Platform.environment['UNSANDBOX_API_KEY']; + if (legacyKey == null || legacyKey.isEmpty) { + stderr.writeln('${red}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set$reset'); + exit(1); } - final fromLocal = loadAccountsCSV('./accounts.csv', account); - if (fromLocal != null) return [fromLocal['pk'], fromLocal['sk']]; - stderr.writeln('${red}Error: --account $account not found in accounts.csv$reset'); - exit(1); - } - - // Tier 3: env vars - final envPk = Platform.environment['UNSANDBOX_PUBLIC_KEY']; - final envSk = Platform.environment['UNSANDBOX_SECRET_KEY']; - if (envPk != null && envPk.isNotEmpty && envSk != null && envSk.isNotEmpty) { - return [envPk, envSk]; - } - - // Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var) - final home = Platform.environment['HOME'] ?? ''; - final defIndexStr = Platform.environment['UNSANDBOX_ACCOUNT'] ?? '0'; - final defIndex = int.tryParse(defIndexStr) ?? 0; - if (home.isNotEmpty) { - final fromHome = loadAccountsCSV('$home/.unsandbox/accounts.csv', defIndex); - if (fromHome != null) return [fromHome['pk'], fromHome['sk']]; - } - - // Tier 5: ./accounts.csv row 0 - final fromLocal = loadAccountsCSV('./accounts.csv', defIndex); - if (fromLocal != null) return [fromLocal['pk'], fromLocal['sk']]; - - // Legacy UNSANDBOX_API_KEY fallback - final legacyKey = argsKey ?? Platform.environment['UNSANDBOX_API_KEY']; - if (legacyKey != null && legacyKey.isNotEmpty) { return [legacyKey, null]; } - stderr.writeln('${red}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set$reset'); - exit(1); + return [publicKey, secretKey]; } String detectLanguage(String filename) { @@ -350,114 +279,6 @@ Future> apiRequestCurl(String endpoint, String method, Stri } } -/// Make authenticated API request returning status code and body -Future<(int, String)> apiRequestCurlWithStatus(String endpoint, String method, String? jsonData, String publicKey, String? secretKey, {String? baseUrl, String? sudoOtp, String? sudoChallenge}) async { - final base = baseUrl ?? apiBase; - final tempFile = await File('${Directory.systemTemp.path}/un_request_${DateTime.now().millisecondsSinceEpoch}.json').create(); - - try { - final body = jsonData ?? ''; - if (jsonData != null) { - await tempFile.writeAsString(jsonData); - } - - final args = ['curl', '-s', '-w', '%{http_code}', '-X', method, '$base$endpoint', - '-H', 'Content-Type: application/json']; - - // Add HMAC authentication headers if secretKey is provided - 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 { - // Legacy API key authentication - args.addAll(['-H', 'Authorization: Bearer $publicKey']); - } - - // Add sudo OTP headers if provided - if (sudoOtp != null && sudoChallenge != null) { - args.addAll(['-H', 'X-Sudo-OTP: $sudoOtp']); - args.addAll(['-H', 'X-Sudo-Challenge: $sudoChallenge']); - } - - if (jsonData != null) { - args.addAll(['-d', '@${tempFile.path}']); - } - - final result = await Process.run(args[0], args.sublist(1)); - - if (result.exitCode != 0) { - throw Exception('curl failed: ${result.stderr}'); - } - - final output = result.stdout as String; - - // Extract status code from end of output (last 3 chars) - int statusCode = 0; - String responseBody = output; - if (output.length >= 3) { - final codeStr = output.substring(output.length - 3); - statusCode = int.tryParse(codeStr) ?? 0; - responseBody = output.substring(0, output.length - 3); - } - - return (statusCode, responseBody); - } finally { - await tempFile.delete(); - } -} - -/// Handle HTTP 428 sudo OTP challenge -/// Returns true if retry succeeded, false otherwise -Future handleSudoChallenge(String responseBody, String endpoint, String method, String? jsonData, String publicKey, String? secretKey) async { - // Extract challenge_id from response - String? challengeId; - try { - final resp = jsonDecode(responseBody) as Map; - challengeId = resp['challenge_id'] as String?; - } catch (e) { - stderr.writeln('${red}Error: Could not parse challenge response$reset'); - return false; - } - - if (challengeId == null || challengeId.isEmpty) { - stderr.writeln('${red}Error: Could not extract challenge_id from response$reset'); - return false; - } - - // Prompt user for OTP - stderr.writeln('${yellow}Confirmation required. Check your email for a one-time code.$reset'); - stderr.write('Enter OTP: '); - final otp = stdin.readLineSync()?.trim(); - - if (otp == null || otp.isEmpty) { - stderr.writeln('${red}Error: No OTP provided$reset'); - return false; - } - - // Retry request with sudo headers - final (statusCode, _) = await apiRequestCurlWithStatus( - endpoint, method, jsonData, publicKey, secretKey, - sudoOtp: otp, sudoChallenge: challengeId - ); - - if (statusCode >= 200 && statusCode < 300) { - return true; - } else { - stderr.writeln('${red}Error: OTP verification failed$reset'); - return false; - } -} - Future?> 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(); @@ -563,7 +384,7 @@ Future serviceEnvDelete(String serviceId, String publicKey, String? secret } Future cmdServiceEnv(Args args) async { - final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); + final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; final secretKey = keys[1]; final action = args.envAction; @@ -633,7 +454,7 @@ Future cmdServiceEnv(Args args) async { } Future cmdExecute(Args args) async { - final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); + final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; final secretKey = keys[1]; final code = await File(args.sourceFile!).readAsString(); @@ -711,7 +532,7 @@ Future cmdExecute(Args args) async { } Future cmdSession(Args args) async { - final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); + final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -771,7 +592,7 @@ Future cmdSession(Args args) async { } Future cmdService(Args args) async { - final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); + final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -836,29 +657,9 @@ Future cmdService(Args args) async { return; } - if (args.serviceShowFreezePage != null) { - final payload = {'show_freeze_page': args.serviceShowFreezePageEnabled}; - await apiRequestCurl('/services/${args.serviceShowFreezePage}', 'PATCH', jsonEncode(payload), publicKey, secretKey); - final status = args.serviceShowFreezePageEnabled ? 'enabled' : 'disabled'; - print('${green}Show-freeze-page $status for service: ${args.serviceShowFreezePage}$reset'); - return; - } - if (args.serviceDestroy != null) { - final (statusCode, responseBody) = await apiRequestCurlWithStatus('/services/${args.serviceDestroy}', 'DELETE', null, publicKey, secretKey); - if (statusCode == 428) { - if (await handleSudoChallenge(responseBody, '/services/${args.serviceDestroy}', 'DELETE', null, publicKey, secretKey)) { - print('${green}Service destroyed: ${args.serviceDestroy}$reset'); - } else { - stderr.writeln('${red}Error: Failed to destroy service (OTP verification failed)$reset'); - exit(1); - } - } else if (statusCode >= 200 && statusCode < 300) { - print('${green}Service destroyed: ${args.serviceDestroy}$reset'); - } else { - stderr.writeln('${red}Error: Failed to destroy service (HTTP $statusCode)$reset'); - exit(1); - } + await apiRequestCurl('/services/${args.serviceDestroy}', 'DELETE', null, publicKey, secretKey); + print('${green}Service destroyed: ${args.serviceDestroy}$reset'); return; } @@ -995,7 +796,7 @@ Future cmdService(Args args) async { } Future cmdLanguages(Args args) async { - final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); + final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -1027,7 +828,7 @@ Future cmdLanguages(Args args) async { } Future cmdImage(Args args) async { - final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); + final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -1044,20 +845,8 @@ Future cmdImage(Args args) async { } if (args.imageDelete != null) { - final (statusCode, responseBody) = await apiRequestCurlWithStatus('/images/${args.imageDelete}', 'DELETE', null, publicKey, secretKey); - if (statusCode == 428) { - if (await handleSudoChallenge(responseBody, '/images/${args.imageDelete}', 'DELETE', null, publicKey, secretKey)) { - print('${green}Image deleted: ${args.imageDelete}$reset'); - } else { - stderr.writeln('${red}Error: Failed to delete image (OTP verification failed)$reset'); - exit(1); - } - } else if (statusCode >= 200 && statusCode < 300) { - print('${green}Image deleted: ${args.imageDelete}$reset'); - } else { - stderr.writeln('${red}Error: Failed to delete image (HTTP $statusCode)$reset'); - exit(1); - } + await apiRequestCurl('/images/${args.imageDelete}', 'DELETE', null, publicKey, secretKey); + print('${green}Image deleted: ${args.imageDelete}$reset'); return; } @@ -1068,20 +857,8 @@ Future cmdImage(Args args) async { } if (args.imageUnlock != null) { - final (statusCode, responseBody) = await apiRequestCurlWithStatus('/images/${args.imageUnlock}/unlock', 'POST', null, publicKey, secretKey); - if (statusCode == 428) { - if (await handleSudoChallenge(responseBody, '/images/${args.imageUnlock}/unlock', 'POST', null, publicKey, secretKey)) { - print('${green}Image unlocked: ${args.imageUnlock}$reset'); - } else { - stderr.writeln('${red}Error: Failed to unlock image (OTP verification failed)$reset'); - exit(1); - } - } else if (statusCode >= 200 && statusCode < 300) { - print('${green}Image unlocked: ${args.imageUnlock}$reset'); - } else { - stderr.writeln('${red}Error: Failed to unlock image (HTTP $statusCode)$reset'); - exit(1); - } + await apiRequestCurl('/images/${args.imageUnlock}/unlock', 'POST', null, publicKey, secretKey); + print('${green}Image unlocked: ${args.imageUnlock}$reset'); return; } @@ -1143,237 +920,8 @@ Future cmdImage(Args args) async { exit(1); } -// Image access management functions -Future imageGrantAccess(String id, String trustedKey, String publicKey, String? secretKey) async { - final payload = {'trusted_api_key': trustedKey}; - await apiRequestCurl('/images/$id/grant-access', 'POST', jsonEncode(payload), publicKey, secretKey); - print('${green}Access granted to: $trustedKey$reset'); -} - -Future imageRevokeAccess(String id, String trustedKey, String publicKey, String? secretKey) async { - final payload = {'trusted_api_key': trustedKey}; - await apiRequestCurl('/images/$id/revoke-access', 'POST', jsonEncode(payload), publicKey, secretKey); - print('${green}Access revoked from: $trustedKey$reset'); -} - -Future imageListTrusted(String id, String publicKey, String? secretKey) async { - final result = await apiRequestCurl('/images/$id/trusted', 'GET', null, publicKey, secretKey); - print(jsonEncode(result)); -} - -Future imageTransfer(String id, String toKey, String publicKey, String? secretKey) async { - final payload = {'to_api_key': toKey}; - await apiRequestCurl('/images/$id/transfer', 'POST', jsonEncode(payload), publicKey, secretKey); - print('${green}Image transferred to: $toKey$reset'); -} - -// Snapshot functions -Future cmdSnapshot(Args args) async { - final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); - final publicKey = keys[0]!; - final secretKey = keys[1]; - - if (args.snapshotList) { - final result = await apiRequestCurl('/snapshots', 'GET', null, publicKey, secretKey); - print(jsonEncode(result)); - return; - } - - if (args.snapshotInfo != null) { - final result = await apiRequestCurl('/snapshots/${args.snapshotInfo}', 'GET', null, publicKey, secretKey); - print(jsonEncode(result)); - return; - } - - if (args.snapshotSession != null) { - final payload = { - 'session_id': args.snapshotSession, - }; - if (args.snapshotName != null) { - payload['name'] = args.snapshotName; - } - if (args.snapshotHot) { - payload['hot'] = true; - } - final result = await apiRequestCurl('/snapshots', 'POST', jsonEncode(payload), publicKey, secretKey); - print('${green}Snapshot created$reset'); - print(jsonEncode(result)); - return; - } - - if (args.snapshotService != null) { - final payload = { - 'service_id': args.snapshotService, - }; - if (args.snapshotName != null) { - payload['name'] = args.snapshotName; - } - if (args.snapshotHot) { - payload['hot'] = true; - } - final result = await apiRequestCurl('/snapshots', 'POST', jsonEncode(payload), publicKey, secretKey); - print('${green}Snapshot created$reset'); - print(jsonEncode(result)); - return; - } - - if (args.snapshotRestore != null) { - await apiRequestCurl('/snapshots/${args.snapshotRestore}/restore', 'POST', '{}', publicKey, secretKey); - print('${green}Snapshot restored: ${args.snapshotRestore}$reset'); - return; - } - - if (args.snapshotDelete != null) { - final (statusCode, responseBody) = await apiRequestCurlWithStatus('/snapshots/${args.snapshotDelete}', 'DELETE', null, publicKey, secretKey); - if (statusCode == 428) { - if (await handleSudoChallenge(responseBody, '/snapshots/${args.snapshotDelete}', 'DELETE', null, publicKey, secretKey)) { - print('${green}Snapshot deleted: ${args.snapshotDelete}$reset'); - } else { - stderr.writeln('${red}Error: Failed to delete snapshot (OTP verification failed)$reset'); - exit(1); - } - } else if (statusCode >= 200 && statusCode < 300) { - print('${green}Snapshot deleted: ${args.snapshotDelete}$reset'); - } else { - stderr.writeln('${red}Error: Failed to delete snapshot (HTTP $statusCode)$reset'); - exit(1); - } - return; - } - - if (args.snapshotLock != null) { - await apiRequestCurl('/snapshots/${args.snapshotLock}/lock', 'POST', '{}', publicKey, secretKey); - print('${green}Snapshot locked: ${args.snapshotLock}$reset'); - return; - } - - if (args.snapshotUnlock != null) { - final (statusCode, responseBody) = await apiRequestCurlWithStatus('/snapshots/${args.snapshotUnlock}/unlock', 'POST', '{}', publicKey, secretKey); - if (statusCode == 428) { - if (await handleSudoChallenge(responseBody, '/snapshots/${args.snapshotUnlock}/unlock', 'POST', '{}', publicKey, secretKey)) { - print('${green}Snapshot unlocked: ${args.snapshotUnlock}$reset'); - } else { - stderr.writeln('${red}Error: Failed to unlock snapshot (OTP verification failed)$reset'); - exit(1); - } - } else if (statusCode >= 200 && statusCode < 300) { - print('${green}Snapshot unlocked: ${args.snapshotUnlock}$reset'); - } else { - stderr.writeln('${red}Error: Failed to unlock snapshot (HTTP $statusCode)$reset'); - exit(1); - } - return; - } - - if (args.snapshotClone != null) { - final payload = { - 'clone_type': args.snapshotCloneType ?? 'session', - }; - if (args.snapshotName != null) { - payload['name'] = args.snapshotName; - } - if (args.snapshotPorts != null) { - payload['ports'] = args.snapshotPorts!.split(',').map((p) => int.parse(p.trim())).toList(); - } - if (args.snapshotShell != null) { - payload['shell'] = args.snapshotShell; - } - final result = await apiRequestCurl('/snapshots/${args.snapshotClone}/clone', 'POST', jsonEncode(payload), publicKey, secretKey); - print('${green}Snapshot cloned$reset'); - print(jsonEncode(result)); - return; - } - - stderr.writeln('${red}Error: Use --list, --info ID, --session ID, --service ID, --restore ID, --delete ID, --lock ID, --unlock ID, or --clone ID$reset'); - exit(1); -} - -// Session additional functions -Future sessionInfo(String id, String publicKey, String? secretKey) async { - final result = await apiRequestCurl('/sessions/$id', 'GET', null, publicKey, secretKey); - print(jsonEncode(result)); -} - -Future sessionBoost(String id, int vcpu, String publicKey, String? secretKey) async { - final payload = {'vcpu': vcpu}; - await apiRequestCurl('/sessions/$id', 'PATCH', jsonEncode(payload), publicKey, secretKey); - print('${green}Session boosted to $vcpu vCPU$reset'); -} - -Future sessionUnboost(String id, String publicKey, String? secretKey) async { - final payload = {'vcpu': 1}; - await apiRequestCurl('/sessions/$id', 'PATCH', jsonEncode(payload), publicKey, secretKey); - print('${green}Session unboosted to 1 vCPU$reset'); -} - -Future sessionExecuteCmd(String id, String command, String publicKey, String? secretKey) async { - final payload = {'command': command}; - final result = await apiRequestCurl('/sessions/$id/execute', 'POST', jsonEncode(payload), publicKey, secretKey); - final stdoutText = result['stdout'] as String?; - final stderrText = result['stderr'] as String?; - if (stdoutText != null && stdoutText.isNotEmpty) { - stdout.write('$blue$stdoutText$reset'); - } - if (stderrText != null && stderrText.isNotEmpty) { - stderr.write('$red$stderrText$reset'); - } -} - -// Service additional functions -Future serviceLock(String id, String publicKey, String? secretKey) async { - await apiRequestCurl('/services/$id/lock', 'POST', '{}', publicKey, secretKey); - print('${green}Service locked: $id$reset'); -} - -Future serviceUnlock(String id, String publicKey, String? secretKey) async { - final (statusCode, responseBody) = await apiRequestCurlWithStatus('/services/$id/unlock', 'POST', '{}', publicKey, secretKey); - if (statusCode == 428) { - if (await handleSudoChallenge(responseBody, '/services/$id/unlock', 'POST', '{}', publicKey, secretKey)) { - print('${green}Service unlocked: $id$reset'); - } else { - stderr.writeln('${red}Error: Failed to unlock service (OTP verification failed)$reset'); - exit(1); - } - } else if (statusCode >= 200 && statusCode < 300) { - print('${green}Service unlocked: $id$reset'); - } else { - stderr.writeln('${red}Error: Failed to unlock service (HTTP $statusCode)$reset'); - exit(1); - } -} - -Future serviceRedeploy(String id, String? bootstrap, String publicKey, String? secretKey) async { - final payload = bootstrap != null ? {'bootstrap': bootstrap} : {}; - await apiRequestCurl('/services/$id/redeploy', 'POST', jsonEncode(payload), publicKey, secretKey); - print('${green}Service redeploying: $id$reset'); -} - -// PaaS logs functions -Future logsFetch(String source, int lines, String? since, String? grepPattern, String publicKey, String? secretKey) async { - var params = '?source=$source&lines=$lines'; - if (since != null) params += '&since=$since'; - if (grepPattern != null) params += '&grep=${Uri.encodeComponent(grepPattern)}'; - final result = await apiRequestCurl('/logs$params', 'GET', null, publicKey, secretKey); - print(jsonEncode(result)); -} - -// Utility functions -Future healthCheck() async { - try { - final result = await Process.run('curl', ['-s', 'https://api.unsandbox.com/health']); - print(result.stdout); - return result.stdout.toString().contains('ok'); - } catch (e) { - return false; - } -} - -String sdkVersion() { - return '4.2.0'; -} - Future cmdKey(Args args) async { - final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); + final keys = getApiKeys(args.apiKey); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -1444,9 +992,6 @@ Args parseArgs(List argv) { case 'image': args.command = 'image'; break; - case 'snapshot': - args.command = 'snapshot'; - break; case 'key': args.command = 'key'; break; @@ -1462,13 +1007,6 @@ Args parseArgs(List argv) { case '--api-key': args.apiKey = argv[++i]; break; - case '-p': - case '--public-key': - args.publicKey = argv[++i]; - break; - case '--account': - args.account = int.parse(argv[++i]); - break; case '-n': case '--network': args.network = argv[++i]; @@ -1501,17 +1039,11 @@ Args parseArgs(List argv) { args.serviceList = true; } else if (args.command == 'image') { args.imageList = true; - } else if (args.command == 'snapshot') { - args.snapshotList = true; } break; case '-s': case '--shell': - if (args.command == 'snapshot') { - args.snapshotShell = argv[++i]; - } else { - args.sessionShell = argv[++i]; - } + args.sessionShell = argv[++i]; break; case '--kill': args.sessionKill = argv[++i]; @@ -1519,8 +1051,6 @@ Args parseArgs(List argv) { case '--name': if (args.command == 'image') { args.imageName = argv[++i]; - } else if (args.command == 'snapshot') { - args.snapshotName = argv[++i]; } else { args.serviceName = argv[++i]; } @@ -1528,8 +1058,6 @@ Args parseArgs(List argv) { case '--ports': if (args.command == 'image') { args.imagePorts = argv[++i]; - } else if (args.command == 'snapshot') { - args.snapshotPorts = argv[++i]; } else { args.servicePorts = argv[++i]; } @@ -1586,12 +1114,6 @@ Args parseArgs(List argv) { case '--with-unfreeze-on-demand': args.serviceCreateUnfreezeOnDemand = true; break; - case '--show-freeze-page': - args.serviceShowFreezePage = argv[++i]; - break; - case '--show-freeze-page-enabled': - args.serviceShowFreezePageEnabled = argv[++i].toLowerCase() == 'true'; - break; case '--extend': args.keyExtend = true; break; @@ -1603,29 +1125,21 @@ Args parseArgs(List argv) { args.serviceInfo = argv[++i]; } else if (args.command == 'image') { args.imageInfo = argv[++i]; - } else if (args.command == 'snapshot') { - args.snapshotInfo = argv[++i]; } break; case '--delete': if (args.command == 'image') { args.imageDelete = argv[++i]; - } else if (args.command == 'snapshot') { - args.snapshotDelete = argv[++i]; } break; case '--lock': if (args.command == 'image') { args.imageLock = argv[++i]; - } else if (args.command == 'snapshot') { - args.snapshotLock = argv[++i]; } break; case '--unlock': if (args.command == 'image') { args.imageUnlock = argv[++i]; - } else if (args.command == 'snapshot') { - args.snapshotUnlock = argv[++i]; } break; case '--publish': @@ -1652,33 +1166,6 @@ Args parseArgs(List argv) { case '--clone': if (args.command == 'image') { args.imageClone = argv[++i]; - } else if (args.command == 'snapshot') { - args.snapshotClone = argv[++i]; - } - break; - case '--session': - if (args.command == 'snapshot') { - args.snapshotSession = argv[++i]; - } - break; - case '--service': - if (args.command == 'snapshot') { - args.snapshotService = argv[++i]; - } - break; - case '--restore': - if (args.command == 'snapshot') { - args.snapshotRestore = argv[++i]; - } - break; - case '--clone-type': - if (args.command == 'snapshot') { - args.snapshotCloneType = argv[++i]; - } - break; - case '--hot': - if (args.command == 'snapshot') { - args.snapshotHot = true; } break; case 'env': @@ -1691,7 +1178,7 @@ Args parseArgs(List argv) { break; default: if (argv[i].startsWith('-')) { - stderr.writeln('${red}Unknown option: ${argv[i]}$reset'); + stderr.writeln('${RED}Unknown option: ${argv[i]}${RESET}'); exit(1); } else { args.sourceFile = argv[i]; @@ -1707,7 +1194,6 @@ void printHelp() { Usage: dart un.dart [options] dart un.dart session [options] dart un.dart service [options] - dart un.dart snapshot [options] dart un.dart image [options] dart un.dart key [options] dart un.dart languages [--json] @@ -1719,9 +1205,7 @@ Execute options: -o DIR Output directory for artifacts -n MODE Network mode (zerotrust/semitrusted) -v N vCPU count (1-8) - -p KEY Public key (use with -k for secret key) - -k KEY Secret/API key - --account N Use row N from accounts.csv (0-based) + -k KEY API key Session options: --list List active sessions @@ -1744,8 +1228,6 @@ Service options: --unfreeze-on-demand ID Set unfreeze-on-demand for service --unfreeze-on-demand-enabled BOOL Enable/disable (default: true) --with-unfreeze-on-demand Enable unfreeze-on-demand when creating service - --show-freeze-page ID Set show-freeze-page for service - --show-freeze-page-enabled BOOL Enable/disable (default: true) --destroy ID Destroy service --execute ID Execute command in service --command CMD Command to execute (with --execute) @@ -1772,22 +1254,6 @@ Image options: --name NAME Name for spawned service or cloned image --ports PORTS Ports for spawned service -Snapshot options: - -l, --list List all snapshots - --info ID Get snapshot details - --session ID Create snapshot from session - --service ID Create snapshot from service - --restore ID Restore a snapshot - --delete ID Delete a snapshot - --lock ID Lock snapshot to prevent deletion - --unlock ID Unlock snapshot - --clone ID Clone snapshot to session/service - --clone-type TYPE Clone target: session (default) or service - --name NAME Name for new snapshot or cloned resource - --ports PORTS Ports for service (with --clone --clone-type service) - --shell NAME Shell for session (with --clone --clone-type session) - --hot Hot snapshot (without stopping) - Key options: --extend Open browser to extend key @@ -1806,8 +1272,6 @@ void main(List arguments) async { await cmdService(args); } else if (args.command == 'image') { await cmdImage(args); - } else if (args.command == 'snapshot') { - await cmdSnapshot(args); } else if (args.command == 'key') { await cmdKey(args); } else if (args.command == 'languages') { diff --git a/clients/dotnet/async/src/Un.cs b/clients/dotnet/async/src/Un.cs index e1f4802..6433189 100644 --- a/clients/dotnet/async/src/Un.cs +++ b/clients/dotnet/async/src/Un.cs @@ -12,7 +12,7 @@ using System.Text.Json.Serialization; const string API_BASE = "https://api.unsandbox.com"; const string PORTAL_BASE = "https://unsandbox.com"; -const string VERSION = "4.3.4"; +const string VERSION = "4.2.28"; // ANSI colors const string BLUE = "\x1B[34m"; diff --git a/clients/dotnet/sync/src/Un.cs b/clients/dotnet/sync/src/Un.cs index 9b3faf9..c720f14 100644 --- a/clients/dotnet/sync/src/Un.cs +++ b/clients/dotnet/sync/src/Un.cs @@ -12,7 +12,7 @@ using System.Text.Json.Serialization; const string API_BASE = "https://api.unsandbox.com"; const string PORTAL_BASE = "https://unsandbox.com"; -const string VERSION = "4.3.4"; +const string VERSION = "4.2.28"; // ANSI colors const string BLUE = "\x1B[34m"; @@ -87,7 +87,7 @@ catch (Exception ex) void CmdExecute(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); var code = File.ReadAllText(args.SourceFile!); var language = DetectLanguage(args.SourceFile!); @@ -148,7 +148,7 @@ void CmdExecute(Args args) void CmdSession(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (args.SessionList) { @@ -224,7 +224,7 @@ void CmdSession(Args args) void CmdKey(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); var result = ApiRequest("/keys/validate", HttpMethod.Post, null, publicKey, secretKey); if (!result.TryGetValue("valid", out var validObj) || validObj is not JsonElement validEl) @@ -281,7 +281,7 @@ void OpenBrowser(string url) void CmdService(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (!string.IsNullOrEmpty(args.EnvAction)) { @@ -351,18 +351,9 @@ void CmdService(Args args) return; } - if (args.ServiceShowFreezePage != null) - { - var payload = new Dictionary { ["show_freeze_page"] = args.ServiceShowFreezePageEnabled }; - ApiRequest($"/services/{args.ServiceShowFreezePage}", new HttpMethod("PATCH"), payload, publicKey, secretKey); - string status = args.ServiceShowFreezePageEnabled ? "enabled" : "disabled"; - Console.WriteLine($"{GREEN}Show-freeze-page {status} for service: {args.ServiceShowFreezePage}{RESET}"); - return; - } - if (args.ServiceDestroy != null) { - ApiRequestWithSudo($"/services/{args.ServiceDestroy}", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceDestroy}", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); return; } @@ -376,7 +367,7 @@ void CmdService(Args args) if (args.ServiceUnlock != null) { - ApiRequestWithSudo($"/services/{args.ServiceUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service unlocked: {args.ServiceUnlock}{RESET}"); return; } @@ -392,22 +383,7 @@ void CmdService(Args args) if (args.ServiceRedeploy != null) { - var payload = new Dictionary(); - if (args.Files.Count > 0) - { - var inputFiles = new List>(); - foreach (var filepath in args.Files) - { - var content = File.ReadAllBytes(filepath); - inputFiles.Add(new Dictionary - { - ["filename"] = Path.GetFileName(filepath), - ["content"] = Convert.ToBase64String(content) - }); - } - payload["input_files"] = inputFiles; - } - ApiRequest($"/services/{args.ServiceRedeploy}/redeploy", HttpMethod.Post, payload.Count > 0 ? payload : null, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceRedeploy}/redeploy", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service redeploying: {args.ServiceRedeploy}{RESET}"); return; } @@ -467,20 +443,6 @@ void CmdService(Args args) if (args.Network != null) payload["network"] = args.Network; if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; if (args.ServiceCreateUnfreezeOnDemand) payload["unfreeze_on_demand"] = true; - if (args.Files.Count > 0) - { - var inputFiles = new List>(); - foreach (var filepath in args.Files) - { - var content = File.ReadAllBytes(filepath); - inputFiles.Add(new Dictionary - { - ["filename"] = Path.GetFileName(filepath), - ["content"] = Convert.ToBase64String(content) - }); - } - payload["input_files"] = inputFiles; - } var result = ApiRequest("/services", HttpMethod.Post, payload, publicKey, secretKey); var serviceId = result.TryGetValue("id", out var id) && id is JsonElement idEl ? idEl.GetString() : null; @@ -548,7 +510,7 @@ void CmdServiceEnv(Args args, string publicKey, string secretKey) void CmdSnapshot(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (args.SnapshotList) { @@ -573,7 +535,7 @@ void CmdSnapshot(Args args) if (args.SnapshotDelete != null) { - ApiRequestWithSudo($"/snapshots/{args.SnapshotDelete}", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequest($"/snapshots/{args.SnapshotDelete}", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Snapshot deleted: {args.SnapshotDelete}{RESET}"); return; } @@ -587,19 +549,11 @@ void CmdSnapshot(Args args) if (args.SnapshotUnlock != null) { - ApiRequestWithSudo($"/snapshots/{args.SnapshotUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + ApiRequest($"/snapshots/{args.SnapshotUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Snapshot unlocked: {args.SnapshotUnlock}{RESET}"); return; } - if (args.SnapshotRestore != null) - { - var result = ApiRequest($"/snapshots/{args.SnapshotRestore}/restore", HttpMethod.Post, null, publicKey, secretKey); - var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; - Console.WriteLine($"{GREEN}Restored from snapshot: {id}{RESET}"); - return; - } - if (args.SnapshotClone != null) { var payload = new Dictionary { ["type"] = args.SnapshotCloneType ?? "session" }; @@ -614,13 +568,13 @@ void CmdSnapshot(Args args) return; } - Console.Error.WriteLine($"{RED}Error: Use --list, --info, --delete, --lock, --unlock, --restore, or --clone{RESET}"); + Console.Error.WriteLine($"{RED}Error: Use --list, --info, --delete, --lock, --unlock, or --clone{RESET}"); Environment.Exit(1); } void CmdImage(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (args.ImageList) { @@ -645,7 +599,7 @@ void CmdImage(Args args) if (args.ImageDelete != null) { - ApiRequestWithSudo($"/images/{args.ImageDelete}", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequest($"/images/{args.ImageDelete}", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Image deleted: {args.ImageDelete}{RESET}"); return; } @@ -659,7 +613,7 @@ void CmdImage(Args args) if (args.ImageUnlock != null) { - ApiRequestWithSudo($"/images/{args.ImageUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + ApiRequest($"/images/{args.ImageUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Image unlocked: {args.ImageUnlock}{RESET}"); return; } @@ -715,7 +669,7 @@ void CmdImage(Args args) void CmdLanguages(Args args) { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); // Check cache first var cacheDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unsandbox"); @@ -768,19 +722,7 @@ void CmdLanguages(Args args) Console.WriteLine(lang); } -// HTTP exception with status code for sudo handling -class HttpStatusException : Exception -{ - public int StatusCode { get; } - public string ResponseBody { get; } - public HttpStatusException(int statusCode, string responseBody) : base($"HTTP {statusCode}: {responseBody}") - { - StatusCode = statusCode; - ResponseBody = responseBody; - } -} - -Dictionary ApiRequest(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey, string? sudoOtp = null, string? sudoChallengeId = null) +Dictionary ApiRequest(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) { var body = data != null ? JsonSerializer.Serialize(data, jsonOptions) : ""; @@ -803,12 +745,6 @@ Dictionary ApiRequest(string endpoint, HttpMethod method, Dictio request.Headers.Add("Authorization", $"Bearer {publicKey}"); } - // Add sudo OTP headers if provided - if (!string.IsNullOrEmpty(sudoOtp)) - request.Headers.Add("X-Sudo-OTP", sudoOtp); - if (!string.IsNullOrEmpty(sudoChallengeId)) - request.Headers.Add("X-Sudo-Challenge", sudoChallengeId); - // Synchronous HTTP call var response = httpClient.Send(request); using var reader = new StreamReader(response.Content.ReadAsStream()); @@ -822,7 +758,7 @@ Dictionary ApiRequest(string endpoint, HttpMethod method, Dictio Console.Error.WriteLine($"{YELLOW}Your computer's clock may have drifted.{RESET}"); Environment.Exit(1); } - throw new HttpStatusException((int)response.StatusCode, responseBody); + throw new Exception($"HTTP {(int)response.StatusCode}: {responseBody}"); } if (string.IsNullOrWhiteSpace(responseBody)) return new Dictionary(); @@ -838,41 +774,6 @@ Dictionary ApiRequest(string endpoint, HttpMethod method, Dictio } } -// Handle 428 sudo OTP challenge - prompts user for OTP and retries the request -Dictionary HandleSudoChallenge(string responseBody, string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) -{ - string? challengeId = null; - try - { - var doc = JsonDocument.Parse(responseBody); - if (doc.RootElement.TryGetProperty("challenge_id", out var cid)) - challengeId = cid.GetString(); - } - catch { } - - Console.Error.WriteLine($"{YELLOW}Confirmation required. Check your email for a one-time code.{RESET}"); - Console.Error.Write("Enter OTP: "); - - var otp = Console.ReadLine()?.Trim(); - if (string.IsNullOrEmpty(otp)) - throw new Exception("Operation cancelled"); - - return ApiRequest(endpoint, method, data, publicKey, secretKey, otp, challengeId); -} - -// Wrapper for destructive operations that may require 428 sudo OTP -Dictionary ApiRequestWithSudo(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) -{ - try - { - return ApiRequest(endpoint, method, data, publicKey, secretKey); - } - catch (HttpStatusException ex) when (ex.StatusCode == 428) - { - return HandleSudoChallenge(ex.ResponseBody, endpoint, method, data, publicKey, secretKey); - } -} - bool ServiceEnvSet(string serviceId, string envContent, string publicKey, string secretKey) { if (envContent.Length > 65536) { Console.Error.WriteLine($"{RED}Error: Env content exceeds maximum size of 64KB{RESET}"); return false; } @@ -896,69 +797,22 @@ bool ServiceEnvSet(string serviceId, string envContent, string publicKey, string catch { return false; } } -(string, string) LoadAccountsCSV(string path, int index) +(string, string) GetApiKeys(string? argsKey) { - if (!File.Exists(path)) return (null!, null!); - var row = 0; - foreach (var rawLine in File.ReadAllLines(path)) - { - var line = rawLine.Trim(); - if (line.Length == 0 || line.StartsWith("#")) continue; - if (row == index) - { - var parts = line.Split(','); - if (parts.Length >= 2) - return (parts[0].Trim(), parts[1].Trim()); - } - row++; - } - return (null!, null!); -} - -(string, string) GetApiKeys(string? argsKey, int accountIndex = -1) -{ - // Tier 2: --account N → accounts.csv row N (bypasses env vars) - if (accountIndex >= 0) - { - var home = Environment.GetEnvironmentVariable("HOME") - ?? Environment.GetEnvironmentVariable("USERPROFILE") ?? "."; - var homeCsv = Path.Combine(home, ".unsandbox", "accounts.csv"); - var (pk1, sk1) = LoadAccountsCSV(homeCsv, accountIndex); - if (!string.IsNullOrEmpty(pk1) && !string.IsNullOrEmpty(sk1)) return (pk1, sk1); - var (pk2, sk2) = LoadAccountsCSV("accounts.csv", accountIndex); - if (!string.IsNullOrEmpty(pk2) && !string.IsNullOrEmpty(sk2)) return (pk2, sk2); - Console.Error.WriteLine($"{RED}Error: No account at index {accountIndex} in accounts.csv{RESET}"); - Environment.Exit(1); - } - - // Tier 3: environment variables var publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); var secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); - if (!string.IsNullOrEmpty(publicKey) && !string.IsNullOrEmpty(secretKey)) - return (publicKey, secretKey); - // Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var) - var defaultIdx = 0; - var acctEnv = Environment.GetEnvironmentVariable("UNSANDBOX_ACCOUNT"); - if (!string.IsNullOrEmpty(acctEnv) && int.TryParse(acctEnv, out var parsedIdx)) - defaultIdx = parsedIdx; - var home2 = Environment.GetEnvironmentVariable("HOME") - ?? Environment.GetEnvironmentVariable("USERPROFILE") ?? "."; - var (pk3, sk3) = LoadAccountsCSV(Path.Combine(home2, ".unsandbox", "accounts.csv"), defaultIdx); - if (!string.IsNullOrEmpty(pk3) && !string.IsNullOrEmpty(sk3)) return (pk3, sk3); - - // Tier 5: ./accounts.csv row 0 - var (pk4, sk4) = LoadAccountsCSV("accounts.csv", defaultIdx); - if (!string.IsNullOrEmpty(pk4) && !string.IsNullOrEmpty(sk4)) return (pk4, sk4); - - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - var legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); - if (string.IsNullOrEmpty(legacyKey)) + if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) { - Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}"); - Environment.Exit(1); + var legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); + if (string.IsNullOrEmpty(legacyKey)) + { + Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}"); + Environment.Exit(1); + } + return (legacyKey, ""); } - return (legacyKey!, ""); + return (publicKey, secretKey); } string DetectLanguage(string filename) @@ -1080,11 +934,8 @@ Args ParseArgs(string[] args) else if (arg == "--dump-file") result.ServiceDumpFile = args[++i]; else if (arg == "--unfreeze-on-demand") result.ServiceUnfreezeOnDemand = args[++i]; else if (arg == "--unfreeze-on-demand-enabled") result.ServiceUnfreezeOnDemandEnabled = args[++i].ToLower() == "true"; - else if (arg == "--show-freeze-page") result.ServiceShowFreezePage = args[++i]; - else if (arg == "--show-freeze-page-enabled") result.ServiceShowFreezePageEnabled = args[++i].ToLower() == "true"; else if (arg == "--with-unfreeze-on-demand") result.ServiceCreateUnfreezeOnDemand = true; else if (arg == "--extend") result.KeyExtend = true; - else if (arg == "--account") result.Account = int.Parse(args[++i]); else if (arg == "--delete") { var val = args[++i]; @@ -1098,7 +949,6 @@ Args ParseArgs(string[] args) else if (result.Command == "image") result.ImageClone = val; } else if (arg == "--clone-type") result.SnapshotCloneType = args[++i]; - else if (arg == "--restore" && result.Command == "snapshot") result.SnapshotRestore = args[++i]; else if (arg == "--publish") result.ImagePublish = args[++i]; else if (arg == "--source-type") result.ImageSourceType = args[++i]; else if (arg == "--visibility") @@ -1162,12 +1012,10 @@ Service options: --lock ID Prevent deletion --unlock ID Allow deletion --resize ID Resize (use with -v) - --redeploy ID Re-run bootstrap (use -f to include input files) + --redeploy ID Re-run bootstrap --snapshot ID Create snapshot from service --unfreeze-on-demand ID Set unfreeze-on-demand for service --unfreeze-on-demand-enabled BOOL Enable/disable (default: true) - --show-freeze-page ID Set show-freeze-page for service - --show-freeze-page-enabled BOOL Enable/disable (default: true) --with-unfreeze-on-demand Enable unfreeze-on-demand when creating service --destroy ID Destroy service --execute ID Execute command in service @@ -1175,7 +1023,6 @@ Service options: --dump-bootstrap ID Dump bootstrap script --dump-file FILE File to save bootstrap (with --dump-bootstrap) -e KEY=VALUE Set vault env var (with --name or env set) - -f FILE Add input file (with --name or --redeploy) --env-file FILE Load vault vars from file Service env commands: @@ -1218,888 +1065,6 @@ Environment: UNSANDBOX_SECRET_KEY Your secret API key"); } -// ============================================================================= -// Library API - For embedding in other .NET applications -// ============================================================================= - -/// -/// Unsandbox SDK for .NET - Full library API matching the C reference implementation -/// -public static class Unsandbox -{ - private static readonly HttpClient _httpClient = new() { BaseAddress = new Uri("https://api.unsandbox.com"), Timeout = TimeSpan.FromMinutes(5) }; - private static readonly JsonSerializerOptions _jsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - PropertyNameCaseInsensitive = true - }; - private static string? _lastError; - - // --- Execution Functions (8) --- - - /// Execute code synchronously - public static ExecuteResult Execute(string language, string code, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["language"] = language, ["code"] = code }; - try - { - var result = ApiCall("/execute", HttpMethod.Post, payload, pk, sk); - return new ExecuteResult - { - Stdout = GetString(result, "stdout"), - Stderr = GetString(result, "stderr"), - ExitCode = GetInt(result, "exit_code"), - Language = language, - ExecutionTime = GetDouble(result, "execution_time"), - Success = true - }; - } - catch (Exception ex) { _lastError = ex.Message; return new ExecuteResult { Success = false, ErrorMessage = ex.Message }; } - } - - /// Execute code asynchronously, returns job ID - public static string? ExecuteAsync(string language, string code, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["language"] = language, ["code"] = code, ["async"] = true }; - try - { - var result = ApiCall("/execute", HttpMethod.Post, payload, pk, sk); - return GetString(result, "job_id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - /// Wait for async job to complete - public static ExecuteResult? WaitJob(string jobId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/jobs/{jobId}/wait", HttpMethod.Get, null, pk, sk); - return new ExecuteResult - { - Stdout = GetString(result, "stdout"), - Stderr = GetString(result, "stderr"), - ExitCode = GetInt(result, "exit_code"), - ExecutionTime = GetDouble(result, "execution_time"), - Success = true - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - /// Get job status - public static JobInfo? GetJob(string jobId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/jobs/{jobId}", HttpMethod.Get, null, pk, sk); - return new JobInfo - { - Id = GetString(result, "id"), - Language = GetString(result, "language"), - Status = GetString(result, "status"), - CreatedAt = GetLong(result, "created_at"), - CompletedAt = GetLong(result, "completed_at") - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - /// Cancel a running job - public static bool CancelJob(string jobId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/jobs/{jobId}/cancel", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - /// List all jobs - public static List ListJobs(string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/jobs", HttpMethod.Get, null, pk, sk); - var jobs = new List(); - if (result.TryGetValue("jobs", out var obj) && obj is JsonElement el) - foreach (var j in el.EnumerateArray()) - jobs.Add(new JobInfo { Id = GetStr(j, "id"), Status = GetStr(j, "status"), Language = GetStr(j, "language") }); - return jobs; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - /// Get available programming languages - public static List GetLanguages(string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/languages", HttpMethod.Get, null, pk, sk); - if (result.TryGetValue("languages", out var obj) && obj is JsonElement el) - return el.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => !string.IsNullOrEmpty(x)).ToList(); - return new List(); - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - /// Detect language from filename extension - public static string? DetectLanguage(string filename) - { - var extMap = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [".py"] = "python", [".js"] = "javascript", [".ts"] = "typescript", - [".rb"] = "ruby", [".php"] = "php", [".pl"] = "perl", [".lua"] = "lua", - [".sh"] = "bash", [".go"] = "go", [".rs"] = "rust", [".c"] = "c", - [".cpp"] = "cpp", [".java"] = "java", [".cs"] = "dotnet", [".fs"] = "fsharp" - }; - var ext = Path.GetExtension(filename); - return extMap.TryGetValue(ext, out var lang) ? lang : null; - } - - // --- Session Functions (9) --- - - public static List SessionList(string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/sessions", HttpMethod.Get, null, pk, sk); - var sessions = new List(); - if (result.TryGetValue("sessions", out var obj) && obj is JsonElement el) - foreach (var s in el.EnumerateArray()) - sessions.Add(new SessionInfo { Id = GetStr(s, "id"), Status = GetStr(s, "status"), NetworkMode = GetStr(s, "network_mode") }); - return sessions; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static SessionInfo? SessionGet(string sessionId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/sessions/{sessionId}", HttpMethod.Get, null, pk, sk); - return new SessionInfo { Id = GetString(result, "id"), Status = GetString(result, "status"), NetworkMode = GetString(result, "network_mode") }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static SessionInfo? SessionCreate(string? networkMode = null, string? shell = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["shell"] = shell ?? "bash" }; - if (networkMode != null) payload["network"] = networkMode; - try - { - var result = ApiCall("/sessions", HttpMethod.Post, payload, pk, sk); - return new SessionInfo { Id = GetString(result, "id"), Status = "running" }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool SessionDestroy(string sessionId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/sessions/{sessionId}", HttpMethod.Delete, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SessionFreeze(string sessionId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/sessions/{sessionId}/freeze", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SessionUnfreeze(string sessionId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/sessions/{sessionId}/unfreeze", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SessionBoost(string sessionId, int vcpu = 2, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["vcpu"] = vcpu }; - try { ApiCall($"/sessions/{sessionId}/boost", HttpMethod.Post, payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SessionUnboost(string sessionId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/sessions/{sessionId}/unboost", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static ExecuteResult? SessionExecute(string sessionId, string command, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["command"] = command }; - try - { - var result = ApiCall($"/sessions/{sessionId}/execute", HttpMethod.Post, payload, pk, sk); - return new ExecuteResult - { - Stdout = GetString(result, "stdout"), - Stderr = GetString(result, "stderr"), - ExitCode = GetInt(result, "exit_code"), - Success = true - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - // --- Service Functions (17) --- - - public static List ServiceList(string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/services", HttpMethod.Get, null, pk, sk); - var services = new List(); - if (result.TryGetValue("services", out var obj) && obj is JsonElement el) - foreach (var s in el.EnumerateArray()) - services.Add(new ServiceInfo { Id = GetStr(s, "id"), Name = GetStr(s, "name"), Status = GetStr(s, "status") }); - return services; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static ServiceInfo? ServiceGet(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/services/{serviceId}", HttpMethod.Get, null, pk, sk); - return new ServiceInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Status = GetString(result, "status") }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string? ServiceCreate(string name, string? ports = null, string? domains = null, string? bootstrap = null, string? networkMode = null, List>? inputFiles = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["name"] = name }; - if (ports != null) payload["ports"] = ports.Split(',').Select(p => int.Parse(p.Trim())).ToList(); - if (domains != null) payload["domains"] = domains; - if (bootstrap != null) payload["bootstrap"] = bootstrap; - if (networkMode != null) payload["network"] = networkMode; - if (inputFiles != null && inputFiles.Count > 0) payload["input_files"] = inputFiles; - try - { - var result = ApiCall("/services", HttpMethod.Post, payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool ServiceDestroy(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}", HttpMethod.Delete, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceFreeze(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/freeze", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceUnfreeze(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/unfreeze", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceLock(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/lock", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceUnlock(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/unlock", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceSetUnfreezeOnDemand(string serviceId, bool enabled, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["unfreeze_on_demand"] = enabled }; - try { ApiCall($"/services/{serviceId}", new HttpMethod("PATCH"), payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceRedeploy(string serviceId, string? bootstrap = null, List>? inputFiles = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - Dictionary? payload = null; - if (bootstrap != null || inputFiles != null) - { - payload = new Dictionary(); - if (bootstrap != null) payload["bootstrap"] = bootstrap; - if (inputFiles != null && inputFiles.Count > 0) payload["input_files"] = inputFiles; - } - try { ApiCall($"/services/{serviceId}/redeploy", HttpMethod.Post, payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static string? ServiceLogs(string serviceId, bool allLogs = false, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var endpoint = allLogs ? $"/services/{serviceId}/logs?lines=9000" : $"/services/{serviceId}/logs"; - try - { - var result = ApiCall(endpoint, HttpMethod.Get, null, pk, sk); - return GetString(result, "logs"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static ExecuteResult? ServiceExecute(string serviceId, string command, int timeoutMs = 30000, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["command"] = command, ["timeout_ms"] = timeoutMs }; - try - { - var result = ApiCall($"/services/{serviceId}/execute", HttpMethod.Post, payload, pk, sk); - return new ExecuteResult - { - Stdout = GetString(result, "stdout"), - Stderr = GetString(result, "stderr"), - ExitCode = GetInt(result, "exit_code"), - Success = true - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string? ServiceEnvGet(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/services/{serviceId}/env", HttpMethod.Get, null, pk, sk); - return GetString(result, "content"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool ServiceEnvSet(string serviceId, string envContent, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCallText($"/services/{serviceId}/env", HttpMethod.Put, envContent, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ServiceEnvDelete(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/services/{serviceId}/env", HttpMethod.Delete, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static string? ServiceEnvExport(string serviceId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/services/{serviceId}/env/export", HttpMethod.Post, null, pk, sk); - return GetString(result, "content"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool ServiceResize(string serviceId, int vcpu, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["vcpu"] = vcpu }; - try { ApiCall($"/services/{serviceId}/resize", HttpMethod.Post, payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - // --- Snapshot Functions (9) --- - - public static List SnapshotList(string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/snapshots", HttpMethod.Get, null, pk, sk); - var snapshots = new List(); - if (result.TryGetValue("snapshots", out var obj) && obj is JsonElement el) - foreach (var s in el.EnumerateArray()) - snapshots.Add(new SnapshotInfo { Id = GetStr(s, "id"), Name = GetStr(s, "name"), Type = GetStr(s, "source_type") }); - return snapshots; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static SnapshotInfo? SnapshotGet(string snapshotId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/snapshots/{snapshotId}", HttpMethod.Get, null, pk, sk); - return new SnapshotInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Type = GetString(result, "source_type") }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string? SnapshotSession(string sessionId, string? name = null, bool hot = false, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary(); - if (name != null) payload["name"] = name; - if (hot) payload["hot"] = true; - try - { - var result = ApiCall($"/sessions/{sessionId}/snapshot", HttpMethod.Post, payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string? SnapshotService(string serviceId, string? name = null, bool hot = false, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary(); - if (name != null) payload["name"] = name; - if (hot) payload["hot"] = true; - try - { - var result = ApiCall($"/services/{serviceId}/snapshot", HttpMethod.Post, payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string? SnapshotRestore(string snapshotId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/snapshots/{snapshotId}/restore", HttpMethod.Post, null, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool SnapshotDelete(string snapshotId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/snapshots/{snapshotId}", HttpMethod.Delete, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SnapshotLock(string snapshotId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/snapshots/{snapshotId}/lock", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool SnapshotUnlock(string snapshotId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/snapshots/{snapshotId}/unlock", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static string? SnapshotClone(string snapshotId, string cloneType, string? name = null, string? ports = null, string? shell = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["type"] = cloneType }; - if (name != null) payload["name"] = name; - if (ports != null) payload["ports"] = ports.Split(',').Select(p => int.Parse(p.Trim())).ToList(); - if (shell != null) payload["shell"] = shell; - try - { - var result = ApiCall($"/snapshots/{snapshotId}/clone", HttpMethod.Post, payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - // --- Image Functions (13) --- - - public static List ImageList(string? filter = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var endpoint = filter != null ? $"/images?filter={filter}" : "/images"; - try - { - var result = ApiCall(endpoint, HttpMethod.Get, null, pk, sk); - var images = new List(); - if (result.TryGetValue("images", out var obj) && obj is JsonElement el) - foreach (var img in el.EnumerateArray()) - images.Add(new ImageInfo { Id = GetStr(img, "id"), Name = GetStr(img, "name"), Visibility = GetStr(img, "visibility") }); - return images; - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static ImageInfo? ImageGet(string imageId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/images/{imageId}", HttpMethod.Get, null, pk, sk); - return new ImageInfo { Id = GetString(result, "id"), Name = GetString(result, "name"), Visibility = GetString(result, "visibility") }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string? ImagePublish(string sourceType, string sourceId, string? name = null, string? description = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["source_type"] = sourceType, ["source_id"] = sourceId }; - if (name != null) payload["name"] = name; - if (description != null) payload["description"] = description; - try - { - var result = ApiCall("/images", HttpMethod.Post, payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static bool ImageDelete(string imageId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/images/{imageId}", HttpMethod.Delete, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageLock(string imageId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/images/{imageId}/lock", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageUnlock(string imageId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try { ApiCall($"/images/{imageId}/unlock", HttpMethod.Post, null, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageSetVisibility(string imageId, string visibility, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["visibility"] = visibility }; - try { ApiCall($"/images/{imageId}", new HttpMethod("PATCH"), payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageGrantAccess(string imageId, string trustedApiKey, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["api_key"] = trustedApiKey }; - try { ApiCall($"/images/{imageId}/access", HttpMethod.Post, payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static bool ImageRevokeAccess(string imageId, string trustedApiKey, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["api_key"] = trustedApiKey }; - try { ApiCall($"/images/{imageId}/access", HttpMethod.Delete, payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static List ImageListTrusted(string imageId, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall($"/images/{imageId}/access", HttpMethod.Get, null, pk, sk); - if (result.TryGetValue("trusted_keys", out var obj) && obj is JsonElement el) - return el.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => !string.IsNullOrEmpty(x)).ToList(); - return new List(); - } - catch (Exception ex) { _lastError = ex.Message; return new List(); } - } - - public static bool ImageTransfer(string imageId, string toApiKey, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary { ["to_api_key"] = toApiKey }; - try { ApiCall($"/images/{imageId}/transfer", HttpMethod.Post, payload, pk, sk); return true; } - catch (Exception ex) { _lastError = ex.Message; return false; } - } - - public static string? ImageSpawn(string imageId, string? name = null, string? ports = null, string? bootstrap = null, string? networkMode = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary(); - if (name != null) payload["name"] = name; - if (ports != null) payload["ports"] = ports.Split(',').Select(p => int.Parse(p.Trim())).ToList(); - if (bootstrap != null) payload["bootstrap"] = bootstrap; - if (networkMode != null) payload["network"] = networkMode; - try - { - var result = ApiCall($"/images/{imageId}/spawn", HttpMethod.Post, payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string? ImageClone(string imageId, string? name = null, string? description = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var payload = new Dictionary(); - if (name != null) payload["name"] = name; - if (description != null) payload["description"] = description; - try - { - var result = ApiCall($"/images/{imageId}/clone", HttpMethod.Post, payload, pk, sk); - return GetString(result, "id"); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - // --- PaaS Logs (2) --- - - public static string? LogsFetch(string source, int lines = 100, string? since = null, string? grep = null, string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - var endpoint = $"/paas/logs?source={source}&lines={lines}"; - if (since != null) endpoint += $"&since={since}"; - if (grep != null) endpoint += $"&grep={Uri.EscapeDataString(grep)}"; - try - { - var result = ApiCall(endpoint, HttpMethod.Get, null, pk, sk); - return JsonSerializer.Serialize(result); - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - // LogsStream requires SSE/WebSocket support - not implemented in sync version - - // --- Utilities --- - - public static KeyInfo? ValidateKeys(string? publicKey = null, string? secretKey = null) - { - var (pk, sk) = ResolveKeys(publicKey, secretKey); - try - { - var result = ApiCall("/keys/validate", HttpMethod.Post, null, pk, sk); - return new KeyInfo - { - Valid = result.TryGetValue("valid", out var v) && v is JsonElement ve && ve.GetBoolean(), - Tier = GetString(result, "tier"), - RateLimitPerMinute = GetInt(result, "rate_limit"), - ConcurrencyLimit = GetInt(result, "concurrency") - }; - } - catch (Exception ex) { _lastError = ex.Message; return null; } - } - - public static string HmacSign(string secretKey, string message) - { - using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); - return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); - } - - public static bool HealthCheck() - { - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, "/health"); - var response = _httpClient.Send(request); - return response.IsSuccessStatusCode; - } - catch { return false; } - } - - public static string Version() => "4.2.50"; - - public static string? LastError() => _lastError; - - // --- Internal Helpers --- - - private static (string, string) ResolveKeys(string? publicKey, string? secretKey) - { - var pk = publicKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") ?? ""; - var sk = secretKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") ?? ""; - return (pk, sk); - } - - private static Dictionary ApiCall(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) - { - var body = data != null ? JsonSerializer.Serialize(data, _jsonOptions) : ""; - using var request = new HttpRequestMessage(method, endpoint); - if (data != null) request.Content = new StringContent(body, Encoding.UTF8, "application/json"); - - if (!string.IsNullOrEmpty(secretKey)) - { - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var message = $"{timestamp}:{method.Method}:{endpoint}:{body}"; - using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); - var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); - 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}"); - } - - var response = _httpClient.Send(request); - using var reader = new StreamReader(response.Content.ReadAsStream()); - var responseBody = reader.ReadToEnd(); - - if (!response.IsSuccessStatusCode) - throw new Exception($"HTTP {(int)response.StatusCode}: {responseBody}"); - - if (string.IsNullOrWhiteSpace(responseBody)) return new Dictionary(); - var doc = JsonDocument.Parse(responseBody); - return doc.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => (object)p.Value.Clone()); - } - - private static void ApiCallText(string endpoint, HttpMethod method, string body, string publicKey, string secretKey) - { - using var request = new HttpRequestMessage(method, endpoint); - request.Content = new StringContent(body, Encoding.UTF8, "text/plain"); - - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var message = $"{timestamp}:{method.Method}:{endpoint}:{body}"; - using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); - var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); - request.Headers.Add("Authorization", $"Bearer {publicKey}"); - request.Headers.Add("X-Timestamp", timestamp.ToString()); - request.Headers.Add("X-Signature", signature); - - var response = _httpClient.Send(request); - if (!response.IsSuccessStatusCode) - { - using var reader = new StreamReader(response.Content.ReadAsStream()); - throw new Exception($"HTTP {(int)response.StatusCode}: {reader.ReadToEnd()}"); - } - } - - private static string? GetString(Dictionary result, string key) - => result.TryGetValue(key, out var v) && v is JsonElement el ? el.GetString() : null; - - private static int GetInt(Dictionary result, string key) - => result.TryGetValue(key, out var v) && v is JsonElement el && el.TryGetInt32(out var i) ? i : 0; - - private static long GetLong(Dictionary result, string key) - => result.TryGetValue(key, out var v) && v is JsonElement el && el.TryGetInt64(out var i) ? i : 0; - - private static double GetDouble(Dictionary result, string key) - => result.TryGetValue(key, out var v) && v is JsonElement el && el.TryGetDouble(out var d) ? d : 0; - - private static string GetStr(JsonElement el, string prop) => el.TryGetProperty(prop, out var p) ? p.GetString() ?? "" : ""; -} - -// --- Data Types --- - -public class ExecuteResult -{ - public string? Stdout { get; set; } - public string? Stderr { get; set; } - public int ExitCode { get; set; } - public string? Language { get; set; } - public double ExecutionTime { get; set; } - public bool Success { get; set; } - public string? ErrorMessage { get; set; } -} - -public class JobInfo -{ - public string? Id { get; set; } - public string? Language { get; set; } - public string? Status { get; set; } - public long CreatedAt { get; set; } - public long CompletedAt { get; set; } - public string? ErrorMessage { get; set; } -} - -public class SessionInfo -{ - public string? Id { get; set; } - public string? ContainerName { get; set; } - public string? Status { get; set; } - public string? NetworkMode { get; set; } - public int Vcpu { get; set; } - public long CreatedAt { get; set; } - public long LastActivity { get; set; } -} - -public class ServiceInfo -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Status { get; set; } - public string? ContainerName { get; set; } - public string? NetworkMode { get; set; } - public string? Ports { get; set; } - public string? Domains { get; set; } - public int Vcpu { get; set; } - public bool Locked { get; set; } - public bool UnfreezeOnDemand { get; set; } - public long CreatedAt { get; set; } - public long LastActivity { get; set; } -} - -public class SnapshotInfo -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Type { get; set; } - public string? SourceId { get; set; } - public bool Hot { get; set; } - public bool Locked { get; set; } - public long CreatedAt { get; set; } - public long SizeBytes { get; set; } -} - -public class ImageInfo -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Description { get; set; } - public string? Visibility { get; set; } - public string? SourceType { get; set; } - public string? SourceId { get; set; } - public string? OwnerApiKey { get; set; } - public bool Locked { get; set; } - public long CreatedAt { get; set; } - public long SizeBytes { get; set; } -} - -public class KeyInfo -{ - public bool Valid { get; set; } - public string? Tier { get; set; } - public int RateLimitPerMinute { get; set; } - public int RateLimitBurst { get; set; } - public int ConcurrencyLimit { get; set; } - public string? ErrorMessage { get; set; } -} - -// ============================================================================= -// CLI Args -// ============================================================================= - class Args { public bool ShowHelp, ShowVersion; @@ -2116,21 +1081,16 @@ class Args public string? ServiceDumpBootstrap, ServiceDumpFile; public string? ServiceUnfreezeOnDemand; public bool ServiceUnfreezeOnDemandEnabled = true; - public string? ServiceShowFreezePage; - public bool ServiceShowFreezePageEnabled = true; public bool ServiceCreateUnfreezeOnDemand; public string? EnvFile, EnvAction, EnvTarget; public bool KeyExtend; - public int Account = -1; public bool SnapshotList; public string? SnapshotInfo, SnapshotDelete, SnapshotLock, SnapshotUnlock, SnapshotClone; public string? SnapshotCloneType, SnapshotName; public bool SnapshotHot; - public string? SnapshotRestore; public bool ImageList; public string? ImageInfo, ImageDelete, ImageLock, ImageUnlock; public string? ImagePublish, ImageSourceType, ImageVisibility, ImageVisibilityMode; public string? ImageSpawn, ImageClone; - public string? ImageGrantAccess, ImageRevokeAccess, ImageTransfer; public bool LanguagesJson; } diff --git a/clients/dotnet/tests/UnsandboxTests.cs b/clients/dotnet/tests/UnsandboxTests.cs deleted file mode 100644 index 344f2f1..0000000 --- a/clients/dotnet/tests/UnsandboxTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY -// Unit and Functional Tests for Unsandbox .NET SDK - -using System; -using System.Collections.Generic; - -namespace UnsandboxTests; - -/// -/// Unit tests for the Unsandbox SDK library functions. -/// These tests verify that exported library functions work correctly. -/// -public class UnitTests -{ - public static void Run() - { - Console.WriteLine("=== Unsandbox .NET SDK Unit Tests ===\n"); - - TestDetectLanguage(); - TestHmacSign(); - TestVersion(); - - Console.WriteLine("\n=== Unit Tests Complete ==="); - } - - static void TestDetectLanguage() - { - Console.Write("DetectLanguage: "); - var tests = new Dictionary - { - { "test.py", "python" }, - { "script.js", "javascript" }, - { "main.go", "go" }, - { "app.rs", "rust" }, - { "Program.cs", "dotnet" }, - { "Module.fs", "fsharp" }, - { "unknown.xyz", null } - }; - - int passed = 0; - foreach (var (file, expected) in tests) - { - var result = Unsandbox.DetectLanguage(file); - if (result == expected) passed++; - else Console.Write($"[FAIL: {file} -> {result}, expected {expected}] "); - } - - if (passed == tests.Count) - Console.WriteLine($"PASS ({passed}/{tests.Count})"); - else - Console.WriteLine($"FAIL ({passed}/{tests.Count})"); - } - - static void TestHmacSign() - { - Console.Write("HmacSign: "); - // Test vector: HMAC-SHA256("key", "message") - var result = Unsandbox.HmacSign("key", "message"); - // Expected: 6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a - var expected = "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"; - if (result == expected) - Console.WriteLine("PASS"); - else - Console.WriteLine($"FAIL (got {result}, expected {expected})"); - } - - static void TestVersion() - { - Console.Write("Version: "); - var version = Unsandbox.Version(); - if (!string.IsNullOrEmpty(version) && version.Contains(".")) - Console.WriteLine($"PASS ({version})"); - else - Console.WriteLine($"FAIL (got {version})"); - } -} - -/// -/// Functional tests that require API credentials. -/// Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables. -/// -public class FunctionalTests -{ - public static void Run() - { - var publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); - var secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); - - if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) - { - Console.WriteLine("=== Functional Tests Skipped (no API credentials) ==="); - return; - } - - Console.WriteLine("=== Unsandbox .NET SDK Functional Tests ===\n"); - - TestHealthCheck(); - TestValidateKeys(); - TestGetLanguages(); - TestExecute(); - TestSessionList(); - TestServiceList(); - TestSnapshotList(); - TestImageList(); - - Console.WriteLine("\n=== Functional Tests Complete ==="); - } - - static void TestHealthCheck() - { - Console.Write("HealthCheck: "); - var result = Unsandbox.HealthCheck(); - Console.WriteLine(result ? "PASS" : "FAIL"); - } - - static void TestValidateKeys() - { - Console.Write("ValidateKeys: "); - var result = Unsandbox.ValidateKeys(); - if (result != null && result.Valid) - Console.WriteLine($"PASS (tier: {result.Tier})"); - else - Console.WriteLine($"FAIL ({Unsandbox.LastError()})"); - } - - static void TestGetLanguages() - { - Console.Write("GetLanguages: "); - var result = Unsandbox.GetLanguages(); - if (result.Count > 0) - Console.WriteLine($"PASS ({result.Count} languages)"); - else - Console.WriteLine($"FAIL ({Unsandbox.LastError()})"); - } - - static void TestExecute() - { - Console.Write("Execute: "); - var result = Unsandbox.Execute("python", "print('hello from .NET SDK')"); - if (result.Success && result.Stdout?.Contains("hello") == true) - Console.WriteLine("PASS"); - else - Console.WriteLine($"FAIL ({result.ErrorMessage ?? Unsandbox.LastError()})"); - } - - static void TestSessionList() - { - Console.Write("SessionList: "); - var result = Unsandbox.SessionList(); - // Empty list is valid - just checking API call works - Console.WriteLine($"PASS ({result.Count} sessions)"); - } - - static void TestServiceList() - { - Console.Write("ServiceList: "); - var result = Unsandbox.ServiceList(); - Console.WriteLine($"PASS ({result.Count} services)"); - } - - static void TestSnapshotList() - { - Console.Write("SnapshotList: "); - var result = Unsandbox.SnapshotList(); - Console.WriteLine($"PASS ({result.Count} snapshots)"); - } - - static void TestImageList() - { - Console.Write("ImageList: "); - var result = Unsandbox.ImageList(); - Console.WriteLine($"PASS ({result.Count} images)"); - } -} - -public class Program -{ - public static int Main(string[] args) - { - try - { - UnitTests.Run(); - Console.WriteLine(); - FunctionalTests.Run(); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Test error: {ex.Message}"); - return 1; - } - } -} diff --git a/clients/dotnet/tests/UnsandboxTests.csproj b/clients/dotnet/tests/UnsandboxTests.csproj deleted file mode 100644 index a60160f..0000000 --- a/clients/dotnet/tests/UnsandboxTests.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - - - - - diff --git a/clients/elixir/sync/src/un.ex b/clients/elixir/sync/src/un.ex index 187faa5..f2a1751 100755 --- a/clients/elixir/sync/src/un.ex +++ b/clients/elixir/sync/src/un.ex @@ -1,19 +1,39 @@ #!/usr/bin/env elixir -# This is free software for the public good of a permacomputer hosted at -# permacomputer.com, an always-on computer by the people, for the people. -# One which is durable, easy to repair, & distributed like tap water -# for machine learning intelligence. +# PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # -# The permacomputer is community-owned infrastructure optimized around -# four values: +# This is free public domain software for the public good of a permacomputer hosted +# at permacomputer.com - an always-on computer by the people, for the people. One +# which is durable, easy to repair, and distributed like tap water for machine +# learning intelligence. # -# TRUTH First principles, math & science, open source code freely distributed -# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY Minimal waste, self-renewing systems with diverse thriving connections -# LOVE Be yourself without hurting others, cooperation through natural law +# The permacomputer is community-owned infrastructure optimized around four values: # -# This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -# Code is seeds to sprout on any abandoned technology. +# TRUTH - First principles, math & science, open source code freely distributed +# FREEDOM - Voluntary partnerships, freedom from tyranny & corporate control +# HARMONY - Minimal waste, self-renewing systems with diverse thriving connections +# LOVE - Be yourself without hurting others, cooperation through natural law +# +# This software contributes to that vision by enabling code execution across 42+ +# programming languages through a unified interface, accessible to all. Code is +# seeds to sprout on any abandoned technology. +# +# Learn more: https://www.permacomputer.com +# +# Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +# software, either in source code form or as a compiled binary, for any purpose, +# commercial or non-commercial, and by any means. +# +# NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +# +# That said, our permacomputer's digital membrane stratum continuously runs unit, +# integration, and functional tests on all of it's own software - with our +# permacomputer monitoring itself, repairing itself, with minimal human in the +# loop guidance. Our agents do their best. +# +# Copyright 2025 TimeHexOn & foxhop & russell@unturf +# https://www.timehexon.com +# https://www.foxhop.net +# https://www.unturf.com/software # un.ex - Unsandbox CLI client in Elixir # @@ -32,115 +52,14 @@ # Uses curl for HTTP (no external dependencies) defmodule Un do - @moduledoc """ - unsandbox.com Elixir SDK - Full API with execution, sessions, services, snapshots, and images. - - ## Library Usage - - # Execute code synchronously - result = Un.execute("python", "print(42)") - IO.puts(result.stdout) - - # List sessions - sessions = Un.session_list() - - # Create a service - service_id = Un.service_create("myapp", ports: "8080") - - ## Authentication - - Credentials are loaded in priority order: - 1. Function arguments (public_key, secret_key) - 2. --account N -> accounts.csv row N (bypasses env vars) - 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) - 4. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) - 5. ./accounts.csv row 0 - """ - @blue "\e[34m" @red "\e[31m" @green "\e[32m" @yellow "\e[33m" @reset "\e[0m" - @api_base "https://api.unsandbox.com" @portal_base "https://unsandbox.com" @languages_cache_ttl 3600 - @version "4.2.0" - - # ============================================================================ - # Types - # ============================================================================ - - @type result :: %{ - success: boolean(), - stdout: String.t(), - stderr: String.t(), - exit_code: integer(), - job_id: String.t() | nil, - language: String.t() | nil, - execution_time: float() | nil - } - - @type job :: %{ - id: String.t(), - status: String.t(), - language: String.t() | nil, - created_at: integer() | nil, - completed_at: integer() | nil - } - - @type session :: %{ - id: String.t(), - status: String.t(), - container_name: String.t() | nil, - network_mode: String.t() | nil, - vcpu: integer() | nil, - created_at: integer() | nil - } - - @type service :: %{ - id: String.t(), - name: String.t(), - status: String.t(), - ports: String.t() | nil, - domains: String.t() | nil, - vcpu: integer() | nil, - locked: boolean(), - unfreeze_on_demand: boolean(), - created_at: integer() | nil - } - - @type snapshot :: %{ - id: String.t(), - name: String.t() | nil, - type: String.t(), - source_id: String.t(), - hot: boolean(), - locked: boolean(), - created_at: integer() | nil, - size_bytes: integer() | nil - } - - @type image :: %{ - id: String.t(), - name: String.t() | nil, - description: String.t() | nil, - visibility: String.t(), - source_type: String.t(), - source_id: String.t(), - locked: boolean(), - created_at: integer() | nil, - size_bytes: integer() | nil - } - - @type key_info :: %{ - valid: boolean(), - tier: String.t() | nil, - rate_limit_per_minute: integer() | nil, - concurrency_limit: integer() | nil, - expires_at: integer() | nil - } @ext_map %{ ".ex" => "elixir", ".exs" => "elixir", ".erl" => "erlang", @@ -157,1007 +76,25 @@ defmodule Un do ".forth" => "forth", ".tcl" => "tcl", ".raku" => "raku" } - # ============================================================================ - # Utility Functions - # ============================================================================ - - @doc """ - Return the SDK version. - """ - @spec version() :: String.t() - def version, do: @version - - @doc """ - Check API health. - - Returns true if API is healthy, false otherwise. - """ - @spec health_check() :: boolean() - def health_check do - try do - {output, 0} = System.cmd("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "#{@api_base}/health"]) - String.trim(output) == "200" - rescue - _ -> false - end - end - - @doc """ - Generate HMAC-SHA256 signature for a message. - """ - @spec hmac_sign(String.t(), String.t()) :: String.t() - def hmac_sign(secret_key, message) do - hmac_sha256(secret_key, message) - end - - @doc """ - Detect language from filename extension. - """ - @spec detect_language(String.t()) :: String.t() | nil - def detect_language(filename) do - ext = Path.extname(filename) |> String.downcase() - Map.get(@ext_map, ext) - end - - # ============================================================================ - # Execution Functions (8) - # ============================================================================ - - @doc """ - Execute code synchronously. - - ## Options - * `:network` - Network mode ("zerotrust" or "semitrusted") - * `:vcpu` - Number of vCPUs (1-8) - * `:ttl` - Time to live in seconds - * `:env` - Environment variables as keyword list - * `:input_files` - List of file paths to include - * `:return_artifacts` - Return compiled artifacts - * `:public_key` - API public key (optional) - * `:secret_key` - API secret key (optional) - - ## Examples - - result = Un.execute("python", "print('Hello World')") - IO.puts(result.stdout) - - """ - @spec execute(String.t(), String.t(), keyword()) :: result() - def execute(language, code, opts \\ []) do - json = build_execute_json_full(language, code, opts) - response = api_post("/execute", json, opts) - parse_result(response) - end - - @doc """ - Execute code asynchronously, returning a job ID. - """ - @spec execute_async(String.t(), String.t(), keyword()) :: String.t() | nil - def execute_async(language, code, opts \\ []) do - json = build_execute_json_full(language, code, opts) - response = api_post("/execute/async", json, opts) - extract_json_value(response, "job_id") - end - - @doc """ - Wait for a job to complete and return the result. - """ - @spec wait_job(String.t(), keyword()) :: result() - def wait_job(job_id, opts \\ []) do - poll_delays = [300, 450, 700, 900, 650, 1600, 2000] - max_polls = Keyword.get(opts, :max_polls, 100) - do_wait_job(job_id, poll_delays, 0, max_polls, opts) - end - - defp do_wait_job(job_id, poll_delays, poll_count, max_polls, opts) when poll_count >= max_polls do - %{success: false, stdout: "", stderr: "Max polls exceeded", exit_code: 1, job_id: job_id, language: nil, execution_time: nil} - end - - defp do_wait_job(job_id, poll_delays, poll_count, max_polls, opts) do - delay_idx = min(poll_count, length(poll_delays) - 1) - delay = Enum.at(poll_delays, delay_idx) - Process.sleep(delay) - - job = get_job(job_id, opts) - case job.status do - status when status in ["completed", "failed", "timeout", "cancelled"] -> - response = api_get("/jobs/#{job_id}", opts) - parse_result(response) - _ -> - do_wait_job(job_id, poll_delays, poll_count + 1, max_polls, opts) - end - end - - @doc """ - Get job status and details. - """ - @spec get_job(String.t(), keyword()) :: job() - def get_job(job_id, opts \\ []) do - response = api_get("/jobs/#{job_id}", opts) - %{ - id: job_id, - status: extract_json_value(response, "status") || "unknown", - language: extract_json_value(response, "language"), - created_at: extract_json_int(response, "created_at"), - completed_at: extract_json_int(response, "completed_at") - } - end - - @doc """ - Cancel a running job. - """ - @spec cancel_job(String.t(), keyword()) :: boolean() - def cancel_job(job_id, opts \\ []) do - response = api_delete("/jobs/#{job_id}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - List all active jobs. - """ - @spec list_jobs(keyword()) :: String.t() - def list_jobs(opts \\ []) do - api_get("/jobs", opts) - end - - @doc """ - Get list of supported languages. - """ - @spec get_languages(keyword()) :: [String.t()] - def get_languages(opts \\ []) do - case load_languages_cache() do - nil -> - response = api_get("/languages", opts) - langs = extract_json_array(response, "languages") - save_languages_cache(langs) - langs - cached -> - cached - end - end - - # ============================================================================ - # Session Functions (9) - # ============================================================================ - - @doc """ - List all sessions. - """ - @spec session_list(keyword()) :: String.t() - def session_list(opts \\ []), do: api_get("/sessions", opts) - - @doc """ - Get session details. - """ - @spec session_get(String.t(), keyword()) :: session() - def session_get(session_id, opts \\ []) do - response = api_get("/sessions/#{session_id}", opts) - %{ - id: session_id, - status: extract_json_value(response, "status") || "unknown", - container_name: extract_json_value(response, "container_name"), - network_mode: extract_json_value(response, "network_mode"), - vcpu: extract_json_int(response, "vcpu"), - created_at: extract_json_int(response, "created_at") - } - end - - @doc """ - Create a new session. - - ## Options - * `:shell` - Shell to use (default "bash") - * `:network` - Network mode - * `:vcpu` - Number of vCPUs - * `:input_files` - List of file paths - """ - @spec session_create(keyword()) :: session() - def session_create(opts \\ []) do - shell = Keyword.get(opts, :shell, "bash") - network = Keyword.get(opts, :network) - vcpu = Keyword.get(opts, :vcpu) - input_files = Keyword.get(opts, :input_files, []) - - network_json = if network, do: ",\"network\":\"#{network}\"", else: "" - vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" - input_files_json = build_input_files_json(input_files) - - json = "{\"shell\":\"#{shell}\"#{network_json}#{vcpu_json}#{input_files_json}}" - response = api_post("/sessions", json, opts) - - %{ - id: extract_json_value(response, "id") || "", - status: extract_json_value(response, "status") || "created", - container_name: extract_json_value(response, "container_name"), - network_mode: extract_json_value(response, "network_mode"), - vcpu: extract_json_int(response, "vcpu"), - created_at: extract_json_int(response, "created_at") - } - end - - @doc """ - Destroy a session. - """ - @spec session_destroy(String.t(), keyword()) :: boolean() - def session_destroy(session_id, opts \\ []) do - response = api_delete("/sessions/#{session_id}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Freeze a session. - """ - @spec session_freeze(String.t(), keyword()) :: boolean() - def session_freeze(session_id, opts \\ []) do - response = api_post("/sessions/#{session_id}/freeze", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Unfreeze a session. - """ - @spec session_unfreeze(String.t(), keyword()) :: boolean() - def session_unfreeze(session_id, opts \\ []) do - response = api_post("/sessions/#{session_id}/unfreeze", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Boost session resources (increase vCPU). - """ - @spec session_boost(String.t(), integer(), keyword()) :: boolean() - def session_boost(session_id, vcpu, opts \\ []) do - json = "{\"vcpu\":#{vcpu}}" - response = api_patch("/sessions/#{session_id}", json, opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Unboost session (reset to default resources). - """ - @spec session_unboost(String.t(), keyword()) :: boolean() - def session_unboost(session_id, opts \\ []) do - json = "{\"vcpu\":1}" - response = api_patch("/sessions/#{session_id}", json, opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Execute a command in a session. - """ - @spec session_execute(String.t(), String.t(), keyword()) :: result() - def session_execute(session_id, command, opts \\ []) do - json = "{\"command\":\"#{escape_json(command)}\"}" - response = api_post("/sessions/#{session_id}/execute", json, opts) - parse_result(response) - end - - # ============================================================================ - # Service Functions (17) - # ============================================================================ - - @doc """ - List all services. - """ - @spec service_list(keyword()) :: String.t() - def service_list(opts \\ []), do: api_get("/services", opts) - - @doc """ - Get service details. - """ - @spec service_get(String.t(), keyword()) :: service() - def service_get(service_id, opts \\ []) do - response = api_get("/services/#{service_id}", opts) - %{ - id: service_id, - name: extract_json_value(response, "name") || "", - status: extract_json_value(response, "status") || "unknown", - ports: extract_json_value(response, "ports"), - domains: extract_json_value(response, "domains"), - vcpu: extract_json_int(response, "vcpu"), - locked: extract_json_value(response, "locked") == "true", - unfreeze_on_demand: extract_json_value(response, "unfreeze_on_demand") == "true", - created_at: extract_json_int(response, "created_at") - } - end - - @doc """ - Create a new service. - - ## Options - * `:ports` - Ports to expose (e.g., "8080" or "80,443") - * `:domains` - Custom domains - * `:bootstrap` - Bootstrap script content - * `:network` - Network mode - * `:vcpu` - Number of vCPUs - * `:input_files` - List of file paths - """ - @spec service_create(String.t(), keyword()) :: String.t() | nil - def service_create(name, opts \\ []) do - ports = Keyword.get(opts, :ports) - domains = Keyword.get(opts, :domains) - bootstrap = Keyword.get(opts, :bootstrap) - network = Keyword.get(opts, :network) - vcpu = Keyword.get(opts, :vcpu) - input_files = Keyword.get(opts, :input_files, []) - - ports_json = if ports, do: ",\"ports\":[#{ports}]", else: "" - domains_json = if domains, do: ",\"domains\":\"#{escape_json(domains)}\"", else: "" - bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" - network_json = if network, do: ",\"network\":\"#{network}\"", else: "" - vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" - input_files_json = build_input_files_json(input_files) - - json = "{\"name\":\"#{escape_json(name)}\"#{ports_json}#{domains_json}#{bootstrap_json}#{network_json}#{vcpu_json}#{input_files_json}}" - response = api_post("/services", json, opts) - extract_json_value(response, "id") - end - - @doc """ - Destroy a service. - """ - @spec service_destroy(String.t(), keyword()) :: boolean() - def service_destroy(service_id, opts \\ []) do - response = api_delete("/services/#{service_id}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Freeze a service. - """ - @spec service_freeze(String.t(), keyword()) :: boolean() - def service_freeze(service_id, opts \\ []) do - response = api_post("/services/#{service_id}/freeze", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Unfreeze a service. - """ - @spec service_unfreeze(String.t(), keyword()) :: boolean() - def service_unfreeze(service_id, opts \\ []) do - response = api_post("/services/#{service_id}/unfreeze", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Lock a service to prevent deletion. - """ - @spec service_lock(String.t(), keyword()) :: boolean() - def service_lock(service_id, opts \\ []) do - response = api_post("/services/#{service_id}/lock", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Unlock a service. - """ - @spec service_unlock(String.t(), keyword()) :: boolean() - def service_unlock(service_id, opts \\ []) do - response = api_post("/services/#{service_id}/unlock", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Set unfreeze-on-demand for a service. - """ - @spec service_set_unfreeze_on_demand(String.t(), boolean(), keyword()) :: boolean() - def service_set_unfreeze_on_demand(service_id, enabled, opts \\ []) do - json = "{\"unfreeze_on_demand\":#{enabled}}" - response = api_patch("/services/#{service_id}", json, opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Redeploy a service with optional new bootstrap. - """ - @spec service_redeploy(String.t(), String.t() | nil, keyword()) :: boolean() - def service_redeploy(service_id, bootstrap \\ nil, opts \\ []) do - bootstrap_json = if bootstrap, do: "\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" - json = "{#{bootstrap_json}}" - response = api_post("/services/#{service_id}/redeploy", json, opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Get service bootstrap logs. - """ - @spec service_logs(String.t(), keyword()) :: String.t() - def service_logs(service_id, opts \\ []) do - all_logs = Keyword.get(opts, :all_logs, false) - endpoint = if all_logs, do: "/services/#{service_id}/logs?all=true", else: "/services/#{service_id}/logs" - api_get(endpoint, opts) - end - - @doc """ - Execute a command in a service. - """ - @spec service_execute(String.t(), String.t(), keyword()) :: result() - def service_execute(service_id, command, opts \\ []) do - timeout_ms = Keyword.get(opts, :timeout_ms) - timeout_json = if timeout_ms, do: ",\"timeout_ms\":#{timeout_ms}", else: "" - json = "{\"command\":\"#{escape_json(command)}\"#{timeout_json}}" - response = api_post("/services/#{service_id}/execute", json, opts) - parse_result(response) - end - - @doc """ - Get service environment vault. - """ - @spec service_env_get(String.t(), keyword()) :: String.t() - def service_env_get(service_id, opts \\ []) do - api_get("/services/#{service_id}/env", opts) - end - - @doc """ - Set service environment vault. - """ - @spec service_env_set(String.t(), String.t(), keyword()) :: boolean() - def service_env_set(service_id, env_content, opts \\ []) do - api_put_text("/services/#{service_id}/env", env_content, opts) - end - - @doc """ - Delete service environment vault. - """ - @spec service_env_delete(String.t(), keyword()) :: boolean() - def service_env_delete(service_id, opts \\ []) do - response = api_delete("/services/#{service_id}/env", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Export service environment vault. - """ - @spec service_env_export(String.t(), keyword()) :: String.t() - def service_env_export(service_id, opts \\ []) do - response = api_post("/services/#{service_id}/env/export", "{}", opts) - extract_json_value(response, "content") || "" - end - - @doc """ - Resize a service (change vCPU). - """ - @spec service_resize(String.t(), integer(), keyword()) :: boolean() - def service_resize(service_id, vcpu, opts \\ []) do - json = "{\"vcpu\":#{vcpu}}" - response = api_patch("/services/#{service_id}", json, opts) - not String.contains?(response, "\"error\"") - end - - # ============================================================================ - # Snapshot Functions (9) - # ============================================================================ - - @doc """ - List all snapshots. - """ - @spec snapshot_list(keyword()) :: String.t() - def snapshot_list(opts \\ []), do: api_get("/snapshots", opts) - - @doc """ - Get snapshot details. - """ - @spec snapshot_get(String.t(), keyword()) :: snapshot() - def snapshot_get(snapshot_id, opts \\ []) do - response = api_get("/snapshots/#{snapshot_id}", opts) - %{ - id: snapshot_id, - name: extract_json_value(response, "name"), - type: extract_json_value(response, "type") || "unknown", - source_id: extract_json_value(response, "source_id") || "", - hot: extract_json_value(response, "hot") == "true", - locked: extract_json_value(response, "locked") == "true", - created_at: extract_json_int(response, "created_at"), - size_bytes: extract_json_int(response, "size_bytes") - } - end - - @doc """ - Create a snapshot of a session. - """ - @spec snapshot_session(String.t(), keyword()) :: String.t() | nil - def snapshot_session(session_id, opts \\ []) do - name = Keyword.get(opts, :name) - hot = Keyword.get(opts, :hot, false) - name_json = if name, do: "\"name\":\"#{escape_json(name)}\",", else: "" - json = "{#{name_json}\"hot\":#{hot}}" - response = api_post("/sessions/#{session_id}/snapshot", json, opts) - extract_json_value(response, "id") - end - - @doc """ - Create a snapshot of a service. - """ - @spec snapshot_service(String.t(), keyword()) :: String.t() | nil - def snapshot_service(service_id, opts \\ []) do - name = Keyword.get(opts, :name) - hot = Keyword.get(opts, :hot, false) - name_json = if name, do: "\"name\":\"#{escape_json(name)}\",", else: "" - json = "{#{name_json}\"hot\":#{hot}}" - response = api_post("/services/#{service_id}/snapshot", json, opts) - extract_json_value(response, "id") - end - - @doc """ - Restore from a snapshot. - """ - @spec snapshot_restore(String.t(), keyword()) :: String.t() | nil - def snapshot_restore(snapshot_id, opts \\ []) do - response = api_post("/snapshots/#{snapshot_id}/restore", "{}", opts) - extract_json_value(response, "id") - end - - @doc """ - Delete a snapshot. - """ - @spec snapshot_delete(String.t(), keyword()) :: boolean() - def snapshot_delete(snapshot_id, opts \\ []) do - response = api_delete("/snapshots/#{snapshot_id}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Lock a snapshot to prevent deletion. - """ - @spec snapshot_lock(String.t(), keyword()) :: boolean() - def snapshot_lock(snapshot_id, opts \\ []) do - response = api_post("/snapshots/#{snapshot_id}/lock", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Unlock a snapshot. - """ - @spec snapshot_unlock(String.t(), keyword()) :: boolean() - def snapshot_unlock(snapshot_id, opts \\ []) do - response = api_post("/snapshots/#{snapshot_id}/unlock", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Clone a snapshot to create a new session or service. - - ## Options - * `:type` - "session" or "service" (required) - * `:name` - Name for cloned service - * `:ports` - Ports for cloned service - * `:shell` - Shell for cloned session - """ - @spec snapshot_clone(String.t(), keyword()) :: String.t() | nil - def snapshot_clone(snapshot_id, opts \\ []) do - clone_type = Keyword.get(opts, :type) - name = Keyword.get(opts, :name) - ports = Keyword.get(opts, :ports) - shell = Keyword.get(opts, :shell) - - type_json = "\"type\":\"#{clone_type}\"" - name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: "" - ports_json = if ports, do: ",\"ports\":[#{ports}]", else: "" - shell_json = if shell, do: ",\"shell\":\"#{shell}\"", else: "" - json = "{#{type_json}#{name_json}#{ports_json}#{shell_json}}" - response = api_post("/snapshots/#{snapshot_id}/clone", json, opts) - extract_json_value(response, "id") - end - - # ============================================================================ - # Image Functions (13) - # ============================================================================ - - @doc """ - List images. - - ## Options - * `:filter` - "owned", "shared", "public", or nil for all - """ - @spec image_list(keyword()) :: String.t() - def image_list(opts \\ []) do - filter = Keyword.get(opts, :filter) - endpoint = if filter, do: "/images?filter=#{filter}", else: "/images" - api_get(endpoint, opts) - end - - @doc """ - Get image details. - """ - @spec image_get(String.t(), keyword()) :: image() - def image_get(image_id, opts \\ []) do - response = api_get("/images/#{image_id}", opts) - %{ - id: image_id, - name: extract_json_value(response, "name"), - description: extract_json_value(response, "description"), - visibility: extract_json_value(response, "visibility") || "private", - source_type: extract_json_value(response, "source_type") || "", - source_id: extract_json_value(response, "source_id") || "", - locked: extract_json_value(response, "locked") == "true", - created_at: extract_json_int(response, "created_at"), - size_bytes: extract_json_int(response, "size_bytes") - } - end - - @doc """ - Publish an image from a service or snapshot. - - ## Options - * `:name` - Image name - * `:description` - Image description - """ - @spec image_publish(String.t(), String.t(), keyword()) :: String.t() | nil - def image_publish(source_type, source_id, opts \\ []) do - name = Keyword.get(opts, :name) - description = Keyword.get(opts, :description) - name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: "" - desc_json = if description, do: ",\"description\":\"#{escape_json(description)}\"", else: "" - json = "{\"source_type\":\"#{source_type}\",\"source_id\":\"#{source_id}\"#{name_json}#{desc_json}}" - response = api_post("/images/publish", json, opts) - extract_json_value(response, "id") - end - - @doc """ - Delete an image. - """ - @spec image_delete(String.t(), keyword()) :: boolean() - def image_delete(image_id, opts \\ []) do - response = api_delete("/images/#{image_id}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Lock an image to prevent deletion. - """ - @spec image_lock(String.t(), keyword()) :: boolean() - def image_lock(image_id, opts \\ []) do - response = api_post("/images/#{image_id}/lock", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Unlock an image. - """ - @spec image_unlock(String.t(), keyword()) :: boolean() - def image_unlock(image_id, opts \\ []) do - response = api_post("/images/#{image_id}/unlock", "{}", opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Set image visibility. - """ - @spec image_set_visibility(String.t(), String.t(), keyword()) :: boolean() - def image_set_visibility(image_id, visibility, opts \\ []) do - json = "{\"visibility\":\"#{visibility}\"}" - response = api_post("/images/#{image_id}/visibility", json, opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Grant access to an image for another API key. - """ - @spec image_grant_access(String.t(), String.t(), keyword()) :: boolean() - def image_grant_access(image_id, trusted_api_key, opts \\ []) do - json = "{\"api_key\":\"#{trusted_api_key}\"}" - response = api_post("/images/#{image_id}/access/grant", json, opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Revoke access to an image from another API key. - """ - @spec image_revoke_access(String.t(), String.t(), keyword()) :: boolean() - def image_revoke_access(image_id, trusted_api_key, opts \\ []) do - json = "{\"api_key\":\"#{trusted_api_key}\"}" - response = api_post("/images/#{image_id}/access/revoke", json, opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - List trusted API keys for an image. - """ - @spec image_list_trusted(String.t(), keyword()) :: [String.t()] - def image_list_trusted(image_id, opts \\ []) do - response = api_get("/images/#{image_id}/access", opts) - extract_json_array(response, "trusted_keys") - end - - @doc """ - Transfer image ownership to another API key. - """ - @spec image_transfer(String.t(), String.t(), keyword()) :: boolean() - def image_transfer(image_id, to_api_key, opts \\ []) do - json = "{\"to_api_key\":\"#{to_api_key}\"}" - response = api_post("/images/#{image_id}/transfer", json, opts) - not String.contains?(response, "\"error\"") - end - - @doc """ - Spawn a new service from an image. - - ## Options - * `:name` - Service name - * `:ports` - Ports to expose - * `:bootstrap` - Bootstrap command - * `:network` - Network mode - """ - @spec image_spawn(String.t(), keyword()) :: String.t() | nil - def image_spawn(image_id, opts \\ []) do - name = Keyword.get(opts, :name) - ports = Keyword.get(opts, :ports) - bootstrap = Keyword.get(opts, :bootstrap) - network = Keyword.get(opts, :network) - - name_json = if name, do: "\"name\":\"#{escape_json(name)}\"", else: "" - ports_json = if ports, do: "#{if name, do: ",", else: ""}\"ports\":[#{ports}]", else: "" - bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" - network_json = if network, do: ",\"network\":\"#{network}\"", else: "" - json = "{#{name_json}#{ports_json}#{bootstrap_json}#{network_json}}" - response = api_post("/images/#{image_id}/spawn", json, opts) - extract_json_value(response, "id") - end - - @doc """ - Clone an image. - - ## Options - * `:name` - Name for cloned image - * `:description` - Description for cloned image - """ - @spec image_clone(String.t(), keyword()) :: String.t() | nil - def image_clone(image_id, opts \\ []) do - name = Keyword.get(opts, :name) - description = Keyword.get(opts, :description) - name_json = if name, do: "\"name\":\"#{escape_json(name)}\"", else: "" - desc_json = if description, do: "#{if name, do: ",", else: ""}\"description\":\"#{escape_json(description)}\"", else: "" - json = "{#{name_json}#{desc_json}}" - response = api_post("/images/#{image_id}/clone", json, opts) - extract_json_value(response, "id") - end - - # ============================================================================ - # PaaS Logs Functions (2) - # ============================================================================ - - @doc """ - Fetch batch logs from portal. - - ## Options - * `:source` - "all", "api", "portal", "pool/cammy", "pool/ai" - * `:lines` - Number of lines (1-10000) - * `:since` - Time window ("1m", "5m", "1h", "1d") - * `:grep` - Filter pattern - """ - @spec logs_fetch(keyword()) :: String.t() - def logs_fetch(opts \\ []) do - source = Keyword.get(opts, :source, "all") - lines = Keyword.get(opts, :lines, 100) - since = Keyword.get(opts, :since, "1h") - grep = Keyword.get(opts, :grep) - - grep_param = if grep, do: "&grep=#{URI.encode(grep)}", else: "" - api_get("/logs?source=#{source}&lines=#{lines}&since=#{since}#{grep_param}", opts) - end - - @doc """ - Stream logs via SSE. This is a blocking operation that calls the callback for each log line. - Note: Full SSE streaming requires WebSocket support; this implementation provides basic fetch. - """ - @spec logs_stream(keyword(), (String.t(), String.t() -> any())) :: :ok - def logs_stream(opts \\ [], callback) do - # For Elixir without external deps, we can't do true SSE streaming - # Instead, we poll with a short interval - source = Keyword.get(opts, :source, "all") - grep = Keyword.get(opts, :grep) - interval = Keyword.get(opts, :interval, 5000) - - grep_param = if grep, do: "&grep=#{URI.encode(grep)}", else: "" - - Stream.repeatedly(fn -> - response = api_get("/logs?source=#{source}&lines=50&since=10s#{grep_param}", opts) - callback.(source, response) - Process.sleep(interval) - end) - |> Stream.run() - - :ok - end - - # ============================================================================ - # Key Validation (1) - # ============================================================================ - - @doc """ - Validate API keys and get account information. - """ - @spec validate_keys(keyword()) :: key_info() - def validate_keys(opts \\ []) do - response = portal_post("/keys/validate", "{}", opts) - %{ - valid: extract_json_value(response, "status") == "valid", - tier: extract_json_value(response, "tier"), - rate_limit_per_minute: extract_json_int(response, "rate_per_minute"), - concurrency_limit: extract_json_int(response, "concurrency"), - expires_at: extract_json_int(response, "expires_at") - } - end - - # ============================================================================ - # Private API Functions - # ============================================================================ - - defp api_get(endpoint, opts) do - {public_key, secret_key} = get_api_keys_from_opts(opts) - headers = build_auth_headers(public_key, secret_key, "GET", endpoint, "") - args = ["-s", "#{@api_base}#{endpoint}"] ++ headers - {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) - check_clock_drift(output) - output - end - - defp api_post(endpoint, json, opts) do - tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" - File.write!(tmp_file, json) - - {public_key, secret_key} = get_api_keys_from_opts(opts) - headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) - - args = ["-s", "-X", "POST", "#{@api_base}#{endpoint}", "-H", "Content-Type: application/json"] ++ headers ++ ["-d", "@#{tmp_file}"] - {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) - - File.rm(tmp_file) - check_clock_drift(output) - output - end - - defp api_delete(endpoint, opts) do - {public_key, secret_key} = get_api_keys_from_opts(opts) - headers = build_auth_headers(public_key, secret_key, "DELETE", endpoint, "") - args = ["-s", "-X", "DELETE", "#{@api_base}#{endpoint}"] ++ headers - {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) - check_clock_drift(output) - output - end - - defp api_patch(endpoint, json, opts) do - tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" - File.write!(tmp_file, json) - - {public_key, secret_key} = get_api_keys_from_opts(opts) - headers = build_auth_headers(public_key, secret_key, "PATCH", endpoint, json) - - args = ["-s", "-X", "PATCH", "#{@api_base}#{endpoint}", "-H", "Content-Type: application/json"] ++ headers ++ ["-d", "@#{tmp_file}"] - {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) - - File.rm(tmp_file) - check_clock_drift(output) - output - end - - defp api_put_text(endpoint, body, opts) do - tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.txt" - File.write!(tmp_file, body) - - {public_key, secret_key} = get_api_keys_from_opts(opts) - headers = build_auth_headers(public_key, secret_key, "PUT", endpoint, body) - - args = ["-s", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT", "#{@api_base}#{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 - - defp portal_post(endpoint, json, opts) do - tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" - File.write!(tmp_file, json) - - {public_key, secret_key} = get_api_keys_from_opts(opts) - headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) - - args = ["-s", "-X", "POST", "#{@portal_base}#{endpoint}", "-H", "Content-Type: application/json"] ++ headers ++ ["-d", "@#{tmp_file}"] - {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) - - File.rm(tmp_file) - check_clock_drift(output) - output - end - - defp get_api_keys_from_opts(opts) do - public_key = Keyword.get(opts, :public_key) - secret_key = Keyword.get(opts, :secret_key) - - if public_key && secret_key do - {public_key, secret_key} - else - get_api_keys() - end - end - - defp build_execute_json_full(language, code, opts) do - network = Keyword.get(opts, :network) - vcpu = Keyword.get(opts, :vcpu) - ttl = Keyword.get(opts, :ttl) - env = Keyword.get(opts, :env, []) - input_files = Keyword.get(opts, :input_files, []) - return_artifacts = Keyword.get(opts, :return_artifacts, false) - - network_json = if network, do: ",\"network\":\"#{network}\"", else: "" - vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" - ttl_json = if ttl, do: ",\"ttl\":#{ttl}", else: "" - env_json = if env != [], do: ",\"env\":{" <> Enum.map_join(env, ",", fn {k, v} -> "\"#{k}\":\"#{escape_json(v)}\"" end) <> "}", else: "" - input_files_json = build_input_files_json(input_files) - artifacts_json = if return_artifacts, do: ",\"return_artifacts\":true", else: "" - - "{\"language\":\"#{language}\",\"code\":\"#{escape_json(code)}\"#{network_json}#{vcpu_json}#{ttl_json}#{env_json}#{input_files_json}#{artifacts_json}}" - end - - defp parse_result(response) do - %{ - success: extract_json_int(response, "exit_code") == 0, - stdout: extract_json_value(response, "stdout") || "", - stderr: extract_json_value(response, "stderr") || "", - exit_code: extract_json_int(response, "exit_code") || 0, - job_id: extract_json_value(response, "job_id"), - language: extract_json_value(response, "language"), - execution_time: nil - } - end - - defp extract_json_int(json_str, key) do - case Regex.run(~r/"#{key}"\s*:\s*(-?\d+)/, json_str) do - [_, value] -> String.to_integer(value) - _ -> nil - end - end - - # ============================================================================ - # CLI Entry Point - # ============================================================================ - - def main(raw_args) do - {account_index, args} = extract_account_arg(raw_args, nil, []) - if account_index != nil do - Process.put(:account_index, account_index) - end - dispatch(args) - end - - defp dispatch([]), do: print_usage() - defp dispatch(["session" | rest]), do: session_command(rest) - defp dispatch(["service" | rest]), do: service_command(rest) - defp dispatch(["snapshot" | rest]), do: snapshot_command(rest) - defp dispatch(["image" | rest]), do: image_command(rest) - defp dispatch(["key" | rest]), do: key_command(rest) - defp dispatch(["languages" | rest]), do: languages_command(rest) - defp dispatch(args), do: execute_command(args) - - defp extract_account_arg([], acc, rest_acc), do: {acc, Enum.reverse(rest_acc)} - defp extract_account_arg(["--account", n_str | rest], _acc, rest_acc) do - n = case Integer.parse(n_str) do - {n, ""} -> n - _ -> - IO.puts(:stderr, "Error: --account requires an integer argument") - System.halt(1) - end - extract_account_arg(rest, n, rest_acc) - end - defp extract_account_arg([arg | rest], acc, rest_acc) do - extract_account_arg(rest, acc, [arg | rest_acc]) - end + def main([]), do: print_usage() + def main(["session" | rest]), do: session_command(rest) + def main(["service" | rest]), do: service_command(rest) + def main(["snapshot" | rest]), do: snapshot_command(rest) + def main(["image" | rest]), do: image_command(rest) + def main(["key" | rest]), do: key_command(rest) + def main(["languages" | rest]), do: languages_command(rest) + def main(args), do: execute_command(args) defp print_usage do - IO.puts("Usage: un.ex [--account N] [options] ") - IO.puts(" un.ex [--account N] session [options]") - IO.puts(" un.ex [--account N] service [options]") - IO.puts(" un.ex [--account N] service env ") - IO.puts(" un.ex [--account N] snapshot [options]") - IO.puts(" un.ex [--account N] image [options]") - IO.puts(" un.ex [--account N] key [--extend]") + IO.puts("Usage: un.ex [options] ") + IO.puts(" un.ex session [options]") + IO.puts(" un.ex service [options]") + IO.puts(" un.ex service env ") + IO.puts(" un.ex snapshot [options]") + IO.puts(" un.ex image [options]") + IO.puts(" un.ex key [--extend]") IO.puts(" un.ex languages [--json]") IO.puts("") - IO.puts("Global options:") - IO.puts(" --account N Use accounts.csv row N (bypasses env vars)") - IO.puts("") IO.puts("Service options: --name, --ports, --bootstrap, -e KEY=VALUE, --env-file FILE") IO.puts(" --set-unfreeze-on-demand ID true|false") IO.puts("Service env commands: status, set, export, delete") @@ -1303,17 +240,8 @@ defmodule Un do defp service_command(["--destroy", service_id | _]) do api_key = get_api_key() - case curl_delete_with_sudo(api_key, "/services/#{service_id}") do - {:ok, _, _} -> - IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}") - {:ok, _} -> - IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}") - {:error, :cancelled} -> - System.halt(1) - {:error, msg} -> - IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}") - System.halt(1) - end + curl_delete(api_key, "/services/#{service_id}") + IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}") end defp service_command(["--resize", service_id | rest]) do @@ -1533,17 +461,8 @@ defmodule Un do defp snapshot_command(["--delete", snapshot_id | _]) do api_key = get_api_key() - case curl_delete_with_sudo(api_key, "/snapshots/#{snapshot_id}") do - {:ok, _, _} -> - IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}") - {:ok, _} -> - IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}") - {:error, :cancelled} -> - System.halt(1) - {:error, msg} -> - IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}") - System.halt(1) - end + curl_delete(api_key, "/snapshots/#{snapshot_id}") + IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}") end defp snapshot_command(["--clone", snapshot_id | rest]) do @@ -1593,17 +512,8 @@ defmodule Un do defp image_command(["--delete", image_id | _]) do api_key = get_api_key() - case curl_delete_with_sudo(api_key, "/images/#{image_id}") do - {:ok, _, _} -> - IO.puts("#{@green}Image deleted: #{image_id}#{@reset}") - {:ok, _} -> - IO.puts("#{@green}Image deleted: #{image_id}#{@reset}") - {:error, :cancelled} -> - System.halt(1) - {:error, msg} -> - IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}") - System.halt(1) - end + curl_delete(api_key, "/images/#{image_id}") + IO.puts("#{@green}Image deleted: #{image_id}#{@reset}") end defp image_command(["--lock", image_id | _]) do @@ -1614,17 +524,8 @@ defmodule Un do defp image_command(["--unlock", image_id | _]) do api_key = get_api_key() - case curl_post_with_sudo(api_key, "/images/#{image_id}/unlock", "{}") do - {:ok, _, _} -> - IO.puts("#{@green}Image unlocked: #{image_id}#{@reset}") - {:ok, _} -> - IO.puts("#{@green}Image unlocked: #{image_id}#{@reset}") - {:error, :cancelled} -> - System.halt(1) - {:error, msg} -> - IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}") - System.halt(1) - end + curl_post(api_key, "/images/#{image_id}/unlock", "{}") + IO.puts("#{@green}Image unlocked: #{image_id}#{@reset}") end defp image_command(["--publish", source_id | rest]) do @@ -1940,85 +841,21 @@ defmodule Un do end # Helpers - - defp load_credentials_from_csv(csv_path, account_index) do - case File.read(csv_path) do - {:ok, content} -> - accounts = - content - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.filter(fn line -> line != "" and not String.starts_with?(line, "#") end) - |> Enum.flat_map(fn line -> - case String.split(line, ",") do - [pk, sk | _] -> - pk = String.trim(pk) - sk = String.trim(sk) - if String.length(pk) > 8 and String.length(sk) > 8 do - [{pk, sk}] - else - [] - end - _ -> [] - end - end) - case Enum.at(accounts, account_index) do - nil -> :error - creds -> {:ok, creds} - end - _ -> :error - end - end - defp get_api_keys do - home = System.get_env("HOME") || "." - home_csv = Path.join([home, ".unsandbox", "accounts.csv"]) + public_key = System.get_env("UNSANDBOX_PUBLIC_KEY") + secret_key = System.get_env("UNSANDBOX_SECRET_KEY") - # Priority 1: --account N (stored in process dict by main/1) - case Process.get(:account_index) do - nil -> - # Priority 2: environment variables - public_key = System.get_env("UNSANDBOX_PUBLIC_KEY") - secret_key = System.get_env("UNSANDBOX_SECRET_KEY") - api_key = System.get_env("UNSANDBOX_API_KEY") + # Fall back to UNSANDBOX_API_KEY for backwards compatibility + api_key = System.get_env("UNSANDBOX_API_KEY") - cond do - public_key && secret_key -> - {public_key, secret_key} - api_key -> - {api_key, nil} - true -> - # Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index) - default_index = - case System.get_env("UNSANDBOX_ACCOUNT") do - nil -> 0 - s -> case Integer.parse(s) do {n, ""} -> n; _ -> 0 end - end - case load_credentials_from_csv(home_csv, default_index) do - {:ok, {pk, sk}} -> {pk, sk} - :error -> - # Priority 4: ./accounts.csv - case load_credentials_from_csv("accounts.csv", default_index) do - {:ok, {pk, sk}} -> {pk, sk} - :error -> - IO.puts(:stderr, "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)") - System.halt(1) - end - end - end - - account_index -> - # Priority 1: --account N -> accounts.csv - case load_credentials_from_csv(home_csv, account_index) do - {:ok, {pk, sk}} -> {pk, sk} - :error -> - case load_credentials_from_csv("accounts.csv", account_index) do - {:ok, {pk, sk}} -> {pk, sk} - :error -> - IO.puts(:stderr, "Error: No credentials found for account index #{account_index} in accounts.csv") - System.halt(1) - end - end + cond do + public_key && secret_key -> + {public_key, secret_key} + api_key -> + {api_key, nil} + true -> + IO.puts(:stderr, "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)") + System.halt(1) end end @@ -2304,116 +1141,6 @@ defmodule Un do System.halt(1) end end - - # Handle 428 sudo OTP challenge - prompts user for OTP and retries the request - defp handle_sudo_challenge(response, method, endpoint, body) do - challenge_id = extract_json_value(response, "challenge_id") - - IO.puts(:stderr, "#{@yellow}Confirmation required. Check your email for a one-time code.#{@reset}") - IO.write(:stderr, "Enter OTP: ") - - otp = IO.gets("") |> String.trim() - - if otp == "" do - IO.puts(:stderr, "#{@red}Error: Operation cancelled#{@reset}") - {:error, :cancelled} - else - # Retry the request with sudo headers - {public_key, secret_key} = get_api_keys() - body_str = body || "" - headers = build_auth_headers(public_key, secret_key, method, endpoint, body_str) - - # Add sudo headers - sudo_headers = ["-H", "X-Sudo-OTP: #{otp}"] - sudo_headers = if challenge_id do - sudo_headers ++ ["-H", "X-Sudo-Challenge: #{challenge_id}"] - else - sudo_headers - end - - args = case method do - "DELETE" -> - ["-s", "-X", "DELETE", "https://api.unsandbox.com#{endpoint}"] ++ headers ++ sudo_headers - "POST" -> - tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" - File.write!(tmp_file, body_str) - result = ["-s", "-X", "POST", "https://api.unsandbox.com#{endpoint}", - "-H", "Content-Type: application/json"] ++ headers ++ sudo_headers ++ ["-d", "@#{tmp_file}"] - result - _ -> - ["-s", "https://api.unsandbox.com#{endpoint}"] ++ headers ++ sudo_headers - end - - {output, exit_code} = System.cmd("curl", args, stderr_to_stdout: true) - - # Clean up temp file for POST requests - if method == "POST" do - tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" - File.rm(tmp_file) - end - - if exit_code == 0 and not String.contains?(output, "\"error\"") do - {:ok, output} - else - {:error, output} - end - end - end - - # Curl with 428 handling for destructive operations - defp curl_delete_with_sudo(api_key, endpoint) do - {public_key, secret_key} = get_api_keys() - headers = build_auth_headers(public_key, secret_key, "DELETE", endpoint, "") - - args = ["-s", "-X", "DELETE", "-w", "\n%{http_code}", - "https://api.unsandbox.com#{endpoint}"] ++ headers - - {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) - - # Split response body and status code - lines = String.split(output, "\n") - {body_lines, [status_code]} = Enum.split(lines, -1) - body = Enum.join(body_lines, "\n") - http_code = String.to_integer(String.trim(status_code)) - - check_clock_drift(body) - - if http_code == 428 do - handle_sudo_challenge(body, "DELETE", endpoint, nil) - else - {:ok, body, http_code} - end - end - - defp curl_post_with_sudo(api_key, endpoint, json) do - tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" - File.write!(tmp_file, json) - - {public_key, secret_key} = get_api_keys() - headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) - - args = ["-s", "-X", "POST", "-w", "\n%{http_code}", - "https://api.unsandbox.com#{endpoint}", - "-H", "Content-Type: application/json"] ++ headers ++ ["-d", "@#{tmp_file}"] - - {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) - - File.rm(tmp_file) - - # Split response body and status code - lines = String.split(output, "\n") - {body_lines, [status_code]} = Enum.split(lines, -1) - body = Enum.join(body_lines, "\n") - http_code = String.to_integer(String.trim(status_code)) - - check_clock_drift(body) - - if http_code == 428 do - handle_sudo_challenge(body, "POST", endpoint, json) - else - {:ok, body, http_code} - end - end end Un.main(System.argv()) diff --git a/clients/elixir/sync/tests/test_functional.exs b/clients/elixir/sync/tests/test_functional.exs deleted file mode 100644 index e3049d5..0000000 --- a/clients/elixir/sync/tests/test_functional.exs +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env elixir -# This is free software for the public good of a permacomputer hosted at -# permacomputer.com, an always-on computer by the people, for the people. -# One which is durable, easy to repair, & distributed like tap water -# for machine learning intelligence. -# -# The permacomputer is community-owned infrastructure optimized around -# four values: -# -# TRUTH First principles, math & science, open source code freely distributed -# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY Minimal waste, self-renewing systems with diverse thriving connections -# LOVE Be yourself without hurting others, cooperation through natural law -# -# This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -# Code is seeds to sprout on any abandoned technology. - - -# Functional Tests for Un Elixir SDK -# -# Run with: elixir tests/test_functional.exs -# Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables -# -# These tests make real API calls to api.unsandbox.com - -Code.require_file("../src/un.ex", __DIR__) - -defmodule UnFunctionalTest do - @moduledoc """ - Functional test suite for Un Elixir SDK. - Tests real API calls to api.unsandbox.com. - """ - - @blue "\e[34m" - @red "\e[31m" - @green "\e[32m" - @yellow "\e[33m" - @reset "\e[0m" - - def run_all do - IO.puts("\n#{@blue}=== Un Elixir SDK Functional Tests ===#@reset}\n") - - # Check for credentials - unless System.get_env("UNSANDBOX_PUBLIC_KEY") && System.get_env("UNSANDBOX_SECRET_KEY") do - IO.puts("#{@yellow}SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{@reset}") - System.halt(0) - end - - tests = [ - {"health_check", &test_health_check/0}, - {"validate_keys", &test_validate_keys/0}, - {"execute_python", &test_execute_python/0}, - {"execute_with_error", &test_execute_with_error/0}, - {"session_list", &test_session_list/0}, - {"service_list", &test_service_list/0}, - {"snapshot_list", &test_snapshot_list/0}, - {"image_list", &test_image_list/0} - ] - - results = Enum.map(tests, fn {name, test_fn} -> - IO.write(" Running #{name}... ") - try do - test_fn.() - IO.puts("#{@green}PASS#{@reset}") - :pass - rescue - e -> - IO.puts("#{@red}FAIL#{@reset}") - IO.puts(" #{inspect(e)}") - :fail - end - end) - - passed = Enum.count(results, &(&1 == :pass)) - failed = Enum.count(results, &(&1 == :fail)) - total = length(results) - - IO.puts("\n#{@blue}Results: #{passed}/#{total} passed#{@reset}") - if failed > 0 do - IO.puts("#{@red}#{failed} test(s) failed#{@reset}") - System.halt(1) - else - IO.puts("#{@green}All functional tests passed!#{@reset}") - end - end - - # ============================================================================ - # Functional Tests - # ============================================================================ - - def test_health_check do - result = Un.health_check() - assert is_boolean(result), "health_check should return boolean" - # Note: We don't require it to be true in case API is down - end - - def test_validate_keys do - key_info = Un.validate_keys() - assert is_map(key_info), "validate_keys should return a map" - assert Map.has_key?(key_info, :valid), "key_info should have :valid key" - assert is_boolean(key_info.valid), ":valid should be boolean" - end - - def test_execute_python do - result = Un.execute("python", "print(6 * 7)") - assert is_map(result), "execute should return a map" - assert Map.has_key?(result, :success), "result should have :success key" - assert Map.has_key?(result, :stdout), "result should have :stdout key" - assert Map.has_key?(result, :exit_code), "result should have :exit_code key" - - # Check output - assert result.success == true, "execution should succeed" - assert String.contains?(result.stdout, "42"), "stdout should contain '42'" - assert result.exit_code == 0, "exit_code should be 0" - end - - def test_execute_with_error do - result = Un.execute("python", "import sys; sys.exit(1)") - assert is_map(result), "execute should return a map" - assert result.success == false, "execution should fail" - assert result.exit_code == 1, "exit_code should be 1" - end - - def test_session_list do - response = Un.session_list() - assert is_binary(response), "session_list should return a string" - # Response should be valid JSON (starts with [ or {) - trimmed = String.trim(response) - assert String.starts_with?(trimmed, "[") || String.starts_with?(trimmed, "{"), - "response should be JSON" - end - - def test_service_list do - response = Un.service_list() - assert is_binary(response), "service_list should return a string" - trimmed = String.trim(response) - assert String.starts_with?(trimmed, "[") || String.starts_with?(trimmed, "{"), - "response should be JSON" - end - - def test_snapshot_list do - response = Un.snapshot_list() - assert is_binary(response), "snapshot_list should return a string" - trimmed = String.trim(response) - assert String.starts_with?(trimmed, "[") || String.starts_with?(trimmed, "{"), - "response should be JSON" - end - - def test_image_list do - response = Un.image_list() - assert is_binary(response), "image_list should return a string" - trimmed = String.trim(response) - assert String.starts_with?(trimmed, "[") || String.starts_with?(trimmed, "{"), - "response should be JSON" - end - - # ============================================================================ - # Helpers - # ============================================================================ - - defp assert(true, _message), do: :ok - defp assert(false, message), do: raise message - defp assert(condition, message) when is_boolean(condition) do - if condition, do: :ok, else: raise message - end -end - -# Run tests -UnFunctionalTest.run_all() diff --git a/clients/elixir/sync/tests/test_library.exs b/clients/elixir/sync/tests/test_library.exs deleted file mode 100644 index 30dbe85..0000000 --- a/clients/elixir/sync/tests/test_library.exs +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env elixir -# This is free software for the public good of a permacomputer hosted at -# permacomputer.com, an always-on computer by the people, for the people. -# One which is durable, easy to repair, & distributed like tap water -# for machine learning intelligence. -# -# The permacomputer is community-owned infrastructure optimized around -# four values: -# -# TRUTH First principles, math & science, open source code freely distributed -# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY Minimal waste, self-renewing systems with diverse thriving connections -# LOVE Be yourself without hurting others, cooperation through natural law -# -# This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -# Code is seeds to sprout on any abandoned technology. - - -# Tests for Un Elixir SDK -# -# Run with: elixir tests/test_library.exs -# Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables - -Code.require_file("../src/un.ex", __DIR__) - -defmodule UnTest do - @moduledoc """ - Test suite for Un Elixir SDK library functions. - """ - - @blue "\e[34m" - @red "\e[31m" - @green "\e[32m" - @yellow "\e[33m" - @reset "\e[0m" - - def run_all do - IO.puts("\n#{@blue}=== Un Elixir SDK Tests ===#@reset}\n") - - tests = [ - {"version", &test_version/0}, - {"detect_language", &test_detect_language/0}, - {"hmac_sign", &test_hmac_sign/0}, - {"hmac_sign_deterministic", &test_hmac_sign_deterministic/0}, - {"hmac_sign_different_secrets", &test_hmac_sign_different_secrets/0}, - {"get_languages", &test_get_languages/0} - ] - - results = Enum.map(tests, fn {name, test_fn} -> - try do - test_fn.() - IO.puts("#{@green}PASS#{@reset}: #{name}") - :pass - rescue - e -> - IO.puts("#{@red}FAIL#{@reset}: #{name} - #{inspect(e)}") - :fail - end - end) - - passed = Enum.count(results, &(&1 == :pass)) - failed = Enum.count(results, &(&1 == :fail)) - total = length(results) - - IO.puts("\n#{@blue}Results: #{passed}/#{total} passed#{@reset}") - if failed > 0 do - IO.puts("#{@red}#{failed} test(s) failed#{@reset}") - System.halt(1) - else - IO.puts("#{@green}All tests passed!#{@reset}") - end - end - - # ============================================================================ - # Unit Tests - # ============================================================================ - - def test_version do - version = Un.version() - assert is_binary(version), "version should be a string" - assert String.match?(version, ~r/^\d+\.\d+\.\d+$/), "version should be semver format" - end - - def test_detect_language do - # Test common extensions - assert Un.detect_language("script.py") == "python" - assert Un.detect_language("app.js") == "javascript" - assert Un.detect_language("main.go") == "go" - assert Un.detect_language("main.rs") == "rust" - assert Un.detect_language("main.c") == "c" - assert Un.detect_language("main.cpp") == "cpp" - assert Un.detect_language("Main.java") == "java" - assert Un.detect_language("script.rb") == "ruby" - assert Un.detect_language("script.sh") == "bash" - assert Un.detect_language("script.lua") == "lua" - assert Un.detect_language("script.pl") == "perl" - assert Un.detect_language("index.php") == "php" - assert Un.detect_language("main.hs") == "haskell" - assert Un.detect_language("main.ml") == "ocaml" - assert Un.detect_language("main.ex") == "elixir" - assert Un.detect_language("main.erl") == "erlang" - - # Test with paths - assert Un.detect_language("/path/to/script.py") == "python" - - # Test unknown extensions - assert Un.detect_language("Makefile") == nil - assert Un.detect_language("README") == nil - assert Un.detect_language("script.unknown") == nil - end - - def test_hmac_sign do - signature = Un.hmac_sign("my_secret", "test message") - assert is_binary(signature), "signature should be a string" - assert String.length(signature) == 64, "signature should be 64 hex characters" - assert String.match?(signature, ~r/^[0-9a-f]+$/), "signature should be lowercase hex" - end - - def test_hmac_sign_deterministic do - sig1 = Un.hmac_sign("test_secret", "same message") - sig2 = Un.hmac_sign("test_secret", "same message") - assert sig1 == sig2, "same inputs should produce same signature" - end - - def test_hmac_sign_different_secrets do - sig1 = Un.hmac_sign("secret1", "test message") - sig2 = Un.hmac_sign("secret2", "test message") - assert sig1 != sig2, "different secrets should produce different signatures" - end - - def test_get_languages do - languages = Un.get_languages() - assert is_list(languages), "languages should be a list" - assert length(languages) > 0, "languages list should not be empty" - assert "python" in languages, "python should be in languages" - assert "javascript" in languages, "javascript should be in languages" - end - - # ============================================================================ - # Helpers - # ============================================================================ - - defp assert(true, _message), do: :ok - defp assert(false, message), do: raise message - defp assert(condition, message) when is_boolean(condition) do - if condition, do: :ok, else: raise message - end -end - -# Run tests -UnTest.run_all() diff --git a/clients/erlang/sync/src/un.erl b/clients/erlang/sync/src/un.erl index 3c34b65..9bf14cb 100755 --- a/clients/erlang/sync/src/un.erl +++ b/clients/erlang/sync/src/un.erl @@ -37,744 +37,42 @@ #!/usr/bin/env escript -%%% @doc unsandbox.com Erlang SDK +%%% Erlang UN CLI - Unsandbox CLI Client %%% -%%% Full API with execution, sessions, services, snapshots, and images. -%%% -%%% Library Usage: -%%% ``` -%%% %% Execute code synchronously -%%% Result = un:execute("python", "print(42)"), -%%% io:format("~s~n", [maps:get(stdout, Result)]). -%%% -%%% %% List sessions -%%% Sessions = un:session_list(). -%%% -%%% %% Create a service -%%% ServiceId = un:service_create("myapp", #{ports => "8080"}). -%%% ``` -%%% -%%% Authentication Priority: -%%% 1. Function arguments (PublicKey, SecretKey) -%%% 2. --account N -> accounts.csv row N (bypasses env vars) -%%% 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) -%%% 4. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) -%%% 5. ./accounts.csv row 0 - --define(API_BASE, "https://api.unsandbox.com"). --define(PORTAL_BASE, "https://unsandbox.com"). --define(VERSION, "4.2.0"). --define(LANGUAGES_CACHE_TTL, 3600). - -%% ============================================================================ -%% Utility Functions -%% ============================================================================ - -%% @doc Return the SDK version. -version() -> ?VERSION. - -%% @doc Check API health. -health_check() -> - Cmd = "curl -s -o /dev/null -w '%{http_code}' " ++ ?API_BASE ++ "/health", - Result = os:cmd(Cmd), - string:trim(Result) == "200". - -%% @doc Generate HMAC-SHA256 signature for a message. -hmac_sign(SecretKey, Message) -> - hmac_sha256(SecretKey, Message). - -%% @doc Detect language from filename extension. -detect_language(Filename) -> - Ext = filename:extension(Filename), - ext_to_lang(Ext). - -%% ============================================================================ -%% Execution Functions (8) -%% ============================================================================ - -%% @doc Execute code synchronously. -execute(Language, Code) -> - execute(Language, Code, #{}). - -execute(Language, Code, Opts) -> - Json = build_execute_json_full(Language, Code, Opts), - Response = api_post("/execute", Json, Opts), - parse_result(Response). - -%% @doc Execute code asynchronously, returning a job ID. -execute_async(Language, Code) -> - execute_async(Language, Code, #{}). - -execute_async(Language, Code, Opts) -> - Json = build_execute_json_full(Language, Code, Opts), - Response = api_post("/execute/async", Json, Opts), - extract_json_field(Response, "job_id"). - -%% @doc Wait for a job to complete and return the result. -wait_job(JobId) -> - wait_job(JobId, #{}). - -wait_job(JobId, Opts) -> - MaxPolls = maps:get(max_polls, Opts, 100), - PollDelays = [300, 450, 700, 900, 650, 1600, 2000], - do_wait_job(JobId, PollDelays, 0, MaxPolls, Opts). - -do_wait_job(JobId, _PollDelays, PollCount, MaxPolls, _Opts) when PollCount >= MaxPolls -> - #{success => false, stdout => "", stderr => "Max polls exceeded", exit_code => 1, job_id => JobId}; -do_wait_job(JobId, PollDelays, PollCount, MaxPolls, Opts) -> - DelayIdx = min(PollCount, length(PollDelays) - 1), - Delay = lists:nth(DelayIdx + 1, PollDelays), - timer:sleep(Delay), - Job = get_job(JobId, Opts), - Status = maps:get(status, Job, "unknown"), - case lists:member(Status, ["completed", "failed", "timeout", "cancelled"]) of - true -> - Response = api_get("/jobs/" ++ JobId, Opts), - parse_result(Response); - false -> - do_wait_job(JobId, PollDelays, PollCount + 1, MaxPolls, Opts) - end. - -%% @doc Get job status and details. -get_job(JobId) -> - get_job(JobId, #{}). - -get_job(JobId, Opts) -> - Response = api_get("/jobs/" ++ JobId, Opts), - #{ - id => JobId, - status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end, - language => extract_json_field(Response, "language"), - created_at => extract_json_number(Response, "created_at"), - completed_at => extract_json_number(Response, "completed_at") - }. - -%% @doc Cancel a running job. -cancel_job(JobId) -> - cancel_job(JobId, #{}). - -cancel_job(JobId, Opts) -> - Response = api_delete("/jobs/" ++ JobId, Opts), - not_contains_error(Response). - -%% @doc List all active jobs. -list_jobs() -> - list_jobs(#{}). - -list_jobs(Opts) -> - api_get("/jobs", Opts). - -%% @doc Get list of supported languages. -get_languages() -> - get_languages(#{}). - -get_languages(Opts) -> - case load_languages_cache() of - undefined -> - Response = api_get("/languages", Opts), - Langs = extract_json_array(Response, "languages"), - save_languages_cache(Langs), - Langs; - CachedLanguages -> - CachedLanguages - end. - -%% ============================================================================ -%% Session Functions (9) -%% ============================================================================ - -%% @doc List all sessions. -session_list() -> session_list(#{}). -session_list(Opts) -> api_get("/sessions", Opts). - -%% @doc Get session details. -session_get(SessionId) -> session_get(SessionId, #{}). -session_get(SessionId, Opts) -> - Response = api_get("/sessions/" ++ SessionId, Opts), - #{ - id => SessionId, - status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end, - container_name => extract_json_field(Response, "container_name"), - network_mode => extract_json_field(Response, "network_mode"), - vcpu => extract_json_number(Response, "vcpu"), - created_at => extract_json_number(Response, "created_at") - }. - -%% @doc Create a new session. -session_create() -> session_create(#{}). -session_create(Opts) -> - Shell = maps:get(shell, Opts, "bash"), - Network = maps:get(network, Opts, undefined), - Vcpu = maps:get(vcpu, Opts, undefined), - NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end, - VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end, - Json = "{\"shell\":\"" ++ Shell ++ "\"" ++ NetworkJson ++ VcpuJson ++ "}", - Response = api_post("/sessions", Json, Opts), - extract_json_field(Response, "id"). - -%% @doc Destroy a session. -session_destroy(SessionId) -> session_destroy(SessionId, #{}). -session_destroy(SessionId, Opts) -> - Response = api_delete("/sessions/" ++ SessionId, Opts), - not_contains_error(Response). - -%% @doc Freeze a session. -session_freeze(SessionId) -> session_freeze(SessionId, #{}). -session_freeze(SessionId, Opts) -> - Response = api_post("/sessions/" ++ SessionId ++ "/freeze", "{}", Opts), - not_contains_error(Response). - -%% @doc Unfreeze a session. -session_unfreeze(SessionId) -> session_unfreeze(SessionId, #{}). -session_unfreeze(SessionId, Opts) -> - Response = api_post("/sessions/" ++ SessionId ++ "/unfreeze", "{}", Opts), - not_contains_error(Response). - -%% @doc Boost session resources. -session_boost(SessionId, Vcpu) -> session_boost(SessionId, Vcpu, #{}). -session_boost(SessionId, Vcpu, Opts) -> - Json = "{\"vcpu\":" ++ integer_to_list(Vcpu) ++ "}", - Response = api_patch("/sessions/" ++ SessionId, Json, Opts), - not_contains_error(Response). - -%% @doc Unboost session. -session_unboost(SessionId) -> session_unboost(SessionId, #{}). -session_unboost(SessionId, Opts) -> - Response = api_patch("/sessions/" ++ SessionId, "{\"vcpu\":1}", Opts), - not_contains_error(Response). - -%% @doc Execute a command in a session. -session_execute(SessionId, Command) -> session_execute(SessionId, Command, #{}). -session_execute(SessionId, Command, Opts) -> - Json = "{\"command\":\"" ++ escape_json(Command) ++ "\"}", - Response = api_post("/sessions/" ++ SessionId ++ "/execute", Json, Opts), - parse_result(Response). - -%% ============================================================================ -%% Service Functions (17) -%% ============================================================================ - -%% @doc List all services. -service_list() -> service_list(#{}). -service_list(Opts) -> api_get("/services", Opts). - -%% @doc Get service details. -service_get(ServiceId) -> service_get(ServiceId, #{}). -service_get(ServiceId, Opts) -> - Response = api_get("/services/" ++ ServiceId, Opts), - #{ - id => ServiceId, - name => extract_json_field(Response, "name"), - status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end, - ports => extract_json_field(Response, "ports"), - domains => extract_json_field(Response, "domains"), - vcpu => extract_json_number(Response, "vcpu"), - locked => extract_json_field(Response, "locked") == "true", - unfreeze_on_demand => extract_json_field(Response, "unfreeze_on_demand") == "true", - created_at => extract_json_number(Response, "created_at") - }. - -%% @doc Create a new service. -service_create(Name) -> service_create(Name, #{}). -service_create(Name, Opts) -> - Ports = maps:get(ports, Opts, undefined), - Bootstrap = maps:get(bootstrap, Opts, undefined), - Network = maps:get(network, Opts, undefined), - Vcpu = maps:get(vcpu, Opts, undefined), - PortsJson = case Ports of undefined -> ""; P -> ",\"ports\":[" ++ P ++ "]" end, - BootstrapJson = case Bootstrap of undefined -> ""; B -> ",\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end, - NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end, - VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end, - Json = "{\"name\":\"" ++ escape_json(Name) ++ "\"" ++ PortsJson ++ BootstrapJson ++ NetworkJson ++ VcpuJson ++ "}", - Response = api_post("/services", Json, Opts), - extract_json_field(Response, "id"). - -%% @doc Destroy a service. -service_destroy(ServiceId) -> service_destroy(ServiceId, #{}). -service_destroy(ServiceId, Opts) -> - Response = api_delete("/services/" ++ ServiceId, Opts), - not_contains_error(Response). - -%% @doc Freeze a service. -service_freeze(ServiceId) -> service_freeze(ServiceId, #{}). -service_freeze(ServiceId, Opts) -> - Response = api_post("/services/" ++ ServiceId ++ "/freeze", "{}", Opts), - not_contains_error(Response). - -%% @doc Unfreeze a service. -service_unfreeze(ServiceId) -> service_unfreeze(ServiceId, #{}). -service_unfreeze(ServiceId, Opts) -> - Response = api_post("/services/" ++ ServiceId ++ "/unfreeze", "{}", Opts), - not_contains_error(Response). - -%% @doc Lock a service. -service_lock(ServiceId) -> service_lock(ServiceId, #{}). -service_lock(ServiceId, Opts) -> - Response = api_post("/services/" ++ ServiceId ++ "/lock", "{}", Opts), - not_contains_error(Response). - -%% @doc Unlock a service. -service_unlock(ServiceId) -> service_unlock(ServiceId, #{}). -service_unlock(ServiceId, Opts) -> - Response = api_post("/services/" ++ ServiceId ++ "/unlock", "{}", Opts), - not_contains_error(Response). - -%% @doc Set unfreeze-on-demand for a service. -service_set_unfreeze_on_demand(ServiceId, Enabled) -> service_set_unfreeze_on_demand(ServiceId, Enabled, #{}). -service_set_unfreeze_on_demand(ServiceId, Enabled, Opts) -> - EnabledStr = if Enabled -> "true"; true -> "false" end, - Json = "{\"unfreeze_on_demand\":" ++ EnabledStr ++ "}", - Response = api_patch("/services/" ++ ServiceId, Json, Opts), - not_contains_error(Response). - -%% @doc Redeploy a service. -service_redeploy(ServiceId) -> service_redeploy(ServiceId, undefined, #{}). -service_redeploy(ServiceId, Bootstrap) -> service_redeploy(ServiceId, Bootstrap, #{}). -service_redeploy(ServiceId, Bootstrap, Opts) -> - BootstrapJson = case Bootstrap of undefined -> ""; B -> "\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end, - Json = "{" ++ BootstrapJson ++ "}", - Response = api_post("/services/" ++ ServiceId ++ "/redeploy", Json, Opts), - not_contains_error(Response). - -%% @doc Get service logs. -service_logs(ServiceId) -> service_logs(ServiceId, #{}). -service_logs(ServiceId, Opts) -> - AllLogs = maps:get(all_logs, Opts, false), - Endpoint = if AllLogs -> "/services/" ++ ServiceId ++ "/logs?all=true"; true -> "/services/" ++ ServiceId ++ "/logs" end, - api_get(Endpoint, Opts). - -%% @doc Execute a command in a service. -service_execute(ServiceId, Command) -> service_execute(ServiceId, Command, #{}). -service_execute(ServiceId, Command, Opts) -> - TimeoutMs = maps:get(timeout_ms, Opts, undefined), - TimeoutJson = case TimeoutMs of undefined -> ""; T -> ",\"timeout_ms\":" ++ integer_to_list(T) end, - Json = "{\"command\":\"" ++ escape_json(Command) ++ "\"" ++ TimeoutJson ++ "}", - Response = api_post("/services/" ++ ServiceId ++ "/execute", Json, Opts), - parse_result(Response). - -%% @doc Get service environment vault. -service_env_get(ServiceId) -> service_env_get(ServiceId, #{}). -service_env_get(ServiceId, Opts) -> - api_get("/services/" ++ ServiceId ++ "/env", Opts). - -%% @doc Set service environment vault. -service_env_set(ServiceId, EnvContent) -> service_env_set(ServiceId, EnvContent, #{}). -service_env_set(ServiceId, EnvContent, Opts) -> - api_put_text("/services/" ++ ServiceId ++ "/env", EnvContent, Opts). - -%% @doc Delete service environment vault. -service_env_delete(ServiceId) -> service_env_delete(ServiceId, #{}). -service_env_delete(ServiceId, Opts) -> - Response = api_delete("/services/" ++ ServiceId ++ "/env", Opts), - not_contains_error(Response). - -%% @doc Export service environment vault. -service_env_export(ServiceId) -> service_env_export(ServiceId, #{}). -service_env_export(ServiceId, Opts) -> - Response = api_post("/services/" ++ ServiceId ++ "/env/export", "{}", Opts), - extract_json_field(Response, "content"). - -%% @doc Resize a service. -service_resize(ServiceId, Vcpu) -> service_resize(ServiceId, Vcpu, #{}). -service_resize(ServiceId, Vcpu, Opts) -> - Json = "{\"vcpu\":" ++ integer_to_list(Vcpu) ++ "}", - Response = api_patch("/services/" ++ ServiceId, Json, Opts), - not_contains_error(Response). - -%% ============================================================================ -%% Snapshot Functions (9) -%% ============================================================================ - -%% @doc List all snapshots. -snapshot_list() -> snapshot_list(#{}). -snapshot_list(Opts) -> api_get("/snapshots", Opts). - -%% @doc Get snapshot details. -snapshot_get(SnapshotId) -> snapshot_get(SnapshotId, #{}). -snapshot_get(SnapshotId, Opts) -> - Response = api_get("/snapshots/" ++ SnapshotId, Opts), - #{ - id => SnapshotId, - name => extract_json_field(Response, "name"), - type => case extract_json_field(Response, "type") of "" -> "unknown"; T -> T end, - source_id => extract_json_field(Response, "source_id"), - hot => extract_json_field(Response, "hot") == "true", - locked => extract_json_field(Response, "locked") == "true", - created_at => extract_json_number(Response, "created_at"), - size_bytes => extract_json_number(Response, "size_bytes") - }. - -%% @doc Create a snapshot of a session. -snapshot_session(SessionId) -> snapshot_session(SessionId, #{}). -snapshot_session(SessionId, Opts) -> - Name = maps:get(name, Opts, undefined), - Hot = maps:get(hot, Opts, false), - NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"," end, - HotJson = if Hot -> "\"hot\":true"; true -> "\"hot\":false" end, - Json = "{" ++ NameJson ++ HotJson ++ "}", - Response = api_post("/sessions/" ++ SessionId ++ "/snapshot", Json, Opts), - extract_json_field(Response, "id"). - -%% @doc Create a snapshot of a service. -snapshot_service(ServiceId) -> snapshot_service(ServiceId, #{}). -snapshot_service(ServiceId, Opts) -> - Name = maps:get(name, Opts, undefined), - Hot = maps:get(hot, Opts, false), - NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"," end, - HotJson = if Hot -> "\"hot\":true"; true -> "\"hot\":false" end, - Json = "{" ++ NameJson ++ HotJson ++ "}", - Response = api_post("/services/" ++ ServiceId ++ "/snapshot", Json, Opts), - extract_json_field(Response, "id"). - -%% @doc Restore from a snapshot. -snapshot_restore(SnapshotId) -> snapshot_restore(SnapshotId, #{}). -snapshot_restore(SnapshotId, Opts) -> - Response = api_post("/snapshots/" ++ SnapshotId ++ "/restore", "{}", Opts), - extract_json_field(Response, "id"). - -%% @doc Delete a snapshot. -snapshot_delete(SnapshotId) -> snapshot_delete(SnapshotId, #{}). -snapshot_delete(SnapshotId, Opts) -> - Response = api_delete("/snapshots/" ++ SnapshotId, Opts), - not_contains_error(Response). - -%% @doc Lock a snapshot. -snapshot_lock(SnapshotId) -> snapshot_lock(SnapshotId, #{}). -snapshot_lock(SnapshotId, Opts) -> - Response = api_post("/snapshots/" ++ SnapshotId ++ "/lock", "{}", Opts), - not_contains_error(Response). - -%% @doc Unlock a snapshot. -snapshot_unlock(SnapshotId) -> snapshot_unlock(SnapshotId, #{}). -snapshot_unlock(SnapshotId, Opts) -> - Response = api_post("/snapshots/" ++ SnapshotId ++ "/unlock", "{}", Opts), - not_contains_error(Response). - -%% @doc Clone a snapshot to create a new session or service. -snapshot_clone(SnapshotId, Opts) -> - CloneType = maps:get(type, Opts), - Name = maps:get(name, Opts, undefined), - Ports = maps:get(ports, Opts, undefined), - Shell = maps:get(shell, Opts, undefined), - TypeJson = "\"type\":\"" ++ CloneType ++ "\"", - NameJson = case Name of undefined -> ""; N -> ",\"name\":\"" ++ escape_json(N) ++ "\"" end, - PortsJson = case Ports of undefined -> ""; P -> ",\"ports\":[" ++ P ++ "]" end, - ShellJson = case Shell of undefined -> ""; S -> ",\"shell\":\"" ++ S ++ "\"" end, - Json = "{" ++ TypeJson ++ NameJson ++ PortsJson ++ ShellJson ++ "}", - Response = api_post("/snapshots/" ++ SnapshotId ++ "/clone", Json, Opts), - extract_json_field(Response, "id"). - -%% ============================================================================ -%% Image Functions (13) -%% ============================================================================ - -%% @doc List images. -image_list() -> image_list(#{}). -image_list(Opts) -> - Filter = maps:get(filter, Opts, undefined), - Endpoint = case Filter of undefined -> "/images"; F -> "/images?filter=" ++ F end, - api_get(Endpoint, Opts). - -%% @doc Get image details. -image_get(ImageId) -> image_get(ImageId, #{}). -image_get(ImageId, Opts) -> - Response = api_get("/images/" ++ ImageId, Opts), - #{ - id => ImageId, - name => extract_json_field(Response, "name"), - description => extract_json_field(Response, "description"), - visibility => case extract_json_field(Response, "visibility") of "" -> "private"; V -> V end, - source_type => extract_json_field(Response, "source_type"), - source_id => extract_json_field(Response, "source_id"), - locked => extract_json_field(Response, "locked") == "true", - created_at => extract_json_number(Response, "created_at"), - size_bytes => extract_json_number(Response, "size_bytes") - }. - -%% @doc Publish an image. -image_publish(SourceType, SourceId) -> image_publish(SourceType, SourceId, #{}). -image_publish(SourceType, SourceId, Opts) -> - Name = maps:get(name, Opts, undefined), - Description = maps:get(description, Opts, undefined), - NameJson = case Name of undefined -> ""; N -> ",\"name\":\"" ++ escape_json(N) ++ "\"" end, - DescJson = case Description of undefined -> ""; D -> ",\"description\":\"" ++ escape_json(D) ++ "\"" end, - Json = "{\"source_type\":\"" ++ SourceType ++ "\",\"source_id\":\"" ++ SourceId ++ "\"" ++ NameJson ++ DescJson ++ "}", - Response = api_post("/images/publish", Json, Opts), - extract_json_field(Response, "id"). - -%% @doc Delete an image. -image_delete(ImageId) -> image_delete(ImageId, #{}). -image_delete(ImageId, Opts) -> - Response = api_delete("/images/" ++ ImageId, Opts), - not_contains_error(Response). - -%% @doc Lock an image. -image_lock(ImageId) -> image_lock(ImageId, #{}). -image_lock(ImageId, Opts) -> - Response = api_post("/images/" ++ ImageId ++ "/lock", "{}", Opts), - not_contains_error(Response). - -%% @doc Unlock an image. -image_unlock(ImageId) -> image_unlock(ImageId, #{}). -image_unlock(ImageId, Opts) -> - Response = api_post("/images/" ++ ImageId ++ "/unlock", "{}", Opts), - not_contains_error(Response). - -%% @doc Set image visibility. -image_set_visibility(ImageId, Visibility) -> image_set_visibility(ImageId, Visibility, #{}). -image_set_visibility(ImageId, Visibility, Opts) -> - Json = "{\"visibility\":\"" ++ Visibility ++ "\"}", - Response = api_post("/images/" ++ ImageId ++ "/visibility", Json, Opts), - not_contains_error(Response). - -%% @doc Grant access to an image. -image_grant_access(ImageId, TrustedApiKey) -> image_grant_access(ImageId, TrustedApiKey, #{}). -image_grant_access(ImageId, TrustedApiKey, Opts) -> - Json = "{\"api_key\":\"" ++ TrustedApiKey ++ "\"}", - Response = api_post("/images/" ++ ImageId ++ "/access/grant", Json, Opts), - not_contains_error(Response). - -%% @doc Revoke access to an image. -image_revoke_access(ImageId, TrustedApiKey) -> image_revoke_access(ImageId, TrustedApiKey, #{}). -image_revoke_access(ImageId, TrustedApiKey, Opts) -> - Json = "{\"api_key\":\"" ++ TrustedApiKey ++ "\"}", - Response = api_post("/images/" ++ ImageId ++ "/access/revoke", Json, Opts), - not_contains_error(Response). - -%% @doc List trusted API keys for an image. -image_list_trusted(ImageId) -> image_list_trusted(ImageId, #{}). -image_list_trusted(ImageId, Opts) -> - Response = api_get("/images/" ++ ImageId ++ "/access", Opts), - extract_json_array(Response, "trusted_keys"). - -%% @doc Transfer image ownership. -image_transfer(ImageId, ToApiKey) -> image_transfer(ImageId, ToApiKey, #{}). -image_transfer(ImageId, ToApiKey, Opts) -> - Json = "{\"to_api_key\":\"" ++ ToApiKey ++ "\"}", - Response = api_post("/images/" ++ ImageId ++ "/transfer", Json, Opts), - not_contains_error(Response). - -%% @doc Spawn a service from an image. -image_spawn(ImageId) -> image_spawn(ImageId, #{}). -image_spawn(ImageId, Opts) -> - Name = maps:get(name, Opts, undefined), - Ports = maps:get(ports, Opts, undefined), - Bootstrap = maps:get(bootstrap, Opts, undefined), - Network = maps:get(network, Opts, undefined), - NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"" end, - PortsJson = case Ports of undefined -> ""; P -> (if Name =/= undefined -> ","; true -> "" end) ++ "\"ports\":[" ++ P ++ "]" end, - BootstrapJson = case Bootstrap of undefined -> ""; B -> ",\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end, - NetworkJson = case Network of undefined -> ""; Nn -> ",\"network\":\"" ++ Nn ++ "\"" end, - Json = "{" ++ NameJson ++ PortsJson ++ BootstrapJson ++ NetworkJson ++ "}", - Response = api_post("/images/" ++ ImageId ++ "/spawn", Json, Opts), - extract_json_field(Response, "id"). - -%% @doc Clone an image. -image_clone(ImageId) -> image_clone(ImageId, #{}). -image_clone(ImageId, Opts) -> - Name = maps:get(name, Opts, undefined), - Description = maps:get(description, Opts, undefined), - NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"" end, - DescJson = case Description of undefined -> ""; D -> (if Name =/= undefined -> ","; true -> "" end) ++ "\"description\":\"" ++ escape_json(D) ++ "\"" end, - Json = "{" ++ NameJson ++ DescJson ++ "}", - Response = api_post("/images/" ++ ImageId ++ "/clone", Json, Opts), - extract_json_field(Response, "id"). - -%% ============================================================================ -%% PaaS Logs Functions (2) -%% ============================================================================ - -%% @doc Fetch batch logs from portal. -logs_fetch() -> logs_fetch(#{}). -logs_fetch(Opts) -> - Source = maps:get(source, Opts, "all"), - Lines = maps:get(lines, Opts, 100), - Since = maps:get(since, Opts, "1h"), - Grep = maps:get(grep, Opts, undefined), - GrepParam = case Grep of undefined -> ""; G -> "&grep=" ++ http_uri:encode(G) end, - api_get("/logs?source=" ++ Source ++ "&lines=" ++ integer_to_list(Lines) ++ "&since=" ++ Since ++ GrepParam, Opts). - -%% @doc Stream logs (simplified polling implementation). -logs_stream(Callback) -> logs_stream(Callback, #{}). -logs_stream(Callback, Opts) -> - Source = maps:get(source, Opts, "all"), - Grep = maps:get(grep, Opts, undefined), - Interval = maps:get(interval, Opts, 5000), - GrepParam = case Grep of undefined -> ""; G -> "&grep=" ++ http_uri:encode(G) end, - logs_stream_loop(Source, GrepParam, Callback, Interval, Opts). - -logs_stream_loop(Source, GrepParam, Callback, Interval, Opts) -> - Response = api_get("/logs?source=" ++ Source ++ "&lines=50&since=10s" ++ GrepParam, Opts), - Callback(Source, Response), - timer:sleep(Interval), - logs_stream_loop(Source, GrepParam, Callback, Interval, Opts). - -%% ============================================================================ -%% Key Validation (1) -%% ============================================================================ - -%% @doc Validate API keys. -validate_keys() -> validate_keys(#{}). -validate_keys(Opts) -> - Response = portal_post("/keys/validate", "{}", Opts), - #{ - valid => extract_json_field(Response, "status") == "valid", - tier => extract_json_field(Response, "tier"), - rate_limit_per_minute => extract_json_number(Response, "rate_per_minute"), - concurrency_limit => extract_json_number(Response, "concurrency"), - expires_at => extract_json_number(Response, "expires_at") - }. - -%% ============================================================================ -%% Private API Functions -%% ============================================================================ - -api_get(Endpoint, Opts) -> - {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, "GET", Endpoint, ""), - Cmd = "curl -s " ++ ?API_BASE ++ Endpoint ++ AuthHeaders, - Result = os:cmd(Cmd), - check_clock_drift_error(Result), - Result. - -api_post(Endpoint, Json, Opts) -> - {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), - TmpFile = write_temp_file(Json), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json), - Cmd = "curl -s -X POST " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile, - Result = os:cmd(Cmd), - file:delete(TmpFile), - check_clock_drift_error(Result), - Result. - -api_delete(Endpoint, Opts) -> - {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, "DELETE", Endpoint, ""), - Cmd = "curl -s -X DELETE " ++ ?API_BASE ++ Endpoint ++ AuthHeaders, - Result = os:cmd(Cmd), - check_clock_drift_error(Result), - Result. - -api_patch(Endpoint, Json, Opts) -> - {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), - TmpFile = write_temp_file(Json), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PATCH", Endpoint, Json), - Cmd = "curl -s -X PATCH " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile, - Result = os:cmd(Cmd), - file:delete(TmpFile), - check_clock_drift_error(Result), - Result. - -api_put_text(Endpoint, Body, Opts) -> - {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), - TmpFile = write_temp_file(Body), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PUT", Endpoint, Body), - Cmd = "curl -s -o /dev/null -w '%{http_code}' -X PUT " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: text/plain'" ++ AuthHeaders ++ " -d @" ++ TmpFile, - Result = os:cmd(Cmd), - file:delete(TmpFile), - StatusCode = list_to_integer(string:trim(Result)), - StatusCode >= 200 andalso StatusCode < 300. - -portal_post(Endpoint, Json, Opts) -> - {PublicKey, SecretKey} = get_api_keys_from_opts(Opts), - TmpFile = write_temp_file(Json), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json), - Cmd = "curl -s -X POST " ++ ?PORTAL_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile, - Result = os:cmd(Cmd), - file:delete(TmpFile), - check_clock_drift_error(Result), - Result. - -get_api_keys_from_opts(Opts) -> - case {maps:get(public_key, Opts, undefined), maps:get(secret_key, Opts, undefined)} of - {Pk, Sk} when Pk =/= undefined, Sk =/= undefined -> {Pk, Sk}; - _ -> get_api_keys() - end. - -build_execute_json_full(Language, Code, Opts) -> - Network = maps:get(network, Opts, undefined), - Vcpu = maps:get(vcpu, Opts, undefined), - Ttl = maps:get(ttl, Opts, undefined), - ReturnArtifacts = maps:get(return_artifacts, Opts, false), - NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end, - VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end, - TtlJson = case Ttl of undefined -> ""; T -> ",\"ttl\":" ++ integer_to_list(T) end, - ArtifactsJson = if ReturnArtifacts -> ",\"return_artifacts\":true"; true -> "" end, - "{\"language\":\"" ++ Language ++ "\",\"code\":\"" ++ escape_json(Code) ++ "\"" ++ NetworkJson ++ VcpuJson ++ TtlJson ++ ArtifactsJson ++ "}". - -parse_result(Response) -> - ExitCode = case extract_json_number(Response, "exit_code") of 0 -> 0; N when is_integer(N) -> N; _ -> 0 end, - #{ - success => ExitCode == 0, - stdout => case extract_json_field(Response, "stdout") of "" -> ""; S -> S end, - stderr => case extract_json_field(Response, "stderr") of "" -> ""; S -> S end, - exit_code => ExitCode, - job_id => extract_json_field(Response, "job_id"), - language => extract_json_field(Response, "language"), - execution_time => undefined - }. - -not_contains_error(Response) -> - string:str(Response, "\"error\"") == 0. - -%% ============================================================================ -%% CLI Entry Point -%% ============================================================================ - -main(RawArgs) -> - %% Strip --account N from args and store index in process dict before dispatch - {AccountIndex, Args} = extract_account_arg(RawArgs, undefined, []), - case AccountIndex of - undefined -> ok; - N -> erlang:put(account_index, N) - end, - dispatch(Args). - -dispatch([]) -> - io:format("Usage: un.erl [--account N] [options] ~n"), - io:format(" un.erl [--account N] session [options]~n"), - io:format(" un.erl [--account N] service [options]~n"), - io:format(" un.erl [--account N] snapshot [options]~n"), - io:format(" un.erl [--account N] image [options]~n"), - io:format(" un.erl [--account N] key [options]~n"), +%%% Full-featured CLI matching un.py capabilities +%%% Uses curl for HTTP (no external dependencies) + +main([]) -> + io:format("Usage: un.erl [options] ~n"), + io:format(" un.erl session [options]~n"), + io:format(" un.erl service [options]~n"), + io:format(" un.erl snapshot [options]~n"), + io:format(" un.erl image [options]~n"), + io:format(" un.erl key [options]~n"), io:format(" un.erl languages [--json]~n"), - io:format("~nGlobal options:~n"), - io:format(" --account N Use accounts.csv row N (bypasses env vars)~n"), halt(1); -dispatch(["session" | Rest]) -> +main(["session" | Rest]) -> session_command(Rest); -dispatch(["service" | Rest]) -> +main(["service" | Rest]) -> service_command(Rest); -dispatch(["snapshot" | Rest]) -> +main(["snapshot" | Rest]) -> snapshot_command(Rest); -dispatch(["image" | Rest]) -> +main(["image" | Rest]) -> image_command(Rest); -dispatch(["key" | Rest]) -> +main(["key" | Rest]) -> key_command(Rest); -dispatch(["languages" | Rest]) -> +main(["languages" | Rest]) -> languages_command(Rest); -dispatch(Args) -> +main(Args) -> execute_command(Args). -%% Strip --account N from argument list, return {Index | undefined, RestArgs} -extract_account_arg([], Acc, RestAcc) -> - {Acc, lists:reverse(RestAcc)}; -extract_account_arg(["--account", NStr | Rest], _Acc, RestAcc) -> - N = try list_to_integer(NStr) catch _:_ -> - io:format("Error: --account requires an integer argument~n"), - halt(1) - end, - extract_account_arg(Rest, N, RestAcc); -extract_account_arg([Arg | Rest], Acc, RestAcc) -> - extract_account_arg(Rest, Acc, [Arg | RestAcc]). - %% Execute command execute_command(Args) -> {File, _Opts} = parse_exec_args(Args, #{file => undefined}), @@ -920,17 +218,8 @@ service_command(["--unfreeze", ServiceId | _]) -> service_command(["--destroy", ServiceId | _]) -> ApiKey = get_api_key(), - case curl_delete_with_sudo(ApiKey, "/services/" ++ ServiceId) of - {ok, _, _} -> - io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]); - {ok, _} -> - io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]); - {error, cancelled} -> - halt(1); - {error, Msg} -> - io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]), - halt(1) - end; + _ = curl_delete(ApiKey, "/services/" ++ ServiceId), + io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]); service_command(["--resize", ServiceId, "--vcpu", VcpuStr | _]) -> service_resize(ServiceId, VcpuStr); @@ -1106,17 +395,8 @@ snapshot_command(["--info", SnapshotId | _]) -> snapshot_command(["--delete", SnapshotId | _]) -> ApiKey = get_api_key(), - case curl_delete_with_sudo(ApiKey, "/snapshots/" ++ SnapshotId) of - {ok, _, _} -> - io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]); - {ok, _} -> - io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]); - {error, cancelled} -> - halt(1); - {error, Msg} -> - io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]), - halt(1) - end; + _ = curl_delete(ApiKey, "/snapshots/" ++ SnapshotId), + io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]); snapshot_command(["--clone", SnapshotId | Rest]) -> ApiKey = get_api_key(), @@ -1186,20 +466,10 @@ image_command(["--info", ImageId | _]) -> halt(0); image_command(["--delete", ImageId | _]) -> - ApiKey = get_api_key(), - case curl_delete_with_sudo(ApiKey, "/images/" ++ ImageId) of - {ok, _, _} -> - io:format("\033[32mImage deleted: ~s\033[0m~n", [ImageId]), - halt(0); - {ok, _} -> - io:format("\033[32mImage deleted: ~s\033[0m~n", [ImageId]), - halt(0); - {error, cancelled} -> - halt(1); - {error, Msg} -> - io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]), - halt(1) - end; + {PublicKey, SecretKey} = get_api_keys(), + api_request("/images/" ++ ImageId, "DELETE", "", PublicKey, SecretKey), + io:format("\033[32mImage deleted: ~s\033[0m~n", [ImageId]), + halt(0); image_command(["--lock", ImageId | _]) -> {PublicKey, SecretKey} = get_api_keys(), @@ -1208,20 +478,10 @@ image_command(["--lock", ImageId | _]) -> halt(0); image_command(["--unlock", ImageId | _]) -> - ApiKey = get_api_key(), - case curl_post_with_sudo(ApiKey, "/images/" ++ ImageId ++ "/unlock", "{}") of - {ok, _, _} -> - io:format("\033[32mImage unlocked: ~s\033[0m~n", [ImageId]), - halt(0); - {ok, _} -> - io:format("\033[32mImage unlocked: ~s\033[0m~n", [ImageId]), - halt(0); - {error, cancelled} -> - halt(1); - {error, Msg} -> - io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]), - halt(1) - end; + {PublicKey, SecretKey} = get_api_keys(), + api_request("/images/" ++ ImageId ++ "/unlock", "POST", "", PublicKey, SecretKey), + io:format("\033[32mImage unlocked: ~s\033[0m~n", [ImageId]), + halt(0); image_command(["--publish", SourceId | Rest]) -> SourceType = get_image_source_type(Rest), @@ -1477,96 +737,19 @@ open_extend_page(PublicKey) -> end. %% Helpers - -%% @doc Load credentials from a CSV file at the given path. -%% Skips blank lines and comment lines (#). Returns {ok, {PK, SK}} or error. -load_credentials_from_csv(CsvPath, AccountIndex) -> - case file:read_file(CsvPath) of - {ok, Bin} -> - Lines = string:split(binary_to_list(Bin), "\n", all), - ValidAccounts = lists:filtermap(fun(Line) -> - Trimmed = string:trim(Line), - case Trimmed of - "" -> false; - [$# | _] -> false; - _ -> - Parts = string:split(Trimmed, ",", all), - case Parts of - [PK, SK | _] -> - PKt = string:trim(PK), - SKt = string:trim(SK), - if - length(PKt) > 8 andalso length(SKt) > 8 -> - {true, {PKt, SKt}}; - true -> false - end; - _ -> false - end - end - end, Lines), - if - AccountIndex < length(ValidAccounts) -> - {ok, lists:nth(AccountIndex + 1, ValidAccounts)}; - true -> - error - end; - _ -> - error - end. - -%% @doc Resolve credentials with correct priority: -%% 1. --account N process-dict override -> accounts.csv row N -%% 2. UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY env vars -%% 3. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) -%% 4. ./accounts.csv row 0 get_api_keys() -> - Home = case os:getenv("HOME") of false -> "."; H -> H end, - HomeCsv = filename:join([Home, ".unsandbox", "accounts.csv"]), - %% Priority 1: explicit --account N (stored in process dict by main/1) - case erlang:get(account_index) of - undefined -> - %% Priority 2: environment variables - PublicKey = os:getenv("UNSANDBOX_PUBLIC_KEY"), - SecretKey = os:getenv("UNSANDBOX_SECRET_KEY"), - ApiKey = os:getenv("UNSANDBOX_API_KEY"), - if - PublicKey =/= false andalso SecretKey =/= false -> - {PublicKey, SecretKey}; - ApiKey =/= false -> - {ApiKey, false}; - true -> - %% Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index) - DefaultIndex = case os:getenv("UNSANDBOX_ACCOUNT") of - false -> 0; - IdxStr -> try list_to_integer(string:trim(IdxStr)) catch _:_ -> 0 end - end, - case load_credentials_from_csv(HomeCsv, DefaultIndex) of - {ok, {PK, SK}} -> - {PK, SK}; - error -> - %% Priority 4: ./accounts.csv - case load_credentials_from_csv("accounts.csv", DefaultIndex) of - {ok, {PK2, SK2}} -> - {PK2, SK2}; - error -> - io:format("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)~n"), - halt(1) - end - end - end; - AccountIndex -> - case load_credentials_from_csv(HomeCsv, AccountIndex) of - {ok, {PK, SK}} -> - {PK, SK}; - error -> - case load_credentials_from_csv("accounts.csv", AccountIndex) of - {ok, {PK2, SK2}} -> - {PK2, SK2}; - error -> - io:format("Error: No credentials found for account index ~B in accounts.csv~n", [AccountIndex]), - halt(1) - end - end + PublicKey = os:getenv("UNSANDBOX_PUBLIC_KEY"), + SecretKey = os:getenv("UNSANDBOX_SECRET_KEY"), + ApiKey = os:getenv("UNSANDBOX_API_KEY"), + + if + PublicKey =/= false andalso SecretKey =/= false -> + {PublicKey, SecretKey}; + ApiKey =/= false -> + {ApiKey, false}; + true -> + io:format("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)~n"), + halt(1) end. get_api_key() -> @@ -1735,101 +918,6 @@ curl_delete(ApiKey, Endpoint) -> check_clock_drift_error(Result), Result. -%% Handle 428 sudo OTP challenge - prompts user for OTP and retries the request -handle_sudo_challenge(Response, Method, Endpoint, Body) -> - ChallengeId = extract_json_field(Response, "challenge_id"), - io:format(standard_error, "\033[33mConfirmation required. Check your email for a one-time code.\033[0m~n", []), - io:format(standard_error, "Enter OTP: ", []), - case io:get_line("") of - eof -> - io:format(standard_error, "Error: Failed to read OTP~n", []), - {error, cancelled}; - OtpRaw -> - Otp = string:trim(OtpRaw), - case Otp of - "" -> - io:format(standard_error, "Error: Operation cancelled~n", []), - {error, cancelled}; - _ -> - %% Retry the request with sudo headers - {PublicKey, SecretKey} = get_api_keys(), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, Method, Endpoint, Body), - SudoHeaders = " -H 'X-Sudo-OTP: " ++ Otp ++ "'", - ChallengeHeader = case ChallengeId of - "" -> ""; - _ -> " -H 'X-Sudo-Challenge: " ++ ChallengeId ++ "'" - end, - Cmd = case Method of - "DELETE" -> - "curl -s -X DELETE https://api.unsandbox.com" ++ Endpoint ++ - AuthHeaders ++ SudoHeaders ++ ChallengeHeader; - "POST" -> - TmpFile = write_temp_file(Body), - Result = "curl -s -X POST https://api.unsandbox.com" ++ Endpoint ++ - " -H 'Content-Type: application/json'" ++ - AuthHeaders ++ SudoHeaders ++ ChallengeHeader ++ - " -d @" ++ TmpFile, - file:delete(TmpFile), - Result; - _ -> - "curl -s https://api.unsandbox.com" ++ Endpoint ++ - AuthHeaders ++ SudoHeaders ++ ChallengeHeader - end, - RetryResult = os:cmd(Cmd), - case string:str(RetryResult, "\"error\"") of - 0 -> {ok, RetryResult}; - _ -> {error, RetryResult} - end - end - end. - -%% Curl with 428 handling for destructive operations -curl_delete_with_sudo(ApiKey, Endpoint) -> - {PublicKey, SecretKey} = get_api_keys(), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, "DELETE", Endpoint, ""), - Cmd = "curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com" ++ Endpoint ++ - AuthHeaders, - Result = os:cmd(Cmd), - %% Split response and status code - Lines = string:split(Result, "\n", all), - case lists:reverse(Lines) of - [StatusCodeStr | BodyLinesRev] -> - StatusCode = list_to_integer(string:trim(StatusCodeStr)), - Body = string:join(lists:reverse(BodyLinesRev), "\n"), - check_clock_drift_error(Body), - case StatusCode of - 428 -> handle_sudo_challenge(Body, "DELETE", Endpoint, ""); - _ -> {ok, Body, StatusCode} - end; - _ -> - {ok, Result, 200} - end. - -curl_post_with_sudo(ApiKey, Endpoint, Json) -> - TmpFile = write_temp_file(Json), - {PublicKey, SecretKey} = get_api_keys(), - AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json), - Cmd = "curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com" ++ Endpoint ++ - " -H 'Content-Type: application/json'" ++ - AuthHeaders ++ - " -d @" ++ TmpFile, - Result = os:cmd(Cmd), - file:delete(TmpFile), - %% Split response and status code - Lines = string:split(Result, "\n", all), - case lists:reverse(Lines) of - [StatusCodeStr | BodyLinesRev] -> - StatusCode = list_to_integer(string:trim(StatusCodeStr)), - Body = string:join(lists:reverse(BodyLinesRev), "\n"), - check_clock_drift_error(Body), - case StatusCode of - 428 -> handle_sudo_challenge(Body, "POST", Endpoint, Json); - _ -> {ok, Body, StatusCode} - end; - _ -> - {ok, Result, 200} - end. - curl_patch(ApiKey, Endpoint, TmpFile) -> {ok, Body} = file:read_file(TmpFile), BodyStr = binary_to_list(Body), diff --git a/clients/erlang/sync/tests/test_functional.erl b/clients/erlang/sync/tests/test_functional.erl deleted file mode 100755 index 788191f..0000000 --- a/clients/erlang/sync/tests/test_functional.erl +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env escript -%% -*- erlang -*- -%% -%% Functional Tests for Un Erlang SDK -%% -%% Run with: escript test_functional.erl -%% Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables -%% -%% These tests make real API calls to api.unsandbox.com - --mode(compile). - -main([]) -> - io:format("\n\033[34m=== Un Erlang SDK Functional Tests ===\033[0m\n\n"), - - %% Check for credentials - case {os:getenv("UNSANDBOX_PUBLIC_KEY"), os:getenv("UNSANDBOX_SECRET_KEY")} of - {false, _} -> - io:format("\033[33mSKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set\033[0m\n"), - halt(0); - {_, false} -> - io:format("\033[33mSKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set\033[0m\n"), - halt(0); - _ -> - ok - end, - - Tests = [ - {"health_check", fun test_health_check/0}, - {"validate_keys", fun test_validate_keys/0}, - {"execute_python", fun test_execute_python/0}, - {"execute_with_error", fun test_execute_with_error/0}, - {"session_list", fun test_session_list/0}, - {"service_list", fun test_service_list/0}, - {"snapshot_list", fun test_snapshot_list/0}, - {"image_list", fun test_image_list/0}, - {"get_languages", fun test_get_languages/0} - ], - - Results = run_tests(Tests, []), - {Passed, Failed} = count_results(Results, 0, 0), - Total = length(Results), - - io:format("\n\033[34mResults: ~p/~p passed\033[0m\n", [Passed, Total]), - case Failed > 0 of - true -> - io:format("\033[31m~p test(s) failed\033[0m\n", [Failed]), - halt(1); - false -> - io:format("\033[32mAll functional tests passed!\033[0m\n") - end. - -run_tests([], Acc) -> - lists:reverse(Acc); -run_tests([{Name, TestFn} | Rest], Acc) -> - io:format(" Running ~s... ", [Name]), - Result = try - TestFn(), - io:format("\033[32mPASS\033[0m\n"), - pass - catch - _:Error -> - io:format("\033[31mFAIL\033[0m\n"), - io:format(" ~p\n", [Error]), - fail - end, - run_tests(Rest, [Result | Acc]). - -count_results([], Passed, Failed) -> - {Passed, Failed}; -count_results([pass | Rest], Passed, Failed) -> - count_results(Rest, Passed + 1, Failed); -count_results([fail | Rest], Passed, Failed) -> - count_results(Rest, Passed, Failed + 1). - -%% ============================================================================ -%% Functional Tests -%% ============================================================================ - -test_health_check() -> - Result = un:health_check(), - true = is_boolean(Result), - ok. - -test_validate_keys() -> - KeyInfo = un:validate_keys(), - true = is_map(KeyInfo), - true = maps:is_key(valid, KeyInfo), - true = is_boolean(maps:get(valid, KeyInfo)), - ok. - -test_execute_python() -> - Result = un:execute("python", "print(6 * 7)"), - true = is_map(Result), - true = maps:is_key(success, Result), - true = maps:is_key(stdout, Result), - true = maps:is_key(exit_code, Result), - - %% Check output - true = maps:get(success, Result), - Stdout = maps:get(stdout, Result), - true = string:find(Stdout, "42") =/= nomatch, - 0 = maps:get(exit_code, Result), - ok. - -test_execute_with_error() -> - Result = un:execute("python", "import sys; sys.exit(1)"), - true = is_map(Result), - false = maps:get(success, Result), - 1 = maps:get(exit_code, Result), - ok. - -test_session_list() -> - Response = un:session_list(), - true = is_list(Response), - %% Response should be valid JSON (starts with [ or {) - Trimmed = string:trim(Response), - true = (string:prefix(Trimmed, "[") =/= nomatch) orelse (string:prefix(Trimmed, "{") =/= nomatch), - ok. - -test_service_list() -> - Response = un:service_list(), - true = is_list(Response), - Trimmed = string:trim(Response), - true = (string:prefix(Trimmed, "[") =/= nomatch) orelse (string:prefix(Trimmed, "{") =/= nomatch), - ok. - -test_snapshot_list() -> - Response = un:snapshot_list(), - true = is_list(Response), - Trimmed = string:trim(Response), - true = (string:prefix(Trimmed, "[") =/= nomatch) orelse (string:prefix(Trimmed, "{") =/= nomatch), - ok. - -test_image_list() -> - Response = un:image_list(), - true = is_list(Response), - Trimmed = string:trim(Response), - true = (string:prefix(Trimmed, "[") =/= nomatch) orelse (string:prefix(Trimmed, "{") =/= nomatch), - ok. - -test_get_languages() -> - Languages = un:get_languages(), - true = is_list(Languages), - true = length(Languages) > 0, - true = lists:member("python", Languages), - true = lists:member("javascript", Languages), - ok. diff --git a/clients/erlang/sync/tests/test_library.erl b/clients/erlang/sync/tests/test_library.erl deleted file mode 100755 index 0a59ee4..0000000 --- a/clients/erlang/sync/tests/test_library.erl +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env escript -%% -*- erlang -*- -%% -%% Unit Tests for Un Erlang SDK Library Functions -%% -%% Run with: escript test_library.erl -%% No credentials required - tests pure library functions only. - --mode(compile). - -main([]) -> - io:format("\n\033[34m=== Un Erlang SDK Library Tests ===\033[0m\n\n"), - - Tests = [ - {"version", fun test_version/0}, - {"detect_language", fun test_detect_language/0}, - {"hmac_sign", fun test_hmac_sign/0}, - {"hmac_sign_deterministic", fun test_hmac_sign_deterministic/0}, - {"hmac_sign_different_secrets", fun test_hmac_sign_different_secrets/0} - ], - - Results = run_tests(Tests, []), - {Passed, Failed} = count_results(Results, 0, 0), - Total = length(Results), - - io:format("\n\033[34mResults: ~p/~p passed\033[0m\n", [Passed, Total]), - case Failed > 0 of - true -> - io:format("\033[31m~p test(s) failed\033[0m\n", [Failed]), - halt(1); - false -> - io:format("\033[32mAll tests passed!\033[0m\n") - end. - -run_tests([], Acc) -> - lists:reverse(Acc); -run_tests([{Name, TestFn} | Rest], Acc) -> - Result = try - TestFn(), - io:format("\033[32mPASS\033[0m: ~s\n", [Name]), - pass - catch - _:Error -> - io:format("\033[31mFAIL\033[0m: ~s - ~p\n", [Name, Error]), - fail - end, - run_tests(Rest, [Result | Acc]). - -count_results([], Passed, Failed) -> - {Passed, Failed}; -count_results([pass | Rest], Passed, Failed) -> - count_results(Rest, Passed + 1, Failed); -count_results([fail | Rest], Passed, Failed) -> - count_results(Rest, Passed, Failed + 1). - -%% ============================================================================ -%% Unit Tests -%% ============================================================================ - -test_version() -> - Version = un:version(), - true = is_list(Version), - %% Should be semver format X.Y.Z - [_Major, _Minor, _Patch] = string:tokens(Version, "."), - ok. - -test_detect_language() -> - %% Test common extensions - {ok, "python"} = un:ext_to_lang(".py"), - {ok, "javascript"} = un:ext_to_lang(".js"), - {ok, "go"} = un:ext_to_lang(".go"), - {ok, "rust"} = un:ext_to_lang(".rs"), - {ok, "c"} = un:ext_to_lang(".c"), - {ok, "cpp"} = un:ext_to_lang(".cpp"), - {ok, "java"} = un:ext_to_lang(".java"), - {ok, "ruby"} = un:ext_to_lang(".rb"), - {ok, "bash"} = un:ext_to_lang(".sh"), - {ok, "lua"} = un:ext_to_lang(".lua"), - {ok, "perl"} = un:ext_to_lang(".pl"), - {ok, "php"} = un:ext_to_lang(".php"), - {ok, "haskell"} = un:ext_to_lang(".hs"), - {ok, "ocaml"} = un:ext_to_lang(".ml"), - {ok, "elixir"} = un:ext_to_lang(".ex"), - {ok, "erlang"} = un:ext_to_lang(".erl"), - - %% Test unknown extensions - {error, _} = un:ext_to_lang(".unknown"), - {error, _} = un:ext_to_lang(""), - ok. - -test_hmac_sign() -> - Signature = un:hmac_sign("my_secret", "test message"), - true = is_list(Signature), - 64 = length(Signature), - %% Should be lowercase hex - true = lists:all(fun(C) -> (C >= $0 andalso C =< $9) orelse (C >= $a andalso C =< $f) end, Signature), - ok. - -test_hmac_sign_deterministic() -> - Sig1 = un:hmac_sign("test_secret", "same message"), - Sig2 = un:hmac_sign("test_secret", "same message"), - Sig1 = Sig2, %% Pattern match ensures equality - ok. - -test_hmac_sign_different_secrets() -> - Sig1 = un:hmac_sign("secret1", "test message"), - Sig2 = un:hmac_sign("secret2", "test message"), - true = Sig1 =/= Sig2, %% Different secrets should produce different signatures - ok. diff --git a/clients/forth/sync/src/un.forth b/clients/forth/sync/src/un.forth index 7b10612..e839e10 100644 --- a/clients/forth/sync/src/un.forth +++ b/clients/forth/sync/src/un.forth @@ -97,84 +97,8 @@ find-ext ext-lang ; -\ Account index for --account N flag (-1 = not set) -variable account-index --1 account-index ! - -\ Argument shift: 0 normally, 2 when --account N is prepended -variable arg-shift -0 arg-shift ! - -\ Shifted arg accessor - applies arg-shift to all handler arg accesses -: sarg ( n -- addr len ) - arg-shift @ + arg -; - -\ Buffer for credentials loaded from CSV -256 constant MAX-KEY-LEN -create csv-pk-buf MAX-KEY-LEN allot -variable csv-pk-len -create csv-sk-buf MAX-KEY-LEN allot -variable csv-sk-len - -\ Load credentials from accounts.csv at given index (n) -\ Writes PK/SK to /tmp/unsb_creds.txt; returns true if PK found -: load-accounts-csv-index ( n -- flag ) - dup 0< if drop 0 exit then - \ Write a shell script with the index embedded - s" /tmp/unsb_cred_resolve.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" IDX=" r@ write-file throw - dup 0 <# #s #> r@ write-file throw - s" " r@ write-line throw - s" CNT=-1; PK=''; SK=''" r@ write-line throw - s" for CSV in \"$HOME/.unsandbox/accounts.csv\" \"./accounts.csv\"; do" r@ write-line throw - s" [ -f \"$CSV\" ] || continue" 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" CNT=$((CNT+1))" r@ write-line throw - s" if [ \"$CNT\" -eq \"$IDX\" ]; then" r@ write-line throw - s" PK=$(echo \"$line\" | cut -d',' -f1 | tr -d ' ')" r@ write-line throw - s" SK=$(echo \"$line\" | cut -d',' -f2 | tr -d ' ')" r@ write-line throw - s" break 2" r@ write-line throw - s" fi" r@ write-line throw - s" done < \"$CSV\"" r@ write-line throw - s" done" r@ write-line throw - s" printf '%s\\n%s\\n' \"$PK\" \"$SK\" > /tmp/unsb_creds.txt" r@ write-line throw - s" [ -n \"$PK\" ]" r@ write-line throw - r> close-file throw - drop \ drop index - s" bash /tmp/unsb_cred_resolve.sh" system - 0= if - \ Script exited 0: PK was found; read results - s" /tmp/unsb_creds.txt" r/o open-file - 0= if - >r - csv-pk-buf MAX-KEY-LEN r@ read-line throw - drop csv-pk-len ! - csv-sk-buf MAX-KEY-LEN r@ read-line throw - drop csv-sk-len ! - r> close-file throw - -1 - else - drop 0 - then - else - 0 - then -; - \ Get API keys from environment (HMAC or legacy) : get-public-key ( -- addr len ) - account-index @ dup 0>= if - load-accounts-csv-index if - csv-pk-buf csv-pk-len @ exit - then - s" Error: Account index not found in accounts.csv" type cr - 1 (bye) - else - drop - then s" UNSANDBOX_PUBLIC_KEY" getenv dup 0= if 2drop s" UNSANDBOX_API_KEY" getenv @@ -186,20 +110,6 @@ variable csv-sk-len ; : get-secret-key ( -- addr len ) - account-index @ dup 0>= if - \ CSV already loaded if pk-buf is non-empty - csv-pk-len @ 0> if - drop csv-sk-buf csv-sk-len @ exit - then - \ Load it now - load-accounts-csv-index if - csv-sk-buf csv-sk-len @ exit - then - \ Index not found - error was printed by get-public-key; return empty - s" " exit - else - drop - then s" UNSANDBOX_SECRET_KEY" getenv dup 0= if 2drop s" UNSANDBOX_API_KEY" getenv @@ -445,30 +355,9 @@ variable csv-sk-len s" TIMESTAMP=$(date +%s)" r@ write-line throw s" MESSAGE=\"$TIMESTAMP:DELETE:/services/$SERVICE_ID:\"" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/services/$SERVICE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\")" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw - s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw - s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw - s" echo -n 'Enter OTP: ' >&2" r@ write-line throw - s" read OTP" r@ write-line throw - s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw - s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:DELETE:/services/$SERVICE_ID:\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/services/$SERVICE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\")" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" fi" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw - s" echo -e '\\x1b[32mService destroyed: " r@ write-file throw + s" curl -s -X DELETE https://api.unsandbox.com/services/$SERVICE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mService destroyed: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw - s" else" r@ write-line throw - s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw - s" echo \"$BODY\" >&2" r@ write-line throw - s" exit 1" r@ write-line throw - s" fi" 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 ; @@ -806,7 +695,7 @@ variable csv-sk-len 0 (bye) then - 2 sarg 2dup s" --extend" compare 0= if + 2 arg 2dup s" --extend" compare 0= if 2drop 1 validate-key 0 (bye) @@ -869,7 +758,7 @@ variable csv-sk-len 1 (bye) then - 2 sarg 2dup s" --list" compare 0= if + 2 arg 2dup s" --list" compare 0= if 2drop session-list 0 (bye) then @@ -885,7 +774,7 @@ variable csv-sk-len s" Error: --kill requires session ID" type cr 1 (bye) then - 3 sarg session-kill + 3 arg session-kill 0 (bye) then @@ -925,7 +814,7 @@ variable csv-sk-len 1 (bye) then - 2 sarg 2dup s" --list" compare 0= if + 2 arg 2dup s" --list" compare 0= if 2drop service-list 0 (bye) then @@ -946,7 +835,7 @@ variable csv-sk-len s" Error: --info requires service ID" type cr 1 (bye) then - 3 sarg service-info + 3 arg service-info 0 (bye) then @@ -956,7 +845,7 @@ variable csv-sk-len s" Error: --logs requires service ID" type cr 1 (bye) then - 3 sarg service-logs + 3 arg service-logs 0 (bye) then @@ -966,7 +855,7 @@ variable csv-sk-len s" Error: --freeze requires service ID" type cr 1 (bye) then - 3 sarg service-sleep + 3 arg service-sleep 0 (bye) then @@ -976,7 +865,7 @@ variable csv-sk-len s" Error: --unfreeze requires service ID" type cr 1 (bye) then - 3 sarg service-wake + 3 arg service-wake 0 (bye) then @@ -986,7 +875,7 @@ variable csv-sk-len s" Error: --destroy requires service ID" type cr 1 (bye) then - 3 sarg service-destroy + 3 arg service-destroy 0 (bye) then @@ -1001,13 +890,13 @@ variable csv-sk-len s" Error: --resize requires --vcpu N" type cr 1 (bye) then - 4 sarg 2dup s" --vcpu" compare 0= if + 4 arg 2dup s" --vcpu" compare 0= if 2drop argc @ 6 < if s" Error: --vcpu requires a value" type cr 1 (bye) then - 3 sarg 5 sarg service-resize + 3 arg 5 arg service-resize 0 (bye) then 2dup s" -v" compare 0= if @@ -1016,7 +905,7 @@ variable csv-sk-len s" Error: -v requires a value" type cr 1 (bye) then - 3 sarg 5 sarg service-resize + 3 arg 5 arg service-resize 0 (bye) then 2drop @@ -1030,16 +919,16 @@ variable csv-sk-len s" Error: --dump-bootstrap requires service ID" type cr 1 (bye) then - 3 sarg + 3 arg \ Check for --dump-file argc @ 5 >= if - 4 sarg 2dup s" --dump-file" compare 0= if + 4 arg 2dup s" --dump-file" compare 0= if 2drop argc @ 6 < if s" Error: --dump-file requires filename" type cr 1 (bye) then - 5 sarg + 5 arg else 2drop 0 0 then @@ -1057,13 +946,13 @@ variable csv-sk-len s" Usage: un.forth service env [options]" type cr 1 (bye) then - 3 sarg 2dup s" status" compare 0= if + 3 arg 2dup s" status" compare 0= if 2drop argc @ 5 < if s" Error: status requires service ID" type cr 1 (bye) then - 4 sarg service-env-status + 4 arg service-env-status 0 (bye) then 2dup s" set" compare 0= if @@ -1081,7 +970,7 @@ variable csv-sk-len s" Error: export requires service ID" type cr 1 (bye) then - 4 sarg service-env-export + 4 arg service-env-export 0 (bye) then 2dup s" delete" compare 0= if @@ -1090,7 +979,7 @@ variable csv-sk-len s" Error: delete requires service ID" type cr 1 (bye) then - 4 sarg service-env-delete + 4 arg service-env-delete 0 (bye) then 2drop @@ -1162,7 +1051,7 @@ variable csv-sk-len 0 (bye) then - 2 sarg 2dup s" --json" compare 0= if + 2 arg 2dup s" --json" compare 0= if 2drop 1 languages-list 0 (bye) @@ -1231,30 +1120,9 @@ variable csv-sk-len s" TIMESTAMP=$(date +%s)" r@ write-line throw s" MESSAGE=\"$TIMESTAMP:DELETE:/images/$IMAGE_ID:\"" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/images/$IMAGE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\")" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw - s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw - s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw - s" echo -n 'Enter OTP: ' >&2" r@ write-line throw - s" read OTP" r@ write-line throw - s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw - s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:DELETE:/images/$IMAGE_ID:\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/images/$IMAGE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\")" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" fi" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw - s" echo -e '\\x1b[32mImage deleted: " r@ write-file throw + s" curl -s -X DELETE https://api.unsandbox.com/images/$IMAGE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mImage deleted: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw - s" else" r@ write-line throw - s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw - s" echo \"$BODY\" >&2" r@ write-line throw - s" exit 1" r@ write-line throw - s" fi" 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 ; @@ -1298,32 +1166,11 @@ variable csv-sk-len get-secret-key r@ write-file throw s" '" r@ write-line throw s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/unlock:{}\"" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/unlock:\"" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com/images/$IMAGE_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: application/json' -d '{}')" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw - s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw - s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw - s" echo -n 'Enter OTP: ' >&2" r@ write-line throw - s" read OTP" r@ write-line throw - s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw - s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/unlock:{}\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com/images/$IMAGE_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: application/json' -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\" -d '{}')" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" fi" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw - s" echo -e '\\x1b[32mImage unlocked: " r@ write-file throw + s" curl -s -X POST https://api.unsandbox.com/images/$IMAGE_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mImage unlocked: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw - s" else" r@ write-line throw - s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw - s" echo \"$BODY\" >&2" r@ write-line throw - s" exit 1" r@ write-line throw - s" fi" 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 ; @@ -1459,382 +1306,6 @@ variable csv-sk-len s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh \"$@\" && rm -f /tmp/unsandbox_cmd.sh" system ; -\ Image grant access -: image-grant-access ( image-id-addr image-id-len key-addr key-len -- ) - get-api-key - s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" IMAGE_ID='" r@ write-file throw - 2over r@ write-file throw - s" '" r@ write-line throw - s" TRUSTED_KEY='" 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" BODY='{\"trusted_api_key\":\"'$TRUSTED_KEY'\"}'" r@ write-line throw - s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/grant-access:$BODY\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" curl -s -X POST https://api.unsandbox.com/images/$IMAGE_ID/grant-access -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" >/dev/null && echo -e \"\\x1b[32mAccess granted to $TRUSTED_KEY\\x1b[0m\"" r@ write-line throw - r> close-file throw - 2drop 2drop \ clean up the stack - s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system -; - -\ Image revoke access -: image-revoke-access ( image-id-addr image-id-len key-addr key-len -- ) - get-api-key - s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" IMAGE_ID='" r@ write-file throw - 2over r@ write-file throw - s" '" r@ write-line throw - s" TRUSTED_KEY='" 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" BODY='{\"trusted_api_key\":\"'$TRUSTED_KEY'\"}'" r@ write-line throw - s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:POST:/images/$IMAGE_ID/revoke-access:$BODY\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" curl -s -X POST https://api.unsandbox.com/images/$IMAGE_ID/revoke-access -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" >/dev/null && echo -e \"\\x1b[32mAccess revoked from $TRUSTED_KEY\\x1b[0m\"" r@ write-line throw - r> close-file throw - 2drop 2drop \ clean up the stack - s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system -; - -\ Image list trusted -: image-list-trusted ( addr len -- ) - get-api-key - s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" IMAGE_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:/images/$IMAGE_ID/trusted:\"" 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/images/$IMAGE_ID/trusted -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 -; - -\ Snapshot list -: snapshot-list ( -- ) - 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" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:GET:/snapshots:\"" 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/snapshots -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 -; - -\ Snapshot info -: snapshot-info ( addr len -- ) - get-api-key - s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" SNAPSHOT_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:/snapshots/$SNAPSHOT_ID:\"" 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/snapshots/$SNAPSHOT_ID -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 -; - -\ Snapshot restore -: snapshot-restore ( addr len -- ) - get-api-key - s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" SNAPSHOT_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:/snapshots/$SNAPSHOT_ID/restore:\"" 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/snapshots/$SNAPSHOT_ID/restore -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mSnapshot restored: " 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 -; - -\ Snapshot delete -: snapshot-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" SNAPSHOT_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:/snapshots/$SNAPSHOT_ID:\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/snapshots/$SNAPSHOT_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\")" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw - s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw - s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw - s" echo -n 'Enter OTP: ' >&2" r@ write-line throw - s" read OTP" r@ write-line throw - s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw - s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:DELETE:/snapshots/$SNAPSHOT_ID:\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com/snapshots/$SNAPSHOT_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\")" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" fi" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw - s" echo -e '\\x1b[32mSnapshot deleted: " r@ write-file throw - r@ write-file throw - s" \\x1b[0m'" r@ write-line throw - s" else" r@ write-line throw - s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw - s" echo \"$BODY\" >&2" r@ write-line throw - s" exit 1" r@ write-line throw - s" fi" 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 -; - -\ Snapshot lock -: snapshot-lock ( addr len -- ) - get-api-key - s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" SNAPSHOT_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:/snapshots/$SNAPSHOT_ID/lock:\"" 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/snapshots/$SNAPSHOT_ID/lock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mSnapshot locked: " 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 -; - -\ Snapshot unlock -: snapshot-unlock ( addr len -- ) - get-api-key - s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" SNAPSHOT_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:/snapshots/$SNAPSHOT_ID/unlock:{}\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com/snapshots/$SNAPSHOT_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: application/json' -d '{}')" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" BODY=$(echo \"$RESP\" | sed '$d')" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"428\" ]; then" r@ write-line throw - s" CHALLENGE_ID=$(echo \"$BODY\" | grep -o '\"challenge_id\":\"[^\"]*\"' | cut -d'\"' -f4)" r@ write-line throw - s" echo -e '\\x1b[33mConfirmation required. Check your email for a one-time code.\\x1b[0m' >&2" r@ write-line throw - s" echo -n 'Enter OTP: ' >&2" r@ write-line throw - s" read OTP" r@ write-line throw - s" if [ -z \"$OTP\" ]; then echo 'Error: Operation cancelled' >&2; exit 1; fi" r@ write-line throw - s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:POST:/snapshots/$SNAPSHOT_ID/unlock:{}\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" RESP=$(curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com/snapshots/$SNAPSHOT_ID/unlock -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -H 'Content-Type: application/json' -H \"X-Sudo-OTP: $OTP\" -H \"X-Sudo-Challenge: $CHALLENGE_ID\" -d '{}')" r@ write-line throw - s" HTTP_CODE=$(echo \"$RESP\" | tail -1)" r@ write-line throw - s" fi" r@ write-line throw - s" if [ \"$HTTP_CODE\" = \"200\" ]; then" r@ write-line throw - s" echo -e '\\x1b[32mSnapshot unlocked: " r@ write-file throw - r@ write-file throw - s" \\x1b[0m'" r@ write-line throw - s" else" r@ write-line throw - s" echo -e \"\\x1b[31mError: HTTP $HTTP_CODE\\x1b[0m\" >&2" r@ write-line throw - s" echo \"$BODY\" >&2" r@ write-line throw - s" exit 1" r@ write-line throw - s" fi" 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 -; - -\ Snapshot clone -: snapshot-clone ( addr len -- ) - get-api-key - s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r - s" #!/bin/bash" r@ write-line throw - s" SNAPSHOT_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" CLONE_TYPE='session'; NAME=''; PORTS=''; SHELL=''" r@ write-line throw - s" i=4" 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" --type) ((i++)); CLONE_TYPE=${!i} ;;" r@ write-line throw - s" --name) ((i++)); NAME=${!i} ;;" r@ write-line throw - s" --ports) ((i++)); PORTS=${!i} ;;" r@ write-line throw - s" --shell) ((i++)); SHELL=${!i} ;;" r@ write-line throw - s" esac" r@ write-line throw - s" ((i++))" r@ write-line throw - s" done" r@ write-line throw - s" BODY='{\"clone_type\":\"'$CLONE_TYPE'\"}'" r@ write-line throw - s" [ -n \"$NAME\" ] && BODY=$(echo $BODY | jq --arg n \"$NAME\" '. + {name: $n}')" r@ write-line throw - s" [ -n \"$PORTS\" ] && BODY=$(echo $BODY | jq --arg p \"$PORTS\" '. + {ports: ($p | split(\",\") | map(tonumber))}')" r@ write-line throw - s" [ -n \"$SHELL\" ] && BODY=$(echo $BODY | jq --arg s \"$SHELL\" '. + {shell: $s}')" r@ write-line throw - s" TIMESTAMP=$(date +%s)" r@ write-line throw - s" MESSAGE=\"$TIMESTAMP:POST:/snapshots/$SNAPSHOT_ID/clone:$BODY\"" r@ write-line throw - s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - s" curl -s -X POST https://api.unsandbox.com/snapshots/$SNAPSHOT_ID/clone -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" | jq ." r@ write-line throw - s" echo -e '\\x1b[32mSnapshot cloned\\x1b[0m'" 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 -; - -\ Handle snapshot subcommand -: handle-snapshot ( -- ) - argc @ 3 < if - snapshot-list - 0 (bye) - then - - 2 sarg 2dup s" --list" compare 0= if - 2drop snapshot-list - 0 (bye) - then - - 2dup s" -l" compare 0= if - 2drop snapshot-list - 0 (bye) - then - - 2dup s" --info" compare 0= if - 2drop - argc @ 4 < if - s" Error: --info requires snapshot ID" type cr - 1 (bye) - then - 3 sarg snapshot-info - 0 (bye) - then - - 2dup s" --restore" compare 0= if - 2drop - argc @ 4 < if - s" Error: --restore requires snapshot ID" type cr - 1 (bye) - then - 3 sarg snapshot-restore - 0 (bye) - then - - 2dup s" --delete" compare 0= if - 2drop - argc @ 4 < if - s" Error: --delete requires snapshot ID" type cr - 1 (bye) - then - 3 sarg snapshot-delete - 0 (bye) - then - - 2dup s" --lock" compare 0= if - 2drop - argc @ 4 < if - s" Error: --lock requires snapshot ID" type cr - 1 (bye) - then - 3 sarg snapshot-lock - 0 (bye) - then - - 2dup s" --unlock" compare 0= if - 2drop - argc @ 4 < if - s" Error: --unlock requires snapshot ID" type cr - 1 (bye) - then - 3 sarg snapshot-unlock - 0 (bye) - then - - 2dup s" --clone" compare 0= if - 2drop - argc @ 4 < if - s" Error: --clone requires snapshot ID" type cr - 1 (bye) - then - 3 sarg snapshot-clone - 0 (bye) - then - - 2drop - s" Error: Use --list, --info ID, --restore ID, --delete ID, --lock ID, --unlock ID, or --clone ID" type cr - 1 (bye) -; - \ Handle image subcommand : handle-image ( -- ) argc @ 3 < if @@ -1842,7 +1313,7 @@ variable csv-sk-len 1 (bye) then - 2 sarg 2dup s" --list" compare 0= if + 2 arg 2dup s" --list" compare 0= if 2drop image-list 0 (bye) then @@ -1858,7 +1329,7 @@ variable csv-sk-len s" Error: --info requires image ID" type cr 1 (bye) then - 3 sarg image-info + 3 arg image-info 0 (bye) then @@ -1868,7 +1339,7 @@ variable csv-sk-len s" Error: --delete requires image ID" type cr 1 (bye) then - 3 sarg image-delete + 3 arg image-delete 0 (bye) then @@ -1878,7 +1349,7 @@ variable csv-sk-len s" Error: --lock requires image ID" type cr 1 (bye) then - 3 sarg image-lock + 3 arg image-lock 0 (bye) then @@ -1888,7 +1359,7 @@ variable csv-sk-len s" Error: --unlock requires image ID" type cr 1 (bye) then - 3 sarg image-unlock + 3 arg image-unlock 0 (bye) then @@ -1908,7 +1379,7 @@ variable csv-sk-len s" Error: --visibility requires image ID and mode" type cr 1 (bye) then - 3 sarg 4 sarg image-visibility + 3 arg 4 arg image-visibility 0 (bye) then @@ -1918,7 +1389,7 @@ variable csv-sk-len s" Error: --spawn requires image ID" type cr 1 (bye) then - 3 sarg image-spawn + 3 arg image-spawn 0 (bye) then @@ -1928,7 +1399,7 @@ variable csv-sk-len s" Error: --clone requires image ID" type cr 1 (bye) then - 3 sarg image-clone + 3 arg image-clone 0 (bye) then @@ -1950,21 +1421,8 @@ variable csv-sk-len 1 (bye) then - \ Check for --account N as first argument (before arg-shift is applied) - 1 arg 2dup s" --account" compare 0= if - 2drop - argc @ 3 < if - s" Error: --account requires a numeric argument" type cr - 1 (bye) - then - 2 arg s>number drop account-index ! - 2 arg-shift ! - else - 2drop - then - - \ Get subcommand (adjusted for arg-shift) - 1 arg-shift @ + arg + \ Get first argument (skip gforth and script name) + 1 arg \ Check for subcommands 2dup s" session" compare 0= if @@ -1982,11 +1440,6 @@ variable csv-sk-len 0 (bye) then - 2dup s" snapshot" compare 0= if - 2drop handle-snapshot - 0 (bye) - then - 2dup s" key" compare 0= if 2drop handle-key 0 (bye) diff --git a/clients/fortran/sync/src/un.f90 b/clients/fortran/sync/src/un.f90 index aebdd50..6f597ad 100644 --- a/clients/fortran/sync/src/un.f90 +++ b/clients/fortran/sync/src/un.f90 @@ -64,11 +64,9 @@ ! ./un key [--extend] ! ! Authentication (in priority order): -! 1. --account N flag -> accounts.csv row N (bypasses env vars) -! 2. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY -! 3. Config file: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT) -! 4. ./accounts.csv row 0 -! 5. Legacy: UNSANDBOX_API_KEY (deprecated) +! 1. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY +! 2. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line) +! 3. Legacy: UNSANDBOX_API_KEY (deprecated) ! ! Compile: ! gfortran -o un un.f90 @@ -192,97 +190,32 @@ module unsandbox_sdk contains - !-------------------------------------------------------------------------- - ! Subroutine: load_csv_row - ! Description: Load public_key,secret_key from a CSV file at row_index - ! (0-based, skipping blank lines and '#' comments). - ! - ! Arguments: - ! csv_path - Path to CSV file - ! row_index - Zero-based data row to read - ! public_key - Output: public key (empty if not found) - ! secret_key - Output: secret key (empty if not found) - !-------------------------------------------------------------------------- - subroutine load_csv_row(csv_path, row_index, public_key, secret_key) - character(len=*), intent(in) :: csv_path - integer, intent(in) :: row_index - character(len=*), intent(out) :: public_key, secret_key - character(len=1024) :: line - integer :: unit_num, ios, data_index - logical :: file_exists - - public_key = '' - secret_key = '' - data_index = 0 - - inquire(file=trim(csv_path), exist=file_exists) - if (.not. file_exists) return - - open(newunit=unit_num, file=trim(csv_path), status='old', action='read', iostat=ios) - if (ios /= 0) return - - do - read(unit_num, '(A)', iostat=ios) line - if (ios /= 0) exit - line = adjustl(line) - if (len_trim(line) == 0) cycle - if (line(1:1) == '#') cycle - if (data_index == row_index) then - call parse_csv_line(line, public_key, secret_key) - close(unit_num) - return - end if - data_index = data_index + 1 - end do - close(unit_num) - end subroutine load_csv_row - !-------------------------------------------------------------------------- ! Subroutine: get_credentials ! Description: Get API credentials from environment or config file ! ! Priority order: - ! 1. account_index >= 0 -> accounts.csv row N (bypasses env vars) - ! 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) - ! 3. Config file (~/.unsandbox/accounts.csv row 0 or UNSANDBOX_ACCOUNT) - ! 4. ./accounts.csv row 0 - ! 5. Legacy UNSANDBOX_API_KEY (deprecated) + ! 1. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + ! 2. Config file (~/.unsandbox/accounts.csv) + ! 3. Legacy UNSANDBOX_API_KEY (deprecated) ! ! Arguments: - ! public_key - Output: API public key - ! secret_key - Output: API secret key - ! status - Output: 0 on success, non-zero on error - ! account_index - Optional input: if >= 0, load that CSV row directly + ! public_key - Output: API public key + ! secret_key - Output: API secret key + ! status - Output: 0 on success, non-zero on error !-------------------------------------------------------------------------- - subroutine get_credentials(public_key, secret_key, status, account_index) + subroutine get_credentials(public_key, secret_key, status) character(len=*), intent(out) :: public_key, secret_key integer, intent(out) :: status - integer, intent(in), optional :: account_index - character(len=1024) :: home_dir, accounts_path, api_key, acct_env - integer :: ios, acct_idx, default_index + character(len=1024) :: home_dir, accounts_path, line, api_key + integer :: unit_num, ios + logical :: file_exists status = 0 public_key = '' secret_key = '' - ! Priority 1: account_index >= 0 -> load that CSV row (bypasses env vars) - if (present(account_index)) then - if (account_index >= 0) then - acct_idx = account_index - call get_environment_variable('HOME', home_dir, status=ios) - if (ios == 0) then - accounts_path = trim(home_dir) // '/.unsandbox/accounts.csv' - call load_csv_row(accounts_path, acct_idx, public_key, secret_key) - if (len_trim(public_key) > 0) return - end if - call load_csv_row('accounts.csv', acct_idx, public_key, secret_key) - if (len_trim(public_key) > 0) return - status = 1 - return - end if - end if - - ! Priority 2: Environment variables + ! Priority 1: Environment variables call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=ios) if (ios == 0 .and. len_trim(public_key) > 0) then call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=ios) @@ -291,27 +224,37 @@ contains end if end if - ! Priority 3: ~/.unsandbox/accounts.csv (default row) - call get_environment_variable('UNSANDBOX_ACCOUNT', acct_env, status=ios) - if (ios == 0 .and. len_trim(acct_env) > 0) then - read(acct_env, *, iostat=ios) default_index - if (ios /= 0) default_index = 0 - else - default_index = 0 - end if - + ! Priority 2: Config file call get_environment_variable('HOME', home_dir, status=ios) if (ios == 0) then accounts_path = trim(home_dir) // '/.unsandbox/accounts.csv' - call load_csv_row(accounts_path, default_index, public_key, secret_key) - if (len_trim(public_key) > 0) return + inquire(file=trim(accounts_path), exist=file_exists) + if (file_exists) then + open(newunit=unit_num, file=trim(accounts_path), status='old', & + action='read', iostat=ios) + if (ios == 0) then + do + read(unit_num, '(A)', iostat=ios) line + if (ios /= 0) exit + line = adjustl(line) + if (len_trim(line) == 0) cycle + if (line(1:1) == '#') cycle + ! Parse CSV: public_key,secret_key + call parse_csv_line(line, public_key, secret_key) + if (len_trim(public_key) > 0 .and. len_trim(secret_key) > 0) then + if (public_key(1:8) == 'unsb-pk-' .and. & + secret_key(1:8) == 'unsb-sk-') then + close(unit_num) + return + end if + end if + end do + close(unit_num) + end if + end if end if - ! Priority 4: ./accounts.csv - call load_csv_row('accounts.csv', default_index, public_key, secret_key) - if (len_trim(public_key) > 0) return - - ! Priority 5: Legacy API key + ! Priority 3: Legacy API key call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=ios) if (ios == 0 .and. len_trim(api_key) > 0) then public_key = api_key @@ -907,7 +850,6 @@ program unsandbox_cli character(len=1024) :: filename, language, api_key, ext, arg, subcommand character(len=256) :: session_id, service_id integer :: stat, i, nargs, dot_pos - integer :: account_index ! -1 = not set; >= 0 means use that CSV row logical :: list_flag, is_session, is_service, is_key ! Initialize @@ -918,7 +860,6 @@ program unsandbox_cli is_key = .false. session_id = '' service_id = '' - account_index = -1 ! Get command line arguments count nargs = command_argument_count() @@ -927,17 +868,6 @@ program unsandbox_cli stop 1 end if - ! Pre-scan all arguments for --account N - do i = 1, nargs - 1 - call get_command_argument(i, arg) - if (trim(arg) == '--account') then - call get_command_argument(i + 1, arg) - read(arg, *, iostat=stat) account_index - if (stat /= 0) account_index = -1 - exit - end if - end do - ! Check for subcommands call get_command_argument(1, arg, status=stat) if (trim(arg) == '-h' .or. trim(arg) == '--help') then @@ -961,9 +891,6 @@ program unsandbox_cli else if (trim(arg) == 'image') then call handle_image() stop 0 - else if (trim(arg) == 'snapshot') then - call handle_snapshot() - stop 0 else ! Default execute command filename = trim(arg) @@ -980,7 +907,6 @@ contains write(*, '(A)') 'Usage: ./un [options] ' write(*, '(A)') ' ./un session [options]' write(*, '(A)') ' ./un service [options]' - write(*, '(A)') ' ./un snapshot [options]' write(*, '(A)') ' ./un image [options]' write(*, '(A)') ' ./un key [--extend]' write(*, '(A)') ' ./un languages [--json]' @@ -1011,19 +937,6 @@ contains write(*, '(A)') ' service env export Export vault' write(*, '(A)') ' service env delete Delete vault' write(*, '(A)') '' - write(*, '(A)') 'Snapshot options:' - write(*, '(A)') ' -l, --list List all snapshots' - write(*, '(A)') ' --info ID Get snapshot details' - write(*, '(A)') ' --delete ID Delete a snapshot' - write(*, '(A)') ' --lock ID Lock snapshot' - write(*, '(A)') ' --unlock ID Unlock snapshot' - write(*, '(A)') ' --restore ID Restore from snapshot' - write(*, '(A)') ' --clone ID Clone snapshot (requires --type)' - write(*, '(A)') ' --type TYPE Clone type: session or service' - write(*, '(A)') ' --name NAME Name for cloned resource' - write(*, '(A)') ' --shell SHELL Shell for cloned session' - write(*, '(A)') ' --ports PORTS Ports for cloned service' - write(*, '(A)') '' write(*, '(A)') 'Image options:' write(*, '(A)') ' -l, --list List all images' write(*, '(A)') ' --info ID Get image details' @@ -1044,9 +957,6 @@ contains write(*, '(A)') 'Languages options:' write(*, '(A)') ' --json Output as JSON array' write(*, '(A)') '' - write(*, '(A)') 'Credential options (global):' - write(*, '(A)') ' --account N Use row N from accounts.csv (bypasses env vars)' - write(*, '(A)') '' write(*, '(A)') 'Library Usage:' write(*, '(A)') ' use unsandbox_sdk' write(*, '(A)') ' type(unsandbox_client) :: client' @@ -1076,7 +986,7 @@ contains end if ! Get API keys - call get_credentials(public_key, secret_key, stat, account_index) + call get_credentials(public_key, secret_key, stat) if (stat /= 0) then write(0, '(A)') 'Error: No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY' stop 1 @@ -1147,8 +1057,6 @@ contains input_files = trim(arg) end if end if - else if (trim(arg) == '--account') then - ! already processed in main pre-scan; skip this token and its value else if (len_trim(arg) > 0) then if (arg(1:1) == '-') then @@ -1161,7 +1069,7 @@ contains end do ! Get API keys - call get_credentials(public_key, secret_key, stat, account_index) + call get_credentials(public_key, secret_key, stat) if (stat /= 0) then write(0, '(A)') 'Error: No credentials found' stop 1 @@ -1364,7 +1272,7 @@ contains end do ! Get API keys - call get_credentials(public_key, secret_key, stat, account_index) + call get_credentials(public_key, secret_key, stat) if (stat /= 0) then write(0, '(A)') 'Error: No credentials found' stop 1 @@ -1494,33 +1402,15 @@ contains 'echo -e "\x1b[32mService unfreezing: ', trim(service_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'destroy' .and. len_trim(service_id) > 0) then - write(full_cmd, '(50A)') & + 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); ', & - 'RESP=$(curl -s -w "\n%{http_code}" -X DELETE https://api.unsandbox.com/services/', & + 'curl -s -X DELETE https://api.unsandbox.com/services/', & trim(service_id), ' ', & '-H "Authorization: Bearer ', trim(public_key), '" ', & '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG"); ', & - 'HTTP_CODE=$(echo "$RESP" | tail -n1); ', & - 'BODY=$(echo "$RESP" | sed ''$d''); ', & - 'if [ "$HTTP_CODE" = "428" ]; then ', & - 'CHALLENGE_ID=$(echo "$BODY" | jq -r ".challenge_id // empty"); ', & - 'echo -e "\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m" >&2; ', & - 'echo -n "Enter OTP: " >&2; read OTP; ', & - 'if [ -z "$OTP" ]; then echo -e "\x1b[31mError: Operation cancelled\x1b[0m" >&2; exit 1; fi; ', & - 'TS2=$(date +%s); ', & - 'SIG2=$(echo -n "$TS2:DELETE:/services/', trim(service_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X DELETE https://api.unsandbox.com/services/', trim(service_id), ' ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS2" ', & - '-H "X-Signature: $SIG2" ', & - '-H "X-Sudo-OTP: $OTP" ', & - '-H "X-Sudo-Challenge: $CHALLENGE_ID" >/dev/null && ', & - 'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"; ', & - 'elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then ', & - 'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"; ', & - 'else echo -e "\x1b[31mError: HTTP $HTTP_CODE\x1b[0m" >&2; echo "$BODY" >&2; exit 1; fi' + '-H "X-Signature: $SIG" >/dev/null && ', & + 'echo -e "\x1b[32mService destroyed: ', trim(service_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'resize' .and. len_trim(service_id) > 0) then if (resize_vcpu < 1 .or. resize_vcpu > 8) then @@ -1622,214 +1512,6 @@ contains end if end subroutine handle_service - subroutine handle_snapshot() - character(len=8192) :: full_cmd - character(len=256) :: arg, snapshot_id, operation, clone_type, name, ports, shell - character(len=1024) :: public_key, secret_key - integer :: i, stat - logical :: list_mode - - snapshot_id = '' - operation = '' - clone_type = '' - name = '' - ports = '' - shell = '' - list_mode = .false. - - ! Parse arguments - do i = 2, command_argument_count() - call get_command_argument(i, arg) - if (trim(arg) == '-l' .or. trim(arg) == '--list') then - list_mode = .true. - else if (trim(arg) == '--info') then - operation = 'info' - if (i < command_argument_count()) then - call get_command_argument(i + 1, snapshot_id) - end if - else if (trim(arg) == '--delete') then - operation = 'delete' - if (i < command_argument_count()) then - call get_command_argument(i + 1, snapshot_id) - end if - else if (trim(arg) == '--lock') then - operation = 'lock' - if (i < command_argument_count()) then - call get_command_argument(i + 1, snapshot_id) - end if - else if (trim(arg) == '--unlock') then - operation = 'unlock' - if (i < command_argument_count()) then - call get_command_argument(i + 1, snapshot_id) - end if - else if (trim(arg) == '--restore') then - operation = 'restore' - if (i < command_argument_count()) then - call get_command_argument(i + 1, snapshot_id) - end if - else if (trim(arg) == '--clone') then - operation = 'clone' - if (i < command_argument_count()) then - call get_command_argument(i + 1, snapshot_id) - end if - else if (trim(arg) == '--type') then - if (i < command_argument_count()) then - call get_command_argument(i + 1, clone_type) - end if - else if (trim(arg) == '--name') then - if (i < command_argument_count()) then - call get_command_argument(i + 1, name) - end if - else if (trim(arg) == '--ports') then - if (i < command_argument_count()) then - call get_command_argument(i + 1, ports) - end if - else if (trim(arg) == '--shell') then - if (i < command_argument_count()) then - call get_command_argument(i + 1, shell) - end if - end if - end do - - ! Get credentials - call get_credentials(public_key, secret_key, stat, account_index) - if (stat /= 0) then - write(0, '(A)') 'Error: No credentials found' - stop 1 - end if - - if (list_mode) then - ! List snapshots - write(full_cmd, '(20A)') & - 'TS=$(date +%s); ', & - 'SIG=$(echo -n "$TS:GET:/snapshots:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X GET https://api.unsandbox.com/snapshots ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" | jq .' - call execute_command_line(trim(full_cmd), wait=.true.) - else if (trim(operation) == 'info' .and. len_trim(snapshot_id) > 0) then - ! Get snapshot info - write(full_cmd, '(20A)') & - 'TS=$(date +%s); ', & - 'SIG=$(echo -n "$TS:GET:/snapshots/', trim(snapshot_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X GET https://api.unsandbox.com/snapshots/', trim(snapshot_id), ' ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" | jq .' - call execute_command_line(trim(full_cmd), wait=.true.) - else if (trim(operation) == 'delete' .and. len_trim(snapshot_id) > 0) then - ! Delete snapshot (with sudo) - write(full_cmd, '(30A)') & - 'TS=$(date +%s); ', & - 'SIG=$(echo -n "$TS:DELETE:/snapshots/', trim(snapshot_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'RESP=$(curl -s -w "\n%{http_code}" -X DELETE https://api.unsandbox.com/snapshots/', trim(snapshot_id), ' ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG"); ', & - 'HTTP_CODE=$(echo "$RESP" | tail -1); ', & - 'BODY=$(echo "$RESP" | head -n -1); ', & - 'if [ "$HTTP_CODE" = "428" ]; then ', & - 'OTP=$(echo "$BODY" | jq -r ".otp // empty"); ', & - 'if [ -n "$OTP" ]; then ', & - 'TS2=$(date +%s); ', & - 'SIG2=$(echo -n "$TS2:DELETE:/snapshots/', trim(snapshot_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X DELETE https://api.unsandbox.com/snapshots/', trim(snapshot_id), ' ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS2" ', & - '-H "X-Signature: $SIG2" ', & - '-H "X-Sudo-OTP: $OTP" | jq .; ', & - 'echo -e "\x1b[32mSnapshot deleted\x1b[0m"; fi; ', & - 'else echo "$BODY" | jq .; fi' - call execute_command_line(trim(full_cmd), wait=.true.) - else if (trim(operation) == 'lock' .and. len_trim(snapshot_id) > 0) then - ! Lock snapshot - write(full_cmd, '(20A)') & - 'TS=$(date +%s); ', & - 'SIG=$(echo -n "$TS:POST:/snapshots/', trim(snapshot_id), '/lock:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/lock ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" | jq . && ', & - 'echo -e "\x1b[32mSnapshot locked\x1b[0m"' - call execute_command_line(trim(full_cmd), wait=.true.) - else if (trim(operation) == 'unlock' .and. len_trim(snapshot_id) > 0) then - ! Unlock snapshot (with sudo) - write(full_cmd, '(30A)') & - 'TS=$(date +%s); ', & - 'BODY="{}"; ', & - 'SIG=$(echo -n "$TS:POST:/snapshots/', trim(snapshot_id), '/unlock:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'RESP=$(curl -s -w "\n%{http_code}" -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/unlock ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" ', & - '-H "Content-Type: application/json" ', & - '-d "$BODY"); ', & - 'HTTP_CODE=$(echo "$RESP" | tail -1); ', & - 'BODY_RESP=$(echo "$RESP" | head -n -1); ', & - 'if [ "$HTTP_CODE" = "428" ]; then ', & - 'OTP=$(echo "$BODY_RESP" | jq -r ".otp // empty"); ', & - 'if [ -n "$OTP" ]; then ', & - 'TS2=$(date +%s); ', & - 'SIG2=$(echo -n "$TS2:POST:/snapshots/', trim(snapshot_id), '/unlock:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/unlock ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS2" ', & - '-H "X-Signature: $SIG2" ', & - '-H "X-Sudo-OTP: $OTP" ', & - '-H "Content-Type: application/json" ', & - '-d "$BODY" | jq .; ', & - 'echo -e "\x1b[32mSnapshot unlocked\x1b[0m"; fi; ', & - 'else echo "$BODY_RESP" | jq .; fi' - call execute_command_line(trim(full_cmd), wait=.true.) - else if (trim(operation) == 'restore' .and. len_trim(snapshot_id) > 0) then - ! Restore snapshot - write(full_cmd, '(20A)') & - 'TS=$(date +%s); ', & - 'BODY="{}"; ', & - 'SIG=$(echo -n "$TS:POST:/snapshots/', trim(snapshot_id), '/restore:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/restore ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" ', & - '-H "Content-Type: application/json" ', & - '-d "$BODY" | jq . && ', & - 'echo -e "\x1b[32mSnapshot restored\x1b[0m"' - call execute_command_line(trim(full_cmd), wait=.true.) - else if (trim(operation) == 'clone' .and. len_trim(snapshot_id) > 0) then - ! Clone snapshot - if (len_trim(clone_type) == 0) then - write(0, '(A)') 'Error: --type required for --clone (session or service)' - stop 1 - end if - write(full_cmd, '(30A)') & - 'TS=$(date +%s); ', & - 'BODY=''{"type":"', trim(clone_type), '"' - if (len_trim(name) > 0) then - write(full_cmd, '(A,A)') trim(full_cmd), ',"name":"' // trim(name) // '"' - end if - if (len_trim(ports) > 0) then - write(full_cmd, '(A,A)') trim(full_cmd), ',"ports":[' // trim(ports) // ']' - end if - if (len_trim(shell) > 0) then - write(full_cmd, '(A,A)') trim(full_cmd), ',"shell":"' // trim(shell) // '"' - end if - write(full_cmd, '(A,20A)') trim(full_cmd), '}''; ', & - 'SIG=$(echo -n "$TS:POST:/snapshots/', trim(snapshot_id), '/clone:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X POST https://api.unsandbox.com/snapshots/', trim(snapshot_id), '/clone ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" ', & - '-H "Content-Type: application/json" ', & - '-d "$BODY" | jq . && ', & - 'echo -e "\x1b[32mSnapshot cloned\x1b[0m"' - call execute_command_line(trim(full_cmd), wait=.true.) - else - write(0, '(A)') 'Error: Use --list, --info, --delete, --lock, --unlock, --restore, or --clone' - stop 1 - end if - end subroutine handle_snapshot - subroutine handle_image() character(len=8192) :: full_cmd character(len=256) :: arg, image_id, operation, source_type, name, ports, visibility_mode @@ -1846,7 +1528,7 @@ contains list_mode = .false. ! Get credentials - call get_credentials(public_key, secret_key, stat, account_index) + call get_credentials(public_key, secret_key, stat) if (stat /= 0) then write(0, '(A)') 'Error: No credentials found' stop 1 @@ -1933,29 +1615,13 @@ contains '-H "X-Timestamp: $TS" -H "X-Signature: $SIG" | jq .' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'delete' .and. len_trim(image_id) > 0) then - write(full_cmd, '(50A)') & + write(full_cmd, '(20A)') & 'TS=$(date +%s); ', & 'SIG=$(echo -n "$TS:DELETE:/images/', trim(image_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'RESP=$(curl -s -w "\n%{http_code}" -X DELETE https://api.unsandbox.com/images/', trim(image_id), ' ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" -H "X-Signature: $SIG"); ', & - 'HTTP_CODE=$(echo "$RESP" | tail -n1); ', & - 'BODY=$(echo "$RESP" | sed ''$d''); ', & - 'if [ "$HTTP_CODE" = "428" ]; then ', & - 'CHALLENGE_ID=$(echo "$BODY" | jq -r ".challenge_id // empty"); ', & - 'echo -e "\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m" >&2; ', & - 'echo -n "Enter OTP: " >&2; read OTP; ', & - 'if [ -z "$OTP" ]; then echo -e "\x1b[31mError: Operation cancelled\x1b[0m" >&2; exit 1; fi; ', & - 'TS2=$(date +%s); ', & - 'SIG2=$(echo -n "$TS2:DELETE:/images/', trim(image_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & 'curl -s -X DELETE https://api.unsandbox.com/images/', trim(image_id), ' ', & '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" ', & - '-H "X-Sudo-OTP: $OTP" -H "X-Sudo-Challenge: $CHALLENGE_ID" >/dev/null && ', & - 'echo -e "\x1b[32mImage deleted: ', trim(image_id), '\x1b[0m"; ', & - 'elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then ', & - 'echo -e "\x1b[32mImage deleted: ', trim(image_id), '\x1b[0m"; ', & - 'else echo -e "\x1b[31mError: HTTP $HTTP_CODE\x1b[0m" >&2; echo "$BODY" >&2; exit 1; fi' + '-H "X-Timestamp: $TS" -H "X-Signature: $SIG" >/dev/null; ', & + 'echo -e "\x1b[32mImage deleted: ', trim(image_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'lock' .and. len_trim(image_id) > 0) then write(full_cmd, '(20A)') & @@ -1967,31 +1633,13 @@ contains 'echo -e "\x1b[32mImage locked: ', trim(image_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'unlock' .and. len_trim(image_id) > 0) then - write(full_cmd, '(50A)') & - 'TS=$(date +%s); BODY="{}"; ', & - 'SIG=$(echo -n "$TS:POST:/images/', trim(image_id), '/unlock:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'RESP=$(curl -s -w "\n%{http_code}" -X POST https://api.unsandbox.com/images/', trim(image_id), '/unlock ', & - '-H "Content-Type: application/json" ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" -H "X-Signature: $SIG" -d "$BODY"); ', & - 'HTTP_CODE=$(echo "$RESP" | tail -n1); ', & - 'RESPBODY=$(echo "$RESP" | sed ''$d''); ', & - 'if [ "$HTTP_CODE" = "428" ]; then ', & - 'CHALLENGE_ID=$(echo "$RESPBODY" | jq -r ".challenge_id // empty"); ', & - 'echo -e "\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m" >&2; ', & - 'echo -n "Enter OTP: " >&2; read OTP; ', & - 'if [ -z "$OTP" ]; then echo -e "\x1b[31mError: Operation cancelled\x1b[0m" >&2; exit 1; fi; ', & - 'TS2=$(date +%s); ', & - 'SIG2=$(echo -n "$TS2:POST:/images/', trim(image_id), '/unlock:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + write(full_cmd, '(20A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/images/', trim(image_id), '/unlock:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & 'curl -s -X POST https://api.unsandbox.com/images/', trim(image_id), '/unlock ', & - '-H "Content-Type: application/json" ', & '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS2" -H "X-Signature: $SIG2" ', & - '-H "X-Sudo-OTP: $OTP" -H "X-Sudo-Challenge: $CHALLENGE_ID" -d "$BODY" >/dev/null && ', & - 'echo -e "\x1b[32mImage unlocked: ', trim(image_id), '\x1b[0m"; ', & - 'elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "204" ]; then ', & - 'echo -e "\x1b[32mImage unlocked: ', trim(image_id), '\x1b[0m"; ', & - 'else echo -e "\x1b[31mError: HTTP $HTTP_CODE\x1b[0m" >&2; echo "$RESPBODY" >&2; exit 1; fi' + '-H "X-Timestamp: $TS" -H "X-Signature: $SIG" >/dev/null; ', & + 'echo -e "\x1b[32mImage unlocked: ', trim(image_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'publish' .and. len_trim(image_id) > 0) then if (len_trim(source_type) == 0) then @@ -2121,7 +1769,7 @@ contains end do ! Get API key - call get_credentials(public_key, secret_key, stat, account_index) + call get_credentials(public_key, secret_key, stat) if (stat /= 0) then write(0, '(A)') 'Error: No credentials found' stop 1 @@ -2218,7 +1866,7 @@ contains end do ! Get API keys - call get_credentials(public_key, secret_key, stat, account_index) + call get_credentials(public_key, secret_key, stat) if (stat /= 0) then write(0, '(A)') 'Error: No credentials found' stop 1 diff --git a/clients/fortran/tests/test_un.sh b/clients/fortran/tests/test_un.sh deleted file mode 100755 index f06dde1..0000000 --- a/clients/fortran/tests/test_un.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/bin/bash -# This is free software for the public good of a permacomputer hosted at -# permacomputer.com, an always-on computer by the people, for the people. -# One which is durable, easy to repair, & distributed like tap water -# for machine learning intelligence. -# -# The permacomputer is community-owned infrastructure optimized around -# four values: -# -# TRUTH First principles, math & science, open source code freely distributed -# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -# HARMONY Minimal waste, self-renewing systems with diverse thriving connections -# LOVE Be yourself without hurting others, cooperation through natural law -# -# This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -# Code is seeds to sprout on any abandoned technology. - -# Test suite for Fortran Unsandbox SDK -# Run: bash tests/test_un.sh - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SDK_DIR="$SCRIPT_DIR/../sync/src" -SOURCE="$SDK_DIR/un.f90" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -NC='\033[0m' - -TESTS_RUN=0 -TESTS_PASSED=0 - -# Test helper -test_that() { - local description="$1" - local test_cmd="$2" - TESTS_RUN=$((TESTS_RUN + 1)) - - if eval "$test_cmd" >/dev/null 2>&1; then - echo -e "[${GREEN}PASS${NC}] $description" - TESTS_PASSED=$((TESTS_PASSED + 1)) - return 0 - else - echo -e "[${RED}FAIL${NC}] $description" - return 1 - fi -} - -echo "" -echo "=== Source File ===" -test_that "Source file exists" "[ -f '$SOURCE' ]" - -echo "" -echo "=== Command Handlers ===" -test_that "Session handler defined" "grep -q 'subroutine handle_session' '$SOURCE'" -test_that "Service handler defined" "grep -q 'subroutine handle_service' '$SOURCE'" -test_that "Snapshot handler defined" "grep -q 'subroutine handle_snapshot' '$SOURCE'" -test_that "Image handler defined" "grep -q 'subroutine handle_image' '$SOURCE'" -test_that "Key handler defined" "grep -q 'subroutine handle_key' '$SOURCE'" -test_that "Languages handler defined" "grep -q 'subroutine handle_languages' '$SOURCE'" - -echo "" -echo "=== Command Dispatch ===" -test_that "Session dispatch" "grep -q \"trim(arg) == 'session'\" '$SOURCE'" -test_that "Service dispatch" "grep -q \"trim(arg) == 'service'\" '$SOURCE'" -test_that "Snapshot dispatch" "grep -q \"trim(arg) == 'snapshot'\" '$SOURCE'" -test_that "Image dispatch" "grep -q \"trim(arg) == 'image'\" '$SOURCE'" -test_that "Key dispatch" "grep -q \"trim(arg) == 'key'\" '$SOURCE'" -test_that "Languages dispatch" "grep -q \"trim(arg) == 'languages'\" '$SOURCE'" - -echo "" -echo "=== Snapshot Operations ===" -test_that "Snapshot --list" "grep -q \"operation = 'list'\" '$SOURCE' || grep -q 'list_mode' '$SOURCE'" -test_that "Snapshot --info" "grep -q \"operation = 'info'\" '$SOURCE'" -test_that "Snapshot --delete" "grep -q \"operation = 'delete'\" '$SOURCE'" -test_that "Snapshot --lock" "grep -q \"operation = 'lock'\" '$SOURCE'" -test_that "Snapshot --unlock" "grep -q \"operation = 'unlock'\" '$SOURCE'" -test_that "Snapshot --restore" "grep -q \"operation = 'restore'\" '$SOURCE'" -test_that "Snapshot --clone" "grep -q \"operation = 'clone'\" '$SOURCE'" - -echo "" -echo "=== Help Text ===" -test_that "Help shows snapshot" "grep -q 'snapshot' '$SOURCE'" -test_that "Help shows --list" "grep -q '\\-\\-list' '$SOURCE'" -test_that "Help shows --info" "grep -q '\\-\\-info' '$SOURCE'" -test_that "Help shows --restore" "grep -q '\\-\\-restore' '$SOURCE'" -test_that "Help shows --clone" "grep -q '\\-\\-clone' '$SOURCE'" - -echo "" -echo "=== HMAC Authentication ===" -test_that "Uses openssl for HMAC" "grep -q 'openssl dgst -sha256 -hmac' '$SOURCE'" -test_that "Has X-Signature header" "grep -q 'X-Signature' '$SOURCE'" -test_that "Has X-Timestamp header" "grep -q 'X-Timestamp' '$SOURCE'" - -echo "" -echo "=== Sudo OTP Handling ===" -test_that "Handles 428 response" "grep -q '428' '$SOURCE'" -test_that "Has X-Sudo-OTP header" "grep -q 'X-Sudo-OTP' '$SOURCE'" - -echo "" -echo "=== Module Structure ===" -test_that "Has unsandbox_sdk module" "grep -q 'module unsandbox_sdk' '$SOURCE'" -test_that "Has unsandbox_client type" "grep -q 'type :: unsandbox_client' '$SOURCE'" -test_that "Has execution_result type" "grep -q 'type :: execution_result' '$SOURCE'" - -echo "" -echo "=== Summary ===" -echo "Tests passed: $TESTS_PASSED / $TESTS_RUN" - -if [ $TESTS_PASSED -eq $TESTS_RUN ]; then - echo -e "${GREEN}All tests passed!${NC}" - exit 0 -else - echo -e "${RED}Some tests failed!${NC}" - exit 1 -fi diff --git a/clients/fsharp/sync/src/un.fs b/clients/fsharp/sync/src/un.fs index 5752bd1..b1087d7 100644 --- a/clients/fsharp/sync/src/un.fs +++ b/clients/fsharp/sync/src/un.fs @@ -77,7 +77,6 @@ type Args = { mutable Command: string option mutable SourceFile: string option mutable ApiKey: string option - mutable AccountIndex: int option mutable Network: string option mutable Vcpu: int Env: ResizeArray @@ -145,70 +144,19 @@ type Args = { mutable ImagePorts: string option } -let loadCredentialsFromCsv (csvPath: string) (accountIndex: int) = - if File.Exists(csvPath) then - try - let lines = File.ReadAllLines(csvPath) - let accounts = - lines - |> Array.map (fun l -> l.Trim()) - |> Array.filter (fun l -> l.Length > 0 && not (l.StartsWith("#"))) - |> Array.choose (fun line -> - let parts = line.Split(',') - if parts.Length >= 2 then - let pk = parts.[0].Trim() - let sk = parts.[1].Trim() - if pk.Length > 8 && sk.Length > 8 then Some (pk, sk) - else None - else None) - if accountIndex < accounts.Length then Some accounts.[accountIndex] - else None - with _ -> None - else None +let getApiKeys (argsKey: string option) = + let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") + let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") -let getApiKeys (argsKey: string option) (accountIndex: int option) = - let home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) - let homeCsv = Path.Combine(home, ".unsandbox", "accounts.csv") - - // Priority 1: --account N -> accounts.csv row N (bypasses env vars) - match accountIndex with - | Some idx -> - let creds = - match loadCredentialsFromCsv homeCsv idx with - | Some c -> Some c - | None -> loadCredentialsFromCsv "accounts.csv" idx - match creds with - | Some (pk, sk) -> (pk, sk) - | None -> - eprintfn "%sError: No credentials found for account index %d in accounts.csv%s" red idx reset + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if String.IsNullOrEmpty(publicKey) || String.IsNullOrEmpty(secretKey) then + let legacyKey = match argsKey with | Some k -> k | None -> Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY") + if String.IsNullOrEmpty(legacyKey) then + eprintfn "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set%s" red reset exit 1 - | None -> - let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") - let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") - - // Priority 2: environment variables - if not (String.IsNullOrEmpty(publicKey)) && not (String.IsNullOrEmpty(secretKey)) then - (publicKey, secretKey) - else - // Fall back to legacy UNSANDBOX_API_KEY - let legacyKey = match argsKey with | Some k -> k | None -> Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY") - if not (String.IsNullOrEmpty(legacyKey)) then - (legacyKey, null) - else - // Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index) - let defaultIndex = - let envIdx = Environment.GetEnvironmentVariable("UNSANDBOX_ACCOUNT") - if String.IsNullOrEmpty(envIdx) then 0 - else match System.Int32.TryParse(envIdx) with | (true, n) -> n | _ -> 0 - let creds = - match loadCredentialsFromCsv homeCsv defaultIndex with - | Some c -> Some c - | None -> loadCredentialsFromCsv "accounts.csv" defaultIndex - match creds with - | Some (pk, sk) -> (pk, sk) - | None -> - eprintfn "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set%s" red reset - exit 1 + (legacyKey, null) + else + (publicKey, secretKey) let detectLanguage (filename: string) = let dotIndex = filename.LastIndexOf('.') @@ -324,10 +272,7 @@ let parseJson (json: string) = result |> Seq.map (fun (k, v) -> k, v) |> Map.ofSeq -// Custom exception for HTTP errors with status code -exception HttpException of int * string - -let apiRequestWithHeaders (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) (sudoOtp: string option) (sudoChallengeId: string option) = +let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest @@ -353,15 +298,6 @@ let apiRequestWithHeaders (endpoint: string) (method: string) (data: (string * o // Legacy API key authentication request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) - // Add sudo OTP headers if provided - match sudoOtp with - | Some otp -> request.Headers.Add("X-Sudo-OTP", otp) - | None -> () - - match sudoChallengeId with - | Some cid -> request.Headers.Add("X-Sudo-Challenge", cid) - | None -> () - match data with | Some d -> let bytes = Encoding.UTF8.GetBytes(body) @@ -386,13 +322,6 @@ let apiRequestWithHeaders (endpoint: string) (method: string) (data: (string * o else ex.Message - let statusCode = - if ex.Response <> null then - let httpResponse = ex.Response :?> HttpWebResponse - int httpResponse.StatusCode - else - 0 - // Check for clock drift error if errorMsg.Contains("timestamp") && (errorMsg.Contains("401") || errorMsg.Contains("expired") || errorMsg.Contains("invalid")) then eprintfn "%sError: Request timestamp expired (must be within 5 minutes of server time)%s" red reset @@ -403,35 +332,6 @@ let apiRequestWithHeaders (endpoint: string) (method: string) (data: (string * o eprintfn " Windows: w32tm /resync%s" reset exit 1 - raise (HttpException(statusCode, errorMsg)) - -let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = - apiRequestWithHeaders endpoint method data publicKey secretKey None None - -// Handle 428 sudo OTP challenge - prompts user for OTP and retries the request -let handleSudoChallenge (responseBody: string) (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = - let challengeId = extractJsonValue responseBody "challenge_id" - - eprintfn "%sConfirmation required. Check your email for a one-time code.%s" yellow reset - eprintf "Enter OTP: " - - let otp = Console.ReadLine() - if String.IsNullOrEmpty(otp) then - failwith "Operation cancelled" - - let otp = otp.Trim() - - // Retry the request with sudo headers - apiRequestWithHeaders endpoint method data publicKey secretKey (Some otp) challengeId - -// Wrapper for destructive operations that may require 428 sudo OTP -let apiRequestWithSudo (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = - try - apiRequest endpoint method data publicKey secretKey - with - | HttpException(428, responseBody) -> - handleSudoChallenge responseBody endpoint method data publicKey secretKey - | HttpException(_, errorMsg) -> failwithf "HTTP error - %s" errorMsg let apiRequestPatch (endpoint: string) (data: (string * obj) list) (publicKey: string) (secretKey: string) = @@ -683,7 +583,7 @@ let cmdServiceEnv (args: Args) (publicKey: string) (secretKey: string) = exit 1 let cmdExecute (args: Args) = - let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex + let (publicKey, secretKey) = getApiKeys args.ApiKey let code = File.ReadAllText(args.SourceFile.Value) let language = detectLanguage args.SourceFile.Value @@ -732,7 +632,7 @@ let cmdExecute (args: Args) = exit exitCode let cmdSession (args: Args) = - let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex + let (publicKey, secretKey) = getApiKeys args.ApiKey if args.SessionSnapshot.IsSome then let mutable payload = [] @@ -792,14 +692,14 @@ let openBrowser (url: string) = eprintfn "%sError opening browser: %s%s" red ex.Message reset let cmdKey (args: Args) = - let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex + let apiKey = getApiKey args.ApiKey ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls let request = WebRequest.Create(portalBase + "/keys/validate") :?> HttpWebRequest request.Method <- "POST" request.ContentType <- "application/json" - request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) + request.Headers.Add("Authorization", sprintf "Bearer %s" apiKey) request.Timeout <- 30000 try @@ -808,7 +708,7 @@ let cmdKey (args: Args) = let responseText = reader.ReadToEnd() let result = parseJson responseText - let resultPublicKey = match result.TryFind "public_key" with | Some v -> v.ToString() | None -> "N/A" + let publicKey = match result.TryFind "public_key" with | Some v -> v.ToString() | None -> "N/A" let tier = match result.TryFind "tier" with | Some v -> v.ToString() | None -> "N/A" let status = match result.TryFind "status" with | Some v -> v.ToString() | None -> "N/A" let expiresAt = match result.TryFind "expires_at" with | Some v -> v.ToString() | None -> "N/A" @@ -818,20 +718,20 @@ let cmdKey (args: Args) = let concurrency = match result.TryFind "concurrency" with | Some v -> v.ToString() | None -> "N/A" let expired = match result.TryFind "expired" with | Some v -> v.ToString() = "True" | None -> false - if args.KeyExtend && resultPublicKey <> "N/A" then - let extendUrl = sprintf "%s/keys/extend?pk=%s" portalBase resultPublicKey + if args.KeyExtend && publicKey <> "N/A" then + let extendUrl = sprintf "%s/keys/extend?pk=%s" portalBase publicKey printfn "%sOpening browser to extend key...%s" blue reset openBrowser extendUrl elif expired then printfn "%sExpired%s" red reset - printfn "Public Key: %s" resultPublicKey + printfn "Public Key: %s" publicKey printfn "Tier: %s" tier printfn "Expired: %s" expiresAt printfn "%sTo renew: Visit https://unsandbox.com/keys/extend%s" yellow reset exit 1 else printfn "%sValid%s" green reset - printfn "Public Key: %s" resultPublicKey + printfn "Public Key: %s" publicKey printfn "Tier: %s" tier printfn "Status: %s" status printfn "Expires: %s" expiresAt @@ -869,7 +769,7 @@ let cmdKey (args: Args) = exit 1 let cmdLanguages (args: Args) = - let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex + let (publicKey, secretKey) = getApiKeys args.ApiKey // Try to load from cache first let cachedResponse = loadLanguagesCache () @@ -924,7 +824,7 @@ let cmdLanguages (args: Args) = printfn "%s" lang let cmdImage (args: Args) = - let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex + let (publicKey, secretKey) = getApiKeys args.ApiKey if args.ImageList then let result = apiRequest "/images" "GET" None publicKey secretKey @@ -933,13 +833,13 @@ let cmdImage (args: Args) = let result = apiRequest (sprintf "/images/%s" args.ImageInfo.Value) "GET" None publicKey secretKey printfn "%s" (toJson (box result)) elif args.ImageDelete.IsSome then - let result = apiRequestWithSudo (sprintf "/images/%s" args.ImageDelete.Value) "DELETE" None publicKey secretKey + let result = apiRequest (sprintf "/images/%s" args.ImageDelete.Value) "DELETE" None publicKey secretKey printfn "%sImage deleted: %s%s" green args.ImageDelete.Value reset elif args.ImageLock.IsSome then let result = apiRequest (sprintf "/images/%s/lock" args.ImageLock.Value) "POST" None publicKey secretKey printfn "%sImage locked: %s%s" green args.ImageLock.Value reset elif args.ImageUnlock.IsSome then - let result = apiRequestWithSudo (sprintf "/images/%s/unlock" args.ImageUnlock.Value) "POST" None publicKey secretKey + let result = apiRequest (sprintf "/images/%s/unlock" args.ImageUnlock.Value) "POST" None publicKey secretKey printfn "%sImage unlocked: %s%s" green args.ImageUnlock.Value reset elif args.ImagePublish.IsSome then if args.ImageSourceType.IsNone then @@ -980,7 +880,7 @@ let cmdImage (args: Args) = exit 1 let cmdSnapshot (args: Args) = - let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex + let (publicKey, secretKey) = getApiKeys args.ApiKey if args.SnapshotList then let result = apiRequest "/snapshots" "GET" None publicKey secretKey @@ -989,7 +889,7 @@ let cmdSnapshot (args: Args) = let result = apiRequest (sprintf "/snapshots/%s" args.SnapshotInfo.Value) "GET" None publicKey secretKey printfn "%s" (toJson (box result)) elif args.SnapshotDelete.IsSome then - let result = apiRequestWithSudo (sprintf "/snapshots/%s" args.SnapshotDelete.Value) "DELETE" None publicKey secretKey + let result = apiRequest (sprintf "/snapshots/%s" args.SnapshotDelete.Value) "DELETE" None publicKey secretKey printfn "%sSnapshot deleted: %s%s" green args.SnapshotDelete.Value reset elif args.SnapshotClone.IsSome then if args.SnapshotType.IsNone then @@ -1011,7 +911,7 @@ let cmdSnapshot (args: Args) = exit 1 let cmdService (args: Args) = - let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex + let (publicKey, secretKey) = getApiKeys args.ApiKey // Handle env subcommand if args.EnvAction.IsSome then @@ -1054,7 +954,7 @@ let cmdService (args: Args) = let result = apiRequest (sprintf "/services/%s/unfreeze" args.ServiceWake.Value) "POST" None publicKey secretKey printfn "%sService unfreezing: %s%s" green args.ServiceWake.Value reset elif args.ServiceDestroy.IsSome then - let result = apiRequestWithSudo (sprintf "/services/%s" args.ServiceDestroy.Value) "DELETE" None publicKey secretKey + let result = apiRequest (sprintf "/services/%s" args.ServiceDestroy.Value) "DELETE" None publicKey secretKey printfn "%sService destroyed: %s%s" green args.ServiceDestroy.Value reset elif args.ServiceResize.IsSome then if args.Vcpu <= 0 then @@ -1159,7 +1059,6 @@ let parseArgs (argv: string[]) = Command = None SourceFile = None ApiKey = None - AccountIndex = None Network = None Vcpu = 0 Env = ResizeArray() @@ -1245,13 +1144,6 @@ let parseArgs (argv: string[]) = i <- i + 1 args.EnvTarget <- Some argv.[i] | "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i] - | "--account" -> - i <- i + 1 - match System.Int32.TryParse(argv.[i]) with - | (true, n) -> args.AccountIndex <- Some n - | _ -> - eprintfn "Error: --account requires an integer argument" - Environment.Exit(1) | "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i] | "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int argv.[i] | "-e" | "--env" -> i <- i + 1; args.Env.Add(argv.[i]) @@ -1418,7 +1310,6 @@ let printHelp () = printfn " -n MODE Network mode (zerotrust/semitrusted)" printfn " -v N vCPU count (1-8)" printfn " -k KEY API key" - printfn " --account N Use accounts.csv row N (bypasses env vars)" printfn "" printfn "Session options:" printfn " --list List active sessions" diff --git a/clients/fsharp/tests/UnsandboxTests.fs b/clients/fsharp/tests/UnsandboxTests.fs deleted file mode 100644 index 622cb06..0000000 --- a/clients/fsharp/tests/UnsandboxTests.fs +++ /dev/null @@ -1,265 +0,0 @@ -// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY -// Unit and Functional Tests for Unsandbox F# SDK - -open System -open System.Collections.Generic -open System.Security.Cryptography -open System.Text - -// Source the main module (when running as script) -// For compiled tests, include un.fs in the project - -/// Unit tests for the Unsandbox SDK library functions. -module UnitTests = - let run () = - printfn "=== Unsandbox F# SDK Unit Tests ===\n" - - testDetectLanguage () - testHmacSign () - testExtensionMap () - - printfn "\n=== Unit Tests Complete ===" - - and testDetectLanguage () = - printf "DetectLanguage: " - let tests = [ - ("test.py", "python") - ("script.js", "javascript") - ("main.go", "go") - ("app.rs", "rust") - ("Program.cs", "csharp") - ("Module.fs", "fsharp") - ] - - let mutable passed = 0 - for (filename, expected) in tests do - let ext = filename.Substring(filename.LastIndexOf('.')) - match Map.tryFind ext extMap with - | Some lang when lang = expected -> passed <- passed + 1 - | Some lang -> printf "[FAIL: %s -> %s, expected %s] " filename lang expected - | None -> printf "[FAIL: %s -> None, expected %s] " filename expected - - if passed = tests.Length then - printfn "PASS (%d/%d)" passed tests.Length - else - printfn "FAIL (%d/%d)" passed tests.Length - - and testHmacSign () = - printf "HmacSign: " - // Test vector: HMAC-SHA256("key", "message") - use hmac = new HMACSHA256(Encoding.UTF8.GetBytes("key")) - let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes("message")) - let signature = BitConverter.ToString(hash).Replace("-", "").ToLower() - let expected = "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a" - if signature = expected then - printfn "PASS" - else - printfn "FAIL (got %s, expected %s)" signature expected - - and testExtensionMap () = - printf "ExtensionMap: " - let tests = [ - (".py", "python") - (".js", "javascript") - (".go", "go") - (".rs", "rust") - (".fs", "fsharp") - ] - - let mutable passed = 0 - for (ext, expected) in tests do - match Map.tryFind ext extMap with - | Some lang when lang = expected -> passed <- passed + 1 - | _ -> () - - if passed = tests.Length then - printfn "PASS (%d/%d)" passed tests.Length - else - printfn "FAIL (%d/%d)" passed tests.Length - -/// Functional tests that require API credentials. -module FunctionalTests = - let run () = - let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") - let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") - - if String.IsNullOrEmpty(publicKey) || String.IsNullOrEmpty(secretKey) then - printfn "=== Functional Tests Skipped (no API credentials) ===" - else - printfn "=== Unsandbox F# SDK Functional Tests ===\n" - - testValidateKeys () - testGetLanguages () - testExecute () - testSessionList () - testServiceList () - testSnapshotList () - testImageList () - - printfn "\n=== Functional Tests Complete ===" - - and testValidateKeys () = - printf "ValidateKeys: " - try - let result = apiRequest "/keys/validate" "POST" None publicKey secretKey - match result.TryFind "valid" with - | Some v when v.ToString() = "True" -> - let tier = match result.TryFind "tier" with | Some t -> t.ToString() | None -> "N/A" - printfn "PASS (tier: %s)" tier - | _ -> printfn "FAIL" - with ex -> - printfn "FAIL (%s)" ex.Message - - and testGetLanguages () = - printf "GetLanguages: " - try - let result = apiRequest "/languages" "GET" None publicKey secretKey - match result.TryFind "languages" with - | Some langs -> printfn "PASS (languages received)" - | None -> printfn "FAIL (no languages in response)" - with ex -> - printfn "FAIL (%s)" ex.Message - - and testExecute () = - printf "Execute: " - try - let payload = [("language", box "python"); ("code", box "print('hello from F# SDK')")] - let result = apiRequest "/execute" "POST" (Some payload) publicKey secretKey - match result.TryFind "stdout" with - | Some stdout when stdout.ToString().Contains("hello") -> printfn "PASS" - | _ -> printfn "FAIL (no expected output)" - with ex -> - printfn "FAIL (%s)" ex.Message - - and testSessionList () = - printf "SessionList: " - try - let result = apiRequest "/sessions" "GET" None publicKey secretKey - printfn "PASS (sessions endpoint responded)" - with ex -> - printfn "FAIL (%s)" ex.Message - - and testServiceList () = - printf "ServiceList: " - try - let result = apiRequest "/services" "GET" None publicKey secretKey - printfn "PASS (services endpoint responded)" - with ex -> - printfn "FAIL (%s)" ex.Message - - and testSnapshotList () = - printf "SnapshotList: " - try - let result = apiRequest "/snapshots" "GET" None publicKey secretKey - printfn "PASS (snapshots endpoint responded)" - with ex -> - printfn "FAIL (%s)" ex.Message - - and testImageList () = - printf "ImageList: " - try - let result = apiRequest "/images" "GET" None publicKey secretKey - printfn "PASS (images endpoint responded)" - with ex -> - printfn "FAIL (%s)" ex.Message - - // Get the API keys from environment - and publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") - and secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") - -// Extension map (duplicated here for standalone testing) -let extMap = - Map.ofList [ - (".py", "python"); (".js", "javascript"); (".ts", "typescript") - (".rb", "ruby"); (".php", "php"); (".pl", "perl"); (".lua", "lua") - (".sh", "bash"); (".go", "go"); (".rs", "rust"); (".c", "c") - (".cpp", "cpp"); (".cc", "cpp"); (".cxx", "cpp") - (".java", "java"); (".kt", "kotlin"); (".cs", "csharp"); (".fs", "fsharp") - (".hs", "haskell"); (".ml", "ocaml"); (".clj", "clojure"); (".scm", "scheme") - (".lisp", "commonlisp"); (".erl", "erlang"); (".ex", "elixir"); (".exs", "elixir") - (".jl", "julia"); (".r", "r"); (".R", "r"); (".cr", "crystal") - (".d", "d"); (".nim", "nim"); (".zig", "zig"); (".v", "v") - (".dart", "dart"); (".groovy", "groovy"); (".scala", "scala") - (".f90", "fortran"); (".f95", "fortran"); (".cob", "cobol") - (".pro", "prolog"); (".forth", "forth"); (".4th", "forth") - (".tcl", "tcl"); (".raku", "raku"); (".m", "objc") - ] - -// API request function (simplified for testing) -open System.Net -open System.IO - -let apiBase = "https://api.unsandbox.com" - -let toJson (obj: obj) = - match obj with - | :? string as s -> sprintf "\"%s\"" (s.Replace("\\", "\\\\").Replace("\"", "\\\"")) - | :? int as i -> i.ToString() - | :? bool as b -> b.ToString().ToLower() - | :? (string * obj) list as lst -> - let entries = lst |> List.map (fun (k, v) -> sprintf "\"%s\":%s" k (toJson v)) |> String.concat "," - sprintf "{%s}" entries - | _ -> sprintf "\"%s\"" (obj.ToString()) - -let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = - ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls - - let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest - request.Method <- method - request.ContentType <- "application/json" - request.Timeout <- 300000 - - let body = match data with | Some d -> toJson (box d) | None -> "" - - if not (String.IsNullOrEmpty(secretKey)) then - let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - let message = sprintf "%d:%s:%s:%s" timestamp method endpoint body - - use hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)) - let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)) - let signature = BitConverter.ToString(hash).Replace("-", "").ToLower() - - request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) - request.Headers.Add("X-Timestamp", timestamp.ToString()) - request.Headers.Add("X-Signature", signature) - else - request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) - - match data with - | Some d -> - let bytes = Encoding.UTF8.GetBytes(body) - request.ContentLength <- int64 bytes.Length - use stream = request.GetRequestStream() - stream.Write(bytes, 0, bytes.Length) - | None -> () - - use response = request.GetResponse() :?> HttpWebResponse - use reader = new StreamReader(response.GetResponseStream()) - let responseText = reader.ReadToEnd() - - // Simple JSON parsing - return as string map - Map.empty - |> fun m -> if responseText.Contains("\"valid\"") then Map.add "valid" (box true) m else m - |> fun m -> if responseText.Contains("\"tier\"") then Map.add "tier" (box "unknown") m else m - |> fun m -> if responseText.Contains("\"languages\"") then Map.add "languages" (box []) m else m - |> fun m -> if responseText.Contains("\"stdout\"") then - let start = responseText.IndexOf("\"stdout\":\"") + 10 - let endIdx = responseText.IndexOf("\"", start) - if start > 10 && endIdx > start then - Map.add "stdout" (box (responseText.Substring(start, endIdx - start))) m - else m - else m - -[] -let main argv = - try - printfn "Unsandbox F# SDK Tests" - printfn "======================\n" - - UnitTests.run () - printfn "" - FunctionalTests.run () - 0 - with ex -> - eprintfn "Test error: %s" ex.Message - 1 diff --git a/clients/go/Makefile b/clients/go/Makefile index 637f44c..11b3472 100644 --- a/clients/go/Makefile +++ b/clients/go/Makefile @@ -5,13 +5,17 @@ # - async/ : Asynchronous Go SDK (goroutines/channels) # # Usage: -# make test # Run all 4 test modes (auto-detects go binary) +# make # Build all +# make test # Run all 4 test modes # make test-cli # CLI mode only # make test-library # Library mode only +# make test-integration # Integration mode only +# make test-functional # Functional mode only # make build # Build binaries # make clean # Remove build artifacts # -# The Makefile auto-detects go from PATH, ~/.local/go, /usr/local/go. +# Dependencies: +# Go 1.18+ (for generics support) .PHONY: all build test test-cli test-library test-integration test-functional .PHONY: test-sync test-async clean help examples fmt vet @@ -21,11 +25,8 @@ ROOT_DIR := $(shell cd ../.. && pwd) SYNC_DIR := sync ASYNC_DIR := async -# Auto-detect Go binary: PATH first, then common install locations -GO := $(or $(shell which go 2>/dev/null), \ - $(shell test -x $(HOME)/.local/go/bin/go && echo $(HOME)/.local/go/bin/go), \ - $(shell test -x /usr/local/go/bin/go && echo /usr/local/go/bin/go), \ - $(shell test -x $(HOME)/go/bin/go && echo $(HOME)/go/bin/go)) +# Go settings +GO := go GOFLAGS := -v # Colors @@ -39,14 +40,6 @@ NC := \033[0m help: @echo "UN Go Client - Build and Test" @echo "" - @if [ -n "$(GO)" ]; then \ - echo " Go binary: $(GO)"; \ - $(GO) version; \ - else \ - echo " $(RED)✗ Go binary not found$(NC)"; \ - echo " Install Go or set PATH to include go binary"; \ - fi - @echo "" @echo "Build:" @echo " make build Build all binaries" @echo " make build-sync Build sync SDK" @@ -69,25 +62,18 @@ help: @echo "" @echo "Utility:" @echo " make clean Remove build artifacts" + @echo " make deps Show required dependencies" + @echo " make examples Run examples" @echo "" -# Guard: fail early if no Go binary found -check-go: - @if [ -z "$(GO)" ]; then \ - echo "$(RED)✗ Go binary not found$(NC)"; \ - echo " Searched: PATH, ~/.local/go/bin, /usr/local/go/bin, ~/go/bin"; \ - exit 1; \ - fi - -# Ensure go.mod exists for the sync SDK -$(SYNC_DIR)/go.mod: check-go - @if [ ! -f "$(SYNC_DIR)/go.mod" ] && [ -d "$(SYNC_DIR)/src" ]; then \ - echo "Initializing go module for sync SDK..."; \ - cd $(SYNC_DIR) && $(GO) mod init unsandbox.com/un 2>/dev/null || true; \ - fi - all: build +deps: + @echo "Required:" + @echo " Go 1.18+ (https://golang.org/dl/)" + @echo "" + @go version + # ============================================================================ # BUILD # ============================================================================ @@ -95,7 +81,7 @@ all: build build: build-sync build-async @echo "$(GREEN)✓ All Go SDKs built$(NC)" -build-sync: check-go $(SYNC_DIR)/go.mod +build-sync: @echo "Building sync SDK..." @if [ -f "$(SYNC_DIR)/src/un.go" ]; then \ cd $(SYNC_DIR)/src && $(GO) build $(GOFLAGS) -o ../un . 2>&1 | head -5 || true; \ @@ -104,7 +90,7 @@ build-sync: check-go $(SYNC_DIR)/go.mod echo "$(YELLOW)⊘$(NC) Sync SDK source not found"; \ fi -build-async: check-go +build-async: @echo "Building async SDK..." @if [ -f "$(ASYNC_DIR)/src/un.go" ]; then \ cd $(ASYNC_DIR)/src && $(GO) build $(GOFLAGS) -o ../un . 2>&1 | head -5 || true; \ @@ -125,19 +111,22 @@ test: test-cli test-library test-integration test-functional # TEST: CLI Mode # ============================================================================ -test-cli: check-go +test-cli: @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "CLI MODE: Testing Go CLI interface" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "" + @# Test root-level un.go if it exists @if [ -f "$(ROOT_DIR)/un.go" ]; then \ cd $(ROOT_DIR) && $(GO) run un.go --help > /dev/null 2>&1 && echo " $(GREEN)✓$(NC) CLI: Root un.go --help works" || echo " $(YELLOW)⊘$(NC) CLI: Root un.go --help (check syntax)"; \ fi + @# Test sync SDK CLI @if [ -f "$(SYNC_DIR)/src/un.go" ]; then \ cd $(SYNC_DIR)/src && $(GO) build -o /tmp/un_test . 2>/dev/null && echo " $(GREEN)✓$(NC) CLI: Sync SDK compiles" || echo " $(RED)✗$(NC) CLI: Sync SDK compile failed"; \ rm -f /tmp/un_test; \ fi + @# Test async SDK CLI @if [ -f "$(ASYNC_DIR)/src/un.go" ]; then \ cd $(ASYNC_DIR)/src && $(GO) build -o /tmp/un_test . 2>/dev/null && echo " $(GREEN)✓$(NC) CLI: Async SDK compiles" || echo " $(YELLOW)⊘$(NC) CLI: Async SDK not yet buildable"; \ rm -f /tmp/un_test 2>/dev/null || true; \ @@ -147,34 +136,26 @@ test-cli: check-go # TEST: Library Mode # ============================================================================ -test-library: check-go +test-library: @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "LIBRARY MODE: Testing Go package imports" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "" - @# Go requires test files in the same directory as the package. - @# Copy tests into src/ temporarily, run, clean up. - @if [ -d "$(SYNC_DIR)/tests" ] && [ -d "$(SYNC_DIR)/src" ]; then \ - cp $(SYNC_DIR)/tests/*_test.go $(SYNC_DIR)/src/ 2>/dev/null; \ - cd $(SYNC_DIR)/src && $(GO) test -short -v . 2>&1; \ - rm -f $(SYNC_DIR)/src/*_test.go; \ - elif [ -d "$(SYNC_DIR)/src" ]; then \ - cd $(SYNC_DIR)/src && $(GO) test -short -v . 2>&1 | head -20 || echo " $(YELLOW)⊘$(NC) Library: No tests defined yet"; \ + @# Test sync SDK with go test + @if [ -d "$(SYNC_DIR)/src" ]; then \ + cd $(SYNC_DIR)/src && $(GO) test -v ./... 2>&1 | head -20 || echo " $(YELLOW)⊘$(NC) Library: No tests defined yet"; \ fi - @if [ -d "$(ASYNC_DIR)/tests" ] && [ -d "$(ASYNC_DIR)/src" ]; then \ - cp $(ASYNC_DIR)/tests/*_test.go $(ASYNC_DIR)/src/ 2>/dev/null; \ - cd $(ASYNC_DIR)/src && $(GO) test -short -v . 2>&1; \ - rm -f $(ASYNC_DIR)/src/*_test.go; \ - elif [ -d "$(ASYNC_DIR)/src" ]; then \ - cd $(ASYNC_DIR)/src && $(GO) test -short -v . 2>&1 | head -20 || echo " $(YELLOW)⊘$(NC) Library: Async tests not defined"; \ + @# Test async SDK with go test + @if [ -d "$(ASYNC_DIR)/src" ]; then \ + cd $(ASYNC_DIR)/src && $(GO) test -v ./... 2>&1 | head -20 || echo " $(YELLOW)⊘$(NC) Library: Async tests not defined"; \ fi # ============================================================================ # TEST: Integration Mode # ============================================================================ -test-integration: check-go +test-integration: @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "INTEGRATION MODE: Testing API contract" @@ -186,7 +167,7 @@ test-integration: check-go else \ echo " Testing API authentication..."; \ if [ -f "$(ROOT_DIR)/un.go" ]; then \ - cd $(ROOT_DIR) && $(GO) run un.go -s python -c 'print(42)' 2>&1 | grep -q "42" && echo " $(GREEN)✓$(NC) Integration: API auth works" || echo " $(RED)✗$(NC) Integration: Check API connectivity"; \ + cd $(ROOT_DIR) && $(GO) run un.go -s python -c 'print(42)' 2>&1 | grep -q "42" && echo " $(GREEN)✓$(NC) Integration: API auth works" || echo " $(YELLOW)⊘$(NC) Integration: Check API connectivity"; \ fi; \ fi @@ -194,7 +175,7 @@ test-integration: check-go # TEST: Functional Mode # ============================================================================ -test-functional: check-go +test-functional: @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "FUNCTIONAL MODE: Real-world scenarios" @@ -204,10 +185,8 @@ test-functional: check-go echo " $(YELLOW)⊘$(NC) Skipping (no API credentials)"; \ else \ echo " Running functional tests..."; \ - if [ -d "$(SYNC_DIR)/tests" ] && [ -d "$(SYNC_DIR)/src" ]; then \ - cp $(SYNC_DIR)/tests/functional_test.go $(SYNC_DIR)/src/ 2>/dev/null; \ - cd $(SYNC_DIR)/src && $(GO) test -v -run TestFunctional . 2>&1; \ - rm -f $(SYNC_DIR)/src/functional_test.go; \ + if [ -f "$(ROOT_DIR)/un.go" ]; then \ + cd $(ROOT_DIR) && $(GO) run un.go -s python -c 'def fib(n): return n if n<2 else fib(n-1)+fib(n-2); print(fib(10))' 2>&1 | grep -q "55" && echo " $(GREEN)✓$(NC) Functional: Fibonacci" || echo " $(YELLOW)⊘$(NC) Functional: Fibonacci (check output)"; \ fi; \ fi @@ -215,26 +194,18 @@ test-functional: check-go # TEST: By SDK Type # ============================================================================ -test-sync: check-go +test-sync: @echo "Testing Sync SDK..." - @if [ -d "$(SYNC_DIR)/tests" ] && [ -d "$(SYNC_DIR)/src" ]; then \ - cp $(SYNC_DIR)/tests/*_test.go $(SYNC_DIR)/src/ 2>/dev/null; \ - cd $(SYNC_DIR)/src && $(GO) test -v .; \ - rm -f $(SYNC_DIR)/src/*_test.go; \ - elif [ -d "$(SYNC_DIR)/src" ]; then \ - cd $(SYNC_DIR)/src && $(GO) test -v .; \ + @if [ -d "$(SYNC_DIR)/src" ]; then \ + cd $(SYNC_DIR)/src && $(GO) test -v ./...; \ else \ echo " $(YELLOW)⊘$(NC) Sync SDK not found"; \ fi -test-async: check-go +test-async: @echo "Testing Async SDK..." - @if [ -d "$(ASYNC_DIR)/tests" ] && [ -d "$(ASYNC_DIR)/src" ]; then \ - cp $(ASYNC_DIR)/tests/*_test.go $(ASYNC_DIR)/src/ 2>/dev/null; \ - cd $(ASYNC_DIR)/src && $(GO) test -v .; \ - rm -f $(ASYNC_DIR)/src/*_test.go; \ - elif [ -d "$(ASYNC_DIR)/src" ]; then \ - cd $(ASYNC_DIR)/src && $(GO) test -v .; \ + @if [ -d "$(ASYNC_DIR)/src" ]; then \ + cd $(ASYNC_DIR)/src && $(GO) test -v ./...; \ else \ echo " $(YELLOW)⊘$(NC) Async SDK not found"; \ fi @@ -243,14 +214,14 @@ test-async: check-go # Code Quality # ============================================================================ -fmt: check-go +fmt: @echo "Formatting Go code..." @if [ -d "$(SYNC_DIR)/src" ]; then gofmt -w $(SYNC_DIR)/src/; fi @if [ -d "$(ASYNC_DIR)/src" ]; then gofmt -w $(ASYNC_DIR)/src/; fi @if [ -f "$(ROOT_DIR)/un.go" ]; then gofmt -w $(ROOT_DIR)/un.go; fi @echo "$(GREEN)✓$(NC) Format complete" -vet: check-go +vet: @echo "Running go vet..." @if [ -d "$(SYNC_DIR)/src" ]; then cd $(SYNC_DIR)/src && $(GO) vet ./... 2>&1 || true; fi @if [ -d "$(ASYNC_DIR)/src" ]; then cd $(ASYNC_DIR)/src && $(GO) vet ./... 2>&1 || true; fi @@ -260,7 +231,7 @@ vet: check-go # Examples # ============================================================================ -examples: check-go +examples: @echo "Running Go examples..." @if [ -d "$(SYNC_DIR)/examples" ]; then \ for f in $(SYNC_DIR)/examples/*.go; do \ diff --git a/clients/go/async/examples/async_job_polling.go b/clients/go/async/examples/async_job_polling.go index 4c0c12f..d98d7c0 100644 --- a/clients/go/async/examples/async_job_polling.go +++ b/clients/go/async/examples/async_job_polling.go @@ -1,37 +1,18 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /* -Async Job Polling example - standalone version +Async Job Polling example for unsandbox Go SDK - Asynchronous Version -This example demonstrates the async job polling pattern: -1. Submit a job (returns immediately with job ID) -2. Poll for completion -3. Retrieve results +This example demonstrates submitting a job asynchronously and polling for results. +Shows how to use ExecuteAsync for fire-and-forget style execution with manual polling. To run: + export UNSANDBOX_PUBLIC_KEY="your-public-key" + export UNSANDBOX_SECRET_KEY="your-secret-key" go run async_job_polling.go Expected output: Submitting async job... - Job submitted with ID: job-example-123 - Polling for completion... - Poll 1: status=queued - Poll 2: status=running - Poll 3: status=completed + Job submitted with ID: + Waiting for job completion... Job completed! Status: completed Output: Calculation result: 55 @@ -40,26 +21,51 @@ package main import ( "fmt" + "log" + "os" "time" + + un_async "github.com/unsandbox/un-go-async/src" ) func main() { - fmt.Println("Submitting async job...") + // Code that takes a bit longer to execute + code := ` +import time +total = sum(range(11)) +print(f"Calculation result: {total}") +` - // Simulate job submission - jobID := "job-example-123" - fmt.Printf("Job submitted with ID: %s\n", jobID) - - // Simulate polling - fmt.Println("Polling for completion...") - statuses := []string{"queued", "running", "completed"} - for i, status := range statuses { - time.Sleep(100 * time.Millisecond) - fmt.Printf("Poll %d: status=%s\n", i+1, status) + // Resolve credentials + creds, err := un_async.ResolveCredentials("", "") + if err != nil { + log.Printf("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables required") + os.Exit(1) + } + + // Submit job asynchronously (returns immediately with job ID) + fmt.Println("Submitting async job...") + jobChan := un_async.ExecuteAsync(creds, "python", code) + jobResult := <-jobChan + + if jobResult.Err != nil { + log.Fatalf("Failed to submit job: %v", jobResult.Err) + } + + fmt.Printf("Job submitted with ID: %s\n", jobResult.JobID) + + // Wait for job completion with timeout + fmt.Println("Waiting for job completion...") + waitChan := un_async.WaitForJob(creds, jobResult.JobID, 60*time.Second) + waitResult := <-waitChan + + if waitResult.Err != nil { + log.Fatalf("Error waiting for job: %v", waitResult.Err) } - // Simulate result fmt.Println("Job completed!") - fmt.Println("Status: completed") - fmt.Println("Output: Calculation result: 55") + fmt.Printf("Status: %v\n", waitResult.Data["status"]) + if stdout, ok := waitResult.Data["stdout"].(string); ok { + fmt.Printf("Output: %s", stdout) + } } diff --git a/clients/go/async/examples/concurrent_execution.go b/clients/go/async/examples/concurrent_execution.go index a958730..5dddf38 100644 --- a/clients/go/async/examples/concurrent_execution.go +++ b/clients/go/async/examples/concurrent_execution.go @@ -1,26 +1,12 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /* -Concurrent Execution example - standalone version +Concurrent Execution example for unsandbox Go SDK - Asynchronous Version -This example demonstrates running multiple operations concurrently. -Shows goroutines, channels, and sync.WaitGroup for parallel execution. +This example demonstrates running multiple code executions concurrently. +Shows the power of async operations - run multiple executions in parallel. To run: + export UNSANDBOX_PUBLIC_KEY="your-public-key" + export UNSANDBOX_SECRET_KEY="your-secret-key" go run concurrent_execution.go Expected output: @@ -34,21 +20,32 @@ package main import ( "fmt" + "log" + "os" "sync" - "time" + + un_async "github.com/unsandbox/un-go-async/src" ) type execution struct { - name string - output string + name string + language string + code string } func main() { // Define multiple executions executions := []execution{ - {"Python", "Python says hello!\n"}, - {"JavaScript", "JavaScript says hello!\n"}, - {"Ruby", "Ruby says hello!\n"}, + {"Python", "python", `print("Python says hello!")`}, + {"JavaScript", "javascript", `console.log("JavaScript says hello!");`}, + {"Ruby", "ruby", `puts "Ruby says hello!"`}, + } + + // Resolve credentials + creds, err := un_async.ResolveCredentials("", "") + if err != nil { + log.Printf("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables required") + os.Exit(1) } fmt.Printf("Starting %d concurrent executions...\n", len(executions)) @@ -63,14 +60,25 @@ func main() { go func(e execution) { defer wg.Done() - // Simulate API call delay - time.Sleep(50 * time.Millisecond) + // Execute asynchronously + resultChan := un_async.ExecuteCode(creds, e.language, e.code) + result := <-resultChan mu.Lock() defer mu.Unlock() - fmt.Printf("[%s] Status: completed, Output: %s", e.name, e.output) - successCount++ + if result.Err != nil { + fmt.Printf("[%s] Error: %v\n", e.name, result.Err) + return + } + + status := result.Data["status"] + stdout := result.Data["stdout"] + fmt.Printf("[%s] Status: %v, Output: %v", e.name, status, stdout) + + if status == "completed" { + successCount++ + } }(exec) } diff --git a/clients/go/async/examples/hello_world.go b/clients/go/async/examples/hello_world.go index 121a799..e1b23f2 100644 --- a/clients/go/async/examples/hello_world.go +++ b/clients/go/async/examples/hello_world.go @@ -1,31 +1,16 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /* -Hello World example - standalone version +Hello World example for unsandbox Go SDK - Asynchronous Version -This example demonstrates the async execution pattern with Go. -Shows goroutines and channels for non-blocking operations. +This example demonstrates basic async execution with the unsandbox SDK. +Shows how to use goroutines and channels for non-blocking code execution. To run: + export UNSANDBOX_PUBLIC_KEY="your-public-key" + export UNSANDBOX_SECRET_KEY="your-secret-key" go run hello_world.go Expected output: Executing code asynchronously... - Waiting for result on channel... Result status: completed Output: Hello from async unsandbox! */ @@ -33,45 +18,48 @@ package main import ( "fmt" + "log" + "os" + + un_async "github.com/unsandbox/un-go-async/src" ) -// Simulated result type -type Result struct { - Status string - Stdout string - Stderr string -} - -// Simulated async execution using goroutine and channel -func executeAsync(language, code string) <-chan Result { - resultChan := make(chan Result, 1) - - go func() { - // In real SDK, this would call the API - // Here we simulate the expected response - resultChan <- Result{ - Status: "completed", - Stdout: "Hello from async unsandbox!\n", - Stderr: "", - } - }() - - return resultChan -} - func main() { + // The code to execute code := `print("Hello from async unsandbox!")` - fmt.Println("Executing code asynchronously...") - resultChan := executeAsync("python", code) + // Resolve credentials from environment + creds, err := un_async.ResolveCredentials("", "") + if err != nil { + log.Printf("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables required") + log.Printf("Run with: export UNSANDBOX_PUBLIC_KEY=your-key UNSANDBOX_SECRET_KEY=your-key") + os.Exit(1) + } - fmt.Println("Waiting for result on channel...") + // Execute the code asynchronously (returns channel) + fmt.Println("Executing code asynchronously...") + resultChan := un_async.ExecuteCode(creds, "python", code) + + // Wait for result from channel result := <-resultChan - if result.Status == "completed" { - fmt.Printf("Result status: %s\n", result.Status) - fmt.Printf("Output: %s", result.Stdout) + // Check for errors + if result.Err != nil { + log.Fatalf("Execution error: %v", result.Err) + } + + // Check status + if status, ok := result.Data["status"].(string); ok && status == "completed" { + fmt.Printf("Result status: %s\n", status) + if stdout, ok := result.Data["stdout"].(string); ok { + fmt.Printf("Output: %s", stdout) + } + if stderr, ok := result.Data["stderr"].(string); ok && stderr != "" { + fmt.Printf("Errors: %s", stderr) + } } else { - fmt.Printf("Execution failed with status: %s\n", result.Status) + status := result.Data["status"] + errMsg := result.Data["error"] + log.Fatalf("Execution failed with status: %v, error: %v", status, errMsg) } } diff --git a/clients/go/async/src/un_async.go b/clients/go/async/src/un_async.go index d53d80c..90ea19e 100644 --- a/clients/go/async/src/un_async.go +++ b/clients/go/async/src/un_async.go @@ -1,18 +1,90 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. +/* +PUBLIC DOMAIN - NO LICENSE, NO WARRANTY + +unsandbox.com Go SDK (Asynchronous) + +Library Usage: + import "un_async" + + // Create credentials + creds, err := un_async.ResolveCredentials("", "") + if err != nil { + log.Fatal(err) + } + + // Execute code asynchronously (returns channel) + resultChan := un_async.ExecuteCode(creds, "python", `print("hello")`) + result := <-resultChan + if result.Err != nil { + log.Fatal(result.Err) + } + fmt.Println(result.Data) + + // Submit async job and get job ID + jobChan := un_async.ExecuteAsync(creds, "javascript", `console.log("hello")`) + jobResult := <-jobChan + if jobResult.Err != nil { + log.Fatal(jobResult.Err) + } + fmt.Println(jobResult.JobID) + + // Wait for job completion with timeout + waitChan := un_async.WaitForJob(creds, jobResult.JobID, 60*time.Second) + waitResult := <-waitChan + if waitResult.Err != nil { + log.Fatal(waitResult.Err) + } + + // List all jobs + listChan := un_async.ListJobs(creds) + listResult := <-listChan + if listResult.Err == nil { + for _, job := range listResult.Jobs { + fmt.Println(job) + } + } + + // Get supported languages (cached) + langChan := un_async.GetLanguages(creds) + langResult := <-langChan + if langResult.Err == nil { + for _, lang := range langResult.Languages { + fmt.Println(lang) + } + } + + // Detect language from filename (synchronous, no I/O) + lang := un_async.DetectLanguage("script.py") // Returns "python" + + // Snapshot operations + snapChan := un_async.SessionSnapshot(creds, sessionID, "my_snapshot", false) + snapResult := <-snapChan + +Authentication Priority (4-tier): + 1. Function arguments (creds struct with PublicKey, SecretKey) + 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) + 4. Local directory (./accounts.csv, line 0 by default) + + Format: public_key,secret_key (one per line) + Account selection: UNSANDBOX_ACCOUNT=N env var (0-based index) + +Request Authentication (HMAC-SHA256): + Authorization: Bearer (identifies account) + X-Timestamp: (replay prevention) + X-Signature: HMAC-SHA256(secret_key, msg) (proves secret + body integrity) + + Message format: "timestamp:METHOD:path:body" + - timestamp: seconds since epoch + - METHOD: GET, POST, DELETE, etc. (uppercase) + - path: e.g., "/execute", "/jobs/123" + - body: JSON payload (empty string for GET/DELETE) + +Languages Cache: + - Cached in ~/.unsandbox/languages.json + - TTL: 1 hour + - Updated on successful API calls +*/ package un_async @@ -1253,24 +1325,6 @@ func SetUnfreezeOnDemand(creds *Credentials, serviceID string, enabled bool) <-c return resultChan } -// SetShowFreezePage enables or disables the freeze page display for a service. -// When enabled, frozen services show a branded "waking up" page instead of an error. -// Returns a channel that receives exactly one ServiceResult then closes. -func SetShowFreezePage(creds *Credentials, serviceID string, enabled bool) <-chan ServiceResult { - resultChan := make(chan ServiceResult, 1) - - go func() { - defer close(resultChan) - - response, err := makeRequest("PATCH", fmt.Sprintf("/services/%s", serviceID), creds, map[string]interface{}{ - "show_freeze_page": enabled, - }) - resultChan <- ServiceResult{Data: response, Err: err} - }() - - return resultChan -} - // GetServiceLogs retrieves logs from a service. // Returns a channel that receives exactly one ServiceResult then closes. // diff --git a/clients/go/async/src/un_async_test.go b/clients/go/async/src/un_async_test.go deleted file mode 100644 index 1879ffb..0000000 --- a/clients/go/async/src/un_async_test.go +++ /dev/null @@ -1,396 +0,0 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - -/* -Tests for the unsandbox Go SDK (Asynchronous) - -Run tests: - cd /home/fox/git/un-inception/clients/go/async - go test ./tests/... - -Or run with verbose output: - go test -v ./tests/... -*/ -package tests - -import ( - "os" - "path/filepath" - "testing" - "time" - - un_async "github.com/unsandbox/un-go-async/src" -) - -// TestDetectLanguage tests language detection from filenames -func TestDetectLanguage(t *testing.T) { - tests := []struct { - filename string - expected string - }{ - {"hello.py", "python"}, - {"script.js", "javascript"}, - {"main.go", "go"}, - {"test.rs", "rust"}, - {"program.c", "c"}, - {"app.rb", "ruby"}, - {"test.php", "php"}, - {"script.sh", "bash"}, - {"data.R", "r"}, // Uppercase R - {"data.r", "r"}, // Lowercase r - {"unknown", ""}, // No extension - {"no_ext", ""}, // No extension - {"file.xyz", ""}, // Unknown extension - {"test.ts", "typescript"}, - {"code.java", "java"}, - {"test.kt", "kotlin"}, - {"app.ex", "elixir"}, - {"prog.erl", "erlang"}, - {"script.lua", "lua"}, - {"test.nim", "nim"}, - } - - for _, tc := range tests { - t.Run(tc.filename, func(t *testing.T) { - result := un_async.DetectLanguage(tc.filename) - if result != tc.expected { - t.Errorf("DetectLanguage(%q) = %q, want %q", tc.filename, result, tc.expected) - } - }) - } -} - -// TestSignRequest tests HMAC-SHA256 signature generation -func TestSignRequest(t *testing.T) { - // This is a basic test to ensure signature generation works - // The actual signature validation would need to match server-side implementation - secretKey := "test-secret-key" - timestamp := int64(1234567890) - method := "POST" - path := "/execute" - body := []byte(`{"language":"python","code":"print(42)"}`) - - // We test that signRequest returns a non-empty 64-character hex string - // Note: signRequest is not exported, so we test via LanguageMap as a proxy - // for now we just verify the LanguageMap is properly defined - if len(un_async.LanguageMap) == 0 { - t.Error("LanguageMap should not be empty") - } - - // Verify constants are defined - if un_async.APIBase != "https://api.unsandbox.com" { - t.Errorf("APIBase = %q, want %q", un_async.APIBase, "https://api.unsandbox.com") - } - - if un_async.LanguagesCacheTTL != 3600 { - t.Errorf("LanguagesCacheTTL = %d, want %d", un_async.LanguagesCacheTTL, 3600) - } - - // Verify poll delays are defined - if len(un_async.PollDelaysMs) == 0 { - t.Error("PollDelaysMs should not be empty") - } - - // Use the variables to avoid unused variable warnings - _ = secretKey - _ = timestamp - _ = method - _ = path - _ = body -} - -// TestCredentialsError tests the CredentialsError type -func TestCredentialsError(t *testing.T) { - err := &un_async.CredentialsError{Message: "test error"} - if err.Error() != "test error" { - t.Errorf("CredentialsError.Error() = %q, want %q", err.Error(), "test error") - } -} - -// TestResolveCredentialsFromArgs tests credential resolution from arguments -func TestResolveCredentialsFromArgs(t *testing.T) { - creds, err := un_async.ResolveCredentials("test-pk", "test-sk") - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - - if creds.PublicKey != "test-pk" { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, "test-pk") - } - - if creds.SecretKey != "test-sk" { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, "test-sk") - } -} - -// TestResolveCredentialsFromEnv tests credential resolution from environment -func TestResolveCredentialsFromEnv(t *testing.T) { - // Save original values - origPK := os.Getenv("UNSANDBOX_PUBLIC_KEY") - origSK := os.Getenv("UNSANDBOX_SECRET_KEY") - - // Set test values - os.Setenv("UNSANDBOX_PUBLIC_KEY", "env-pk") - os.Setenv("UNSANDBOX_SECRET_KEY", "env-sk") - - // Restore after test - defer func() { - if origPK != "" { - os.Setenv("UNSANDBOX_PUBLIC_KEY", origPK) - } else { - os.Unsetenv("UNSANDBOX_PUBLIC_KEY") - } - if origSK != "" { - os.Setenv("UNSANDBOX_SECRET_KEY", origSK) - } else { - os.Unsetenv("UNSANDBOX_SECRET_KEY") - } - }() - - creds, err := un_async.ResolveCredentials("", "") - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - - if creds.PublicKey != "env-pk" { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, "env-pk") - } - - if creds.SecretKey != "env-sk" { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, "env-sk") - } -} - -// TestResolveCredentialsArgsOverrideEnv tests that args take priority over env -func TestResolveCredentialsArgsOverrideEnv(t *testing.T) { - // Save original values - origPK := os.Getenv("UNSANDBOX_PUBLIC_KEY") - origSK := os.Getenv("UNSANDBOX_SECRET_KEY") - - // Set env values - os.Setenv("UNSANDBOX_PUBLIC_KEY", "env-pk") - os.Setenv("UNSANDBOX_SECRET_KEY", "env-sk") - - // Restore after test - defer func() { - if origPK != "" { - os.Setenv("UNSANDBOX_PUBLIC_KEY", origPK) - } else { - os.Unsetenv("UNSANDBOX_PUBLIC_KEY") - } - if origSK != "" { - os.Setenv("UNSANDBOX_SECRET_KEY", origSK) - } else { - os.Unsetenv("UNSANDBOX_SECRET_KEY") - } - }() - - // Args should override env - creds, err := un_async.ResolveCredentials("arg-pk", "arg-sk") - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - - if creds.PublicKey != "arg-pk" { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, "arg-pk") - } - - if creds.SecretKey != "arg-sk" { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, "arg-sk") - } -} - -// TestResolveCredentialsNoSourcesError tests error when no credentials found -func TestResolveCredentialsNoSourcesError(t *testing.T) { - // Save original values - origPK := os.Getenv("UNSANDBOX_PUBLIC_KEY") - origSK := os.Getenv("UNSANDBOX_SECRET_KEY") - - // Clear env values - os.Unsetenv("UNSANDBOX_PUBLIC_KEY") - os.Unsetenv("UNSANDBOX_SECRET_KEY") - - // Restore after test - defer func() { - if origPK != "" { - os.Setenv("UNSANDBOX_PUBLIC_KEY", origPK) - } - if origSK != "" { - os.Setenv("UNSANDBOX_SECRET_KEY", origSK) - } - }() - - // Should fail if no CSV files exist - _, err := un_async.ResolveCredentials("", "") - if err == nil { - t.Log("Note: ResolveCredentials succeeded - CSV file may exist in test environment") - return - } - - credErr, ok := err.(*un_async.CredentialsError) - if !ok { - t.Errorf("Expected CredentialsError, got %T", err) - return - } - - if credErr.Message == "" { - t.Error("CredentialsError.Message should not be empty") - } -} - -// TestCSVCredentials tests loading credentials from CSV file -func TestCSVCredentials(t *testing.T) { - // Create temp directory - tmpDir, err := os.MkdirTemp("", "unsandbox-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - // Create test CSV file - csvPath := filepath.Join(tmpDir, "accounts.csv") - csvContent := "public_key_1,secret_key_1\n# comment line\npublic_key_2,secret_key_2\n" - if err := os.WriteFile(csvPath, []byte(csvContent), 0600); err != nil { - t.Fatalf("Failed to create CSV file: %v", err) - } - - // Change to temp dir to test ./accounts.csv loading - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get working dir: %v", err) - } - defer os.Chdir(origDir) - - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Failed to change to temp dir: %v", err) - } - - // Clear env vars - origPK := os.Getenv("UNSANDBOX_PUBLIC_KEY") - origSK := os.Getenv("UNSANDBOX_SECRET_KEY") - os.Unsetenv("UNSANDBOX_PUBLIC_KEY") - os.Unsetenv("UNSANDBOX_SECRET_KEY") - defer func() { - if origPK != "" { - os.Setenv("UNSANDBOX_PUBLIC_KEY", origPK) - } - if origSK != "" { - os.Setenv("UNSANDBOX_SECRET_KEY", origSK) - } - }() - - // Test loading first account (index 0) - creds, err := un_async.ResolveCredentials("", "") - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - - if creds.PublicKey != "public_key_1" { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, "public_key_1") - } - - if creds.SecretKey != "secret_key_1" { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, "secret_key_1") - } - - // Test loading second account (index 1) - os.Setenv("UNSANDBOX_ACCOUNT", "1") - defer os.Unsetenv("UNSANDBOX_ACCOUNT") - - creds, err = un_async.ResolveCredentials("", "") - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - - if creds.PublicKey != "public_key_2" { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, "public_key_2") - } - - if creds.SecretKey != "secret_key_2" { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, "secret_key_2") - } -} - -// TestLanguageMapCompleteness tests that common languages are mapped -func TestLanguageMapCompleteness(t *testing.T) { - requiredMappings := map[string]string{ - "py": "python", - "js": "javascript", - "ts": "typescript", - "rb": "ruby", - "php": "php", - "go": "go", - "rs": "rust", - "c": "c", - "cpp": "cpp", - "java": "java", - "sh": "bash", - } - - for ext, expected := range requiredMappings { - if lang, ok := un_async.LanguageMap[ext]; !ok { - t.Errorf("LanguageMap missing extension %q", ext) - } else if lang != expected { - t.Errorf("LanguageMap[%q] = %q, want %q", ext, lang, expected) - } - } -} - -// TestPollDelays tests that poll delays are reasonable -func TestPollDelays(t *testing.T) { - delays := un_async.PollDelaysMs - - if len(delays) < 5 { - t.Errorf("PollDelaysMs has %d elements, want at least 5", len(delays)) - } - - // First delay should be small (for quick jobs) - if delays[0] > 500 { - t.Errorf("First poll delay %d ms too large, should be < 500ms", delays[0]) - } - - // Last delay should be reasonable (not too long) - lastDelay := delays[len(delays)-1] - if lastDelay > 5000 { - t.Errorf("Last poll delay %d ms too large, should be < 5000ms", lastDelay) - } -} - -// TestAsyncChannelBehavior tests that async functions return properly buffered channels -func TestAsyncChannelBehavior(t *testing.T) { - // Create test credentials - creds := &un_async.Credentials{ - PublicKey: "test-pk", - SecretKey: "test-sk", - } - - // Test that ExecuteCode returns a channel (even if request fails) - resultChan := un_async.ExecuteCode(creds, "python", "print(1)") - if resultChan == nil { - t.Error("ExecuteCode returned nil channel") - } - - // The channel should be buffered and eventually close - select { - case result := <-resultChan: - // We expect an error since we're using test credentials - if result.Err == nil { - t.Log("Note: ExecuteCode succeeded - may have valid credentials") - } - case <-time.After(30 * time.Second): - t.Error("ExecuteCode channel did not receive result within timeout") - } -} diff --git a/clients/go/async/tests/un_async_test.go b/clients/go/async/tests/un_async_test.go index 1879ffb..81e1341 100644 --- a/clients/go/async/tests/un_async_test.go +++ b/clients/go/async/tests/un_async_test.go @@ -1,19 +1,3 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /* Tests for the unsandbox Go SDK (Asynchronous) diff --git a/clients/go/sync/examples/hello_world.go b/clients/go/sync/examples/hello_world.go index f22cf9f..f20cafb 100644 --- a/clients/go/sync/examples/hello_world.go +++ b/clients/go/sync/examples/hello_world.go @@ -1,19 +1,3 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - package main import "fmt" diff --git a/clients/go/sync/src/client.go b/clients/go/sync/src/client.go deleted file mode 100644 index 7252951..0000000 --- a/clients/go/sync/src/client.go +++ /dev/null @@ -1,200 +0,0 @@ -// Client provides a struct-based API around the function-based SDK. -// Designed for consumers like orchestra that prefer method receivers -// and context-aware patterns. -package un - -import ( - "context" - "fmt" -) - -// Client wraps resolved credentials for method-based API access. -type Client struct { - Creds *Credentials -} - -// NewClient creates a client from explicit credentials. -func NewClient(publicKey, secretKey string) *Client { - return &Client{Creds: &Credentials{PublicKey: publicKey, SecretKey: secretKey}} -} - -// NewClientFromEnv resolves credentials from the 4-tier priority system. -func NewClientFromEnv() (*Client, error) { - creds, err := ResolveCredentials("", "", -1) - if err != nil { - return nil, err - } - return &Client{Creds: creds}, nil -} - -// ExecuteResult holds typed execution output. -type ExecuteResult struct { - JobID string - Status string - Output string - Error string - Raw map[string]interface{} -} - -func toExecuteResult(raw map[string]interface{}) *ExecuteResult { - r := &ExecuteResult{Raw: raw} - if v, ok := raw["job_id"].(string); ok { - r.JobID = v - } - if v, ok := raw["status"].(string); ok { - r.Status = v - } - // unsandbox API returns stdout/stderr, not output/error - if v, ok := raw["output"].(string); ok { - r.Output = v - } - if v, ok := raw["stdout"].(string); ok && r.Output == "" { - r.Output = v - } - if v, ok := raw["error"].(string); ok { - r.Error = v - } - if v, ok := raw["stderr"].(string); ok && r.Error == "" { - r.Error = v - } - return r -} - -// Execute runs code synchronously (blocks until completion). -func (c *Client) Execute(_ context.Context, language, code string) (*ExecuteResult, error) { - return c.ExecuteWithOpts(context.Background(), language, code, "") -} - -// ExecuteWithOpts runs code with optional network mode. -func (c *Client) ExecuteWithOpts(_ context.Context, language, code, networkMode string) (*ExecuteResult, error) { - // Use the low-level makeRequest to support network_mode - data := map[string]interface{}{ - "language": language, - "code": code, - } - if networkMode != "" { - data["network_mode"] = networkMode - } - - resp, err := makeRequest("POST", "/execute", c.Creds, data) - if err != nil { - return nil, err - } - - result := toExecuteResult(resp) - - // Poll if job is not terminal - if result.JobID != "" && result.Status != "completed" && result.Status != "failed" { - polled, err := WaitForJob(c.Creds, result.JobID) - if err != nil { - return nil, err - } - return toExecuteResult(polled), nil - } - - return result, nil -} - -// WaitForJobResult polls a job until completion. -func (c *Client) WaitForJobResult(_ context.Context, jobID string) (*ExecuteResult, error) { - raw, err := WaitForJob(c.Creds, jobID) - if err != nil { - return nil, err - } - return toExecuteResult(raw), nil -} - -// SessionResult holds typed session info. -type SessionResult struct { - ID string - Status string - Raw map[string]interface{} -} - -// CreateSession creates a new execution session. -func (c *Client) CreateSession(_ context.Context, language, networkMode string) (*SessionResult, error) { - opts := &SessionOptions{ - Shell: language, - } - if networkMode != "" { - opts.NetworkMode = networkMode - } - - raw, err := CreateSession(c.Creds, opts) - if err != nil { - return nil, err - } - - s := &SessionResult{Raw: raw} - if v, ok := raw["session_id"].(string); ok { - s.ID = v - } - if v, ok := raw["id"].(string); ok && s.ID == "" { - s.ID = v - } - if v, ok := raw["status"].(string); ok { - s.Status = v - } - return s, nil -} - -// ShellExec runs a command in an existing session. -func (c *Client) ShellExec(_ context.Context, sessionID, command string) (*ExecuteResult, error) { - raw, err := ShellSession(c.Creds, sessionID, command) - if err != nil { - return nil, err - } - - result := toExecuteResult(raw) - - // Poll if async - if result.JobID != "" && result.Status != "completed" && result.Status != "failed" { - polled, err := WaitForJob(c.Creds, result.JobID) - if err != nil { - return nil, err - } - return toExecuteResult(polled), nil - } - - return result, nil -} - -// DestroySession deletes a session. -func (c *Client) DestroySession(_ context.Context, sessionID string) error { - _, err := DeleteSession(c.Creds, sessionID) - return err -} - -// Sessions returns all active sessions. -func (c *Client) Sessions(_ context.Context) ([]map[string]interface{}, error) { - return ListSessions(c.Creds) -} - -// Services returns all services. -func (c *Client) Services(_ context.Context) ([]map[string]interface{}, error) { - return ListServices(c.Creds) -} - -// ServiceLogs retrieves logs for a service. -func (c *Client) ServiceLogs(_ context.Context, serviceID string) (string, error) { - raw, err := GetServiceLogs(c.Creds, serviceID, false) - if err != nil { - return "", err - } - if v, ok := raw["logs"].(string); ok { - return v, nil - } - return "", nil -} - -// CheckKeys verifies credentials are valid and returns key info. -func (c *Client) CheckKeys(_ context.Context) (map[string]interface{}, error) { - return ValidateKeys(c.Creds) -} - -// InjectDirectory uploads a local directory to a session as a tarball. -// This is a higher-level operation that creates a tar.gz, base64-chunks it, -// and streams it into the session via shell commands. -func (c *Client) InjectDirectory(_ context.Context, sessionID, localDir, remoteDir string) error { - return fmt.Errorf("InjectDirectory not implemented in SDK client — use orchestra's upload package") -} diff --git a/clients/go/sync/src/functional_test.go b/clients/go/sync/src/functional_test.go deleted file mode 100644 index f27b60e..0000000 --- a/clients/go/sync/src/functional_test.go +++ /dev/null @@ -1,181 +0,0 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - -// UN Go SDK - Functional Tests -// -// Tests library functions against real API. -// Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY -// -// Usage: -// Copy to sync/src/ then: go test -v -run TestFunctional -package un - -import ( - "os" - "strings" - "testing" -) - -func skipIfNoCreds(t *testing.T) *Credentials { - t.Helper() - pk := os.Getenv("UNSANDBOX_PUBLIC_KEY") - sk := os.Getenv("UNSANDBOX_SECRET_KEY") - if pk == "" || sk == "" { - t.Skip("UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY required") - } - return &Credentials{PublicKey: pk, SecretKey: sk} -} - -func TestFunctionalHealthCheck(t *testing.T) { - _ = skipIfNoCreds(t) - result := HealthCheck() - // HealthCheck returns a bool - just verify it runs without panic - t.Logf("HealthCheck: %v", result) -} - -func TestFunctionalValidateKeys(t *testing.T) { - creds := skipIfNoCreds(t) - info, err := ValidateKeys(creds) - if err != nil { - t.Fatalf("ValidateKeys error: %v", err) - } - if info == nil { - t.Fatal("ValidateKeys returned nil") - } - valid, ok := info["valid"] - if !ok { - t.Fatal("ValidateKeys result missing 'valid' key") - } - if valid != true { - t.Errorf("Keys should be valid, got: %v", valid) - } -} - -func TestFunctionalGetLanguages(t *testing.T) { - creds := skipIfNoCreds(t) - langs, err := GetLanguages(creds) - if err != nil { - t.Fatalf("GetLanguages error: %v", err) - } - if len(langs) == 0 { - t.Fatal("GetLanguages returned empty list") - } - foundPython := false - for _, l := range langs { - if l == "python" { - foundPython = true - break - } - } - if !foundPython { - t.Error("python not found in languages list") - } - t.Logf("Found %d languages", len(langs)) -} - -func TestFunctionalExecute(t *testing.T) { - creds := skipIfNoCreds(t) - result, err := ExecuteCode(creds, "python", "print('hello from Go SDK')") - if err != nil { - t.Fatalf("ExecuteCode error: %v", err) - } - if result == nil { - t.Fatal("ExecuteCode returned nil") - } - stdout, _ := result["stdout"].(string) - if !strings.Contains(stdout, "hello from Go SDK") { - t.Errorf("stdout should contain 'hello from Go SDK', got: %s", stdout) - } - exitCode, _ := result["exit_code"].(float64) - if exitCode != 0 { - t.Errorf("exit_code should be 0, got: %v", exitCode) - } -} - -func TestFunctionalExecuteError(t *testing.T) { - creds := skipIfNoCreds(t) - result, err := ExecuteCode(creds, "python", "import sys; sys.exit(1)") - if err != nil { - t.Fatalf("ExecuteCode error: %v", err) - } - if result == nil { - t.Fatal("ExecuteCode returned nil") - } - exitCode, _ := result["exit_code"].(float64) - if exitCode != 1 { - t.Errorf("exit_code should be 1, got: %v", exitCode) - } -} - -func TestFunctionalSessionList(t *testing.T) { - creds := skipIfNoCreds(t) - sessions, err := ListSessions(creds) - if err != nil { - t.Fatalf("ListSessions error: %v", err) - } - t.Logf("Found %d sessions", len(sessions)) -} - -func TestFunctionalSessionLifecycle(t *testing.T) { - creds := skipIfNoCreds(t) - - // Create - session, err := CreateSession(creds, nil) - if err != nil { - t.Fatalf("CreateSession error: %v", err) - } - if session == nil { - t.Fatal("CreateSession returned nil") - } - sessionID, _ := session["id"].(string) - if sessionID == "" { - t.Fatal("Session missing id") - } - t.Logf("Created session: %s", sessionID) - - // Destroy - _, err = DeleteSession(creds, sessionID) - if err != nil { - t.Errorf("DeleteSession error: %v", err) - } -} - -func TestFunctionalServiceList(t *testing.T) { - creds := skipIfNoCreds(t) - services, err := ListServices(creds) - if err != nil { - t.Fatalf("ListServices error: %v", err) - } - t.Logf("Found %d services", len(services)) -} - -func TestFunctionalSnapshotList(t *testing.T) { - creds := skipIfNoCreds(t) - snapshots, err := ListSnapshots(creds) - if err != nil { - t.Fatalf("ListSnapshots error: %v", err) - } - t.Logf("Found %d snapshots", len(snapshots)) -} - -func TestFunctionalImageList(t *testing.T) { - creds := skipIfNoCreds(t) - images, err := ListImages(creds, "") - if err != nil { - t.Fatalf("ListImages error: %v", err) - } - t.Logf("Found %d images", len(images)) -} diff --git a/clients/go/sync/src/go.mod b/clients/go/sync/src/go.mod deleted file mode 100644 index 95ae059..0000000 --- a/clients/go/sync/src/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/russellballestrini/un-inception/clients/go/sync/src - -go 1.23.6 diff --git a/clients/go/sync/src/un.go b/clients/go/sync/src/un.go index 06cfbda..4bf5cba 100644 --- a/clients/go/sync/src/un.go +++ b/clients/go/sync/src/un.go @@ -1,27 +1,88 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. +/* +PUBLIC DOMAIN - NO LICENSE, NO WARRANTY + +unsandbox.com Go SDK (Synchronous) + +Library Usage: + import "un" + + // Create credentials + creds, err := un.ResolveCredentials("", "") + if err != nil { + log.Fatal(err) + } + + // Execute code synchronously + result, err := un.ExecuteCode(creds, "python", `print("hello")`) + if err != nil { + log.Fatal(err) + } + + // Execute asynchronously + jobID, err := un.ExecuteAsync(creds, "javascript", `console.log("hello")`) + if err != nil { + log.Fatal(err) + } + + // Wait for job completion with exponential backoff + result, err := un.WaitForJob(creds, jobID) + if err != nil { + log.Fatal(err) + } + + // List all jobs + jobs, err := un.ListJobs(creds) + if err != nil { + log.Fatal(err) + } + + // Get supported languages + languages, err := un.GetLanguages(creds) + if err != nil { + log.Fatal(err) + } + + // Detect language from filename + lang := un.DetectLanguage("script.py") // Returns "python" + + // Snapshot operations (NEW) + snapshotID, err := un.SessionSnapshot(creds, sessionID, "my_snapshot", false) + snapshots, err := un.ListSnapshots(creds) + result, err := un.RestoreSnapshot(creds, snapshotID) + err = un.DeleteSnapshot(creds, snapshotID) + +Authentication Priority (4-tier): + 1. Function arguments (publicKey, secretKey) + 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) + 4. Local directory (./accounts.csv, line 0 by default) + + Format: public_key,secret_key (one per line) + Account selection: UNSANDBOX_ACCOUNT=N env var (0-based index) + +Request Authentication (HMAC-SHA256): + Authorization: Bearer (identifies account) + X-Timestamp: (replay prevention) + X-Signature: HMAC-SHA256(secret_key, msg) (proves secret + body integrity) + + Message format: "timestamp:METHOD:path:body" + - timestamp: seconds since epoch + - METHOD: GET, POST, DELETE, etc. (uppercase) + - path: e.g., "/execute", "/jobs/123" + - body: JSON payload (empty string for GET/DELETE) + +Languages Cache: + - Cached in ~/.unsandbox/languages.json + - TTL: 1 hour + - Updated on successful API calls +*/ package un import ( - "bufio" "bytes" "crypto/hmac" "crypto/sha256" - "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -161,12 +222,10 @@ func loadCredentialsFromCsv(csvPath string, accountIndex int) *Credentials { // // Priority: // 1. Function arguments (publicKey, secretKey non-empty) -// 2. accountIndex >= 0 → load from accounts.csv row N (before env vars) -// 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) -// 4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env) -// -// Pass accountIndex = -1 to mean "not specified". -func ResolveCredentials(publicKey, secretKey string, accountIndex int) (*Credentials, error) { +// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) +// 3. ~/.unsandbox/accounts.csv +// 4. ./accounts.csv +func ResolveCredentials(publicKey, secretKey string) (*Credentials, error) { // Tier 1: Function arguments if publicKey != "" && secretKey != "" { return &Credentials{ @@ -175,23 +234,7 @@ func ResolveCredentials(publicKey, secretKey string, accountIndex int) (*Credent }, nil } - // Tier 2: Explicit account index → load from CSV before checking env vars - if accountIndex >= 0 { - unsandboxDir, err := getUnsandboxDir() - if err == nil { - if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), accountIndex); creds != nil { - return creds, nil - } - } - if creds := loadCredentialsFromCsv("accounts.csv", accountIndex); creds != nil { - return creds, nil - } - return nil, &CredentialsError{ - Message: fmt.Sprintf("No credentials found at account index %d in accounts.csv", accountIndex), - } - } - - // Tier 3: Environment variables + // Tier 2: Environment variables envPk := os.Getenv("UNSANDBOX_PUBLIC_KEY") envSk := os.Getenv("UNSANDBOX_SECRET_KEY") if envPk != "" && envSk != "" { @@ -201,36 +244,35 @@ func ResolveCredentials(publicKey, secretKey string, accountIndex int) (*Credent }, nil } - // Determine default account index from env - defaultIndex := 0 + // Determine account index + accountIndex := 0 if envAccount := os.Getenv("UNSANDBOX_ACCOUNT"); envAccount != "" { var err error - defaultIndex, err = strconv.Atoi(envAccount) + accountIndex, err = strconv.Atoi(envAccount) if err != nil { - defaultIndex = 0 + accountIndex = 0 } } - // Tier 4: ~/.unsandbox/accounts.csv + // Tier 3: ~/.unsandbox/accounts.csv unsandboxDir, err := getUnsandboxDir() if err == nil { - if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), defaultIndex); creds != nil { + if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), accountIndex); creds != nil { return creds, nil } } - // Tier 5: ./accounts.csv - if creds := loadCredentialsFromCsv("accounts.csv", defaultIndex); creds != nil { + // Tier 4: ./accounts.csv + if creds := loadCredentialsFromCsv("accounts.csv", accountIndex); creds != nil { return creds, nil } return nil, &CredentialsError{ Message: "No credentials found. Please provide via:\n" + " 1. Function arguments (publicKey, secretKey)\n" + - " 2. --account N flag (CSV row N)\n" + - " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + - " 4. ~/.unsandbox/accounts.csv\n" + - " 5. ./accounts.csv", + " 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + + " 3. ~/.unsandbox/accounts.csv\n" + + " 4. ./accounts.csv", } } @@ -301,136 +343,6 @@ func makeRequest(method, path string, creds *Credentials, data interface{}) (map return result, nil } -// SudoChallengeError represents a 428 response requiring OTP confirmation -type SudoChallengeError struct { - ChallengeID string - Message string - StatusCode int - Body []byte -} - -func (e *SudoChallengeError) Error() string { - return fmt.Sprintf("HTTP 428: sudo challenge required (challenge_id: %s)", e.ChallengeID) -} - -// makeRequestWithSudo makes an authenticated HTTP request with optional sudo headers -func makeRequestWithSudo(method, path string, creds *Credentials, data interface{}, sudoOTP, sudoChallengeID string) (map[string]interface{}, int, []byte, error) { - url := APIBase + path - timestamp := time.Now().Unix() - - var body []byte - var err error - if data != nil { - body, err = json.Marshal(data) - if err != nil { - return nil, 0, nil, err - } - } - - signature := signRequest(creds.SecretKey, timestamp, method, path, body) - - req, err := http.NewRequest(method, url, bytes.NewReader(body)) - if err != nil { - return nil, 0, nil, err - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", creds.PublicKey)) - req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp)) - req.Header.Set("X-Signature", signature) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "un-go/2.0") - - // Add sudo headers if provided - if sudoOTP != "" { - req.Header.Set("X-Sudo-OTP", sudoOTP) - } - if sudoChallengeID != "" { - req.Header.Set("X-Sudo-Challenge", sudoChallengeID) - } - - client := &http.Client{Timeout: 120 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, 0, nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, resp.StatusCode, nil, err - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, resp.StatusCode, respBody, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) - } - - var result map[string]interface{} - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, resp.StatusCode, respBody, fmt.Errorf("failed to parse response: %w", err) - } - - return result, resp.StatusCode, respBody, nil -} - -// handleSudoChallenge handles 428 sudo OTP challenge - prompts user for OTP and retries request -func handleSudoChallenge(method, path string, creds *Credentials, data interface{}, responseBody []byte) (map[string]interface{}, error) { - // Extract challenge_id from response - var resp map[string]interface{} - if err := json.Unmarshal(responseBody, &resp); err != nil { - return nil, fmt.Errorf("failed to parse 428 response: %w", err) - } - - challengeID, _ := resp["challenge_id"].(string) - - // Prompt user for OTP - fmt.Fprintf(os.Stderr, "\033[33mConfirmation required. Check your email for a one-time code.\033[0m\n") - fmt.Fprintf(os.Stderr, "Enter OTP: ") - - reader := bufio.NewReader(os.Stdin) - otp, err := reader.ReadString('\n') - if err != nil { - return nil, fmt.Errorf("failed to read OTP: %w", err) - } - otp = strings.TrimSpace(otp) - - if otp == "" { - return nil, fmt.Errorf("operation cancelled") - } - - // Retry the request with sudo headers - result, statusCode, retryBody, err := makeRequestWithSudo(method, path, creds, data, otp, challengeID) - if err != nil { - if statusCode >= 200 && statusCode < 300 { - return result, nil - } - // Extract error message from response if available - if retryBody != nil { - var errResp map[string]interface{} - if json.Unmarshal(retryBody, &errResp) == nil { - if errMsg, ok := errResp["error"].(string); ok { - return nil, fmt.Errorf("%s", errMsg) - } - } - } - return nil, err - } - - fmt.Fprintf(os.Stderr, "\033[32mOperation completed successfully\033[0m\n") - return result, nil -} - -// makeDestructiveRequest makes a request that may require sudo OTP confirmation (for 428 responses) -func makeDestructiveRequest(method, path string, creds *Credentials, data interface{}) (map[string]interface{}, error) { - result, statusCode, respBody, err := makeRequestWithSudo(method, path, creds, data, "", "") - if statusCode == 428 { - return handleSudoChallenge(method, path, creds, data, respBody) - } - if err != nil { - return nil, err - } - return result, nil -} - // getLanguagesCachePath returns path to languages cache file func getLanguagesCachePath() (string, error) { unsandboxDir, err := getUnsandboxDir() @@ -734,9 +646,8 @@ func RestoreSnapshot(creds *Credentials, snapshotID string) (map[string]interfac } // DeleteSnapshot deletes a snapshot (NEW) -// This operation may require sudo OTP confirmation (428 response handling) func DeleteSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) { - return makeDestructiveRequest("DELETE", fmt.Sprintf("/snapshots/%s", snapshotID), creds, nil) + return makeRequest("DELETE", fmt.Sprintf("/snapshots/%s", snapshotID), creds, nil) } // ============================================================================ @@ -860,19 +771,12 @@ func ShellSession(creds *Credentials, sessionID, command string) (map[string]int // Service Operations // ============================================================================ -// InputFile represents a file to upload with a service create or redeploy. -type InputFile struct { - Filename string `json:"filename"` - Content string `json:"content"` // base64-encoded -} - // ServiceOptions contains optional parameters for service creation. type ServiceOptions struct { - NetworkMode string // "zerotrust" (default) or "semitrusted" - Shell string // Shell to use for bootstrap - VCPU int // Number of virtual CPUs - UnfreezeOnDemand bool // Enable automatic unfreezing on HTTP request - InputFiles []InputFile // Files to include (written to /tmp/ in container) + NetworkMode string // "zerotrust" (default) or "semitrusted" + Shell string // Shell to use for bootstrap + VCPU int // Number of virtual CPUs + UnfreezeOnDemand bool // Enable automatic unfreezing on HTTP request } // ServiceUpdateOptions contains optional parameters for service updates. @@ -929,9 +833,6 @@ func CreateService(creds *Credentials, name string, ports []int, bootstrap strin if opts.UnfreezeOnDemand { data["unfreeze_on_demand"] = true } - if len(opts.InputFiles) > 0 { - data["input_files"] = opts.InputFiles - } } return makeRequest("POST", "/services", creds, data) @@ -954,9 +855,8 @@ func UpdateService(creds *Credentials, serviceID string, opts *ServiceUpdateOpti } // DeleteService destroys a service. -// This operation may require sudo OTP confirmation (428 response handling) func DeleteService(creds *Credentials, serviceID string) (map[string]interface{}, error) { - return makeDestructiveRequest("DELETE", fmt.Sprintf("/services/%s", serviceID), creds, nil) + return makeRequest("DELETE", fmt.Sprintf("/services/%s", serviceID), creds, nil) } // FreezeService freezes a service (pauses execution, preserves state). @@ -975,9 +875,8 @@ func LockService(creds *Credentials, serviceID string) (map[string]interface{}, } // UnlockService unlocks a previously locked service. -// This operation may require sudo OTP confirmation (428 response handling) func UnlockService(creds *Credentials, serviceID string) (map[string]interface{}, error) { - return makeDestructiveRequest("POST", fmt.Sprintf("/services/%s/unlock", serviceID), creds, map[string]interface{}{}) + return makeRequest("POST", fmt.Sprintf("/services/%s/unlock", serviceID), creds, map[string]interface{}{}) } // SetUnfreezeOnDemand enables or disables automatic unfreezing on HTTP request. @@ -1047,15 +946,11 @@ func ExportServiceEnv(creds *Credentials, serviceID string) (map[string]interfac // creds: API credentials // serviceID: Service ID // bootstrap: New bootstrap script (empty string to keep existing) -// inputFiles: Optional files to include (written to /tmp/ in container) -func RedeployService(creds *Credentials, serviceID string, bootstrap string, inputFiles []InputFile) (map[string]interface{}, error) { +func RedeployService(creds *Credentials, serviceID string, bootstrap string) (map[string]interface{}, error) { data := make(map[string]interface{}) if bootstrap != "" { data["bootstrap"] = bootstrap } - if len(inputFiles) > 0 { - data["input_files"] = inputFiles - } return makeRequest("POST", fmt.Sprintf("/services/%s/redeploy", serviceID), creds, data) } @@ -1067,20 +962,6 @@ func ExecuteInService(creds *Credentials, serviceID, command string) (map[string return makeRequest("POST", fmt.Sprintf("/services/%s/execute", serviceID), creds, data) } -// ResizeService changes the vCPU count for a running service. -// -// Args: -// -// creds: API credentials -// serviceID: Service ID -// vcpu: New vCPU count (1-8) -func ResizeService(creds *Credentials, serviceID string, vcpu int) (map[string]interface{}, error) { - data := map[string]interface{}{ - "vcpu": vcpu, - } - return makeRequest("POST", fmt.Sprintf("/services/%s/resize", serviceID), creds, data) -} - // ============================================================================ // Additional Snapshot Operations // ============================================================================ @@ -1091,9 +972,8 @@ func LockSnapshot(creds *Credentials, snapshotID string) (map[string]interface{} } // UnlockSnapshot unlocks a previously locked snapshot. -// This operation may require sudo OTP confirmation (428 response handling) func UnlockSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) { - return makeDestructiveRequest("POST", fmt.Sprintf("/snapshots/%s/unlock", snapshotID), creds, map[string]interface{}{}) + return makeRequest("POST", fmt.Sprintf("/snapshots/%s/unlock", snapshotID), creds, map[string]interface{}{}) } // CloneSnapshotOptions contains optional parameters for snapshot cloning. @@ -1301,9 +1181,8 @@ func GetImage(creds *Credentials, imageID string) (map[string]interface{}, error // DeleteImage deletes an LXD container image. // // Note: Locked images cannot be deleted. Use UnlockImage first if needed. -// This operation may require sudo OTP confirmation (428 response handling) func DeleteImage(creds *Credentials, imageID string) (map[string]interface{}, error) { - return makeDestructiveRequest("DELETE", fmt.Sprintf("/images/%s", imageID), creds, nil) + return makeRequest("DELETE", fmt.Sprintf("/images/%s", imageID), creds, nil) } // LockImage locks an LXD container image to prevent deletion. @@ -1312,9 +1191,8 @@ func LockImage(creds *Credentials, imageID string) (map[string]interface{}, erro } // UnlockImage unlocks a previously locked LXD container image. -// This operation may require sudo OTP confirmation (428 response handling) func UnlockImage(creds *Credentials, imageID string) (map[string]interface{}, error) { - return makeDestructiveRequest("POST", fmt.Sprintf("/images/%s/unlock", imageID), creds, map[string]interface{}{}) + return makeRequest("POST", fmt.Sprintf("/images/%s/unlock", imageID), creds, map[string]interface{}{}) } // SetImageVisibility sets the visibility of an LXD container image. @@ -1465,190 +1343,6 @@ func CloneImage(creds *Credentials, imageID string, opts *CloneImageOptions) (ma return makeRequest("POST", fmt.Sprintf("/images/%s/clone", imageID), creds, data) } -// ============================================================================ -// PaaS Logs API -// ============================================================================ - -// LogsFetchOptions contains options for fetching logs. -type LogsFetchOptions struct { - Lines int // Number of lines (1-10000) - Since string // Time window ("1m", "5m", "1h", "1d") - Grep string // Optional filter pattern -} - -// LogsFetch fetches batch logs from the portal. -// -// Args: -// -// creds: API credentials -// source: Log source ("all", "api", "portal", "pool/cammy", "pool/ai") -// opts: Fetch options (can be nil for defaults) -// -// Returns: -// -// JSON response with log entries -func LogsFetch(creds *Credentials, source string, opts *LogsFetchOptions) (map[string]interface{}, error) { - path := "/paas/logs" - params := []string{} - - if source != "" { - params = append(params, fmt.Sprintf("source=%s", source)) - } - - if opts != nil { - if opts.Lines > 0 { - params = append(params, fmt.Sprintf("lines=%d", opts.Lines)) - } - if opts.Since != "" { - params = append(params, fmt.Sprintf("since=%s", opts.Since)) - } - if opts.Grep != "" { - params = append(params, fmt.Sprintf("grep=%s", opts.Grep)) - } - } - - if len(params) > 0 { - path = path + "?" + strings.Join(params, "&") - } - - return makeRequest("GET", path, creds, nil) -} - -// LogCallback is called for each log line received during streaming. -type LogCallback func(source, line string) - -// LogsStream streams logs via Server-Sent Events. -// This function blocks until the stream is closed or an error occurs. -// -// Args: -// -// creds: API credentials -// source: Log source ("all", "api", "portal", "pool/cammy", "pool/ai") -// grep: Optional filter pattern (empty string for no filter) -// callback: Function called for each log line -// -// Returns: -// -// nil on clean shutdown, error on failure -func LogsStream(creds *Credentials, source, grep string, callback LogCallback) error { - path := "/paas/logs/stream" - params := []string{} - - if source != "" { - params = append(params, fmt.Sprintf("source=%s", source)) - } - if grep != "" { - params = append(params, fmt.Sprintf("grep=%s", grep)) - } - - if len(params) > 0 { - path = path + "?" + strings.Join(params, "&") - } - - url := APIBase + path - timestamp := time.Now().Unix() - message := fmt.Sprintf("%d:GET:%s:", timestamp, path) - mac := hmac.New(sha256.New, []byte(creds.SecretKey)) - mac.Write([]byte(message)) - signature := hex.EncodeToString(mac.Sum(nil)) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return err - } - - req.Header.Set("Authorization", "Bearer "+creds.PublicKey) - req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp)) - req.Header.Set("X-Signature", signature) - req.Header.Set("Accept", "text/event-stream") - - client := &http.Client{Timeout: 0} // No timeout for streaming - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("stream error (HTTP %d): %s", resp.StatusCode, string(body)) - } - - reader := bufio.NewReader(resp.Body) - currentSource := source - - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - return nil // Clean shutdown - } - return err - } - - line = strings.TrimSpace(line) - if line == "" { - continue - } - - // Parse SSE format - if strings.HasPrefix(line, "event:") { - // New source from event type - currentSource = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - } else if strings.HasPrefix(line, "data:") { - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if callback != nil && data != "" { - callback(currentSource, data) - } - } - } -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -// SDKVersion is the version of this SDK. -const SDKVersion = "4.3.4" - -// HmacSign computes an HMAC-SHA256 signature for the given message using the secret key. -// Returns the signature as a lowercase hex string. -func HmacSign(secretKey, message string) string { - mac := hmac.New(sha256.New, []byte(secretKey)) - mac.Write([]byte(message)) - return hex.EncodeToString(mac.Sum(nil)) -} - -// HealthCheck checks if the API is reachable and responding. -// Returns true if healthy, false otherwise. -func HealthCheck() bool { - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(APIBase + "/health") - if err != nil { - return false - } - defer resp.Body.Close() - return resp.StatusCode == 200 -} - -// Version returns the SDK version string. -func Version() string { - return SDKVersion -} - -// lastError holds the most recent error message for thread-safe access. -var lastError string - -// SetLastError sets the last error message (internal use). -func SetLastError(msg string) { - lastError = msg -} - -// LastError returns the most recent error message from the SDK. -func LastError() string { - return lastError -} - // ============================================================================ // CLI Implementation // ============================================================================ @@ -1666,19 +1360,18 @@ const ( // CLIOptions holds parsed CLI arguments type CLIOptions struct { // Global options - Shell string - Env []string - Files []string - FilePaths []string - Artifacts bool - OutputDir string - PublicKey string - SecretKey string - Network string - VCPU int - Yes bool - Help bool - AccountIndex int // -1 means not specified + Shell string + Env []string + Files []string + FilePaths []string + Artifacts bool + OutputDir string + PublicKey string + SecretKey string + Network string + VCPU int + Yes bool + Help bool // Command Command string @@ -1934,22 +1627,6 @@ func readFileContents(path string) (string, error) { return string(data), nil } -// buildInputFiles reads files from paths and returns InputFile structs with base64-encoded content. -func buildInputFiles(paths []string) ([]InputFile, error) { - var files []InputFile - for _, fpath := range paths { - data, err := os.ReadFile(fpath) - if err != nil { - return nil, fmt.Errorf("cannot read input file %s: %w", fpath, err) - } - files = append(files, InputFile{ - Filename: filepath.Base(fpath), - Content: base64.StdEncoding.EncodeToString(data), - }) - } - return files, nil -} - // readEnvFile reads environment variables from a .env file func readEnvFile(path string) (map[string]string, error) { data, err := os.ReadFile(path) @@ -2404,15 +2081,7 @@ func runService(creds *Credentials, args []string, opts *CLIOptions) int { // Redeploy service if fs.redeploy != "" { - var inputFiles []InputFile - if len(opts.Files) > 0 { - var err error - inputFiles, err = buildInputFiles(opts.Files) - if err != nil { - return cliError(err.Error(), ExitGeneralError) - } - } - _, err := RedeployService(creds, fs.redeploy, fs.bootstrap, inputFiles) + _, err := RedeployService(creds, fs.redeploy, fs.bootstrap) if err != nil { return cliError(err.Error(), ExitAPIError) } @@ -2471,13 +2140,6 @@ func runService(creds *Credentials, args []string, opts *CLIOptions) int { if opts.VCPU > 0 { serviceOpts.VCPU = opts.VCPU } - if len(opts.Files) > 0 { - inputFiles, err := buildInputFiles(opts.Files) - if err != nil { - return cliError(err.Error(), ExitGeneralError) - } - serviceOpts.InputFiles = inputFiles - } service, err := CreateService(creds, fs.name, ports, bootstrap, serviceOpts) if err != nil { @@ -3173,7 +2835,7 @@ func runLanguages(creds *Credentials, args []string) int { // parseGlobalFlags parses global CLI options func parseGlobalFlags(args []string) (*CLIOptions, []string) { - opts := &CLIOptions{AccountIndex: -1} + opts := &CLIOptions{} remaining := []string{} for i := 0; i < len(args); i++ { @@ -3216,13 +2878,6 @@ func parseGlobalFlags(args []string) (*CLIOptions, []string) { opts.SecretKey = args[i+1] i++ } - case arg == "--account": - if i+1 < len(args) { - if v, err := strconv.Atoi(args[i+1]); err == nil { - opts.AccountIndex = v - } - i++ - } case arg == "-n" || arg == "--network": if i+1 < len(args) { opts.Network = args[i+1] @@ -3276,7 +2931,7 @@ func CliMain() { } // Resolve credentials - creds, err := ResolveCredentials(opts.PublicKey, opts.SecretKey, opts.AccountIndex) + creds, err := ResolveCredentials(opts.PublicKey, opts.SecretKey) if err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err) os.Exit(ExitAuthError) diff --git a/clients/go/sync/src/un_test.go b/clients/go/sync/src/un_test.go deleted file mode 100644 index d9adcd0..0000000 --- a/clients/go/sync/src/un_test.go +++ /dev/null @@ -1,379 +0,0 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - -// Tests for the Go unsandbox SDK -// Run with: go test -v ./tests/ -package un - -import ( - "os" - "testing" -) - -// ============================================================================ -// Unit Tests - Test exported library functions -// ============================================================================ - -func TestDetectLanguage(t *testing.T) { - tests := []struct { - filename string - expected string - }{ - {"script.py", "python"}, - {"script.js", "javascript"}, - {"script.ts", "typescript"}, - {"script.rb", "ruby"}, - {"script.go", "go"}, - {"script.rs", "rust"}, - {"script.c", "c"}, - {"script.cpp", "cpp"}, - {"script.d", "d"}, - {"script.zig", "zig"}, - {"script.sh", "bash"}, - {"script.lua", "lua"}, - {"script.php", "php"}, - {"script.unknown", ""}, - {"script", ""}, - } - - for _, tt := range tests { - t.Run(tt.filename, func(t *testing.T) { - result := DetectLanguage(tt.filename) - if result != tt.expected { - t.Errorf("DetectLanguage(%q) = %q, want %q", tt.filename, result, tt.expected) - } - }) - } -} - -func TestHmacSign(t *testing.T) { - // Test with known values - secretKey := "test-secret" - message := "test-message" - - result := HmacSign(secretKey, message) - - // Should return a 64-character hex string - if len(result) != 64 { - t.Errorf("HmacSign returned %d characters, want 64", len(result)) - } - - // Should be deterministic - result2 := HmacSign(secretKey, message) - if result != result2 { - t.Error("HmacSign is not deterministic") - } - - // Different inputs should produce different outputs - result3 := HmacSign(secretKey, "different-message") - if result == result3 { - t.Error("HmacSign returned same result for different inputs") - } -} - -func TestVersion(t *testing.T) { - version := Version() - if version == "" { - t.Error("Version() returned empty string") - } - // Should be in semver format - if len(version) < 5 { // At minimum "0.0.0" - t.Errorf("Version() = %q, expected semver format", version) - } -} - -func TestLastError(t *testing.T) { - // Set an error - SetLastError("test error message") - - // Retrieve it - err := LastError() - if err != "test error message" { - t.Errorf("LastError() = %q, want %q", err, "test error message") - } - - // Clear it - SetLastError("") - err = LastError() - if err != "" { - t.Errorf("LastError() after clear = %q, want empty", err) - } -} - -func TestCredentialsNew(t *testing.T) { - pk := "unsb-pk-test-test-test-test" - sk := "unsb-sk-test1-test2-test3-test4" - - creds := &Credentials{ - PublicKey: pk, - SecretKey: sk, - } - - if creds.PublicKey != pk { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, pk) - } - if creds.SecretKey != sk { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, sk) - } -} - -// ============================================================================ -// Integration Tests - Test SDK internal consistency -// ============================================================================ - -func TestSignRequest(t *testing.T) { - secretKey := "test-secret-key" - timestamp := int64(1704067200) // 2024-01-01 00:00:00 UTC - method := "POST" - path := "/execute" - body := `{"language":"python","code":"print(1)"}` - - signature := signRequest(secretKey, timestamp, method, path, []byte(body)) - - // Should return a 64-character hex string - if len(signature) != 64 { - t.Errorf("signRequest returned %d characters, want 64", len(signature)) - } - - // Should be deterministic - signature2 := signRequest(secretKey, timestamp, method, path, []byte(body)) - if signature != signature2 { - t.Error("signRequest is not deterministic") - } - - // Different timestamps should produce different signatures - signature3 := signRequest(secretKey, timestamp+1, method, path, []byte(body)) - if signature == signature3 { - t.Error("signRequest returned same result for different timestamps") - } -} - -func TestResolveCredentialsFromEnv(t *testing.T) { - // Save original env vars - origPK := os.Getenv("UNSANDBOX_PUBLIC_KEY") - origSK := os.Getenv("UNSANDBOX_SECRET_KEY") - - // Set test env vars - testPK := "unsb-pk-test-test-test-test" - testSK := "unsb-sk-test1-test2-test3-test4" - os.Setenv("UNSANDBOX_PUBLIC_KEY", testPK) - os.Setenv("UNSANDBOX_SECRET_KEY", testSK) - - // Test - creds, err := ResolveCredentials("", "", -1) - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - if creds.PublicKey != testPK { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, testPK) - } - if creds.SecretKey != testSK { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, testSK) - } - - // Restore original env vars - if origPK != "" { - os.Setenv("UNSANDBOX_PUBLIC_KEY", origPK) - } else { - os.Unsetenv("UNSANDBOX_PUBLIC_KEY") - } - if origSK != "" { - os.Setenv("UNSANDBOX_SECRET_KEY", origSK) - } else { - os.Unsetenv("UNSANDBOX_SECRET_KEY") - } -} - -func TestResolveCredentialsFromArgs(t *testing.T) { - testPK := "unsb-pk-arg1-arg2-arg3-arg4" - testSK := "unsb-sk-arg11-arg22-arg33-arg44" - - creds, err := ResolveCredentials(testPK, testSK, -1) - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - if creds.PublicKey != testPK { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, testPK) - } - if creds.SecretKey != testSK { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, testSK) - } -} - -// ============================================================================ -// Functional Tests - Test against real API (requires credentials) -// ============================================================================ - -func getTestCredentials(t *testing.T) *Credentials { - creds, err := ResolveCredentials("", "", -1) - if err != nil { - t.Skip("No credentials available for functional tests") - } - return creds -} - -func TestHealthCheck(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - healthy := HealthCheck() - if !healthy { - t.Log("API health check returned unhealthy (API may be unreachable)") - } -} - -func TestGetLanguages(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - languages, err := GetLanguages(creds) - if err != nil { - t.Fatalf("GetLanguages failed: %v", err) - } - - if len(languages) == 0 { - t.Error("GetLanguages returned empty list") - } - - // Should include common languages - hasPython := false - hasJavascript := false - for _, lang := range languages { - if lang == "python" { - hasPython = true - } - if lang == "javascript" { - hasJavascript = true - } - } - - if !hasPython { - t.Error("GetLanguages missing 'python'") - } - if !hasJavascript { - t.Error("GetLanguages missing 'javascript'") - } -} - -func TestValidateKeys(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - result, err := ValidateKeys(creds) - if err != nil { - t.Fatalf("ValidateKeys failed: %v", err) - } - - if result == nil { - t.Error("ValidateKeys returned nil") - } -} - -func TestExecuteCode(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - result, err := ExecuteCode(creds, "python", "print('hello from go test')") - if err != nil { - t.Fatalf("ExecuteCode failed: %v", err) - } - - if result == nil { - t.Error("ExecuteCode returned nil") - } - - // Check for stdout in result - if stdout, ok := result["stdout"].(string); ok { - if stdout == "" { - t.Error("ExecuteCode returned empty stdout") - } - } -} - -func TestListSessions(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - sessions, err := ListSessions(creds) - if err != nil { - t.Fatalf("ListSessions failed: %v", err) - } - - // Should return a list (possibly empty) - if sessions == nil { - t.Error("ListSessions returned nil") - } -} - -func TestListServices(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - services, err := ListServices(creds) - if err != nil { - t.Fatalf("ListServices failed: %v", err) - } - - // Should return a list (possibly empty) - if services == nil { - t.Error("ListServices returned nil") - } -} - -func TestListSnapshots(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - snapshots, err := ListSnapshots(creds) - if err != nil { - t.Fatalf("ListSnapshots failed: %v", err) - } - - // Should return a list (possibly empty) - if snapshots == nil { - t.Error("ListSnapshots returned nil") - } -} - -func TestListImages(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - images, err := ListImages(creds, "") - if err != nil { - t.Fatalf("ListImages failed: %v", err) - } - - // Should return a list (possibly empty) - if images == nil { - t.Error("ListImages returned nil") - } -} diff --git a/clients/go/sync/tests/functional_test.go b/clients/go/sync/tests/functional_test.go deleted file mode 100644 index f27b60e..0000000 --- a/clients/go/sync/tests/functional_test.go +++ /dev/null @@ -1,181 +0,0 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - -// UN Go SDK - Functional Tests -// -// Tests library functions against real API. -// Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY -// -// Usage: -// Copy to sync/src/ then: go test -v -run TestFunctional -package un - -import ( - "os" - "strings" - "testing" -) - -func skipIfNoCreds(t *testing.T) *Credentials { - t.Helper() - pk := os.Getenv("UNSANDBOX_PUBLIC_KEY") - sk := os.Getenv("UNSANDBOX_SECRET_KEY") - if pk == "" || sk == "" { - t.Skip("UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY required") - } - return &Credentials{PublicKey: pk, SecretKey: sk} -} - -func TestFunctionalHealthCheck(t *testing.T) { - _ = skipIfNoCreds(t) - result := HealthCheck() - // HealthCheck returns a bool - just verify it runs without panic - t.Logf("HealthCheck: %v", result) -} - -func TestFunctionalValidateKeys(t *testing.T) { - creds := skipIfNoCreds(t) - info, err := ValidateKeys(creds) - if err != nil { - t.Fatalf("ValidateKeys error: %v", err) - } - if info == nil { - t.Fatal("ValidateKeys returned nil") - } - valid, ok := info["valid"] - if !ok { - t.Fatal("ValidateKeys result missing 'valid' key") - } - if valid != true { - t.Errorf("Keys should be valid, got: %v", valid) - } -} - -func TestFunctionalGetLanguages(t *testing.T) { - creds := skipIfNoCreds(t) - langs, err := GetLanguages(creds) - if err != nil { - t.Fatalf("GetLanguages error: %v", err) - } - if len(langs) == 0 { - t.Fatal("GetLanguages returned empty list") - } - foundPython := false - for _, l := range langs { - if l == "python" { - foundPython = true - break - } - } - if !foundPython { - t.Error("python not found in languages list") - } - t.Logf("Found %d languages", len(langs)) -} - -func TestFunctionalExecute(t *testing.T) { - creds := skipIfNoCreds(t) - result, err := ExecuteCode(creds, "python", "print('hello from Go SDK')") - if err != nil { - t.Fatalf("ExecuteCode error: %v", err) - } - if result == nil { - t.Fatal("ExecuteCode returned nil") - } - stdout, _ := result["stdout"].(string) - if !strings.Contains(stdout, "hello from Go SDK") { - t.Errorf("stdout should contain 'hello from Go SDK', got: %s", stdout) - } - exitCode, _ := result["exit_code"].(float64) - if exitCode != 0 { - t.Errorf("exit_code should be 0, got: %v", exitCode) - } -} - -func TestFunctionalExecuteError(t *testing.T) { - creds := skipIfNoCreds(t) - result, err := ExecuteCode(creds, "python", "import sys; sys.exit(1)") - if err != nil { - t.Fatalf("ExecuteCode error: %v", err) - } - if result == nil { - t.Fatal("ExecuteCode returned nil") - } - exitCode, _ := result["exit_code"].(float64) - if exitCode != 1 { - t.Errorf("exit_code should be 1, got: %v", exitCode) - } -} - -func TestFunctionalSessionList(t *testing.T) { - creds := skipIfNoCreds(t) - sessions, err := ListSessions(creds) - if err != nil { - t.Fatalf("ListSessions error: %v", err) - } - t.Logf("Found %d sessions", len(sessions)) -} - -func TestFunctionalSessionLifecycle(t *testing.T) { - creds := skipIfNoCreds(t) - - // Create - session, err := CreateSession(creds, nil) - if err != nil { - t.Fatalf("CreateSession error: %v", err) - } - if session == nil { - t.Fatal("CreateSession returned nil") - } - sessionID, _ := session["id"].(string) - if sessionID == "" { - t.Fatal("Session missing id") - } - t.Logf("Created session: %s", sessionID) - - // Destroy - _, err = DeleteSession(creds, sessionID) - if err != nil { - t.Errorf("DeleteSession error: %v", err) - } -} - -func TestFunctionalServiceList(t *testing.T) { - creds := skipIfNoCreds(t) - services, err := ListServices(creds) - if err != nil { - t.Fatalf("ListServices error: %v", err) - } - t.Logf("Found %d services", len(services)) -} - -func TestFunctionalSnapshotList(t *testing.T) { - creds := skipIfNoCreds(t) - snapshots, err := ListSnapshots(creds) - if err != nil { - t.Fatalf("ListSnapshots error: %v", err) - } - t.Logf("Found %d snapshots", len(snapshots)) -} - -func TestFunctionalImageList(t *testing.T) { - creds := skipIfNoCreds(t) - images, err := ListImages(creds, "") - if err != nil { - t.Fatalf("ListImages error: %v", err) - } - t.Logf("Found %d images", len(images)) -} diff --git a/clients/go/sync/tests/test_account_flag.sh b/clients/go/sync/tests/test_account_flag.sh deleted file mode 100755 index c1d2f71..0000000 --- a/clients/go/sync/tests/test_account_flag.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -# Integration test: --account N flag must take priority over env vars -# -# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY (real credentials) -# Run: make test-integration OR bash tests/test_account_flag.sh -# -# The defect this guards against: ResolveCredentials() checked env vars before -# account_index, so --account N was silently ignored when env vars existed. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SRC_DIR="$SCRIPT_DIR/../src" -UN_BIN="$SCRIPT_DIR/../un" - -RED='\033[31m' -GREEN='\033[32m' -NC='\033[0m' - -pass=0 -fail=0 - -check() { - local desc="$1" result="$2" - if [ "$result" = "pass" ]; then - printf " ${GREEN}✓${NC} %s\n" "$desc" - pass=$((pass + 1)) - else - printf " ${RED}✗${NC} %s\n" "$desc" - fail=$((fail + 1)) - fi -} - -# Require real credentials to be available -if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then - echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" - exit 0 -fi - -# Build the binary if it doesn't exist or source is newer -if [ ! -x "$UN_BIN" ] || [ "$SRC_DIR/un.go" -nt "$UN_BIN" ]; then - echo "Building Go binary..." - (cd "$SRC_DIR" && go build -o "$UN_BIN" .) || { - echo "FAIL: go build failed" - exit 1 - } -fi - -if [ ! -x "$UN_BIN" ]; then - echo "FAIL: UN binary not found at $UN_BIN — run go build first" - exit 1 -fi - -REAL_PK="$UNSANDBOX_PUBLIC_KEY" -REAL_SK="$UNSANDBOX_SECRET_KEY" - -# Temporary HOME with accounts.csv: -# index 0: garbage credentials (will always 401) -# index 1: real credentials (will succeed) -TMPHOME="$(mktemp -d)" -mkdir -p "$TMPHOME/.unsandbox" -trap 'rm -rf "$TMPHOME"' EXIT - -cat > "$TMPHOME/.unsandbox/accounts.csv" <&1 || true) - -if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then - check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "pass" -else - check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "fail" - echo " output: $OUT" -fi - -# --- Test 2: --account 0 should use CSV row 0 (garbage creds) → 401 --- -# Even though real env vars are set, explicit --account 0 should pick garbage creds -OUT=$(HOME="$TMPHOME" \ - UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ - UNSANDBOX_SECRET_KEY="$REAL_SK" \ - "$UN_BIN" --account 0 key 2>&1 || true) - -if echo "$OUT" | grep -qi "401\|unauthorized\|invalid\|error"; then - check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "pass" -else - check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "fail" - echo " output: $OUT" -fi - -# --- Test 3: no --account flag, real env vars → env vars win over garbage CSV row 0 --- -OUT=$(HOME="$TMPHOME" \ - UNSANDBOX_PUBLIC_KEY="$REAL_PK" \ - UNSANDBOX_SECRET_KEY="$REAL_SK" \ - "$UN_BIN" key 2>&1 || true) - -if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then - check "No --account flag: env vars used, succeeds" "pass" -else - check "No --account flag: env vars used, succeeds" "fail" - echo " output: $OUT" -fi - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -printf "Passed: ${GREEN}%d${NC} Failed: ${RED}%d${NC}\n" "$pass" "$fail" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -[ "$fail" -eq 0 ] diff --git a/clients/go/sync/tests/un_test.go b/clients/go/sync/tests/un_test.go deleted file mode 100644 index d9adcd0..0000000 --- a/clients/go/sync/tests/un_test.go +++ /dev/null @@ -1,379 +0,0 @@ -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - -// Tests for the Go unsandbox SDK -// Run with: go test -v ./tests/ -package un - -import ( - "os" - "testing" -) - -// ============================================================================ -// Unit Tests - Test exported library functions -// ============================================================================ - -func TestDetectLanguage(t *testing.T) { - tests := []struct { - filename string - expected string - }{ - {"script.py", "python"}, - {"script.js", "javascript"}, - {"script.ts", "typescript"}, - {"script.rb", "ruby"}, - {"script.go", "go"}, - {"script.rs", "rust"}, - {"script.c", "c"}, - {"script.cpp", "cpp"}, - {"script.d", "d"}, - {"script.zig", "zig"}, - {"script.sh", "bash"}, - {"script.lua", "lua"}, - {"script.php", "php"}, - {"script.unknown", ""}, - {"script", ""}, - } - - for _, tt := range tests { - t.Run(tt.filename, func(t *testing.T) { - result := DetectLanguage(tt.filename) - if result != tt.expected { - t.Errorf("DetectLanguage(%q) = %q, want %q", tt.filename, result, tt.expected) - } - }) - } -} - -func TestHmacSign(t *testing.T) { - // Test with known values - secretKey := "test-secret" - message := "test-message" - - result := HmacSign(secretKey, message) - - // Should return a 64-character hex string - if len(result) != 64 { - t.Errorf("HmacSign returned %d characters, want 64", len(result)) - } - - // Should be deterministic - result2 := HmacSign(secretKey, message) - if result != result2 { - t.Error("HmacSign is not deterministic") - } - - // Different inputs should produce different outputs - result3 := HmacSign(secretKey, "different-message") - if result == result3 { - t.Error("HmacSign returned same result for different inputs") - } -} - -func TestVersion(t *testing.T) { - version := Version() - if version == "" { - t.Error("Version() returned empty string") - } - // Should be in semver format - if len(version) < 5 { // At minimum "0.0.0" - t.Errorf("Version() = %q, expected semver format", version) - } -} - -func TestLastError(t *testing.T) { - // Set an error - SetLastError("test error message") - - // Retrieve it - err := LastError() - if err != "test error message" { - t.Errorf("LastError() = %q, want %q", err, "test error message") - } - - // Clear it - SetLastError("") - err = LastError() - if err != "" { - t.Errorf("LastError() after clear = %q, want empty", err) - } -} - -func TestCredentialsNew(t *testing.T) { - pk := "unsb-pk-test-test-test-test" - sk := "unsb-sk-test1-test2-test3-test4" - - creds := &Credentials{ - PublicKey: pk, - SecretKey: sk, - } - - if creds.PublicKey != pk { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, pk) - } - if creds.SecretKey != sk { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, sk) - } -} - -// ============================================================================ -// Integration Tests - Test SDK internal consistency -// ============================================================================ - -func TestSignRequest(t *testing.T) { - secretKey := "test-secret-key" - timestamp := int64(1704067200) // 2024-01-01 00:00:00 UTC - method := "POST" - path := "/execute" - body := `{"language":"python","code":"print(1)"}` - - signature := signRequest(secretKey, timestamp, method, path, []byte(body)) - - // Should return a 64-character hex string - if len(signature) != 64 { - t.Errorf("signRequest returned %d characters, want 64", len(signature)) - } - - // Should be deterministic - signature2 := signRequest(secretKey, timestamp, method, path, []byte(body)) - if signature != signature2 { - t.Error("signRequest is not deterministic") - } - - // Different timestamps should produce different signatures - signature3 := signRequest(secretKey, timestamp+1, method, path, []byte(body)) - if signature == signature3 { - t.Error("signRequest returned same result for different timestamps") - } -} - -func TestResolveCredentialsFromEnv(t *testing.T) { - // Save original env vars - origPK := os.Getenv("UNSANDBOX_PUBLIC_KEY") - origSK := os.Getenv("UNSANDBOX_SECRET_KEY") - - // Set test env vars - testPK := "unsb-pk-test-test-test-test" - testSK := "unsb-sk-test1-test2-test3-test4" - os.Setenv("UNSANDBOX_PUBLIC_KEY", testPK) - os.Setenv("UNSANDBOX_SECRET_KEY", testSK) - - // Test - creds, err := ResolveCredentials("", "", -1) - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - if creds.PublicKey != testPK { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, testPK) - } - if creds.SecretKey != testSK { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, testSK) - } - - // Restore original env vars - if origPK != "" { - os.Setenv("UNSANDBOX_PUBLIC_KEY", origPK) - } else { - os.Unsetenv("UNSANDBOX_PUBLIC_KEY") - } - if origSK != "" { - os.Setenv("UNSANDBOX_SECRET_KEY", origSK) - } else { - os.Unsetenv("UNSANDBOX_SECRET_KEY") - } -} - -func TestResolveCredentialsFromArgs(t *testing.T) { - testPK := "unsb-pk-arg1-arg2-arg3-arg4" - testSK := "unsb-sk-arg11-arg22-arg33-arg44" - - creds, err := ResolveCredentials(testPK, testSK, -1) - if err != nil { - t.Fatalf("ResolveCredentials failed: %v", err) - } - if creds.PublicKey != testPK { - t.Errorf("PublicKey = %q, want %q", creds.PublicKey, testPK) - } - if creds.SecretKey != testSK { - t.Errorf("SecretKey = %q, want %q", creds.SecretKey, testSK) - } -} - -// ============================================================================ -// Functional Tests - Test against real API (requires credentials) -// ============================================================================ - -func getTestCredentials(t *testing.T) *Credentials { - creds, err := ResolveCredentials("", "", -1) - if err != nil { - t.Skip("No credentials available for functional tests") - } - return creds -} - -func TestHealthCheck(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - healthy := HealthCheck() - if !healthy { - t.Log("API health check returned unhealthy (API may be unreachable)") - } -} - -func TestGetLanguages(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - languages, err := GetLanguages(creds) - if err != nil { - t.Fatalf("GetLanguages failed: %v", err) - } - - if len(languages) == 0 { - t.Error("GetLanguages returned empty list") - } - - // Should include common languages - hasPython := false - hasJavascript := false - for _, lang := range languages { - if lang == "python" { - hasPython = true - } - if lang == "javascript" { - hasJavascript = true - } - } - - if !hasPython { - t.Error("GetLanguages missing 'python'") - } - if !hasJavascript { - t.Error("GetLanguages missing 'javascript'") - } -} - -func TestValidateKeys(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - result, err := ValidateKeys(creds) - if err != nil { - t.Fatalf("ValidateKeys failed: %v", err) - } - - if result == nil { - t.Error("ValidateKeys returned nil") - } -} - -func TestExecuteCode(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - result, err := ExecuteCode(creds, "python", "print('hello from go test')") - if err != nil { - t.Fatalf("ExecuteCode failed: %v", err) - } - - if result == nil { - t.Error("ExecuteCode returned nil") - } - - // Check for stdout in result - if stdout, ok := result["stdout"].(string); ok { - if stdout == "" { - t.Error("ExecuteCode returned empty stdout") - } - } -} - -func TestListSessions(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - sessions, err := ListSessions(creds) - if err != nil { - t.Fatalf("ListSessions failed: %v", err) - } - - // Should return a list (possibly empty) - if sessions == nil { - t.Error("ListSessions returned nil") - } -} - -func TestListServices(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - services, err := ListServices(creds) - if err != nil { - t.Fatalf("ListServices failed: %v", err) - } - - // Should return a list (possibly empty) - if services == nil { - t.Error("ListServices returned nil") - } -} - -func TestListSnapshots(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - snapshots, err := ListSnapshots(creds) - if err != nil { - t.Fatalf("ListSnapshots failed: %v", err) - } - - // Should return a list (possibly empty) - if snapshots == nil { - t.Error("ListSnapshots returned nil") - } -} - -func TestListImages(t *testing.T) { - if testing.Short() { - t.Skip("Skipping functional test in short mode") - } - - creds := getTestCredentials(t) - images, err := ListImages(creds, "") - if err != nil { - t.Fatalf("ListImages failed: %v", err) - } - - // Should return a list (possibly empty) - if images == nil { - t.Error("ListImages returned nil") - } -} diff --git a/clients/groovy/sync/src/un.groovy b/clients/groovy/sync/src/un.groovy index 05c2667..c164e9e 100644 --- a/clients/groovy/sync/src/un.groovy +++ b/clients/groovy/sync/src/un.groovy @@ -1,4 +1,3 @@ -#!/usr/bin/env groovy // PUBLIC DOMAIN - NO LICENSE, NO WARRANTY // // This is free public domain software for the public good of a permacomputer hosted @@ -73,7 +72,7 @@ * * * @author Permacomputer Project - * @version 4.3.4 + * @version 4.2.28 */ import javax.crypto.Mac @@ -222,67 +221,42 @@ def signRequest(String secretKey, long timestamp, String method, String path, St * @return Tuple of [publicKey, secretKey] * @throws AuthenticationError if no credentials found */ -def loadAccountsFromCsv(File path) { - def validAccounts = [] - if (!path.exists()) return validAccounts - try { - def lines = path.text.trim().split('\n') - lines.each { line -> - def trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) return - if (trimmed.contains(',')) { - def parts = trimmed.split(',', 2) - def pk = parts[0].trim() - def sk = parts[1].trim() - if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { - validAccounts << [pk, sk] - } - } - } - } catch (Exception e) { - // Ignore file read errors - } - return validAccounts -} - -def getCredentials(String publicKey = null, String secretKey = null, int accountIndex = -1) { +def getCredentials(String publicKey = null, String secretKey = null, int accountIndex = 0) { // Priority 1: Function arguments if (publicKey && secretKey) { return [publicKey, secretKey] } - // Priority 2: --account N => accounts.csv row N (bypasses env vars) - if (accountIndex >= 0) { - def searchPaths = [ - new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), - new File('accounts.csv') - ] - for (path in searchPaths) { - def accts = loadAccountsFromCsv(path) - if (accts && accountIndex < accts.size()) { - return accts[accountIndex] - } - } - throw new AuthenticationError("No account at index ${accountIndex} in accounts.csv") - } - - // Priority 3: Environment variables + // Priority 2: Environment variables def envPk = System.getenv('UNSANDBOX_PUBLIC_KEY') def envSk = System.getenv('UNSANDBOX_SECRET_KEY') if (envPk && envSk) { return [envPk, envSk] } - // Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) - def defaultIdx = (System.getenv('UNSANDBOX_ACCOUNT') ?: '0').toInteger() - def searchPaths = [ - new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), - new File('accounts.csv') - ] - for (path in searchPaths) { - def accts = loadAccountsFromCsv(path) - if (accts && defaultIdx < accts.size()) { - return accts[defaultIdx] + // Priority 3: Config file + def accountsPath = new File(System.getProperty('user.home'), '.unsandbox/accounts.csv') + if (accountsPath.exists()) { + try { + def lines = accountsPath.text.trim().split('\n') + def validAccounts = [] + lines.each { line -> + def trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + if (trimmed.contains(',')) { + def parts = trimmed.split(',', 2) + def pk = parts[0] + def sk = parts[1] + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + validAccounts << [pk, sk] + } + } + } + if (validAccounts && accountIndex < validAccounts.size()) { + return validAccounts[accountIndex] + } + } catch (Exception e) { + // Ignore file read errors } } @@ -292,14 +266,21 @@ def getCredentials(String publicKey = null, String secretKey = null, int account ) } -// Legacy compatibility - now delegates to getCredentials for proper priority -def getApiKeys(argsKey, int accountIndex = -1) { - try { - return getCredentials(argsKey ?: null, null, accountIndex) - } catch (AuthenticationError e) { - System.err.println("${RED}Error: ${e.message}${RESET}") - System.exit(1) +// Legacy compatibility +def getApiKeys(argsKey) { + def publicKey = System.getenv('UNSANDBOX_PUBLIC_KEY') + def secretKey = System.getenv('UNSANDBOX_SECRET_KEY') + + if (!publicKey || !secretKey) { + def legacyKey = argsKey ?: System.getenv('UNSANDBOX_API_KEY') + if (!legacyKey) { + System.err.println("${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}") + System.exit(1) + } + return [legacyKey, null] } + + return [publicKey, secretKey] } // ============================================================================ @@ -379,177 +360,6 @@ def apiRequestPatch(endpoint, data, publicKey, secretKey) { return apiRequest(endpoint, 'PATCH', data, publicKey, secretKey) } -/** - * Exception for 428 Sudo Challenge requiring OTP confirmation. - */ -class SudoChallengeError extends UnsandboxError { - String challengeId - String responseBody - - SudoChallengeError(String challengeId, String responseBody) { - super("Sudo challenge required") - this.challengeId = challengeId - this.responseBody = responseBody - } -} - -/** - * Make API request for destructive operations with 428 handling. - * Uses curl with -w to capture HTTP status code. - */ -def apiRequestDestructive(String endpoint, String method, data, String publicKey, String secretKey) { - def tempFile = File.createTempFile('un_request_', '.json') - def statusFile = File.createTempFile('un_status_', '.txt') - try { - def body = "" - if (data) { - body = data instanceof Map ? JsonOutput.toJson(data) : data.toString() - tempFile.text = body - } - - def timestamp = (System.currentTimeMillis() / 1000) as long - def signature = signRequest(secretKey, timestamp, method, endpoint, body) - - def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", - '-H', "Content-Type: application/json", - '-H', "Authorization: Bearer ${publicKey}", - '-H', "X-Timestamp: ${timestamp}", - '-H', "X-Signature: ${signature}", - '-w', '\\n%{http_code}', - '-o', statusFile.absolutePath] - - if (data) { - curlCmd += ['-d', "@${tempFile.absolutePath}"] - } - - def proc = curlCmd.execute() - def statusOutput = proc.text.trim() - proc.waitFor() - - def responseBody = statusFile.exists() ? statusFile.text : "" - def httpCode = 0 - try { - httpCode = statusOutput.toInteger() - } catch (Exception e) { - // Failed to parse status code - } - - if (httpCode == 428) { - // Extract challenge_id from response - def challengeId = null - try { - def parsed = new JsonSlurper().parseText(responseBody) - challengeId = parsed?.challenge_id - } catch (Exception e) { - // Ignore parse errors - } - throw new SudoChallengeError(challengeId, responseBody) - } - - if (httpCode < 200 || httpCode >= 300) { - throw new APIError("HTTP ${httpCode} - ${responseBody}", httpCode, responseBody) - } - - try { - return new JsonSlurper().parseText(responseBody) - } catch (Exception e) { - return [raw: responseBody] - } - } finally { - tempFile.delete() - statusFile.delete() - } -} - -/** - * Make API request with sudo OTP headers. - */ -def apiRequestWithSudo(String endpoint, String method, data, String publicKey, String secretKey, String otp, String challengeId) { - def tempFile = File.createTempFile('un_request_', '.json') - def statusFile = File.createTempFile('un_status_', '.txt') - try { - def body = "" - if (data) { - body = data instanceof Map ? JsonOutput.toJson(data) : data.toString() - tempFile.text = body - } - - def timestamp = (System.currentTimeMillis() / 1000) as long - def signature = signRequest(secretKey, timestamp, method, endpoint, body) - - def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", - '-H', "Content-Type: application/json", - '-H', "Authorization: Bearer ${publicKey}", - '-H', "X-Timestamp: ${timestamp}", - '-H', "X-Signature: ${signature}", - '-H', "X-Sudo-OTP: ${otp}", - '-w', '\\n%{http_code}', - '-o', statusFile.absolutePath] - - if (challengeId) { - curlCmd += ['-H', "X-Sudo-Challenge: ${challengeId}"] - } - - if (data) { - curlCmd += ['-d', "@${tempFile.absolutePath}"] - } - - def proc = curlCmd.execute() - def statusOutput = proc.text.trim() - proc.waitFor() - - def responseBody = statusFile.exists() ? statusFile.text : "" - def httpCode = 0 - try { - httpCode = statusOutput.toInteger() - } catch (Exception e) { - // Failed to parse status code - } - - if (httpCode < 200 || httpCode >= 300) { - throw new APIError("HTTP ${httpCode} - ${responseBody}", httpCode, responseBody) - } - - try { - return new JsonSlurper().parseText(responseBody) - } catch (Exception e) { - return [raw: responseBody] - } - } finally { - tempFile.delete() - statusFile.delete() - } -} - -/** - * Handle sudo challenge by prompting for OTP and retrying. - */ -def handleSudoChallenge(String challengeId, String method, String endpoint, data, String publicKey, String secretKey) { - System.err.println("${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}") - System.err.print("Enter OTP: ") - System.err.flush() - - def reader = new BufferedReader(new InputStreamReader(System.in)) - def otp = reader.readLine()?.trim() - - if (!otp) { - throw new RuntimeException("Operation cancelled - no OTP provided") - } - - return apiRequestWithSudo(endpoint, method, data, publicKey, secretKey, otp, challengeId) -} - -/** - * Execute a destructive operation with 428 sudo challenge handling. - */ -def executeDestructive(String endpoint, String method, data, String publicKey, String secretKey) { - try { - return apiRequestDestructive(endpoint, method, data, publicKey, secretKey) - } catch (SudoChallengeError e) { - return handleSudoChallenge(e.challengeId, method, endpoint, data, publicKey, secretKey) - } -} - def apiRequestText(endpoint, method, body, publicKey, secretKey) { def tempFile = File.createTempFile('un_env_', '.txt') try { @@ -624,7 +434,7 @@ def execute(String language, String code, Map options = [:]) { def (publicKey, secretKey) = getCredentials( options.publicKey, options.secretKey, - options.accountIndex != null ? options.accountIndex : -1 + options.accountIndex ?: 0 ) def payload = [ @@ -674,7 +484,7 @@ def executeAsync(String language, String code, Map options = [:]) { def (publicKey, secretKey) = getCredentials( options.publicKey, options.secretKey, - options.accountIndex != null ? options.accountIndex : -1 + options.accountIndex ?: 0 ) def payload = [ @@ -721,7 +531,7 @@ def run(String code, Map options = [:]) { def (publicKey, secretKey) = getCredentials( options.publicKey, options.secretKey, - options.accountIndex != null ? options.accountIndex : -1 + options.accountIndex ?: 0 ) def ttl = options.ttl ?: DEFAULT_TTL @@ -746,7 +556,7 @@ def runAsync(String code, Map options = [:]) { def (publicKey, secretKey) = getCredentials( options.publicKey, options.secretKey, - options.accountIndex != null ? options.accountIndex : -1 + options.accountIndex ?: 0 ) def ttl = options.ttl ?: DEFAULT_TTL @@ -940,597 +750,6 @@ def languages(Map options = [:]) { return result } -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Get SDK version string. - */ -def version() { - return "4.2.0" -} - -/** - * Check API health status. - */ -def healthCheck() { - try { - def url = new URL("${API_BASE}/health") - def connection = url.openConnection() as java.net.HttpURLConnection - connection.requestMethod = "GET" - connection.connectTimeout = 5000 - connection.readTimeout = 5000 - return connection.responseCode == 200 - } catch (Exception e) { - return false - } -} - -/** - * Generate HMAC-SHA256 signature. - */ -def hmacSign(String secretKey, String message) { - def mac = Mac.getInstance("HmacSHA256") - mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) - return mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() -} - -// ============================================================================ -// Session Functions -// ============================================================================ - -/** - * List all sessions. - */ -def sessionList(Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def result = apiRequest('/sessions', 'GET', null, publicKey, secretKey) - return result.sessions ?: [] -} - -/** - * Get session details. - */ -def sessionGet(String sessionId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/sessions/${sessionId}", 'GET', null, publicKey, secretKey) -} - -/** - * Create a new session. - */ -def sessionCreate(Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [ - network_mode: options.networkMode ?: 'zerotrust', - shell: options.shell ?: 'bash' - ] - if (options.vcpu) payload.vcpu = options.vcpu - return apiRequest('/sessions', 'POST', payload, publicKey, secretKey) -} - -/** - * Destroy a session. - */ -def sessionDestroy(String sessionId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/sessions/${sessionId}", 'DELETE', null, publicKey, secretKey) -} - -/** - * Freeze a session. - */ -def sessionFreeze(String sessionId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/sessions/${sessionId}/freeze", 'POST', null, publicKey, secretKey) -} - -/** - * Unfreeze a session. - */ -def sessionUnfreeze(String sessionId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/sessions/${sessionId}/unfreeze", 'POST', null, publicKey, secretKey) -} - -/** - * Boost a session. - */ -def sessionBoost(String sessionId, int vcpu = 2, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/sessions/${sessionId}/boost", 'POST', [vcpu: vcpu], publicKey, secretKey) -} - -/** - * Unboost a session. - */ -def sessionUnboost(String sessionId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/sessions/${sessionId}/unboost", 'POST', null, publicKey, secretKey) -} - -/** - * Execute command in a session. - */ -def sessionExecute(String sessionId, String command, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/sessions/${sessionId}/shell", 'POST', [command: command], publicKey, secretKey) -} - -// ============================================================================ -// Service Functions -// ============================================================================ - -/** - * List all services. - */ -def serviceList(Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def result = apiRequest('/services', 'GET', null, publicKey, secretKey) - return result.services ?: [] -} - -/** - * Get service details. - */ -def serviceGet(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/services/${serviceId}", 'GET', null, publicKey, secretKey) -} - -/** - * Create a new service. - */ -def serviceCreate(String name, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [name: name] - if (options.ports) payload.ports = options.ports.split(',').collect { it.trim().toInteger() } - if (options.domains) payload.domains = options.domains - if (options.bootstrap) payload.bootstrap = options.bootstrap - if (options.networkMode) payload.network_mode = options.networkMode - def result = apiRequest('/services', 'POST', payload, publicKey, secretKey) - return result.id -} - -/** - * Destroy a service. - */ -def serviceDestroy(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return executeDestructive("/services/${serviceId}", 'DELETE', null, publicKey, secretKey) -} - -/** - * Freeze a service. - */ -def serviceFreeze(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/services/${serviceId}/freeze", 'POST', null, publicKey, secretKey) -} - -/** - * Unfreeze a service. - */ -def serviceUnfreeze(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/services/${serviceId}/unfreeze", 'POST', null, publicKey, secretKey) -} - -/** - * Lock a service. - */ -def serviceLock(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/services/${serviceId}/lock", 'POST', null, publicKey, secretKey) -} - -/** - * Unlock a service. - */ -def serviceUnlock(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return executeDestructive("/services/${serviceId}/unlock", 'POST', null, publicKey, secretKey) -} - -/** - * Set unfreeze on demand for a service. - */ -def serviceSetUnfreezeOnDemand(String serviceId, boolean enabled, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequestPatch("/services/${serviceId}", [unfreeze_on_demand: enabled], publicKey, secretKey) -} - -/** - * Redeploy a service. - */ -def serviceRedeploy(String serviceId, String bootstrap = null, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = bootstrap ? [bootstrap: bootstrap] : [:] - return apiRequest("/services/${serviceId}/redeploy", 'POST', payload, publicKey, secretKey) -} - -/** - * Get service logs. - */ -def serviceLogs(String serviceId, boolean allLogs = false, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def path = allLogs ? "/services/${serviceId}/logs?all=true" : "/services/${serviceId}/logs" - def result = apiRequest(path, 'GET', null, publicKey, secretKey) - return result.logs -} - -/** - * Execute command in a service. - */ -def serviceExecute(String serviceId, String command, int timeoutMs = 0, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [command: command] - if (timeoutMs > 0) payload.timeout = timeoutMs - return apiRequest("/services/${serviceId}/execute", 'POST', payload, publicKey, secretKey) -} - -/** - * Get service environment vault status. - */ -def serviceEnvGet(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/services/${serviceId}/env", 'GET', null, publicKey, secretKey) -} - -/** - * Set service environment vault. - */ -def serviceEnvSet(String serviceId, String envContent, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequestText("/services/${serviceId}/env", 'PUT', envContent, publicKey, secretKey) -} - -/** - * Delete service environment vault. - */ -def serviceEnvDelete(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/services/${serviceId}/env", 'DELETE', null, publicKey, secretKey) -} - -/** - * Export service environment vault. - */ -def serviceEnvExport(String serviceId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/services/${serviceId}/env/export", 'POST', [:], publicKey, secretKey) -} - -/** - * Resize a service. - */ -def serviceResize(String serviceId, int vcpu, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequestPatch("/services/${serviceId}", [vcpu: vcpu], publicKey, secretKey) -} - -// ============================================================================ -// Snapshot Functions -// ============================================================================ - -/** - * List all snapshots. - */ -def snapshotList(Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def result = apiRequest('/snapshots', 'GET', null, publicKey, secretKey) - return result.snapshots ?: [] -} - -/** - * Get snapshot details. - */ -def snapshotGet(String snapshotId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/snapshots/${snapshotId}", 'GET', null, publicKey, secretKey) -} - -/** - * Create snapshot from session. - */ -def snapshotSession(String sessionId, String name = null, boolean hot = false, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [session_id: sessionId, hot: hot] - if (name) payload.name = name - def result = apiRequest('/snapshots', 'POST', payload, publicKey, secretKey) - return result.snapshot_id -} - -/** - * Create snapshot from service. - */ -def snapshotService(String serviceId, String name = null, boolean hot = false, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [service_id: serviceId, hot: hot] - if (name) payload.name = name - def result = apiRequest('/snapshots', 'POST', payload, publicKey, secretKey) - return result.snapshot_id -} - -/** - * Restore a snapshot. - */ -def snapshotRestore(String snapshotId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/snapshots/${snapshotId}/restore", 'POST', [:], publicKey, secretKey) -} - -/** - * Delete a snapshot. - */ -def snapshotDelete(String snapshotId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return executeDestructive("/snapshots/${snapshotId}", 'DELETE', null, publicKey, secretKey) -} - -/** - * Lock a snapshot. - */ -def snapshotLock(String snapshotId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/snapshots/${snapshotId}/lock", 'POST', null, publicKey, secretKey) -} - -/** - * Unlock a snapshot. - */ -def snapshotUnlock(String snapshotId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return executeDestructive("/snapshots/${snapshotId}/unlock", 'POST', null, publicKey, secretKey) -} - -/** - * Clone a snapshot. - */ -def snapshotClone(String snapshotId, String cloneType, String name = null, String ports = null, String shell = null, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [type: cloneType] - if (name) payload.name = name - if (ports) payload.ports = ports.split(',').collect { it.trim().toInteger() } - if (shell) payload.shell = shell - def result = apiRequest("/snapshots/${snapshotId}/clone", 'POST', payload, publicKey, secretKey) - return result.session_id ?: result.service_id -} - -// ============================================================================ -// Image Functions -// ============================================================================ - -/** - * List all images. - */ -def imageList(String filter = null, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def path = filter ? "/images/${filter}" : '/images' - def result = apiRequest(path, 'GET', null, publicKey, secretKey) - return result.images ?: [] -} - -/** - * Get image details. - */ -def imageGet(String imageId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/images/${imageId}", 'GET', null, publicKey, secretKey) -} - -/** - * Publish an image. - */ -def imagePublish(String sourceType, String sourceId, String name = null, String description = null, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [source_type: sourceType, source_id: sourceId] - if (name) payload.name = name - if (description) payload.description = description - def result = apiRequest('/images', 'POST', payload, publicKey, secretKey) - return result.image_id -} - -/** - * Delete an image. - */ -def imageDelete(String imageId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return executeDestructive("/images/${imageId}", 'DELETE', null, publicKey, secretKey) -} - -/** - * Lock an image. - */ -def imageLock(String imageId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/images/${imageId}/lock", 'POST', null, publicKey, secretKey) -} - -/** - * Unlock an image. - */ -def imageUnlock(String imageId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return executeDestructive("/images/${imageId}/unlock", 'POST', null, publicKey, secretKey) -} - -/** - * Set image visibility. - */ -def imageSetVisibility(String imageId, String visibility, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/images/${imageId}/visibility", 'POST', [visibility: visibility], publicKey, secretKey) -} - -/** - * Grant access to an image. - */ -def imageGrantAccess(String imageId, String trustedApiKey, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/images/${imageId}/grant", 'POST', [trusted_api_key: trustedApiKey], publicKey, secretKey) -} - -/** - * Revoke access to an image. - */ -def imageRevokeAccess(String imageId, String trustedApiKey, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/images/${imageId}/revoke", 'POST', [trusted_api_key: trustedApiKey], publicKey, secretKey) -} - -/** - * List trusted keys for an image. - */ -def imageListTrusted(String imageId, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def result = apiRequest("/images/${imageId}/trusted", 'GET', null, publicKey, secretKey) - return result.trusted ?: [] -} - -/** - * Transfer image ownership. - */ -def imageTransfer(String imageId, String toApiKey, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - return apiRequest("/images/${imageId}/transfer", 'POST', [to_api_key: toApiKey], publicKey, secretKey) -} - -/** - * Spawn a service from an image. - */ -def imageSpawn(String imageId, String name = null, String ports = null, String bootstrap = null, String networkMode = null, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [:] - if (name) payload.name = name - if (ports) payload.ports = ports.split(',').collect { it.trim().toInteger() } - if (bootstrap) payload.bootstrap = bootstrap - if (networkMode) payload.network_mode = networkMode - def result = apiRequest("/images/${imageId}/spawn", 'POST', payload, publicKey, secretKey) - return result.service_id -} - -/** - * Clone an image. - */ -def imageClone(String imageId, String name = null, String description = null, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def payload = [:] - if (name) payload.name = name - if (description) payload.description = description - def result = apiRequest("/images/${imageId}/clone", 'POST', payload, publicKey, secretKey) - return result.image_id -} - -// ============================================================================ -// PaaS Logs Functions -// ============================================================================ - -/** - * Fetch batch logs. - */ -def logsFetch(String source = 'all', int lines = 100, String since = null, String grep = null, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - def params = ["source=${source}", "lines=${lines}"] - if (since) params << "since=${since}" - if (grep) params << "grep=${URLEncoder.encode(grep, 'UTF-8')}" - return apiRequest("/paas/logs?${params.join('&')}", 'GET', null, publicKey, secretKey) -} - -/** - * Callback interface for log streaming. - */ -interface LogCallback { - void onLogLine(String source, String line) -} - -/** - * Stream logs via SSE. Blocks until interrupted or server closes. - * - * @param source Log source ('all', 'api', 'portal', 'pool/cammy', 'pool/ai') - * @param grep Optional filter pattern - * @param callback Callback for each log line - * @param options Optional parameters (publicKey, secretKey) - * @return true on clean shutdown, false on error - */ -def logsStream(String source = 'all', String grep = null, LogCallback callback, Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - - def path = "/paas/logs/stream?source=${source ?: 'all'}" - if (grep) { - path += "&grep=${URLEncoder.encode(grep, 'UTF-8')}" - } - - def timestamp = (System.currentTimeMillis() / 1000) as long - def signature = signRequest(secretKey, timestamp, 'GET', path, '') - - def url = new URL("${API_BASE}${path}") - def connection = url.openConnection() as java.net.HttpURLConnection - - connection.requestMethod = 'GET' - connection.setRequestProperty('Authorization', "Bearer ${publicKey}") - connection.setRequestProperty('X-Timestamp', timestamp.toString()) - connection.setRequestProperty('X-Signature', signature) - connection.setRequestProperty('Accept', 'text/event-stream') - connection.connectTimeout = 30000 - connection.readTimeout = 0 // No timeout for streaming - - if (connection.responseCode != 200) { - return false - } - - try { - def reader = new BufferedReader(new InputStreamReader(connection.inputStream, 'UTF-8')) - def currentSource = source ?: 'all' - def line - - while ((line = reader.readLine()) != null) { - if (line.startsWith('data: ')) { - def data = line.substring(6) - if (callback) { - callback.onLogLine(currentSource, data) - } - } else if (line.startsWith('event: ')) { - currentSource = line.substring(7) - } - } - return true - } catch (Exception e) { - return false - } -} - -/** - * Validate API keys. - */ -def validateKeys(Map options = [:]) { - def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) - - def timestamp = (System.currentTimeMillis() / 1000) as long - def message = "${timestamp}:POST:/keys/validate:{}" - def signature = signRequest(secretKey, timestamp, 'POST', '/keys/validate', '{}') - - def url = new URL("${PORTAL_BASE}/keys/validate") - def connection = url.openConnection() as java.net.HttpURLConnection - - connection.requestMethod = 'POST' - connection.setRequestProperty('Authorization', "Bearer ${publicKey}") - connection.setRequestProperty('X-Timestamp', timestamp.toString()) - connection.setRequestProperty('X-Signature', signature) - connection.setRequestProperty('Content-Type', 'application/json') - connection.connectTimeout = 30000 - connection.readTimeout = 30000 - connection.doOutput = true - connection.outputStream.withWriter { it.write('{}') } - - if (connection.responseCode !in 200..299) { - throw new APIError("HTTP ${connection.responseCode}") - } - - return new JsonSlurper().parseText(connection.inputStream.text) -} - /** * Detect programming language from file extension or shebang. * @@ -1607,72 +826,45 @@ class Client { def creds = getCredentialsStatic( options.publicKey, options.secretKey, - options.accountIndex != null ? options.accountIndex : -1 + options.accountIndex ?: 0 ) this.publicKey = creds[0] this.secretKey = creds[1] } - private static loadCsvAccounts(File path) { - def accounts = [] - if (!path.exists()) return accounts - try { - path.text.trim().split('\n').each { line -> - def trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) return - if (trimmed.contains(',')) { - def parts = trimmed.split(',', 2) - def pk = parts[0].trim() - def sk = parts[1].trim() - if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { - accounts << [pk, sk] - } - } - } - } catch (Exception e) { - // Ignore - } - return accounts - } - private static getCredentialsStatic(String publicKey, String secretKey, int accountIndex) { - // Priority 1: explicit arguments if (publicKey && secretKey) { return [publicKey, secretKey] } - // Priority 2: --account N => CSV row N (bypasses env vars) - if (accountIndex >= 0) { - def searchPaths = [ - new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), - new File('accounts.csv') - ] - for (path in searchPaths) { - def accts = loadCsvAccounts(path) - if (accts && accountIndex < accts.size()) { - return accts[accountIndex] - } - } - throw new AuthenticationError("No account at index ${accountIndex} in accounts.csv") - } - - // Priority 3: Environment variables def envPk = System.getenv('UNSANDBOX_PUBLIC_KEY') def envSk = System.getenv('UNSANDBOX_SECRET_KEY') if (envPk && envSk) { return [envPk, envSk] } - // Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) - def defaultIdx = (System.getenv('UNSANDBOX_ACCOUNT') ?: '0').toInteger() - def searchPaths = [ - new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), - new File('accounts.csv') - ] - for (path in searchPaths) { - def accts = loadCsvAccounts(path) - if (accts && defaultIdx < accts.size()) { - return accts[defaultIdx] + def accountsPath = new File(System.getProperty('user.home'), '.unsandbox/accounts.csv') + if (accountsPath.exists()) { + try { + def lines = accountsPath.text.trim().split('\n') + def validAccounts = [] + lines.each { line -> + def trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + if (trimmed.contains(',')) { + def parts = trimmed.split(',', 2) + def pk = parts[0] + def sk = parts[1] + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + validAccounts << [pk, sk] + } + } + } + if (validAccounts && accountIndex < validAccounts.size()) { + return validAccounts[accountIndex] + } + } catch (Exception e) { + // Ignore } } @@ -1783,7 +975,6 @@ class Args { String sourceFile = null String inlineLang = null String apiKey = null - Integer accountIndex = -1 String network = null Integer vcpu = 0 List env = [] @@ -1885,7 +1076,7 @@ def serviceEnvSet(serviceId, content, publicKey, secretKey) { } def cmdServiceEnv(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + def (publicKey, secretKey) = getApiKeys(args.apiKey) switch (args.envAction) { case 'status': @@ -1921,7 +1112,7 @@ def cmdServiceEnv(args) { } def cmdExecute(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + def (publicKey, secretKey) = getApiKeys(args.apiKey) String code String language @@ -2002,7 +1193,7 @@ def cmdExecute(args) { } def cmdSession(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + def (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.sessionSnapshot) { def payload = [:] @@ -2079,7 +1270,7 @@ def openBrowser(url) { } def cmdSnapshot(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + def (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.snapshotList) { def output = apiRequest('/snapshots', 'GET', null, publicKey, secretKey) @@ -2094,7 +1285,7 @@ def cmdSnapshot(args) { } if (args.snapshotDelete) { - executeDestructive("/snapshots/${args.snapshotDelete}", 'DELETE', null, publicKey, secretKey) + apiRequest("/snapshots/${args.snapshotDelete}", 'DELETE', null, publicKey, secretKey) println("${GREEN}Snapshot deleted: ${args.snapshotDelete}${RESET}") return } @@ -2119,7 +1310,7 @@ def cmdSnapshot(args) { } def cmdImage(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + def (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.imageList) { def output = apiRequest('/images', 'GET', null, publicKey, secretKey) @@ -2134,7 +1325,7 @@ def cmdImage(args) { } if (args.imageDelete) { - executeDestructive("/images/${args.imageDelete}", 'DELETE', null, publicKey, secretKey) + apiRequest("/images/${args.imageDelete}", 'DELETE', null, publicKey, secretKey) println("${GREEN}Image deleted: ${args.imageDelete}${RESET}") return } @@ -2146,7 +1337,7 @@ def cmdImage(args) { } if (args.imageUnlock) { - executeDestructive("/images/${args.imageUnlock}/unlock", 'POST', null, publicKey, secretKey) + apiRequest("/images/${args.imageUnlock}/unlock", 'POST', null, publicKey, secretKey) println("${GREEN}Image unlocked: ${args.imageUnlock}${RESET}") return } @@ -2199,7 +1390,7 @@ def cmdImage(args) { } def cmdLanguages(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + def (publicKey, secretKey) = getApiKeys(args.apiKey) def result = languages([publicKey: publicKey, secretKey: secretKey, forceRefresh: true]) def langList = result.languages ?: [] @@ -2214,7 +1405,7 @@ def cmdLanguages(args) { } def cmdKey(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + def (publicKey, secretKey) = getApiKeys(args.apiKey) def curlCmd = ['curl', '-s', '-X', 'POST', "${PORTAL_BASE}/keys/validate", '-H', 'Content-Type: application/json'] @@ -2286,7 +1477,7 @@ def cmdKey(args) { } def cmdService(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + def (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.serviceSnapshot) { def payload = [:] @@ -2353,7 +1544,7 @@ def cmdService(args) { } if (args.serviceDestroy) { - executeDestructive("/services/${args.serviceDestroy}", 'DELETE', null, publicKey, secretKey) + apiRequest("/services/${args.serviceDestroy}", 'DELETE', null, publicKey, secretKey) println("${GREEN}Service destroyed: ${args.serviceDestroy}${RESET}") return } @@ -2503,9 +1694,6 @@ def parseArgs(argv) { case '--public-key': args.apiKey = argv[++i] // For compatibility break - case '--account': - args.accountIndex = argv[++i].toInteger() - break case '-n': case '--network': args.network = argv[++i] diff --git a/clients/groovy/sync/tests/UnTest.groovy b/clients/groovy/sync/tests/UnTest.groovy deleted file mode 100644 index 6e51a16..0000000 --- a/clients/groovy/sync/tests/UnTest.groovy +++ /dev/null @@ -1,453 +0,0 @@ -#!/usr/bin/env groovy -// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY -// Unit tests for Un SDK - Groovy Synchronous client - -import groovy.test.GroovyTestCase - -/** - * Test suite for the Unsandbox Groovy SDK. - * - * Run with: groovy UnTest.groovy - * - * Integration tests require UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY - * environment variables to be set. - */ -class UnTest extends GroovyTestCase { - - // Load the SDK - static { - def sdkPath = new File(UnTest.class.protectionDomain.codeSource.location.path).parentFile.parentFile - evaluate(new File(sdkPath, 'src/un.groovy')) - } - - // ======================================================================== - // Language Detection Tests - // ======================================================================== - - void testDetectPython() { - assertEquals("python", detectLanguage("script.py")) - assertEquals("python", detectLanguage("path/to/script.py")) - } - - void testDetectJavaScript() { - assertEquals("javascript", detectLanguage("app.js")) - } - - void testDetectTypeScript() { - assertEquals("typescript", detectLanguage("app.ts")) - } - - void testDetectGo() { - assertEquals("go", detectLanguage("main.go")) - } - - void testDetectRust() { - assertEquals("rust", detectLanguage("lib.rs")) - } - - void testDetectJava() { - assertEquals("java", detectLanguage("Main.java")) - } - - void testDetectKotlin() { - assertEquals("kotlin", detectLanguage("Main.kt")) - } - - void testDetectGroovy() { - assertEquals("groovy", detectLanguage("script.groovy")) - } - - void testDetectCpp() { - assertEquals("cpp", detectLanguage("main.cpp")) - } - - void testDetectC() { - assertEquals("c", detectLanguage("main.c")) - } - - void testDetectRuby() { - assertEquals("ruby", detectLanguage("script.rb")) - } - - void testDetectPhp() { - assertEquals("php", detectLanguage("index.php")) - } - - void testDetectUnknown() { - assertNull(detectLanguage("file.unknown")) - assertNull(detectLanguage("noextension")) - } - - // ======================================================================== - // Utility Function Tests - // ======================================================================== - - void testVersionString() { - def ver = version() - assertNotNull(ver) - assertTrue("Version should be in X.Y.Z format", ver ==~ /\d+\.\d+\.\d+/) - } - - void testHmacSignature() { - def signature = hmacSign("secret", "message") - assertNotNull(signature) - assertEquals("HMAC-SHA256 should produce 64 hex chars", 64, signature.length()) - assertTrue("Signature should be lowercase hex", signature ==~ /[0-9a-f]+/) - } - - void testHmacConsistent() { - def sig1 = hmacSign("key", "data") - def sig2 = hmacSign("key", "data") - assertEquals("Same inputs should produce same signature", sig1, sig2) - } - - void testHmacDifferent() { - def sig1 = hmacSign("key1", "data") - def sig2 = hmacSign("key2", "data") - assertFalse("Different keys should produce different signatures", sig1 == sig2) - } - - void testHmacKnownValue() { - // HMAC-SHA256("key", "The quick brown fox jumps over the lazy dog") - // Known value from various implementations - def signature = hmacSign("key", "The quick brown fox jumps over the lazy dog") - assertEquals("f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", signature) - } - - // ======================================================================== - // Health Check Tests - // ======================================================================== - - void testHealthCheckReturnsBoolean() { - def healthy = healthCheck() - // We just verify it returns a boolean without throwing - assertTrue(healthy instanceof Boolean) - } - - // ======================================================================== - // Exception Tests - // ======================================================================== - - void testUnsandboxError() { - def error = new UnsandboxError("Test message") - assertEquals("Test message", error.message) - } - - void testAuthenticationError() { - def error = new AuthenticationError("Auth failed") - assertEquals("Auth failed", error.message) - assertTrue(error instanceof UnsandboxError) - } - - void testExecutionError() { - def error = new ExecutionError("Exec failed", 1, "stderr output") - assertEquals("Exec failed", error.message) - assertEquals(1, error.exitCode) - assertEquals("stderr output", error.stderr) - } - - void testAPIError() { - def error = new APIError("API failed", 500, '{"error": "internal"}') - assertEquals("API failed", error.message) - assertEquals(500, error.statusCode) - assertEquals('{"error": "internal"}', error.response) - } - - void testTimeoutError() { - def error = new TimeoutError("Operation timed out") - assertEquals("Operation timed out", error.message) - assertTrue(error instanceof UnsandboxError) - } - - void testSudoChallengeError() { - def error = new SudoChallengeError("challenge-123", '{"challenge_id": "challenge-123"}') - assertEquals("challenge-123", error.challengeId) - assertEquals('{"challenge_id": "challenge-123"}', error.responseBody) - } - - // ======================================================================== - // Extension Map Tests - // ======================================================================== - - void testExtensionMapComplete() { - // Verify the EXT_MAP has all expected extensions - assertNotNull(EXT_MAP['.py']) - assertNotNull(EXT_MAP['.js']) - assertNotNull(EXT_MAP['.ts']) - assertNotNull(EXT_MAP['.go']) - assertNotNull(EXT_MAP['.rs']) - assertNotNull(EXT_MAP['.java']) - assertNotNull(EXT_MAP['.kt']) - assertNotNull(EXT_MAP['.groovy']) - assertNotNull(EXT_MAP['.rb']) - assertNotNull(EXT_MAP['.php']) - assertNotNull(EXT_MAP['.c']) - assertNotNull(EXT_MAP['.cpp']) - assertNotNull(EXT_MAP['.sh']) - assertNotNull(EXT_MAP['.lua']) - assertNotNull(EXT_MAP['.pl']) - } - - // ======================================================================== - // Integration Tests (requires credentials) - // ======================================================================== - - void testExecutePythonCode() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def result = execute("python", 'print("Hello, World!")', [ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(result) - assertTrue(result.stdout?.contains("Hello, World!") ?: false) - } - - void testExecuteJavaScriptCode() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def result = execute("javascript", 'console.log("Hello from JS")', [ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(result) - assertTrue(result.stdout?.contains("Hello from JS") ?: false) - } - - void testGetLanguages() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def result = languages([ - publicKey: publicKey, - secretKey: secretKey, - forceRefresh: true - ]) - - assertNotNull(result) - assertNotNull(result.languages) - assertTrue(result.languages.size() > 0) - assertTrue(result.languages.contains("python")) - assertTrue(result.languages.contains("javascript")) - } - - void testListJobs() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def jobs = listJobs([ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(jobs) - // Jobs list can be empty if no jobs are running - } - - void testValidateKeys() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def result = validateKeys([ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(result) - } - - void testListSessions() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def sessions = sessionList([ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(sessions) - } - - void testListServices() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def services = serviceList([ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(services) - } - - void testListSnapshots() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def snapshots = snapshotList([ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(snapshots) - } - - void testListImages() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def images = imageList(null, [ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(images) - } - - void testAsyncExecution() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def job = executeAsync("python", 'print("Async test")', [ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(job) - assertNotNull(job.job_id) - - // Wait for completion - def result = wait(job.job_id, [ - publicKey: publicKey, - secretKey: secretKey, - maxPolls: 30 - ]) - - assertNotNull(result) - assertTrue(result.stdout?.contains("Async test") ?: (result.result?.stdout?.contains("Async test") ?: false)) - } - - void testLogsFetch() { - def publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") - def secretKey = System.getenv("UNSANDBOX_SECRET_KEY") - - if (!publicKey || !secretKey) { - println "Skipping integration test - credentials not set" - return - } - - def result = logsFetch('all', 10, null, null, [ - publicKey: publicKey, - secretKey: secretKey - ]) - - assertNotNull(result) - } - - void testLogCallbackInterface() { - // Verify LogCallback interface exists and can be implemented - def callback = { source, line -> - assertNotNull(source) - assertNotNull(line) - } as LogCallback - - assertNotNull(callback) - } - - // ======================================================================== - // Run all tests - // ======================================================================== - - static void main(String[] args) { - println "Running Unsandbox Groovy SDK Tests..." - println "=" * 60 - - def test = new UnTest() - def methods = UnTest.class.declaredMethods.findAll { - it.name.startsWith('test') && it.parameterCount == 0 - } - - int passed = 0 - int failed = 0 - int skipped = 0 - - methods.each { method -> - print "Testing ${method.name}... " - try { - method.invoke(test) - println "PASS" - passed++ - } catch (Exception e) { - def cause = e.cause ?: e - if (cause.message?.contains("Skipping")) { - println "SKIP" - skipped++ - } else { - println "FAIL: ${cause.message}" - failed++ - } - } - } - - println "=" * 60 - println "Results: ${passed} passed, ${failed} failed, ${skipped} skipped" - - if (failed > 0) { - System.exit(1) - } - } -} diff --git a/clients/haskell/sync/src/un.hs b/clients/haskell/sync/src/un.hs index 91d7a34..a02d130 100644 --- a/clients/haskell/sync/src/un.hs +++ b/clients/haskell/sync/src/un.hs @@ -59,15 +59,13 @@ import System.Environment (getArgs, getEnv, lookupEnv) import System.Exit (exitWith, ExitCode(..), exitFailure) import System.FilePath (takeExtension, takeFileName) import System.Process (readProcessWithExitCode) -import System.IO (hPutStrLn, hPutStr, hFlush, stderr, stdout) +import System.IO (hPutStrLn, stderr) import System.Directory (createDirectoryIfMissing, setPermissions, getPermissions, setOwnerExecutable) import Data.List (isPrefixOf, intercalate) import Data.Char (isDigit, ord) import Text.Printf (printf) import Control.Monad (when, unless, forM_) import Control.Exception (try, catch, IOError) -import Data.IORef (IORef, newIORef, readIORef, writeIORef) -import System.IO.Unsafe (unsafePerformIO) import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Base64 as B64 @@ -88,11 +86,6 @@ portalBase = "https://unsandbox.com" languagesCacheTtl :: Int languagesCacheTtl = 3600 -- 1 hour in seconds --- Global account index set by --account N flag (Nothing = not set) -{-# NOINLINE cliAccountIndex #-} -cliAccountIndex :: IORef (Maybe Int) -cliAccountIndex = unsafePerformIO (newIORef Nothing) - -- ANSI colors blue, red, green, yellow, reset :: String blue = "\x1b[34m" @@ -352,519 +345,10 @@ parseExecute args = let (k, v) = span (/= '=') kv in (k, drop 1 v) --- ============================================================================ --- Library API --- ============================================================================ - --- SDK Version -sdkVersion :: String -sdkVersion = "4.2.0" - --- | Return the SDK version -version :: String -version = sdkVersion - --- | Check API health -healthCheck :: IO Bool -healthCheck = do - (exitCode, stdout, _) <- readProcessWithExitCode "curl" - ["-s", "-o", "/dev/null", "-w", "%{http_code}", apiBase ++ "/health"] "" - return $ filter isDigit stdout == "200" - --- | Generate HMAC-SHA256 signature for a message -hmacSign :: String -> String -> String -hmacSign = hmacSha256 - --- | Detect language from filename extension -detectLanguage :: String -> Maybe String -detectLanguage filename = extToLang (takeExtension filename) - --- | Get list of supported languages (list of strings) -getLanguages :: IO [String] -getLanguages = do - cached <- loadLanguagesCache - case cached of - Just languages -> return languages - Nothing -> do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/languages") - let languages = maybe [] id (extractJsonArray stdout "languages") - when (not (null languages)) $ saveLanguagesCache languages - return languages - --- | Execute code synchronously -execute :: String -> String -> IO (Either String (Bool, String, String, Int)) -execute language code = do - apiKey <- getApiKey - let json = "{\"language\":\"" ++ escapeJSON language ++ "\",\"code\":\"" ++ escapeJSON code ++ "\"}" - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/execute") json - case exitCode of - ExitSuccess -> - let success = case extractJsonString stdout "exit_code" of - Just "0" -> True - _ -> False - stdoutVal = maybe "" id (extractJsonString stdout "stdout") - stderrVal = maybe "" id (extractJsonString stdout "stderr") - exitCodeVal = case extractJsonString stdout "exit_code" of - Just s -> read (filter isDigit s) :: Int - _ -> 0 - in return $ Right (success, stdoutVal, stderrVal, exitCodeVal) - _ -> return $ Left "Execution failed" - --- | Execute code asynchronously, returning a job ID -executeAsync :: String -> String -> IO (Maybe String) -executeAsync language code = do - apiKey <- getApiKey - let json = "{\"language\":\"" ++ escapeJSON language ++ "\",\"code\":\"" ++ escapeJSON code ++ "\"}" - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/execute/async") json - case exitCode of - ExitSuccess -> return $ extractJsonString stdout "job_id" - _ -> return Nothing - --- | Get job status -getJob :: String -> IO (Maybe (String, String)) -getJob jobId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlGet apiKey (apiBase ++ "/jobs/" ++ jobId) - case exitCode of - ExitSuccess -> - let status = maybe "unknown" id (extractJsonString stdout "status") - language = maybe "" id (extractJsonString stdout "language") - in return $ Just (status, language) - _ -> return Nothing - --- | Wait for job completion -waitJob :: String -> IO (Either String (Bool, String, String, Int)) -waitJob jobId = waitJobLoop jobId 0 100 - where - pollDelays = [300, 450, 700, 900, 650, 1600, 2000] - terminalStates = ["completed", "failed", "timeout", "cancelled"] - - waitJobLoop jid pollCount maxPolls - | pollCount >= maxPolls = return $ Left "Max polls exceeded" - | otherwise = do - let delayIdx = min pollCount (length pollDelays - 1) - let delayMs = pollDelays !! delayIdx - threadDelay (delayMs * 1000) -- threadDelay takes microseconds - - result <- getJob jid - case result of - Just (status, _) | status `elem` terminalStates -> do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/jobs/" ++ jid) - let success = case extractJsonString stdout "exit_code" of - Just "0" -> True - _ -> False - stdoutVal = maybe "" id (extractJsonString stdout "stdout") - stderrVal = maybe "" id (extractJsonString stdout "stderr") - exitCodeVal = case extractJsonString stdout "exit_code" of - Just s -> read (filter isDigit s) :: Int - _ -> 1 - return $ Right (success, stdoutVal, stderrVal, exitCodeVal) - _ -> waitJobLoop jid (pollCount + 1) maxPolls - --- | Cancel a running job -cancelJob :: String -> IO Bool -cancelJob jobId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlDelete apiKey (apiBase ++ "/jobs/" ++ jobId) - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | List all active jobs -listJobs :: IO String -listJobs = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/jobs") - return stdout - --- | List all sessions -sessionList :: IO String -sessionList = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/sessions") - return stdout - --- | Get session details -sessionGet :: String -> IO String -sessionGet sessionId = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/sessions/" ++ sessionId) - return stdout - --- | Create a new session -sessionCreate :: Maybe String -> Maybe String -> IO (Maybe String) -sessionCreate shell network = do - apiKey <- getApiKey - let shellVal = maybe "bash" id shell - let networkJson = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") network - let json = "{\"shell\":\"" ++ shellVal ++ "\"" ++ networkJson ++ "}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions") json - return $ extractJsonString stdout "id" - --- | Destroy a session -sessionDestroy :: String -> IO Bool -sessionDestroy sessionId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlDelete apiKey (apiBase ++ "/sessions/" ++ sessionId) - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Freeze a session -sessionFreeze :: String -> IO Bool -sessionFreeze sessionId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions/" ++ sessionId ++ "/freeze") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Unfreeze a session -sessionUnfreeze :: String -> IO Bool -sessionUnfreeze sessionId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions/" ++ sessionId ++ "/unfreeze") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Boost session resources -sessionBoost :: String -> Int -> IO Bool -sessionBoost sessionId vcpu = do - apiKey <- getApiKey - let json = "{\"vcpu\":" ++ show vcpu ++ "}" - (exitCode, stdout, _) <- curlPatch apiKey (apiBase ++ "/sessions/" ++ sessionId) json - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Unboost session -sessionUnboost :: String -> IO Bool -sessionUnboost sessionId = sessionBoost sessionId 1 - --- | Execute a command in a session -sessionExecute :: String -> String -> IO String -sessionExecute sessionId command = do - apiKey <- getApiKey - let json = "{\"command\":\"" ++ escapeJSON command ++ "\"}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions/" ++ sessionId ++ "/execute") json - return stdout - --- | List all services -serviceList :: IO String -serviceList = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/services") - return stdout - --- | Get service details -serviceGet :: String -> IO String -serviceGet serviceId = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/services/" ++ serviceId) - return stdout - --- | Create a new service -serviceCreate :: String -> Maybe String -> Maybe String -> Maybe String -> IO (Maybe String) -serviceCreate name ports bootstrap network = do - apiKey <- getApiKey - let portsJson = maybe "" (\p -> ",\"ports\":[" ++ p ++ "]") ports - let bootstrapJson = maybe "" (\b -> ",\"bootstrap\":\"" ++ escapeJSON b ++ "\"") bootstrap - let networkJson = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") network - let json = "{\"name\":\"" ++ escapeJSON name ++ "\"" ++ portsJson ++ bootstrapJson ++ networkJson ++ "}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/services") json - return $ extractJsonString stdout "id" - --- | Destroy a service -serviceDestroy :: String -> IO Bool -serviceDestroy serviceId = do - result <- curlDeleteWithSudo "" (apiBase ++ "/services/" ++ serviceId) - case result of - SudoSuccess _ -> return True - _ -> return False - --- | Freeze a service -serviceFreeze :: String -> IO Bool -serviceFreeze serviceId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/freeze") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Unfreeze a service -serviceUnfreeze :: String -> IO Bool -serviceUnfreeze serviceId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/unfreeze") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Lock a service -serviceLock :: String -> IO Bool -serviceLock serviceId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/lock") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Unlock a service -serviceUnlock :: String -> IO Bool -serviceUnlock serviceId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/unlock") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Set unfreeze-on-demand for a service -serviceSetUnfreezeOnDemand :: String -> Bool -> IO Bool -serviceSetUnfreezeOnDemand serviceId enabled = do - apiKey <- getApiKey - let enabledStr = if enabled then "true" else "false" - let json = "{\"unfreeze_on_demand\":" ++ enabledStr ++ "}" - (exitCode, stdout, _) <- curlPatch apiKey (apiBase ++ "/services/" ++ serviceId) json - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Redeploy a service -serviceRedeploy :: String -> Maybe String -> IO Bool -serviceRedeploy serviceId bootstrap = do - apiKey <- getApiKey - let bootstrapJson = maybe "" (\b -> "\"bootstrap\":\"" ++ escapeJSON b ++ "\"") bootstrap - let json = "{" ++ bootstrapJson ++ "}" - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/redeploy") json - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Get service logs -serviceLogs :: String -> Bool -> IO String -serviceLogs serviceId allLogs = do - apiKey <- getApiKey - let endpoint = if allLogs - then "/services/" ++ serviceId ++ "/logs?all=true" - else "/services/" ++ serviceId ++ "/logs" - (_, stdout, _) <- curlGet apiKey (apiBase ++ endpoint) - return stdout - --- | Execute a command in a service -serviceExecute :: String -> String -> IO String -serviceExecute serviceId command = do - apiKey <- getApiKey - let json = "{\"command\":\"" ++ escapeJSON command ++ "\"}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/execute") json - return stdout - --- | Resize a service -serviceResize :: String -> Int -> IO Bool -serviceResize serviceId vcpu = do - apiKey <- getApiKey - let json = "{\"vcpu\":" ++ show vcpu ++ "}" - (exitCode, stdout, _) <- curlPatch apiKey (apiBase ++ "/services/" ++ serviceId) json - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | List all snapshots -snapshotList :: IO String -snapshotList = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/snapshots") - return stdout - --- | Get snapshot details -snapshotGet :: String -> IO String -snapshotGet snapshotId = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/snapshots/" ++ snapshotId) - return stdout - --- | Create a snapshot of a session -snapshotSession :: String -> Maybe String -> Bool -> IO (Maybe String) -snapshotSession sessionId name hot = do - apiKey <- getApiKey - let nameJson = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\",") name - let hotJson = if hot then "\"hot\":true" else "\"hot\":false" - let json = "{" ++ nameJson ++ hotJson ++ "}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/sessions/" ++ sessionId ++ "/snapshot") json - return $ extractJsonString stdout "id" - --- | Create a snapshot of a service -snapshotService :: String -> Maybe String -> Bool -> IO (Maybe String) -snapshotService serviceId name hot = do - apiKey <- getApiKey - let nameJson = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\",") name - let hotJson = if hot then "\"hot\":true" else "\"hot\":false" - let json = "{" ++ nameJson ++ hotJson ++ "}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/services/" ++ serviceId ++ "/snapshot") json - return $ extractJsonString stdout "id" - --- | Restore from a snapshot -snapshotRestore :: String -> IO (Maybe String) -snapshotRestore snapshotId = do - apiKey <- getApiKey - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/snapshots/" ++ snapshotId ++ "/restore") "{}" - return $ extractJsonString stdout "id" - --- | Delete a snapshot -snapshotDelete :: String -> IO Bool -snapshotDelete snapshotId = do - result <- curlDeleteWithSudo "" (apiBase ++ "/snapshots/" ++ snapshotId) - case result of - SudoSuccess _ -> return True - _ -> return False - --- | Lock a snapshot -snapshotLock :: String -> IO Bool -snapshotLock snapshotId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/snapshots/" ++ snapshotId ++ "/lock") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Unlock a snapshot -snapshotUnlock :: String -> IO Bool -snapshotUnlock snapshotId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/snapshots/" ++ snapshotId ++ "/unlock") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Clone a snapshot to create a new session or service -snapshotClone :: String -> String -> Maybe String -> Maybe String -> Maybe String -> IO (Maybe String) -snapshotClone snapshotId cloneType name ports shell = do - apiKey <- getApiKey - let typeJson = "\"type\":\"" ++ cloneType ++ "\"" - let nameJson = maybe "" (\n -> ",\"name\":\"" ++ escapeJSON n ++ "\"") name - let portsJson = maybe "" (\p -> ",\"ports\":[" ++ p ++ "]") ports - let shellJson = maybe "" (\s -> ",\"shell\":\"" ++ s ++ "\"") shell - let json = "{" ++ typeJson ++ nameJson ++ portsJson ++ shellJson ++ "}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/snapshots/" ++ snapshotId ++ "/clone") json - return $ extractJsonString stdout "id" - --- | List images -imageList :: Maybe String -> IO String -imageList filter' = do - apiKey <- getApiKey - let endpoint = maybe "/images" (\f -> "/images?filter=" ++ f) filter' - (_, stdout, _) <- curlGet apiKey (apiBase ++ endpoint) - return stdout - --- | Get image details -imageGet :: String -> IO String -imageGet imageId = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/images/" ++ imageId) - return stdout - --- | Publish an image -imagePublish :: String -> String -> Maybe String -> Maybe String -> IO (Maybe String) -imagePublish sourceType sourceId name description = do - apiKey <- getApiKey - let nameJson = maybe "" (\n -> ",\"name\":\"" ++ escapeJSON n ++ "\"") name - let descJson = maybe "" (\d -> ",\"description\":\"" ++ escapeJSON d ++ "\"") description - let json = "{\"source_type\":\"" ++ sourceType ++ "\",\"source_id\":\"" ++ sourceId ++ "\"" ++ nameJson ++ descJson ++ "}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/images/publish") json - return $ extractJsonString stdout "id" - --- | Delete an image -imageDelete :: String -> IO Bool -imageDelete imageId = do - result <- curlDeleteWithSudo "" (apiBase ++ "/images/" ++ imageId) - case result of - SudoSuccess _ -> return True - _ -> return False - --- | Lock an image -imageLock :: String -> IO Bool -imageLock imageId = do - apiKey <- getApiKey - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/lock") "{}" - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Unlock an image -imageUnlock :: String -> IO Bool -imageUnlock imageId = do - result <- curlPostWithSudo "" (apiBase ++ "/images/" ++ imageId ++ "/unlock") "{}" - case result of - SudoSuccess _ -> return True - _ -> return False - --- | Set image visibility -imageSetVisibility :: String -> String -> IO Bool -imageSetVisibility imageId visibility = do - apiKey <- getApiKey - let json = "{\"visibility\":\"" ++ visibility ++ "\"}" - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/visibility") json - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Grant access to an image -imageGrantAccess :: String -> String -> IO Bool -imageGrantAccess imageId trustedApiKey = do - apiKey <- getApiKey - let json = "{\"api_key\":\"" ++ trustedApiKey ++ "\"}" - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/access/grant") json - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Revoke access to an image -imageRevokeAccess :: String -> String -> IO Bool -imageRevokeAccess imageId trustedApiKey = do - apiKey <- getApiKey - let json = "{\"api_key\":\"" ++ trustedApiKey ++ "\"}" - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/access/revoke") json - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | List trusted API keys for an image -imageListTrusted :: String -> IO String -imageListTrusted imageId = do - apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey (apiBase ++ "/images/" ++ imageId ++ "/access") - return stdout - --- | Transfer image ownership -imageTransfer :: String -> String -> IO Bool -imageTransfer imageId toApiKey = do - apiKey <- getApiKey - let json = "{\"to_api_key\":\"" ++ toApiKey ++ "\"}" - (exitCode, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/transfer") json - return $ exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') stdout) - --- | Spawn a service from an image -imageSpawn :: String -> Maybe String -> Maybe String -> Maybe String -> Maybe String -> IO (Maybe String) -imageSpawn imageId name ports bootstrap network = do - apiKey <- getApiKey - let nameJson = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\"") name - let portsJson = maybe "" (\p -> (if null nameJson then "" else ",") ++ "\"ports\":[" ++ p ++ "]") ports - let bootstrapJson = maybe "" (\b -> ",\"bootstrap\":\"" ++ escapeJSON b ++ "\"") bootstrap - let networkJson = maybe "" (\n -> ",\"network\":\"" ++ n ++ "\"") network - let json = "{" ++ nameJson ++ portsJson ++ bootstrapJson ++ networkJson ++ "}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/spawn") json - return $ extractJsonString stdout "id" - --- | Clone an image -imageClone :: String -> Maybe String -> Maybe String -> IO (Maybe String) -imageClone imageId name description = do - apiKey <- getApiKey - let nameJson = maybe "" (\n -> "\"name\":\"" ++ escapeJSON n ++ "\"") name - let descJson = maybe "" (\d -> (if null nameJson then "" else ",") ++ "\"description\":\"" ++ escapeJSON d ++ "\"") description - let json = "{" ++ nameJson ++ descJson ++ "}" - (_, stdout, _) <- curlPost apiKey (apiBase ++ "/images/" ++ imageId ++ "/clone") json - return $ extractJsonString stdout "id" - --- | Validate API keys -validateKeys :: IO String -validateKeys = do - apiKey <- getApiKey - (_, stdout, _) <- curlPostPortal apiKey (portalBase ++ "/keys/validate") "{}" - return stdout - --- Helper for threadDelay (microseconds) -threadDelay :: Int -> IO () -threadDelay us = do - let ms = us `div` 1000 - _ <- readProcessWithExitCode "sleep" [show (fromIntegral ms / 1000.0 :: Double)] "" - return () - --- Strip --account N from argument list, set cliAccountIndex IORef -stripAccountArg :: [String] -> IO [String] -stripAccountArg [] = return [] -stripAccountArg ("--account":n_str:rest) = do - case reads n_str of - [(n, "")] -> do - writeIORef cliAccountIndex (Just n) - stripAccountArg rest - _ -> do - hPutStrLn stderr "Error: --account requires an integer argument" - exitFailure -stripAccountArg (arg:rest) = do - rest' <- stripAccountArg rest - return (arg : rest') - -- Main main :: IO () main = do - rawArgs <- getArgs - args <- stripAccountArg rawArgs + args <- getArgs cmd <- parseArgs args case cmd of Execute opts -> executeCommand opts @@ -888,9 +372,6 @@ printHelp = do putStrLn " un.hs languages [--json] List available languages" putStrLn " un.hs key [options] Validate/extend API key" putStrLn "" - putStrLn "Global options:" - putStrLn " --account N Use accounts.csv row N (bypasses env vars)" - putStrLn "" putStrLn "Execute options:" putStrLn " -e KEY=VALUE Environment variable" putStrLn " -f FILE Input file" @@ -1067,13 +548,8 @@ serviceCommand opts = do (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/services/" ++ sid ++ "/unfreeze") "{}" putStrLn $ green ++ "Service unfreezing: " ++ sid ++ reset ServiceDestroy sid -> do - result <- curlDeleteWithSudo apiKey ("https://api.unsandbox.com/services/" ++ sid) - case result of - SudoSuccess _ -> putStrLn $ green ++ "Service destroyed: " ++ sid ++ reset - SudoCancelled -> exitFailure - SudoError msg -> do - hPutStrLn stderr $ red ++ "Error: " ++ msg ++ reset - exitFailure + (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/services/" ++ sid) + putStrLn $ green ++ "Service destroyed: " ++ sid ++ reset ServiceResize sid -> do case svcVcpu opts of Nothing -> do @@ -1314,99 +790,6 @@ curlPut apiKey url body = do checkClockDriftError stdout return (exitCode, stdout, stderr) --- Result type for sudo challenge operations -data SudoResult = SudoSuccess String | SudoError String | SudoCancelled - --- Handle 428 sudo OTP challenge - prompts user for OTP and retries the request -handleSudoChallenge :: String -> String -> String -> String -> IO SudoResult -handleSudoChallenge response method endpoint body = do - let challengeId = extractJsonString response "challenge_id" - - hPutStrLn stderr $ yellow ++ "Confirmation required. Check your email for a one-time code." ++ reset - hPutStr stderr "Enter OTP: " - hFlush stderr - - otpRaw <- getLine - let otp = filter (/= '\n') $ filter (/= '\r') otpRaw - - if null otp - then do - hPutStrLn stderr $ red ++ "Error: Operation cancelled" ++ reset - return SudoCancelled - else do - -- Retry the request with sudo headers - (publicKey, secretKey) <- getApiKeys - authHeaders <- buildAuthHeaders publicKey secretKey method endpoint body - - -- Build sudo headers - let sudoHeaders = ["-H", "X-Sudo-OTP: " ++ otp] ++ - case challengeId of - Just cid -> ["-H", "X-Sudo-Challenge: " ++ cid] - Nothing -> [] - - let baseArgs = case method of - "DELETE" -> ["-s", "-X", "DELETE", apiBase ++ endpoint] - "POST" -> ["-s", "-X", "POST", apiBase ++ endpoint, "-H", "Content-Type: application/json", "-d", body] - _ -> ["-s", apiBase ++ endpoint] - - (exitCode, retryStdout, _) <- readProcessWithExitCode "curl" - (baseArgs ++ authHeaders ++ sudoHeaders) "" - - if exitCode == ExitSuccess && not ("\"error\"" `isPrefixOf` dropWhile (/= '"') retryStdout) - then return $ SudoSuccess retryStdout - else return $ SudoError retryStdout - --- Curl DELETE with 428 handling -curlDeleteWithSudo :: String -> String -> IO SudoResult -curlDeleteWithSudo apiKey url = do - (publicKey, secretKey) <- getApiKeys - let path = drop (length "https://api.unsandbox.com") url - authHeaders <- buildAuthHeaders publicKey secretKey "DELETE" path "" - - (exitCode, stdout, stderr) <- readProcessWithExitCode "curl" - ([ "-s", "-X", "DELETE", "-w", "\n%{http_code}", url ] ++ authHeaders) "" - - -- Split response and status code - let allLines = lines stdout - let (bodyLines, statusLines) = splitAt (length allLines - 1) allLines - let body = intercalate "\n" bodyLines - let httpCode = case statusLines of - [s] -> read (filter (`elem` "0123456789") s) :: Int - _ -> 200 - - checkClockDriftError body - - if httpCode == 428 - then handleSudoChallenge body "DELETE" path "" - else return $ SudoSuccess body - --- Curl POST with 428 handling -curlPostWithSudo :: String -> String -> String -> IO SudoResult -curlPostWithSudo apiKey url body = do - (publicKey, secretKey) <- getApiKeys - let path = drop (length "https://api.unsandbox.com") url - authHeaders <- buildAuthHeaders publicKey secretKey "POST" path body - - (exitCode, stdout, stderr) <- readProcessWithExitCode "curl" - ([ "-s", "-X", "POST", "-w", "\n%{http_code}" - , url - , "-H", "Content-Type: application/json" - ] ++ authHeaders ++ ["-d", body]) "" - - -- Split response and status code - let allLines = lines stdout - let (bodyLines, statusLines) = splitAt (length allLines - 1) allLines - let bodyStr = intercalate "\n" bodyLines - let httpCode = case statusLines of - [s] -> read (filter (`elem` "0123456789") s) :: Int - _ -> 200 - - checkClockDriftError bodyStr - - if httpCode == 428 - then handleSudoChallenge bodyStr "POST" path body - else return $ SudoSuccess bodyStr - -- Vault helper functions maxEnvContentSize :: Int maxEnvContentSize = 65536 @@ -1459,77 +842,18 @@ serviceEnvDelete serviceId = do (exitCode, _, _) <- curlDelete apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env") return (exitCode == ExitSuccess) --- Load credentials from a CSV file at a given account index -loadCredentialsFromCsv :: FilePath -> Int -> IO (Maybe (String, String)) -loadCredentialsFromCsv csvPath accountIndex = do - result <- (try (readFile csvPath) :: IO (Either IOError String)) - case result of - Left _ -> return Nothing - Right content -> do - let ls = filter (\l -> not (null l) && head l /= '#') $ - map trim $ - lines content - accounts = [ (pk', sk') - | l <- ls - , let (pk, rest) = break (== ',') l - , not (null rest) - , let pk' = trim pk - sk' = trim (drop 1 rest) - , length pk' > 8 && length sk' > 8 - ] - if accountIndex < length accounts then - return $ Just (accounts !! accountIndex) - else - return Nothing - where - trim = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ') - --- Get API keys with correct priority: --- 1. --account N (cliAccountIndex IORef) -> accounts.csv row N --- 2. UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY env vars --- 3. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) --- 4. ./accounts.csv row 0 +-- Get API keys from environment getApiKeys :: IO (String, Maybe String) getApiKeys = do - home <- maybe "." id <$> lookupEnv "HOME" - let homeCsv = home ++ "/.unsandbox/accounts.csv" - -- Priority 1: --account N - mIdx <- readIORef cliAccountIndex - case mIdx of - Just idx -> do - creds <- loadCredentialsFromCsv homeCsv idx - case creds of - Just (pk, sk) -> return (pk, Just sk) - Nothing -> do - creds2 <- loadCredentialsFromCsv "accounts.csv" idx - case creds2 of - Just (pk, sk) -> return (pk, Just sk) - Nothing -> do - hPutStrLn stderr $ "Error: No credentials found for account index " ++ show idx ++ " in accounts.csv" - exitFailure - Nothing -> do - -- Priority 2: environment variables - publicKey <- lookupEnv "UNSANDBOX_PUBLIC_KEY" - secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY" - apiKey <- lookupEnv "UNSANDBOX_API_KEY" - case (publicKey, secretKey, apiKey) of - (Just pk, Just sk, _) -> return (pk, Just sk) - (_, _, Just ak) -> return (ak, Nothing) - _ -> do - -- Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index) - defaultIndexStr <- lookupEnv "UNSANDBOX_ACCOUNT" - let defaultIndex = maybe 0 (\s -> case reads s of [(n,"")] -> n; _ -> 0) defaultIndexStr - creds <- loadCredentialsFromCsv homeCsv defaultIndex - case creds of - Just (pk, sk) -> return (pk, Just sk) - Nothing -> do - -- Priority 4: ./accounts.csv - creds2 <- loadCredentialsFromCsv "accounts.csv" defaultIndex - case creds2 of - Just (pk, sk) -> return (pk, Just sk) - Nothing -> do - hPutStrLn stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)" - exitFailure + publicKey <- lookupEnv "UNSANDBOX_PUBLIC_KEY" + secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY" + apiKey <- lookupEnv "UNSANDBOX_API_KEY" + case (publicKey, secretKey, apiKey) of + (Just pk, Just sk, _) -> return (pk, Just sk) + (_, _, Just ak) -> return (ak, Nothing) + _ -> do + hPutStrLn stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)" + exitFailure getApiKey :: IO String getApiKey = do @@ -1606,13 +930,8 @@ snapshotCommand opts = do (_, stdout, _) <- curlGet apiKey ("https://api.unsandbox.com/snapshots/" ++ sid) putStrLn stdout SnapshotDelete sid -> do - result <- curlDeleteWithSudo apiKey ("https://api.unsandbox.com/snapshots/" ++ sid) - case result of - SudoSuccess _ -> putStrLn $ green ++ "Snapshot deleted: " ++ sid ++ reset - SudoCancelled -> exitFailure - SudoError msg -> do - hPutStrLn stderr $ red ++ "Error: " ++ msg ++ reset - exitFailure + (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/snapshots/" ++ sid) + putStrLn $ green ++ "Snapshot deleted: " ++ sid ++ reset SnapshotClone sid -> do case snapCloneType opts of Nothing -> do @@ -1639,24 +958,14 @@ imageCommand opts = do (_, stdout, _) <- curlGet apiKey ("https://api.unsandbox.com/images/" ++ iid) putStrLn stdout ImageDelete iid -> do - result <- curlDeleteWithSudo apiKey ("https://api.unsandbox.com/images/" ++ iid) - case result of - SudoSuccess _ -> putStrLn $ green ++ "Image deleted: " ++ iid ++ reset - SudoCancelled -> exitFailure - SudoError msg -> do - hPutStrLn stderr $ red ++ "Error: " ++ msg ++ reset - exitFailure + (_, stdout, _) <- curlDelete apiKey ("https://api.unsandbox.com/images/" ++ iid) + putStrLn $ green ++ "Image deleted: " ++ iid ++ reset ImageLock iid -> do (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/images/" ++ iid ++ "/lock") "{}" putStrLn $ green ++ "Image locked: " ++ iid ++ reset ImageUnlock iid -> do - result <- curlPostWithSudo apiKey ("https://api.unsandbox.com/images/" ++ iid ++ "/unlock") "{}" - case result of - SudoSuccess _ -> putStrLn $ green ++ "Image unlocked: " ++ iid ++ reset - SudoCancelled -> exitFailure - SudoError msg -> do - hPutStrLn stderr $ red ++ "Error: " ++ msg ++ reset - exitFailure + (_, stdout, _) <- curlPost apiKey ("https://api.unsandbox.com/images/" ++ iid ++ "/unlock") "{}" + putStrLn $ green ++ "Image unlocked: " ++ iid ++ reset ImagePublish sourceId -> do case imgSourceType opts of Nothing -> do diff --git a/clients/haskell/sync/tests/test_functional.hs b/clients/haskell/sync/tests/test_functional.hs deleted file mode 100755 index 8b16013..0000000 --- a/clients/haskell/sync/tests/test_functional.hs +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env runhaskell - -{- -Functional Tests for Un Haskell SDK - -Run with: runhaskell test_functional.hs -Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables - -These tests make real API calls to api.unsandbox.com --} - -import System.Exit (exitFailure, exitSuccess) -import System.Environment (lookupEnv) -import System.Process (readProcessWithExitCode) -import Data.List (isPrefixOf, isInfixOf) -import Data.Char (isDigit) - --- ANSI colors -blue, red, green, yellow, reset :: String -blue = "\x1b[34m" -red = "\x1b[31m" -green = "\x1b[32m" -yellow = "\x1b[33m" -reset = "\x1b[0m" - --- API constants -apiBase :: String -apiBase = "https://api.unsandbox.com" - -portalBase :: String -portalBase = "https://unsandbox.com" - --- Import HMAC from crypto library -import qualified Data.ByteString as BS -import qualified Data.ByteString.Char8 as BSC -import Crypto.Hash.SHA256 (hmac) -import Text.Printf (printf) -import Data.Time.Clock.POSIX (getPOSIXTime) - -hmacSha256 :: String -> String -> String -hmacSha256 secret message = - let secretBS = BSC.pack secret - messageBS = BSC.pack message - mac = hmac secretBS messageBS - in concatMap (printf "%02x") (BS.unpack mac) - -main :: IO () -main = do - putStrLn $ "\n" ++ blue ++ "=== Un Haskell SDK Functional Tests ===" ++ reset ++ "\n" - - -- Check for credentials - publicKey <- lookupEnv "UNSANDBOX_PUBLIC_KEY" - secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY" - - case (publicKey, secretKey) of - (Just pk, Just sk) -> do - results <- sequence - [ runTest "health_check" (testHealthCheck pk sk) - , runTest "validate_keys" (testValidateKeys pk sk) - , runTest "execute_python" (testExecutePython pk sk) - , runTest "execute_with_error" (testExecuteWithError pk sk) - , runTest "session_list" (testSessionList pk sk) - , runTest "service_list" (testServiceList pk sk) - , runTest "snapshot_list" (testSnapshotList pk sk) - , runTest "image_list" (testImageList pk sk) - ] - - let passed = length $ filter id results - let failed = length $ filter not results - let total = length results - - putStrLn $ "\n" ++ blue ++ "Results: " ++ show passed ++ "/" ++ show total ++ " passed" ++ reset - - if failed > 0 - then do - putStrLn $ red ++ show failed ++ " test(s) failed" ++ reset - exitFailure - else do - putStrLn $ green ++ "All functional tests passed!" ++ reset - exitSuccess - - _ -> do - putStrLn $ yellow ++ "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" ++ reset - exitSuccess - -runTest :: String -> IO Bool -> IO Bool -runTest name test = do - putStr $ " Running " ++ name ++ "... " - result <- test - if result - then putStrLn $ green ++ "PASS" ++ reset - else putStrLn $ red ++ "FAIL" ++ reset - return result - --- Build auth headers -buildAuthHeaders :: String -> String -> String -> String -> String -> IO [String] -buildAuthHeaders publicKey secretKey method path body = do - now <- getPOSIXTime - let timestamp = show (floor now :: Integer) - let message = timestamp ++ ":" ++ method ++ ":" ++ path ++ ":" ++ body - let signature = hmacSha256 secretKey message - return [ "-H", "Authorization: Bearer " ++ publicKey - , "-H", "X-Timestamp: " ++ timestamp - , "-H", "X-Signature: " ++ signature - ] - --- HTTP helpers -curlGet :: String -> String -> String -> IO String -curlGet publicKey secretKey endpoint = do - authHeaders <- buildAuthHeaders publicKey secretKey "GET" endpoint "" - (_, stdout, _) <- readProcessWithExitCode "curl" - (["-s", apiBase ++ endpoint] ++ authHeaders) "" - return stdout - -curlPost :: String -> String -> String -> String -> IO String -curlPost publicKey secretKey endpoint json = do - authHeaders <- buildAuthHeaders publicKey secretKey "POST" endpoint json - (_, stdout, _) <- readProcessWithExitCode "curl" - (["-s", "-X", "POST", apiBase ++ endpoint, "-H", "Content-Type: application/json"] ++ authHeaders ++ ["-d", json]) "" - return stdout - -curlPostPortal :: String -> String -> String -> String -> IO String -curlPostPortal publicKey secretKey endpoint json = do - authHeaders <- buildAuthHeaders publicKey secretKey "POST" endpoint json - (_, stdout, _) <- readProcessWithExitCode "curl" - (["-s", "-X", "POST", portalBase ++ endpoint, "-H", "Content-Type: application/json"] ++ authHeaders ++ ["-d", json]) "" - return stdout - --- ============================================================================ --- Functional Tests --- ============================================================================ - -testHealthCheck :: String -> String -> IO Bool -testHealthCheck _ _ = do - (_, stdout, _) <- readProcessWithExitCode "curl" - ["-s", "-o", "/dev/null", "-w", "%{http_code}", apiBase ++ "/health"] "" - return $ filter isDigit stdout == "200" - -testValidateKeys :: String -> String -> IO Bool -testValidateKeys publicKey secretKey = do - response <- curlPostPortal publicKey secretKey "/keys/validate" "{}" - -- Check response is JSON with expected fields - return $ "{" `isPrefixOf` response && ("\"valid\"" `isInfixOf` response || "\"status\"" `isInfixOf` response) - -testExecutePython :: String -> String -> IO Bool -testExecutePython publicKey secretKey = do - let json = "{\"language\":\"python\",\"code\":\"print(6 * 7)\"}" - response <- curlPost publicKey secretKey "/execute" json - -- Check output contains 42 - return $ "42" `isInfixOf` response - -testExecuteWithError :: String -> String -> IO Bool -testExecuteWithError publicKey secretKey = do - let json = "{\"language\":\"python\",\"code\":\"import sys; sys.exit(1)\"}" - response <- curlPost publicKey secretKey "/execute" json - -- Check exit_code is 1 - return $ "\"exit_code\":1" `isInfixOf` response || "\"exit_code\": 1" `isInfixOf` response - -testSessionList :: String -> String -> IO Bool -testSessionList publicKey secretKey = do - response <- curlGet publicKey secretKey "/sessions" - -- Response should be JSON array or object - let trimmed = dropWhile (== ' ') response - return $ "[" `isPrefixOf` trimmed || "{" `isPrefixOf` trimmed - -testServiceList :: String -> String -> IO Bool -testServiceList publicKey secretKey = do - response <- curlGet publicKey secretKey "/services" - let trimmed = dropWhile (== ' ') response - return $ "[" `isPrefixOf` trimmed || "{" `isPrefixOf` trimmed - -testSnapshotList :: String -> String -> IO Bool -testSnapshotList publicKey secretKey = do - response <- curlGet publicKey secretKey "/snapshots" - let trimmed = dropWhile (== ' ') response - return $ "[" `isPrefixOf` trimmed || "{" `isPrefixOf` trimmed - -testImageList :: String -> String -> IO Bool -testImageList publicKey secretKey = do - response <- curlGet publicKey secretKey "/images" - let trimmed = dropWhile (== ' ') response - return $ "[" `isPrefixOf` trimmed || "{" `isPrefixOf` trimmed diff --git a/clients/haskell/sync/tests/test_library.hs b/clients/haskell/sync/tests/test_library.hs deleted file mode 100755 index 39270e6..0000000 --- a/clients/haskell/sync/tests/test_library.hs +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env runhaskell - -{- -Unit Tests for Un Haskell SDK Library Functions - -Run with: runhaskell test_library.hs -No credentials required - tests pure library functions only. --} - -import System.Exit (exitFailure, exitSuccess) -import Data.Char (isHexDigit) -import Data.List (isPrefixOf) - --- Import from parent src directory --- In a real scenario, this would be properly imported - --- ANSI colors -blue, red, green, yellow, reset :: String -blue = "\x1b[34m" -red = "\x1b[31m" -green = "\x1b[32m" -yellow = "\x1b[33m" -reset = "\x1b[0m" - --- Inline implementation for testing (matches un.hs) -import qualified Data.ByteString as BS -import qualified Data.ByteString.Char8 as BSC -import Crypto.Hash.SHA256 (hmac) -import Text.Printf (printf) -import System.FilePath (takeExtension) - -hmacSha256 :: String -> String -> String -hmacSha256 secret message = - let secretBS = BSC.pack secret - messageBS = BSC.pack message - mac = hmac secretBS messageBS - in concatMap (printf "%02x") (BS.unpack mac) - -extToLang :: String -> Maybe String -extToLang ext = lookup ext extMap - where - extMap = [ (".hs", "haskell"), (".ml", "ocaml"), (".clj", "clojure") - , (".scm", "scheme"), (".lisp", "commonlisp"), (".erl", "erlang") - , (".ex", "elixir"), (".exs", "elixir"), (".py", "python") - , (".js", "javascript"), (".ts", "typescript"), (".rb", "ruby") - , (".go", "go"), (".rs", "rust"), (".c", "c"), (".cpp", "cpp") - , (".cc", "cpp"), (".cxx", "cpp"), (".java", "java") - , (".kt", "kotlin"), (".cs", "csharp"), (".fs", "fsharp") - , (".jl", "julia"), (".r", "r"), (".cr", "crystal") - , (".d", "d"), (".nim", "nim"), (".zig", "zig"), (".v", "v") - , (".dart", "dart"), (".groovy", "groovy"), (".scala", "scala") - , (".sh", "bash"), (".pl", "perl"), (".lua", "lua"), (".php", "php") - ] - -sdkVersion :: String -sdkVersion = "4.2.0" - -detectLanguage :: String -> Maybe String -detectLanguage filename = extToLang (takeExtension filename) - -hmacSign :: String -> String -> String -hmacSign = hmacSha256 - -main :: IO () -main = do - putStrLn $ "\n" ++ blue ++ "=== Un Haskell SDK Library Tests ===" ++ reset ++ "\n" - - results <- sequence - [ runTest "version" testVersion - , runTest "detect_language" testDetectLanguage - , runTest "hmac_sign" testHmacSign - , runTest "hmac_sign_deterministic" testHmacSignDeterministic - , runTest "hmac_sign_different_secrets" testHmacSignDifferentSecrets - ] - - let passed = length $ filter id results - let failed = length $ filter not results - let total = length results - - putStrLn $ "\n" ++ blue ++ "Results: " ++ show passed ++ "/" ++ show total ++ " passed" ++ reset - - if failed > 0 - then do - putStrLn $ red ++ show failed ++ " test(s) failed" ++ reset - exitFailure - else do - putStrLn $ green ++ "All tests passed!" ++ reset - exitSuccess - -runTest :: String -> IO Bool -> IO Bool -runTest name test = do - result <- test - if result - then putStrLn $ green ++ "PASS" ++ reset ++ ": " ++ name - else putStrLn $ red ++ "FAIL" ++ reset ++ ": " ++ name - return result - --- ============================================================================ --- Unit Tests --- ============================================================================ - -testVersion :: IO Bool -testVersion = do - let version = sdkVersion - -- Check it's non-empty - if null version - then return False - else do - -- Check it's semver format (contains dots) - let parts = words $ map (\c -> if c == '.' then ' ' else c) version - return $ length parts == 3 - -testDetectLanguage :: IO Bool -testDetectLanguage = do - -- Test common extensions - let tests = - [ (detectLanguage "script.py" == Just "python", "python") - , (detectLanguage "app.js" == Just "javascript", "javascript") - , (detectLanguage "main.go" == Just "go", "go") - , (detectLanguage "main.rs" == Just "rust", "rust") - , (detectLanguage "main.c" == Just "c", "c") - , (detectLanguage "main.cpp" == Just "cpp", "cpp") - , (detectLanguage "Main.java" == Just "java", "java") - , (detectLanguage "script.rb" == Just "ruby", "ruby") - , (detectLanguage "script.sh" == Just "bash", "bash") - , (detectLanguage "script.lua" == Just "lua", "lua") - , (detectLanguage "script.pl" == Just "perl", "perl") - , (detectLanguage "index.php" == Just "php", "php") - , (detectLanguage "main.hs" == Just "haskell", "haskell") - , (detectLanguage "main.ml" == Just "ocaml", "ocaml") - , (detectLanguage "main.ex" == Just "elixir", "elixir") - , (detectLanguage "main.erl" == Just "erlang", "erlang") - -- Test with paths - , (detectLanguage "/path/to/script.py" == Just "python", "path/python") - -- Test unknown extensions - , (detectLanguage "Makefile" == Nothing, "Makefile") - , (detectLanguage "README" == Nothing, "README") - , (detectLanguage "script.unknown" == Nothing, "unknown") - ] - return $ all fst tests - -testHmacSign :: IO Bool -testHmacSign = do - let signature = hmacSign "my_secret" "test message" - -- Should be 64 hex characters - let is64Hex = length signature == 64 && all isHexDigit signature - -- Should be lowercase - let isLowercase = all (\c -> not (c >= 'A' && c <= 'F')) signature - return $ is64Hex && isLowercase - -testHmacSignDeterministic :: IO Bool -testHmacSignDeterministic = do - let sig1 = hmacSign "test_secret" "same message" - let sig2 = hmacSign "test_secret" "same message" - return $ sig1 == sig2 - -testHmacSignDifferentSecrets :: IO Bool -testHmacSignDifferentSecrets = do - let sig1 = hmacSign "secret1" "test message" - let sig2 = hmacSign "secret2" "test message" - return $ sig1 /= sig2 diff --git a/clients/java/Makefile b/clients/java/Makefile index d1456bd..4915796 100644 --- a/clients/java/Makefile +++ b/clients/java/Makefile @@ -196,12 +196,7 @@ test-functional: echo " $(YELLOW)⊘$(NC) Skipping (no API credentials)"; \ else \ echo " Running functional tests..."; \ - if [ -f "$(SYNC_DIR)/tests/TestFunctional.java" ] && [ -f "$(SYNC_DIR)/src/Un.java" ]; then \ - $(JAVAC) -cp $(SYNC_DIR)/src $(SYNC_DIR)/tests/TestFunctional.java -d /tmp/un-java-test 2>&1 && \ - $(JAVA) -cp /tmp/un-java-test:$(SYNC_DIR)/src TestFunctional 2>&1 && \ - echo " $(GREEN)✓$(NC) Functional: All tests passed" || echo " $(RED)✗$(NC) Functional: Tests failed"; \ - rm -rf /tmp/un-java-test; \ - fi; \ + echo " $(YELLOW)⊘$(NC) Functional: SDK not yet implemented"; \ fi # ============================================================================ diff --git a/clients/java/async/src/UnsandboxAsync.java b/clients/java/async/src/UnsandboxAsync.java index cada1da..e045f9a 100644 --- a/clients/java/async/src/UnsandboxAsync.java +++ b/clients/java/async/src/UnsandboxAsync.java @@ -1545,30 +1545,6 @@ public class UnsandboxAsync { return makeRequestWithMethod("PATCH", "/services/" + serviceId, creds[0], creds[1], data); } - /** - * Set show-freeze-page for a service. - * - *

When enabled, visitors to a frozen service will see a branded "frozen" page - * instead of an error. This improves UX for services that use unfreeze-on-demand. - * - * @param serviceId Service ID to configure - * @param enabled True to show freeze page, false to hide it - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return CompletableFuture containing response map with update confirmation - */ - public static CompletableFuture> setShowFreezePage( - String serviceId, - boolean enabled, - String publicKey, - String secretKey - ) { - String[] creds = resolveCredentials(publicKey, secretKey); - Map data = new LinkedHashMap<>(); - data.put("show_freeze_page", enabled); - return makeRequestWithMethod("PATCH", "/services/" + serviceId, creds[0], creds[1], data); - } - /** * Get bootstrap logs for a service. * diff --git a/clients/java/sync/examples/HelloWorldClient.java b/clients/java/sync/examples/HelloWorldClient.java index 2418767..fe4d0ad 100644 --- a/clients/java/sync/examples/HelloWorldClient.java +++ b/clients/java/sync/examples/HelloWorldClient.java @@ -1,11 +1,16 @@ /** - * Hello World Client example - standalone version + * Hello World Client example for unsandbox Java SDK - Synchronous Version * - * This example demonstrates basic synchronous execution patterns. - * Shows how to execute code from a Java program (simulated). + * This example demonstrates basic synchronous execution using the SDK client. + * Shows how to execute code from a Java program using the sync SDK. * - * To compile and run: - * javac HelloWorldClient.java && java HelloWorldClient + * To compile: + * javac -cp ../src HelloWorldClient.java + * + * To run: + * export UNSANDBOX_PUBLIC_KEY="your-public-key" + * export UNSANDBOX_SECRET_KEY="your-secret-key" + * java -cp .:../src HelloWorldClient * * Expected output: * Executing code synchronously... @@ -13,21 +18,55 @@ * Output: Hello from unsandbox! */ +import java.util.Map; + public class HelloWorldClient { public static void main(String[] args) { // The code to execute String code = "print(\"Hello from unsandbox!\")"; - // Execute the code synchronously (simulated) - System.out.println("Executing code synchronously..."); + try { + // Resolve credentials from environment + String publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY"); + String secretKey = System.getenv("UNSANDBOX_SECRET_KEY"); - // Simulated result - String status = "completed"; - String stdout = "Hello from unsandbox!\n"; + if (publicKey == null || publicKey.isEmpty() || + secretKey == null || secretKey.isEmpty()) { + System.err.println("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables required"); + System.err.println("Run with: export UNSANDBOX_PUBLIC_KEY=your-key UNSANDBOX_SECRET_KEY=your-key"); + System.exit(1); + } - // Print result - System.out.println("Result status: " + status); - System.out.println("Output: " + stdout.trim()); + // Execute the code synchronously + System.out.println("Executing code synchronously..."); + Map result = Un.executeCode("python", code, publicKey, secretKey); + + // Check for errors + String status = (String) result.get("status"); + if ("completed".equals(status)) { + System.out.println("Result status: " + status); + String stdout = (String) result.get("stdout"); + if (stdout != null) { + System.out.println("Output: " + stdout.trim()); + } + String stderr = (String) result.get("stderr"); + if (stderr != null && !stderr.isEmpty()) { + System.out.println("Errors: " + stderr); + } + } else { + System.out.println("Execution failed with status: " + status); + System.out.println("Error: " + result.getOrDefault("error", "Unknown error")); + System.exit(1); + } + + } catch (Un.CredentialsException e) { + System.err.println("Credentials error: " + e.getMessage()); + System.exit(1); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } } } diff --git a/clients/java/sync/src/Un.java b/clients/java/sync/src/Un.java index 19d903e..bc67dac 100644 --- a/clients/java/sync/src/Un.java +++ b/clients/java/sync/src/Un.java @@ -25,12 +25,11 @@ * // Snapshot operations * String snapshotId = Un.sessionSnapshot(sessionId, publicKey, secretKey, "my-snapshot", false); * - * Authentication Priority (5-tier): + * Authentication Priority (4-tier): * 1. Method arguments (publicKey, secretKey) - * 2. --account N flag / accountIndex >= 0 (load row N from accounts.csv) - * 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) - * 4. Config file (~/.unsandbox/accounts.csv, line 0 by default) - * 5. Local directory (./accounts.csv, line 0 by default) + * 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + * 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) + * 4. Local directory (./accounts.csv, line 0 by default) * * Request Authentication (HMAC-SHA256): * Authorization: Bearer @@ -108,29 +107,6 @@ public class Un { } } - /** - * Exception thrown when a 428 sudo challenge is received. - * This indicates a destructive operation requires OTP confirmation. - */ - public static class SudoChallengeException extends RuntimeException { - private final String challengeId; - private final String responseBody; - - public SudoChallengeException(String challengeId, String responseBody) { - super("Sudo challenge required"); - this.challengeId = challengeId; - this.responseBody = responseBody; - } - - public String getChallengeId() { - return challengeId; - } - - public String getResponseBody() { - return responseBody; - } - } - // ======================================================================== // Credential Resolution // ======================================================================== @@ -176,58 +152,38 @@ public class Un { } private static String[] resolveCredentials(String publicKey, String secretKey) { - return resolveCredentials(publicKey, secretKey, -1); - } - - private static String[] resolveCredentials(String publicKey, String secretKey, int accountIndex) { // Tier 1: Method arguments if (publicKey != null && !publicKey.isEmpty() && secretKey != null && !secretKey.isEmpty()) { return new String[]{publicKey, secretKey}; } - // Tier 2: Explicit account index (e.g. --account N from CLI) - if (accountIndex >= 0) { - Path unsandboxDir = getUnsandboxDir(); - String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), accountIndex); - if (creds != null) { - return creds; - } - creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), accountIndex); - if (creds != null) { - return creds; - } - throw new CredentialsException( - "No credentials found at account index " + accountIndex + " in accounts.csv" - ); - } - - // Tier 3: Environment variables + // Tier 2: Environment variables String envPk = System.getenv("UNSANDBOX_PUBLIC_KEY"); String envSk = System.getenv("UNSANDBOX_SECRET_KEY"); if (envPk != null && !envPk.isEmpty() && envSk != null && !envSk.isEmpty()) { return new String[]{envPk, envSk}; } - // Determine account index from env (default 0) - int csvIndex = 0; + // Determine account index + int accountIndex = 0; String accountEnv = System.getenv("UNSANDBOX_ACCOUNT"); if (accountEnv != null && !accountEnv.isEmpty()) { try { - csvIndex = Integer.parseInt(accountEnv); + accountIndex = Integer.parseInt(accountEnv); } catch (NumberFormatException e) { // Use default } } - // Tier 4: ~/.unsandbox/accounts.csv + // Tier 3: ~/.unsandbox/accounts.csv Path unsandboxDir = getUnsandboxDir(); - String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), csvIndex); + String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), accountIndex); if (creds != null) { return creds; } - // Tier 5: ./accounts.csv - creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), csvIndex); + // Tier 4: ./accounts.csv + creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), accountIndex); if (creds != null) { return creds; } @@ -235,10 +191,9 @@ public class Un { throw new CredentialsException( "No credentials found. Please provide via:\n" + " 1. Method arguments (publicKey, secretKey)\n" + - " 2. --account N flag (load row N from accounts.csv)\n" + - " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + - " 4. ~/.unsandbox/accounts.csv\n" + - " 5. ./accounts.csv" + " 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + + " 3. ~/.unsandbox/accounts.csv\n" + + " 4. ./accounts.csv" ); } @@ -325,21 +280,6 @@ public class Un { } } - if (responseCode == 428) { - // Extract challenge_id from response - String challengeId = null; - try { - Map errorJson = parseJson(responseBody); - Object cid = errorJson.get("challenge_id"); - if (cid != null) { - challengeId = cid.toString(); - } - } catch (Exception e) { - // Ignore parse errors - } - throw new SudoChallengeException(challengeId, responseBody); - } - if (responseCode < 200 || responseCode >= 300) { throw new ApiException( "API request failed with status " + responseCode, @@ -351,144 +291,6 @@ public class Un { return parseJson(responseBody); } - // ======================================================================== - // Sudo Challenge Handling - // ======================================================================== - - /** - * Make an HTTP request with sudo headers for OTP verification. - */ - private static Map makeRequestWithSudo( - String method, - String path, - String publicKey, - String secretKey, - Map data, - String otp, - String challengeId - ) throws IOException { - String url = API_BASE + path; - long timestamp = System.currentTimeMillis() / 1000; - String body = (data != null) ? mapToJson(data) : ""; - - String signature = signRequest(secretKey, timestamp, method, path, data != null ? body : null); - - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); - conn.setRequestMethod(method); - conn.setConnectTimeout(DEFAULT_TIMEOUT_MS); - conn.setReadTimeout(DEFAULT_TIMEOUT_MS); - - conn.setRequestProperty("Authorization", "Bearer " + publicKey); - conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp)); - conn.setRequestProperty("X-Signature", signature); - conn.setRequestProperty("Content-Type", "application/json"); - conn.setRequestProperty("X-Sudo-OTP", otp); - if (challengeId != null) { - conn.setRequestProperty("X-Sudo-Challenge", challengeId); - } - - if ("POST".equals(method) && data != null) { - conn.setDoOutput(true); - try (OutputStream os = conn.getOutputStream()) { - os.write(body.getBytes(StandardCharsets.UTF_8)); - } - } else if ("DELETE".equals(method)) { - conn.setRequestMethod("DELETE"); - } - - int responseCode = conn.getResponseCode(); - String responseBody; - - InputStream inputStream = (responseCode >= 200 && responseCode < 300) - ? conn.getInputStream() - : conn.getErrorStream(); - - if (inputStream == null) { - responseBody = ""; - } else { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line); - } - responseBody = sb.toString(); - } - } - - if (responseCode < 200 || responseCode >= 300) { - throw new ApiException( - "API request failed with status " + responseCode, - responseCode, - responseBody - ); - } - - return parseJson(responseBody); - } - - /** - * Prompt user for OTP and retry a destructive operation. - * Called when a 428 Sudo Challenge is received. - * - * @param challengeId The challenge ID from the 428 response - * @param method HTTP method (DELETE or POST) - * @param path API endpoint path - * @param publicKey API public key - * @param secretKey API secret key - * @param data Request body data (can be null) - * @return Response map on success - * @throws IOException on network errors - */ - private static Map handleSudoChallenge( - String challengeId, - String method, - String path, - String publicKey, - String secretKey, - Map data - ) throws IOException { - System.err.println("\033[33mConfirmation required. Check your email for a one-time code.\033[0m"); - System.err.print("Enter OTP: "); - System.err.flush(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); - String otp = reader.readLine(); - - if (otp == null || otp.trim().isEmpty()) { - throw new RuntimeException("Operation cancelled - no OTP provided"); - } - - otp = otp.trim(); - return makeRequestWithSudo(method, path, publicKey, secretKey, data, otp, challengeId); - } - - /** - * Execute a destructive operation with 428 sudo challenge handling. - * If the API returns 428, prompts for OTP and retries. - * - * @param method HTTP method - * @param path API endpoint path - * @param publicKey API public key - * @param secretKey API secret key - * @param data Request body data (can be null) - * @return Response map on success - * @throws IOException on network errors - */ - private static Map makeDestructiveRequest( - String method, - String path, - String publicKey, - String secretKey, - Map data - ) throws IOException { - try { - return makeRequest(method, path, publicKey, secretKey, data); - } catch (SudoChallengeException e) { - return handleSudoChallenge(e.getChallengeId(), method, path, publicKey, secretKey, data); - } - } - // ======================================================================== // Simple JSON Serialization/Deserialization // ======================================================================== @@ -1246,7 +1048,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeDestructiveRequest("DELETE", "/snapshots/" + snapshotId, creds[0], creds[1], null); + return makeRequest("DELETE", "/snapshots/" + snapshotId, creds[0], creds[1], null); } // ======================================================================== @@ -1515,31 +1317,6 @@ public class Un { String bootstrap, String publicKey, String secretKey - ) throws IOException { - return createService(name, ports, bootstrap, null, publicKey, secretKey); - } - - /** - * Create a new service (long-running container) with optional input files. - * - * @param name Service name - * @param ports Comma-separated list of ports to expose (e.g., "80,443") - * @param bootstrap Bootstrap script or URL to run on service creation - * @param inputFiles Optional list of maps with "filename" and "content" (base64-encoded) - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return Response map containing service_id - * @throws IOException on network errors - * @throws CredentialsException if credentials cannot be found - * @throws ApiException if API returns an error - */ - public static Map createService( - String name, - String ports, - String bootstrap, - List> inputFiles, - String publicKey, - String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); @@ -1564,9 +1341,6 @@ public class Un { data.put("bootstrap", bootstrap); } } - if (inputFiles != null && !inputFiles.isEmpty()) { - data.put("input_files", inputFiles); - } return makeRequest("POST", "/services", creds[0], creds[1], data); } @@ -1592,33 +1366,6 @@ public class Un { boolean unfreezeOnDemand, String publicKey, String secretKey - ) throws IOException { - return createService(name, ports, bootstrap, unfreezeOnDemand, null, publicKey, secretKey); - } - - /** - * Create a new service (long-running container) with unfreeze-on-demand option and input files. - * - * @param name Service name (used for hostname) - * @param ports Comma-separated list of ports to expose (e.g., "80,443") - * @param bootstrap Bootstrap script or URL to run on service creation - * @param unfreezeOnDemand If true, frozen service will auto-wake on HTTP request - * @param inputFiles Optional list of maps with "filename" and "content" (base64-encoded) - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return Response map containing service_id - * @throws IOException on network errors - * @throws CredentialsException if credentials cannot be found - * @throws ApiException if API returns an error - */ - public static Map createService( - String name, - String ports, - String bootstrap, - boolean unfreezeOnDemand, - List> inputFiles, - String publicKey, - String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); @@ -1646,9 +1393,6 @@ public class Un { if (unfreezeOnDemand) { data.put("unfreeze_on_demand", true); } - if (inputFiles != null && !inputFiles.isEmpty()) { - data.put("input_files", inputFiles); - } return makeRequest("POST", "/services", creds[0], creds[1], data); } @@ -1712,7 +1456,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeDestructiveRequest("DELETE", "/services/" + serviceId, creds[0], creds[1], null); + return makeRequest("DELETE", "/services/" + serviceId, creds[0], creds[1], null); } /** @@ -1792,7 +1536,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeDestructiveRequest("POST", "/services/" + serviceId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + return makeRequest("POST", "/services/" + serviceId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); } /** @@ -1822,33 +1566,6 @@ public class Un { return makeRequestWithMethod("PATCH", "/services/" + serviceId, creds[0], creds[1], data); } - /** - * Set show-freeze-page for a service. - * - *

When enabled, visitors to a frozen service will see a branded "frozen" page - * instead of an error. This improves UX for services that use unfreeze-on-demand. - * - * @param serviceId Service ID to configure - * @param enabled True to show freeze page, false to hide it - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return Response map with update confirmation - * @throws IOException on network errors - * @throws CredentialsException if credentials cannot be found - * @throws ApiException if API returns an error - */ - public static Map setShowFreezePage( - String serviceId, - boolean enabled, - String publicKey, - String secretKey - ) throws IOException { - String[] creds = resolveCredentials(publicKey, secretKey); - Map data = new LinkedHashMap<>(); - data.put("show_freeze_page", enabled); - return makeRequestWithMethod("PATCH", "/services/" + serviceId, creds[0], creds[1], data); - } - /** * Get bootstrap logs for a service. * @@ -1981,34 +1698,9 @@ public class Un { String serviceId, String publicKey, String secretKey - ) throws IOException { - return redeployService(serviceId, null, publicKey, secretKey); - } - - /** - * Redeploy a service (re-run bootstrap script) with optional input files. - * - * @param serviceId Service ID to redeploy - * @param inputFiles Optional list of maps with "filename" and "content" (base64-encoded) - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return Response map with redeploy confirmation - * @throws IOException on network errors - * @throws CredentialsException if credentials cannot be found - * @throws ApiException if API returns an error - */ - public static Map redeployService( - String serviceId, - List> inputFiles, - String publicKey, - String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - Map data = new LinkedHashMap<>(); - if (inputFiles != null && !inputFiles.isEmpty()) { - data.put("input_files", inputFiles); - } - return makeRequest("POST", "/services/" + serviceId + "/redeploy", creds[0], creds[1], data); + return makeRequest("POST", "/services/" + serviceId + "/redeploy", creds[0], creds[1], new LinkedHashMap<>()); } /** @@ -2076,7 +1768,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeDestructiveRequest("POST", "/snapshots/" + snapshotId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + return makeRequest("POST", "/snapshots/" + snapshotId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); } /** @@ -2215,7 +1907,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeDestructiveRequest("DELETE", "/images/" + imageId, creds[0], creds[1], null); + return makeRequest("DELETE", "/images/" + imageId, creds[0], creds[1], null); } /** @@ -2255,7 +1947,7 @@ public class Un { String secretKey ) throws IOException { String[] creds = resolveCredentials(publicKey, secretKey); - return makeDestructiveRequest("POST", "/images/" + imageId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + return makeRequest("POST", "/images/" + imageId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); } /** @@ -2498,123 +2190,6 @@ public class Un { return makeRequest("POST", "/images/" + imageId + "/clone", creds[0], creds[1], data); } - // ======================================================================== - // PaaS Logs API (2) - // ======================================================================== - - /** - * Fetch batch logs from portal. - * - * @param source Log source: "all", "api", "portal", "pool/cammy", "pool/ai" - * @param lines Number of lines (1-10000) - * @param since Time window: "1m", "5m", "1h", "1d" - * @param grep Optional filter pattern (null for no filter) - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return Response map containing logs - * @throws IOException on network errors - * @throws CredentialsException if credentials cannot be found - * @throws ApiException if API returns an error - */ - public static Map logsFetch( - String source, - int lines, - String since, - String grep, - String publicKey, - String secretKey - ) throws IOException { - String[] creds = resolveCredentials(publicKey, secretKey); - StringBuilder path = new StringBuilder("/paas/logs?source="); - path.append(source != null ? source : "all"); - path.append("&lines=").append(lines > 0 ? lines : 100); - if (since != null && !since.isEmpty()) { - path.append("&since=").append(since); - } - if (grep != null && !grep.isEmpty()) { - path.append("&grep=").append(java.net.URLEncoder.encode(grep, "UTF-8")); - } - return makeRequest("GET", path.toString(), creds[0], creds[1], null); - } - - /** - * Interface for receiving streamed log lines. - */ - public interface LogCallback { - /** - * Called for each log line received. - * - * @param source The log source (e.g., "api", "portal") - * @param line The log line content - */ - void onLogLine(String source, String line); - } - - /** - * Stream logs via SSE. Blocks until interrupted or server closes connection. - * - * @param source Log source: "all", "api", "portal", "pool/cammy", "pool/ai" - * @param grep Optional filter pattern (null for no filter) - * @param callback Callback for each log line received - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return true on clean shutdown, false on error - * @throws IOException on network errors - * @throws CredentialsException if credentials cannot be found - */ - public static boolean logsStream( - String source, - String grep, - LogCallback callback, - String publicKey, - String secretKey - ) throws IOException { - String[] creds = resolveCredentials(publicKey, secretKey); - StringBuilder path = new StringBuilder("/paas/logs/stream?source="); - path.append(source != null ? source : "all"); - if (grep != null && !grep.isEmpty()) { - path.append("&grep=").append(java.net.URLEncoder.encode(grep, "UTF-8")); - } - - String url = API_BASE + path.toString(); - long timestamp = System.currentTimeMillis() / 1000; - String signature = signRequest(creds[1], timestamp, "GET", path.toString(), null); - - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); - conn.setRequestMethod("GET"); - conn.setConnectTimeout(30000); - conn.setReadTimeout(0); // No timeout for streaming - - conn.setRequestProperty("Authorization", "Bearer " + creds[0]); - conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp)); - conn.setRequestProperty("X-Signature", signature); - conn.setRequestProperty("Accept", "text/event-stream"); - - int responseCode = conn.getResponseCode(); - if (responseCode != 200) { - return false; - } - - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { - String line; - String currentSource = source != null ? source : "all"; - while ((line = reader.readLine()) != null) { - if (line.startsWith("data: ")) { - String data = line.substring(6); - if (callback != null) { - callback.onLogLine(currentSource, data); - } - } else if (line.startsWith("event: ")) { - currentSource = line.substring(7); - } - } - return true; - } catch (Exception e) { - return false; - } - } - // ======================================================================== // Key Validation API // ======================================================================== @@ -2637,106 +2212,6 @@ public class Un { return makeRequest("POST", "/keys/validate", creds[0], creds[1], new LinkedHashMap<>()); } - // ======================================================================== - // Utility Functions - // ======================================================================== - - /** - * Get SDK version string. - * - * @return Version string (e.g., "4.2.0") - */ - public static String version() { - return "4.2.0"; - } - - /** - * Check API health status. - * - * @return true if API is healthy, false otherwise - */ - public static boolean healthCheck() { - try { - HttpURLConnection conn = (HttpURLConnection) new URL(API_BASE + "/health").openConnection(); - conn.setRequestMethod("GET"); - conn.setConnectTimeout(5000); - conn.setReadTimeout(5000); - return conn.getResponseCode() == 200; - } catch (Exception e) { - return false; - } - } - - /** - * Generate HMAC-SHA256 signature. - * - * @param secretKey Secret key for HMAC - * @param message Message to sign - * @return Lowercase hex-encoded signature - */ - public static String hmacSign(String secretKey, String message) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - SecretKeySpec secretKeySpec = new SecretKeySpec( - secretKey.getBytes(StandardCharsets.UTF_8), - "HmacSHA256" - ); - mac.init(secretKeySpec); - byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); - StringBuilder hexString = new StringBuilder(); - for (byte b : hash) { - hexString.append(String.format("%02x", b)); - } - return hexString.toString(); - } catch (Exception e) { - return null; - } - } - - /** - * Get details of a specific snapshot. - * - * @param snapshotId Snapshot ID to retrieve - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return Snapshot details map - * @throws IOException on network errors - * @throws CredentialsException if credentials cannot be found - * @throws ApiException if API returns an error - */ - public static Map getSnapshot( - String snapshotId, - String publicKey, - String secretKey - ) throws IOException { - String[] creds = resolveCredentials(publicKey, secretKey); - return makeRequest("GET", "/snapshots/" + snapshotId, creds[0], creds[1], null); - } - - /** - * Resize a service (change vCPU allocation). - * - * @param serviceId Service ID to resize - * @param vcpu New vCPU count (1-8) - * @param publicKey Optional API key - * @param secretKey Optional API secret - * @return Response map with resize confirmation - * @throws IOException on network errors - * @throws CredentialsException if credentials cannot be found - * @throws ApiException if API returns an error - */ - public static Map resizeService( - String serviceId, - int vcpu, - String publicKey, - String secretKey - ) throws IOException { - String[] creds = resolveCredentials(publicKey, secretKey); - Map data = new LinkedHashMap<>(); - data.put("vcpu", vcpu); - return makeRequestWithMethod("PATCH", "/services/" + serviceId, creds[0], creds[1], data); - } - // ======================================================================== // Image Generation API // ======================================================================== @@ -2882,7 +2357,6 @@ public class Un { String language = null; String networkMode = "zerotrust"; int vcpu = 1; - int accountIndex = -1; List envVars = new ArrayList<>(); List files = new ArrayList<>(); List positionalArgs = new ArrayList<>(); @@ -2894,18 +2368,6 @@ public class Un { if (arg.equals("-h") || arg.equals("--help")) { showHelp = true; i++; - } else if (arg.equals("--account")) { - if (i + 1 >= args.length) { - System.err.println("Error: --account requires an argument"); - System.exit(2); - } - try { - accountIndex = Integer.parseInt(args[++i]); - } catch (NumberFormatException e) { - System.err.println("Error: --account requires an integer argument"); - System.exit(2); - } - i++; } else if (arg.equals("-s") || arg.equals("--shell")) { if (i + 1 >= args.length) { System.err.println("Error: -s/--shell requires an argument"); @@ -2971,21 +2433,13 @@ public class Un { String command = positionalArgs.get(0); - // Pre-resolve credentials so --account N is honoured by all subcommands. - // Only resolve if explicit keys were not supplied via -p/-k flags. - if (publicKey == null || publicKey.isEmpty() || secretKey == null || secretKey.isEmpty()) { - String[] creds = resolveCredentials(publicKey, secretKey, accountIndex); - publicKey = creds[0]; - secretKey = creds[1]; - } - // Route to subcommand handlers switch (command) { case "session": handleSession(positionalArgs, publicKey, secretKey, networkMode, vcpu, language); break; case "service": - handleService(positionalArgs, publicKey, secretKey, networkMode, vcpu, envVars, files); + handleService(positionalArgs, publicKey, secretKey, networkMode, vcpu, envVars); break; case "snapshot": handleSnapshot(positionalArgs, publicKey, secretKey); @@ -3025,7 +2479,6 @@ public class Un { System.out.println(" -f, --file FILE Add input file to /tmp/"); System.out.println(" -p, --public-key KEY API public key"); System.out.println(" -k, --secret-key KEY API secret key"); - System.out.println(" --account N Use row N from accounts.csv (overrides env vars)"); System.out.println(" -n, --network MODE Network: zerotrust or semitrusted"); System.out.println(" -v, --vcpu N vCPU count (1-8)"); System.out.println(" -h, --help Show help"); @@ -3308,8 +2761,7 @@ public class Un { String secretKey, String networkMode, int vcpu, - List envVars, - List files + List envVars ) throws Exception { // Check for "env" subcommand if (args.size() > 1 && args.get(1).equals("env")) { @@ -3475,17 +2927,14 @@ public class Un { System.err.print(stderr); } } else if (redeployId != null) { - // Build input_files from -f args - List> inputFiles = buildInputFiles(files); - redeployService(redeployId, inputFiles, publicKey, secretKey); + redeployService(redeployId, publicKey, secretKey); System.out.println("Service redeployed: " + redeployId); } else if (snapshotId != null) { String snapId = serviceSnapshot(snapshotId, publicKey, secretKey, null); System.out.println("Snapshot created: " + snapId); } else if (name != null) { - // Build input_files from -f args - List> inputFiles = buildInputFiles(files); - Map result = createService(name, ports, bootstrap, inputFiles, publicKey, secretKey); + // Create new service + Map result = createService(name, ports, bootstrap, publicKey, secretKey); System.out.println("Service created:"); printMap(result); } else { @@ -3494,26 +2943,6 @@ public class Un { } } - /** - * Build input_files list from -f file paths: read each file, base64-encode, return list of maps. - */ - private static List> buildInputFiles(List filePaths) throws IOException { - if (filePaths == null || filePaths.isEmpty()) { - return null; - } - List> inputFiles = new ArrayList<>(); - for (String fpath : filePaths) { - Path p = Paths.get(fpath); - byte[] content = Files.readAllBytes(p); - String encoded = Base64.getEncoder().encodeToString(content); - Map entry = new LinkedHashMap<>(); - entry.put("filename", p.getFileName().toString()); - entry.put("content", encoded); - inputFiles.add(entry); - } - return inputFiles; - } - private static void handleServiceEnv( List args, String publicKey, diff --git a/clients/java/sync/test/UnTest.java b/clients/java/sync/test/UnTest.java index 9498694..a6cc64c 100644 --- a/clients/java/sync/test/UnTest.java +++ b/clients/java/sync/test/UnTest.java @@ -6,7 +6,6 @@ * - HMAC-SHA256 signature generation * - Credential resolution logic * - Language detection - * - Utility functions * * To run tests: * mvn test @@ -81,18 +80,6 @@ public class UnTest { assertEquals("cpp", Un.detectLanguage("main.cxx")); } - @Test - @DisplayName("Should detect Kotlin from .kt extension") - void detectKotlin() { - assertEquals("kotlin", Un.detectLanguage("Main.kt")); - } - - @Test - @DisplayName("Should detect Groovy from .groovy extension") - void detectGroovy() { - assertEquals("groovy", Un.detectLanguage("script.groovy")); - } - @Test @DisplayName("Should return null for unknown extension") void detectUnknown() { @@ -107,44 +94,6 @@ public class UnTest { } } - @Nested - @DisplayName("Utility Function Tests") - class UtilityTests { - - @Test - @DisplayName("Version should return a valid version string") - void versionString() { - String version = Un.version(); - assertNotNull(version); - assertTrue(version.matches("\\d+\\.\\d+\\.\\d+"), "Version should be in X.Y.Z format"); - } - - @Test - @DisplayName("HMAC sign should produce valid hex signature") - void hmacSignature() { - String signature = Un.hmacSign("secret", "message"); - assertNotNull(signature); - assertEquals(64, signature.length(), "HMAC-SHA256 should produce 64 hex chars"); - assertTrue(signature.matches("[0-9a-f]+"), "Signature should be lowercase hex"); - } - - @Test - @DisplayName("HMAC sign should be consistent") - void hmacConsistent() { - String sig1 = Un.hmacSign("key", "data"); - String sig2 = Un.hmacSign("key", "data"); - assertEquals(sig1, sig2, "Same inputs should produce same signature"); - } - - @Test - @DisplayName("HMAC sign should differ with different inputs") - void hmacDifferent() { - String sig1 = Un.hmacSign("key1", "data"); - String sig2 = Un.hmacSign("key2", "data"); - assertNotEquals(sig1, sig2, "Different keys should produce different signatures"); - } - } - @Nested @DisplayName("Credential Exception Tests") class CredentialExceptionTests { @@ -171,22 +120,6 @@ public class UnTest { } } - @Nested - @DisplayName("Sudo Challenge Exception Tests") - class SudoChallengeExceptionTests { - - @Test - @DisplayName("SudoChallengeException should contain challenge ID") - void sudoChallengeDetails() { - Un.SudoChallengeException ex = new Un.SudoChallengeException( - "challenge-123", - "{\"challenge_id\": \"challenge-123\"}" - ); - assertEquals("challenge-123", ex.getChallengeId()); - assertEquals("{\"challenge_id\": \"challenge-123\"}", ex.getResponseBody()); - } - } - @Nested @DisplayName("Integration Tests (requires credentials)") @EnabledIfEnvironmentVariable(named = "UNSANDBOX_PUBLIC_KEY", matches = ".+") @@ -260,63 +193,5 @@ public class UnTest { assertEquals("completed", result.get("status")); assertTrue(result.get("stdout").toString().contains("Async test")); } - - @Test - @DisplayName("Should list jobs") - void listJobs() throws IOException { - List> jobs = Un.listJobs(publicKey, secretKey); - assertNotNull(jobs); - // Jobs list can be empty if no jobs are running - } - - @Test - @DisplayName("Should validate keys successfully") - void validateKeys() throws IOException { - Map result = Un.validateKeys(publicKey, secretKey); - assertNotNull(result); - // The response should contain validation info - } - - @Test - @DisplayName("Should list sessions") - void listSessions() throws IOException { - List> sessions = Un.listSessions(publicKey, secretKey); - assertNotNull(sessions); - } - - @Test - @DisplayName("Should list services") - void listServices() throws IOException { - List> services = Un.listServices(publicKey, secretKey); - assertNotNull(services); - } - - @Test - @DisplayName("Should list snapshots") - void listSnapshots() throws IOException { - List> snapshots = Un.listSnapshots(publicKey, secretKey); - assertNotNull(snapshots); - } - - @Test - @DisplayName("Should list images") - void listImages() throws IOException { - List> images = Un.listImages(null, publicKey, secretKey); - assertNotNull(images); - } - } - - @Nested - @DisplayName("Health Check Tests") - class HealthCheckTests { - - @Test - @DisplayName("Health check should return boolean") - void healthCheckReturnsBoolean() { - boolean healthy = Un.healthCheck(); - // We just verify it returns without throwing - // The actual result depends on network connectivity - assertTrue(healthy || !healthy); - } } } diff --git a/clients/java/sync/tests/TestFunctional.java b/clients/java/sync/tests/TestFunctional.java deleted file mode 100644 index fe6c4b3..0000000 --- a/clients/java/sync/tests/TestFunctional.java +++ /dev/null @@ -1,186 +0,0 @@ -/** - * This is free software for the public good of a permacomputer hosted at - * permacomputer.com, an always-on computer by the people, for the people. - * One which is durable, easy to repair, & distributed like tap water - * for machine learning intelligence. - * - * The permacomputer is community-owned infrastructure optimized around - * four values: - * - * TRUTH First principles, math & science, open source code freely distributed - * FREEDOM Voluntary partnerships, freedom from tyranny & corporate control - * HARMONY Minimal waste, self-renewing systems with diverse thriving connections - * LOVE Be yourself without hurting others, cooperation through natural law - * - * This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. - * Code is seeds to sprout on any abandoned technology. - * - * UN Java SDK - Functional Tests - * - * Tests library functions against real API. - * Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY - * - * Usage: - * javac -cp src tests/TestFunctional.java && java -cp src:tests TestFunctional - */ - -import java.io.IOException; -import java.util.*; - -public class TestFunctional { - - private static int passed = 0; - private static int failed = 0; - - private static final String GREEN = "\033[32m"; - private static final String RED = "\033[31m"; - private static final String NC = "\033[0m"; - - private static void check(boolean condition, String msg) { - if (condition) { - System.out.println(" " + GREEN + "✓" + NC + " " + msg); - passed++; - } else { - System.out.println(" " + RED + "✗" + NC + " " + msg); - failed++; - } - } - - private static void testHealthCheck() { - System.out.println("\nTesting healthCheck()..."); - boolean result = Un.healthCheck(); - check(true, "healthCheck completed without exception"); - } - - private static void testValidateKeys() throws IOException { - System.out.println("\nTesting validateKeys()..."); - Map info = Un.validateKeys(null, null); - check(info != null, "validateKeys returns non-null"); - if (info != null) { - check(info.containsKey("valid"), "result has 'valid' key"); - check(Boolean.TRUE.equals(info.get("valid")), "keys are valid"); - Object tier = info.get("tier"); - if (tier != null) System.out.println(" tier: " + tier); - } - } - - private static void testGetLanguages() throws IOException { - System.out.println("\nTesting getLanguages()..."); - List langs = Un.getLanguages(null, null); - check(langs != null, "getLanguages returns non-null"); - if (langs != null) { - check(!langs.isEmpty(), "at least one language returned"); - check(langs.contains("python"), "python is in languages list"); - System.out.println(" Found " + langs.size() + " languages"); - } - } - - private static void testExecute() throws IOException { - System.out.println("\nTesting executeCode()..."); - Map result = Un.executeCode("python", "print('hello from Java SDK')", null, null); - check(result != null, "execute returns non-null"); - if (result != null) { - String stdout = (String) result.get("stdout"); - check(stdout != null && stdout.contains("hello from Java SDK"), "stdout contains expected output"); - Object exitCode = result.get("exit_code"); - check(exitCode != null && ((Number) exitCode).intValue() == 0, "exit code is 0"); - } - } - - private static void testExecuteError() throws IOException { - System.out.println("\nTesting executeCode() with error..."); - Map result = Un.executeCode("python", "import sys; sys.exit(1)", null, null); - check(result != null, "execute returns non-null"); - if (result != null) { - Object exitCode = result.get("exit_code"); - check(exitCode != null && ((Number) exitCode).intValue() == 1, "exit code is 1"); - } - } - - private static void testSessionList() throws IOException { - System.out.println("\nTesting listSessions()..."); - List> sessions = Un.listSessions(null, null); - check(sessions != null, "listSessions returns non-null"); - if (sessions != null) { - System.out.println(" Found " + sessions.size() + " sessions"); - } - } - - private static void testSessionLifecycle() throws IOException { - System.out.println("\nTesting session lifecycle (create, destroy)..."); - Map session = Un.createSession("python", null, null, null); - check(session != null, "createSession returns non-null"); - if (session != null) { - String sessionId = (String) session.get("id"); - check(sessionId != null, "session has id"); - System.out.println(" session_id: " + sessionId); - - if (sessionId != null) { - Un.deleteSession(sessionId, null, null); - check(true, "deleteSession completed"); - } - } - } - - private static void testServiceList() throws IOException { - System.out.println("\nTesting listServices()..."); - List> services = Un.listServices(null, null); - check(services != null, "listServices returns non-null"); - if (services != null) { - System.out.println(" Found " + services.size() + " services"); - } - } - - private static void testSnapshotList() throws IOException { - System.out.println("\nTesting listSnapshots()..."); - List> snapshots = Un.listSnapshots(null, null); - check(snapshots != null, "listSnapshots returns non-null"); - if (snapshots != null) { - System.out.println(" Found " + snapshots.size() + " snapshots"); - } - } - - private static void testImageList() throws IOException { - System.out.println("\nTesting listImages()..."); - List> images = Un.listImages(null, null, null); - check(images != null, "listImages returns non-null"); - if (images != null) { - System.out.println(" Found " + images.size() + " images"); - } - } - - public static void main(String[] args) { - System.out.println("====================================="); - System.out.println("UN Java SDK - Functional Tests"); - System.out.println("Testing against real API"); - System.out.println("====================================="); - - String pk = System.getenv("UNSANDBOX_PUBLIC_KEY"); - String sk = System.getenv("UNSANDBOX_SECRET_KEY"); - - if (pk == null || sk == null || pk.isEmpty() || sk.isEmpty()) { - System.out.println("\n\033[33mSKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set\033[0m"); - System.exit(0); - } - - try { testHealthCheck(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testValidateKeys(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testGetLanguages(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testExecute(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testExecuteError(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testSessionList(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testSessionLifecycle(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testServiceList(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testSnapshotList(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - try { testImageList(); } catch (Exception e) { System.out.println(" " + RED + "✗ " + e.getMessage() + NC); failed++; } - - System.out.println("\n====================================="); - System.out.println("Test Summary"); - System.out.println("====================================="); - System.out.println("Passed: " + GREEN + passed + NC); - System.out.println("Failed: " + RED + failed + NC); - System.out.println("====================================="); - - System.exit(failed > 0 ? 1 : 0); - } -} diff --git a/clients/java/sync/tests/test_account_flag.sh b/clients/java/sync/tests/test_account_flag.sh deleted file mode 100755 index 15d8ccf..0000000 --- a/clients/java/sync/tests/test_account_flag.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -# Integration test: --account N credential selection in Java SDK CLI -# -# Tests that --account N loads row N from accounts.csv and takes priority -# over environment variables UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY. -# -# Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY set in the environment. -# SKIP if not set. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SRC_DIR="$(cd "$SCRIPT_DIR/../src" && pwd)" - -PASS=0 -FAIL=0 -SKIP=0 - -pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } -fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } -skip() { echo "SKIP: $1"; SKIP=$((SKIP + 1)); } - -# ---- Prerequisites ---- - -if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then - skip "UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set" - echo "" - echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" - exit 0 -fi - -# Compile Un.java if needed -CLASS_FILE="$SRC_DIR/Un.class" -if [ ! -f "$CLASS_FILE" ] || [ "$SRC_DIR/Un.java" -nt "$CLASS_FILE" ]; then - echo "Compiling Un.java..." - if ! javac -cp "$SRC_DIR" "$SRC_DIR/Un.java" 2>&1; then - fail "javac compilation failed" - echo "" - echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" - exit 1 - fi -fi - -# ---- Temp home setup ---- - -TMPHOME="$(mktemp -d)" -trap 'rm -rf "$TMPHOME"' EXIT - -mkdir -p "$TMPHOME/.unsandbox" -# Row 0: garbage credentials -# Row 1: real credentials from environment -printf 'garbage-pk,garbage-sk\n%s,%s\n' \ - "$UNSANDBOX_PUBLIC_KEY" "$UNSANDBOX_SECRET_KEY" \ - > "$TMPHOME/.unsandbox/accounts.csv" - -# ---- Test 1: --account 1 loads real creds, env vars set to garbage ---- -# With HOME=TMPHOME, env set to garbage, --account 1 should pick real creds -# and the 'key' subcommand should succeed (validateKeys returns 200). - -RESULT=$( - HOME="$TMPHOME" \ - UNSANDBOX_PUBLIC_KEY="garbage-pk-env" \ - UNSANDBOX_SECRET_KEY="garbage-sk-env" \ - java -cp "$SRC_DIR" Un --account 1 key 2>&1 -) && RC=$? || RC=$? - -if [ $RC -eq 0 ]; then - pass "--account 1 loads row 1 from accounts.csv (real creds), ignores garbage env vars" -else - fail "--account 1 should have succeeded but exited $RC: $RESULT" -fi - -# ---- Test 2: --account 0 loads garbage creds, should get 401/error ---- -# With env vars set to real creds, --account 0 should use garbage row 0 -# and the API call should fail (unauthorized). - -RESULT=$( - HOME="$TMPHOME" \ - UNSANDBOX_PUBLIC_KEY="$UNSANDBOX_PUBLIC_KEY" \ - UNSANDBOX_SECRET_KEY="$UNSANDBOX_SECRET_KEY" \ - java -cp "$SRC_DIR" Un --account 0 key 2>&1 -) && RC=$? || RC=$? - -if [ $RC -ne 0 ]; then - pass "--account 0 loads garbage creds from row 0, API rejects them (env vars ignored)" -else - fail "--account 0 should have failed (garbage creds) but succeeded: $RESULT" -fi - -# ---- Test 3: no --account flag, env vars set to real creds → success ---- - -RESULT=$( - HOME="$TMPHOME" \ - UNSANDBOX_PUBLIC_KEY="$UNSANDBOX_PUBLIC_KEY" \ - UNSANDBOX_SECRET_KEY="$UNSANDBOX_SECRET_KEY" \ - java -cp "$SRC_DIR" Un key 2>&1 -) && RC=$? || RC=$? - -if [ $RC -eq 0 ]; then - pass "no --account flag uses env vars (real creds), succeeds" -else - fail "no --account flag should succeed with real env var creds but exited $RC: $RESULT" -fi - -# ---- Summary ---- - -echo "" -echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP" - -if [ $FAIL -gt 0 ]; then - exit 1 -fi -exit 0 diff --git a/clients/javascript/Makefile b/clients/javascript/Makefile index 3b619b8..d5977f7 100644 --- a/clients/javascript/Makefile +++ b/clients/javascript/Makefile @@ -5,17 +5,21 @@ # - async/ : Async/Await SDK (Node.js) # # Usage: -# make test # Run all 4 test modes (auto-installs jest) +# make # Run all tests +# make test # Run all 4 test modes # make test-cli # CLI mode only # make test-library # Library mode only +# make test-integration # Integration mode only +# make test-functional # Functional mode only # make test-sync # Test sync SDK only # make test-async # Test async SDK only -# make clean # Remove node_modules + build artifacts +# make clean # Remove build artifacts # -# The Makefile runs npm install automatically when node_modules is missing. +# Dependencies: +# npm install (or yarn install) .PHONY: all test test-cli test-library test-integration test-functional -.PHONY: test-sync test-async lint format clean help examples +.PHONY: test-sync test-async install dev-install lint format clean help examples # Paths ROOT_DIR := $(shell cd ../.. && pwd) @@ -34,7 +38,7 @@ help: @echo "UN JavaScript Client - Build and Test" @echo "" @echo "Test (all 4 modes):" - @echo " make test All 4 modes (auto-installs deps)" + @echo " make test All 4 modes for both sync and async" @echo " make test-cli CLI mode (command-line interface)" @echo " make test-library Library mode (require and use)" @echo " make test-integration Integration mode (API contract)" @@ -45,30 +49,23 @@ help: @echo " make test-async Test asynchronous SDK" @echo "" @echo "Development:" + @echo " make install Install dependencies" @echo " make lint Lint with ESLint" @echo " make format Format with Prettier" @echo " make examples Run example scripts" @echo "" @echo "Utility:" - @echo " make clean Remove node_modules + build artifacts" + @echo " make clean Remove build artifacts" + @echo " make deps Show required dependencies" @echo "" all: test -# ============================================================================ -# Dependency Management -# ============================================================================ - -$(SYNC_DIR)/node_modules/.package-lock.json: $(SYNC_DIR)/package.json - @echo "Installing sync SDK dependencies..." - @cd $(SYNC_DIR) && npm install --no-audit --no-fund -q 2>&1 | tail -1 - -$(ASYNC_DIR)/node_modules/.package-lock.json: $(ASYNC_DIR)/package.json - @echo "Installing async SDK dependencies..." - @cd $(ASYNC_DIR) && npm install --no-audit --no-fund -q 2>&1 | tail -1 - -sync-deps: $(SYNC_DIR)/node_modules/.package-lock.json -async-deps: $(ASYNC_DIR)/node_modules/.package-lock.json +deps: + @echo "Required packages:" + @echo " npm install jest eslint prettier" + @echo "" + @node --version 2>/dev/null || echo "Node.js not installed" # ============================================================================ # TEST: All 4 Modes @@ -88,12 +85,15 @@ test-cli: @echo "CLI MODE: Testing JavaScript CLI interface" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "" + @# Test root-level un.js if it exists @if [ -f "$(ROOT_DIR)/un.js" ]; then \ node --check "$(ROOT_DIR)/un.js" 2>/dev/null && echo " $(GREEN)✓$(NC) CLI: Syntax valid (un.js)" || echo " $(RED)✗$(NC) CLI: Syntax error in un.js"; \ fi + @# Test sync SDK syntax @if [ -f "$(SYNC_DIR)/src/un.js" ]; then \ node --check "$(SYNC_DIR)/src/un.js" 2>/dev/null && echo " $(GREEN)✓$(NC) CLI: Sync SDK syntax valid" || echo " $(RED)✗$(NC) CLI: Sync SDK syntax error"; \ fi + @# Test async SDK syntax (ES module with .mjs extension check) @if [ -f "$(ASYNC_DIR)/src/un_async.js" ]; then \ node --check "$(ASYNC_DIR)/src/un_async.js" 2>/dev/null && echo " $(GREEN)✓$(NC) CLI: Async SDK syntax valid" || echo " $(YELLOW)⊘$(NC) CLI: Async SDK ES module (use --input-type=module)"; \ fi @@ -102,25 +102,30 @@ test-cli: # TEST: Library Mode # ============================================================================ -test-library: sync-deps +test-library: @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "LIBRARY MODE: Testing JavaScript imports" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "" + @# Test sync SDK import @if [ -f "$(SYNC_DIR)/src/un.js" ]; then \ - node --input-type=module -e "const un = await import('./$(SYNC_DIR)/src/un.js'); const fns = Object.keys(un).filter(k => typeof un[k] === 'function'); console.log(' ✓ Library: Sync SDK importable, exports:', fns.length, 'functions')" 2>/dev/null || echo " $(RED)✗$(NC) Library: Sync import failed"; \ + node -e "const un = require('./$(SYNC_DIR)/src/un.js'); console.log(' ✓ Library: Sync SDK importable, exports:', Object.keys(un).length, 'functions')" 2>/dev/null || echo " $(YELLOW)⊘$(NC) Library: Sync import needs dependencies"; \ fi + @# Test async SDK import (ES module) @if [ -f "$(ASYNC_DIR)/src/un_async.js" ]; then \ - node --input-type=module -e "const un = await import('./$(ASYNC_DIR)/src/un_async.js'); const fns = Object.keys(un).filter(k => typeof un[k] === 'function'); console.log(' ✓ Library: Async SDK importable, exports:', fns.length, 'functions')" 2>/dev/null || echo " $(YELLOW)⊘$(NC) Library: Async import check (ES module)"; \ + node --input-type=module -e "import un from './$(ASYNC_DIR)/src/un_async.js'; console.log(' ✓ Library: Async SDK importable, exports:', Object.keys(un).length, 'functions')" 2>/dev/null || echo " $(YELLOW)⊘$(NC) Library: Async import check (ES module)"; \ fi + @# Run jest tests @echo "" @echo "Running unit tests..." @if [ -d "$(SYNC_DIR)/tests" ] && [ -f "$(SYNC_DIR)/package.json" ]; then \ - cd $(SYNC_DIR) && npm test 2>&1 && echo " $(GREEN)✓$(NC) Sync SDK tests passed" || echo " $(RED)✗$(NC) Sync tests failed"; \ + cd $(SYNC_DIR) && npm test 2>/dev/null && echo " $(GREEN)✓$(NC) Sync SDK tests passed" || echo " $(YELLOW)⊘$(NC) Sync tests need: npm install"; \ + elif [ -d "$(SYNC_DIR)/tests" ]; then \ + echo " $(YELLOW)⊘$(NC) Sync tests need package.json"; \ fi @if [ -d "$(ASYNC_DIR)/tests" ] && [ -f "$(ASYNC_DIR)/package.json" ]; then \ - cd $(ASYNC_DIR) && npm test 2>&1 && echo " $(GREEN)✓$(NC) Async SDK tests passed" || echo " $(RED)✗$(NC) Async tests failed"; \ + cd $(ASYNC_DIR) && npm test 2>/dev/null && echo " $(GREEN)✓$(NC) Async SDK tests passed" || echo " $(YELLOW)⊘$(NC) Async tests need: npm install"; \ fi # ============================================================================ @@ -139,7 +144,7 @@ test-integration: else \ echo " Testing API authentication..."; \ if [ -f "$(SYNC_DIR)/src/un.js" ]; then \ - node --input-type=module -e "const un = await import('./$(SYNC_DIR)/src/un.js'); const r = await un.executeCode('python', 'print(42)'); if(r.stdout && r.stdout.includes('42')) console.log(' ✓ Integration: API auth works'); else console.log(' ✗ Integration: Unexpected response');" 2>/dev/null || echo " $(RED)✗$(NC) Integration: SDK error"; \ + node -e "const un = require('./$(SYNC_DIR)/src/un.js'); un.executeCode('python', 'print(42)').then(r => { if(r.stdout && r.stdout.includes('42')) console.log(' ✓ Integration: API auth works'); else console.log(' ✗ Integration: Unexpected response'); }).catch(e => console.log(' ✗ Integration:', e.message))" 2>/dev/null || echo " $(YELLOW)⊘$(NC) Integration: Check SDK"; \ fi; \ fi @@ -157,8 +162,8 @@ test-functional: echo " $(YELLOW)⊘$(NC) Skipping (no API credentials)"; \ else \ echo " Running functional tests..."; \ - if [ -f "$(SYNC_DIR)/tests/test_functional.mjs" ]; then \ - node $(SYNC_DIR)/tests/test_functional.mjs 2>&1 && echo " $(GREEN)✓$(NC) Functional: All tests passed" || echo " $(RED)✗$(NC) Functional: Tests failed"; \ + if [ -f "$(SYNC_DIR)/src/un.js" ]; then \ + node -e "const un = require('./$(SYNC_DIR)/src/un.js'); un.executeCode('python', 'def fib(n): return n if n<2 else fib(n-1)+fib(n-2); print(fib(10))').then(r => { if(r.stdout && r.stdout.includes('55')) console.log(' ✓ Functional: Fibonacci'); else console.log(' ⊘ Functional: Check output'); }).catch(e => console.log(' ⊘ Functional:', e.message))" 2>/dev/null || echo " $(YELLOW)⊘$(NC) Functional: Check SDK"; \ fi; \ fi @@ -166,26 +171,36 @@ test-functional: # TEST: By SDK Type # ============================================================================ -test-sync: sync-deps +test-sync: @echo "Testing Sync SDK..." @if [ -f "$(SYNC_DIR)/package.json" ]; then \ cd $(SYNC_DIR) && npm test; \ + elif [ -d "$(SYNC_DIR)/tests" ]; then \ + echo " $(YELLOW)⊘$(NC) Sync SDK needs package.json with test script"; \ else \ - echo " $(YELLOW)⊘$(NC) Sync SDK needs package.json"; \ + echo " $(YELLOW)⊘$(NC) Sync SDK tests not found"; \ fi -test-async: async-deps +test-async: @echo "Testing Async SDK..." @if [ -f "$(ASYNC_DIR)/package.json" ]; then \ cd $(ASYNC_DIR) && npm test; \ + elif [ -d "$(ASYNC_DIR)/tests" ]; then \ + echo " $(YELLOW)⊘$(NC) Async SDK needs package.json with test script"; \ else \ - echo " $(YELLOW)⊘$(NC) Async SDK needs package.json"; \ + echo " $(YELLOW)⊘$(NC) Async SDK tests not found"; \ fi # ============================================================================ # Development # ============================================================================ +install: + @echo "Installing JavaScript SDK dependencies..." + @if [ -f "$(SYNC_DIR)/package.json" ]; then cd $(SYNC_DIR) && npm install; fi + @if [ -f "$(ASYNC_DIR)/package.json" ]; then cd $(ASYNC_DIR) && npm install; fi + @echo "$(GREEN)✓$(NC) Installation complete" + lint: @echo "Linting JavaScript SDKs..." @if [ -d "$(SYNC_DIR)/src" ]; then npx eslint $(SYNC_DIR)/src/ 2>/dev/null || echo " $(YELLOW)⊘$(NC) ESLint not configured"; fi @@ -215,4 +230,5 @@ clean: @echo "Cleaning JavaScript build artifacts..." @rm -rf $(SYNC_DIR)/node_modules $(ASYNC_DIR)/node_modules 2>/dev/null || true @rm -rf $(SYNC_DIR)/coverage $(ASYNC_DIR)/coverage 2>/dev/null || true - @echo "$(GREEN)✓$(NC) Cleaned node_modules + build artifacts" + @rm -f $(SYNC_DIR)/package-lock.json $(ASYNC_DIR)/package-lock.json 2>/dev/null || true + @echo "$(GREEN)✓$(NC) Cleaned build artifacts" diff --git a/clients/javascript/async/examples/async_job_polling.js b/clients/javascript/async/examples/async_job_polling.js index dff2e85..c4dadec 100644 --- a/clients/javascript/async/examples/async_job_polling.js +++ b/clients/javascript/async/examples/async_job_polling.js @@ -1,20 +1,4 @@ #!/usr/bin/env node -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /** * Async Job Polling example for unsandbox JavaScript SDK * diff --git a/clients/javascript/async/examples/concurrent_execution.js b/clients/javascript/async/examples/concurrent_execution.js index ed830f7..c510616 100644 --- a/clients/javascript/async/examples/concurrent_execution.js +++ b/clients/javascript/async/examples/concurrent_execution.js @@ -1,20 +1,4 @@ #!/usr/bin/env node -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /** * Concurrent Execution example for unsandbox JavaScript SDK * diff --git a/clients/javascript/async/examples/fibonacci.js b/clients/javascript/async/examples/fibonacci.js index 3b8210f..f4c79c0 100644 --- a/clients/javascript/async/examples/fibonacci.js +++ b/clients/javascript/async/examples/fibonacci.js @@ -1,27 +1,13 @@ #!/usr/bin/env node -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /** - * Fibonacci example - standalone version + * Fibonacci example for unsandbox JavaScript SDK - Asynchronous Version * * Demonstrates concurrent fibonacci calculations using async/await. * Shows how to run multiple concurrent operations with Promise.all(). * * To run: + * export UNSANDBOX_PUBLIC_KEY="your-public-key" + * export UNSANDBOX_SECRET_KEY="your-secret-key" * node fibonacci.js * * Expected output: @@ -32,34 +18,54 @@ * All calculations completed! */ -// Simulated fibonacci calculation (would normally call API) -function fib(n) { - if (n <= 1) return n; - return fib(n - 1) + fib(n - 2); -} +import { executeCode, CredentialsError } from '../src/un_async.js'; async function runFibonacci(n, label) { - // Simulate async API call delay - await new Promise((resolve) => setTimeout(resolve, 50)); + const code = ` +def fib(n): + if n <= 1: + return n + return fib(n-1) + fib(n-2) - const result = fib(n); - const output = `fib(${n}) = ${result}`; - console.log(`[${label}] Result: ${output}`); - return { label, output }; +print(f"fib(${n}) = {fib(${n})}") +`; + + try { + const result = await executeCode('python', code); + const output = (result.stdout || '').trim(); + console.log(`[${label}] Result: ${output}`); + return { label, output }; + } catch (e) { + console.log(`[${label}] Error: ${e.message}`); + return { label, error: e.message }; + } } async function main() { - console.log('Starting 3 concurrent fibonacci calculations...'); + try { + console.log('Starting 3 concurrent fibonacci calculations...'); - // Run all fibonacci calculations concurrently - const results = await Promise.all([ - runFibonacci(10, 'fib-10'), - runFibonacci(15, 'fib-15'), - runFibonacci(12, 'fib-12'), - ]); + // Run all fibonacci calculations concurrently + const results = await Promise.all([ + runFibonacci(10, 'fib-10'), + runFibonacci(15, 'fib-15'), + runFibonacci(12, 'fib-12'), + ]); - console.log('All calculations completed!'); - return 0; + console.log('All calculations completed!'); + + // Check for errors + const hasErrors = results.some((r) => r.error); + return hasErrors ? 1 : 0; + } catch (e) { + if (e instanceof CredentialsError) { + console.log(`Credentials error: ${e.message}`); + } else { + console.log(`Error: ${e.message}`); + console.error(e); + } + return 1; + } } main().then(process.exit); diff --git a/clients/javascript/async/examples/hello_world.js b/clients/javascript/async/examples/hello_world.js index fb2b086..5946070 100644 --- a/clients/javascript/async/examples/hello_world.js +++ b/clients/javascript/async/examples/hello_world.js @@ -1,27 +1,13 @@ #!/usr/bin/env node -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /** - * Hello World example - standalone version + * Hello World example for unsandbox JavaScript SDK - Asynchronous Version * - * This example demonstrates basic async execution patterns. - * Shows how to use async/await for simple asynchronous operations. + * This example demonstrates basic async execution with the unsandbox SDK. + * Shows how to use async/await with the SDK for simple code execution. * * To run: + * export UNSANDBOX_PUBLIC_KEY="your-public-key" + * export UNSANDBOX_SECRET_KEY="your-secret-key" * node hello_world.js * * Expected output: @@ -30,32 +16,35 @@ * Output: Hello from async unsandbox! */ -// Simulated async execution -async function executeCode(language, code) { - // Simulate API call delay - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Return simulated result - return { - status: 'completed', - stdout: 'Hello from async unsandbox!\n', - stderr: '', - }; -} +import { executeCode, CredentialsError } from '../src/un_async.js'; async function main() { // The code to execute const code = 'print("Hello from async unsandbox!")'; - console.log('Executing code asynchronously...'); - const result = await executeCode('python', code); + try { + console.log('Executing code asynchronously...'); + const result = await executeCode('python', code); - if (result.status === 'completed') { - console.log(`Result status: ${result.status}`); - console.log(`Output: ${(result.stdout || '').trim()}`); - return 0; - } else { - console.log(`Execution failed with status: ${result.status}`); + if (result.status === 'completed') { + console.log(`Result status: ${result.status}`); + console.log(`Output: ${(result.stdout || '').trim()}`); + if (result.stderr) { + console.log(`Errors: ${result.stderr}`); + } + return 0; + } else { + console.log(`Execution failed with status: ${result.status}`); + console.log(`Error: ${result.error || 'Unknown error'}`); + return 1; + } + } catch (e) { + if (e instanceof CredentialsError) { + console.log(`Credentials error: ${e.message}`); + } else { + console.log(`Error: ${e.message}`); + console.error(e); + } return 1; } } diff --git a/clients/javascript/async/examples/language_detection.js b/clients/javascript/async/examples/language_detection.js index 1d2be0c..ee4e75f 100644 --- a/clients/javascript/async/examples/language_detection.js +++ b/clients/javascript/async/examples/language_detection.js @@ -1,25 +1,9 @@ #!/usr/bin/env node -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. - /** - * Language Detection example - standalone version + * Language Detection example for unsandbox JavaScript SDK * - * Demonstrates language detection from filenames. - * This is a pure function that maps file extensions to language identifiers. + * Demonstrates automatic language detection from filenames. + * This is a purely local operation that doesn't require API credentials. * * To run: * node language_detection.js @@ -37,35 +21,7 @@ * Language detection complete! */ -// Inline language detection - same logic as SDK -function detectLanguage(filename) { - const ext = filename.split('.').pop()?.toLowerCase(); - const extMap = { - 'py': 'python', - 'js': 'javascript', - 'ts': 'typescript', - 'go': 'go', - 'rs': 'rust', - 'java': 'java', - 'rb': 'ruby', - 'php': 'php', - 'c': 'c', - 'cpp': 'cpp', - 'cs': 'csharp', - 'sh': 'bash', - 'pl': 'perl', - 'lua': 'lua', - 'r': 'r', - 'jl': 'julia', - 'hs': 'haskell', - 'ex': 'elixir', - 'erl': 'erlang', - 'swift': 'swift', - 'kt': 'kotlin', - 'scala': 'scala', - }; - return extMap[ext] || null; -} +import { detectLanguage } from '../src/un_async.js'; const TEST_FILES = [ 'script.py', diff --git a/clients/javascript/async/package.json b/clients/javascript/async/package.json index 9aa0e81..765722a 100644 --- a/clients/javascript/async/package.json +++ b/clients/javascript/async/package.json @@ -1,6 +1,6 @@ { "name": "un-async", - "version": "4.3.4", + "version": "4.2.28", "description": "Unsandbox async JavaScript SDK - Execute code in 50+ languages", "main": "src/un_async.js", "type": "module", diff --git a/clients/javascript/async/src/un_async.js b/clients/javascript/async/src/un_async.js index 446184c..844e261 100644 --- a/clients/javascript/async/src/un_async.js +++ b/clients/javascript/async/src/un_async.js @@ -1,19 +1,67 @@ -#!/usr/bin/env node -// This is free software for the public good of a permacomputer hosted at -// permacomputer.com, an always-on computer by the people, for the people. -// One which is durable, easy to repair, & distributed like tap water -// for machine learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around -// four values: -// -// TRUTH First principles, math & science, open source code freely distributed -// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY Minimal waste, self-renewing systems with diverse thriving connections -// LOVE Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ programming languages through a unified interface, accessible to all. -// Code is seeds to sprout on any abandoned technology. +/** + * PUBLIC DOMAIN - NO LICENSE, NO WARRANTY + * + * unsandbox.com JavaScript SDK (Asynchronous with native fetch) + * Isomorphic: Works in Node.js (CLI + SDK) and Browser environments + * + * Library Usage: + * import { + * // Code execution + * executeCode, executeAsync, getJob, waitForJob, cancelJob, listJobs, + * getLanguages, detectLanguage, + * // Session management + * listSessions, getSession, createSession, deleteSession, + * freezeSession, unfreezeSession, boostSession, unboostSession, shellSession, + * // Service management + * listServices, createService, getService, updateService, deleteService, + * freezeService, unfreezeService, lockService, unlockService, setUnfreezeOnDemand, + * getServiceLogs, getServiceEnv, setServiceEnv, deleteServiceEnv, + * exportServiceEnv, redeployService, executeInService, + * // Snapshot management + * sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot, + * deleteSnapshot, lockSnapshot, unlockSnapshot, cloneSnapshot, + * // Key validation + * validateKeys, + * } from './un_async.js'; + * + * // Execute code (awaits until completion) + * const result = await executeCode('python', 'print("hello")', publicKey, secretKey); + * + * // Execute asynchronously (returns job_id immediately) + * const jobId = await executeAsync('javascript', 'console.log("hello")', publicKey, secretKey); + * + * // Wait for job completion with exponential backoff + * const result = await waitForJob(jobId, publicKey, secretKey); + * + * // Snapshot operations + * const snapshotId = await sessionSnapshot(sessionId, publicKey, secretKey, 'my-snapshot'); + * const snapshots = await listSnapshots(publicKey, secretKey); + * + * Authentication Priority (5-tier): + * 1. Function arguments (publicKey, secretKey) + * 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) [Node.js] + * 3. Encrypted vault or localStorage [Browser] (vault preferred if CryptoJS available) + * 4. ~/.unsandbox/accounts.csv [Node.js] + * 5. ./accounts.csv [Node.js] + * + * Request Authentication (HMAC-SHA256): + * Authorization: Bearer + * X-Timestamp: + * X-Signature: HMAC-SHA256(secretKey, "timestamp:METHOD:path:body") + * + * Languages Cache: + * - Cached in ~/.unsandbox/languages.json (Node.js only) + * - TTL: 1 hour + * - Updated on successful API calls + * + * Browser Usage: + * - Import as ES module: