onec-interactive-runtime-core 0.1.19__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.
- onec_interactive_runtime_core-0.1.19.dist-info/METADATA +12 -0
- onec_interactive_runtime_core-0.1.19.dist-info/RECORD +87 -0
- onec_interactive_runtime_core-0.1.19.dist-info/WHEEL +4 -0
- onec_interactive_runtime_core-0.1.19.dist-info/licenses/COPYRIGHT +9 -0
- onec_interactive_runtime_core-0.1.19.dist-info/licenses/LICENSE +232 -0
- onec_interactive_runtime_core-0.1.19.dist-info/licenses/THIRD_PARTY_NOTICES.txt +32 -0
- onec_runtime/AGENTS.md +7 -0
- onec_runtime/__init__.py +3 -0
- onec_runtime/artifacts.py +59 -0
- onec_runtime/bootstrap.py +310 -0
- onec_runtime/breakpoint_workspace.py +249 -0
- onec_runtime/bsl/__init__.py +139 -0
- onec_runtime/bsl/diagnostics.py +1046 -0
- onec_runtime/bsl/full_ast_worker_projection.py +522 -0
- onec_runtime/bsl/generated_semantic_parser.py +6322 -0
- onec_runtime/bsl/lexer.py +164 -0
- onec_runtime/bsl/module_catalog.py +420 -0
- onec_runtime/bsl/module_delta.py +1079 -0
- onec_runtime/bsl/module_universe.py +1877 -0
- onec_runtime/bsl/notebook_cells.py +232 -0
- onec_runtime/bsl/notebook_method_globals.py +74 -0
- onec_runtime/bsl/notebook_methods.py +175 -0
- onec_runtime/bsl/parser_artifact_identity.py +119 -0
- onec_runtime/bsl/parser_target.py +388 -0
- onec_runtime/bsl/preprocessor.py +16 -0
- onec_runtime/bsl/semantic_lowering.py +1539 -0
- onec_runtime/bsl/source_maps.py +2411 -0
- onec_runtime/bsl/worker_dependency_resolver.py +289 -0
- onec_runtime/bsl/worker_preprocessor.py +247 -0
- onec_runtime/bsl/worker_projection_model.py +137 -0
- onec_runtime/bsl/worker_reload_source_map.py +1697 -0
- onec_runtime/capture.py +113 -0
- onec_runtime/capture_source.py +453 -0
- onec_runtime/compact_table.py +376 -0
- onec_runtime/compact_table_backend.py +500 -0
- onec_runtime/config.py +198 -0
- onec_runtime/configurator_agent.py +520 -0
- onec_runtime/controller_worker.py +400 -0
- onec_runtime/credentials.py +18 -0
- onec_runtime/epf_container.py +196 -0
- onec_runtime/errors.py +205 -0
- onec_runtime/experiment.py +130 -0
- onec_runtime/extension_bundle.py +944 -0
- onec_runtime/extension_lifecycle.py +402 -0
- onec_runtime/extension_state.py +222 -0
- onec_runtime/fault_injection.py +30 -0
- onec_runtime/kernel.py +179 -0
- onec_runtime/lease.py +123 -0
- onec_runtime/observation.py +287 -0
- onec_runtime/performance_profile.py +245 -0
- onec_runtime/privacy.py +263 -0
- onec_runtime/processes.py +265 -0
- onec_runtime/prototype_runtime.py +2644 -0
- onec_runtime/rdbg/__init__.py +1 -0
- onec_runtime/rdbg/models.py +130 -0
- onec_runtime/rdbg/reconnect.py +59 -0
- onec_runtime/rdbg/session.py +913 -0
- onec_runtime/rdbg/transport.py +138 -0
- onec_runtime/rdbg/xml_codec.py +980 -0
- onec_runtime/recovery.py +106 -0
- onec_runtime/recovery_journal.py +79 -0
- onec_runtime/resources/extension/OnecInteractiveRuntime.cfe +0 -0
- onec_runtime/resources/extension/extension-manifest.json +108 -0
- onec_runtime/runtime_api.py +5272 -0
- onec_runtime/runtime_contracts.py +196 -0
- onec_runtime/server_worker.py +2034 -0
- onec_runtime/session.py +2123 -0
- onec_runtime/startup_diagnostics.py +45 -0
- onec_runtime/stop_routing.py +65 -0
- onec_runtime/supervised_processes.py +232 -0
- onec_runtime/supervised_runtime.py +512 -0
- onec_runtime/supervisor.py +363 -0
- onec_runtime/supervisor_model.py +148 -0
- onec_runtime/supervisor_protocol.py +77 -0
- onec_runtime/table_materialization.py +471 -0
- onec_runtime/table_transfer_backend.py +203 -0
- onec_runtime/table_value.py +311 -0
- onec_runtime/toolchain.py +730 -0
- onec_runtime/value_materialization.py +432 -0
- onec_runtime/value_transfer_backend.py +199 -0
- onec_runtime/worker_breakpoints.py +1431 -0
- onec_runtime/worker_epf.py +313 -0
- onec_runtime/worker_epf_template.py +53 -0
- onec_runtime/worker_stage_protocol.py +516 -0
- onec_runtime/worker_universe.py +3772 -0
- onec_runtime_build/__init__.py +1 -0
- onec_runtime_build/parser_target_development.py +116 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""Prepare the product extension through a run-owned loopback Configurator Agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager, suppress
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from threading import Event, Timer
|
|
12
|
+
from time import monotonic, sleep
|
|
13
|
+
from typing import Callable, Iterator
|
|
14
|
+
|
|
15
|
+
import paramiko
|
|
16
|
+
|
|
17
|
+
from onec_runtime.config import RuntimeConfig
|
|
18
|
+
from onec_runtime.errors import ExtensionLifecycleError
|
|
19
|
+
from onec_runtime.extension_bundle import EXTENSION_NAME
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_HOST = "127.0.0.1"
|
|
23
|
+
_PROMPT = b"designer> "
|
|
24
|
+
_START_TIMEOUT_SECONDS = 30.0
|
|
25
|
+
_AUTH_TIMEOUT_SECONDS = 8.0
|
|
26
|
+
_COMMAND_TIMEOUT_SECONDS = 30.0
|
|
27
|
+
_MUTATION_TIMEOUT_SECONDS = 300.0
|
|
28
|
+
_STOP_TIMEOUT_SECONDS = 10.0
|
|
29
|
+
_CLEANUP_RETRY_NOTE = (
|
|
30
|
+
"Configurator Agent resource cleanup is incomplete; retry with exception.retry_cleanup()"
|
|
31
|
+
)
|
|
32
|
+
_DISCONNECT_FAILURE_NOTE = "Configurator Agent infobase disconnect failed"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _DiagnosticTransport(paramiko.Transport):
|
|
36
|
+
"""Retain the Agent's SSH disconnect reason before Paramiko drops it."""
|
|
37
|
+
|
|
38
|
+
disconnect_reason: str | None = None
|
|
39
|
+
|
|
40
|
+
def _parse_disconnect(self, message: paramiko.Message) -> None:
|
|
41
|
+
message.get_int()
|
|
42
|
+
self.disconnect_reason = message.get_text()
|
|
43
|
+
message.rewind()
|
|
44
|
+
super()._parse_disconnect(message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _designer_lock_error(client: paramiko.SSHClient) -> ExtensionLifecycleError | None:
|
|
48
|
+
try:
|
|
49
|
+
transport = client.get_transport()
|
|
50
|
+
reason = getattr(transport, "disconnect_reason", None)
|
|
51
|
+
except Exception:
|
|
52
|
+
return None
|
|
53
|
+
if isinstance(reason, str) and (
|
|
54
|
+
"cannot lock the infobase because it is open in designer" in reason.casefold()
|
|
55
|
+
):
|
|
56
|
+
return ExtensionLifecycleError(
|
|
57
|
+
"Configurator Agent cannot lock the infobase because it is open in Designer; "
|
|
58
|
+
"close the regular Designer session and retry, or use "
|
|
59
|
+
"ExtensionMode.MANUAL for an already prepared extension"
|
|
60
|
+
)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _expose_cleanup_retry(error: BaseException, retry_cleanup: Callable[[], None]) -> None:
|
|
65
|
+
setattr(error, "retry_cleanup", retry_cleanup)
|
|
66
|
+
if _CLEANUP_RETRY_NOTE not in getattr(error, "__notes__", ()):
|
|
67
|
+
error.add_note(_CLEANUP_RETRY_NOTE)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _available_loopback_port() -> int:
|
|
71
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
|
72
|
+
listener.bind((_HOST, 0))
|
|
73
|
+
return int(listener.getsockname()[1])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_prompt(channel: paramiko.Channel, *, timeout: float) -> bytes:
|
|
77
|
+
deadline = monotonic() + timeout
|
|
78
|
+
payload = bytearray()
|
|
79
|
+
while not payload.endswith(_PROMPT):
|
|
80
|
+
remaining = deadline - monotonic()
|
|
81
|
+
if remaining <= 0:
|
|
82
|
+
raise ExtensionLifecycleError("Configurator Agent response timed out")
|
|
83
|
+
channel.settimeout(min(remaining, 1.0))
|
|
84
|
+
try:
|
|
85
|
+
chunk = channel.recv(65536)
|
|
86
|
+
except socket.timeout:
|
|
87
|
+
continue
|
|
88
|
+
if not chunk:
|
|
89
|
+
raise ExtensionLifecycleError("Configurator Agent closed its SSH channel")
|
|
90
|
+
payload.extend(chunk)
|
|
91
|
+
return bytes(payload[: -len(_PROMPT)])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_response(payload: bytes) -> list[dict[str, object]]:
|
|
95
|
+
try:
|
|
96
|
+
result = json.loads(payload.decode("utf-8"))
|
|
97
|
+
except (ValueError, UnicodeError):
|
|
98
|
+
raise ExtensionLifecycleError("Invalid Configurator Agent JSON response") from None
|
|
99
|
+
if not isinstance(result, list) or not result or not all(
|
|
100
|
+
isinstance(item, dict) for item in result
|
|
101
|
+
):
|
|
102
|
+
raise ExtensionLifecycleError("Invalid Configurator Agent response structure")
|
|
103
|
+
if any(item.get("type") == "error" for item in result):
|
|
104
|
+
# Agent error text can echo credentials; identify our command instead.
|
|
105
|
+
raise ExtensionLifecycleError("Configurator Agent rejected the command")
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _command(
|
|
110
|
+
channel: paramiko.Channel, command: str, transcript: list[str],
|
|
111
|
+
*, timeout_s: float | None = None,
|
|
112
|
+
) -> list[dict[str, object]]:
|
|
113
|
+
timeout = _COMMAND_TIMEOUT_SECONDS if timeout_s is None else timeout_s
|
|
114
|
+
transcript.append("> " + command)
|
|
115
|
+
channel.settimeout(timeout)
|
|
116
|
+
channel.sendall((command + "\n").encode("utf-8"))
|
|
117
|
+
try:
|
|
118
|
+
response = _parse_response(
|
|
119
|
+
_read_prompt(channel, timeout=timeout)
|
|
120
|
+
)
|
|
121
|
+
except ExtensionLifecycleError as error:
|
|
122
|
+
transcript.append("< " + str(error))
|
|
123
|
+
raise ExtensionLifecycleError(f"{error}: {command}") from None
|
|
124
|
+
# Do not persist arbitrary server replies, which may contain credentials.
|
|
125
|
+
transcript.append("< response accepted")
|
|
126
|
+
return response
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _properties(response: list[dict[str, object]]) -> dict[str, object]:
|
|
130
|
+
found = [item.get("body") for item in response if item.get("type") == "extension-properties"]
|
|
131
|
+
if (
|
|
132
|
+
len(found) != 1
|
|
133
|
+
or not isinstance(found[0], dict)
|
|
134
|
+
or found[0].get("name") != EXTENSION_NAME
|
|
135
|
+
or type(found[0].get("safe-mode")) is not bool
|
|
136
|
+
or type(found[0].get("unsafe-action-protection")) is not bool
|
|
137
|
+
or found[0].get("active") is not True
|
|
138
|
+
):
|
|
139
|
+
raise ExtensionLifecycleError(
|
|
140
|
+
f"Expected the active {EXTENSION_NAME} extension with explicit safety properties"
|
|
141
|
+
)
|
|
142
|
+
return found[0]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _connect_client(
|
|
146
|
+
config: RuntimeConfig, port: int, process: subprocess.Popen,
|
|
147
|
+
clients: list[paramiko.SSHClient],
|
|
148
|
+
) -> paramiko.SSHClient:
|
|
149
|
+
deadline = monotonic() + _START_TIMEOUT_SECONDS
|
|
150
|
+
while (remaining := deadline - monotonic()) > 0:
|
|
151
|
+
if process.poll() is not None:
|
|
152
|
+
raise ExtensionLifecycleError(
|
|
153
|
+
f"Configurator Agent exited with code {process.returncode}; see agent.log"
|
|
154
|
+
)
|
|
155
|
+
client = paramiko.SSHClient()
|
|
156
|
+
clients.append(client)
|
|
157
|
+
connected = False
|
|
158
|
+
try:
|
|
159
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
160
|
+
client.connect(
|
|
161
|
+
_HOST,
|
|
162
|
+
port=port,
|
|
163
|
+
username=config.username,
|
|
164
|
+
password=config.password,
|
|
165
|
+
look_for_keys=False,
|
|
166
|
+
allow_agent=False,
|
|
167
|
+
timeout=min(remaining, 1.0),
|
|
168
|
+
banner_timeout=min(remaining, 2.0),
|
|
169
|
+
auth_timeout=min(remaining, _AUTH_TIMEOUT_SECONDS),
|
|
170
|
+
transport_factory=_DiagnosticTransport,
|
|
171
|
+
)
|
|
172
|
+
connected = True
|
|
173
|
+
return client
|
|
174
|
+
except paramiko.AuthenticationException:
|
|
175
|
+
if lock_error := _designer_lock_error(client):
|
|
176
|
+
raise lock_error from None
|
|
177
|
+
empty_password = " with an empty password" if not config.password else ""
|
|
178
|
+
manual_hint = (
|
|
179
|
+
" For an already installed and prepared extension, use "
|
|
180
|
+
"extension_mode=ExtensionMode.MANUAL."
|
|
181
|
+
if not config.password else ""
|
|
182
|
+
)
|
|
183
|
+
raise ExtensionLifecycleError(
|
|
184
|
+
"Configurator Agent rejected SSH authentication"
|
|
185
|
+
f"{empty_password}; 1C client login may still succeed "
|
|
186
|
+
f"separately.{manual_hint}"
|
|
187
|
+
) from None
|
|
188
|
+
except (OSError, paramiko.SSHException):
|
|
189
|
+
if lock_error := _designer_lock_error(client):
|
|
190
|
+
raise lock_error from None
|
|
191
|
+
sleep(min(0.1, max(0.0, deadline - monotonic())))
|
|
192
|
+
finally:
|
|
193
|
+
if not connected:
|
|
194
|
+
try:
|
|
195
|
+
client.close()
|
|
196
|
+
except Exception:
|
|
197
|
+
pass # Keep the failed client in the owned cleanup closure.
|
|
198
|
+
else:
|
|
199
|
+
clients.remove(client)
|
|
200
|
+
raise ExtensionLifecycleError(
|
|
201
|
+
"Configurator Agent did not open its loopback SSH endpoint"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _invoke_shell(channel: paramiko.Channel) -> None:
|
|
206
|
+
# Paramiko's shell acknowledgement ignores Channel.settimeout(). Closing
|
|
207
|
+
# the channel releases its waiting event when the peer never acknowledges.
|
|
208
|
+
expired = Event()
|
|
209
|
+
|
|
210
|
+
def expire() -> None:
|
|
211
|
+
expired.set()
|
|
212
|
+
with suppress(Exception):
|
|
213
|
+
channel.close()
|
|
214
|
+
|
|
215
|
+
timer = Timer(_COMMAND_TIMEOUT_SECONDS, expire)
|
|
216
|
+
timer.daemon = True
|
|
217
|
+
timer.start()
|
|
218
|
+
try:
|
|
219
|
+
channel.invoke_shell()
|
|
220
|
+
except Exception:
|
|
221
|
+
if expired.is_set():
|
|
222
|
+
raise ExtensionLifecycleError("Configurator Agent shell request timed out") from None
|
|
223
|
+
raise ExtensionLifecycleError("Configurator Agent could not open its SSH shell") from None
|
|
224
|
+
finally:
|
|
225
|
+
timer.cancel()
|
|
226
|
+
if expired.is_set():
|
|
227
|
+
raise ExtensionLifecycleError("Configurator Agent shell request timed out")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _stop_process(process: subprocess.Popen) -> None:
|
|
231
|
+
if process.poll() is not None:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
process.terminate()
|
|
235
|
+
process.wait(timeout=_STOP_TIMEOUT_SECONDS)
|
|
236
|
+
except Exception:
|
|
237
|
+
if process.poll() is None:
|
|
238
|
+
process.kill()
|
|
239
|
+
process.wait(timeout=_STOP_TIMEOUT_SECONDS)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@contextmanager
|
|
243
|
+
def _owned_agent(config: RuntimeConfig, logs: Path) -> Iterator[paramiko.Channel]:
|
|
244
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
port = _available_loopback_port()
|
|
246
|
+
base = tempfile.TemporaryDirectory(prefix="agent-", dir=logs)
|
|
247
|
+
process = None
|
|
248
|
+
clients: list[paramiko.SSHClient] = []
|
|
249
|
+
channel = None
|
|
250
|
+
primary_error: BaseException | None = None
|
|
251
|
+
|
|
252
|
+
def retry_cleanup() -> None:
|
|
253
|
+
nonlocal channel, process, base
|
|
254
|
+
cleanup_failed = False
|
|
255
|
+
if channel is not None:
|
|
256
|
+
try:
|
|
257
|
+
channel.close()
|
|
258
|
+
except Exception:
|
|
259
|
+
cleanup_failed = True
|
|
260
|
+
else:
|
|
261
|
+
channel = None
|
|
262
|
+
for pending_client in list(clients):
|
|
263
|
+
try:
|
|
264
|
+
pending_client.close()
|
|
265
|
+
except Exception:
|
|
266
|
+
cleanup_failed = True
|
|
267
|
+
else:
|
|
268
|
+
clients.remove(pending_client)
|
|
269
|
+
if process is not None:
|
|
270
|
+
try:
|
|
271
|
+
_stop_process(process)
|
|
272
|
+
except Exception:
|
|
273
|
+
cleanup_failed = True
|
|
274
|
+
else:
|
|
275
|
+
process = None
|
|
276
|
+
# A still-running agent may need its private base until a later retry.
|
|
277
|
+
if base is not None and process is None:
|
|
278
|
+
try:
|
|
279
|
+
base.cleanup()
|
|
280
|
+
except Exception:
|
|
281
|
+
cleanup_failed = True
|
|
282
|
+
else:
|
|
283
|
+
base = None
|
|
284
|
+
if cleanup_failed:
|
|
285
|
+
error = ExtensionLifecycleError("Configurator Agent resource cleanup failed")
|
|
286
|
+
_expose_cleanup_retry(error, retry_cleanup)
|
|
287
|
+
raise error from None
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
command = [
|
|
291
|
+
str(config.designer_exe), "DESIGNER", *config.infobase_arguments,
|
|
292
|
+
"/AgentMode", "/AgentSSHHostKeyAuto", "/AgentBaseDir", base.name,
|
|
293
|
+
"/AgentPort", str(port), "/AgentListenAddress", _HOST,
|
|
294
|
+
"/DisableStartupDialogs", "/DisableStartupMessages",
|
|
295
|
+
"/Out", str(logs / "agent.log"),
|
|
296
|
+
]
|
|
297
|
+
try:
|
|
298
|
+
process = subprocess.Popen(
|
|
299
|
+
command,
|
|
300
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
301
|
+
shell=False, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
302
|
+
)
|
|
303
|
+
except OSError:
|
|
304
|
+
raise ExtensionLifecycleError(
|
|
305
|
+
"Configurator Agent could not start; check the configured 1C executable"
|
|
306
|
+
) from None
|
|
307
|
+
client = _connect_client(config, port, process, clients)
|
|
308
|
+
transport = client.get_transport()
|
|
309
|
+
if transport is None:
|
|
310
|
+
raise ExtensionLifecycleError("Configurator Agent has no SSH transport")
|
|
311
|
+
channel = transport.open_session(timeout=_COMMAND_TIMEOUT_SECONDS)
|
|
312
|
+
channel.settimeout(_COMMAND_TIMEOUT_SECONDS)
|
|
313
|
+
_invoke_shell(channel)
|
|
314
|
+
_read_prompt(channel, timeout=_COMMAND_TIMEOUT_SECONDS)
|
|
315
|
+
yield channel
|
|
316
|
+
except BaseException as error:
|
|
317
|
+
primary_error = error
|
|
318
|
+
raise
|
|
319
|
+
finally:
|
|
320
|
+
if channel is not None:
|
|
321
|
+
with suppress(Exception):
|
|
322
|
+
channel.settimeout(_STOP_TIMEOUT_SECONDS)
|
|
323
|
+
channel.sendall(b"common shutdown\n")
|
|
324
|
+
process.wait(timeout=_STOP_TIMEOUT_SECONDS)
|
|
325
|
+
try:
|
|
326
|
+
retry_cleanup()
|
|
327
|
+
except ExtensionLifecycleError:
|
|
328
|
+
if primary_error is not None:
|
|
329
|
+
_expose_cleanup_retry(primary_error, retry_cleanup)
|
|
330
|
+
else:
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _disable_safe_mode(
|
|
335
|
+
channel: paramiko.Channel, transcript: list[str],
|
|
336
|
+
) -> dict[str, dict[str, object]]:
|
|
337
|
+
get = f"config extensions properties get --extension={EXTENSION_NAME}"
|
|
338
|
+
before = _properties(_command(channel, get, transcript))
|
|
339
|
+
if before["safe-mode"]:
|
|
340
|
+
_command(
|
|
341
|
+
channel,
|
|
342
|
+
f"config extensions properties set --extension={EXTENSION_NAME} --safe-mode=no",
|
|
343
|
+
transcript,
|
|
344
|
+
)
|
|
345
|
+
after = _properties(_command(channel, get, transcript))
|
|
346
|
+
if after["safe-mode"] is not False:
|
|
347
|
+
raise ExtensionLifecycleError("Configurator Agent did not disable safe-mode")
|
|
348
|
+
# JSON comparison also preserves types: Python treats 1 == True.
|
|
349
|
+
if (
|
|
350
|
+
json.dumps(
|
|
351
|
+
{key: value for key, value in before.items() if key != "safe-mode"},
|
|
352
|
+
sort_keys=True,
|
|
353
|
+
)
|
|
354
|
+
!= json.dumps(
|
|
355
|
+
{key: value for key, value in after.items() if key != "safe-mode"},
|
|
356
|
+
sort_keys=True,
|
|
357
|
+
)
|
|
358
|
+
):
|
|
359
|
+
raise ExtensionLifecycleError("Configurator Agent changed other extension properties")
|
|
360
|
+
return {"before": before, "after": after}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class ExtensionAgentEditor:
|
|
364
|
+
"""One connected agent reused across extension mutation and verification."""
|
|
365
|
+
|
|
366
|
+
def __init__(
|
|
367
|
+
self, config: RuntimeConfig, channel: paramiko.Channel, transcript: list[str],
|
|
368
|
+
) -> None:
|
|
369
|
+
self._config = config
|
|
370
|
+
self._channel = channel
|
|
371
|
+
self._transcript = transcript
|
|
372
|
+
self.connected = True
|
|
373
|
+
|
|
374
|
+
def connect(self) -> None:
|
|
375
|
+
if not self.connected:
|
|
376
|
+
_command(self._channel, "common connect-ib", self._transcript)
|
|
377
|
+
self.connected = True
|
|
378
|
+
|
|
379
|
+
def disconnect(self) -> None:
|
|
380
|
+
if self.connected:
|
|
381
|
+
_command(self._channel, "common disconnect-ib", self._transcript)
|
|
382
|
+
self.connected = False
|
|
383
|
+
|
|
384
|
+
@contextmanager
|
|
385
|
+
def designer_access(self) -> Iterator[None]:
|
|
386
|
+
"""Release the infobase lock while a headless Designer dumps XML/CFE."""
|
|
387
|
+
self.disconnect()
|
|
388
|
+
primary_error: BaseException | None = None
|
|
389
|
+
try:
|
|
390
|
+
yield
|
|
391
|
+
except BaseException as error:
|
|
392
|
+
primary_error = error
|
|
393
|
+
raise
|
|
394
|
+
finally:
|
|
395
|
+
try:
|
|
396
|
+
self.connect()
|
|
397
|
+
except BaseException:
|
|
398
|
+
if primary_error is None:
|
|
399
|
+
raise
|
|
400
|
+
primary_error.add_note("Configurator Agent infobase reconnect failed")
|
|
401
|
+
|
|
402
|
+
def load_cfe(self, source: Path) -> None:
|
|
403
|
+
if not self.connected:
|
|
404
|
+
raise ExtensionLifecycleError("Configurator Agent is disconnected")
|
|
405
|
+
_command(
|
|
406
|
+
self._channel,
|
|
407
|
+
f'config load-cfg --file="{source.resolve()}" --extension={EXTENSION_NAME}',
|
|
408
|
+
self._transcript,
|
|
409
|
+
timeout_s=_MUTATION_TIMEOUT_SECONDS,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def apply(self) -> None:
|
|
413
|
+
if not self.connected:
|
|
414
|
+
raise ExtensionLifecycleError("Configurator Agent is disconnected")
|
|
415
|
+
dynamic = "--dynamic-disable " if self._config.uses_external_infobase else ""
|
|
416
|
+
_command(
|
|
417
|
+
self._channel,
|
|
418
|
+
f"config update-db-cfg {dynamic}--extension={EXTENSION_NAME}",
|
|
419
|
+
self._transcript,
|
|
420
|
+
timeout_s=_MUTATION_TIMEOUT_SECONDS,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def disable_safe_mode(self) -> dict[str, dict[str, object]]:
|
|
424
|
+
if not self.connected:
|
|
425
|
+
raise ExtensionLifecycleError("Configurator Agent is disconnected")
|
|
426
|
+
return _disable_safe_mode(self._channel, self._transcript)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@contextmanager
|
|
430
|
+
def edit_extension(
|
|
431
|
+
config: RuntimeConfig, logs: Path,
|
|
432
|
+
) -> Iterator[ExtensionAgentEditor]:
|
|
433
|
+
"""Keep one owned Agent for mutation, release it for Designer XML dumps."""
|
|
434
|
+
logs = Path(logs).resolve()
|
|
435
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
436
|
+
transcript: list[str] = []
|
|
437
|
+
primary_error: BaseException | None = None
|
|
438
|
+
try:
|
|
439
|
+
with _owned_agent(config, logs) as channel:
|
|
440
|
+
_command(channel, "options set --output-format=json", transcript)
|
|
441
|
+
_command(channel, "common connect-ib", transcript)
|
|
442
|
+
editor = ExtensionAgentEditor(config, channel, transcript)
|
|
443
|
+
try:
|
|
444
|
+
yield editor
|
|
445
|
+
except BaseException as error:
|
|
446
|
+
primary_error = error
|
|
447
|
+
raise
|
|
448
|
+
finally:
|
|
449
|
+
try:
|
|
450
|
+
editor.disconnect()
|
|
451
|
+
except BaseException:
|
|
452
|
+
if primary_error is None:
|
|
453
|
+
raise
|
|
454
|
+
primary_error.add_note(_DISCONNECT_FAILURE_NOTE)
|
|
455
|
+
finally:
|
|
456
|
+
try:
|
|
457
|
+
(logs / "commands.log").write_text("\n".join(transcript) + "\n", encoding="utf-8")
|
|
458
|
+
except OSError:
|
|
459
|
+
if primary_error is None:
|
|
460
|
+
raise ExtensionLifecycleError("Configurator Agent command log could not be written") from None
|
|
461
|
+
primary_error.add_note("Configurator Agent command log could not be written")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def prepare_extension(config: RuntimeConfig, logs: Path) -> dict[str, dict[str, object]]:
|
|
465
|
+
"""Disable only the verified product extension's safe mode and read it back.
|
|
466
|
+
|
|
467
|
+
The lifecycle caller must verify the packaged artifact's permanent identity
|
|
468
|
+
and fingerprints under its infobase lock before calling this function.
|
|
469
|
+
"""
|
|
470
|
+
logs = Path(logs).resolve()
|
|
471
|
+
transcript: list[str] = []
|
|
472
|
+
primary_error: BaseException | None = None
|
|
473
|
+
try:
|
|
474
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
475
|
+
with _owned_agent(config, logs) as channel:
|
|
476
|
+
_command(channel, "options set --output-format=json", transcript)
|
|
477
|
+
_command(channel, "common connect-ib", transcript)
|
|
478
|
+
connected_error: BaseException | None = None
|
|
479
|
+
try:
|
|
480
|
+
return _disable_safe_mode(channel, transcript)
|
|
481
|
+
except BaseException as error:
|
|
482
|
+
connected_error = error
|
|
483
|
+
raise
|
|
484
|
+
finally:
|
|
485
|
+
try:
|
|
486
|
+
_command(channel, "common disconnect-ib", transcript)
|
|
487
|
+
except Exception:
|
|
488
|
+
if connected_error is None:
|
|
489
|
+
raise
|
|
490
|
+
connected_error.add_note(_DISCONNECT_FAILURE_NOTE)
|
|
491
|
+
except ExtensionLifecycleError as error:
|
|
492
|
+
primary_error = error
|
|
493
|
+
raise
|
|
494
|
+
except Exception as error:
|
|
495
|
+
primary_error = ExtensionLifecycleError(
|
|
496
|
+
"Configurator Agent preparation failed; see commands.log"
|
|
497
|
+
)
|
|
498
|
+
# Preserve our cleanup evidence without copying untrusted exception text.
|
|
499
|
+
for note in getattr(error, "__notes__", ()):
|
|
500
|
+
if note in (_CLEANUP_RETRY_NOTE, _DISCONNECT_FAILURE_NOTE):
|
|
501
|
+
primary_error.add_note(note)
|
|
502
|
+
retry_cleanup = getattr(error, "retry_cleanup", None)
|
|
503
|
+
if callable(retry_cleanup):
|
|
504
|
+
_expose_cleanup_retry(primary_error, retry_cleanup)
|
|
505
|
+
raise primary_error from None
|
|
506
|
+
except BaseException as error:
|
|
507
|
+
primary_error = error
|
|
508
|
+
raise
|
|
509
|
+
finally:
|
|
510
|
+
try:
|
|
511
|
+
(logs / "commands.log").write_text("\n".join(transcript) + "\n", encoding="utf-8")
|
|
512
|
+
except OSError:
|
|
513
|
+
message = "Configurator Agent command log could not be written"
|
|
514
|
+
if primary_error is not None:
|
|
515
|
+
primary_error.add_note(message)
|
|
516
|
+
else:
|
|
517
|
+
raise ExtensionLifecycleError(message) from None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
__all__ = ["ExtensionAgentEditor", "edit_extension", "prepare_extension"]
|