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.
@@ -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
+ ]
@@ -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