antioch-py 2.0.6__py3-none-any.whl → 3.0.12__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.
Potentially problematic release.
This version of antioch-py might be problematic. Click here for more details.
- antioch/__init__.py +101 -0
- antioch/{module/execution.py → execution.py} +1 -1
- antioch/{module/input.py → input.py} +2 -4
- antioch/{module/module.py → module.py} +17 -34
- antioch/{module/node.py → node.py} +17 -16
- {antioch_py-2.0.6.dist-info → antioch_py-3.0.12.dist-info}/METADATA +8 -11
- antioch_py-3.0.12.dist-info/RECORD +61 -0
- {antioch_py-2.0.6.dist-info → antioch_py-3.0.12.dist-info}/WHEEL +1 -1
- antioch_py-3.0.12.dist-info/licenses/LICENSE +21 -0
- common/ark/__init__.py +6 -16
- common/ark/ark.py +23 -60
- common/ark/hardware.py +13 -37
- common/ark/kinematics.py +1 -1
- common/ark/module.py +22 -0
- common/ark/node.py +46 -3
- common/ark/scheduler.py +2 -29
- common/ark/sim.py +1 -1
- {antioch/module → common/ark}/token.py +17 -0
- common/assets/rigging.usd +0 -0
- common/constants.py +83 -4
- common/core/__init__.py +37 -24
- common/core/auth.py +87 -114
- common/core/container.py +261 -0
- common/core/registry.py +131 -152
- common/core/rome.py +251 -0
- common/core/telemetry.py +176 -0
- common/core/types.py +219 -0
- common/message/__init__.py +19 -3
- common/message/annotation.py +174 -23
- common/message/array.py +25 -1
- common/message/camera.py +23 -1
- common/message/color.py +32 -6
- common/message/detection.py +40 -0
- common/message/foxglove.py +20 -0
- common/message/frame.py +71 -7
- common/message/image.py +58 -9
- common/message/imu.py +24 -4
- common/message/joint.py +69 -10
- common/message/log.py +52 -7
- common/message/pir.py +22 -5
- common/message/plot.py +57 -0
- common/message/point.py +55 -6
- common/message/point_cloud.py +55 -19
- common/message/pose.py +59 -19
- common/message/quaternion.py +105 -92
- common/message/radar.py +195 -29
- common/message/twist.py +34 -0
- common/message/types.py +40 -5
- common/message/vector.py +180 -245
- common/sim/__init__.py +49 -0
- common/sim/objects.py +460 -0
- common/sim/state.py +11 -0
- common/utils/comms.py +30 -12
- common/utils/logger.py +26 -7
- antioch/message.py +0 -87
- antioch/module/__init__.py +0 -53
- antioch/session/__init__.py +0 -150
- antioch/session/ark.py +0 -504
- antioch/session/asset.py +0 -65
- antioch/session/error.py +0 -80
- antioch/session/record.py +0 -158
- antioch/session/scene.py +0 -1521
- antioch/session/session.py +0 -220
- antioch/session/task.py +0 -323
- antioch/session/views/__init__.py +0 -40
- antioch/session/views/animation.py +0 -189
- antioch/session/views/articulation.py +0 -245
- antioch/session/views/basis_curve.py +0 -186
- antioch/session/views/camera.py +0 -92
- antioch/session/views/collision.py +0 -75
- antioch/session/views/geometry.py +0 -74
- antioch/session/views/ground_plane.py +0 -63
- antioch/session/views/imu.py +0 -73
- antioch/session/views/joint.py +0 -64
- antioch/session/views/light.py +0 -175
- antioch/session/views/pir_sensor.py +0 -140
- antioch/session/views/radar.py +0 -73
- antioch/session/views/rigid_body.py +0 -282
- antioch/session/views/xform.py +0 -119
- antioch_py-2.0.6.dist-info/RECORD +0 -99
- antioch_py-2.0.6.dist-info/entry_points.txt +0 -2
- common/core/agent.py +0 -296
- common/core/task.py +0 -36
- common/rome/__init__.py +0 -9
- common/rome/client.py +0 -430
- common/rome/error.py +0 -16
- common/session/__init__.py +0 -54
- common/session/environment.py +0 -31
- common/session/sim.py +0 -240
- common/session/views/__init__.py +0 -263
- common/session/views/animation.py +0 -73
- common/session/views/articulation.py +0 -184
- common/session/views/basis_curve.py +0 -102
- common/session/views/camera.py +0 -147
- common/session/views/collision.py +0 -59
- common/session/views/geometry.py +0 -102
- common/session/views/ground_plane.py +0 -41
- common/session/views/imu.py +0 -66
- common/session/views/joint.py +0 -81
- common/session/views/light.py +0 -96
- common/session/views/pir_sensor.py +0 -115
- common/session/views/radar.py +0 -82
- common/session/views/rigid_body.py +0 -236
- common/session/views/viewport.py +0 -21
- common/session/views/xform.py +0 -39
- common/utils/usd.py +0 -12
- /antioch/{module/clock.py → clock.py} +0 -0
- {antioch_py-2.0.6.dist-info → antioch_py-3.0.12.dist-info}/top_level.txt +0 -0
- /common/message/{base.py → message.py} +0 -0
common/core/auth.py
CHANGED
|
@@ -7,25 +7,18 @@ from pathlib import Path
|
|
|
7
7
|
import requests
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
|
-
from common.constants import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
AUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
23
|
-
AUTH_TIMEOUT_SECONDS = 120
|
|
24
|
-
|
|
25
|
-
# Authentication claims
|
|
26
|
-
AUTH_ORG_ID_CLAIM = "https://antioch.com/org_id"
|
|
27
|
-
AUTH_ORG_NAME_CLAIM = "https://antioch.com/org_name"
|
|
28
|
-
AUTH_ORGANIZATIONS_CLAIM = "https://antioch.com/organizations"
|
|
10
|
+
from common.constants import (
|
|
11
|
+
AUDIENCE,
|
|
12
|
+
AUTH_CLIENT_ID,
|
|
13
|
+
AUTH_GRANT_TYPE,
|
|
14
|
+
AUTH_ORG_ID_CLAIM,
|
|
15
|
+
AUTH_ORG_NAME_CLAIM,
|
|
16
|
+
AUTH_SCOPE,
|
|
17
|
+
AUTH_TIMEOUT_SECONDS,
|
|
18
|
+
AUTH_TOKEN_URL,
|
|
19
|
+
DEVICE_CODE_URL,
|
|
20
|
+
get_auth_dir,
|
|
21
|
+
)
|
|
29
22
|
|
|
30
23
|
|
|
31
24
|
class AuthError(Exception):
|
|
@@ -36,38 +29,51 @@ class AuthError(Exception):
|
|
|
36
29
|
|
|
37
30
|
class Organization(BaseModel):
|
|
38
31
|
"""
|
|
39
|
-
Organization information.
|
|
32
|
+
Organization information extracted from JWT token.
|
|
40
33
|
"""
|
|
41
34
|
|
|
42
35
|
org_id: str
|
|
43
36
|
org_name: str
|
|
44
37
|
|
|
45
38
|
|
|
39
|
+
class UserInfo(BaseModel):
|
|
40
|
+
"""
|
|
41
|
+
User information extracted from JWT token.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
user_id: str
|
|
45
|
+
name: str | None = None
|
|
46
|
+
email: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
46
49
|
class AuthHandler:
|
|
47
50
|
"""
|
|
48
|
-
Client for handling authentication.
|
|
51
|
+
Client for handling authentication via OAuth2 device code flow.
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- Pulling assets from the remote asset registry
|
|
53
|
+
Manages authentication tokens and organization context for interacting
|
|
54
|
+
with Antioch services.
|
|
53
55
|
"""
|
|
54
56
|
|
|
55
57
|
def __init__(self):
|
|
56
58
|
"""
|
|
57
59
|
Initialize the auth handler.
|
|
60
|
+
|
|
61
|
+
Automatically loads any existing token from disk.
|
|
58
62
|
"""
|
|
59
63
|
|
|
60
64
|
self._token: str | None = None
|
|
61
|
-
self.
|
|
62
|
-
self.
|
|
63
|
-
self._available_orgs: list[Organization] = []
|
|
65
|
+
self._org: Organization | None = None
|
|
66
|
+
self._user: UserInfo | None = None
|
|
64
67
|
self._load_local_token()
|
|
65
68
|
|
|
66
69
|
def login(self) -> None:
|
|
67
70
|
"""
|
|
68
|
-
Authenticate the user via device code flow.
|
|
71
|
+
Authenticate the user via OAuth2 device code flow.
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
Initiates the device code flow, prompts the user to authenticate
|
|
74
|
+
in their browser, and saves the token to disk on success.
|
|
75
|
+
|
|
76
|
+
:raises AuthError: If authentication fails or times out.
|
|
71
77
|
"""
|
|
72
78
|
|
|
73
79
|
if self.is_authenticated():
|
|
@@ -97,26 +103,27 @@ class AuthHandler:
|
|
|
97
103
|
"client_id": AUTH_CLIENT_ID,
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
authenticated = False
|
|
101
106
|
start_time = time.time()
|
|
102
|
-
while
|
|
107
|
+
while True:
|
|
103
108
|
token_response = requests.post(AUTH_TOKEN_URL, data=token_payload)
|
|
104
109
|
token_data = token_response.json()
|
|
105
110
|
if token_response.status_code == 200:
|
|
106
111
|
print("Authenticated!")
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
self._token = token_data["access_token"]
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
if token_data["error"] not in ("authorization_pending", "slow_down"):
|
|
109
116
|
print(token_data["error_description"])
|
|
110
117
|
raise AuthError("Error authenticating the user") from Exception(token_data)
|
|
111
|
-
else:
|
|
112
|
-
if time.time() - start_time > AUTH_TIMEOUT_SECONDS:
|
|
113
|
-
raise AuthError("Timeout waiting for authentication")
|
|
114
|
-
time.sleep(device_code_data["interval"])
|
|
115
118
|
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
if time.time() - start_time > AUTH_TIMEOUT_SECONDS:
|
|
120
|
+
raise AuthError("Timeout waiting for authentication")
|
|
121
|
+
|
|
122
|
+
time.sleep(device_code_data["interval"])
|
|
123
|
+
|
|
118
124
|
if self._token is None:
|
|
119
125
|
raise AuthError("No token received")
|
|
126
|
+
|
|
120
127
|
self._validate_token_claims(self._token)
|
|
121
128
|
self.save_token()
|
|
122
129
|
|
|
@@ -124,69 +131,44 @@ class AuthHandler:
|
|
|
124
131
|
"""
|
|
125
132
|
Check if the user is authenticated.
|
|
126
133
|
|
|
127
|
-
:return: True if authenticated, False otherwise.
|
|
134
|
+
:return: True if authenticated with a valid token, False otherwise.
|
|
128
135
|
"""
|
|
129
136
|
|
|
130
|
-
return self.
|
|
137
|
+
return self._org is not None
|
|
131
138
|
|
|
132
|
-
def
|
|
139
|
+
def get_org(self) -> Organization:
|
|
133
140
|
"""
|
|
134
|
-
|
|
141
|
+
Get the current organization.
|
|
135
142
|
|
|
136
|
-
:
|
|
143
|
+
:return: The current organization.
|
|
137
144
|
:raises AuthError: If the user is not authenticated.
|
|
138
145
|
"""
|
|
139
146
|
|
|
140
|
-
if not self.is_authenticated():
|
|
147
|
+
if not self.is_authenticated() or self._org is None:
|
|
141
148
|
raise AuthError("Not authenticated. Please login first")
|
|
149
|
+
return self._org
|
|
142
150
|
|
|
143
|
-
|
|
144
|
-
if org.org_id == org_id:
|
|
145
|
-
self._current_org = org
|
|
146
|
-
return
|
|
147
|
-
|
|
148
|
-
raise AuthError(f"Organization '{org_id}' is not in your available organizations")
|
|
149
|
-
|
|
150
|
-
def get_current_org(self) -> Organization | None:
|
|
151
|
+
def get_user_info(self) -> UserInfo | None:
|
|
151
152
|
"""
|
|
152
|
-
Get the current
|
|
153
|
+
Get the current user information.
|
|
153
154
|
|
|
154
|
-
:return: The current
|
|
155
|
+
:return: The current user info, or None if not available.
|
|
155
156
|
:raises AuthError: If the user is not authenticated.
|
|
156
157
|
"""
|
|
157
158
|
|
|
158
159
|
if not self.is_authenticated():
|
|
159
160
|
raise AuthError("Not authenticated. Please login first")
|
|
161
|
+
return self._user
|
|
160
162
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def get_user_id(self) -> str | None:
|
|
164
|
-
"""
|
|
165
|
-
Get the user ID.
|
|
166
|
-
|
|
167
|
-
:return: The user ID.
|
|
163
|
+
def get_token(self) -> str:
|
|
168
164
|
"""
|
|
165
|
+
Get the current authentication token.
|
|
169
166
|
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
def get_available_orgs(self) -> list[Organization]:
|
|
173
|
-
"""
|
|
174
|
-
Get the available organizations.
|
|
175
|
-
|
|
176
|
-
:return: The available organizations.
|
|
177
|
-
"""
|
|
178
|
-
|
|
179
|
-
return self._available_orgs
|
|
180
|
-
|
|
181
|
-
def get_token(self) -> str | None:
|
|
182
|
-
"""
|
|
183
|
-
Get the token.
|
|
184
|
-
|
|
185
|
-
:return: The token.
|
|
167
|
+
:return: The JWT access token.
|
|
186
168
|
:raises AuthError: If the user is not authenticated.
|
|
187
169
|
"""
|
|
188
170
|
|
|
189
|
-
if not self.is_authenticated():
|
|
171
|
+
if not self.is_authenticated() or self._token is None:
|
|
190
172
|
raise AuthError("Not authenticated. Please login first")
|
|
191
173
|
return self._token
|
|
192
174
|
|
|
@@ -194,22 +176,16 @@ class AuthHandler:
|
|
|
194
176
|
"""
|
|
195
177
|
Save the authentication token and organization data to disk.
|
|
196
178
|
|
|
179
|
+
Creates the token file with restrictive permissions (0600).
|
|
180
|
+
|
|
197
181
|
:raises AuthError: If not authenticated.
|
|
198
182
|
"""
|
|
199
183
|
|
|
200
184
|
if not self.is_authenticated():
|
|
201
185
|
raise AuthError("Not authenticated. Please login first")
|
|
202
186
|
|
|
203
|
-
stored_data = {
|
|
204
|
-
"token": self._token,
|
|
205
|
-
"current_org": self._current_org.model_dump() if self._current_org else None,
|
|
206
|
-
"available_orgs": [org.model_dump() for org in self._available_orgs],
|
|
207
|
-
}
|
|
208
|
-
|
|
187
|
+
stored_data = {"token": self._token, "org": self._org.model_dump() if self._org else None}
|
|
209
188
|
token_path = self._get_token_path()
|
|
210
|
-
|
|
211
|
-
# Create file with restrictive permissions (owner read/write only)
|
|
212
|
-
# Use os.open to atomically create file with 0o600 permissions
|
|
213
189
|
fd = os.open(token_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
214
190
|
with os.fdopen(fd, "w") as f:
|
|
215
191
|
json.dump(stored_data, f, indent=2)
|
|
@@ -225,9 +201,9 @@ class AuthHandler:
|
|
|
225
201
|
|
|
226
202
|
def _load_local_token(self) -> None:
|
|
227
203
|
"""
|
|
228
|
-
Load the authentication token
|
|
204
|
+
Load the authentication token from disk.
|
|
229
205
|
|
|
230
|
-
Silently returns if no token exists
|
|
206
|
+
Silently returns if no token exists. Clears invalid or expired tokens.
|
|
231
207
|
"""
|
|
232
208
|
|
|
233
209
|
token_path = self._get_token_path()
|
|
@@ -244,17 +220,16 @@ class AuthHandler:
|
|
|
244
220
|
|
|
245
221
|
# Validate and extract all claims from token
|
|
246
222
|
self._validate_token_claims(self._token)
|
|
223
|
+
if stored_data.get("org"):
|
|
224
|
+
self._org = Organization(**stored_data["org"])
|
|
247
225
|
except Exception as e:
|
|
248
226
|
print(f"Error loading local token: {e}")
|
|
249
|
-
|
|
250
|
-
# Clear invalid or expired tokens
|
|
251
227
|
self._token = None
|
|
252
228
|
self.clear_token()
|
|
253
|
-
return
|
|
254
229
|
|
|
255
|
-
def _validate_token_claims(self, token: str):
|
|
230
|
+
def _validate_token_claims(self, token: str) -> None:
|
|
256
231
|
"""
|
|
257
|
-
Validate the token and extract
|
|
232
|
+
Validate the token and extract organization and user information.
|
|
258
233
|
|
|
259
234
|
:param token: The JWT token to validate.
|
|
260
235
|
:raises AuthError: If the token is invalid, expired, or missing required claims.
|
|
@@ -264,7 +239,7 @@ class AuthHandler:
|
|
|
264
239
|
if len(parts) != 3:
|
|
265
240
|
raise AuthError("Invalid token format")
|
|
266
241
|
|
|
267
|
-
# Decode the payload
|
|
242
|
+
# Decode the payload
|
|
268
243
|
payload_encoded = parts[1]
|
|
269
244
|
padding = len(payload_encoded) % 4
|
|
270
245
|
if padding:
|
|
@@ -277,29 +252,27 @@ class AuthHandler:
|
|
|
277
252
|
if exp and time.time() > exp:
|
|
278
253
|
raise AuthError("Token has expired")
|
|
279
254
|
|
|
280
|
-
# Extract
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
for org in payload.get(AUTH_ORGANIZATIONS_CLAIM, [])
|
|
296
|
-
]
|
|
255
|
+
# Extract organization
|
|
256
|
+
org_id = payload.get(AUTH_ORG_ID_CLAIM)
|
|
257
|
+
org_name = payload.get(AUTH_ORG_NAME_CLAIM)
|
|
258
|
+
if not org_id or not org_name:
|
|
259
|
+
raise AuthError("Organization information not found in token claims")
|
|
260
|
+
self._org = Organization(org_id=org_id, org_name=org_name)
|
|
261
|
+
|
|
262
|
+
# Extract user info (optional claims)
|
|
263
|
+
user_id = payload.get("sub")
|
|
264
|
+
if user_id:
|
|
265
|
+
self._user = UserInfo(
|
|
266
|
+
user_id=user_id,
|
|
267
|
+
name=payload.get("name") or payload.get("nickname"),
|
|
268
|
+
email=payload.get("email"),
|
|
269
|
+
)
|
|
297
270
|
|
|
298
271
|
def _get_token_path(self) -> Path:
|
|
299
272
|
"""
|
|
300
273
|
Get the token file path.
|
|
301
274
|
|
|
302
|
-
:return: Path to the token file.
|
|
275
|
+
:return: Path to the token.json file.
|
|
303
276
|
"""
|
|
304
277
|
|
|
305
278
|
return get_auth_dir() / "token.json"
|
common/core/container.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import time
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import docker
|
|
6
|
+
from docker.errors import APIError
|
|
7
|
+
from docker.models.containers import Container
|
|
8
|
+
|
|
9
|
+
from common.ark import Ark as ArkDefinition, Environment
|
|
10
|
+
from common.ark.module import ModuleImage, ModuleReady, ModuleStart
|
|
11
|
+
from common.constants import ANTIOCH_API_URL
|
|
12
|
+
from common.core.auth import AuthHandler
|
|
13
|
+
from common.core.rome import RomeClient
|
|
14
|
+
from common.utils.comms import CommsSession
|
|
15
|
+
from common.utils.time import now_us
|
|
16
|
+
|
|
17
|
+
# Container naming prefix for all Antioch module containers
|
|
18
|
+
CONTAINER_PREFIX = "antioch-module-"
|
|
19
|
+
|
|
20
|
+
# Synchronization paths for module coordination
|
|
21
|
+
ARK_MODULE_READY_PATH = "_ark/module_ready"
|
|
22
|
+
ARK_MODULE_START_PATH = "_ark/module_start"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ContainerSource(str, Enum):
|
|
26
|
+
"""
|
|
27
|
+
Source location for container images.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
LOCAL = "Local"
|
|
31
|
+
REMOTE = "Remote"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ContainerManagerError(Exception):
|
|
35
|
+
"""
|
|
36
|
+
Raised when container management operations fail.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ContainerManager:
|
|
43
|
+
"""
|
|
44
|
+
Manages Docker containers for Ark modules.
|
|
45
|
+
|
|
46
|
+
Handles launching, coordination, and cleanup of module containers.
|
|
47
|
+
Uses host networking for Zenoh communication.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Create a new container manager.
|
|
53
|
+
|
|
54
|
+
Initializes Docker client and Zenoh communication session.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
self._comms = CommsSession()
|
|
58
|
+
self._client = docker.from_env()
|
|
59
|
+
self._containers: dict[str, Container] = {}
|
|
60
|
+
|
|
61
|
+
def launch_ark(
|
|
62
|
+
self,
|
|
63
|
+
ark: ArkDefinition,
|
|
64
|
+
source: ContainerSource = ContainerSource.LOCAL,
|
|
65
|
+
environment: Environment = Environment.SIM,
|
|
66
|
+
debug: bool = False,
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
) -> int:
|
|
69
|
+
"""
|
|
70
|
+
Launch all module containers for an Ark.
|
|
71
|
+
|
|
72
|
+
Stops any existing module containers first to ensure idempotent behavior.
|
|
73
|
+
|
|
74
|
+
:param ark: Ark definition to launch.
|
|
75
|
+
:param source: Container image source (local or remote).
|
|
76
|
+
:param environment: Environment to run in (sim or real).
|
|
77
|
+
:param debug: Enable debug mode.
|
|
78
|
+
:param timeout: Timeout in seconds for modules to become ready.
|
|
79
|
+
:return: Global start time in microseconds.
|
|
80
|
+
:raises ContainerManagerError: If environment is incompatible or launch fails.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Validate environment compatibility
|
|
84
|
+
if ark.capability == Environment.SIM and environment == Environment.REAL:
|
|
85
|
+
raise ContainerManagerError(f"Ark '{ark.name}' has sim capability but requested for real")
|
|
86
|
+
if ark.capability == Environment.REAL and environment == Environment.SIM:
|
|
87
|
+
raise ContainerManagerError(f"Ark '{ark.name}' has real capability but requested for sim")
|
|
88
|
+
|
|
89
|
+
# Stop all existing module containers (idempotent)
|
|
90
|
+
self._stop_all()
|
|
91
|
+
|
|
92
|
+
# Get GAR credentials if pulling from remote
|
|
93
|
+
gar_auth = self._get_gar_auth() if source == ContainerSource.REMOTE else None
|
|
94
|
+
|
|
95
|
+
# Build container configs
|
|
96
|
+
configs: list[tuple[str, str, str]] = []
|
|
97
|
+
for module in ark.modules:
|
|
98
|
+
image = self._get_image(module.image, environment)
|
|
99
|
+
if image is None:
|
|
100
|
+
raise ContainerManagerError(f"No image for module '{module.name}' in {environment}")
|
|
101
|
+
if gar_auth is not None:
|
|
102
|
+
image = f"{gar_auth['registry_host']}/{gar_auth['repository']}/{image}"
|
|
103
|
+
container_name = f"{CONTAINER_PREFIX}{ark.name.replace('_', '-')}-{module.name.replace('_', '-')}"
|
|
104
|
+
configs.append((module.name, container_name, image))
|
|
105
|
+
|
|
106
|
+
# Pull images if remote
|
|
107
|
+
if gar_auth is not None:
|
|
108
|
+
self._pull_images([c[2] for c in configs], gar_auth)
|
|
109
|
+
|
|
110
|
+
# Set up ready subscriber before launching
|
|
111
|
+
ready_sub = self._comms.declare_async_subscriber(ARK_MODULE_READY_PATH)
|
|
112
|
+
|
|
113
|
+
# Launch containers
|
|
114
|
+
ark_json = ark.model_dump_json()
|
|
115
|
+
for module_name, container_name, image in configs:
|
|
116
|
+
self._launch(module_name, container_name, image, ark_json, environment, debug)
|
|
117
|
+
|
|
118
|
+
# Wait for all modules to be ready
|
|
119
|
+
pending = {m.name for m in ark.modules}
|
|
120
|
+
start = time.time()
|
|
121
|
+
while pending:
|
|
122
|
+
if time.time() - start > timeout:
|
|
123
|
+
raise ContainerManagerError(f"Timeout waiting for modules: {', '.join(sorted(pending))}")
|
|
124
|
+
msg = ready_sub.recv_timeout(ModuleReady, timeout=0.1)
|
|
125
|
+
if msg is not None:
|
|
126
|
+
print(f"Module ready: {msg.module_name}")
|
|
127
|
+
pending.discard(msg.module_name)
|
|
128
|
+
|
|
129
|
+
# Broadcast global start time (2s in future for sync)
|
|
130
|
+
global_start_us = ((now_us() // 1_000_000) + 2) * 1_000_000
|
|
131
|
+
self._comms.declare_publisher(ARK_MODULE_START_PATH).publish(ModuleStart(global_start_time_us=global_start_us))
|
|
132
|
+
return global_start_us
|
|
133
|
+
|
|
134
|
+
def stop(self, timeout: float = 10.0) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Stop all module containers.
|
|
137
|
+
|
|
138
|
+
:param timeout: Timeout in seconds for container stop operation.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
self._stop_all(timeout)
|
|
142
|
+
|
|
143
|
+
def close(self, timeout: float = 10.0) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Close the container manager and clean up resources.
|
|
146
|
+
|
|
147
|
+
Stops all module containers and closes the Zenoh session.
|
|
148
|
+
|
|
149
|
+
:param timeout: Timeout in seconds for container stop operation.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
self._stop_all(timeout)
|
|
153
|
+
self._comms.close()
|
|
154
|
+
|
|
155
|
+
def _launch(
|
|
156
|
+
self,
|
|
157
|
+
module_name: str,
|
|
158
|
+
container_name: str,
|
|
159
|
+
image: str,
|
|
160
|
+
ark_json: str,
|
|
161
|
+
environment: Environment,
|
|
162
|
+
debug: bool,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Launch a single module container.
|
|
166
|
+
|
|
167
|
+
:param module_name: Name of the module.
|
|
168
|
+
:param container_name: Docker container name.
|
|
169
|
+
:param image: Docker image to use.
|
|
170
|
+
:param ark_json: Serialized Ark definition.
|
|
171
|
+
:param environment: Environment (sim or real).
|
|
172
|
+
:param debug: Enable debug mode.
|
|
173
|
+
:raises ContainerManagerError: If container launch fails.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
container = self._client.containers.run(
|
|
178
|
+
image=image,
|
|
179
|
+
name=container_name,
|
|
180
|
+
environment={
|
|
181
|
+
"_MODULE_NAME": module_name,
|
|
182
|
+
"_ARK": ark_json,
|
|
183
|
+
"_ENVIRONMENT": str(environment.value),
|
|
184
|
+
"_DEBUG": str(debug).lower(),
|
|
185
|
+
},
|
|
186
|
+
network_mode="host",
|
|
187
|
+
ipc_mode="host",
|
|
188
|
+
detach=True,
|
|
189
|
+
remove=False,
|
|
190
|
+
)
|
|
191
|
+
self._containers[container_name] = container
|
|
192
|
+
print(f"Launched container: {container_name}")
|
|
193
|
+
except APIError as e:
|
|
194
|
+
raise ContainerManagerError(f"Failed to launch '{container_name}': {e}") from e
|
|
195
|
+
|
|
196
|
+
def _stop_all(self, timeout: float = 10.0) -> None:
|
|
197
|
+
"""
|
|
198
|
+
Stop all Antioch module containers.
|
|
199
|
+
|
|
200
|
+
Finds all containers with the antioch-module- prefix and stops them.
|
|
201
|
+
|
|
202
|
+
:param timeout: Timeout in seconds for stop operation.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
with contextlib.suppress(APIError):
|
|
206
|
+
for container in self._client.containers.list(all=True):
|
|
207
|
+
if container.name and container.name.startswith(CONTAINER_PREFIX):
|
|
208
|
+
print(f"Stopping container: {container.name}")
|
|
209
|
+
self._stop_container(container, timeout)
|
|
210
|
+
|
|
211
|
+
self._containers.clear()
|
|
212
|
+
|
|
213
|
+
def _stop_container(self, container: Container, timeout: float) -> None:
|
|
214
|
+
"""
|
|
215
|
+
Stop and remove a single container.
|
|
216
|
+
|
|
217
|
+
:param container: Docker container to stop.
|
|
218
|
+
:param timeout: Timeout in seconds for stop operation.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
with contextlib.suppress(APIError):
|
|
222
|
+
container.stop(timeout=int(timeout))
|
|
223
|
+
with contextlib.suppress(APIError):
|
|
224
|
+
container.remove(force=True)
|
|
225
|
+
|
|
226
|
+
def _get_image(self, image: str | ModuleImage, environment: Environment) -> str | None:
|
|
227
|
+
"""
|
|
228
|
+
Get the image name for the given environment.
|
|
229
|
+
|
|
230
|
+
:param image: Image specification (string or ModuleImage).
|
|
231
|
+
:param environment: Environment to get image for.
|
|
232
|
+
:return: Image name or None if not available.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
if isinstance(image, str):
|
|
236
|
+
return image
|
|
237
|
+
return image.sim if environment == Environment.SIM else image.real
|
|
238
|
+
|
|
239
|
+
def _get_gar_auth(self) -> dict:
|
|
240
|
+
"""
|
|
241
|
+
Get Google Artifact Registry authentication credentials.
|
|
242
|
+
|
|
243
|
+
:return: Dictionary containing registry host, repository, and access token.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
auth = AuthHandler()
|
|
247
|
+
rome = RomeClient(ANTIOCH_API_URL, auth.get_token())
|
|
248
|
+
return rome.get_gar_token()
|
|
249
|
+
|
|
250
|
+
def _pull_images(self, images: list[str], gar_auth: dict) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Pull container images from the registry.
|
|
253
|
+
|
|
254
|
+
:param images: List of image names to pull.
|
|
255
|
+
:param gar_auth: GAR authentication credentials.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
auth_config = {"username": "oauth2accesstoken", "password": gar_auth["access_token"]}
|
|
259
|
+
for image in set(images):
|
|
260
|
+
print(f"Pulling image: {image}")
|
|
261
|
+
self._client.images.pull(image, auth_config=auth_config)
|