hyper-wireless 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyper-wireless
3
+ Version: 1.0.0
4
+ Summary: Hyper Wireless - Cloud Testing Platform for 3GPP 5G NR Conformance
5
+ Author-email: Hyper Wireless Team <dev@hyper-wireless.com>
6
+ Project-URL: Homepage, https://hyper-wireless.com
7
+ Project-URL: Dashboard, https://app.hyper-wireless.com
8
+ Project-URL: API, https://cloud-api.hyper-wireless.com
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Software Development :: Testing
11
+ Classifier: Topic :: System :: Networking
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: requests>=2.28.0
15
+ Requires-Dist: websockets>=12.0
16
+
17
+ # TTCPP (TTCN-3 C++ Test Suite)
18
+
19
+ ## Overview
20
+
21
+ **TTCPP** is a C++ implementation of the 3GPP 5G test suite, originally written in TTCN-3. It is designed to test the 5G UE stack (specifically using `srsRAN_5G`'s `srsue` implementation). By transpiling the TTCN-3 test cases and components into C++, TTCPP offers a native, performant environment for 5G testing, completely bypassing the need for a traditional TTCN-3 compiler or heavy runtime environment.
22
+
23
+ The test suite acts as the network (AMF/gNB) and communicates with the Device Under Test (DUT), the `srsue` stack (packaged as `ttcn3_dut`), exchanging RRC, NAS, and IP packets to validate compliance against 3GPP specifications (such as TS 24.501 and TS 38.331).
24
+
25
+ ## Key Features
26
+
27
+ - **Native C++ Execution:** The test cases are transpiled from TTCN-3 to modern C++ (`src/TTCN_TestSuite`), enabling standard debugging (GDB, LLDB) and high performance.
28
+ - **5G UE Testing:** Directly integrates with `srsRAN_5G` to validate UE behavior.
29
+ - **Custom Port Handlers:** C++ implementations for standard TTCN-3 port operations (handling sockets, threading, and system-level IPC) in `src/components/` and `src/common/`.
30
+ - **Message Sequence Charts (MSC):** Automatic parsing of test logs to render visual sequence diagrams of test execution.
31
+ - **Web GUI:** A modern Next.js-based graphical dashboard for managing, executing, and reviewing tests.
32
+
33
+ ## Directory Structure
34
+
35
+ - `src/` - Source code for the transpiled test suite, custom components, codecs, and security algorithms.
36
+ - `src/suites/<version>/` - Isolated C++ transpiled test suite packages (e.g. `NR5GC_IWD_25wk50`).
37
+ - `src/TTCN_TestSuite/` - Symlink pointing to the active suite's transpiled modules.
38
+ - `src/components/` - TTCN-3 port and component handlers.
39
+ - `src/main.cpp` - The entry point for the `ttcn3_runner` test executor.
40
+ - `ttcn/` - Contains raw TTCN-3 packages organized by deliverable version (e.g. `ttcn/NR5GC_IWD_25wk50/`).
41
+ - `configs/` - Modular configuration repository:
42
+ - `configs/transpiler/` - Base transpiler configurations and transforms for `TTCN3-X`.
43
+ - `configs/suites/` - Per-suite transpiler overlay configurations.
44
+ - `configs/modulepars/` - Per-suite module parameters (PICS/PIXIT).
45
+ - `srsRAN_5G/` - The full-stack 5G RAN and UE source code repository, which compiles the `ttcn3_dut` UE simulator executable.
46
+ - `tools/` - Utility scripts and tools.
47
+ - `tools/suite_manager.py` - Test suite version manager (list, info, switch, transpile).
48
+ - `logs/` - Directory where test execution logs and artifacts are stored.
49
+ - `usim.cfg` - Configuration for the simulated USIM (Home PLMN, Equivalent PLMNs, etc.).
50
+
51
+ ## Prerequisites & System Dependencies
52
+
53
+ To compile and run the test suite (specifically the virtual USIM smartcard integration), the following system dependencies and packages are required:
54
+
55
+ ### 1. Host Packages
56
+ Install PC/SC daemon, PCSC-Lite development headers, and tools:
57
+ ```bash
58
+ sudo apt-get install pcscd libpcsclite-dev pcsc-tools
59
+ ```
60
+
61
+ ### 2. Virtual Smartcard Simulator (`onomondo-uicc`)
62
+ - Implements the softsim smartcard that simulates physical UICC/USIM behavior over PC/SC.
63
+ - Should be cloned and built in a sibling directory (e.g., `../onomondo-uicc` relative to this repository). Respository URL: https://github.com/onomondo/onomondo-uicc.git
64
+ - The test runner defaults to this sibling directory, but you can override it by exporting the `ONOMONDO_UICC_DIR` environment variable.
65
+ - Ensure it is compiled before running tests:
66
+ ```bash
67
+ cd ../onomondo-uicc
68
+ make # or standard build commands for softsim
69
+ ```
70
+
71
+ ### 3. SIM Card Configuration Utility (`pySim`)
72
+ - Used by the test suite to configure the softsim smartcard (programming IMSIs, security parameters, resetting SQNs, etc.).
73
+ - Should be set up in a sibling directory (e.g., `../pysim` relative to this repository). Respository URL: https://github.com/osmocom/pysim.git
74
+ - The test runner defaults to this sibling directory, but you can override it by exporting the `PYSIM_DIR` environment variable.
75
+ - Ensure the virtual environment and requirements are prepared:
76
+ ```bash
77
+ cd ../pysim
78
+ python3 -m venv venv
79
+ ./venv/bin/pip install -r requirements.txt
80
+ ```
81
+
82
+ ## Building the Project
83
+
84
+ This project uses CMake for its build system. Only the LLVM Clang compiler is supported for building the C++ test runner due to GCC's high CPU consumption during parallel compilation.
85
+
86
+ ### 1. Build the C++ Test Runner (`ttcn3_runner`)
87
+ Configure and build the runner using the Clang preset:
88
+ ```bash
89
+ cmake --preset clang
90
+ cmake --build build-clang -j$(nproc) --target ttcn3_runner
91
+ ```
92
+
93
+ ### 2. Build the UE Simulator (`ttcn3_dut`)
94
+ Build the simulator inside the `srsRAN_5G` subdirectory:
95
+ ```bash
96
+ cd srsRAN_5G
97
+ mkdir -p build
98
+ cd build
99
+ cmake .. -G Ninja -DENABLE_TTCN3=ON -DENABLE_TTCN3_NR=ON
100
+ ninja ttcn3_dut
101
+ ```
102
+
103
+ ## Managing TTCN-3 Test Suite Versions
104
+
105
+ TTCPP supports multiple co-existing versions of the 3GPP TTCN-3 test suite. Use `./tools/suite_manager.py` to inspect, switch, and transpile test suite versions:
106
+
107
+ ```bash
108
+ # List all available raw and transpiled test suite versions
109
+ ./tools/suite_manager.py list
110
+
111
+ # View detailed information about a specific suite
112
+ ./tools/suite_manager.py info NR5GC_IWD_25wk50
113
+
114
+ # Switch the active test suite profile
115
+ ./tools/suite_manager.py switch NR5GC_IWD_25wk50
116
+
117
+ # Transpile a new TTCN-3 deliverable package
118
+ ./tools/suite_manager.py transpile NR5GC_IWD_26wk08
119
+ ```
120
+
121
+ ## Running Tests
122
+
123
+ Test execution is orchestrated using the provided bash script, `./tools/scripts/run_test.sh`. This script handles launching the `ttcn3_dut` and the `ttcn3_runner` processes, establishing their communication via sockets.
124
+
125
+ To run a specific test case (using the active suite):
126
+ ```bash
127
+ ./tools/scripts/run_test.sh <TEST_CASE_NAME>
128
+
129
+ # Example:
130
+ ./tools/scripts/run_test.sh TC_6_1_1_1_NR5GC
131
+ ```
132
+
133
+ To run a test case against a specific test suite version:
134
+ ```bash
135
+ ./tools/scripts/run_test.sh --suite NR5GC_IWD_25wk50 TC_6_1_1_1_NR5GC
136
+ ```
137
+
138
+ Test logs (such as `tc_runner.log` and `ttcn3_ue.log`) will be populated in a subfolder corresponding to the test case inside the `logs/` directory (e.g., `logs/<TEST_CASE_NAME>/`).
139
+
140
+ ## Upper Tester (UT) Integration
141
+
142
+ The test suite orchestrates testcase execution and logs via Upper Tester (UT) interface messages exchanged between the runner (`ttcn3_runner`) and the simulator (`ttcn3_dut`).
143
+
144
+ In addition to standard MMI commands, the runner sends the following native UT control messages:
145
+ - **`TC_START`**: Sent at the start of a test run to initialize the simulator's logging sink and parameterize the simulator logs (e.g., `logs/<TEST_CASE_NAME>/<TEST_CASE_NAME>_run0_ttcn3_ue.log`) dynamically with the active testcase name.
146
+ - JSON payload: `{"Cmd": {"TC_START": {"Name": "<TEST_CASE_NAME>"}}}`
147
+ - **`TC_END`**: Sent at the end of a test run to finalize the simulator's test case context.
148
+ - JSON payload: `{"Cmd": {"TC_END": null}}`
149
+
150
+ ## GUI Dashboard
151
+
152
+ A graphical web dashboard is provided to easily manage test execution, view historical test runs, and visualize signaling logs using Mermaid.js MSCs.
153
+
154
+ To start the GUI:
155
+ ```bash
156
+ cd tools/gui
157
+ npm install
158
+ npm run dev
159
+ ```
160
+ Navigate to `http://localhost:3000` to access the dashboard.
@@ -0,0 +1,6 @@
1
+ ttcn3_cloud_cli.py,sha256=ABbzeYeScEM1icBBdU3I0fnRMsPliszIaqen5Sl6SaM,47041
2
+ hyper_wireless-1.0.0.dist-info/METADATA,sha256=mI2OL1pHw6uV7YjmuCSH9EcDOehtoIzUrv2YqevQb-o,7820
3
+ hyper_wireless-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ hyper_wireless-1.0.0.dist-info/entry_points.txt,sha256=Rsc9_32-RLm0RsxjnnDJMocX18yOZkdpqYoRHZ9aqQc,85
5
+ hyper_wireless-1.0.0.dist-info/top_level.txt,sha256=fcN7XHL2e8IQqi9eak42OwFbrAgYeClHz4tscAbOXTg,16
6
+ hyper_wireless-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ hyper = ttcn3_cloud_cli:main
3
+ hyper-wireless = ttcn3_cloud_cli:main
@@ -0,0 +1 @@
1
+ ttcn3_cloud_cli
ttcn3_cloud_cli.py ADDED
@@ -0,0 +1,994 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ttcn3_cloud_cli.py - Client CLI for the TTCN-3 Cloud Testing Service.
4
+
5
+ Enables developers and CI/CD pipelines to run 3GPP conformance test suites in the cloud
6
+ while testing local UEs (such as srsRAN_5G `ttcn3_dut` or hardware UEs) bridged over
7
+ a secure WebSocket multiplex tunnel.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import re
13
+ import json
14
+ import time
15
+ import socket
16
+ import select
17
+ import signal
18
+ import asyncio
19
+ import logging
20
+ import argparse
21
+ import subprocess
22
+ import shutil
23
+ from pathlib import Path
24
+ from typing import Optional, Dict, Any
25
+
26
+ # Auto-detect local virtual environment if missing dependencies in system python
27
+ _venv_python = Path(__file__).resolve().parent.parent / ".venv" / "bin" / "python3"
28
+ if (sys.prefix == sys.base_prefix) and _venv_python.is_file():
29
+ try:
30
+ import requests
31
+ import websockets
32
+ except ImportError:
33
+ os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
34
+
35
+ try:
36
+ import requests
37
+ except ImportError:
38
+ print("Error: 'requests' package is required.")
39
+ print("Run: source .venv/bin/activate (or: pip install --break-system-packages requests)")
40
+ sys.exit(1)
41
+
42
+ try:
43
+ import websockets
44
+ except ImportError:
45
+ print("Error: 'websockets' package is required.")
46
+ print("Run: source .venv/bin/activate (or: pip install --break-system-packages websockets)")
47
+ sys.exit(1)
48
+
49
+
50
+ def load_env_file(path: Path) -> Dict[str, str]:
51
+ """Reads key-value pairs from an environment/credential file."""
52
+ if not path.is_file():
53
+ return {}
54
+ try:
55
+ content = path.read_text(encoding="utf-8").strip()
56
+ env_vars = {}
57
+ for line in content.splitlines():
58
+ line = line.strip()
59
+ if not line or line.startswith('#'):
60
+ continue
61
+ if '=' in line:
62
+ k, v = line.split('=', 1)
63
+ k = k.strip().removeprefix('export ').strip()
64
+ v = v.strip().strip('"\'')
65
+ if k:
66
+ env_vars[k] = v
67
+ return env_vars
68
+ except Exception:
69
+ return {}
70
+
71
+
72
+ # Auto-load candidate credential files
73
+ for env_candidate in [Path.home() / ".jenkins_credentials", Path.home() / ".jenkins_env", Path.cwd() / ".jenkins_env", Path.cwd() / ".jenkins_credentials"]:
74
+ loaded = load_env_file(env_candidate)
75
+ for k, v in loaded.items():
76
+ if k not in os.environ:
77
+ os.environ[k] = v
78
+
79
+
80
+
81
+ class CloudClient:
82
+ def __init__(
83
+ self,
84
+ endpoint: str,
85
+ api_key: str = "",
86
+ cf_client_id: str = "",
87
+ cf_client_secret: str = ""
88
+ ):
89
+ endpoint = (endpoint or "").strip()
90
+ if endpoint and not endpoint.startswith("http://") and not endpoint.startswith("https://"):
91
+ endpoint = f"https://{endpoint}"
92
+ self.endpoint = endpoint.rstrip("/")
93
+ self.headers = {"Content-Type": "application/json"}
94
+ api_key = api_key or os.environ.get("TTCN3_CLOUD_API_KEY", "")
95
+ cf_client_id = cf_client_id or os.environ.get("CF_ACCESS_CLIENT_ID", "")
96
+ cf_client_secret = cf_client_secret or os.environ.get("CF_ACCESS_CLIENT_SECRET", "")
97
+ if api_key:
98
+ self.headers["Authorization"] = f"Bearer {api_key}"
99
+ if cf_client_id and cf_client_secret:
100
+ self.headers["CF-Access-Client-Id"] = cf_client_id
101
+ self.headers["CF-Access-Client-Secret"] = cf_client_secret
102
+
103
+ def get_health(self):
104
+ resp = requests.get(f"{self.endpoint}/health", headers=self.headers, timeout=10)
105
+ resp.raise_for_status()
106
+ return resp.json()
107
+
108
+ def get_suites(self):
109
+ resp = requests.get(f"{self.endpoint}/api/v1/suites", headers=self.headers, timeout=10)
110
+ resp.raise_for_status()
111
+ return resp.json()
112
+
113
+ def get_testcases(self, suite: str):
114
+ resp = requests.get(f"{self.endpoint}/api/v1/suites/{suite}/testcases", headers=self.headers, timeout=15)
115
+ resp.raise_for_status()
116
+ return resp.json()
117
+
118
+ def create_session(
119
+ self,
120
+ testcase: str,
121
+ suite: Optional[str] = None,
122
+ timeout: int = 300,
123
+ log_level: str = "info",
124
+ mode: str = "remote_ue",
125
+ rat: str = "nr",
126
+ timer_mode: str = "balanced",
127
+ timer_step_ms: Optional[int] = None,
128
+ ):
129
+ payload = {
130
+ "testcase": testcase,
131
+ "suite": suite,
132
+ "timeout_seconds": timeout,
133
+ "log_level": log_level,
134
+ "mode": mode,
135
+ "rat": rat,
136
+ "timer_mode": timer_mode,
137
+ }
138
+ if timer_step_ms is not None:
139
+ payload["timer_step_ms"] = timer_step_ms
140
+ max_attempts = 15
141
+ for attempt in range(max_attempts):
142
+ try:
143
+ resp = requests.post(f"{self.endpoint}/api/v1/sessions", json=payload, headers=self.headers, timeout=30)
144
+ if resp.status_code in (503, 429) and attempt < max_attempts - 1:
145
+ backoff = min(2 * (attempt + 1), 10)
146
+ print(f"[*] Cloud runner slots busy (HTTP {resp.status_code}). Retrying in {backoff}s (attempt {attempt + 1}/{max_attempts})...")
147
+ time.sleep(backoff)
148
+ continue
149
+ resp.raise_for_status()
150
+ return resp.json()
151
+ except requests.exceptions.RequestException as e:
152
+ status_code = getattr(getattr(e, 'response', None), 'status_code', None)
153
+ if attempt < max_attempts - 1 and status_code in (503, 429):
154
+ backoff = min(2 * (attempt + 1), 10)
155
+ print(f"[*] Cloud runner slots busy (HTTP {status_code}). Retrying in {backoff}s (attempt {attempt + 1}/{max_attempts})...")
156
+ time.sleep(backoff)
157
+ continue
158
+ raise
159
+
160
+ def get_session(self, session_id: str):
161
+ resp = requests.get(f"{self.endpoint}/api/v1/sessions/{session_id}", headers=self.headers, timeout=10)
162
+ resp.raise_for_status()
163
+ return resp.json()
164
+
165
+ def cancel_session(self, session_id: str):
166
+ resp = requests.delete(f"{self.endpoint}/api/v1/sessions/{session_id}", headers=self.headers, timeout=10)
167
+ resp.raise_for_status()
168
+ return resp.json()
169
+
170
+ def list_artifacts(self, session_id: str):
171
+ try:
172
+ resp = requests.get(f"{self.endpoint}/api/v1/sessions/{session_id}/artifacts", headers=self.headers, timeout=10)
173
+ if resp.status_code == 200:
174
+ return resp.json().get("artifacts", [])
175
+ except Exception:
176
+ pass
177
+ return []
178
+
179
+ def download_artifact(self, session_id: str, filename: str, target_path: Path):
180
+ resp = requests.get(f"{self.endpoint}/api/v1/sessions/{session_id}/artifacts/{filename}", headers=self.headers, stream=True, timeout=30)
181
+ if resp.status_code == 200:
182
+ target_path.parent.mkdir(parents=True, exist_ok=True)
183
+ with open(target_path, "wb") as f:
184
+ for chunk in resp.iter_content(chunk_size=8192):
185
+ f.write(chunk)
186
+ return True
187
+ return False
188
+
189
+ def download_logs_archive(self, session_id: str, target_path: Path) -> bool:
190
+ """Downloads all session execution logs in a single compressed .tar.gz bundle."""
191
+ url = f"{self.endpoint}/api/v1/sessions/{session_id}/logs.tar.gz"
192
+ try:
193
+ resp = requests.get(url, headers=self.headers, stream=True, timeout=60)
194
+ if resp.status_code == 200:
195
+ target_path.parent.mkdir(parents=True, exist_ok=True)
196
+ with open(target_path, "wb") as f:
197
+ for chunk in resp.iter_content(chunk_size=65536):
198
+ if chunk:
199
+ f.write(chunk)
200
+ return True
201
+ except Exception:
202
+ pass
203
+ return False
204
+
205
+
206
+
207
+ def get_ws_kwargs(headers_dict):
208
+ if not headers_dict:
209
+ return {}
210
+ import inspect
211
+ sig = inspect.signature(websockets.connect)
212
+ if "additional_headers" in sig.parameters:
213
+ return {"additional_headers": headers_dict}
214
+ return {"extra_headers": headers_dict}
215
+
216
+
217
+ async def run_local_relay(local_port: int, remote_ws_url: str, headers: dict, stop_event: asyncio.Event, ready_event: Optional[asyncio.Event] = None):
218
+ """
219
+ Listens on local_port for TCP connections (from srsue/ttcn3_dut) and bridges
220
+ raw bytes to the remote WebSocket MUX tunnel.
221
+ """
222
+ server = None
223
+
224
+ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
225
+ sock = writer.get_extra_info("socket")
226
+ if sock:
227
+ try:
228
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
229
+ except Exception:
230
+ pass
231
+
232
+ print(f"\n[\033[1;34mRELAY\033[0m] Local UE connected on port {local_port}. Connecting to cloud MUX tunnel...")
233
+ try:
234
+ extra_headers = {}
235
+ if "Authorization" in headers:
236
+ extra_headers["Authorization"] = headers["Authorization"]
237
+ if "CF-Access-Client-Id" in headers:
238
+ extra_headers["CF-Access-Client-Id"] = headers["CF-Access-Client-Id"]
239
+ extra_headers["CF-Access-Client-Secret"] = headers["CF-Access-Client-Secret"]
240
+
241
+ ws_kwargs = get_ws_kwargs(extra_headers)
242
+
243
+ ws = None
244
+ for attempt in range(10):
245
+ if stop_event.is_set():
246
+ return
247
+ try:
248
+ ws = await websockets.connect(
249
+ remote_ws_url,
250
+ ping_interval=20,
251
+ ping_timeout=20,
252
+ max_size=10 * 1024 * 1024,
253
+ **ws_kwargs,
254
+ )
255
+ break
256
+ except Exception as ce:
257
+ if attempt == 9:
258
+ print(f"[\033[1;31mRELAY ERROR\033[0m] Failed to connect to cloud MUX tunnel at {remote_ws_url}: {ce}")
259
+ return
260
+ await asyncio.sleep(0.3)
261
+
262
+ if not ws:
263
+ return
264
+
265
+ async with ws:
266
+ print(f"[\033[1;32mRELAY\033[0m] Cloud MUX WebSocket tunnel established.")
267
+
268
+ is_done = False
269
+
270
+ async def tcp_to_ws():
271
+ nonlocal is_done
272
+ try:
273
+ while not is_done and not stop_event.is_set():
274
+ data = await reader.read(65536)
275
+ if not data:
276
+ break
277
+ await ws.send(data)
278
+ except Exception:
279
+ pass
280
+ finally:
281
+ is_done = True
282
+
283
+ async def ws_to_tcp():
284
+ nonlocal is_done
285
+ try:
286
+ while not is_done and not stop_event.is_set():
287
+ msg = await ws.recv()
288
+ if isinstance(msg, bytes):
289
+ writer.write(msg)
290
+ await writer.drain()
291
+ elif isinstance(msg, str):
292
+ writer.write(msg.encode("utf-8"))
293
+ await writer.drain()
294
+ except Exception:
295
+ pass
296
+ finally:
297
+ is_done = True
298
+
299
+ t1 = asyncio.create_task(tcp_to_ws())
300
+ t2 = asyncio.create_task(ws_to_tcp())
301
+ await asyncio.wait([t1, t2], return_when=asyncio.FIRST_COMPLETED)
302
+ is_done = True
303
+ t1.cancel()
304
+ t2.cancel()
305
+
306
+ except Exception as e:
307
+ print(f"[\033[1;31mRELAY ERROR\033[0m] Error in relay bridge: {e}")
308
+ finally:
309
+ try:
310
+ writer.close()
311
+ await writer.wait_closed()
312
+ except Exception:
313
+ pass
314
+ print(f"[\033[1;34mRELAY\033[0m] Relay connection closed.")
315
+
316
+ try:
317
+ server = await asyncio.start_server(handle_client, "127.0.0.1", local_port)
318
+ print(f"[\033[1;32mRELAY\033[0m] Local TCP Multiplex Relay listening on 127.0.0.1:{local_port}")
319
+ if ready_event:
320
+ ready_event.set()
321
+ while not stop_event.is_set():
322
+ await asyncio.sleep(0.5)
323
+ except Exception as e:
324
+ print(f"[\033[1;31mRELAY ERROR\033[0m] Failed to bind local relay on port {local_port}: {e}")
325
+ finally:
326
+ if server:
327
+ server.close()
328
+ await server.wait_closed()
329
+
330
+
331
+ SIGNALING_PATTERNS = [
332
+ (re.compile(r'Starting execution of target testcase:\s*(\S+)'), 'Test Case Execution Started: \\1'),
333
+ (re.compile(r'\"rrcSetupRequest\"'), 'RRC: RRCSetupRequest (UE -> Network)'),
334
+ (re.compile(r'\"rrcSetup\"'), 'RRC: RRCSetup (Network -> UE)'),
335
+ (re.compile(r'\"rrcSetupComplete\"'), 'RRC: RRCSetupComplete (UE -> Network)'),
336
+ (re.compile(r'\"securityModeCommand\"'), 'RRC: SecurityModeCommand (Network -> UE)'),
337
+ (re.compile(r'\"securityModeComplete\"'), 'RRC: SecurityModeComplete (UE -> Network)'),
338
+ (re.compile(r'\"rrcReconfiguration\"'), 'RRC: RRCReconfiguration (Network -> UE)'),
339
+ (re.compile(r'\"rrcReconfigurationComplete\"'), 'RRC: RRCReconfigurationComplete (UE -> Network)'),
340
+ (re.compile(r'\"rrcRelease\"'), 'RRC: RRCRelease (Network -> UE)'),
341
+ (re.compile(r'f_NR_PreliminaryPass.*\"(Step[^\"]+)\"'), 'Preliminary Pass: \\1'),
342
+ (re.compile(r'Active cell changed to (\d+)'), 'Cell Configuration: Active Cell -> \\1'),
343
+ (re.compile(r'AS Security StartRestart.*CellId (\d+)'), 'Security: AS Security Activated (Cell \\1)'),
344
+ (re.compile(r'AS Security Release.*CellId (\d+)'), 'Security: AS Security Released (Cell \\1)'),
345
+ (re.compile(r'Testcase gracefully stopped'), 'Test Case Execution Stopped Gracefully'),
346
+ ]
347
+
348
+
349
+ def format_elapsed(seconds: float) -> str:
350
+ m = int(seconds) // 60
351
+ s = int(seconds) % 60
352
+ return f"{m:02d}:{s:02d}"
353
+
354
+
355
+ async def stream_logs(remote_ws_url: str, headers: dict, stop_event: asyncio.Event, verdict_holder: dict, raw_logs: bool = False, start_time: Optional[float] = None):
356
+ """
357
+ Subscribes to the live log stream WebSocket on the cloud runner and displays
358
+ real-time test progress (steps, signaling milestones, verdicts, elapsed time).
359
+ """
360
+ extra_headers = {}
361
+ if "Authorization" in headers:
362
+ extra_headers["Authorization"] = headers["Authorization"]
363
+ if "CF-Access-Client-Id" in headers:
364
+ extra_headers["CF-Access-Client-Id"] = headers["CF-Access-Client-Id"]
365
+ extra_headers["CF-Access-Client-Secret"] = headers["CF-Access-Client-Secret"]
366
+
367
+ ws_kwargs = get_ws_kwargs(extra_headers)
368
+
369
+ ws = None
370
+ last_err = None
371
+ for _ in range(15):
372
+ if stop_event.is_set():
373
+ return
374
+ try:
375
+ ws = await websockets.connect(remote_ws_url, **ws_kwargs)
376
+ break
377
+ except Exception as e:
378
+ last_err = e
379
+ await asyncio.sleep(0.3)
380
+
381
+ if not ws:
382
+ print(f"[\033[1;31mSTREAM ERROR\033[0m] Could not connect to cloud log stream at {remote_ws_url}: {last_err}", flush=True)
383
+ return
384
+
385
+ if start_time is None:
386
+ start_time = time.time()
387
+ current_step = None
388
+ last_event = None
389
+ last_heartbeat_time = time.time()
390
+
391
+ def print_progress(text: str, event_type: str = "milestone"):
392
+ elapsed = format_elapsed(time.time() - start_time)
393
+ if event_type == "step":
394
+ print(f"\n\033[1;36m[{elapsed}] ───▶ {text}\033[0m", flush=True)
395
+ elif event_type == "verdict":
396
+ color = "\033[1;32m" if "PASS" in text else "\033[1;31m"
397
+ print(f"\n{color}[{elapsed}] =========================================================================\033[0m", flush=True)
398
+ print(f"{color}[{elapsed}] >>> {text}\033[0m", flush=True)
399
+ print(f"{color}[{elapsed}] =========================================================================\033[0m\n", flush=True)
400
+ elif event_type == "pass":
401
+ print(f" \033[1;32m✔\033[0m \033[32m[{elapsed}] {text}\033[0m", flush=True)
402
+ elif event_type == "status":
403
+ print(f"\033[1;33m[{elapsed}] [*] {text}\033[0m", flush=True)
404
+ elif event_type == "error":
405
+ print(f"\033[1;31m[{elapsed}] [!] {text}\033[0m", flush=True)
406
+ else:
407
+ print(f" \033[1;34m✔\033[0m \033[90m[{elapsed}]\033[0m {text}", flush=True)
408
+
409
+ try:
410
+ async with ws:
411
+ while not stop_event.is_set():
412
+ try:
413
+ msg_text = await asyncio.wait_for(ws.recv(), timeout=1.0)
414
+ msg = json.loads(msg_text)
415
+ mtype = msg.get("type", "log")
416
+ content = msg.get("content", "")
417
+
418
+ if mtype == "step":
419
+ if content != current_step:
420
+ current_step = content
421
+ print_progress(content, event_type="step")
422
+ last_event = None
423
+ last_heartbeat_time = time.time()
424
+ elif mtype == "verdict":
425
+ if not verdict_holder.get("verdict"):
426
+ verdict_holder["verdict"] = content
427
+ print_progress(f"Final Test Verdict: {content}", event_type="verdict")
428
+ elif mtype == "status":
429
+ print_progress(content, event_type="status")
430
+ elif mtype == "error":
431
+ print_progress(content, event_type="error")
432
+ else:
433
+ if raw_logs:
434
+ sys.stdout.write(content)
435
+ sys.stdout.flush()
436
+ else:
437
+ for raw_line in content.splitlines():
438
+ clean = re.sub(r'^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?\]\s*', '', raw_line.strip())
439
+ if re.match(r'^Steps?\s+[0-9a-zA-Z]', clean, re.IGNORECASE) and len(clean) < 60:
440
+ if clean != current_step:
441
+ current_step = clean
442
+ print_progress(clean, event_type="step")
443
+ last_event = None
444
+ last_heartbeat_time = time.time()
445
+ continue
446
+
447
+ if "Final Test Verdict:" in clean:
448
+ v = clean.split("Final Test Verdict:", 1)[1].strip()
449
+ if not verdict_holder.get("verdict"):
450
+ verdict_holder["verdict"] = v
451
+ print_progress(f"Final Test Verdict: {v}", event_type="verdict")
452
+ continue
453
+
454
+ if "PreliminaryPass" in clean or "f_NR_PreliminaryPass" in clean:
455
+ m = re.search(r'PreliminaryPass.*\"([^\"]+)\"', clean)
456
+ pass_name = m.group(1) if m else "Pass"
457
+ print_progress(f"Preliminary Pass: {pass_name}", event_type="pass")
458
+ last_heartbeat_time = time.time()
459
+ continue
460
+
461
+ for pat, label in SIGNALING_PATTERNS:
462
+ m = pat.search(clean)
463
+ if m:
464
+ desc = label
465
+ if '\\1' in label and m.groups():
466
+ desc = label.replace('\\1', m.group(1))
467
+ if desc != last_event:
468
+ last_event = desc
469
+ print_progress(desc, event_type="milestone")
470
+ last_heartbeat_time = time.time()
471
+ break
472
+
473
+ if "[RUNNER_STATUS]" in clean:
474
+ print_progress(clean, event_type="status")
475
+
476
+ except asyncio.TimeoutError:
477
+ if not raw_logs and (time.time() - last_heartbeat_time > 10.0):
478
+ elapsed = format_elapsed(time.time() - start_time)
479
+ step_info = f" ({current_step})" if current_step else ""
480
+ print(f" \033[90m... [{elapsed}] Executing{step_info}\033[0m", flush=True)
481
+ last_heartbeat_time = time.time()
482
+ continue
483
+ except websockets.ConnectionClosed:
484
+ break
485
+ except Exception as e:
486
+ print(f"[\033[1;33mSTREAM NOTE\033[0m] Stream closed: {e}", flush=True)
487
+
488
+
489
+ def run_command_handler(args):
490
+ client = CloudClient(
491
+ endpoint=args.endpoint,
492
+ api_key=args.token or os.environ.get("TTCN3_CLOUD_API_KEY", ""),
493
+ cf_client_id=args.cf_client_id or os.environ.get("CF_ACCESS_CLIENT_ID", ""),
494
+ cf_client_secret=args.cf_client_secret or os.environ.get("CF_ACCESS_CLIENT_SECRET", ""),
495
+ )
496
+
497
+ exec_mode = getattr(args, "mode", None)
498
+ if not exec_mode:
499
+ if getattr(args, "srsue", False):
500
+ exec_mode = "remote_ue"
501
+ else:
502
+ exec_mode = "cloud_ue"
503
+
504
+ mode_label = "Cloud Headless UE (Colocated High-Speed)" if exec_mode == "cloud_ue" else "Remote UE (Local Relay)"
505
+ timer_mode = getattr(args, "timer_mode", "adaptive")
506
+ timer_step_ms = getattr(args, "timer_step_ms", None)
507
+ if timer_step_ms:
508
+ step_desc = f"{timer_step_ms}ms"
509
+ elif timer_mode == "accurate":
510
+ step_desc = "100ms"
511
+ elif timer_mode == "fast":
512
+ step_desc = "2000ms"
513
+ elif timer_mode == "balanced":
514
+ step_desc = "500ms"
515
+ elif timer_mode == "adaptive":
516
+ step_desc = "dynamic 100ms - 5000ms (Gear 3 auto-geared)"
517
+ else:
518
+ step_desc = "500ms"
519
+
520
+ timeout = getattr(args, "timeout", None)
521
+ if timeout is None:
522
+ timeout = 900 if exec_mode == "remote_ue" else 600
523
+ timeout_desc = f"{timeout}s ({timeout // 60} mins)" if timeout % 60 == 0 else f"{timeout}s"
524
+
525
+ print("\n=========================================================================")
526
+ print(f" TTCN-3 Cloud Runner - Test Session Initialization")
527
+ print("=========================================================================")
528
+ print(f" Cloud Endpoint : {args.endpoint}")
529
+ print(f" Test Case : {args.tc}")
530
+ print(f" Execution Mode : {mode_label}")
531
+ print(f" Simulation Time: {timer_mode} mode (max step: {step_desc})")
532
+ print(f" Timeout : {timeout_desc}")
533
+ if args.suite:
534
+ print(f" Suite Version : {args.suite}")
535
+ print("=========================================================================\n")
536
+
537
+ print("[1/4] Creating remote test session in the cloud...")
538
+ try:
539
+ session = client.create_session(
540
+ testcase=args.tc,
541
+ suite=args.suite,
542
+ timeout=timeout,
543
+ log_level=args.loglevel,
544
+ mode=exec_mode,
545
+ rat=getattr(args, "rat", "nr"),
546
+ timer_mode=timer_mode,
547
+ timer_step_ms=timer_step_ms,
548
+ )
549
+ except Exception as e:
550
+ print(f"Error creating session: {e}")
551
+ return 1
552
+
553
+ session_id = session["session_id"]
554
+ slot = session["slot"]
555
+ cloud_mux_port = session["ports"]["multiplex"]
556
+ vpcd_port = session["ports"]["vpcd"]
557
+
558
+ # Use specified local port or avoid collision if testing on localhost
559
+ is_localhost = "127.0.0.1" in args.endpoint or "localhost" in args.endpoint
560
+ if args.local_port:
561
+ local_port = args.local_port
562
+ elif is_localhost:
563
+ local_port = cloud_mux_port + 1000
564
+ else:
565
+ local_port = cloud_mux_port
566
+
567
+ # Resolve URLs
568
+ stream_url = session["ws_stream_url"]
569
+ mux_url = session["ws_mux_url"]
570
+
571
+ # If URLs are relative, make absolute based on endpoint
572
+ if stream_url.startswith("/"):
573
+ ws_base = args.endpoint.replace("https://", "wss://").replace("http://", "ws://")
574
+ stream_url = f"{ws_base}{stream_url}"
575
+ if mux_url.startswith("/"):
576
+ ws_base = args.endpoint.replace("https://", "wss://").replace("http://", "ws://")
577
+ mux_url = f"{ws_base}{mux_url}"
578
+
579
+ print(f" Allocated Cloud Slot: {slot}")
580
+ print(f" Cloud Session ID : {session_id}")
581
+ if exec_mode == "cloud_ue":
582
+ print(f" Cloud Multiplex Port: {cloud_mux_port} (Internal Loopback)")
583
+ else:
584
+ print(f" Local Multiplex Port: {local_port}")
585
+ print(f" Cloud Multiplex Port: {cloud_mux_port}")
586
+
587
+ # Start asyncio loop for relay and streaming
588
+ loop = asyncio.new_event_loop()
589
+ asyncio.set_event_loop(loop)
590
+ stop_event = asyncio.Event()
591
+ verdict_holder = {"verdict": None}
592
+
593
+ dut_proc = None
594
+ softsim_proc = None
595
+ ue_stdout_file = None
596
+ softsim_file = None
597
+
598
+ def cleanup_all():
599
+ stop_event.set()
600
+ if dut_proc and dut_proc.poll() is None:
601
+ print("\nStopping local UE simulator...")
602
+ try:
603
+ os.killpg(os.getpgid(dut_proc.pid), signal.SIGKILL)
604
+ except Exception:
605
+ try:
606
+ dut_proc.terminate()
607
+ except Exception:
608
+ dut_proc.kill()
609
+ if softsim_proc and softsim_proc.poll() is None:
610
+ print("Stopping local SoftSIM...")
611
+ try:
612
+ os.killpg(os.getpgid(softsim_proc.pid), signal.SIGKILL)
613
+ except Exception:
614
+ pass
615
+ if ue_stdout_file and not ue_stdout_file.closed:
616
+ try:
617
+ ue_stdout_file.close()
618
+ except Exception:
619
+ pass
620
+ if softsim_file and not softsim_file.closed:
621
+ try:
622
+ softsim_file.close()
623
+ except Exception:
624
+ pass
625
+ if exec_mode != "cloud_ue":
626
+ subprocess.run(f"pkill -9 -f 'softsim.*{vpcd_port}' >/dev/null 2>&1 || true", shell=True)
627
+ subprocess.run(f"pkill -9 -f 'softsim_instance_{slot}' >/dev/null 2>&1 || true", shell=True)
628
+ subprocess.run(f"fuser -k {vpcd_port}/tcp >/dev/null 2>&1 || true", shell=True)
629
+ subprocess.run(f"fuser -k {local_port}/tcp >/dev/null 2>&1 || true", shell=True)
630
+
631
+ async def main_async():
632
+ nonlocal dut_proc, softsim_proc, ue_stdout_file, softsim_file
633
+
634
+ raw_logs_mode = getattr(args, "raw_logs", False)
635
+ common_start_time = time.time()
636
+ ue_monitor_task = None
637
+
638
+ if exec_mode == "cloud_ue":
639
+ print("\n[2/4] Headless UE (`ttcn3_dut` + SoftSIM) running in cloud alongside runner...")
640
+ print("\n[3/4] Streaming Live Test Progress from Cloud Runner...")
641
+ print("-------------------------------------------------------------------------")
642
+ stream_task = asyncio.create_task(stream_logs(stream_url, client.headers, stop_event, verdict_holder, raw_logs=raw_logs_mode, start_time=common_start_time))
643
+ relay_task = None
644
+ else:
645
+ if exec_mode != "cloud_ue":
646
+ subprocess.run(f"pkill -9 -f 'softsim.*{vpcd_port}' >/dev/null 2>&1 || true", shell=True)
647
+ subprocess.run(f"pkill -9 -f 'softsim_instance_{slot}' >/dev/null 2>&1 || true", shell=True)
648
+ subprocess.run(f"fuser -k {local_port}/tcp >/dev/null 2>&1 || true", shell=True)
649
+ subprocess.run(f"fuser -k {vpcd_port}/tcp >/dev/null 2>&1 || true", shell=True)
650
+
651
+ ready_event = asyncio.Event()
652
+ relay_task = asyncio.create_task(run_local_relay(local_port, mux_url, client.headers, stop_event, ready_event))
653
+ stream_task = asyncio.create_task(stream_logs(stream_url, client.headers, stop_event, verdict_holder, raw_logs=raw_logs_mode, start_time=common_start_time))
654
+
655
+ # Wait for local TCP relay to start listening
656
+ await ready_event.wait()
657
+
658
+ # Optional local srsRAN_5G / softsim launch
659
+ if args.srsue:
660
+ print("\n[2/4] Starting local SoftSIM & srsRAN_5G UE (`ttcn3_dut`)...")
661
+ ttcpp_dir = Path(__file__).resolve().parent.parent
662
+ dev_dir = ttcpp_dir.parent
663
+ onomondo_dir = Path(os.environ.get("ONOMONDO_UICC_DIR", dev_dir / "onomondo-uicc"))
664
+ srsran_dir = Path(os.environ.get("SRSRAN_DIR", dev_dir / "srsRAN_5G"))
665
+
666
+ softsim_bin = onomondo_dir / "build" / "src" / "softsim" / "softsim"
667
+ dut_bin = srsran_dir / "build" / "bin" / "ttcn3_dut"
668
+
669
+ if not softsim_bin.exists() and (ttcpp_dir / "bin/softsim").exists():
670
+ softsim_bin = ttcpp_dir / "bin/softsim"
671
+ if not dut_bin.exists() and (ttcpp_dir / "bin/ttcn3_dut").exists():
672
+ dut_bin = ttcpp_dir / "bin/ttcn3_dut"
673
+
674
+ files_dir = onomondo_dir / "files"
675
+ if not files_dir.exists() and (ttcpp_dir / "files").exists():
676
+ files_dir = ttcpp_dir / "files"
677
+
678
+ dut_log_dir = ttcpp_dir / f"logs/{args.tc}"
679
+ dut_log_dir.mkdir(parents=True, exist_ok=True)
680
+
681
+ if softsim_bin.exists():
682
+ subprocess.run(f"pkill -9 -f 'softsim.*{vpcd_port}' >/dev/null 2>&1 || true", shell=True)
683
+ subprocess.run(f"pkill -9 -f 'softsim_instance_{slot}' >/dev/null 2>&1 || true", shell=True)
684
+ softsim_dir = ttcpp_dir / f"logs/softsim_instance_{slot}"
685
+ if softsim_dir.exists():
686
+ shutil.rmtree(softsim_dir, ignore_errors=True)
687
+ softsim_dir.mkdir(parents=True, exist_ok=True)
688
+ if files_dir.exists():
689
+ shutil.copytree(files_dir, softsim_dir / "files", dirs_exist_ok=True)
690
+ sys.path.append(str(ttcpp_dir / "tools" / "usim_configs"))
691
+ try:
692
+ from softsim_utils import get_init_script_for_testcase
693
+ init_script = Path(get_init_script_for_testcase(args.tc, str(ttcpp_dir / "tools" / "usim_configs")))
694
+ except Exception:
695
+ init_script = ttcpp_dir / "tools/usim_configs/init_softsim_config_1.py"
696
+ if init_script.exists():
697
+ env_init = os.environ.copy()
698
+ env_init["PYTHONPATH"] = str(ttcpp_dir / "tools" / "usim_configs")
699
+ subprocess.run([sys.executable, str(init_script), str(softsim_dir)], env=env_init, check=True, stdout=subprocess.DEVNULL)
700
+
701
+ softsim_file = open(dut_log_dir / "softsim.log", "w", encoding="utf-8", errors="replace")
702
+ softsim_loop_cmd = f"while true; do '{softsim_bin}' {vpcd_port} || true; sleep 0.2; done"
703
+ softsim_proc = subprocess.Popen(
704
+ softsim_loop_cmd,
705
+ shell=True,
706
+ cwd=str(softsim_dir),
707
+ stdout=softsim_file,
708
+ stderr=subprocess.STDOUT,
709
+ preexec_fn=os.setsid,
710
+ )
711
+ print(f" SoftSIM running on port {vpcd_port} (PID: {softsim_proc.pid})")
712
+ await asyncio.sleep(0.5)
713
+
714
+ if dut_bin.exists():
715
+ dut_cmd = [
716
+ str(dut_bin),
717
+ "--tester.instance_id", str(slot),
718
+ "--tester.ip", "127.0.0.1",
719
+ "--tester.port", str(local_port),
720
+ "--usim.mode", "vpcd",
721
+ "--rat", args.rat,
722
+ "--loglevel", args.loglevel,
723
+ "--logfilename", str(dut_log_dir / "ttcn3_ue.log"),
724
+ "--log.file_max_size=-1",
725
+ "--log.file_max_size_truncate=true",
726
+ "--at.port", str(27007 + slot * 20),
727
+ ]
728
+ dut_env = os.environ.copy()
729
+ dut_env["INSTANCE_ID"] = str(slot)
730
+ dut_env["TESTER_IP"] = "127.0.0.1"
731
+ dut_env["TESTER_PORT"] = str(local_port)
732
+ dut_env["SRSRAN_POOL_SIZE"] = "512"
733
+ ue_stdout_file = open(dut_log_dir / "ue_stdout.log", "w", encoding="utf-8", errors="replace")
734
+ dut_proc = subprocess.Popen(
735
+ dut_cmd,
736
+ cwd=str(ttcpp_dir),
737
+ env=dut_env,
738
+ stdout=ue_stdout_file,
739
+ stderr=subprocess.STDOUT,
740
+ preexec_fn=os.setsid,
741
+ )
742
+ print(f" srsRAN_5G `ttcn3_dut` launched (PID: {dut_proc.pid})")
743
+
744
+ async def monitor_local_ue():
745
+ ue_log_file = dut_log_dir / "ue_stdout.log"
746
+ local_patterns = [
747
+ (re.compile(r"Initializing TTCN3 Mux Client"), "Local UE: Connecting to cloud multiplex relay"),
748
+ (re.compile(r"Switching on UE"), "Local UE: Powering ON stack"),
749
+ (re.compile(r"RRC_NR: SIB1 received! setting for pci=(\d+)"), "Local UE: SIB1 acquired (PCI \\1)"),
750
+ (re.compile(r"RRC_NR: (?:send_setup_request|connection_request)"), "Local UE: Transmitting RRCSetupRequest"),
751
+ (re.compile(r"MAC_NR: Calling phy->send_prach"), "Local UE: Transmitting PRACH Preamble"),
752
+ (re.compile(r"MAC_NR: ra_response_reception"), "Local UE: Random Access Response (RAR) Received"),
753
+ (re.compile(r"Applying security context|AS Security"), "Local UE: AS Security Activated"),
754
+ (re.compile(r"Received RRC Release|rrcRelease"), "Local UE: RRC Release Received"),
755
+ ]
756
+ seen_events = set()
757
+ while not stop_event.is_set():
758
+ if ue_log_file.exists():
759
+ try:
760
+ with open(ue_log_file, "r", encoding="utf-8", errors="replace") as f:
761
+ for line in f:
762
+ for pat, desc in local_patterns:
763
+ m = pat.search(line)
764
+ if m and desc not in seen_events:
765
+ seen_events.add(desc)
766
+ actual_desc = desc
767
+ if "\\1" in desc and m.groups():
768
+ actual_desc = desc.replace("\\1", m.group(1))
769
+ elapsed = format_elapsed(time.time() - common_start_time)
770
+ print(f" \033[1;35m⚙\033[0m \033[90m[{elapsed}]\033[0m \033[35m{actual_desc}\033[0m", flush=True)
771
+ except Exception:
772
+ pass
773
+ await asyncio.sleep(0.3)
774
+
775
+ ue_monitor_task = asyncio.create_task(monitor_local_ue())
776
+ else:
777
+ print(f"\n[2/4] Awaiting local UE connection on 127.0.0.1:{local_port}...")
778
+ print(f" (Start your local UE/srsue pointing to tester port {local_port})")
779
+
780
+ print("\n[3/4] Establishing Cloud Tunnel & Streaming Logs...")
781
+ print("-------------------------------------------------------------------------")
782
+
783
+ # Check session completion periodically
784
+ async def poll_status():
785
+ while not stop_event.is_set():
786
+ await asyncio.sleep(1.0)
787
+ try:
788
+ s = client.get_session(session_id)
789
+ if s["status"] in ("COMPLETED", "FAILED", "TIMEOUT", "CANCELLED"):
790
+ verdict_holder["verdict"] = s.get("verdict")
791
+ if s.get("error_message"):
792
+ print(f"\n\033[1;31m[CLOUD RUNNER ERROR]\033[0m {s['error_message']}\n", flush=True)
793
+ stop_event.set()
794
+ break
795
+ except Exception:
796
+ pass
797
+
798
+ poll_task = asyncio.create_task(poll_status())
799
+
800
+ # Wait until stop_event is set
801
+ while not stop_event.is_set():
802
+ await asyncio.sleep(0.5)
803
+
804
+ if ue_monitor_task:
805
+ ue_monitor_task.cancel()
806
+ if relay_task:
807
+ relay_task.cancel()
808
+ stream_task.cancel()
809
+ poll_task.cancel()
810
+
811
+ try:
812
+ loop.run_until_complete(main_async())
813
+ except KeyboardInterrupt:
814
+ print("\n\nTest interrupted by user. Cancelling cloud session...")
815
+ try:
816
+ client.cancel_session(session_id)
817
+ except Exception:
818
+ pass
819
+ finally:
820
+ cleanup_all()
821
+
822
+ # 4. Summary & Artifact Download
823
+ print("-------------------------------------------------------------------------")
824
+ print("\n=========================================================================")
825
+ print(" TEST EXECUTION SUMMARY")
826
+ print("=========================================================================")
827
+ final_session = {}
828
+ try:
829
+ final_session = client.get_session(session_id)
830
+ verdict = final_session.get("verdict") or verdict_holder.get("verdict") or "UNKNOWN"
831
+ duration = final_session.get("duration_seconds", "N/A")
832
+ print(f" Test Case : {args.tc}")
833
+ print(f" Suite : {final_session.get('suite', args.suite)}")
834
+ print(f" Duration : {duration}s")
835
+ print(f" Verdict : {verdict}")
836
+ except Exception:
837
+ verdict = verdict_holder.get("verdict") or "UNKNOWN"
838
+ print(f" Verdict : {verdict}")
839
+
840
+ if args.download_artifacts:
841
+ print("\n[4/4] Downloading compressed execution logs from cloud...")
842
+ local_log_dir = Path(f"logs/{args.tc}")
843
+ local_log_dir.mkdir(parents=True, exist_ok=True)
844
+ archive_path = local_log_dir / f"{args.tc}_{session_id}_logs.tar.gz"
845
+
846
+ archive_success = client.download_logs_archive(session_id, archive_path)
847
+ if archive_success and archive_path.is_file() and archive_path.stat().st_size > 0:
848
+ compressed_size = archive_path.stat().st_size
849
+ uncompressed_size = 0
850
+ extracted_files = []
851
+ import tarfile
852
+ with tarfile.open(archive_path, "r:gz") as tar:
853
+ for member in tar.getmembers():
854
+ if member.isfile():
855
+ uncompressed_size += member.size
856
+ extracted_files.append((member.name, member.size))
857
+ tar.extractall(path=local_log_dir)
858
+
859
+ archive_path.unlink(missing_ok=True)
860
+ ratio = (1.0 - (compressed_size / max(uncompressed_size, 1))) * 100
861
+ comp_mb = compressed_size / (1024 * 1024)
862
+ uncomp_mb = uncompressed_size / (1024 * 1024)
863
+ print(f" Downloaded {comp_mb:.2f} MB compressed (uncompressed {uncomp_mb:.2f} MB, {ratio:.1f}% bandwidth saved)")
864
+ print(f" Extracted {len(extracted_files)} files to: {local_log_dir}/")
865
+ for fname, fsize in extracted_files:
866
+ sz_str = f"{fsize / (1024*1024):.1f} MB" if fsize >= 1024 * 1024 else f"{fsize / 1024:.1f} KB"
867
+ print(f" - {fname:<32} ({sz_str})")
868
+ else:
869
+ # Fallback to individual artifact downloads
870
+ artifacts = client.list_artifacts(session_id)
871
+ if artifacts:
872
+ for item in artifacts:
873
+ fname = item.get("name")
874
+ if fname and not fname.endswith(".tar.gz"):
875
+ dest = local_log_dir / fname
876
+ if client.download_artifact(session_id, fname, dest):
877
+ print(f" Downloaded artifact to: {dest}")
878
+ else:
879
+ local_runner_log = local_log_dir / "tc_runner.log"
880
+ if client.download_artifact(session_id, "tc_runner.log", local_runner_log):
881
+ print(f" Downloaded runner log to: {local_runner_log}")
882
+ local_ue_log = local_log_dir / "ttcn3_ue.log"
883
+ if client.download_artifact(session_id, "ttcn3_ue.log", local_ue_log):
884
+ print(f" Downloaded UE simulator log to: {local_ue_log}")
885
+
886
+ print("=========================================================================\n")
887
+ return 0 if verdict == "PASS" else 1
888
+
889
+
890
+ def main():
891
+ default_endpoint = os.environ.get("TTCN3_CLOUD_ENDPOINT")
892
+ if not default_endpoint or "ttcn-cloud.hyper-wireless.com" in default_endpoint:
893
+ default_endpoint = "https://cloud-api.hyper-wireless.com"
894
+
895
+
896
+ common_parser = argparse.ArgumentParser(add_help=False)
897
+ common_parser.add_argument("--endpoint", default=default_endpoint, help=f"Cloud service URL (default: {default_endpoint})")
898
+ common_parser.add_argument("--token", default=os.environ.get("TTCN3_CLOUD_API_KEY", ""), help="API Bearer Token")
899
+ common_parser.add_argument("--cf-client-id", default=os.environ.get("CF_ACCESS_CLIENT_ID", ""), help="Cloudflare Access Client ID")
900
+ common_parser.add_argument("--cf-client-secret", default=os.environ.get("CF_ACCESS_CLIENT_SECRET", ""), help="Cloudflare Access Client Secret")
901
+
902
+ parser = argparse.ArgumentParser(description="TTCN-3 Cloud Runner CLI for UE Testing", parents=[common_parser])
903
+ subparsers = parser.add_subparsers(dest="command")
904
+
905
+ # Command: suites
906
+ subparsers.add_parser("suites", parents=[common_parser], help="List available test suites in the cloud")
907
+
908
+ # Command: health
909
+ subparsers.add_parser("health", parents=[common_parser], help="Check health and slot availability")
910
+
911
+ # Command: testcases
912
+ tc_parser = subparsers.add_parser("testcases", parents=[common_parser], help="List test cases for a suite")
913
+ tc_parser.add_argument("--suite", default="NR5GC_IWD_25wk50", help="Suite version")
914
+
915
+ # Command: run
916
+ run_parser = subparsers.add_parser("run", parents=[common_parser], help="Run a test case in the cloud")
917
+ run_parser.add_argument("--tc", required=True, help="Testcase name (e.g. TC_6_1_1_2_NR5GC)")
918
+ run_parser.add_argument("--suite", default=None, help="Suite version (defaults to active suite)")
919
+ run_parser.add_argument("--timeout", type=int, default=None, help="Test execution timeout in seconds (default: 600s [10 mins] for cloud UE, 900s [15 mins] for local/remote UE)")
920
+ run_parser.add_argument("--loglevel", default="info", help="Log level")
921
+ run_parser.add_argument("--cloud-ue", action="store_true", default=True, help="Execute with headless UE (ttcn3_dut + SoftSIM) colocated in cloud (default: True, high speed)")
922
+ run_parser.add_argument("--mode", choices=["cloud_ue", "remote_ue", "no_ue"], default=None, help="Explicit execution mode")
923
+ run_parser.add_argument("--timer-mode", "--speed-mode", dest="timer_mode", choices=["adaptive", "accurate", "balanced", "fast"], default="adaptive", help="Simulation timer mode for lockstep execution: 'adaptive' (dynamic auto-gearing up to 5000ms Gear 3, default), 'accurate' (100ms steps, high precision), 'balanced' (500ms steps), 'fast' (2000ms steps, speed-optimized for UE over WAN/internet)")
924
+ run_parser.add_argument("--timer-step-ms", "--max-step-ms", dest="timer_step_ms", type=int, default=None, help="Explicit max simulated step size in milliseconds (e.g. 100, 500, 1000, 2000). Overrides --timer-mode preset.")
925
+ run_parser.add_argument("--local-port", type=int, default=None, help="Local TCP relay listening port (for remote_ue mode)")
926
+ run_parser.add_argument("--srsue", action="store_true", help="Launch local srsRAN_5G ttcn3_dut (selects remote_ue mode)")
927
+ run_parser.add_argument("--rat", default="nr", help="RAT for srsue (nr, lte)")
928
+ run_parser.add_argument("--raw-logs", action="store_true", help="Stream raw unformatted log lines instead of clean progress view")
929
+ run_parser.add_argument("--download-artifacts", action="store_true", default=True, help="Download execution logs")
930
+
931
+ args = parser.parse_args()
932
+
933
+ # Ensure flags passed before or after subcommands are preserved
934
+ for i, arg in enumerate(sys.argv):
935
+ if arg == "--endpoint" and i + 1 < len(sys.argv):
936
+ args.endpoint = sys.argv[i + 1]
937
+ elif arg.startswith("--endpoint="):
938
+ args.endpoint = arg.split("=", 1)[1]
939
+ elif arg == "--cf-client-id" and i + 1 < len(sys.argv):
940
+ args.cf_client_id = sys.argv[i + 1]
941
+ elif arg.startswith("--cf-client-id="):
942
+ args.cf_client_id = arg.split("=", 1)[1]
943
+ elif arg == "--cf-client-secret" and i + 1 < len(sys.argv):
944
+ args.cf_client_secret = sys.argv[i + 1]
945
+ elif arg.startswith("--cf-client-secret="):
946
+ args.cf_client_secret = arg.split("=", 1)[1]
947
+
948
+ if not args.command or args.command == "run":
949
+ if not hasattr(args, "tc") or not args.tc:
950
+ parser.print_help()
951
+ sys.exit(1)
952
+ sys.exit(run_command_handler(args))
953
+
954
+ client = CloudClient(
955
+ endpoint=args.endpoint,
956
+ api_key=args.token,
957
+ cf_client_id=args.cf_client_id,
958
+ cf_client_secret=args.cf_client_secret,
959
+ )
960
+
961
+ if args.command == "health":
962
+ h = client.get_health()
963
+ print(json.dumps(h, indent=2))
964
+ print()
965
+
966
+ elif args.command == "suites":
967
+ suites = client.get_suites()
968
+ print(f"\nAvailable Cloud Test Suites ({len(suites)} total):")
969
+ print(f"{'SUITE NAME':<26} {'ACTIVE':<8} {'RUNNER AVAILABLE':<18} {'TESTCASES':<10}")
970
+ print("-" * 65)
971
+ for s in suites:
972
+ active_str = "[*]" if s["is_active"] else " "
973
+ runner_str = "YES" if s["has_runner"] else "NO"
974
+ tc_count = str(s["testcase_count"]) if s["testcase_count"] is not None else "-"
975
+ print(f"{s['name']:<26} {active_str:<8} {runner_str:<18} {tc_count:<10}")
976
+ print()
977
+
978
+ elif args.command == "testcases":
979
+ tcs = client.get_testcases(args.suite)
980
+ print(f"\nTest Cases for Suite '{args.suite}' ({len(tcs)} total):")
981
+ print(f"{'TESTCASE NAME':<28} {'STATUS':<12} {'CONDITION':<12} {'DESCRIPTION'}")
982
+ print("-" * 80)
983
+ for tc in tcs[:50]:
984
+ status = "Supported" if tc["supported"] else "Not Supported"
985
+ cond = tc.get("condition") or "-"
986
+ desc = tc.get("purpose") or ""
987
+ print(f"{tc['name']:<28} {status:<12} {cond:<12} {desc[:40]}")
988
+ if len(tcs) > 50:
989
+ print(f"... and {len(tcs) - 50} more test cases.")
990
+ print()
991
+
992
+
993
+ if __name__ == "__main__":
994
+ main()