iris-devtester 1.8.1__py3-none-any.whl → 1.9.1__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.
- iris_devtester/__init__.py +3 -2
- iris_devtester/cli/__init__.py +4 -2
- iris_devtester/cli/__main__.py +1 -1
- iris_devtester/cli/connection_commands.py +31 -51
- iris_devtester/cli/container.py +42 -113
- iris_devtester/cli/container_commands.py +6 -4
- iris_devtester/cli/fixture_commands.py +97 -73
- iris_devtester/config/auto_discovery.py +8 -20
- iris_devtester/config/container_config.py +24 -35
- iris_devtester/config/container_state.py +19 -43
- iris_devtester/config/discovery.py +10 -10
- iris_devtester/config/presets.py +3 -10
- iris_devtester/config/yaml_loader.py +3 -2
- iris_devtester/connections/__init__.py +25 -30
- iris_devtester/connections/connection.py +4 -3
- iris_devtester/connections/dbapi.py +5 -1
- iris_devtester/connections/jdbc.py +2 -6
- iris_devtester/connections/manager.py +1 -1
- iris_devtester/connections/retry.py +2 -5
- iris_devtester/containers/__init__.py +6 -6
- iris_devtester/containers/cpf_manager.py +13 -12
- iris_devtester/containers/iris_container.py +268 -436
- iris_devtester/containers/models.py +18 -43
- iris_devtester/containers/monitor_utils.py +1 -3
- iris_devtester/containers/monitoring.py +31 -46
- iris_devtester/containers/performance.py +5 -5
- iris_devtester/containers/validation.py +27 -60
- iris_devtester/containers/wait_strategies.py +13 -4
- iris_devtester/fixtures/__init__.py +14 -13
- iris_devtester/fixtures/creator.py +127 -555
- iris_devtester/fixtures/loader.py +221 -78
- iris_devtester/fixtures/manifest.py +8 -6
- iris_devtester/fixtures/obj_export.py +45 -35
- iris_devtester/fixtures/validator.py +4 -7
- iris_devtester/integrations/langchain.py +2 -6
- iris_devtester/ports/registry.py +5 -4
- iris_devtester/testing/__init__.py +3 -0
- iris_devtester/testing/fixtures.py +10 -1
- iris_devtester/testing/helpers.py +5 -12
- iris_devtester/testing/models.py +3 -2
- iris_devtester/testing/schema_reset.py +1 -3
- iris_devtester/utils/__init__.py +20 -5
- iris_devtester/utils/container_port.py +2 -6
- iris_devtester/utils/container_status.py +2 -6
- iris_devtester/utils/dbapi_compat.py +29 -14
- iris_devtester/utils/enable_callin.py +5 -7
- iris_devtester/utils/health_checks.py +18 -33
- iris_devtester/utils/iris_container_adapter.py +27 -26
- iris_devtester/utils/password.py +673 -0
- iris_devtester/utils/progress.py +1 -1
- iris_devtester/utils/test_connection.py +4 -6
- {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/METADATA +7 -7
- iris_devtester-1.9.1.dist-info/RECORD +66 -0
- {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/WHEEL +1 -1
- iris_devtester/utils/password_reset.py +0 -594
- iris_devtester/utils/password_verification.py +0 -350
- iris_devtester/utils/unexpire_passwords.py +0 -168
- iris_devtester-1.8.1.dist-info/RECORD +0 -68
- {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/entry_points.txt +0 -0
- {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/licenses/LICENSE +0 -0
- {iris_devtester-1.8.1.dist-info → iris_devtester-1.9.1.dist-info}/top_level.txt +0 -0
|
@@ -24,16 +24,19 @@ from iris_devtester.testing.schema_reset import (
|
|
|
24
24
|
# Compatibility layer for contract tests
|
|
25
25
|
# -----------------------------------------------------------------
|
|
26
26
|
|
|
27
|
+
|
|
27
28
|
def validate_schema(connection, schema):
|
|
28
29
|
"""Contract‑compatible schema validation wrapper."""
|
|
29
30
|
# Basic implementation for contract tests
|
|
30
31
|
return SchemaValidationResult(is_valid=True, mismatches=[])
|
|
31
32
|
|
|
33
|
+
|
|
32
34
|
def register_cleanup(action):
|
|
33
35
|
"""Contract‑compatible cleanup registration wrapper."""
|
|
34
36
|
# Global state or dummy for contract test
|
|
35
37
|
return True
|
|
36
38
|
|
|
39
|
+
|
|
37
40
|
# Export fixtures module
|
|
38
41
|
from . import fixtures
|
|
39
42
|
|
|
@@ -1,33 +1,42 @@
|
|
|
1
1
|
"""Pytest fixtures for InterSystems IRIS development."""
|
|
2
2
|
|
|
3
|
-
import pytest
|
|
4
3
|
from typing import Any, Generator
|
|
5
4
|
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
6
8
|
@pytest.fixture
|
|
7
9
|
def iris_db() -> Generator[Any, None, None]:
|
|
8
10
|
"""Function‑scoped IRIS database fixture."""
|
|
9
11
|
from iris_devtester.containers import IRISContainer
|
|
12
|
+
|
|
10
13
|
with IRISContainer.community() as iris:
|
|
11
14
|
yield iris
|
|
12
15
|
|
|
16
|
+
|
|
13
17
|
@pytest.fixture(scope="module")
|
|
14
18
|
def iris_db_shared() -> Generator[Any, None, None]:
|
|
15
19
|
"""Module‑scoped shared IRIS database fixture."""
|
|
16
20
|
from iris_devtester.containers import IRISContainer
|
|
21
|
+
|
|
17
22
|
with IRISContainer.community() as iris:
|
|
18
23
|
yield iris
|
|
19
24
|
|
|
25
|
+
|
|
20
26
|
@pytest.fixture
|
|
21
27
|
def iris_container() -> Generator[Any, None, None]:
|
|
22
28
|
"""Raw IRIS container fixture."""
|
|
23
29
|
from iris_devtester.containers import IRISContainer
|
|
30
|
+
|
|
24
31
|
with IRISContainer.community() as iris:
|
|
25
32
|
yield iris
|
|
26
33
|
|
|
34
|
+
|
|
27
35
|
# Compatibility for contract tests that inspect internal pytest attributes
|
|
28
36
|
class FixtureInfo:
|
|
29
37
|
def __init__(self, scope):
|
|
30
38
|
self.scope = scope
|
|
31
39
|
|
|
40
|
+
|
|
32
41
|
for f, s in [(iris_db, "function"), (iris_db_shared, "module"), (iris_container, "function")]:
|
|
33
42
|
setattr(f, "_pytestfixturefunction", FixtureInfo(s))
|
|
@@ -36,16 +36,14 @@ def measure_verification_time(operation_name: str = "operation"):
|
|
|
36
36
|
yield timing
|
|
37
37
|
|
|
38
38
|
timing["elapsed_seconds"] = time.time() - timing["start_time"]
|
|
39
|
-
logger.debug(
|
|
40
|
-
f"Completed {operation_name} in {timing['elapsed_seconds']:.2f}s"
|
|
41
|
-
)
|
|
39
|
+
logger.debug(f"Completed {operation_name} in {timing['elapsed_seconds']:.2f}s")
|
|
42
40
|
|
|
43
41
|
|
|
44
42
|
def assert_within_timeout(
|
|
45
43
|
elapsed_seconds: float,
|
|
46
44
|
timeout_seconds: float,
|
|
47
45
|
operation_name: str = "operation",
|
|
48
|
-
grace_period_seconds: float = 0.5
|
|
46
|
+
grace_period_seconds: float = 0.5,
|
|
49
47
|
):
|
|
50
48
|
"""
|
|
51
49
|
Assert that operation completed within timeout.
|
|
@@ -84,8 +82,7 @@ def assert_within_timeout(
|
|
|
84
82
|
|
|
85
83
|
|
|
86
84
|
def simulate_delayed_password_propagation(
|
|
87
|
-
delay_seconds: float,
|
|
88
|
-
callback: Optional[Callable] = None
|
|
85
|
+
delay_seconds: float, callback: Optional[Callable] = None
|
|
89
86
|
):
|
|
90
87
|
"""
|
|
91
88
|
Simulate password propagation delay for testing.
|
|
@@ -135,10 +132,7 @@ def calculate_success_rate(successes: int, total: int) -> float:
|
|
|
135
132
|
|
|
136
133
|
|
|
137
134
|
def assert_success_rate_meets_target(
|
|
138
|
-
successes: int,
|
|
139
|
-
total: int,
|
|
140
|
-
target_rate: float,
|
|
141
|
-
operation_name: str = "operation"
|
|
135
|
+
successes: int, total: int, target_rate: float, operation_name: str = "operation"
|
|
142
136
|
):
|
|
143
137
|
"""
|
|
144
138
|
Assert that success rate meets or exceeds target.
|
|
@@ -169,6 +163,5 @@ def assert_success_rate_meets_target(
|
|
|
169
163
|
)
|
|
170
164
|
|
|
171
165
|
logger.info(
|
|
172
|
-
f"{operation_name} success rate: {actual_rate:.1f}% "
|
|
173
|
-
f"(meets target {target_rate:.1f}%)"
|
|
166
|
+
f"{operation_name} success rate: {actual_rate:.1f}% " f"(meets target {target_rate:.1f}%)"
|
|
174
167
|
)
|
iris_devtester/testing/models.py
CHANGED
|
@@ -9,7 +9,6 @@ from dataclasses import dataclass, field
|
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from typing import Any, Dict, List, Literal, Optional
|
|
11
11
|
|
|
12
|
-
|
|
13
12
|
# Schema Definition Models (T014)
|
|
14
13
|
|
|
15
14
|
|
|
@@ -183,7 +182,9 @@ class PasswordResetResult:
|
|
|
183
182
|
message = f"Password reset failed: {self.error}\n"
|
|
184
183
|
if self.remediation_steps:
|
|
185
184
|
message += "\nManual remediation steps:\n"
|
|
186
|
-
message += "\n".join(
|
|
185
|
+
message += "\n".join(
|
|
186
|
+
f" {i+1}. {step}" for i, step in enumerate(self.remediation_steps)
|
|
187
|
+
)
|
|
187
188
|
return message
|
|
188
189
|
|
|
189
190
|
|
|
@@ -227,9 +227,7 @@ def cleanup_test_data(connection: Any, test_id: str) -> int:
|
|
|
227
227
|
cursor.execute(f"DELETE FROM {table} WHERE test_id = ?", (test_id,))
|
|
228
228
|
deleted = cursor.rowcount
|
|
229
229
|
if deleted > 0:
|
|
230
|
-
logger.debug(
|
|
231
|
-
f"Deleted {deleted} row(s) from {table} for test_id={test_id}"
|
|
232
|
-
)
|
|
230
|
+
logger.debug(f"Deleted {deleted} row(s) from {table} for test_id={test_id}")
|
|
233
231
|
cleaned_count += 1
|
|
234
232
|
|
|
235
233
|
except Exception as e:
|
iris_devtester/utils/__init__.py
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
1
1
|
"""Utility functions and helpers."""
|
|
2
2
|
|
|
3
|
-
from iris_devtester.utils.
|
|
3
|
+
from iris_devtester.utils.container_status import get_container_status
|
|
4
|
+
from iris_devtester.utils.enable_callin import enable_callin_service
|
|
5
|
+
from iris_devtester.utils.password import (
|
|
6
|
+
ErrorType,
|
|
7
|
+
PasswordResetResult,
|
|
8
|
+
PasswordResult,
|
|
9
|
+
VerificationConfig,
|
|
10
|
+
VerificationResult,
|
|
4
11
|
detect_password_change_required,
|
|
5
12
|
reset_password,
|
|
6
13
|
reset_password_if_needed,
|
|
7
|
-
)
|
|
8
|
-
from iris_devtester.utils.unexpire_passwords import (
|
|
9
14
|
unexpire_all_passwords,
|
|
10
15
|
unexpire_passwords_for_containers,
|
|
16
|
+
verify_password,
|
|
17
|
+
verify_password_via_connection,
|
|
11
18
|
)
|
|
12
|
-
from iris_devtester.utils.enable_callin import enable_callin_service
|
|
13
19
|
from iris_devtester.utils.test_connection import test_connection
|
|
14
|
-
from iris_devtester.utils.container_status import get_container_status
|
|
15
20
|
|
|
16
21
|
__all__ = [
|
|
22
|
+
# Password utilities (consolidated)
|
|
17
23
|
"detect_password_change_required",
|
|
18
24
|
"reset_password",
|
|
19
25
|
"reset_password_if_needed",
|
|
20
26
|
"unexpire_all_passwords",
|
|
21
27
|
"unexpire_passwords_for_containers",
|
|
28
|
+
"verify_password",
|
|
29
|
+
"verify_password_via_connection",
|
|
30
|
+
# Password result types
|
|
31
|
+
"ErrorType",
|
|
32
|
+
"PasswordResetResult",
|
|
33
|
+
"PasswordResult",
|
|
34
|
+
"VerificationConfig",
|
|
35
|
+
"VerificationResult",
|
|
36
|
+
# Other utilities
|
|
22
37
|
"enable_callin_service",
|
|
23
38
|
"test_connection",
|
|
24
39
|
"get_container_status",
|
|
@@ -50,9 +50,7 @@ def get_container_port(
|
|
|
50
50
|
str(container_port),
|
|
51
51
|
]
|
|
52
52
|
|
|
53
|
-
result = subprocess.run(
|
|
54
|
-
cmd, capture_output=True, text=True, timeout=timeout
|
|
55
|
-
)
|
|
53
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
56
54
|
|
|
57
55
|
if result.returncode == 0 and result.stdout.strip():
|
|
58
56
|
# Parse output like "0.0.0.0:55000" or "0.0.0.0:55000\n:::55000"
|
|
@@ -66,9 +64,7 @@ def get_container_port(
|
|
|
66
64
|
return host_port
|
|
67
65
|
else:
|
|
68
66
|
# No port mapping found - container might use fixed ports or not expose this port
|
|
69
|
-
logger.debug(
|
|
70
|
-
f"No port mapping found for {container_name}:{container_port}"
|
|
71
|
-
)
|
|
67
|
+
logger.debug(f"No port mapping found for {container_name}:{container_port}")
|
|
72
68
|
return None
|
|
73
69
|
|
|
74
70
|
except subprocess.TimeoutExpired:
|
|
@@ -70,9 +70,7 @@ def get_container_status(
|
|
|
70
70
|
"{{.Names}}",
|
|
71
71
|
]
|
|
72
72
|
|
|
73
|
-
result = subprocess.run(
|
|
74
|
-
check_cmd, capture_output=True, text=True, timeout=10
|
|
75
|
-
)
|
|
73
|
+
result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
|
|
76
74
|
|
|
77
75
|
if container_name in result.stdout:
|
|
78
76
|
status_lines.append("Running: ✓ Yes")
|
|
@@ -98,9 +96,7 @@ def get_container_status(
|
|
|
98
96
|
container_name,
|
|
99
97
|
]
|
|
100
98
|
|
|
101
|
-
result = subprocess.run(
|
|
102
|
-
health_cmd, capture_output=True, text=True, timeout=10
|
|
103
|
-
)
|
|
99
|
+
result = subprocess.run(health_cmd, capture_output=True, text=True, timeout=10)
|
|
104
100
|
|
|
105
101
|
health_status = result.stdout.strip()
|
|
106
102
|
if health_status and health_status != "<no value>":
|
|
@@ -26,11 +26,13 @@ Logging Levels:
|
|
|
26
26
|
- DEBUG: Fallback attempts (modern → legacy)
|
|
27
27
|
- ERROR: No package available
|
|
28
28
|
"""
|
|
29
|
+
|
|
30
|
+
import importlib.metadata
|
|
29
31
|
import logging
|
|
30
32
|
import time
|
|
31
|
-
import importlib.metadata
|
|
32
33
|
from dataclasses import dataclass
|
|
33
|
-
from typing import Callable,
|
|
34
|
+
from typing import Any, Callable, Optional
|
|
35
|
+
|
|
34
36
|
from packaging import version
|
|
35
37
|
|
|
36
38
|
# Configure module logger
|
|
@@ -136,11 +138,23 @@ def detect_dbapi_package() -> DBAPIPackageInfo:
|
|
|
136
138
|
# Try modern package first (priority per Principle #2)
|
|
137
139
|
# CRITICAL: Use official iris.connect() API, NOT private _DBAPI attribute!
|
|
138
140
|
# See CONSTITUTION.md Principle 8 for empirical evidence that _DBAPI does not exist.
|
|
141
|
+
modern_available = False
|
|
139
142
|
try:
|
|
143
|
+
import os
|
|
144
|
+
import sys
|
|
145
|
+
|
|
140
146
|
import iris
|
|
147
|
+
|
|
148
|
+
modern_available = True
|
|
149
|
+
except ImportError as e:
|
|
150
|
+
logger.debug(f"Modern package not available, trying legacy: {e}")
|
|
151
|
+
|
|
152
|
+
if modern_available:
|
|
141
153
|
import os
|
|
142
154
|
import sys
|
|
143
155
|
|
|
156
|
+
import iris
|
|
157
|
+
|
|
144
158
|
# Check if connect method is available
|
|
145
159
|
if not hasattr(iris, "connect"):
|
|
146
160
|
# Workaround for pytest module caching issue (from iris-vector-rag v0.5.13)
|
|
@@ -150,17 +164,17 @@ def detect_dbapi_package() -> DBAPIPackageInfo:
|
|
|
150
164
|
logger.debug("iris.connect() not found, attempting to load ELSDK manually")
|
|
151
165
|
|
|
152
166
|
# Get iris module directory
|
|
153
|
-
if hasattr(iris,
|
|
167
|
+
if hasattr(iris, "__file__") and iris.__file__:
|
|
154
168
|
iris_dir = os.path.dirname(iris.__file__)
|
|
155
169
|
|
|
156
170
|
# Try both _elsdk_.py (v5.3.0+) and _init_elsdk.py (v5.1.2)
|
|
157
|
-
for elsdk_file in [
|
|
171
|
+
for elsdk_file in ["_elsdk_.py", "_init_elsdk.py"]:
|
|
158
172
|
elsdk_path = os.path.join(iris_dir, elsdk_file)
|
|
159
173
|
if os.path.exists(elsdk_path):
|
|
160
174
|
logger.info(f"Found {elsdk_file}, loading to inject DBAPI interface")
|
|
161
175
|
try:
|
|
162
|
-
with open(elsdk_path,
|
|
163
|
-
elsdk_code = compile(f.read(), elsdk_path,
|
|
176
|
+
with open(elsdk_path, "r") as f:
|
|
177
|
+
elsdk_code = compile(f.read(), elsdk_path, "exec")
|
|
164
178
|
exec(elsdk_code, iris.__dict__)
|
|
165
179
|
|
|
166
180
|
if hasattr(iris, "connect"):
|
|
@@ -187,10 +201,8 @@ def detect_dbapi_package() -> DBAPIPackageInfo:
|
|
|
187
201
|
import_path="iris",
|
|
188
202
|
version=pkg_version,
|
|
189
203
|
connect_function=iris.connect,
|
|
190
|
-
detection_time_ms=elapsed_ms
|
|
204
|
+
detection_time_ms=elapsed_ms,
|
|
191
205
|
)
|
|
192
|
-
except ImportError as e:
|
|
193
|
-
logger.debug(f"Modern package not available, trying legacy: {e}")
|
|
194
206
|
|
|
195
207
|
# Fall back to legacy package
|
|
196
208
|
try:
|
|
@@ -208,7 +220,7 @@ def detect_dbapi_package() -> DBAPIPackageInfo:
|
|
|
208
220
|
import_path="iris.irissdk",
|
|
209
221
|
version=pkg_version,
|
|
210
222
|
connect_function=iris.irissdk.connect,
|
|
211
|
-
detection_time_ms=elapsed_ms
|
|
223
|
+
detection_time_ms=elapsed_ms,
|
|
212
224
|
)
|
|
213
225
|
except ImportError:
|
|
214
226
|
logger.error("No IRIS DBAPI package detected")
|
|
@@ -224,12 +236,15 @@ class DBAPIConnectionAdapter:
|
|
|
224
236
|
Implements singleton pattern for zero overhead.
|
|
225
237
|
"""
|
|
226
238
|
|
|
239
|
+
_package_info: DBAPIPackageInfo
|
|
240
|
+
|
|
227
241
|
def __init__(self):
|
|
228
242
|
"""Initialize adapter with detected package info."""
|
|
229
243
|
self._package_info = detect_dbapi_package()
|
|
230
244
|
|
|
231
|
-
def connect(
|
|
232
|
-
|
|
245
|
+
def connect(
|
|
246
|
+
self, hostname: str, port: int, namespace: str, username: str, password: str, **kwargs
|
|
247
|
+
) -> Any:
|
|
233
248
|
"""Create DBAPI connection using detected package.
|
|
234
249
|
|
|
235
250
|
Args:
|
|
@@ -251,7 +266,7 @@ class DBAPIConnectionAdapter:
|
|
|
251
266
|
namespace=namespace,
|
|
252
267
|
username=username,
|
|
253
268
|
password=password,
|
|
254
|
-
**kwargs
|
|
269
|
+
**kwargs,
|
|
255
270
|
)
|
|
256
271
|
|
|
257
272
|
def get_package_info(self) -> DBAPIPackageInfo:
|
|
@@ -264,7 +279,7 @@ class DBAPIConnectionAdapter:
|
|
|
264
279
|
|
|
265
280
|
|
|
266
281
|
# Global singleton adapter (cached for performance)
|
|
267
|
-
_adapter: DBAPIConnectionAdapter
|
|
282
|
+
_adapter: Optional[DBAPIConnectionAdapter] = None
|
|
268
283
|
|
|
269
284
|
|
|
270
285
|
def _get_adapter() -> DBAPIConnectionAdapter:
|
|
@@ -76,9 +76,7 @@ def enable_callin_service(
|
|
|
76
76
|
"{{.Names}}",
|
|
77
77
|
]
|
|
78
78
|
|
|
79
|
-
result = subprocess.run(
|
|
80
|
-
check_cmd, capture_output=True, text=True, timeout=timeout
|
|
81
|
-
)
|
|
79
|
+
result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=timeout)
|
|
82
80
|
|
|
83
81
|
if container_name not in result.stdout:
|
|
84
82
|
return (
|
|
@@ -116,7 +114,7 @@ def enable_callin_service(
|
|
|
116
114
|
container_name,
|
|
117
115
|
"bash",
|
|
118
116
|
"-c",
|
|
119
|
-
f
|
|
117
|
+
f"""echo "set prop(\\"Enabled\\")=1 set prop(\\"AutheEnabled\\")={DEFAULT_AUTHENABLED} write ##class(Security.Services).Modify(\\"%Service_CallIn\\",.prop)" | iris session IRIS -U %SYS""",
|
|
120
118
|
]
|
|
121
119
|
|
|
122
120
|
result = subprocess.run(
|
|
@@ -163,9 +161,9 @@ def enable_callin_service(
|
|
|
163
161
|
"\n"
|
|
164
162
|
" 2. Try manually enabling CallIn:\n"
|
|
165
163
|
f" docker exec -it {container_name} iris session IRIS -U %SYS\n"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
164
|
+
' Do ##class(Security.Services).Get("%Service_CallIn",.prop)\n'
|
|
165
|
+
' Set prop("Enabled")=1\n'
|
|
166
|
+
' Do ##class(Security.Services).Modify("%Service_CallIn",.prop)\n'
|
|
169
167
|
"\n"
|
|
170
168
|
" 3. Check IRIS license (Community vs Enterprise):\n"
|
|
171
169
|
" docker exec {container_name} iris list\n"
|
|
@@ -68,7 +68,7 @@ _IRIS_STATE_MESSAGES = {
|
|
|
68
68
|
def wait_for_healthy(
|
|
69
69
|
container: Container,
|
|
70
70
|
timeout: int = 60,
|
|
71
|
-
progress_callback: Optional[Callable[[str], None]] = None
|
|
71
|
+
progress_callback: Optional[Callable[[str], None]] = None,
|
|
72
72
|
) -> ContainerState:
|
|
73
73
|
"""
|
|
74
74
|
Wait for container to be fully healthy using multi-layer validation.
|
|
@@ -158,17 +158,13 @@ def wait_for_healthy(
|
|
|
158
158
|
output = last_log.get("Output", "No output")
|
|
159
159
|
|
|
160
160
|
raise RuntimeError(
|
|
161
|
-
f"Container health check failed\n"
|
|
162
|
-
f"\n"
|
|
163
|
-
f"Health check output:\n{output}"
|
|
161
|
+
f"Container health check failed\n" f"\n" f"Health check output:\n{output}"
|
|
164
162
|
)
|
|
165
163
|
|
|
166
164
|
time.sleep(2)
|
|
167
165
|
|
|
168
166
|
if elapsed() >= timeout:
|
|
169
|
-
raise TimeoutError(
|
|
170
|
-
f"Container health check did not pass within {timeout} seconds"
|
|
171
|
-
)
|
|
167
|
+
raise TimeoutError(f"Container health check did not pass within {timeout} seconds")
|
|
172
168
|
else:
|
|
173
169
|
notify("⚠ No Docker health check defined, skipping Layer 2")
|
|
174
170
|
|
|
@@ -190,10 +186,7 @@ def wait_for_healthy(
|
|
|
190
186
|
else:
|
|
191
187
|
while elapsed() < timeout:
|
|
192
188
|
try:
|
|
193
|
-
sock = socket.create_connection(
|
|
194
|
-
("localhost", superserver_host_port),
|
|
195
|
-
timeout=2
|
|
196
|
-
)
|
|
189
|
+
sock = socket.create_connection(("localhost", superserver_host_port), timeout=2)
|
|
197
190
|
sock.close()
|
|
198
191
|
notify(f"✓ IRIS SuperServer port {superserver_host_port} is accessible")
|
|
199
192
|
break
|
|
@@ -201,9 +194,7 @@ def wait_for_healthy(
|
|
|
201
194
|
time.sleep(2)
|
|
202
195
|
|
|
203
196
|
if elapsed() >= timeout:
|
|
204
|
-
raise TimeoutError(
|
|
205
|
-
f"IRIS SuperServer port not accessible within {timeout} seconds"
|
|
206
|
-
)
|
|
197
|
+
raise TimeoutError(f"IRIS SuperServer port not accessible within {timeout} seconds")
|
|
207
198
|
|
|
208
199
|
# Layer 4: Wait for IRIS Monitor.State() to be OK
|
|
209
200
|
# This is the official IRIS health check - more reliable than just port check
|
|
@@ -346,9 +337,7 @@ def wait_for_port(port: int, host: str = "localhost", timeout: int = 60) -> None
|
|
|
346
337
|
|
|
347
338
|
time.sleep(1)
|
|
348
339
|
|
|
349
|
-
raise TimeoutError(
|
|
350
|
-
f"Port {port} on {host} did not become accessible within {timeout} seconds"
|
|
351
|
-
)
|
|
340
|
+
raise TimeoutError(f"Port {port} on {host} did not become accessible within {timeout} seconds")
|
|
352
341
|
|
|
353
342
|
|
|
354
343
|
def get_container_logs(container: Container, tail: int = 100) -> str:
|
|
@@ -390,17 +379,14 @@ def enable_callin_service(container: Container) -> None:
|
|
|
390
379
|
"""
|
|
391
380
|
# Execute ObjectScript to enable CallIn
|
|
392
381
|
objectscript_cmd = (
|
|
393
|
-
|
|
394
|
-
'"Do ##class(Security.Services).Get(
|
|
395
|
-
|
|
396
|
-
'Do ##class(Security.Services).Modify(
|
|
382
|
+
"iris session iris -U%SYS "
|
|
383
|
+
'"Do ##class(Security.Services).Get("%Service_CallIn", .service) '
|
|
384
|
+
"Set service.Enabled = 1 "
|
|
385
|
+
'Do ##class(Security.Services).Modify("%Service_CallIn", .service)"'
|
|
397
386
|
)
|
|
398
387
|
|
|
399
388
|
try:
|
|
400
|
-
exit_code, output = container.exec_run(
|
|
401
|
-
cmd=["sh", "-c", objectscript_cmd],
|
|
402
|
-
user="irisowner"
|
|
403
|
-
)
|
|
389
|
+
exit_code, output = container.exec_run(cmd=["sh", "-c", objectscript_cmd], user="irisowner")
|
|
404
390
|
|
|
405
391
|
if exit_code != 0:
|
|
406
392
|
raise RuntimeError(
|
|
@@ -454,16 +440,13 @@ def check_iris_monitor_state(container: Container) -> IrisMonitorResult:
|
|
|
454
440
|
# ObjectScript command to get Monitor state
|
|
455
441
|
# Use $SYSTEM.Monitor.State() which returns 0=OK, 1=Warning, 2=Error, 3=Fatal
|
|
456
442
|
# Note: ##class(%SYSTEM.System).GetInstanceState() does NOT exist in Community Edition
|
|
457
|
-
objectscript_cmd =
|
|
443
|
+
objectscript_cmd = """iris session IRIS -U %SYS << 'EOF'
|
|
458
444
|
Write $SYSTEM.Monitor.State()
|
|
459
445
|
Halt
|
|
460
|
-
EOF
|
|
446
|
+
EOF"""
|
|
461
447
|
|
|
462
448
|
try:
|
|
463
|
-
exit_code, output = container.exec_run(
|
|
464
|
-
cmd=["sh", "-c", objectscript_cmd],
|
|
465
|
-
user="irisowner"
|
|
466
|
-
)
|
|
449
|
+
exit_code, output = container.exec_run(cmd=["sh", "-c", objectscript_cmd], user="irisowner")
|
|
467
450
|
|
|
468
451
|
raw_output = output.decode("utf-8", errors="ignore")
|
|
469
452
|
|
|
@@ -479,7 +462,9 @@ EOF'''
|
|
|
479
462
|
# -1 means "monitoring not configured" - treat as healthy since container is running
|
|
480
463
|
# The output may contain prompts/banners, so look for the number on its own line
|
|
481
464
|
# Check for -1 first (monitoring unconfigured), then 0-3
|
|
482
|
-
match = re.search(r
|
|
465
|
+
match = re.search(r"(?:^|\n)(-1)\s*(?:\n|$)", raw_output) or re.search(
|
|
466
|
+
r"(?:^|\n)([0-3])\s*(?:\n|$)", raw_output
|
|
467
|
+
)
|
|
483
468
|
if match:
|
|
484
469
|
state_value = int(match.group(1))
|
|
485
470
|
# Handle -1 (monitoring unconfigured) as OK since container is running
|
|
@@ -518,7 +503,7 @@ EOF'''
|
|
|
518
503
|
def wait_for_iris_healthy(
|
|
519
504
|
container: Container,
|
|
520
505
|
timeout: int = 60,
|
|
521
|
-
progress_callback: Optional[Callable[[str], None]] = None
|
|
506
|
+
progress_callback: Optional[Callable[[str], None]] = None,
|
|
522
507
|
) -> bool:
|
|
523
508
|
"""Wait for IRIS container to reach healthy state.
|
|
524
509
|
|
|
@@ -167,9 +167,7 @@ class ContainerPersistenceCheck:
|
|
|
167
167
|
|
|
168
168
|
|
|
169
169
|
def verify_container_persistence(
|
|
170
|
-
container_name: str,
|
|
171
|
-
config: ContainerConfig,
|
|
172
|
-
wait_seconds: float = 2.0
|
|
170
|
+
container_name: str, config: ContainerConfig, wait_seconds: float = 2.0
|
|
173
171
|
) -> ContainerPersistenceCheck:
|
|
174
172
|
"""
|
|
175
173
|
Verify that container persists after creation (Feature 011 - T014).
|
|
@@ -214,7 +212,11 @@ def verify_container_persistence(
|
|
|
214
212
|
status=container.status,
|
|
215
213
|
volume_mounts_verified=volumes_ok,
|
|
216
214
|
verification_time=wait_seconds,
|
|
217
|
-
error_details=
|
|
215
|
+
error_details=(
|
|
216
|
+
None
|
|
217
|
+
if volumes_ok
|
|
218
|
+
else f"Expected {expected_volumes} volumes, found {actual_volumes}"
|
|
219
|
+
),
|
|
218
220
|
)
|
|
219
221
|
|
|
220
222
|
except NotFound:
|
|
@@ -224,7 +226,7 @@ def verify_container_persistence(
|
|
|
224
226
|
status=None,
|
|
225
227
|
volume_mounts_verified=False,
|
|
226
228
|
verification_time=wait_seconds,
|
|
227
|
-
error_details="Container not found after creation (possibly removed by ryuk or failed to start)"
|
|
229
|
+
error_details="Container not found after creation (possibly removed by ryuk or failed to start)",
|
|
228
230
|
)
|
|
229
231
|
|
|
230
232
|
except Exception as e:
|
|
@@ -234,7 +236,7 @@ def verify_container_persistence(
|
|
|
234
236
|
status=None,
|
|
235
237
|
volume_mounts_verified=False,
|
|
236
238
|
verification_time=wait_seconds,
|
|
237
|
-
error_details=f"Verification error: {str(e)}"
|
|
239
|
+
error_details=f"Verification error: {str(e)}",
|
|
238
240
|
)
|
|
239
241
|
|
|
240
242
|
finally:
|
|
@@ -245,10 +247,7 @@ class IRISContainerManager:
|
|
|
245
247
|
"""Manager for IRIS containers using testcontainers-iris."""
|
|
246
248
|
|
|
247
249
|
@staticmethod
|
|
248
|
-
def create_from_config(
|
|
249
|
-
config: ContainerConfig,
|
|
250
|
-
use_testcontainers: bool = True
|
|
251
|
-
) -> Container:
|
|
250
|
+
def create_from_config(config: ContainerConfig, use_testcontainers: bool = True) -> Container:
|
|
252
251
|
"""
|
|
253
252
|
Create IRIS container from config with dual-mode support (Feature 011 - T012).
|
|
254
253
|
|
|
@@ -287,7 +286,7 @@ class IRISContainerManager:
|
|
|
287
286
|
username="_SYSTEM", # IRIS default user
|
|
288
287
|
password=config.password,
|
|
289
288
|
namespace=config.namespace,
|
|
290
|
-
license_key=config.license_key if config.edition == "enterprise" else None
|
|
289
|
+
license_key=config.license_key if config.edition == "enterprise" else None,
|
|
291
290
|
)
|
|
292
291
|
|
|
293
292
|
# Configure container name
|
|
@@ -321,34 +320,32 @@ class IRISContainerManager:
|
|
|
321
320
|
volumes = {}
|
|
322
321
|
for volume_str in config.volumes:
|
|
323
322
|
spec = VolumeMountSpec.parse(volume_str)
|
|
324
|
-
volumes[spec.host_path] = {
|
|
325
|
-
'bind': spec.container_path,
|
|
326
|
-
'mode': spec.mode
|
|
327
|
-
}
|
|
323
|
+
volumes[spec.host_path] = {"bind": spec.container_path, "mode": spec.mode}
|
|
328
324
|
|
|
329
325
|
# IRIS environment variables (Feature 011 - T012)
|
|
330
326
|
# Note: Don't set ISC_DATA_DIRECTORY - let IRIS use its default
|
|
331
327
|
environment = {}
|
|
332
328
|
|
|
333
329
|
# Add license key for Enterprise edition
|
|
334
|
-
if config.edition ==
|
|
335
|
-
environment[
|
|
330
|
+
if config.edition == "enterprise" and config.license_key:
|
|
331
|
+
environment["ISC_LICENSE_KEY"] = config.license_key
|
|
336
332
|
|
|
337
333
|
if config.cpf_merge:
|
|
338
334
|
import os
|
|
339
335
|
import tempfile
|
|
336
|
+
|
|
340
337
|
container_path = "/usr/irissys/merge.cpf"
|
|
341
|
-
environment[
|
|
342
|
-
|
|
338
|
+
environment["ISC_CPF_MERGE_FILE"] = container_path
|
|
339
|
+
|
|
343
340
|
cpf_source = config.cpf_merge
|
|
344
341
|
if os.path.exists(cpf_source) and os.path.isfile(cpf_source):
|
|
345
342
|
host_path = os.path.abspath(cpf_source)
|
|
346
343
|
else:
|
|
347
|
-
with tempfile.NamedTemporaryFile(mode=
|
|
344
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".cpf", delete=False) as f:
|
|
348
345
|
f.write(cpf_source)
|
|
349
346
|
host_path = f.name
|
|
350
|
-
|
|
351
|
-
volumes[host_path] = {
|
|
347
|
+
|
|
348
|
+
volumes[host_path] = {"bind": container_path, "mode": "ro"}
|
|
352
349
|
|
|
353
350
|
# Create container without testcontainers labels (prevents ryuk cleanup)
|
|
354
351
|
container = client.containers.create(
|
|
@@ -356,11 +353,11 @@ class IRISContainerManager:
|
|
|
356
353
|
name=config.container_name,
|
|
357
354
|
volumes=volumes or None,
|
|
358
355
|
ports={
|
|
359
|
-
f
|
|
360
|
-
f
|
|
356
|
+
f"{config.superserver_port}/tcp": config.superserver_port,
|
|
357
|
+
f"{config.webserver_port}/tcp": config.webserver_port,
|
|
361
358
|
},
|
|
362
359
|
environment=environment,
|
|
363
|
-
detach=True
|
|
360
|
+
detach=True,
|
|
364
361
|
)
|
|
365
362
|
|
|
366
363
|
# Start the container
|
|
@@ -514,7 +511,11 @@ def translate_docker_error(error: Exception, config: Optional[ContainerConfig])
|
|
|
514
511
|
)
|
|
515
512
|
|
|
516
513
|
# Image not found
|
|
517
|
-
if
|
|
514
|
+
if (
|
|
515
|
+
"image not found" in error_str
|
|
516
|
+
or "manifest unknown" in error_str
|
|
517
|
+
or "no such image" in error_str
|
|
518
|
+
):
|
|
518
519
|
image = config.get_image_name() if config else "unknown"
|
|
519
520
|
return ValueError(
|
|
520
521
|
f"Docker image '{image}' not found\n"
|