ctm-web-client 2.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.
- ctm_web_client/__init__.py +32 -0
- ctm_web_client/client.py +336 -0
- ctm_web_client/client_v2.py +959 -0
- ctm_web_client/downloader.py +216 -0
- ctm_web_client/exceptions.py +26 -0
- ctm_web_client/exporters.py +131 -0
- ctm_web_client/proto_decoder.py +152 -0
- ctm_web_client-2.0.0.dist-info/METADATA +221 -0
- ctm_web_client-2.0.0.dist-info/RECORD +12 -0
- ctm_web_client-2.0.0.dist-info/WHEEL +5 -0
- ctm_web_client-2.0.0.dist-info/licenses/LICENSE +17 -0
- ctm_web_client-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ctm_web_client - Biblioteca para extraer reportes y logs de Control-M/EM Web
|
|
3
|
+
sin necesidad de acceso al API oficial.
|
|
4
|
+
|
|
5
|
+
Uso básico:
|
|
6
|
+
from ctm_web_client import ControlMWebClient
|
|
7
|
+
|
|
8
|
+
client = ControlMWebClient("https://controlm-server:8443/ControlM")
|
|
9
|
+
client.login("usuario", "contraseña")
|
|
10
|
+
|
|
11
|
+
# Obtener jobs ejecutados
|
|
12
|
+
jobs = client.get_jobs(folder="MI_FOLDER", date="2026-08-20")
|
|
13
|
+
|
|
14
|
+
# Descargar log de un job
|
|
15
|
+
log = client.get_job_log(job_id="SERVER:00abc")
|
|
16
|
+
|
|
17
|
+
# Exportar reporte
|
|
18
|
+
client.export_report("ejecuciones", format="csv", output_path="reporte.csv")
|
|
19
|
+
|
|
20
|
+
client.logout()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from ctm_web_client.client_v2 import ControlMWebClient
|
|
24
|
+
from ctm_web_client.exporters import JSONExporter, CSVExporter, TextExporter
|
|
25
|
+
from ctm_web_client.proto_decoder import decode_em_response, decode_nested, decode_strings
|
|
26
|
+
|
|
27
|
+
__version__ = "2.0.0"
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ControlMWebClient",
|
|
30
|
+
"JSONExporter", "CSVExporter", "TextExporter",
|
|
31
|
+
"decode_em_response", "decode_nested", "decode_strings",
|
|
32
|
+
]
|
ctm_web_client/client.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Backward-compatible re-export. Usa client_v2 como implementación canónica."""
|
|
2
|
+
|
|
3
|
+
from ctm_web_client.client_v2 import ControlMWebClient, _proto_string, _proto_varint
|
|
4
|
+
|
|
5
|
+
__all__ = ["ControlMWebClient", "_proto_string", "_proto_varint"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ─── Cliente principal ────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
class ControlMWebClient:
|
|
11
|
+
"""
|
|
12
|
+
Cliente HTTP para Control-M/EM Web (on-premise).
|
|
13
|
+
|
|
14
|
+
Usa los endpoints internos de la interfaz web para extraer datos
|
|
15
|
+
sin necesidad de acceso a la Automation API.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
base_url: URL base (ej: "https://server:8443/ControlM")
|
|
19
|
+
verify_ssl: Verificar certificados SSL (default False para self-signed).
|
|
20
|
+
timeout: Timeout por request en segundos.
|
|
21
|
+
|
|
22
|
+
Ejemplo:
|
|
23
|
+
client = ControlMWebClient("https://controlm-server:8443/ControlM")
|
|
24
|
+
client.login("usuario", "password")
|
|
25
|
+
viewpoints = client.get_viewpoints()
|
|
26
|
+
servers = client.get_servers_info()
|
|
27
|
+
client.logout()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
base_url: str,
|
|
33
|
+
verify_ssl: bool = False,
|
|
34
|
+
timeout: int = 30,
|
|
35
|
+
):
|
|
36
|
+
self.base_url = base_url.rstrip("/")
|
|
37
|
+
self.verify_ssl = verify_ssl
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self._session: Optional[requests.Session] = None
|
|
40
|
+
self._authenticated = False
|
|
41
|
+
self._em_token: Optional[str] = None
|
|
42
|
+
self._username: Optional[str] = None
|
|
43
|
+
self._auth_data: Optional[str] = None # protobuf base64 para EmWebServices
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_authenticated(self) -> bool:
|
|
47
|
+
return self._authenticated
|
|
48
|
+
|
|
49
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
50
|
+
# HTTP internals
|
|
51
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def _rest_get(self, path: str, **kwargs) -> requests.Response:
|
|
54
|
+
"""GET a un REST endpoint con Bearer auth."""
|
|
55
|
+
if not self._session or not self._authenticated:
|
|
56
|
+
raise ControlMWebError("No autenticado. Ejecuta login() primero.")
|
|
57
|
+
|
|
58
|
+
url = f"{self.base_url}/rest/{path.lstrip('/')}"
|
|
59
|
+
headers = {"Authorization": f"Bearer {self._em_token}"}
|
|
60
|
+
|
|
61
|
+
resp = self._session.get(
|
|
62
|
+
url, headers=headers, verify=self.verify_ssl, timeout=self.timeout, **kwargs
|
|
63
|
+
)
|
|
64
|
+
self._check_response(resp, url)
|
|
65
|
+
return resp
|
|
66
|
+
|
|
67
|
+
def _rest_post(self, path: str, json_body=None, **kwargs) -> requests.Response:
|
|
68
|
+
"""POST a un REST endpoint con Bearer auth."""
|
|
69
|
+
if not self._session or not self._authenticated:
|
|
70
|
+
raise ControlMWebError("No autenticado. Ejecuta login() primero.")
|
|
71
|
+
|
|
72
|
+
url = f"{self.base_url}/rest/{path.lstrip('/')}"
|
|
73
|
+
headers = {"Authorization": f"Bearer {self._em_token}"}
|
|
74
|
+
|
|
75
|
+
resp = self._session.post(
|
|
76
|
+
url, json=json_body, headers=headers,
|
|
77
|
+
verify=self.verify_ssl, timeout=self.timeout, **kwargs
|
|
78
|
+
)
|
|
79
|
+
self._check_response(resp, url)
|
|
80
|
+
return resp
|
|
81
|
+
|
|
82
|
+
def _em_service(self, service_name: str, extra_proto: bytes = b"") -> dict:
|
|
83
|
+
"""
|
|
84
|
+
Llama a un EmWebService con autenticación protobuf.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
service_name: Nombre del servicio.
|
|
88
|
+
extra_proto: Bytes protobuf adicionales después de username+token.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Response JSON (contiene campo "data" con protobuf base64).
|
|
92
|
+
"""
|
|
93
|
+
if not self._session or not self._authenticated:
|
|
94
|
+
raise ControlMWebError("No autenticado. Ejecuta login() primero.")
|
|
95
|
+
|
|
96
|
+
url = f"{self.base_url}/rest/EmWebServices/{service_name}"
|
|
97
|
+
auth_bytes = _proto_string(1, self._username) + _proto_string(2, self._em_token)
|
|
98
|
+
payload = base64.b64encode(auth_bytes + extra_proto).decode("ascii")
|
|
99
|
+
|
|
100
|
+
resp = self._session.post(
|
|
101
|
+
url, json={"data": payload},
|
|
102
|
+
verify=self.verify_ssl, timeout=self.timeout
|
|
103
|
+
)
|
|
104
|
+
self._check_response(resp, url)
|
|
105
|
+
return resp.json()
|
|
106
|
+
|
|
107
|
+
def _em_service_raw(self, service_name: str, data: str = "") -> dict:
|
|
108
|
+
"""Llama EmWebService con data arbitrario (ya codificado)."""
|
|
109
|
+
url = f"{self.base_url}/rest/EmWebServices/{service_name}"
|
|
110
|
+
resp = self._session.post(
|
|
111
|
+
url, json={"data": data},
|
|
112
|
+
verify=self.verify_ssl, timeout=self.timeout
|
|
113
|
+
)
|
|
114
|
+
self._check_response(resp, url)
|
|
115
|
+
return resp.json()
|
|
116
|
+
|
|
117
|
+
def _check_response(self, resp: requests.Response, url: str):
|
|
118
|
+
if resp.status_code == 401:
|
|
119
|
+
self._authenticated = False
|
|
120
|
+
raise SessionExpiredError("Sesión expirada. Ejecuta login() de nuevo.")
|
|
121
|
+
if resp.status_code == 404:
|
|
122
|
+
raise ResourceNotFoundError(f"No encontrado: {url}")
|
|
123
|
+
if resp.status_code >= 500:
|
|
124
|
+
raise ControlMWebError(f"Error servidor {resp.status_code}: {url}")
|
|
125
|
+
|
|
126
|
+
def _decode_em_data(self, response: dict) -> bytes:
|
|
127
|
+
"""Decodifica el campo 'data' protobuf de un EmWebService response."""
|
|
128
|
+
raw = response.get("data", "")
|
|
129
|
+
if raw:
|
|
130
|
+
return base64.b64decode(raw)
|
|
131
|
+
return b""
|
|
132
|
+
|
|
133
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
134
|
+
# Autenticación
|
|
135
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def login(self, username: str, password: str) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Autentica contra Control-M/EM Web.
|
|
140
|
+
|
|
141
|
+
Usa el mismo mecanismo que el navegador:
|
|
142
|
+
POST /rest/EmWebServices/login con protobuf(username, password, domain, flag)
|
|
143
|
+
"""
|
|
144
|
+
self._session = requests.Session()
|
|
145
|
+
self._session.headers.update({
|
|
146
|
+
"Accept": "application/json",
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
})
|
|
149
|
+
self._username = username
|
|
150
|
+
|
|
151
|
+
# Construir protobuf de login (formato verificado en discovery Phase 1)
|
|
152
|
+
login_proto = (
|
|
153
|
+
_proto_string(1, username)
|
|
154
|
+
+ _proto_string(2, password)
|
|
155
|
+
+ _proto_string(3, username) # domain
|
|
156
|
+
+ _proto_varint(4, 1) # flag
|
|
157
|
+
)
|
|
158
|
+
login_data = base64.b64encode(login_proto).decode("ascii")
|
|
159
|
+
|
|
160
|
+
url = f"{self.base_url}/rest/EmWebServices/login"
|
|
161
|
+
try:
|
|
162
|
+
resp = self._session.post(
|
|
163
|
+
url, json={"data": login_data},
|
|
164
|
+
verify=self.verify_ssl, timeout=self.timeout
|
|
165
|
+
)
|
|
166
|
+
except requests.exceptions.ConnectionError as e:
|
|
167
|
+
raise ControlMWebError(f"No se pudo conectar a Control-M: {e}")
|
|
168
|
+
|
|
169
|
+
if resp.status_code != 200:
|
|
170
|
+
raise AuthenticationError(
|
|
171
|
+
f"Login falló (HTTP {resp.status_code}): {resp.text[:300]}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Extraer EM_TOKEN
|
|
175
|
+
em_token = None
|
|
176
|
+
for cookie in self._session.cookies:
|
|
177
|
+
if cookie.name == "EM_TOKEN":
|
|
178
|
+
em_token = cookie.value
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if not em_token:
|
|
182
|
+
# Fallback: GET /rest/em-token
|
|
183
|
+
try:
|
|
184
|
+
resp2 = self._session.get(
|
|
185
|
+
f"{self.base_url}/rest/em-token",
|
|
186
|
+
verify=self.verify_ssl, timeout=10
|
|
187
|
+
)
|
|
188
|
+
if resp2.status_code == 200:
|
|
189
|
+
em_token = resp2.json().get("EM_TOKEN", "")
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
if not em_token:
|
|
194
|
+
raise AuthenticationError("Login exitoso pero no se obtuvo EM_TOKEN.")
|
|
195
|
+
|
|
196
|
+
self._em_token = em_token
|
|
197
|
+
self._authenticated = True
|
|
198
|
+
logger.info("Login exitoso en Control-M Web.")
|
|
199
|
+
|
|
200
|
+
def logout(self) -> None:
|
|
201
|
+
"""Cierra la sesión."""
|
|
202
|
+
if self._session and self._authenticated:
|
|
203
|
+
try:
|
|
204
|
+
self._em_service("logout")
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
finally:
|
|
208
|
+
self._session.close()
|
|
209
|
+
self._session = None
|
|
210
|
+
self._authenticated = False
|
|
211
|
+
self._em_token = None
|
|
212
|
+
logger.info("Sesión cerrada.")
|
|
213
|
+
|
|
214
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
215
|
+
# REST Endpoints (con Bearer token)
|
|
216
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
def get_viewpoints(self) -> list:
|
|
219
|
+
"""Lista viewpoints disponibles."""
|
|
220
|
+
resp = self._rest_get("viewpoints")
|
|
221
|
+
return resp.json()
|
|
222
|
+
|
|
223
|
+
def get_viewpoint_filters(self) -> list:
|
|
224
|
+
"""Obtiene filtros de viewpoints."""
|
|
225
|
+
resp = self._rest_get("viewpointFilters")
|
|
226
|
+
return resp.json()
|
|
227
|
+
|
|
228
|
+
def get_environment(self) -> dict:
|
|
229
|
+
"""Info del entorno (mode, features, licenses)."""
|
|
230
|
+
resp = self._rest_get("environment")
|
|
231
|
+
return resp.json()
|
|
232
|
+
|
|
233
|
+
def get_site_customizations(self) -> list:
|
|
234
|
+
"""Obtiene personalizaciones del sitio."""
|
|
235
|
+
resp = self._rest_get("site-customizations?recordsLimit=10000")
|
|
236
|
+
return resp.json()
|
|
237
|
+
|
|
238
|
+
def get_user_data(self, category: str, sub_category: str = "") -> list:
|
|
239
|
+
"""Obtiene datos del usuario por categoría."""
|
|
240
|
+
path = f"userData/getItemsInCategory?category={category}"
|
|
241
|
+
if sub_category:
|
|
242
|
+
path += f"&subCategory={sub_category}"
|
|
243
|
+
resp = self._rest_get(path)
|
|
244
|
+
return resp.json()
|
|
245
|
+
|
|
246
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
247
|
+
# EmWebServices (con protobuf auth)
|
|
248
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def get_servers_info(self) -> bytes:
|
|
251
|
+
"""
|
|
252
|
+
Obtiene información de servidores Control-M (CTM_CB, CTM_COLP, etc).
|
|
253
|
+
Retorna datos protobuf decodificados.
|
|
254
|
+
"""
|
|
255
|
+
result = self._em_service("getCTMInformation")
|
|
256
|
+
return self._decode_em_data(result)
|
|
257
|
+
|
|
258
|
+
def get_topology(self) -> bytes:
|
|
259
|
+
"""Obtiene topología de la infraestructura."""
|
|
260
|
+
result = self._em_service("GetTopology")
|
|
261
|
+
return self._decode_em_data(result)
|
|
262
|
+
|
|
263
|
+
def get_system_info(self) -> bytes:
|
|
264
|
+
"""Información del sistema."""
|
|
265
|
+
result = self._em_service("getSystemInformation")
|
|
266
|
+
return self._decode_em_data(result)
|
|
267
|
+
|
|
268
|
+
def get_communication_status(self) -> bytes:
|
|
269
|
+
"""Estado de comunicación con servidores."""
|
|
270
|
+
result = self._em_service("GetCommunicationStatus")
|
|
271
|
+
return self._decode_em_data(result)
|
|
272
|
+
|
|
273
|
+
def get_server_list(self) -> bytes:
|
|
274
|
+
"""Lista servidores con parámetros."""
|
|
275
|
+
result = self._em_service("ListCTMDefsWithCTMParams")
|
|
276
|
+
return self._decode_em_data(result)
|
|
277
|
+
|
|
278
|
+
def get_fields_descriptors(self) -> bytes:
|
|
279
|
+
"""Descriptores de campos (para filtros)."""
|
|
280
|
+
result = self._em_service("getFieldsDescSeq")
|
|
281
|
+
return self._decode_em_data(result)
|
|
282
|
+
|
|
283
|
+
def get_plugin_versions(self) -> bytes:
|
|
284
|
+
"""Versiones de plugins instalados."""
|
|
285
|
+
result = self._em_service("getApplFieldsVersions")
|
|
286
|
+
return self._decode_em_data(result)
|
|
287
|
+
|
|
288
|
+
def get_license_info(self) -> bytes:
|
|
289
|
+
"""Información de licencia."""
|
|
290
|
+
result = self._em_service("getLicenseInformation")
|
|
291
|
+
return self._decode_em_data(result)
|
|
292
|
+
|
|
293
|
+
def get_job_output(self, extra_params: bytes = b"") -> bytes:
|
|
294
|
+
"""
|
|
295
|
+
Obtiene output de un job (requiere parámetros adicionales en protobuf).
|
|
296
|
+
NOTA: Este endpoint existe (500 sin params) - requiere investigación
|
|
297
|
+
adicional para los parámetros exactos del job.
|
|
298
|
+
"""
|
|
299
|
+
result = self._em_service("getJobOutput", extra_params)
|
|
300
|
+
return self._decode_em_data(result)
|
|
301
|
+
|
|
302
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
303
|
+
# Alertas (REST con Bearer)
|
|
304
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def subscribe_alerts(self) -> dict:
|
|
307
|
+
"""Suscribirse a alertas (inicia stream de alertas)."""
|
|
308
|
+
resp = self._rest_post("alerts/subscribe", json_body={})
|
|
309
|
+
return resp.json() if resp.text else {}
|
|
310
|
+
|
|
311
|
+
def unsubscribe_alerts(self) -> None:
|
|
312
|
+
"""Desuscribirse de alertas."""
|
|
313
|
+
self._rest_post("alerts/unsubscribe", json_body={})
|
|
314
|
+
|
|
315
|
+
def subscribe_alert_statistics(self) -> dict:
|
|
316
|
+
"""Suscribirse a estadísticas de alertas."""
|
|
317
|
+
resp = self._rest_post("alerts/subscribeStatistics", json_body={})
|
|
318
|
+
return resp.json() if resp.text else {}
|
|
319
|
+
|
|
320
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
321
|
+
# Reportes (pendiente discovery de endpoints específicos)
|
|
322
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
# TODO: Endpoints de reportes .em.json por descubrir
|
|
325
|
+
# Se agregarán después de capturar el tráfico de la sección Reports
|
|
326
|
+
|
|
327
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
328
|
+
# Context manager
|
|
329
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
def __enter__(self):
|
|
332
|
+
return self
|
|
333
|
+
|
|
334
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
335
|
+
self.logout()
|
|
336
|
+
return False
|