mrg-iot 1.1.8__tar.gz

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.
mrg_iot-1.1.8/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sphere / NEU IoT Testbed contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mrg_iot-1.1.8/PKG-INFO ADDED
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: mrg-iot
3
+ Version: 1.1.8
4
+ Summary: IoT Testbed Experiment Automation CLI for MergeTB / Sphere Testbed
5
+ Author: Sphere / NEU IoT Testbed contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://gitlab.com/sphere-neu/mrg-iot
8
+ Project-URL: Repository, https://gitlab.com/sphere-neu/mrg-iot
9
+ Project-URL: Issues, https://gitlab.com/sphere-neu/mrg-iot/-/issues
10
+ Keywords: iot,testbed,mergetb,sphere,automation,cli
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: System :: Distributed Computing
24
+ Classifier: Topic :: System :: Networking
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: bcrypt>=5.0.0
29
+ Requires-Dist: betterproto==2.0.0b7
30
+ Requires-Dist: certifi
31
+ Requires-Dist: cffi>=2.0.0
32
+ Requires-Dist: cryptography>=46.0.3
33
+ Requires-Dist: grpclib>=0.4.8
34
+ Requires-Dist: h2>=4.3.0
35
+ Requires-Dist: hpack>=4.1.0
36
+ Requires-Dist: hyperframe>=6.1.0
37
+ Requires-Dist: mergetbapi==1.3.41
38
+ Requires-Dist: multidict>=6.7.0
39
+ Requires-Dist: packaging>=25.0
40
+ Requires-Dist: paramiko>=4.0.0
41
+ Requires-Dist: pycparser>=2.23
42
+ Requires-Dist: PyNaCl>=1.6.0
43
+ Requires-Dist: python-dateutil>=2.9.0
44
+ Requires-Dist: six>=1.17.0
45
+ Requires-Dist: typing_extensions>=4.15.0
46
+ Provides-Extra: dev
47
+ Requires-Dist: astroid==4.0.1; extra == "dev"
48
+ Requires-Dist: black==25.9.0; extra == "dev"
49
+ Requires-Dist: dill==0.4.0; extra == "dev"
50
+ Requires-Dist: invoke==2.2.1; extra == "dev"
51
+ Requires-Dist: isort==7.0.0; extra == "dev"
52
+ Requires-Dist: mccabe==0.7.0; extra == "dev"
53
+ Requires-Dist: mypy_extensions==1.1.0; extra == "dev"
54
+ Requires-Dist: pathspec==0.12.1; extra == "dev"
55
+ Requires-Dist: platformdirs==4.5.0; extra == "dev"
56
+ Requires-Dist: pylint==4.0.2; extra == "dev"
57
+ Requires-Dist: pytokens==0.2.0; extra == "dev"
58
+ Requires-Dist: tomlkit==0.13.3; extra == "dev"
59
+ Requires-Dist: types-paramiko; extra == "dev"
60
+ Dynamic: license-file
61
+ Dynamic: requires-python
62
+
63
+ # mrg-iot
64
+
65
+ A Python CLI for automating IoT testbed experiments on the
66
+ [Sphere / Merge Testbed](https://sphere-testbed.net) platform.
67
+
68
+ `mrg-iot` drives the full experiment lifecycle so you don't have to:
69
+
70
+ - Create an experiment and its network model for the selected devices.
71
+ - Realize and materialize the model.
72
+ - Create an XDC (eXperimental Data Center) and attach it to the
73
+ materialization.
74
+ - Set up SSH port forwarding for RTSP streams and file downloads.
75
+ - (Optional) De-materialize and clean up resources when you're done.
76
+
77
+ ## Installation
78
+
79
+ ```bash
80
+ pipx install mrg-iot
81
+ ```
82
+
83
+ (Or `pip install mrg-iot` inside a virtualenv.)
84
+
85
+ `mrg-iot` requires **Python 3.9+** and a working
86
+ [VLC](https://www.videolan.org/vlc/) install on the host for RTSP
87
+ playback — `pip` can't install VLC for you.
88
+
89
+ ## Usage
90
+
91
+ ### Interactive mode
92
+
93
+ ```bash
94
+ mrg-iot login # save credentials to ~/.mrg-iot/config.json
95
+ mrg-iot # walk through the experiment flow
96
+ ```
97
+
98
+ ### Non-interactive mode
99
+
100
+ ```bash
101
+ mrg-iot --non-interactive \
102
+ --project neuiot \
103
+ --devices s-echodot-1 \
104
+ --exp-name myexp \
105
+ --exp-desc "my experiment" \
106
+ --realization realiot \
107
+ --duration 1w \
108
+ --xdc myxdc
109
+ ```
110
+
111
+ ### Cleanup subcommands
112
+
113
+ ```bash
114
+ mrg-iot exp delete --project neuiot --name myexp
115
+ mrg-iot exp dematerialize --project neuiot --name myexp --realization realiot
116
+ mrg-iot xdc detach --project neuiot --name myxdc --experiment myexp --realization realiot
117
+ mrg-iot xdc delete --project neuiot --name myxdc
118
+ ```
119
+
120
+ Run `mrg-iot --help` for the full flag list.
121
+
122
+ ### Debug logging
123
+
124
+ ```bash
125
+ mrg-iot --debug # verbose logs to stderr + debug.log
126
+ ```
127
+
128
+ ## Input validation
129
+
130
+ The CLI validates inputs upfront so bad args fail fast rather than
131
+ deep inside a portal call:
132
+
133
+ - **Names** (`exp-name`, `realization`, `xdc`, network): start with a
134
+ letter, lowercase letters and digits only, max 32 chars.
135
+ - **Description**: letters, digits, spaces, commas, periods, hyphens;
136
+ max 256 chars.
137
+ - **Duration**: minimum 4 days. Accepts `1w`, `4d`, `1w2d`,
138
+ `1 week`, etc.
139
+
140
+ ## macOS SSL note
141
+
142
+ If portal calls fail with an SSL error on macOS:
143
+
144
+ ```bash
145
+ export SSL_CERT_FILE="$(python -m certifi)"
146
+ export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE"
147
+ ```
148
+
149
+ ## Development
150
+
151
+ ```bash
152
+ git clone https://gitlab.com/sphere-neu/mrg-iot.git
153
+ cd mrg-iot
154
+ python3 -m venv .venv && source .venv/bin/activate
155
+ pip install -e ".[dev]"
156
+
157
+ mypy .
158
+ pylint *.py
159
+ black *.py
160
+ isort *.py
161
+ ```
162
+
163
+ ## License
164
+
165
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,103 @@
1
+ # mrg-iot
2
+
3
+ A Python CLI for automating IoT testbed experiments on the
4
+ [Sphere / Merge Testbed](https://sphere-testbed.net) platform.
5
+
6
+ `mrg-iot` drives the full experiment lifecycle so you don't have to:
7
+
8
+ - Create an experiment and its network model for the selected devices.
9
+ - Realize and materialize the model.
10
+ - Create an XDC (eXperimental Data Center) and attach it to the
11
+ materialization.
12
+ - Set up SSH port forwarding for RTSP streams and file downloads.
13
+ - (Optional) De-materialize and clean up resources when you're done.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pipx install mrg-iot
19
+ ```
20
+
21
+ (Or `pip install mrg-iot` inside a virtualenv.)
22
+
23
+ `mrg-iot` requires **Python 3.9+** and a working
24
+ [VLC](https://www.videolan.org/vlc/) install on the host for RTSP
25
+ playback — `pip` can't install VLC for you.
26
+
27
+ ## Usage
28
+
29
+ ### Interactive mode
30
+
31
+ ```bash
32
+ mrg-iot login # save credentials to ~/.mrg-iot/config.json
33
+ mrg-iot # walk through the experiment flow
34
+ ```
35
+
36
+ ### Non-interactive mode
37
+
38
+ ```bash
39
+ mrg-iot --non-interactive \
40
+ --project neuiot \
41
+ --devices s-echodot-1 \
42
+ --exp-name myexp \
43
+ --exp-desc "my experiment" \
44
+ --realization realiot \
45
+ --duration 1w \
46
+ --xdc myxdc
47
+ ```
48
+
49
+ ### Cleanup subcommands
50
+
51
+ ```bash
52
+ mrg-iot exp delete --project neuiot --name myexp
53
+ mrg-iot exp dematerialize --project neuiot --name myexp --realization realiot
54
+ mrg-iot xdc detach --project neuiot --name myxdc --experiment myexp --realization realiot
55
+ mrg-iot xdc delete --project neuiot --name myxdc
56
+ ```
57
+
58
+ Run `mrg-iot --help` for the full flag list.
59
+
60
+ ### Debug logging
61
+
62
+ ```bash
63
+ mrg-iot --debug # verbose logs to stderr + debug.log
64
+ ```
65
+
66
+ ## Input validation
67
+
68
+ The CLI validates inputs upfront so bad args fail fast rather than
69
+ deep inside a portal call:
70
+
71
+ - **Names** (`exp-name`, `realization`, `xdc`, network): start with a
72
+ letter, lowercase letters and digits only, max 32 chars.
73
+ - **Description**: letters, digits, spaces, commas, periods, hyphens;
74
+ max 256 chars.
75
+ - **Duration**: minimum 4 days. Accepts `1w`, `4d`, `1w2d`,
76
+ `1 week`, etc.
77
+
78
+ ## macOS SSL note
79
+
80
+ If portal calls fail with an SSL error on macOS:
81
+
82
+ ```bash
83
+ export SSL_CERT_FILE="$(python -m certifi)"
84
+ export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE"
85
+ ```
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ git clone https://gitlab.com/sphere-neu/mrg-iot.git
91
+ cd mrg-iot
92
+ python3 -m venv .venv && source .venv/bin/activate
93
+ pip install -e ".[dev]"
94
+
95
+ mypy .
96
+ pylint *.py
97
+ black *.py
98
+ isort *.py
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,93 @@
1
+ """
2
+ Creates an implementation for easily managing a grpc call and accounting for stream terminations
3
+ """
4
+
5
+ import socket
6
+ from typing import Any
7
+ import logging
8
+
9
+ from types import FunctionType
10
+ import betterproto
11
+ from grpclib import GRPCError
12
+ from grpclib.client import Channel
13
+ from grpclib.config import Configuration
14
+ from grpclib.exceptions import StreamTerminatedError
15
+
16
+ GRPC_HOST = "grpc.sphere-testbed.net"
17
+ GRPC_PORT = 443
18
+ MAX_TERM_RETRIES = 3
19
+ AUTH = "authorization"
20
+ TOKEN_TEMPLATE = "Bearer {}"
21
+
22
+
23
+ class GrpcRunner:
24
+ """
25
+ Class for running grpc calls and handling stream terminations
26
+ """
27
+
28
+ @staticmethod
29
+ async def run(
30
+ grpc_stub: type[betterproto.ServiceStub],
31
+ target: FunctionType,
32
+ request: betterproto.Message,
33
+ token: str = "",
34
+ ) -> Any:
35
+ """
36
+ Runs a grpc method with a given stub and target function
37
+
38
+ :param grpc_stub: The stub to call the function from
39
+ :param target: The target function to call
40
+ :param request: The request object of the function
41
+ :param token: User's authentication token, defaults to ""
42
+ :raises GRPCError: When the grpc call raises an exception back
43
+ :raises StreamTerminatedError: When the stream terminates due to idle
44
+ :return: Response from the grpc function
45
+ """
46
+
47
+ i = MAX_TERM_RETRIES
48
+
49
+ while i > 0:
50
+
51
+ channel = GrpcRunner.create_channel()
52
+ stub = grpc_stub(channel)
53
+ meta_data = {AUTH: TOKEN_TEMPLATE.format(token)} if token else None
54
+
55
+ try:
56
+ # pyright: ignore[reportCallIssue]
57
+ response: betterproto.Message = await target(
58
+ stub, request, metadata=meta_data
59
+ )
60
+ channel.close()
61
+ return response
62
+
63
+ except GRPCError as e:
64
+ channel.close()
65
+ logging.debug("GRPC channel closed after error")
66
+ raise e
67
+
68
+ except (StreamTerminatedError, socket.gaierror) as e:
69
+ i -= 1
70
+
71
+ if i <= 0:
72
+ print(
73
+ "[mrg-iot] "
74
+ + "Communication with Merge Portal interrupted and failed to reconnect. "
75
+ "Please wait a few minutes before before attempting communication again. "
76
+ f"Received from communication channel: {e}"
77
+ )
78
+ logging.exception("Max retries exceeded | original_error=%s", e)
79
+ raise e
80
+
81
+ @staticmethod
82
+ def create_channel() -> Channel:
83
+ """
84
+ Creates a channel for use for grpc communication
85
+
86
+ :return: grpc channel connected to
87
+ """
88
+
89
+ config = Configuration(30, 15, True)
90
+
91
+ channel = Channel(GRPC_HOST, GRPC_PORT, ssl=True, config=config)
92
+
93
+ return channel
@@ -0,0 +1,53 @@
1
+ """
2
+ A module to hold all logging-related classes and methods.
3
+ """
4
+
5
+ import logging
6
+ from mrgiot_consts import DEBUG_LOG_PATH
7
+
8
+ class LogManager:
9
+ """
10
+ Singleton class for managing logging configuration.
11
+ """
12
+
13
+ _instance = None
14
+ _initialized = False
15
+
16
+ def __new__(cls):
17
+ if cls._instance is None:
18
+ cls._instance = super().__new__(cls)
19
+ return cls._instance
20
+
21
+ def __init__(self):
22
+ if self._initialized:
23
+ return
24
+ self._initialized = True
25
+
26
+ @staticmethod
27
+ def get_instance() -> "LogManager":
28
+ """This function returns the instance of our Log Manager
29
+
30
+ Returns:
31
+ LogManager: The instance of our Log Manager
32
+ """
33
+ if LogManager._instance is None:
34
+ return LogManager()
35
+ return LogManager._instance
36
+
37
+ def configure_logging(self, debug: bool = False):
38
+ """This function configures logging
39
+
40
+ Args:
41
+ debug (bool, optional): handles debug logging based on the debug flag. Defaults to False.
42
+ """
43
+ logging.basicConfig(
44
+ filename=DEBUG_LOG_PATH,
45
+ level=logging.DEBUG if debug else logging.INFO,
46
+ format="%(asctime)s [%(levelname)s] %(message)s",
47
+ datefmt="%d %B %Y %I:%M:%S %p",
48
+ filemode="w",
49
+ )
50
+ logging.getLogger("paramiko").setLevel(logging.WARNING)
51
+ logging.getLogger("paramiko.transport").setLevel(logging.WARNING)
52
+ logging.getLogger("hpack").setLevel(logging.WARNING)
53
+ logging.getLogger("grpclib").setLevel(logging.WARNING)
@@ -0,0 +1,145 @@
1
+ """
2
+ Implementation for a paramiko based tunnel based on information gotten from the following demo:
3
+ https://github.com/paramiko/paramiko/blob/main/demos/forward.py
4
+
5
+ Which creates a substitution for an openSSH tunnel since it is not implementated by paramiko
6
+ """
7
+
8
+ from socketserver import ThreadingTCPServer
9
+
10
+
11
+ import select
12
+ import socketserver
13
+ import threading
14
+ import paramiko
15
+ from paramiko.ssh_exception import SSHException
16
+
17
+
18
+ # pylint: disable=too-few-public-methods
19
+ class MikoTunnel:
20
+ """
21
+ Implementation for a paramiko based tunnel
22
+ """
23
+
24
+ __transport: paramiko.Transport
25
+ __remote_host: str
26
+ __remote_port: int
27
+ __local_port: int
28
+ __forward_server: socketserver.ThreadingTCPServer
29
+
30
+ def __init__(
31
+ self,
32
+ transport: paramiko.Transport,
33
+ remote_host: str,
34
+ remote_port: int,
35
+ local_port: int = -1,
36
+ ):
37
+ """
38
+ Instantiates a paramiko based forwarding tunnel
39
+
40
+ :param transport: paramiko transport to be the source of the tunnel
41
+ :param remote_host: The remote host to bind to
42
+ :param remote_port: The remote port to bind to
43
+ :param local_port: The local port to bind to, defaults to match remote_port
44
+ """
45
+
46
+ self.transport = transport
47
+ self.remote_host = remote_host
48
+ self.remote_port = remote_port
49
+ self.local_port = local_port if local_port > 0 else remote_port
50
+ self.__forward_server = self.__start()
51
+
52
+ def __start(self) -> ThreadingTCPServer:
53
+ """
54
+ Starts the tunnel by opening a TCP server that forwards traffic as a daemon thread
55
+
56
+ :return: The running traffic forwarding server
57
+ """
58
+
59
+ class _Handler(ForwardHandler):
60
+ """
61
+ Local subclass for passing information about the remote host to the server
62
+ """
63
+
64
+ target_host = self.remote_host
65
+ target_port = self.remote_port
66
+ ssh_transport = self.transport
67
+
68
+ tunnel = socketserver.ThreadingTCPServer(("", self.local_port), _Handler)
69
+ tunnel.allow_reuse_address = True
70
+ tunnel.daemon_threads = True
71
+ thread = threading.Thread(target=tunnel.serve_forever, daemon=True)
72
+ thread.start()
73
+
74
+ return tunnel
75
+
76
+ def close(self):
77
+ """
78
+ Translates a close call into the server close call to properly close the tunnel
79
+ """
80
+ self.__forward_server.server_close()
81
+
82
+ def __del__(self):
83
+ """
84
+ Automatically closes the tunnel when the instance is deleted to prevent sockets from being occupied
85
+ """
86
+
87
+ self.close()
88
+
89
+
90
+
91
+ class ForwardHandler(socketserver.BaseRequestHandler):
92
+ """
93
+ Handler for the tunnel that forwards traffic from each end of the tunnel to the other
94
+ """
95
+
96
+ target_host: str
97
+ target_port: int
98
+ ssh_transport: paramiko.Transport
99
+
100
+ def handle(self) -> None:
101
+ """
102
+ Handler function for handling incoming communication from the either end of the tunnel
103
+ """
104
+
105
+ try:
106
+ channel = self.ssh_transport.open_channel(
107
+ "direct-tcpip",
108
+ (self.target_host, self.target_port),
109
+ self.request.getpeername(),
110
+ )
111
+
112
+ except SSHException as e:
113
+ print(e)
114
+ return
115
+
116
+ if not channel:
117
+ return
118
+
119
+ # Main tunnel loop
120
+ try:
121
+ while not channel.closed:
122
+ read_list, _, _ = select.select([self.request, channel], [], [], 1.0)
123
+
124
+ # Send incoming traffic out
125
+ if self.request in read_list:
126
+ traffic = self.request.recv(1024)
127
+ if not traffic:
128
+ break
129
+ channel.send(traffic)
130
+
131
+ # Recieve or redirect inbound traffic
132
+ if channel in read_list:
133
+ traffic = channel.recv(1024)
134
+ if not traffic:
135
+ break
136
+ self.request.send(traffic)
137
+
138
+ except (ConnectionResetError, OSError):
139
+ pass
140
+
141
+ finally:
142
+
143
+ # Close the socket if needed
144
+ if self.request.fileno() != -1:
145
+ self.request.close()