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
|
@@ -1,487 +1,319 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Enhanced IRIS container wrapper.
|
|
3
|
-
|
|
4
|
-
Extends testcontainers-iris-python with automatic connection management,
|
|
5
|
-
password reset, and better wait strategies.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import hashlib
|
|
9
1
|
import logging
|
|
10
2
|
import os
|
|
11
|
-
import platform as platform_module
|
|
12
3
|
import subprocess
|
|
13
4
|
import time
|
|
14
|
-
from typing import Any, Optional,
|
|
15
|
-
|
|
16
|
-
from iris_devtester.config
|
|
17
|
-
from iris_devtester.connections
|
|
18
|
-
from iris_devtester.utils.password_reset import reset_password_if_needed
|
|
19
|
-
from iris_devtester.containers.wait_strategies import IRISReadyWaitStrategy
|
|
20
|
-
from iris_devtester.containers.monitoring import (
|
|
21
|
-
MonitoringPolicy,
|
|
22
|
-
configure_monitoring,
|
|
23
|
-
disable_monitoring,
|
|
24
|
-
)
|
|
25
|
-
from iris_devtester.containers.performance import get_resource_metrics
|
|
26
|
-
|
|
27
|
-
if TYPE_CHECKING:
|
|
28
|
-
from iris_devtester.ports.registry import PortRegistry
|
|
29
|
-
from iris_devtester.containers.models import HealthCheckLevel, ValidationResult
|
|
30
|
-
from iris_devtester.config.container_config import ContainerConfig
|
|
5
|
+
from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union
|
|
6
|
+
|
|
7
|
+
from iris_devtester.config import IRISConfig, discover_config
|
|
8
|
+
from iris_devtester.connections import get_connection
|
|
31
9
|
|
|
32
10
|
logger = logging.getLogger(__name__)
|
|
33
11
|
|
|
34
|
-
try:
|
|
35
|
-
from testcontainers.iris import IRISContainer as BaseIRISContainer
|
|
36
|
-
HAS_TESTCONTAINERS_IRIS = True
|
|
37
|
-
except ImportError:
|
|
38
|
-
logger.warning("testcontainers-iris-python not installed.")
|
|
39
|
-
HAS_TESTCONTAINERS_IRIS = False
|
|
40
12
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
13
|
+
# Single base class definition to satisfy LSP
|
|
14
|
+
class _IRISMockContainer:
|
|
15
|
+
def __init__(self, image: str = "", **kwargs):
|
|
16
|
+
self.image = image
|
|
17
|
+
self._container = None
|
|
18
|
+
|
|
19
|
+
def start(self):
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def stop(self, *args, **kwargs):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def get_container_host_ip(self) -> str:
|
|
26
|
+
return "localhost"
|
|
27
|
+
|
|
28
|
+
def get_exposed_port(self, port: int) -> int:
|
|
29
|
+
return port
|
|
30
|
+
|
|
31
|
+
def with_env(self, key: str, value: str):
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __enter__(self):
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(self, *args):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def get_container_name(self) -> str:
|
|
41
|
+
return "iris_db"
|
|
42
|
+
|
|
51
43
|
|
|
44
|
+
# Select the base class. We use Any type to bypass strict type check on the class itself.
|
|
45
|
+
IRISBase: Any = _IRISMockContainer
|
|
52
46
|
|
|
47
|
+
# Check for testcontainers
|
|
48
|
+
HAS_TESTCONTAINERS = False
|
|
49
|
+
try:
|
|
50
|
+
from testcontainers.iris import IRISContainer as _ActualBase
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def with_name(self, name: str): return self
|
|
59
|
-
def get_container_host_ip(self): return "localhost"
|
|
60
|
-
def get_exposed_port(self, port: int): return port
|
|
52
|
+
IRISBase = _ActualBase
|
|
53
|
+
HAS_TESTCONTAINERS = True
|
|
54
|
+
except ImportError:
|
|
55
|
+
pass
|
|
61
56
|
|
|
62
57
|
|
|
63
|
-
class IRISContainer(
|
|
64
|
-
"""
|
|
58
|
+
class IRISContainer(IRISBase):
|
|
59
|
+
"""
|
|
60
|
+
Enhanced IRIS container with automatic connection and password management.
|
|
61
|
+
"""
|
|
65
62
|
|
|
66
63
|
def __init__(
|
|
67
64
|
self,
|
|
68
65
|
image: str = "intersystemsdc/iris-community:latest",
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
username: str = "SuperUser",
|
|
67
|
+
password: str = "SYS",
|
|
68
|
+
namespace: str = "USER",
|
|
72
69
|
**kwargs,
|
|
73
70
|
):
|
|
74
|
-
if not
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# Extract known args for BaseIRISContainer if it's the mock one
|
|
78
|
-
base_kwargs = {}
|
|
79
|
-
if not HAS_TESTCONTAINERS_IRIS:
|
|
80
|
-
base_kwargs = {k: v for k, v in kwargs.items() if k in ["username", "password", "namespace"]}
|
|
81
|
-
|
|
71
|
+
if not HAS_TESTCONTAINERS:
|
|
72
|
+
logger.warning("testcontainers not installed. Functionality will be limited.")
|
|
73
|
+
|
|
82
74
|
super().__init__(image=image, **kwargs)
|
|
75
|
+
self._username = username
|
|
76
|
+
self._password = password
|
|
77
|
+
self._namespace = namespace
|
|
83
78
|
self._connection = None
|
|
84
|
-
self._config = None
|
|
85
79
|
self._callin_enabled = False
|
|
80
|
+
self._password_preconfigured = False
|
|
86
81
|
self._is_attached = False
|
|
87
|
-
self.
|
|
88
|
-
self.
|
|
89
|
-
self._preferred_port = preferred_port
|
|
90
|
-
self._cpf_manager = None
|
|
91
|
-
self._cpf_merge_path = None
|
|
92
|
-
self._project_path = project_path or os.getcwd()
|
|
93
|
-
self._container_name = "iris_container"
|
|
94
|
-
self._username = kwargs.get("username", "SuperUser")
|
|
95
|
-
self._password = kwargs.get("password", "SYS")
|
|
96
|
-
self._namespace = kwargs.get("namespace", "USER")
|
|
97
|
-
|
|
98
|
-
def get_assigned_port(self) -> int:
|
|
99
|
-
"""Get the port assigned to this container."""
|
|
100
|
-
if os.environ.get("IRIS_TEST_MODE"):
|
|
101
|
-
return self._port_assignment.port if self._port_assignment else 1972
|
|
102
|
-
|
|
103
|
-
if self._port_assignment:
|
|
104
|
-
return self._port_assignment.port
|
|
105
|
-
return int(self.get_exposed_port(1972))
|
|
106
|
-
|
|
107
|
-
def get_test_namespace(self) -> str:
|
|
108
|
-
"""Generate a unique test namespace."""
|
|
109
|
-
import uuid
|
|
110
|
-
ns = f"TEST_{str(uuid.uuid4())[:8].upper()}"
|
|
111
|
-
self.execute_objectscript(f'Set status = ##class(Config.Namespaces).Create("{ns}",.p)')
|
|
112
|
-
return ns
|
|
82
|
+
self._container_name: Optional[str] = kwargs.get("name")
|
|
83
|
+
self._config: Optional[IRISConfig] = None
|
|
113
84
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if os.path.exists(path_or_content) and os.path.isfile(path_or_content):
|
|
123
|
-
self._cpf_merge_path = os.path.abspath(path_or_content)
|
|
124
|
-
else:
|
|
125
|
-
if self._cpf_manager is None:
|
|
126
|
-
self._cpf_manager = TempCPFManager()
|
|
127
|
-
self._cpf_merge_path = self._cpf_manager.create_temp_cpf(path_or_content)
|
|
128
|
-
|
|
129
|
-
container_path = "/usr/irissys/merge.cpf"
|
|
130
|
-
self.with_env("ISC_CPF_MERGE_FILE", container_path)
|
|
131
|
-
self.with_volume_mapping(self._cpf_merge_path, container_path, "ro")
|
|
132
|
-
return self
|
|
85
|
+
# Standard attributes used by fixtures
|
|
86
|
+
# IMPORTANT: self.port must remain the INTERNAL container port (1972)
|
|
87
|
+
# for testcontainers' get_exposed_port() to work correctly.
|
|
88
|
+
# Use self._mapped_port for the host-side mapped port.
|
|
89
|
+
self.host = "localhost"
|
|
90
|
+
self.port = 1972 # Internal container port - DO NOT CHANGE
|
|
91
|
+
self._mapped_port: Optional[int] = None # Host-side mapped port
|
|
133
92
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
self._port_assignment = self._port_registry.assign_port(
|
|
138
|
-
project_path=self._project_path, preferred_port=self._preferred_port
|
|
139
|
-
)
|
|
140
|
-
assigned_port = self._port_assignment.port
|
|
141
|
-
if self._config:
|
|
142
|
-
self._config.port = assigned_port
|
|
143
|
-
|
|
144
|
-
self.with_bind_ports(1972, assigned_port)
|
|
145
|
-
# Ensure assigned_port is set on self for base class
|
|
146
|
-
self.port = assigned_port
|
|
147
|
-
project_hash = hashlib.md5(self._project_path.encode()).hexdigest()[:8]
|
|
148
|
-
container_name = f"iris_{project_hash}_{assigned_port}"
|
|
149
|
-
self._port_assignment.container_name = container_name
|
|
150
|
-
self.with_name(container_name)
|
|
151
|
-
self._container_name = container_name
|
|
152
|
-
|
|
153
|
-
try:
|
|
154
|
-
if HAS_TESTCONTAINERS_IRIS and not os.environ.get("IRIS_TEST_MODE"):
|
|
155
|
-
result = super().start()
|
|
156
|
-
else:
|
|
157
|
-
logger.info("Mock IRIS container started (test mode)")
|
|
158
|
-
result = self
|
|
159
|
-
except Exception as e:
|
|
160
|
-
logger.error(f"Failed to start container: {e}")
|
|
161
|
-
raise
|
|
162
|
-
|
|
163
|
-
# Only wait if we actually have a running container and haven't already waited
|
|
164
|
-
# testcontainers.iris.IRISContainer already waits for "Enabling logons"
|
|
165
|
-
if HAS_TESTCONTAINERS_IRIS and not os.environ.get("IRIS_TEST_MODE"):
|
|
166
|
-
# We skip wait_for_ready() here because super().start() already waited
|
|
167
|
-
# but we still need to perform the initial password reset hardening
|
|
168
|
-
config = self.get_config()
|
|
169
|
-
from iris_devtester.utils.password_reset import reset_password
|
|
170
|
-
try:
|
|
171
|
-
reset_password(
|
|
172
|
-
container_name=self.get_container_name(),
|
|
173
|
-
username=config.username,
|
|
174
|
-
new_password=config.password,
|
|
175
|
-
hostname=None,
|
|
176
|
-
port=config.port,
|
|
177
|
-
namespace=config.namespace,
|
|
178
|
-
timeout=10 # Short timeout for auto-start
|
|
179
|
-
)
|
|
180
|
-
except Exception as e:
|
|
181
|
-
logger.debug(f"Initial password reset failed (non-critical): {e}")
|
|
182
|
-
|
|
183
|
-
return result
|
|
184
|
-
|
|
185
|
-
if self._config:
|
|
186
|
-
self._config.host = self.get_container_host_ip()
|
|
187
|
-
if self._port_registry:
|
|
188
|
-
self._config.port = self._port_assignment.port
|
|
189
|
-
else:
|
|
190
|
-
self._config.port = int(self.get_exposed_port(self.port or 1972))
|
|
191
|
-
|
|
192
|
-
return result
|
|
193
|
-
|
|
194
|
-
def stop(self, *args, **kwargs):
|
|
195
|
-
"""Stop IRIS container and release resources."""
|
|
196
|
-
try:
|
|
197
|
-
super().stop(*args, **kwargs)
|
|
198
|
-
finally:
|
|
199
|
-
if self._port_registry and self._port_assignment:
|
|
200
|
-
try:
|
|
201
|
-
self._port_registry.release_port(self._project_path)
|
|
202
|
-
except Exception as e:
|
|
203
|
-
logger.warning(f"Failed to release port assignment: {e}")
|
|
204
|
-
if self._cpf_manager:
|
|
205
|
-
self._cpf_manager.cleanup()
|
|
93
|
+
# Pre-configuration fields (Feature 001)
|
|
94
|
+
self._preconfigure_password: Optional[str] = None
|
|
95
|
+
self._preconfigure_username: Optional[str] = None
|
|
206
96
|
|
|
207
97
|
@classmethod
|
|
208
|
-
def community(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
**kwargs,
|
|
214
|
-
) -> "IRISContainer":
|
|
215
|
-
"""Create Community Edition IRIS container."""
|
|
216
|
-
if "image" not in kwargs:
|
|
98
|
+
def community(cls, image: Optional[str] = None, **kwargs) -> "IRISContainer":
|
|
99
|
+
"""Create a Community Edition container."""
|
|
100
|
+
if image is None:
|
|
101
|
+
import platform as platform_module
|
|
102
|
+
|
|
217
103
|
if platform_module.machine() == "arm64":
|
|
218
|
-
|
|
104
|
+
image = "containers.intersystems.com/intersystems/iris-community:2025.1"
|
|
219
105
|
else:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
container = cls(
|
|
223
|
-
username=username,
|
|
224
|
-
password=password,
|
|
225
|
-
namespace=namespace,
|
|
226
|
-
**kwargs
|
|
227
|
-
)
|
|
106
|
+
image = "intersystemsdc/iris-community:latest"
|
|
107
|
+
return cls(image=image, **kwargs)
|
|
228
108
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
container_name=container.get_container_name()
|
|
236
|
-
)
|
|
109
|
+
@classmethod
|
|
110
|
+
def enterprise(cls, license_key: str, image: Optional[str] = None, **kwargs) -> "IRISContainer":
|
|
111
|
+
"""Create an Enterprise Edition container."""
|
|
112
|
+
if image is None:
|
|
113
|
+
image = "containers.intersystems.com/intersystems/iris:latest"
|
|
114
|
+
container = cls(image=image, **kwargs)
|
|
237
115
|
return container
|
|
238
116
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if config_dict is None: return None
|
|
246
|
-
return IRISConfig(
|
|
247
|
-
host=config_dict.get("host", "localhost"),
|
|
248
|
-
port=config_dict.get("port", 1972),
|
|
249
|
-
namespace=config_dict.get("namespace", "USER"),
|
|
250
|
-
username=config_dict.get("username", "_SYSTEM"),
|
|
251
|
-
password=config_dict.get("password", "SYS"),
|
|
252
|
-
)
|
|
117
|
+
def with_name(self, name: str) -> "IRISContainer":
|
|
118
|
+
"""Set the container name."""
|
|
119
|
+
self._container_name = name
|
|
120
|
+
if hasattr(self, "with_kwargs"):
|
|
121
|
+
self.with_kwargs(name=name)
|
|
122
|
+
return self
|
|
253
123
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
username: str = "SuperUser",
|
|
260
|
-
password: str = "SYS",
|
|
261
|
-
**kwargs,
|
|
262
|
-
) -> "IRISContainer":
|
|
263
|
-
"""Create Enterprise Edition IRIS container."""
|
|
264
|
-
license_key = license_key or os.environ.get("IRIS_LICENSE_KEY")
|
|
265
|
-
if license_key is None:
|
|
266
|
-
raise ValueError("Enterprise Edition requires license key")
|
|
267
|
-
|
|
268
|
-
if platform_module.machine() == "arm64":
|
|
269
|
-
image = "containers.intersystems.com/intersystems/iris-arm64:2025.1"
|
|
270
|
-
else:
|
|
271
|
-
image = "intersystemsdc/iris:latest"
|
|
272
|
-
|
|
273
|
-
container = cls(
|
|
274
|
-
image=image,
|
|
275
|
-
username=username,
|
|
276
|
-
password=password,
|
|
277
|
-
namespace=namespace,
|
|
278
|
-
**kwargs
|
|
279
|
-
)
|
|
280
|
-
container.with_env("ISC_LICENSE_KEY", license_key)
|
|
281
|
-
container._config = IRISConfig(
|
|
282
|
-
host="localhost",
|
|
283
|
-
port=1972,
|
|
284
|
-
namespace=namespace,
|
|
285
|
-
username=username,
|
|
286
|
-
password=password,
|
|
287
|
-
container_name=container.get_container_name()
|
|
288
|
-
)
|
|
289
|
-
return container
|
|
124
|
+
def get_container_name(self) -> str:
|
|
125
|
+
"""Get the actual container name."""
|
|
126
|
+
# Priority 1: Explicit name set by with_name()
|
|
127
|
+
if self._container_name:
|
|
128
|
+
return self._container_name
|
|
290
129
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if container_name not in result.stdout:
|
|
298
|
-
raise ValueError(f"Container '{container_name}' not found")
|
|
299
|
-
|
|
300
|
-
port_cmd = ["docker", "port", container_name, "1972"]
|
|
301
|
-
result = subprocess.run(port_cmd, capture_output=True, text=True, timeout=10)
|
|
302
|
-
exposed_port = int(result.stdout.strip().split(":")[-1]) if result.returncode == 0 and result.stdout.strip() else 1972
|
|
303
|
-
|
|
304
|
-
instance = cls.__new__(cls)
|
|
305
|
-
instance._connection = None
|
|
306
|
-
instance._callin_enabled = False
|
|
307
|
-
instance._is_attached = True
|
|
308
|
-
instance._config = IRISConfig(
|
|
309
|
-
host="localhost",
|
|
310
|
-
port=exposed_port,
|
|
311
|
-
namespace="USER",
|
|
312
|
-
username="SuperUser",
|
|
313
|
-
password="SYS",
|
|
314
|
-
container_name=container_name
|
|
315
|
-
)
|
|
316
|
-
instance._container_name = container_name
|
|
317
|
-
return instance
|
|
130
|
+
# Priority 2: Get from actual Docker container (after start)
|
|
131
|
+
try:
|
|
132
|
+
if hasattr(self, "_container") and self._container is not None:
|
|
133
|
+
return str(self._container.name)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
318
136
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
137
|
+
# Priority 3: Try parent class method (testcontainers might have one)
|
|
138
|
+
try:
|
|
139
|
+
parent_name = super().get_container_name()
|
|
140
|
+
if parent_name and parent_name != "iris_db":
|
|
141
|
+
return str(parent_name)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
# Fallback - but this is problematic if container isn't started yet
|
|
146
|
+
return "iris_db"
|
|
147
|
+
|
|
148
|
+
def execute_objectscript(self, script: str, namespace: Optional[str] = None) -> str:
|
|
149
|
+
"""Execute ObjectScript in the container."""
|
|
323
150
|
container_name = self.get_container_name()
|
|
324
|
-
|
|
325
|
-
if enable_callin and not self._callin_enabled:
|
|
326
|
-
self.enable_callin_service()
|
|
151
|
+
ns = namespace or self._namespace
|
|
327
152
|
|
|
328
|
-
|
|
329
|
-
unexpire_all_passwords(container_name)
|
|
153
|
+
cmd = ["docker", "exec", "-i", container_name, "iris", "session", "IRIS", "-U", ns]
|
|
330
154
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
reset_password(
|
|
335
|
-
container_name=container_name,
|
|
336
|
-
username=config.username,
|
|
337
|
-
new_password=config.password,
|
|
338
|
-
hostname=config.host,
|
|
339
|
-
port=config.port,
|
|
340
|
-
namespace=config.namespace,
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
cmd, input=f"{script}\nHalt\n".encode("utf-8"), capture_output=True, timeout=30
|
|
341
157
|
)
|
|
342
158
|
|
|
343
|
-
|
|
344
|
-
|
|
159
|
+
if result.returncode != 0:
|
|
160
|
+
raise RuntimeError(f"OS failed: {result.stderr.decode()}")
|
|
345
161
|
|
|
346
|
-
|
|
347
|
-
"""Get connection configuration."""
|
|
348
|
-
if self._config is None: self._config = IRISConfig()
|
|
349
|
-
try:
|
|
350
|
-
if HAS_TESTCONTAINERS_IRIS and hasattr(self, "get_container_host_ip"):
|
|
351
|
-
self._config = IRISConfig(
|
|
352
|
-
host=self.get_container_host_ip(),
|
|
353
|
-
port=int(self.get_exposed_port(1972)),
|
|
354
|
-
namespace=self._config.namespace,
|
|
355
|
-
username=self._config.username,
|
|
356
|
-
password=self._config.password,
|
|
357
|
-
container_name=self.get_container_name()
|
|
358
|
-
)
|
|
359
|
-
elif self._is_attached:
|
|
360
|
-
self._config.container_name = self.get_container_name()
|
|
361
|
-
except Exception as e:
|
|
362
|
-
logger.debug(f"Could not update config: {e}")
|
|
363
|
-
return self._config
|
|
162
|
+
return result.stdout.decode("utf-8", errors="replace")
|
|
364
163
|
|
|
365
|
-
def
|
|
366
|
-
"""
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
try:
|
|
370
|
-
ready = strategy.wait_until_ready(
|
|
371
|
-
config.host,
|
|
372
|
-
config.port,
|
|
373
|
-
timeout,
|
|
374
|
-
container_name=self.get_container_name()
|
|
375
|
-
)
|
|
376
|
-
if not ready: return False
|
|
377
|
-
|
|
378
|
-
from iris_devtester.utils.password_reset import reset_password
|
|
379
|
-
# Convert result to bool to satisfy type checker
|
|
380
|
-
result = reset_password(
|
|
381
|
-
container_name=self.get_container_name(),
|
|
382
|
-
username=config.username,
|
|
383
|
-
new_password=config.password,
|
|
384
|
-
hostname=None,
|
|
385
|
-
port=config.port,
|
|
386
|
-
namespace=config.namespace
|
|
387
|
-
)
|
|
388
|
-
if hasattr(result, "success"):
|
|
389
|
-
return bool(getattr(result, "success"))
|
|
390
|
-
return bool(result[0])
|
|
391
|
-
except (TimeoutError, IndexError, TypeError): return False
|
|
392
|
-
|
|
393
|
-
def reset_password(self, username: str = "_SYSTEM", new_password: str = "SYS") -> bool:
|
|
394
|
-
"""Reset user password."""
|
|
395
|
-
from iris_devtester.utils.password_reset import reset_password
|
|
396
|
-
config = self.get_config()
|
|
397
|
-
result = reset_password(
|
|
398
|
-
container_name=self.get_container_name(),
|
|
399
|
-
username=username,
|
|
400
|
-
new_password=new_password,
|
|
401
|
-
hostname=config.host,
|
|
402
|
-
port=config.port,
|
|
403
|
-
namespace=config.namespace
|
|
404
|
-
)
|
|
405
|
-
# Handle both Tuple[bool, str] and PasswordResetResult
|
|
406
|
-
if hasattr(result, "success"):
|
|
407
|
-
success = bool(getattr(result, "success"))
|
|
408
|
-
elif isinstance(result, (list, tuple)):
|
|
409
|
-
success = bool(result[0])
|
|
410
|
-
else:
|
|
411
|
-
success = False
|
|
412
|
-
|
|
413
|
-
if success: config.password = new_password
|
|
414
|
-
return success
|
|
164
|
+
def enable_callin_service(self) -> bool:
|
|
165
|
+
"""Enable the CallIn service (required for DBAPI)."""
|
|
166
|
+
if self._callin_enabled:
|
|
167
|
+
return True
|
|
415
168
|
|
|
416
|
-
|
|
417
|
-
"""Get current container name."""
|
|
418
|
-
if hasattr(self, "_is_attached") and self._is_attached: return self._container_name
|
|
419
|
-
if HAS_TESTCONTAINERS_IRIS:
|
|
420
|
-
try:
|
|
421
|
-
wrapped = self.get_wrapped_container()
|
|
422
|
-
if wrapped and hasattr(wrapped, "name"):
|
|
423
|
-
return str(wrapped.name)
|
|
424
|
-
except Exception: pass
|
|
425
|
-
return "iris_container"
|
|
426
|
-
|
|
427
|
-
def get_project_path(self) -> Optional[str]:
|
|
428
|
-
"""Get project path if port registry is used."""
|
|
429
|
-
if self._port_registry:
|
|
430
|
-
return self._project_path
|
|
431
|
-
return None
|
|
169
|
+
from iris_devtester.utils.enable_callin import enable_callin_service
|
|
432
170
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
cmd = ["docker", "exec", "-u", "root", container_name, "sh", "-c", f'iris session IRIS -U %SYS << "EOF"\n{script}\nEOF']
|
|
440
|
-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
441
|
-
if result.returncode == 0 and "OK" in result.stdout:
|
|
442
|
-
self._callin_enabled = True
|
|
443
|
-
return True
|
|
171
|
+
success, msg = enable_callin_service(self.get_container_name())
|
|
172
|
+
if success:
|
|
173
|
+
self._callin_enabled = True
|
|
174
|
+
return True
|
|
175
|
+
else:
|
|
176
|
+
logger.error(f"Failed to enable CallIn: {msg}")
|
|
444
177
|
return False
|
|
445
|
-
except Exception: return False
|
|
446
178
|
|
|
447
179
|
def check_callin_enabled(self) -> bool:
|
|
448
180
|
"""Check if CallIn is enabled."""
|
|
449
181
|
try:
|
|
450
|
-
script = 'Do ##class(Security.Services).Get("%Service_CallIn",.
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
is_enabled
|
|
454
|
-
|
|
182
|
+
script = 'Do ##class(Security.Services).Get("%Service_CallIn",.p) Write "ENABLED:",p("Enabled")'
|
|
183
|
+
output = self.execute_objectscript(script, namespace="%SYS")
|
|
184
|
+
is_enabled = "ENABLED:1" in output
|
|
185
|
+
if is_enabled:
|
|
186
|
+
self._callin_enabled = True
|
|
455
187
|
return is_enabled
|
|
456
|
-
except
|
|
457
|
-
|
|
458
|
-
def execute_objectscript(self, code: str, namespace: Optional[str] = None) -> str:
|
|
459
|
-
"""Execute ObjectScript code."""
|
|
460
|
-
ns = namespace or self.get_config().namespace
|
|
461
|
-
if "Halt" not in code: code += "\nHalt"
|
|
462
|
-
cmd = ["docker", "exec", self.get_container_name(), "sh", "-c", f'iris session IRIS -U {ns} << "EOF"\n{code}\nEOF']
|
|
463
|
-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
464
|
-
if result.returncode != 0: raise RuntimeError(f"OS failed: {result.stderr}")
|
|
465
|
-
return result.stdout
|
|
466
|
-
|
|
467
|
-
def validate(self, level: Any = None) -> Any:
|
|
468
|
-
"""Validate container health."""
|
|
469
|
-
from iris_devtester.containers.models import HealthCheckLevel
|
|
470
|
-
from iris_devtester.containers.validation import validate_container
|
|
471
|
-
return validate_container(container_name=self.get_container_name(), level=level or HealthCheckLevel.STANDARD, docker_client=None)
|
|
472
|
-
|
|
473
|
-
def assert_healthy(self, level: Any = None):
|
|
474
|
-
"""Raise if not healthy."""
|
|
475
|
-
res = self.validate(level=level)
|
|
476
|
-
if not res.success: raise RuntimeError(res.format_message())
|
|
188
|
+
except:
|
|
189
|
+
return False
|
|
477
190
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
191
|
+
def get_test_namespace(self, prefix: str = "TEST") -> str:
|
|
192
|
+
"""Generate a unique test namespace with its own database."""
|
|
193
|
+
import uuid
|
|
194
|
+
|
|
195
|
+
ns = f"{prefix}_{str(uuid.uuid4())[:8].upper()}"
|
|
196
|
+
db_dir = f"/usr/irissys/mgr/db_{ns.lower()}"
|
|
197
|
+
|
|
198
|
+
script = f"""
|
|
199
|
+
Set ns="{ns}"
|
|
200
|
+
Set dbDir="{db_dir}"
|
|
201
|
+
If '##class(%File).DirectoryExists(dbDir) Do ##class(%File).CreateDirectoryChain(dbDir)
|
|
202
|
+
Set db=##class(SYS.Database).%New() Set db.Directory=dbDir Do db.%Save()
|
|
203
|
+
Do ##class(Config.Databases).Create(ns,dbDir)
|
|
204
|
+
Set p("Globals")=ns,p("Routines")=ns Do ##class(Config.Namespaces).Create(ns,.p)
|
|
205
|
+
Write "SUCCESS" Halt
|
|
206
|
+
"""
|
|
207
|
+
self.execute_objectscript(script, namespace="%SYS")
|
|
208
|
+
return ns
|
|
209
|
+
|
|
210
|
+
def delete_namespace(self, namespace: str):
|
|
211
|
+
"""Delete a namespace and its associated database files cleanly."""
|
|
212
|
+
script = f"""
|
|
213
|
+
Set ns="{namespace}"
|
|
214
|
+
Do ##class(Config.Namespaces).Delete(ns)
|
|
215
|
+
If ##class(Config.Databases).Get(ns,.p) {{
|
|
216
|
+
Set dir = p("Directory")
|
|
217
|
+
Do ##class(SYS.Database).DismountDatabase(dir)
|
|
218
|
+
Do ##class(Config.Databases).Delete(ns)
|
|
219
|
+
Do ##class(%File).RemoveDirectoryTree(dir)
|
|
220
|
+
}}
|
|
221
|
+
Write "SUCCESS" Halt
|
|
222
|
+
"""
|
|
223
|
+
self.execute_objectscript(script, namespace="%SYS")
|
|
224
|
+
|
|
225
|
+
def get_config(self) -> IRISConfig:
|
|
226
|
+
"""Get connection configuration."""
|
|
227
|
+
if self._config is None:
|
|
228
|
+
self._config = IRISConfig(
|
|
229
|
+
username=self._username,
|
|
230
|
+
password=self._password,
|
|
231
|
+
namespace=self._namespace,
|
|
232
|
+
container_name=self.get_container_name(),
|
|
233
|
+
)
|
|
234
|
+
config = self._config
|
|
235
|
+
try:
|
|
236
|
+
# Get host and mapped port from testcontainers
|
|
237
|
+
# IMPORTANT: self.port must remain 1972 (internal port) for get_exposed_port() to work
|
|
238
|
+
self.host = self.get_container_host_ip()
|
|
239
|
+
self._mapped_port = int(self.get_exposed_port(1972)) # Use internal port to get mapping
|
|
240
|
+
config.host = self.host
|
|
241
|
+
config.port = (
|
|
242
|
+
self._mapped_port
|
|
243
|
+
) # Config uses the host-mapped port for connections
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
return config
|
|
247
|
+
|
|
248
|
+
def get_mapped_port(self, internal_port: int = 1972) -> int:
|
|
249
|
+
"""Get the host-side mapped port for a given internal container port.
|
|
250
|
+
|
|
251
|
+
This is a convenience wrapper around get_exposed_port() that ensures
|
|
252
|
+
we always pass the internal port (not the host port).
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
internal_port: The port inside the container (default: 1972 for IRIS superserver)
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
The host-side port that maps to the internal port
|
|
259
|
+
"""
|
|
260
|
+
if self._mapped_port is not None and internal_port == 1972:
|
|
261
|
+
return self._mapped_port
|
|
262
|
+
return int(self.get_exposed_port(internal_port))
|
|
263
|
+
|
|
264
|
+
def get_connection(self, enable_callin: bool = True) -> Any:
|
|
265
|
+
"""Get database connection."""
|
|
266
|
+
if self._connection is not None:
|
|
267
|
+
return self._connection
|
|
268
|
+
|
|
269
|
+
if enable_callin:
|
|
270
|
+
self.enable_callin_service()
|
|
271
|
+
|
|
272
|
+
from iris_devtester.utils.password import unexpire_all_passwords
|
|
273
|
+
|
|
274
|
+
unexpire_all_passwords(self.get_container_name())
|
|
275
|
+
|
|
276
|
+
config = self.get_config()
|
|
277
|
+
from iris_devtester.connections.connection import get_connection as get_modern_connection
|
|
278
|
+
|
|
279
|
+
self._connection = get_modern_connection(config)
|
|
280
|
+
return self._connection
|
|
281
|
+
|
|
282
|
+
def with_preconfigured_password(self, password: str) -> "IRISContainer":
|
|
283
|
+
"""Set password for pre-configuration."""
|
|
284
|
+
if not password:
|
|
285
|
+
raise ValueError("Password cannot be empty")
|
|
286
|
+
self._preconfigure_password = password
|
|
287
|
+
self._password = password
|
|
288
|
+
return self
|
|
289
|
+
|
|
290
|
+
def with_credentials(self, username: str, password: str) -> "IRISContainer":
|
|
291
|
+
"""Set credentials for pre-configuration."""
|
|
292
|
+
if not username:
|
|
293
|
+
raise ValueError("Username cannot be empty")
|
|
294
|
+
if not password:
|
|
295
|
+
raise ValueError("Password cannot be empty")
|
|
296
|
+
self._preconfigure_username = username
|
|
297
|
+
self._preconfigure_password = password
|
|
298
|
+
self._username = username
|
|
299
|
+
self._password = password
|
|
300
|
+
return self
|
|
301
|
+
|
|
302
|
+
def start(self) -> "IRISContainer":
|
|
303
|
+
"""Start container with pre-config support."""
|
|
304
|
+
if self._preconfigure_password:
|
|
305
|
+
self.with_env("IRIS_PASSWORD", self._preconfigure_password)
|
|
306
|
+
if self._preconfigure_username:
|
|
307
|
+
self.with_env("IRIS_USERNAME", self._preconfigure_username)
|
|
308
|
+
|
|
309
|
+
super().start()
|
|
310
|
+
# Ensure host/port are updated after start
|
|
311
|
+
self.get_config()
|
|
312
|
+
self._password_preconfigured = True
|
|
313
|
+
return self
|
|
314
|
+
|
|
315
|
+
def wait_for_ready(self, timeout: int = 60) -> bool:
|
|
316
|
+
"""Wait for IRIS to be ready."""
|
|
317
|
+
# Simple wait for prototype
|
|
318
|
+
time.sleep(15)
|
|
319
|
+
return True
|