iris-pex-embedded-python 3.7.2b2__py3-none-any.whl → 4.0.0__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.
- iop/__init__.py +276 -18
- iop/__main__.py +3 -3
- iop/cli/__init__.py +1 -0
- iop/cli/formatting.py +45 -0
- iop/{_cli.py → cli/main.py} +238 -216
- iop/cli/parser.py +165 -0
- iop/cli/types.py +73 -0
- iop/cls/IOP/Common.cls +11 -2
- iop/cls/IOP/PrivateSession/Duplex.cls +1 -1
- iop/cls/IOP/Service/Remote/Rest/v1.cls +152 -5
- iop/cls/IOP/Utils.cls +814 -7
- iop/cls/IOP/Wrapper.cls +1 -1
- iop/components/__init__.py +16 -0
- iop/{_async_request.py → components/async_request.py} +7 -7
- iop/components/business_host.py +363 -0
- iop/components/business_operation.py +84 -0
- iop/{_business_process.py → components/business_process.py} +145 -125
- iop/components/business_service.py +106 -0
- iop/{_common.py → components/common.py} +160 -164
- iop/{_debugpy.py → components/debugpy.py} +11 -11
- iop/{_generator_request.py → components/generator_request.py} +4 -4
- iop/{_inbound_adapter.py → components/inbound_adapter.py} +10 -18
- iop/{_log_manager.py → components/log_manager.py} +1 -1
- iop/{_outbound_adapter.py → components/outbound_adapter.py} +1 -1
- iop/components/polling_business_service.py +48 -0
- iop/{_private_session_duplex.py → components/private_session_duplex.py} +7 -9
- iop/{_private_session_process.py → components/private_session_process.py} +2 -2
- iop/{_settings.py → components/settings.py} +23 -3
- iop/messages/__init__.py +7 -0
- iop/messages/base.py +61 -0
- iop/{_decorators.py → messages/decorators.py} +8 -2
- iop/messages/dispatch.py +351 -0
- iop/{_persistent_message.py → messages/persistent.py} +24 -19
- iop/{_serialization.py → messages/serialization.py} +34 -21
- iop/{_message_validator.py → messages/validation.py} +15 -13
- iop/migration/__init__.py +1 -0
- iop/migration/io.py +61 -0
- iop/migration/manifest.py +534 -0
- iop/migration/plans.py +80 -0
- iop/migration/utils.py +1126 -0
- iop/production/__init__.py +55 -0
- iop/production/actions.py +525 -0
- iop/production/common.py +103 -0
- iop/production/component.py +234 -0
- iop/production/declarations.py +190 -0
- iop/production/declarative.py +214 -0
- iop/production/diff.py +327 -0
- iop/production/import_.py +296 -0
- iop/production/inspection.py +92 -0
- iop/production/model.py +1143 -0
- iop/production/planning.py +678 -0
- iop/production/reconstruction.py +341 -0
- iop/production/rendering.py +610 -0
- iop/production/runtime.py +91 -0
- iop/production/source_inference.py +729 -0
- iop/production/types.py +546 -0
- iop/production/validation.py +452 -0
- iop/runtime/__init__.py +1 -0
- iop/runtime/director.py +415 -0
- iop/runtime/environment.py +59 -0
- iop/{_iris.py → runtime/iris.py} +2 -2
- iop/runtime/local.py +145 -0
- iop/{_director_protocol.py → runtime/protocol.py} +25 -8
- iop/runtime/remote/__init__.py +12 -0
- iop/runtime/remote/client.py +90 -0
- iop/runtime/remote/director.py +283 -0
- iop/runtime/remote/migration.py +57 -0
- iop/runtime/remote/settings.py +69 -0
- iop/runtime/remote/setup.py +72 -0
- {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/METADATA +11 -2
- iris_pex_embedded_python-4.0.0.dist-info/RECORD +99 -0
- iris_pex_embedded_python-4.0.0.dist-info/entry_points.txt +2 -0
- iris_pex_embedded_python-4.0.0.dist-info/top_level.txt +1 -0
- grongier/__init__.py +0 -0
- grongier/cls/Grongier/PEX/BusinessOperation.cls +0 -8
- grongier/cls/Grongier/PEX/BusinessProcess.cls +0 -13
- grongier/cls/Grongier/PEX/BusinessService.cls +0 -8
- grongier/cls/Grongier/PEX/Common.cls +0 -10
- grongier/cls/Grongier/PEX/Director.cls +0 -10
- grongier/cls/Grongier/PEX/Duplex/Operation.cls +0 -4
- grongier/cls/Grongier/PEX/Duplex/Process.cls +0 -13
- grongier/cls/Grongier/PEX/Duplex/Service.cls +0 -4
- grongier/cls/Grongier/PEX/InboundAdapter.cls +0 -8
- grongier/cls/Grongier/PEX/Message.cls +0 -13
- grongier/cls/Grongier/PEX/OutboundAdapter.cls +0 -8
- grongier/cls/Grongier/PEX/PickleMessage.cls +0 -13
- grongier/cls/Grongier/PEX/PrivateSession/Duplex.cls +0 -8
- grongier/cls/Grongier/PEX/PrivateSession/Message/Ack.cls +0 -14
- grongier/cls/Grongier/PEX/PrivateSession/Message/Poll.cls +0 -14
- grongier/cls/Grongier/PEX/PrivateSession/Message/Start.cls +0 -14
- grongier/cls/Grongier/PEX/PrivateSession/Message/Stop.cls +0 -14
- grongier/cls/Grongier/PEX/Test.cls +0 -10
- grongier/cls/Grongier/PEX/Utils.cls +0 -10
- grongier/cls/Grongier/Service/WSGI.cls +0 -4
- grongier/pex/__init__.py +0 -75
- grongier/pex/__main__.py +0 -4
- grongier/pex/_business_host.py +0 -1
- grongier/pex/_cli.py +0 -4
- grongier/pex/_common.py +0 -1
- grongier/pex/_director.py +0 -1
- grongier/pex/_utils.py +0 -1
- grongier/pex/wsgi/handlers.py +0 -104
- iop/_business_host.py +0 -322
- iop/_business_operation.py +0 -77
- iop/_business_service.py +0 -67
- iop/_director.py +0 -333
- iop/_dispatch.py +0 -194
- iop/_local.py +0 -106
- iop/_message.py +0 -45
- iop/_polling_business_service.py +0 -6
- iop/_remote.py +0 -473
- iop/_utils.py +0 -912
- iop/cls/IOP/Service/WSGI.cls +0 -310
- iop/wsgi/handlers.py +0 -112
- iris_pex_embedded_python-3.7.2b2.dist-info/RECORD +0 -96
- iris_pex_embedded_python-3.7.2b2.dist-info/entry_points.txt +0 -2
- iris_pex_embedded_python-3.7.2b2.dist-info/top_level.txt +0 -2
- {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/WHEEL +0 -0
- {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .diff import _connection_signature, _item_signature, _production_signature
|
|
14
|
+
from .types import ProductionDiffEntry
|
|
15
|
+
|
|
16
|
+
SAFE = "safe"
|
|
17
|
+
DESTRUCTIVE = "destructive"
|
|
18
|
+
UNSUPPORTED = "unsupported"
|
|
19
|
+
|
|
20
|
+
_ITEM_FIELD_OPS = {
|
|
21
|
+
"category",
|
|
22
|
+
"pool_size",
|
|
23
|
+
"enabled",
|
|
24
|
+
"foreground",
|
|
25
|
+
"comment",
|
|
26
|
+
"log_trace_events",
|
|
27
|
+
"schedule",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ProductionPlanOperation:
|
|
33
|
+
"""One granular operation in a production change plan."""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
op_type: str
|
|
37
|
+
action: str
|
|
38
|
+
kind: str
|
|
39
|
+
path: str
|
|
40
|
+
before: Any = None
|
|
41
|
+
after: Any = None
|
|
42
|
+
risk: str = SAFE
|
|
43
|
+
reason: str = ""
|
|
44
|
+
detail: str = ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def supported(self) -> bool:
|
|
48
|
+
return self.risk != UNSUPPORTED
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def allowed_by_default(self) -> bool:
|
|
52
|
+
return self.risk == SAFE
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def requires_allow_destructive(self) -> bool:
|
|
56
|
+
return self.risk == DESTRUCTIVE
|
|
57
|
+
|
|
58
|
+
def can_apply(self, *, allow_destructive: bool = False) -> bool:
|
|
59
|
+
if not self.supported:
|
|
60
|
+
return False
|
|
61
|
+
if self.allowed_by_default:
|
|
62
|
+
return True
|
|
63
|
+
return bool(allow_destructive and self.requires_allow_destructive)
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
data: dict[str, Any] = {
|
|
67
|
+
"id": self.id,
|
|
68
|
+
"op_type": self.op_type,
|
|
69
|
+
"action": self.action,
|
|
70
|
+
"kind": self.kind,
|
|
71
|
+
"path": self.path,
|
|
72
|
+
"risk": self.risk,
|
|
73
|
+
"allowed_by_default": self.allowed_by_default,
|
|
74
|
+
}
|
|
75
|
+
if self.before is not None:
|
|
76
|
+
data["before"] = self.before
|
|
77
|
+
if self.after is not None:
|
|
78
|
+
data["after"] = self.after
|
|
79
|
+
if self.reason:
|
|
80
|
+
data["reason"] = self.reason
|
|
81
|
+
if self.detail:
|
|
82
|
+
data["detail"] = self.detail
|
|
83
|
+
data.update(_operation_target_data(self))
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dict(cls, data: dict[str, Any]) -> ProductionPlanOperation:
|
|
88
|
+
return cls(
|
|
89
|
+
id=str(data["id"]),
|
|
90
|
+
op_type=str(data["op_type"]),
|
|
91
|
+
action=str(data.get("action", "")),
|
|
92
|
+
kind=str(data.get("kind", "")),
|
|
93
|
+
path=str(data.get("path", "")),
|
|
94
|
+
before=data.get("before"),
|
|
95
|
+
after=data.get("after"),
|
|
96
|
+
risk=str(data.get("risk", SAFE)),
|
|
97
|
+
reason=str(data.get("reason", "")),
|
|
98
|
+
detail=str(data.get("detail", "")),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def to_text(self) -> str:
|
|
102
|
+
suffix = f" [{self.risk}]"
|
|
103
|
+
reason = f": {self.reason}" if self.reason else ""
|
|
104
|
+
return f"{self.id} {self.op_type} {self.path}{suffix}{reason}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class ProductionChangePlan:
|
|
109
|
+
"""Versioned desired-vs-current production change plan."""
|
|
110
|
+
|
|
111
|
+
id: str
|
|
112
|
+
production_name: str
|
|
113
|
+
namespace: str = ""
|
|
114
|
+
created_at: str = ""
|
|
115
|
+
source_fingerprint: str = ""
|
|
116
|
+
desired_fingerprint: str = ""
|
|
117
|
+
operations: tuple[ProductionPlanOperation, ...] = ()
|
|
118
|
+
warnings: tuple[str, ...] = ()
|
|
119
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def has_changes(self) -> bool:
|
|
123
|
+
return bool(self.operations)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def safe_operations(self) -> tuple[ProductionPlanOperation, ...]:
|
|
127
|
+
return tuple(op for op in self.operations if op.allowed_by_default)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def blocked_operations(self) -> tuple[ProductionPlanOperation, ...]:
|
|
131
|
+
return tuple(op for op in self.operations if not op.allowed_by_default)
|
|
132
|
+
|
|
133
|
+
def operations_for_apply(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
allow_destructive: bool = False,
|
|
137
|
+
) -> tuple[ProductionPlanOperation, ...]:
|
|
138
|
+
return tuple(
|
|
139
|
+
op for op in self.operations if op.can_apply(allow_destructive=allow_destructive)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def to_dict(self) -> dict[str, Any]:
|
|
143
|
+
data: dict[str, Any] = {
|
|
144
|
+
"id": self.id,
|
|
145
|
+
"production": self.production_name,
|
|
146
|
+
"namespace": self.namespace,
|
|
147
|
+
"created_at": self.created_at,
|
|
148
|
+
"source_fingerprint": self.source_fingerprint,
|
|
149
|
+
"desired_fingerprint": self.desired_fingerprint,
|
|
150
|
+
"has_changes": self.has_changes,
|
|
151
|
+
"operations": [op.to_dict() for op in self.operations],
|
|
152
|
+
}
|
|
153
|
+
if self.warnings:
|
|
154
|
+
data["warnings"] = list(self.warnings)
|
|
155
|
+
if self.metadata:
|
|
156
|
+
data["metadata"] = dict(self.metadata)
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_dict(cls, data: dict[str, Any]) -> ProductionChangePlan:
|
|
161
|
+
return cls(
|
|
162
|
+
id=str(data["id"]),
|
|
163
|
+
production_name=str(data.get("production") or data.get("production_name")),
|
|
164
|
+
namespace=str(data.get("namespace") or ""),
|
|
165
|
+
created_at=str(data.get("created_at") or ""),
|
|
166
|
+
source_fingerprint=str(data.get("source_fingerprint") or ""),
|
|
167
|
+
desired_fingerprint=str(data.get("desired_fingerprint") or ""),
|
|
168
|
+
operations=tuple(
|
|
169
|
+
ProductionPlanOperation.from_dict(op)
|
|
170
|
+
for op in data.get("operations", ())
|
|
171
|
+
),
|
|
172
|
+
warnings=tuple(str(item) for item in data.get("warnings", ())),
|
|
173
|
+
metadata=dict(data.get("metadata") or {}),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def to_text(self) -> str:
|
|
177
|
+
lines = [
|
|
178
|
+
f"Production change plan: {self.production_name}",
|
|
179
|
+
f" id: {self.id}",
|
|
180
|
+
]
|
|
181
|
+
if self.namespace:
|
|
182
|
+
lines.append(f" namespace: {self.namespace}")
|
|
183
|
+
if not self.operations:
|
|
184
|
+
lines.append(" no changes")
|
|
185
|
+
else:
|
|
186
|
+
lines.append(" operations:")
|
|
187
|
+
lines.extend(f" {op.to_text()}" for op in self.operations)
|
|
188
|
+
if self.warnings:
|
|
189
|
+
lines.append(" warnings:")
|
|
190
|
+
lines.extend(f" {warning}" for warning in self.warnings)
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
def save(self, path: str | os.PathLike[str]) -> None:
|
|
194
|
+
destination = Path(path)
|
|
195
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
destination.write_text(
|
|
197
|
+
json.dumps(self.to_dict(), indent=2, sort_keys=True),
|
|
198
|
+
encoding="utf-8",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def load(cls, path: str | os.PathLike[str]) -> ProductionChangePlan:
|
|
203
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
204
|
+
return cls.from_dict(data)
|
|
205
|
+
|
|
206
|
+
def __str__(self) -> str:
|
|
207
|
+
return self.to_text()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class ProductionApplyResult:
|
|
212
|
+
"""Result returned after applying a production change plan."""
|
|
213
|
+
|
|
214
|
+
plan_id: str
|
|
215
|
+
production_name: str
|
|
216
|
+
backup_path: str = ""
|
|
217
|
+
operations: tuple[dict[str, Any], ...] = ()
|
|
218
|
+
updated_runtime: bool = False
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def applied(self) -> int:
|
|
222
|
+
return sum(1 for op in self.operations if op.get("status") == "applied")
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def skipped(self) -> int:
|
|
226
|
+
return sum(1 for op in self.operations if op.get("status") == "skipped")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def failed(self) -> int:
|
|
230
|
+
return sum(1 for op in self.operations if op.get("status") == "failed")
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def success(self) -> bool:
|
|
234
|
+
return self.failed == 0
|
|
235
|
+
|
|
236
|
+
def to_dict(self) -> dict[str, Any]:
|
|
237
|
+
data: dict[str, Any] = {
|
|
238
|
+
"plan_id": self.plan_id,
|
|
239
|
+
"production": self.production_name,
|
|
240
|
+
"success": self.success,
|
|
241
|
+
"applied": self.applied,
|
|
242
|
+
"skipped": self.skipped,
|
|
243
|
+
"failed": self.failed,
|
|
244
|
+
"updated_runtime": self.updated_runtime,
|
|
245
|
+
"operations": list(self.operations),
|
|
246
|
+
}
|
|
247
|
+
if self.backup_path:
|
|
248
|
+
data["backup_path"] = self.backup_path
|
|
249
|
+
return data
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def from_dict(cls, data: dict[str, Any]) -> ProductionApplyResult:
|
|
253
|
+
return cls(
|
|
254
|
+
plan_id=str(data.get("plan_id") or data.get("plan") or ""),
|
|
255
|
+
production_name=str(data.get("production") or ""),
|
|
256
|
+
backup_path=str(data.get("backup_path") or ""),
|
|
257
|
+
operations=tuple(dict(op) for op in data.get("operations", ())),
|
|
258
|
+
updated_runtime=bool(data.get("updated_runtime", False)),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def to_text(self) -> str:
|
|
262
|
+
lines = [
|
|
263
|
+
f"Production apply result: {self.production_name}",
|
|
264
|
+
f" plan: {self.plan_id}",
|
|
265
|
+
f" applied: {self.applied}, skipped: {self.skipped}, failed: {self.failed}",
|
|
266
|
+
]
|
|
267
|
+
if self.backup_path:
|
|
268
|
+
lines.append(f" backup: {self.backup_path}")
|
|
269
|
+
if self.operations:
|
|
270
|
+
lines.append(" operations:")
|
|
271
|
+
for op in self.operations:
|
|
272
|
+
lines.append(
|
|
273
|
+
f" {op.get('id', '')} {op.get('op_type', '')}: "
|
|
274
|
+
f"{op.get('status', 'unknown')}"
|
|
275
|
+
)
|
|
276
|
+
return "\n".join(lines)
|
|
277
|
+
|
|
278
|
+
def __str__(self) -> str:
|
|
279
|
+
return self.to_text()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class ProductionVerifyResult:
|
|
284
|
+
"""Result returned after verifying a production change plan."""
|
|
285
|
+
|
|
286
|
+
plan_id: str
|
|
287
|
+
production_name: str
|
|
288
|
+
converged_operations: tuple[str, ...] = ()
|
|
289
|
+
failed_operations: tuple[dict[str, Any], ...] = ()
|
|
290
|
+
residual_operations: tuple[dict[str, Any], ...] = ()
|
|
291
|
+
warnings: tuple[str, ...] = ()
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def success(self) -> bool:
|
|
295
|
+
return not self.failed_operations
|
|
296
|
+
|
|
297
|
+
def to_dict(self) -> dict[str, Any]:
|
|
298
|
+
data: dict[str, Any] = {
|
|
299
|
+
"plan_id": self.plan_id,
|
|
300
|
+
"production": self.production_name,
|
|
301
|
+
"success": self.success,
|
|
302
|
+
"converged_operations": list(self.converged_operations),
|
|
303
|
+
"failed_operations": list(self.failed_operations),
|
|
304
|
+
"residual_operations": list(self.residual_operations),
|
|
305
|
+
}
|
|
306
|
+
if self.warnings:
|
|
307
|
+
data["warnings"] = list(self.warnings)
|
|
308
|
+
return data
|
|
309
|
+
|
|
310
|
+
def to_text(self) -> str:
|
|
311
|
+
lines = [
|
|
312
|
+
f"Production verify result: {self.production_name}",
|
|
313
|
+
f" plan: {self.plan_id}",
|
|
314
|
+
f" success: {str(self.success).lower()}",
|
|
315
|
+
f" converged: {len(self.converged_operations)}",
|
|
316
|
+
f" failed: {len(self.failed_operations)}",
|
|
317
|
+
f" residual: {len(self.residual_operations)}",
|
|
318
|
+
]
|
|
319
|
+
if self.failed_operations:
|
|
320
|
+
lines.append(" failed operations:")
|
|
321
|
+
lines.extend(
|
|
322
|
+
f" {op.get('id', '')} {op.get('path', '')}" for op in self.failed_operations
|
|
323
|
+
)
|
|
324
|
+
if self.residual_operations:
|
|
325
|
+
lines.append(" residual operations:")
|
|
326
|
+
lines.extend(
|
|
327
|
+
f" {op.get('id', '')} {op.get('path', '')}"
|
|
328
|
+
for op in self.residual_operations
|
|
329
|
+
)
|
|
330
|
+
if self.warnings:
|
|
331
|
+
lines.append(" warnings:")
|
|
332
|
+
lines.extend(f" {warning}" for warning in self.warnings)
|
|
333
|
+
return "\n".join(lines)
|
|
334
|
+
|
|
335
|
+
def __str__(self) -> str:
|
|
336
|
+
return self.to_text()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def build_change_plan(desired, current) -> ProductionChangePlan:
|
|
340
|
+
diff = desired.diff_deployable(current)
|
|
341
|
+
operations = tuple(_operation_from_diff(index, change) for index, change in enumerate(diff.changes, start=1))
|
|
342
|
+
created_at = datetime.now(UTC).isoformat()
|
|
343
|
+
source_fingerprint = production_fingerprint(current)
|
|
344
|
+
desired_fingerprint = production_fingerprint(desired)
|
|
345
|
+
plan_id = _plan_id(
|
|
346
|
+
desired.name,
|
|
347
|
+
source_fingerprint,
|
|
348
|
+
desired_fingerprint,
|
|
349
|
+
operations,
|
|
350
|
+
)
|
|
351
|
+
namespace = getattr(desired, "namespace", None) or getattr(current, "namespace", None) or ""
|
|
352
|
+
return ProductionChangePlan(
|
|
353
|
+
id=plan_id,
|
|
354
|
+
production_name=desired.name,
|
|
355
|
+
namespace=str(namespace or ""),
|
|
356
|
+
created_at=created_at,
|
|
357
|
+
source_fingerprint=source_fingerprint,
|
|
358
|
+
desired_fingerprint=desired_fingerprint,
|
|
359
|
+
operations=operations,
|
|
360
|
+
warnings=diff.warnings,
|
|
361
|
+
metadata={
|
|
362
|
+
"generator": "iop.production.plan.v1",
|
|
363
|
+
"default_policy": "safe-only",
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def production_fingerprint(production) -> str:
|
|
369
|
+
payload = production.to_dict()
|
|
370
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
|
371
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def create_backup(
|
|
375
|
+
*,
|
|
376
|
+
backup_dir: str | os.PathLike[str],
|
|
377
|
+
plan: ProductionChangePlan,
|
|
378
|
+
current,
|
|
379
|
+
current_export: dict[str, Any],
|
|
380
|
+
connections: Any,
|
|
381
|
+
queues: Any,
|
|
382
|
+
) -> Path:
|
|
383
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
384
|
+
backup_path = Path(backup_dir) / f"{timestamp}-{plan.id[:12]}"
|
|
385
|
+
backup_path.mkdir(parents=True, exist_ok=False)
|
|
386
|
+
_write_json(backup_path / "production.json", current_export)
|
|
387
|
+
(backup_path / "production.xml").write_text(current.to_xml(), encoding="utf-8")
|
|
388
|
+
_write_json(backup_path / "connections.json", connections or {})
|
|
389
|
+
_write_json(backup_path / "queues.json", queues or {})
|
|
390
|
+
plan.save(backup_path / "plan.json")
|
|
391
|
+
_write_json(
|
|
392
|
+
backup_path / "metadata.json",
|
|
393
|
+
{
|
|
394
|
+
"backup_id": backup_path.name,
|
|
395
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
396
|
+
"user": getpass.getuser(),
|
|
397
|
+
"host": socket.gethostname(),
|
|
398
|
+
"namespace": plan.namespace,
|
|
399
|
+
"production": plan.production_name,
|
|
400
|
+
"plan_id": plan.id,
|
|
401
|
+
"source_fingerprint": plan.source_fingerprint,
|
|
402
|
+
"desired_fingerprint": plan.desired_fingerprint,
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
return backup_path
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def verify_change_plan(plan: ProductionChangePlan, current) -> ProductionVerifyResult:
|
|
409
|
+
converged: list[str] = []
|
|
410
|
+
failed: list[dict[str, Any]] = []
|
|
411
|
+
residual: list[dict[str, Any]] = []
|
|
412
|
+
|
|
413
|
+
for operation in plan.operations:
|
|
414
|
+
if not operation.allowed_by_default:
|
|
415
|
+
residual.append(operation.to_dict())
|
|
416
|
+
continue
|
|
417
|
+
if _operation_converged(operation, current):
|
|
418
|
+
converged.append(operation.id)
|
|
419
|
+
else:
|
|
420
|
+
failed.append(operation.to_dict())
|
|
421
|
+
|
|
422
|
+
return ProductionVerifyResult(
|
|
423
|
+
plan_id=plan.id,
|
|
424
|
+
production_name=plan.production_name,
|
|
425
|
+
converged_operations=tuple(converged),
|
|
426
|
+
failed_operations=tuple(failed),
|
|
427
|
+
residual_operations=tuple(residual),
|
|
428
|
+
warnings=tuple(current.graph().warnings),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def operation_result(
|
|
433
|
+
operation: ProductionPlanOperation,
|
|
434
|
+
*,
|
|
435
|
+
status: str,
|
|
436
|
+
message: str = "",
|
|
437
|
+
) -> dict[str, Any]:
|
|
438
|
+
data = operation.to_dict()
|
|
439
|
+
data["status"] = status
|
|
440
|
+
if message:
|
|
441
|
+
data["message"] = message
|
|
442
|
+
return data
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def skipped_operation_results(
|
|
446
|
+
plan: ProductionChangePlan,
|
|
447
|
+
*,
|
|
448
|
+
allow_destructive: bool,
|
|
449
|
+
) -> list[dict[str, Any]]:
|
|
450
|
+
results = []
|
|
451
|
+
for operation in plan.operations:
|
|
452
|
+
if operation.can_apply(allow_destructive=allow_destructive):
|
|
453
|
+
continue
|
|
454
|
+
if operation.risk == DESTRUCTIVE:
|
|
455
|
+
message = "requires allow_destructive=True"
|
|
456
|
+
elif operation.risk == UNSUPPORTED:
|
|
457
|
+
message = operation.reason or "unsupported operation"
|
|
458
|
+
else:
|
|
459
|
+
message = "operation is not applicable"
|
|
460
|
+
results.append(operation_result(operation, status="skipped", message=message))
|
|
461
|
+
return results
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _operation_from_diff(index: int, change: ProductionDiffEntry) -> ProductionPlanOperation:
|
|
465
|
+
op_type, risk, reason = _classify_change(change)
|
|
466
|
+
return ProductionPlanOperation(
|
|
467
|
+
id=f"{index:04d}-{op_type}",
|
|
468
|
+
op_type=op_type,
|
|
469
|
+
action=change.action,
|
|
470
|
+
kind=change.kind,
|
|
471
|
+
path=change.path,
|
|
472
|
+
before=change.before,
|
|
473
|
+
after=change.after,
|
|
474
|
+
risk=risk,
|
|
475
|
+
reason=reason,
|
|
476
|
+
detail=change.detail,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _classify_change(change: ProductionDiffEntry) -> tuple[str, str, str]:
|
|
481
|
+
if change.kind == "item":
|
|
482
|
+
if change.action == "add":
|
|
483
|
+
return "add_item", SAFE, ""
|
|
484
|
+
if change.action == "remove":
|
|
485
|
+
return "delete_item", DESTRUCTIVE, "item deletion is destructive"
|
|
486
|
+
field_name = _item_field_name(change.path)
|
|
487
|
+
if field_name == "class_name":
|
|
488
|
+
return "replace_item_class", DESTRUCTIVE, "class replacement can change runtime behavior"
|
|
489
|
+
if field_name in _ITEM_FIELD_OPS:
|
|
490
|
+
return "set_item_field", SAFE, ""
|
|
491
|
+
return "set_item_field", UNSUPPORTED, "unsupported item field"
|
|
492
|
+
|
|
493
|
+
if change.kind == "setting":
|
|
494
|
+
if change.action == "remove":
|
|
495
|
+
return "remove_setting", DESTRUCTIVE, "setting removal is destructive"
|
|
496
|
+
return "set_setting", SAFE, ""
|
|
497
|
+
|
|
498
|
+
if change.kind == "connection":
|
|
499
|
+
source_item, source_target_setting = _connection_source(change.path)
|
|
500
|
+
if not source_item or not source_target_setting:
|
|
501
|
+
return (
|
|
502
|
+
"set_route_setting",
|
|
503
|
+
UNSUPPORTED,
|
|
504
|
+
"runtime-only route has no source setting to mutate",
|
|
505
|
+
)
|
|
506
|
+
if change.action == "remove":
|
|
507
|
+
return "remove_route", DESTRUCTIVE, "route removal is destructive"
|
|
508
|
+
return "set_route_setting", SAFE, ""
|
|
509
|
+
|
|
510
|
+
if change.kind == "production":
|
|
511
|
+
return "set_production_field", SAFE, ""
|
|
512
|
+
|
|
513
|
+
return "unsupported", UNSUPPORTED, "unsupported diff entry"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _operation_converged(operation: ProductionPlanOperation, current) -> bool:
|
|
517
|
+
if operation.op_type == "add_item":
|
|
518
|
+
return _item_matches(current, operation.path, operation.after)
|
|
519
|
+
if operation.op_type == "delete_item":
|
|
520
|
+
item_name = _item_name(operation.path)
|
|
521
|
+
return item_name not in current._items_by_name
|
|
522
|
+
if operation.op_type in {"set_item_field", "replace_item_class"}:
|
|
523
|
+
item_name = _item_name(operation.path)
|
|
524
|
+
field_name = _item_field_name(operation.path)
|
|
525
|
+
ref = current._items_by_name.get(item_name)
|
|
526
|
+
if ref is None:
|
|
527
|
+
return False
|
|
528
|
+
return _item_signature(ref).get(field_name) == operation.after
|
|
529
|
+
if operation.op_type == "set_production_field":
|
|
530
|
+
field_name = operation.path.removeprefix("production.")
|
|
531
|
+
return _production_signature(current).get(field_name) == operation.after
|
|
532
|
+
if operation.op_type == "set_setting":
|
|
533
|
+
parsed = _setting_path(operation.path)
|
|
534
|
+
if parsed is None:
|
|
535
|
+
return False
|
|
536
|
+
item_name, target, setting_name = parsed
|
|
537
|
+
ref = current._items_by_name.get(item_name)
|
|
538
|
+
if ref is None:
|
|
539
|
+
return False
|
|
540
|
+
if target == "Host":
|
|
541
|
+
return _string_value(ref.host_settings.get(setting_name)) == _string_value(operation.after)
|
|
542
|
+
if target == "Adapter":
|
|
543
|
+
return _string_value(ref.adapter_settings.get(setting_name)) == _string_value(operation.after)
|
|
544
|
+
if target == "Other":
|
|
545
|
+
return ref.other_settings == operation.after
|
|
546
|
+
return False
|
|
547
|
+
if operation.op_type == "remove_setting":
|
|
548
|
+
parsed = _setting_path(operation.path)
|
|
549
|
+
if parsed is None:
|
|
550
|
+
return False
|
|
551
|
+
item_name, target, setting_name = parsed
|
|
552
|
+
ref = current._items_by_name.get(item_name)
|
|
553
|
+
if ref is None:
|
|
554
|
+
return True
|
|
555
|
+
if target == "Host":
|
|
556
|
+
return setting_name not in ref.host_settings
|
|
557
|
+
if target == "Adapter":
|
|
558
|
+
return setting_name not in ref.adapter_settings
|
|
559
|
+
return False
|
|
560
|
+
if operation.op_type == "set_route_setting":
|
|
561
|
+
source_item, source_target_setting = _connection_source(operation.path)
|
|
562
|
+
if not source_item or not source_target_setting:
|
|
563
|
+
return False
|
|
564
|
+
connections = _connection_signature(current)
|
|
565
|
+
return (
|
|
566
|
+
connections.get((source_item, source_target_setting), [])
|
|
567
|
+
== operation.after
|
|
568
|
+
)
|
|
569
|
+
if operation.op_type == "remove_route":
|
|
570
|
+
source_item, source_target_setting = _connection_source(operation.path)
|
|
571
|
+
connections = _connection_signature(current)
|
|
572
|
+
return (source_item, source_target_setting) not in connections
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _item_matches(current, path: str, expected: Any) -> bool:
|
|
577
|
+
item_name = _item_name(path)
|
|
578
|
+
ref = current._items_by_name.get(item_name)
|
|
579
|
+
return ref is not None and _item_signature(ref) == expected
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _item_name(path: str) -> str:
|
|
583
|
+
if not path.startswith("items."):
|
|
584
|
+
return ""
|
|
585
|
+
remainder = path.removeprefix("items.")
|
|
586
|
+
if ".settings." in remainder:
|
|
587
|
+
return remainder.split(".settings.", 1)[0]
|
|
588
|
+
for field_name in ("class_name", *_ITEM_FIELD_OPS):
|
|
589
|
+
suffix = f".{field_name}"
|
|
590
|
+
if remainder.endswith(suffix):
|
|
591
|
+
return remainder[: -len(suffix)]
|
|
592
|
+
return remainder
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _item_field_name(path: str) -> str:
|
|
596
|
+
for field_name in ("class_name", *_ITEM_FIELD_OPS):
|
|
597
|
+
if path.endswith(f".{field_name}"):
|
|
598
|
+
return field_name
|
|
599
|
+
return ""
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _setting_path(path: str) -> tuple[str, str, str] | None:
|
|
603
|
+
if not path.startswith("items.") or ".settings." not in path:
|
|
604
|
+
return None
|
|
605
|
+
item_name, rest = path.removeprefix("items.").split(".settings.", 1)
|
|
606
|
+
if "." not in rest:
|
|
607
|
+
return item_name, rest, ""
|
|
608
|
+
target, setting_name = rest.split(".", 1)
|
|
609
|
+
return item_name, target, setting_name
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _connection_source(path: str) -> tuple[str, str]:
|
|
613
|
+
source = path.removeprefix("connections.")
|
|
614
|
+
source_item, separator, source_target_setting = source.rpartition(".")
|
|
615
|
+
if not separator:
|
|
616
|
+
return source, ""
|
|
617
|
+
return source_item, source_target_setting
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _string_value(value: Any) -> str:
|
|
621
|
+
if value is None:
|
|
622
|
+
return ""
|
|
623
|
+
return str(value)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _operation_target_data(operation: ProductionPlanOperation) -> dict[str, Any]:
|
|
627
|
+
if operation.op_type in {"add_item", "delete_item"}:
|
|
628
|
+
return {"item": _item_name(operation.path)}
|
|
629
|
+
if operation.op_type in {"set_item_field", "replace_item_class"}:
|
|
630
|
+
return {
|
|
631
|
+
"item": _item_name(operation.path),
|
|
632
|
+
"field": _item_field_name(operation.path),
|
|
633
|
+
}
|
|
634
|
+
if operation.op_type in {"set_setting", "remove_setting"}:
|
|
635
|
+
parsed = _setting_path(operation.path)
|
|
636
|
+
if parsed is None:
|
|
637
|
+
return {}
|
|
638
|
+
item_name, target, setting_name = parsed
|
|
639
|
+
return {
|
|
640
|
+
"item": item_name,
|
|
641
|
+
"target": target,
|
|
642
|
+
"setting": setting_name,
|
|
643
|
+
}
|
|
644
|
+
if operation.op_type in {"set_route_setting", "remove_route"}:
|
|
645
|
+
source_item, source_target_setting = _connection_source(operation.path)
|
|
646
|
+
return {
|
|
647
|
+
"source_item": source_item,
|
|
648
|
+
"source_target_setting": source_target_setting,
|
|
649
|
+
"item": source_item,
|
|
650
|
+
"target": "Host",
|
|
651
|
+
"setting": source_target_setting,
|
|
652
|
+
}
|
|
653
|
+
if operation.op_type == "set_production_field":
|
|
654
|
+
return {"field": operation.path.removeprefix("production.")}
|
|
655
|
+
return {}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _plan_id(
|
|
659
|
+
production_name: str,
|
|
660
|
+
source_fingerprint: str,
|
|
661
|
+
desired_fingerprint: str,
|
|
662
|
+
operations: tuple[ProductionPlanOperation, ...],
|
|
663
|
+
) -> str:
|
|
664
|
+
payload = {
|
|
665
|
+
"production": production_name,
|
|
666
|
+
"source": source_fingerprint,
|
|
667
|
+
"desired": desired_fingerprint,
|
|
668
|
+
"operations": [op.to_dict() for op in operations],
|
|
669
|
+
}
|
|
670
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
|
671
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _write_json(path: Path, data: Any) -> None:
|
|
675
|
+
path.write_text(
|
|
676
|
+
json.dumps(data, indent=2, sort_keys=True, default=str),
|
|
677
|
+
encoding="utf-8",
|
|
678
|
+
)
|