@cat-indev/catops-cli 0.0.1-alpha.10
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.
- package/LICENSE +21 -0
- package/README.md +1254 -0
- package/dist/bin/devops-cli.d.ts +2 -0
- package/dist/bin/devops-cli.js +132 -0
- package/dist/bin/devops-cli.js.map +1 -0
- package/dist/core/Context.d.ts +56 -0
- package/dist/core/Context.js +197 -0
- package/dist/core/Context.js.map +1 -0
- package/dist/core/Menu.d.ts +23 -0
- package/dist/core/Menu.js +160 -0
- package/dist/core/Menu.js.map +1 -0
- package/dist/core/Notifier.d.ts +42 -0
- package/dist/core/Notifier.js +128 -0
- package/dist/core/Notifier.js.map +1 -0
- package/dist/core/classifiers.d.ts +45 -0
- package/dist/core/classifiers.js +90 -0
- package/dist/core/classifiers.js.map +1 -0
- package/dist/core/logger.d.ts +8 -0
- package/dist/core/logger.js +37 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/messages.d.ts +102 -0
- package/dist/core/messages.js +163 -0
- package/dist/core/messages.js.map +1 -0
- package/dist/core/prompt.d.ts +10 -0
- package/dist/core/prompt.js +87 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/senders.d.ts +27 -0
- package/dist/core/senders.js +109 -0
- package/dist/core/senders.js.map +1 -0
- package/dist/core/types.d.ts +135 -0
- package/dist/core/types.js +33 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/services/ansible.d.ts +12 -0
- package/dist/services/ansible.js +35 -0
- package/dist/services/ansible.js.map +1 -0
- package/dist/services/archive.d.ts +4 -0
- package/dist/services/archive.js +16 -0
- package/dist/services/archive.js.map +1 -0
- package/dist/services/argocd.d.ts +23 -0
- package/dist/services/argocd.js +48 -0
- package/dist/services/argocd.js.map +1 -0
- package/dist/services/az.d.ts +38 -0
- package/dist/services/az.js +51 -0
- package/dist/services/az.js.map +1 -0
- package/dist/services/azdo-api.d.ts +213 -0
- package/dist/services/azdo-api.js +247 -0
- package/dist/services/azdo-api.js.map +1 -0
- package/dist/services/azdo.d.ts +13 -0
- package/dist/services/azdo.js +57 -0
- package/dist/services/azdo.js.map +1 -0
- package/dist/services/docker.d.ts +21 -0
- package/dist/services/docker.js +36 -0
- package/dist/services/docker.js.map +1 -0
- package/dist/services/git.d.ts +9 -0
- package/dist/services/git.js +36 -0
- package/dist/services/git.js.map +1 -0
- package/dist/services/helm.d.ts +4 -0
- package/dist/services/helm.js +24 -0
- package/dist/services/helm.js.map +1 -0
- package/dist/services/http-types.d.ts +57 -0
- package/dist/services/http-types.js +3 -0
- package/dist/services/http-types.js.map +1 -0
- package/dist/services/http.d.ts +64 -0
- package/dist/services/http.js +288 -0
- package/dist/services/http.js.map +1 -0
- package/dist/services/index.d.ts +37 -0
- package/dist/services/index.js +88 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/kubectl.d.ts +137 -0
- package/dist/services/kubectl.js +271 -0
- package/dist/services/kubectl.js.map +1 -0
- package/dist/services/npm.d.ts +5 -0
- package/dist/services/npm.js +20 -0
- package/dist/services/npm.js.map +1 -0
- package/dist/services/oc.d.ts +156 -0
- package/dist/services/oc.js +309 -0
- package/dist/services/oc.js.map +1 -0
- package/dist/services/pipeline.d.ts +116 -0
- package/dist/services/pipeline.js +474 -0
- package/dist/services/pipeline.js.map +1 -0
- package/dist/services/shell.d.ts +28 -0
- package/dist/services/shell.js +206 -0
- package/dist/services/shell.js.map +1 -0
- package/dist/services/tekton.d.ts +16 -0
- package/dist/services/tekton.js +46 -0
- package/dist/services/tekton.js.map +1 -0
- package/dist/services/terraform.d.ts +31 -0
- package/dist/services/terraform.js +63 -0
- package/dist/services/terraform.js.map +1 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
# catops-cli
|
|
2
|
+
|
|
3
|
+
Framework para pipelines DevOps, escrito en **TypeScript** (100% usable desde JavaScript puro), empaquetado como librería npm instalable en cualquier proyecto.
|
|
4
|
+
|
|
5
|
+
Trae:
|
|
6
|
+
|
|
7
|
+
- Un **ExecutionContext** compartido (`flags`, `params`, `env`, `vars`, `results`, `logger`, `services`, `notifier`) para que ninguna task tenga que recibir parámetros manualmente.
|
|
8
|
+
- **15 servicios** listos (`shell`, `docker`, `git`, `kubectl`, `helm`, `npm`, `archive`, `terraform`, `ansible`, `argocd`, `tekton`, `oc`, `az`, `azdo`, `http`) + un **cliente REST API** para Azure DevOps (`AzureDevOpsApi`) + un **motor de pipelines** declarativo con dependencias (`Pipeline`).
|
|
9
|
+
- **Servicio HTTP** con interceptores de request/response, registry de agentes nombrados, configuración global, query params, dry-run y timeout.
|
|
10
|
+
- **Menús interactivos** con navegación anidada y **selección automática por flag** (para correr pipelines sin prompts, ideal para CI).
|
|
11
|
+
- **retry / timeout / dryRun** en cada comando de shell.
|
|
12
|
+
- Validación cíclica de rollouts de Kubernetes/OpenShift (`waitForDeployment`, `waitForDeploymentGroup`).
|
|
13
|
+
- **Callbacks de éxito/error por tarea** + un sistema de **notificaciones clasificadas por área de TI**, con mensajes personalizables y senders (`log`, `file`, `http`, `webhook`, `websocket`). Incluye `ctx.wrap()` para monitoreo automático de servicios con metadata de servicio/método/argumentos.
|
|
14
|
+
|
|
15
|
+
## Instalación
|
|
16
|
+
|
|
17
|
+
**Opción A — publicado en tu registro npm (público o privado tipo Verdaccio/Artifactory/GitHub Packages):**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install catops-cli
|
|
21
|
+
# o si lo publicas con scope propio, p.ej. @miorg/catops-cli
|
|
22
|
+
npm install @miorg/catops-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Opción B — sin publicar, directo desde este proyecto (útil mientras lo maduras):**
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Dentro del repo de catops-cli
|
|
29
|
+
npm pack # genera catops-cli-<version>.tgz
|
|
30
|
+
|
|
31
|
+
# Dentro del proyecto que lo va a consumir
|
|
32
|
+
npm install /ruta/a/catops-cli-<version>.tgz
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Opción C — enlazado local con `npm link` (para desarrollar la librería y el proyecto que la consume al mismo tiempo):**
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Dentro del repo de catops-cli
|
|
39
|
+
npm link
|
|
40
|
+
|
|
41
|
+
# Dentro del proyecto consumidor
|
|
42
|
+
npm link catops-cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Opción D — como dependencia de Git (monorepo o repo privado, sin registro npm):**
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install git+https://github.com/tu-org/catops-cli.git
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Cualquiera de las 4 deja disponibles dos cosas en el proyecto consumidor:
|
|
52
|
+
|
|
53
|
+
1. La librería, tanto desde TS como desde JS puro:
|
|
54
|
+
```typescript
|
|
55
|
+
import { Context, Menu, services, type MenuDefinition } from "catops-cli";
|
|
56
|
+
```
|
|
57
|
+
```javascript
|
|
58
|
+
const { Context, Menu, services } = require("catops-cli");
|
|
59
|
+
```
|
|
60
|
+
2. El binario: `npx catops-cli` (o `catops-cli` si lo instalaste global con `-g`).
|
|
61
|
+
|
|
62
|
+
## Publicar una nueva versión
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm version patch # o minor / major
|
|
66
|
+
npm publish # agrega --access public si usas un scope (@miorg/catops-cli)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`prepublishOnly` corre el build y los tests automáticamente antes de publicar.
|
|
70
|
+
|
|
71
|
+
## TypeScript
|
|
72
|
+
|
|
73
|
+
Todo `src/` está escrito en TypeScript, con `strict: true`. `npm run build` compila a `dist/` (JS + `.d.ts` + source maps por archivo) — eso es lo único que se publica (ver `files` en `package.json`).
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
src/
|
|
77
|
+
core/
|
|
78
|
+
Context.ts -> ExecutionContext singleton (Context.current() / Context.parseArgv() / ctx.wrap())
|
|
79
|
+
Menu.ts -> Menu.render() con navegación anidada + selección automática por flag
|
|
80
|
+
Notifier.ts -> clasificación de errores por área + canales + senders
|
|
81
|
+
classifiers.ts -> fábricas de ErrorClassifier: byCommand, byPattern, byService
|
|
82
|
+
messages.ts -> fábricas de ErrorMessageFormatter: byPattern, byCommand, byRule, byService
|
|
83
|
+
senders.ts -> fábricas de Sender: log, file, http, webhook, websocket
|
|
84
|
+
prompt.ts -> ctx.ask / ctx.confirm / ctx.select (sin dependencias externas)
|
|
85
|
+
logger.ts -> logger usado por Context y por shell.ts
|
|
86
|
+
types.ts -> tipos compartidos (MenuDefinition, ExecOptions, NotificationEvent, ServiceError, ...)
|
|
87
|
+
services/
|
|
88
|
+
shell.ts -> motor base (spawn), con retry/timeout/dryRun
|
|
89
|
+
http.ts -> cliente HTTP con interceptores de request/response, múltiples instancias
|
|
90
|
+
http-types.ts -> tipos del servicio HTTP (HttpRequest, HttpResponse, interceptors, ...)
|
|
91
|
+
docker.ts, git.ts, kubectl.ts, helm.ts, npm.ts, archive.ts
|
|
92
|
+
terraform.ts, ansible.ts, argocd.ts, tekton.ts, oc.ts, az.ts, azdo.ts, azdo-api.ts, pipeline.ts
|
|
93
|
+
index.ts -> registra todos los servicios anteriores (ServicesRegistry)
|
|
94
|
+
index.ts -> entry point público: Context, Menu, Notifier, senders, classifiers, messages, services, http, tipos
|
|
95
|
+
bin/
|
|
96
|
+
devops-cli.ts -> CLI ejecutable (busca devops.pipeline.js en el proyecto consumidor)
|
|
97
|
+
examples/
|
|
98
|
+
pipeline-example.js -> pipeline + menú + notificaciones de ejemplo, corre contra dist/
|
|
99
|
+
test/
|
|
100
|
+
context.test.js, shell.test.js, services.test.js, menu-selector.test.js,
|
|
101
|
+
notifier.test.js, senders.test.js, hooks-integration.test.js,
|
|
102
|
+
http.test.js, kubectl.test.js, oc.test.js, deployment-group.test.js,
|
|
103
|
+
exec-options-passthrough.test.js, azdo-api.test.js, pipeline.test.js,
|
|
104
|
+
service-notify.test.js
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Uso rápido: menú con `devops.pipeline.js` + el bin
|
|
108
|
+
|
|
109
|
+
Crea un `devops.pipeline.js` (o `devops.config.js` / `.catops-cli.js`) en la raíz de tu proyecto:
|
|
110
|
+
|
|
111
|
+
```javascript
|
|
112
|
+
// devops.pipeline.js
|
|
113
|
+
module.exports = (ctx) => ({
|
|
114
|
+
title: "Pipeline",
|
|
115
|
+
options: {
|
|
116
|
+
Build: async () => {
|
|
117
|
+
await ctx.services.docker.build({ image: "registry/app:v1", dockerfile: "Dockerfile" });
|
|
118
|
+
},
|
|
119
|
+
Deploy: async () => {
|
|
120
|
+
await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx catops-cli --debug --env=prod
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`catops-cli` detecta el archivo, arma el `Context` a partir de los flags/params de `argv`, y renderiza el menú.
|
|
131
|
+
|
|
132
|
+
## Uso directo en tu propio script (p. ej. con `tsx`)
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// src/index.ts
|
|
136
|
+
import { Context, Menu, type MenuDefinition } from "catops-cli";
|
|
137
|
+
|
|
138
|
+
const ctx = Context.parseArgv();
|
|
139
|
+
|
|
140
|
+
const mainMenu: MenuDefinition = {
|
|
141
|
+
title: "Pipeline",
|
|
142
|
+
"flag-selector": "--menu-selector",
|
|
143
|
+
options: {
|
|
144
|
+
Build: { selector: "build", action: () => ctx.services.docker.build({ image: "app:v1" }) }
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
Menu.render(mainMenu, ctx);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{ "scripts": { "dev": "tsx src/index.ts" } }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npx tsx src/index.ts --menu-selector=build
|
|
157
|
+
npm run dev -- --menu-selector=build # con npm hace falta el "--" para reenviar flags
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Uso como librería sin menú (pipeline lineal)
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
const { Context } = require("catops-cli"); // o require("./dist") dentro de este repo
|
|
164
|
+
|
|
165
|
+
const ctx = Context.parseArgv(); // llena flags/params desde argv
|
|
166
|
+
|
|
167
|
+
ctx.set("image", "registry/api:v1");
|
|
168
|
+
|
|
169
|
+
await ctx.services.git.checkout("develop");
|
|
170
|
+
await ctx.services.npm.ci();
|
|
171
|
+
await ctx.services.docker.build({ image: ctx.get("image"), dockerfile: "Dockerfile" });
|
|
172
|
+
await ctx.services.docker.push(ctx.get("image"));
|
|
173
|
+
await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Menús: definición, anidamiento y selectores automáticos por flag
|
|
177
|
+
|
|
178
|
+
Un `MenuDefinition` es `{ title, options }`, donde cada entrada de `options` puede ser:
|
|
179
|
+
|
|
180
|
+
- una **función** — task directa: `Build: () => {...}`
|
|
181
|
+
- otro **`MenuDefinition`** — submenú directo: `Docker: dockerMenu`
|
|
182
|
+
- un **objeto largo** — para poder darle `selector`, `onSuccess`/`onError`, o envolver un submenú:
|
|
183
|
+
```javascript
|
|
184
|
+
Build: { selector: "build", action: () => {...}, onSuccess: (r, ctx) => {...}, onError: (e, ctx) => {...} }
|
|
185
|
+
Docker: { selector: "docker", menu: dockerMenu }
|
|
186
|
+
// también podés inlinear el submenú directo con su propio selector al lado:
|
|
187
|
+
Docker: { selector: "docker", title: "Docker", "flag-selector": "--docker-action", options: {...} }
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Selección automática por flag
|
|
191
|
+
|
|
192
|
+
Cualquier `MenuDefinition` puede declarar `"flag-selector": "--algun-flag"`. Si el `Context` trae un param que matchea el `selector` de alguno de sus items, esa opción se ejecuta **automáticamente, sin ningún prompt**:
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
const dockerMenu = {
|
|
196
|
+
title: "Docker",
|
|
197
|
+
"flag-selector": "--docker-action",
|
|
198
|
+
options: {
|
|
199
|
+
Build: { selector: "build", action: buildTask },
|
|
200
|
+
Push: { selector: "push", action: pushTask }
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const mainMenu = {
|
|
205
|
+
title: "Pipeline",
|
|
206
|
+
"flag-selector": "--menu-selector",
|
|
207
|
+
options: {
|
|
208
|
+
Docker: { selector: "docker", menu: dockerMenu },
|
|
209
|
+
Deploy: { selector: "deploy", action: deployTask }
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
Menu.render(mainMenu, ctx);
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# encadena ambos niveles en un solo comando, sin ningún prompt interactivo:
|
|
218
|
+
catops-cli --menu-selector=docker --docker-action=build
|
|
219
|
+
|
|
220
|
+
# un solo nivel:
|
|
221
|
+
catops-cli --menu-selector=deploy
|
|
222
|
+
|
|
223
|
+
# sin flags -> menú interactivo normal
|
|
224
|
+
catops-cli
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Si el valor del flag no matchea ningún `selector` del nivel actual, cae de vuelta al menú interactivo (con un warning), en vez de fallar en seco. Cada submenú revisa su **propio** `flag-selector` de forma independiente, así que podés automatizar tantos niveles como quieras encadenando flags.
|
|
228
|
+
|
|
229
|
+
### Callbacks de éxito/error por item
|
|
230
|
+
|
|
231
|
+
```javascript
|
|
232
|
+
Deploy: {
|
|
233
|
+
selector: "deploy",
|
|
234
|
+
action: () => ctx.services.kubectl.apply("deployment.yaml"),
|
|
235
|
+
onSuccess: (result, ctx) => ctx.logger.success("Deploy OK"),
|
|
236
|
+
onError: (error, ctx) => ctx.logger.error(`Deploy falló: ${error.message}`)
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Mismo patrón con `ctx.run()` fuera de un menú:
|
|
241
|
+
|
|
242
|
+
```javascript
|
|
243
|
+
await ctx.run(
|
|
244
|
+
"deploy",
|
|
245
|
+
() => ctx.services.kubectl.apply("deployment.yaml"),
|
|
246
|
+
{
|
|
247
|
+
onSuccess: (result, ctx) => ctx.logger.success("Deploy OK"),
|
|
248
|
+
onError: (error, ctx) => ctx.logger.error(`Deploy falló: ${error.message}`)
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
En ambos casos, además de tus callbacks, el resultado se reporta automáticamente al `ctx.notifier` (ver más abajo) — no hay que llamarlo a mano.
|
|
254
|
+
|
|
255
|
+
## retry / timeout / dryRun — en shell.exec y en TODOS los servicios
|
|
256
|
+
|
|
257
|
+
`shell.exec(command, ...args)` sigue aceptando exactamente los mismos argumentos de siempre. Si el último argumento es un objeto plano, se interpreta como opciones **solo para esa llamada**:
|
|
258
|
+
|
|
259
|
+
```javascript
|
|
260
|
+
await ctx.services.docker.push(image); // igual que siempre
|
|
261
|
+
|
|
262
|
+
await ctx.services.shell.exec("curl", "https://flaky-api.internal", {
|
|
263
|
+
retry: 3, // reintentos totales (default: 1 = sin retry)
|
|
264
|
+
retryDelay: 1000, // ms entre reintentos
|
|
265
|
+
timeout: 5000, // ms antes de matar el proceso con SIGTERM
|
|
266
|
+
dryRun: true // solo loguea el comando, no lo ejecuta
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Todos los comandos de todos los servicios** (`docker`, `git`, `kubectl`, `helm`, `npm`, `archive`, `terraform`, `ansible`, `argocd`, `tekton`, `oc`, `az`) aceptan este mismo control, sin que cambie nada de lo que ya usabas:
|
|
271
|
+
|
|
272
|
+
- Si la función ya recibía un **objeto de opciones** (la mayoría), agregale la clave `exec`:
|
|
273
|
+
```javascript
|
|
274
|
+
await ctx.services.terraform.apply({ autoApprove: true, exec: { retry: 3 } });
|
|
275
|
+
await ctx.services.argocd.appSync("mi-app", { prune: true, exec: { retry: 3 } });
|
|
276
|
+
```
|
|
277
|
+
- Si la función recibe **argumentos posicionales** (strings sueltos), `exec` va como el **último argumento**:
|
|
278
|
+
```javascript
|
|
279
|
+
await ctx.services.docker.push("registry/app:v1", { retry: 3 });
|
|
280
|
+
await ctx.services.git.push({ retry: 3 });
|
|
281
|
+
await ctx.services.helm.uninstall("mi-app", { retry: 3 });
|
|
282
|
+
```
|
|
283
|
+
- `kubectl` y `oc` combinan `exec` en el **mismo objeto** que ya usás para `kubeconfig`/`namespace`:
|
|
284
|
+
```javascript
|
|
285
|
+
// 10 reintentos en un login inestable de OpenShift
|
|
286
|
+
await ctx.services.oc.login({
|
|
287
|
+
server: "https://api.cluster:6443",
|
|
288
|
+
token: process.env.OC_TOKEN,
|
|
289
|
+
namespace: "prod",
|
|
290
|
+
exec: { retry: 10, retryDelay: 2000 }
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await ctx.services.kubectl.apply("deploy.yaml", { namespace: "prod", exec: { retry: 5, timeout: 30000 } });
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Defaults globales para todo el proceso (afecta a todos los comandos que no pasen su propio `exec`/opciones puntuales):
|
|
297
|
+
|
|
298
|
+
```javascript
|
|
299
|
+
ctx.services.shell.configure({ retry: 3, timeout: 30000 });
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`Context.parseArgv()` ya conecta flags de línea de comandos automáticamente a esos defaults globales:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
npx catops-cli --dry-run # activa dryRun global
|
|
306
|
+
npx catops-cli --retry=3 --timeout=15000
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Servicios de infraestructura incluidos
|
|
310
|
+
|
|
311
|
+
```javascript
|
|
312
|
+
await ctx.services.terraform.plan({ varFile: "prod.tfvars" });
|
|
313
|
+
await ctx.services.terraform.apply();
|
|
314
|
+
|
|
315
|
+
await ctx.services.ansible.playbook("site.yml", { inventory: "hosts.ini" });
|
|
316
|
+
|
|
317
|
+
await ctx.services.argocd.appSync("mi-app", { prune: true });
|
|
318
|
+
|
|
319
|
+
await ctx.services.tekton.pipelineStart("build-pipeline", { params: { image: "app:v1" } });
|
|
320
|
+
|
|
321
|
+
await ctx.services.az.acrBuild({ registry: "miregistro", image: "app:v1" });
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## kubectl / oc: kubeconfig, namespace, retry/timeout, y espera cíclica del rollout
|
|
325
|
+
|
|
326
|
+
`kubectl` y `oc` aceptan `{ kubeconfig, namespace, exec }` como último argumento en **todos** sus comandos (retrocompatible, sigue funcionando sin ese argumento — ver la sección anterior para el detalle de `exec`):
|
|
327
|
+
|
|
328
|
+
```javascript
|
|
329
|
+
await ctx.services.kubectl.apply("deploy.yaml", { kubeconfig: "/etc/kube/prod.yaml", namespace: "prod" });
|
|
330
|
+
await ctx.services.kubectl.get("pods", "-o", "wide", { namespace: "staging", exec: { retry: 3 } });
|
|
331
|
+
|
|
332
|
+
await ctx.services.oc.login({ server: "https://api.cluster:6443", token, namespace: "prod", exec: { retry: 10 } });
|
|
333
|
+
await ctx.services.oc.apply("deploy.yaml", { namespace: "prod" });
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### `waitForDeployment` — validación cíclica del rollout
|
|
337
|
+
|
|
338
|
+
Sondea el Deployment (o `DeploymentConfig` con `oc` + `resourceType: "dc"`) hasta que:
|
|
339
|
+
|
|
340
|
+
- llega a **estado exitoso** (réplicas listas/actualizadas == deseadas) → resuelve con `{ status: "success", ... }`,
|
|
341
|
+
- se queda en **estado Failed** más de `failedGracePeriod` sin recuperarse → lanza `DeploymentRolloutError`,
|
|
342
|
+
- supera **`maxRestarts`** reinicios acumulados entre todos sus pods → lanza `DeploymentRolloutError` de inmediato, sin esperar el grace period,
|
|
343
|
+
- o se cumple el **`timeout`** global sin éxito → lanza `DeploymentRolloutError`.
|
|
344
|
+
|
|
345
|
+
En los tres casos de fallo, el polling se detiene y el error se re-lanza — listo para que `ctx.run(...)` lo capture y lo reporte automáticamente vía `ctx.notifier` (el error ya trae `command: "kubectl"` / `command: "oc"`, así que `classifiers.byCommand({ kubectl: "kubernetes" })` lo clasifica sin configuración extra).
|
|
346
|
+
|
|
347
|
+
```javascript
|
|
348
|
+
await ctx.run("deploy-api", async () => {
|
|
349
|
+
await ctx.services.kubectl.apply("deployment.yaml", { namespace: "prod" });
|
|
350
|
+
|
|
351
|
+
return ctx.services.kubectl.waitForDeployment({
|
|
352
|
+
deployment: "api",
|
|
353
|
+
namespace: "prod",
|
|
354
|
+
timeout: 5 * 60 * 1000, // 5 min totales antes de abortar
|
|
355
|
+
pollInterval: 5000, // chequea cada 5s
|
|
356
|
+
failedGracePeriod: 30_000, // si entra en Failed, espera 30s a que se recupere
|
|
357
|
+
maxRestarts: 5 // si supera 5 reinicios acumulados, aborta ya
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### `waitForDeploymentGroup` — validar todas las instancias de un mismo despliegue GitOps
|
|
363
|
+
|
|
364
|
+
Pensada para el caso de GitOps donde un mismo repo termina desplegado como **varios Deployments** (una instancia por región/config/cliente, etc.), todos marcados con un label común:
|
|
365
|
+
|
|
366
|
+
```yaml
|
|
367
|
+
metadata:
|
|
368
|
+
labels:
|
|
369
|
+
deployment-group: repository-14
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Descubre todas las instancias que compartan ese label y corre `waitForDeployment` sobre **cada una en paralelo**, con el mismo `timeout`/`pollInterval`/`failedGracePeriod`/`maxRestarts` para todas:
|
|
373
|
+
|
|
374
|
+
```javascript
|
|
375
|
+
await ctx.run("deploy-repo-14", () =>
|
|
376
|
+
ctx.services.kubectl.waitForDeploymentGroup({
|
|
377
|
+
label: { "deployment-group": "repository-14" }, // o el string ya armado: "deployment-group=repository-14"
|
|
378
|
+
namespace: "prod",
|
|
379
|
+
timeout: 5 * 60 * 1000,
|
|
380
|
+
pollInterval: 5000,
|
|
381
|
+
failedGracePeriod: 30_000,
|
|
382
|
+
maxRestarts: 5
|
|
383
|
+
})
|
|
384
|
+
);
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
- Si **todas** llegan a estado exitoso → resuelve con `{ status: "success", deployments: [...] }` (el detalle de cada una).
|
|
388
|
+
- Si **alguna falla** → espera a que las demás terminen, y lanza `DeploymentGroupRolloutError` con `succeeded` (nombres que sí llegaron) y `failed` (nombre + status + mensaje de cada una que no).
|
|
389
|
+
- Si el label **no matchea ningún deployment**, también lanza `DeploymentGroupRolloutError` (grupo vacío = error, no éxito silencioso).
|
|
390
|
+
|
|
391
|
+
Con `oc`, ambas funciones aceptan `resourceType: "dc"` para apuntar a `DeploymentConfig` clásico en vez de `Deployment` nativo (default: `"deployment"`).
|
|
392
|
+
|
|
393
|
+
## Logging commands de Azure Pipelines (`ctx.services.azdo`)
|
|
394
|
+
|
|
395
|
+
```javascript
|
|
396
|
+
ctx.services.azdo.setVariable("BUILD_TAG", "v1.2.3");
|
|
397
|
+
ctx.services.azdo.logWarning("El caché de npm no se encontró, se reconstruye desde cero.");
|
|
398
|
+
ctx.services.azdo.group("Build");
|
|
399
|
+
// ... pasos ...
|
|
400
|
+
ctx.services.azdo.endGroup();
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Cliente HTTP de Azure DevOps REST API (`AzureDevOpsApi`)
|
|
404
|
+
|
|
405
|
+
Un cliente tipado para la REST API de Azure DevOps (Repos, Builds, Pipelines, Work Items). Usa autenticación Basic con PAT, project-level por defecto, y soporta overrides por llamada.
|
|
406
|
+
|
|
407
|
+
### Configuración
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
import { AzureDevOpsApi } from "catops-cli";
|
|
411
|
+
|
|
412
|
+
const azdo = new AzureDevOpsApi().configure({
|
|
413
|
+
baseUrl: "https://dev.azure.com/miorg",
|
|
414
|
+
pat: process.env.AZDO_PAT,
|
|
415
|
+
project: "mi-proyecto", // project por defecto (opcional)
|
|
416
|
+
apiVersion: "7.1", // default
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Se puede pasar un agente HTTP existente con `agent` para reusar interceptores/configuración:
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
const azdo = new AzureDevOpsApi().configure({
|
|
424
|
+
baseUrl: "https://dev.azure.com/miorg",
|
|
425
|
+
pat: process.env.AZDO_PAT,
|
|
426
|
+
agent: ctx.services.http.agent("azdo") // o un HttpService nuevo
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Proyectos
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
const res = await azdo.listProjects();
|
|
434
|
+
const proj = await azdo.getProject("mi-proyecto");
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Git Repos
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
const repos = await azdo.listRepos();
|
|
441
|
+
const repo = await azdo.getRepo("frontend");
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Branches
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
const branches = await azdo.listBranches("frontend");
|
|
448
|
+
|
|
449
|
+
const exists = await azdo.branchExists("frontend", "feature/login"); // true | false
|
|
450
|
+
|
|
451
|
+
const branch = await azdo.getBranch("frontend", "feature/login");
|
|
452
|
+
// → branch.body.aheadCount, .behindCount, .commit.commitId, ...
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Commits
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
const commits = await azdo.listCommits("frontend", { branch: "main", top: 10 });
|
|
459
|
+
const commit = await azdo.getCommit("frontend", "abc123");
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### Pull Requests
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
const prs = await azdo.listPullRequests("frontend", { status: "active" });
|
|
466
|
+
const pr = await azdo.getPullRequest("frontend", 42);
|
|
467
|
+
const newPr = await azdo.createPullRequest("frontend", {
|
|
468
|
+
sourceRefName: "refs/heads/feature/login",
|
|
469
|
+
targetRefName: "refs/heads/main",
|
|
470
|
+
title: "feat: login",
|
|
471
|
+
description: "Agrega pantalla de login"
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Build Definitions & Builds
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
const defs = await azdo.listBuildDefinitions();
|
|
479
|
+
const def = await azdo.getBuildDefinition(1);
|
|
480
|
+
|
|
481
|
+
const builds = await azdo.listBuilds({ definitionId: 5, top: 10 });
|
|
482
|
+
const build = await azdo.getBuild(100);
|
|
483
|
+
|
|
484
|
+
const queued = await azdo.queueBuild(5, { branch: "main", parameters: { config: "Release" } });
|
|
485
|
+
// → queued.body.id, .status, .buildNumber
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### Pipelines
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
const pipelines = await azdo.listPipelines();
|
|
492
|
+
const pipeline = await azdo.getPipeline(10);
|
|
493
|
+
|
|
494
|
+
const run = await azdo.runPipeline(10, {
|
|
495
|
+
branch: "main",
|
|
496
|
+
variables: { ENV: { value: "production" } }
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### Work Items
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
const wi = await azdo.getWorkItem(123, { fields: ["System.Title", "System.State"] });
|
|
504
|
+
|
|
505
|
+
const query = await azdo.queryWorkItems(
|
|
506
|
+
"SELECT [System.Id] FROM WorkItems WHERE [System.State] = 'Active'"
|
|
507
|
+
);
|
|
508
|
+
// → query.body.workItems → [{ id, url }, ...]
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### Overrides por llamada
|
|
512
|
+
|
|
513
|
+
Cada método acepta un objeto de opciones con `project`, `apiVersion`, `query`, y `exec`:
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
// project override → usa otro proyecto solo para esta llamada
|
|
517
|
+
await azdo.listRepos({ project: "otro-proyecto" });
|
|
518
|
+
|
|
519
|
+
// organization-level → omite el project de la URL
|
|
520
|
+
await azdo.listProjects({ organizationLevel: true });
|
|
521
|
+
|
|
522
|
+
// dry-run — solo loguea la petición HTTP sin enviarla
|
|
523
|
+
await azdo.listRepos({ exec: { dryRun: true } });
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Los tipos de respuesta completos (`AzdoProject`, `AzdoGitRepository`, `AzdoBuild`, etc.) se exportan desde la raíz del paquete para tipado en TypeScript.
|
|
527
|
+
|
|
528
|
+
## Motor de pipelines declarativo (`ctx.services.pipeline`)
|
|
529
|
+
|
|
530
|
+
Un motor de ejecución de pipelines con **stages → jobs → tasks**, dependencias entre entidades, acceso a resultados jerárquico por contexto (`stage.job.task`), tipos de task extensibles, y registro global de pipelines reutilizables.
|
|
531
|
+
|
|
532
|
+
### Estructura flexible
|
|
533
|
+
|
|
534
|
+
La estructura es **completamente opcional en cada nivel** — podés definir un pipeline con stages completos, solo jobs, o solo tasks:
|
|
535
|
+
|
|
536
|
+
```javascript
|
|
537
|
+
// Pipeline completo: stages → jobs → tasks
|
|
538
|
+
const fullPipeline = new Pipeline("deploy");
|
|
539
|
+
fullPipeline
|
|
540
|
+
.stage("build")
|
|
541
|
+
.job("compile")
|
|
542
|
+
.task("install-deps", { exec: async (ctx) => { /* ... */ } })
|
|
543
|
+
.task("compile", { exec: async (ctx) => { /* ... */ } })
|
|
544
|
+
.stage("test")
|
|
545
|
+
.job("unit-tests")
|
|
546
|
+
.task("run-tests", { exec: async (ctx) => { /* ... */ } })
|
|
547
|
+
.stage("deploy")
|
|
548
|
+
.job("push")
|
|
549
|
+
.task("upload", { exec: async (ctx, results) => { /* results.build.compile */ }, depends: ["build.compile"] });
|
|
550
|
+
|
|
551
|
+
await fullPipeline.run(ctx);
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
```javascript
|
|
555
|
+
// Solo tasks (sin stages ni jobs) — acceso flat dentro del mismo job
|
|
556
|
+
const simple = new Pipeline("simple", {
|
|
557
|
+
tasks: {
|
|
558
|
+
build: { exec: async (ctx) => { await ctx.services.docker.build({...}); } },
|
|
559
|
+
test: { exec: async (ctx) => { await ctx.services.shell.exec("npm", "test"); } },
|
|
560
|
+
push: { exec: async (ctx, results) => { await ctx.services.docker.push({...}); }, depends: ["build"] }
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
await simple.run(ctx);
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
```javascript
|
|
567
|
+
// Solo jobs (sin stages)
|
|
568
|
+
const jobsOnly = new Pipeline("ci", {
|
|
569
|
+
jobs: {
|
|
570
|
+
build: { tasks: { compile: { exec: () => "ok" } } },
|
|
571
|
+
test: { tasks: { unit: { exec: () => "pass" } } }
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
await jobsOnly.run(ctx);
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Dependencias
|
|
578
|
+
|
|
579
|
+
Cada task, job, o stage puede declarar `depends: ["nombre"]` — el motor resuelve el orden automáticamente:
|
|
580
|
+
|
|
581
|
+
```javascript
|
|
582
|
+
const pipeline = new Pipeline("ordered");
|
|
583
|
+
pipeline
|
|
584
|
+
.stage("build")
|
|
585
|
+
.job("compile")
|
|
586
|
+
.task("install", { exec: () => "deps installed" })
|
|
587
|
+
.task("compile", { exec: () => "compiled", depends: ["install"] })
|
|
588
|
+
.stage("test")
|
|
589
|
+
.job("unit")
|
|
590
|
+
.task("test", {
|
|
591
|
+
exec: (_, results) => `testing ${results.build.compile.compile}`,
|
|
592
|
+
depends: ["build.compile.compile"] // cross-stage: stage.job.task
|
|
593
|
+
})
|
|
594
|
+
.stage("deploy")
|
|
595
|
+
.job("push")
|
|
596
|
+
.task("upload", {
|
|
597
|
+
exec: (_, results) => `deployed ${results.unit.test}`,
|
|
598
|
+
depends: ["unit.test"] // cross-job mismo stage: job.task
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await pipeline.run(ctx);
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
Las dependencias son **cross-level** — una task en un stage puede depender de una task de otro stage usando paths con dot notation:
|
|
605
|
+
|
|
606
|
+
```javascript
|
|
607
|
+
// Cross-stage: deploy necesita un resultado de build
|
|
608
|
+
.task("upload", {
|
|
609
|
+
exec: (_, results) => `uploaded ${results.build.compile.artifact}`,
|
|
610
|
+
depends: ["build.compile.artifact"] // stage.job.task
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
// Cross-job mismo stage: test necesita algo de build
|
|
614
|
+
.task("verify", {
|
|
615
|
+
exec: (_, results) => `verified ${results.compile.output}`,
|
|
616
|
+
depends: ["compile.output"] // job.task
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
// Mismo job: dependencia directa por nombre
|
|
620
|
+
.task("deploy", {
|
|
621
|
+
exec: (_, results) => `deploy-${results.build}`,
|
|
622
|
+
depends: ["build"] // task (flat)
|
|
623
|
+
})
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Los stages y jobs se ejecutan en **orden secuencial por defecto** (definition order).
|
|
627
|
+
|
|
628
|
+
### Acceso a resultados
|
|
629
|
+
|
|
630
|
+
Los resultados se organizan jerárquicamente: `stage → job → task`. Cada callback recibe `(ctx, results)` donde `results` es un proxy que resuelve por contexto:
|
|
631
|
+
|
|
632
|
+
```javascript
|
|
633
|
+
pipeline
|
|
634
|
+
.stage("build")
|
|
635
|
+
.job("compile")
|
|
636
|
+
.task("compile", { exec: () => "artifact-v1" })
|
|
637
|
+
.stage("deploy")
|
|
638
|
+
.job("push")
|
|
639
|
+
.task("upload", {
|
|
640
|
+
exec: (_, results) => {
|
|
641
|
+
// Mismo job: acceso directo por nombre de task
|
|
642
|
+
// results.myTask = "valor"
|
|
643
|
+
|
|
644
|
+
// Mismo stage, otro job: job.task
|
|
645
|
+
// results.compile.compile = "artifact-v1"
|
|
646
|
+
|
|
647
|
+
// Otro stage: stage.job.task
|
|
648
|
+
// results.build.compile = { compile: "artifact-v1" }
|
|
649
|
+
|
|
650
|
+
return `uploaded ${results.build.compile.compile}`;
|
|
651
|
+
},
|
|
652
|
+
depends: ["build.compile.compile"]
|
|
653
|
+
});
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**Reglas de resolución:**
|
|
657
|
+
|
|
658
|
+
| Contexto | Sintaxis | Ejemplo |
|
|
659
|
+
|---|---|---|
|
|
660
|
+
| Misma task (otro task en el mismo job) | `results.<task>` | `results.build` |
|
|
661
|
+
| Mismo stage, otro job | `results.<job>.<task>` | `results.compile.output` |
|
|
662
|
+
| Otro stage | `results.<stage>.<job>.<task>` | `results.build.compile.artifact` |
|
|
663
|
+
|
|
664
|
+
El proxy intenta resolver en este orden: flat → job path → stage path. El primer match gana.
|
|
665
|
+
|
|
666
|
+
Cada entidad también expone sus resultados vía `.results` (PipelineTask), `.results` (PipelineJob — mapa anidado), y `.getResults()` (PipelineStage/Pipeline).
|
|
667
|
+
|
|
668
|
+
### Registry global de pipelines
|
|
669
|
+
|
|
670
|
+
`ctx.services.pipeline` es un registry — definís pipelines al inicio y los ejecutás por nombre:
|
|
671
|
+
|
|
672
|
+
```javascript
|
|
673
|
+
// Definir pipelines globales
|
|
674
|
+
ctx.services.pipeline.define("build-and-test", {
|
|
675
|
+
tasks: {
|
|
676
|
+
build: { exec: async (ctx) => { await ctx.services.docker.build({...}); } },
|
|
677
|
+
test: { exec: async (ctx) => { await ctx.services.shell.exec("npm", "test"); } }
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
ctx.services.pipeline.define("deploy-prod", {
|
|
682
|
+
stages: {
|
|
683
|
+
build: { jobs: { compile: { tasks: { step: { exec: async (ctx) => { /* ... */ } } } } } },
|
|
684
|
+
deploy: { jobs: { push: { tasks: { step: { exec: async (ctx) => { /* ... */ } } } } } }
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Ejecutar por nombre
|
|
689
|
+
await ctx.services.pipeline.run("build-and-test", ctx);
|
|
690
|
+
await ctx.services.pipeline.run("deploy-prod", ctx);
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
**Gestión de pipelines:**
|
|
694
|
+
|
|
695
|
+
```javascript
|
|
696
|
+
// Listar todos los pipelines registrados
|
|
697
|
+
ctx.services.pipeline.list(); // ["build-and-test", "deploy-prod"]
|
|
698
|
+
|
|
699
|
+
// Obtener un pipeline para modificarlo
|
|
700
|
+
const p = ctx.services.pipeline.get("build-and-test");
|
|
701
|
+
|
|
702
|
+
// Eliminar un pipeline
|
|
703
|
+
ctx.services.pipeline.remove("deploy-prod");
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
### Context access
|
|
707
|
+
|
|
708
|
+
Cada task recibe `ctx` como primer argumento — acceso completo a servicios, flags, params, logger, etc.:
|
|
709
|
+
|
|
710
|
+
```javascript
|
|
711
|
+
pipeline.stage("build").job("compile").task("step1", {
|
|
712
|
+
exec: async (ctx) => {
|
|
713
|
+
ctx.logger.info(`Building with env: ${ctx.params.env}`);
|
|
714
|
+
await ctx.services.docker.build({ image: `app:${ctx.params.version}` });
|
|
715
|
+
ctx.set("image", `app:${ctx.params.version}`);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
### PipelineRunResult
|
|
721
|
+
|
|
722
|
+
`pipeline.run()` devuelve un objeto con status, results (jerárquico), error, y duration:
|
|
723
|
+
|
|
724
|
+
```javascript
|
|
725
|
+
const result = await pipeline.run(ctx);
|
|
726
|
+
|
|
727
|
+
if (result.status === "success") {
|
|
728
|
+
ctx.logger.success(`Pipeline completed in ${result.duration}ms`);
|
|
729
|
+
console.log(result.results);
|
|
730
|
+
// {
|
|
731
|
+
// build: { // stage
|
|
732
|
+
// compile: { // job
|
|
733
|
+
// step1: "compiled" // task
|
|
734
|
+
// }
|
|
735
|
+
// },
|
|
736
|
+
// deploy: {
|
|
737
|
+
// push: {
|
|
738
|
+
// upload: "uploaded-v1"
|
|
739
|
+
// }
|
|
740
|
+
// }
|
|
741
|
+
// }
|
|
742
|
+
} else {
|
|
743
|
+
ctx.logger.error(`Pipeline failed: ${result.error.message}`);
|
|
744
|
+
}
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
### Task types
|
|
748
|
+
|
|
749
|
+
El tipo de ejecución se determina por la **propiedad** presente en la config. No hay campo `type` — la propiedad misma es el tipo:
|
|
750
|
+
|
|
751
|
+
```javascript
|
|
752
|
+
// exec = callback (único type actualmente)
|
|
753
|
+
.task("compile", {
|
|
754
|
+
exec: async (ctx, results) => { /* ... */ }
|
|
755
|
+
})
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Para agregar un nuevo tipo en el futuro, solo se agrega la propiedad al config y el case en `_execute`:
|
|
759
|
+
|
|
760
|
+
```javascript
|
|
761
|
+
// Futuro: shell
|
|
762
|
+
.task("test", {
|
|
763
|
+
shell: { command: "npm", args: ["test"] }
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
// Futuro: docker
|
|
767
|
+
.task("build", {
|
|
768
|
+
docker: { action: "build", image: "app:v1" }
|
|
769
|
+
})
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
El engine detecta `"exec" in config`, `"shell" in config`, etc. y ejecuta la estrategia correspondiente.
|
|
773
|
+
|
|
774
|
+
### Reset y reutilización
|
|
775
|
+
|
|
776
|
+
Los pipelines son reutilizables — `reset()` restaura el estado de todas las entidades:
|
|
777
|
+
|
|
778
|
+
```javascript
|
|
779
|
+
const pipeline = new Pipeline("reusable", {
|
|
780
|
+
tasks: { step: { exec: () => ++count } }
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
await pipeline.run(ctx); // count = 1
|
|
784
|
+
pipeline.reset();
|
|
785
|
+
await pipeline.run(ctx); // count = 2
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
### Servicio HTTP con interceptores (`ctx.services.http`)
|
|
789
|
+
|
|
790
|
+
Un cliente HTTP completo con soporte para **interceptors de request y response**, configurable como servicio global o como instancias independientes.
|
|
791
|
+
|
|
792
|
+
### Uso básico
|
|
793
|
+
|
|
794
|
+
```javascript
|
|
795
|
+
// GET
|
|
796
|
+
const res = await ctx.services.http.get("https://api.example.com/users");
|
|
797
|
+
console.log(res.body); // { users: [...] }
|
|
798
|
+
|
|
799
|
+
// POST
|
|
800
|
+
const res = await ctx.services.http.post("https://api.example.com/users", {
|
|
801
|
+
name: "John",
|
|
802
|
+
email: "john@example.com"
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// PUT / PATCH / DELETE
|
|
806
|
+
await ctx.services.http.put("/users/1", { name: "Jane" });
|
|
807
|
+
await ctx.services.http.patch("/users/1", { email: "new@example.com" });
|
|
808
|
+
await ctx.services.http.delete("/users/1");
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### Configuración global
|
|
812
|
+
|
|
813
|
+
```javascript
|
|
814
|
+
ctx.services.http.configure({
|
|
815
|
+
baseUrl: "https://api.example.com",
|
|
816
|
+
defaultHeaders: {
|
|
817
|
+
"Authorization": `Bearer ${process.env.API_TOKEN}`,
|
|
818
|
+
"Accept": "application/json"
|
|
819
|
+
},
|
|
820
|
+
defaultTimeout: 10000 // 10 segundos
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// Ahora las peticiones son relativas
|
|
824
|
+
await ctx.services.http.get("/users"); // -> GET https://api.example.com/users
|
|
825
|
+
await ctx.services.http.post("/users", data); // -> POST https://api.example.com/users
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
### Query params
|
|
829
|
+
|
|
830
|
+
```javascript
|
|
831
|
+
await ctx.services.http.get("/search", {
|
|
832
|
+
query: { q: "hello", page: 1, active: true }
|
|
833
|
+
});
|
|
834
|
+
// -> GET /search?q=hello&page=1&active=true
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
### Errores HTTP
|
|
838
|
+
|
|
839
|
+
Las respuestas con status 4xx/5xx lanzan un error con metadata completa:
|
|
840
|
+
|
|
841
|
+
```javascript
|
|
842
|
+
try {
|
|
843
|
+
await ctx.services.http.get("/missing");
|
|
844
|
+
} catch (err) {
|
|
845
|
+
console.log(err.status); // 404
|
|
846
|
+
console.log(err.body); // { error: "not found" }
|
|
847
|
+
console.log(err.headers); // { ... }
|
|
848
|
+
console.log(err.request); // { url, method, headers, ... }
|
|
849
|
+
}
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
### Interceptores de request
|
|
853
|
+
|
|
854
|
+
Los interceptors se ejecutan **antes** de cada petición. Pueden mutar el request (headers, auth, logging) o abortarlo:
|
|
855
|
+
|
|
856
|
+
```javascript
|
|
857
|
+
// Agregar token de auth a todas las peticiones
|
|
858
|
+
ctx.services.http.addRequestInterceptor((ctx) => {
|
|
859
|
+
ctx.request.headers["Authorization"] = `Bearer ${process.env.TOKEN}`;
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// Logging de cada petición
|
|
863
|
+
ctx.services.http.addRequestInterceptor((ctx) => {
|
|
864
|
+
console.log(`→ ${ctx.request.method} ${ctx.request.url}`);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// Abortar peticiones a ciertos dominios
|
|
868
|
+
ctx.services.http.addRequestInterceptor((ctx) => {
|
|
869
|
+
if (ctx.request.url.includes("internal")) {
|
|
870
|
+
ctx.abort("blocked by policy");
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
Los interceptors se ejecutan en orden. Si uno aborta, se lanza un error y no se envía la petición.
|
|
876
|
+
|
|
877
|
+
### Interceptores de response
|
|
878
|
+
|
|
879
|
+
Los interceptors se ejecutan **después** de cada respuesta. Pueden transformar el body, loguear, o hacer retry:
|
|
880
|
+
|
|
881
|
+
```javascript
|
|
882
|
+
// Logging de cada respuesta
|
|
883
|
+
ctx.services.http.addResponseInterceptor((ctx) => {
|
|
884
|
+
console.log(`← ${ctx.response.status} ${ctx.request.url}`);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// Transformar la respuesta
|
|
888
|
+
ctx.services.http.addResponseInterceptor((ctx) => {
|
|
889
|
+
if (ctx.response.body?.data) {
|
|
890
|
+
ctx.response.body = ctx.response.body.data;
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
### Gestión de interceptors
|
|
896
|
+
|
|
897
|
+
```javascript
|
|
898
|
+
// Agregar
|
|
899
|
+
const myInterceptor = (ctx) => { /* ... */ };
|
|
900
|
+
ctx.services.http.addRequestInterceptor(myInterceptor);
|
|
901
|
+
ctx.services.http.addResponseInterceptor(myInterceptor);
|
|
902
|
+
|
|
903
|
+
// Eliminar uno específico
|
|
904
|
+
ctx.services.http.removeRequestInterceptor(myInterceptor);
|
|
905
|
+
ctx.services.http.removeResponseInterceptor(myInterceptor);
|
|
906
|
+
|
|
907
|
+
// Limpiar todos
|
|
908
|
+
ctx.services.http.clearRequestInterceptors();
|
|
909
|
+
ctx.services.http.clearResponseInterceptors();
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
### Agentes HTTP nombrados
|
|
913
|
+
|
|
914
|
+
`ctx.services.http` es un **registry** que gestiona agentes HTTP. Cada agente tiene su propia configuración, interceptores y defaults aislados.
|
|
915
|
+
|
|
916
|
+
**Default agent** — directamente en `ctx.services.http`:
|
|
917
|
+
|
|
918
|
+
```javascript
|
|
919
|
+
await ctx.services.http.get("/users");
|
|
920
|
+
await ctx.services.http.post("/users", data);
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
**Agentes nombrados** — para APIs distintas con configuración propia:
|
|
924
|
+
|
|
925
|
+
```javascript
|
|
926
|
+
import { HttpService } from "catops-cli";
|
|
927
|
+
|
|
928
|
+
// Crear y registrar un agente
|
|
929
|
+
ctx.services.http.createAgent(
|
|
930
|
+
new HttpService().configure({
|
|
931
|
+
baseUrl: "https://api.github.com",
|
|
932
|
+
defaultHeaders: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
|
|
933
|
+
}),
|
|
934
|
+
"github"
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
ctx.services.http.createAgent(
|
|
938
|
+
new HttpService().configure({
|
|
939
|
+
baseUrl: "https://internal.mycompany.com/api",
|
|
940
|
+
defaultHeaders: { "X-API-Key": process.env.INTERNAL_KEY }
|
|
941
|
+
}),
|
|
942
|
+
"internal"
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
// Usar por nombre — cada uno tiene interceptores y config aislados
|
|
946
|
+
await ctx.services.http.agent("github").get("/repos/org/repo");
|
|
947
|
+
await ctx.services.http.agent("internal").get("/services/status");
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
**Gestión de agentes:**
|
|
951
|
+
|
|
952
|
+
```javascript
|
|
953
|
+
// Listar todos los agentes registrados
|
|
954
|
+
ctx.services.http.listAgents(); // ["github", "internal"]
|
|
955
|
+
|
|
956
|
+
// Eliminar un agente
|
|
957
|
+
ctx.services.http.removeAgent("github");
|
|
958
|
+
|
|
959
|
+
// Reemplazar un agente existente (mismo nombre)
|
|
960
|
+
ctx.services.http.createAgent(new HttpService().configure({...}), "internal");
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
**Ejemplo completo — interceptores por agente:**
|
|
964
|
+
|
|
965
|
+
```javascript
|
|
966
|
+
const github = new HttpService()
|
|
967
|
+
.configure({
|
|
968
|
+
baseUrl: "https://api.github.com",
|
|
969
|
+
defaultHeaders: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
|
|
970
|
+
})
|
|
971
|
+
.addRequestInterceptor((ctx) => {
|
|
972
|
+
ctx.request.headers["Accept"] = "application/vnd.github.v3+json";
|
|
973
|
+
})
|
|
974
|
+
.addResponseInterceptor((ctx) => {
|
|
975
|
+
if (ctx.response.body?.data) {
|
|
976
|
+
ctx.response.body = ctx.response.body.data;
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
const internal = new HttpService()
|
|
981
|
+
.configure({ baseUrl: "https://internal.mycompany.com/api" })
|
|
982
|
+
.addRequestInterceptor(async (ctx) => {
|
|
983
|
+
const token = await fetchTokenFromVault();
|
|
984
|
+
ctx.request.headers["Authorization"] = `Bearer ${token}`;
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
ctx.services.http.createAgent(github, "github");
|
|
988
|
+
ctx.services.http.createAgent(internal, "internal");
|
|
989
|
+
|
|
990
|
+
// Cada agente usa sus propios interceptores
|
|
991
|
+
await ctx.services.http.agent("github").get("/repos/org/repo");
|
|
992
|
+
await ctx.services.http.agent("internal").get("/services/status");
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
### Opciones por llamada
|
|
996
|
+
|
|
997
|
+
```javascript
|
|
998
|
+
await ctx.services.http.get("/slow-endpoint", {
|
|
999
|
+
timeout: 30000, // override del default
|
|
1000
|
+
headers: { "X-Request-Id": "123" },
|
|
1001
|
+
query: { includeDeleted: false }
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
await ctx.services.http.post("/data", payload, {
|
|
1005
|
+
exec: { dryRun: true } // soporte dry-run
|
|
1006
|
+
});
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
### Interceptors con async/await
|
|
1010
|
+
|
|
1011
|
+
Los interceptors soportan operaciones asíncronas (base de datos, llamadas a servicios, etc.):
|
|
1012
|
+
|
|
1013
|
+
```javascript
|
|
1014
|
+
ctx.services.http.addRequestInterceptor(async (ctx) => {
|
|
1015
|
+
const token = await fetchTokenFromVault();
|
|
1016
|
+
ctx.request.headers["Authorization"] = `Bearer ${token}`;
|
|
1017
|
+
});
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
## Notificaciones: clasificar errores por área de TI, personalizar el mensaje, y enviarlos
|
|
1021
|
+
|
|
1022
|
+
`ctx.notifier` tiene tres responsabilidades independientes:
|
|
1023
|
+
|
|
1024
|
+
1. **`classify()`** — decide a qué **área de TI** pertenece un error (para elegir a qué canal mandarlo).
|
|
1025
|
+
2. **`describeError()`** — decide el **mensaje** a reportar (reemplaza el stderr/stdout crudo por algo humano).
|
|
1026
|
+
3. **`channel()`** / **`onSuccess()`** — a qué **senders** se manda cada área.
|
|
1027
|
+
|
|
1028
|
+
```javascript
|
|
1029
|
+
const { classifiers, messages, senders } = require("catops-cli");
|
|
1030
|
+
|
|
1031
|
+
// 1. ¿A qué área de TI pertenece este error?
|
|
1032
|
+
ctx.notifier.classify(classifiers.byCommand({
|
|
1033
|
+
docker: "containers",
|
|
1034
|
+
kubectl: "kubernetes",
|
|
1035
|
+
oc: "kubernetes",
|
|
1036
|
+
terraform: "infra",
|
|
1037
|
+
ansible: "infra",
|
|
1038
|
+
git: "scm",
|
|
1039
|
+
argocd: "cd-pipeline",
|
|
1040
|
+
tkn: "cd-pipeline",
|
|
1041
|
+
az: "cloud-azure"
|
|
1042
|
+
}));
|
|
1043
|
+
|
|
1044
|
+
// también podés clasificar por el texto del error:
|
|
1045
|
+
ctx.notifier.classify(classifiers.byPattern([
|
|
1046
|
+
[/permission denied|unauthorized/i, "security"],
|
|
1047
|
+
[/timeout|ECONNREFUSED/i, "networking"],
|
|
1048
|
+
[/no space left|ENOSPC/i, "infra"]
|
|
1049
|
+
]));
|
|
1050
|
+
|
|
1051
|
+
// 2. ¿qué mensaje se reporta? (opcional — sin esto, se usa el stderr/stdout crudo)
|
|
1052
|
+
ctx.notifier.describeError(messages.byRule([
|
|
1053
|
+
{
|
|
1054
|
+
command: "docker", args: "push", pattern: /500 Internal Server Error/,
|
|
1055
|
+
message: "Se ha reportado a infraestructura: falta de espacio en el registry"
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
command: "kubectl", pattern: /500/,
|
|
1059
|
+
message: "El API server de Kubernetes devolvió 500, reintenta en unos minutos"
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
command: "terraform", pattern: /500/,
|
|
1063
|
+
message: (error, ctx) => `Backend remoto de Terraform no respondió (env: ${ctx.params.env ?? "?"})`
|
|
1064
|
+
}
|
|
1065
|
+
]));
|
|
1066
|
+
|
|
1067
|
+
// 3. ¿a dónde se manda cada área?
|
|
1068
|
+
ctx.notifier.channel("kubernetes", senders.webhook({ url: process.env.TEAMS_WEBHOOK }));
|
|
1069
|
+
ctx.notifier.channel("security", senders.http({ url: "https://security.miempresa.com/incidents" }));
|
|
1070
|
+
ctx.notifier.channel("*", senders.file({ path: "./catops-cli-errors.log" })); // TODO error, sin importar el área
|
|
1071
|
+
|
|
1072
|
+
// (opcional) éxito, sin clasificación de área
|
|
1073
|
+
ctx.notifier.onSuccess(senders.log());
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
A partir de aquí, cualquier `ctx.run(...)` o item de menú con `action` reporta automáticamente al notifier — no hay que llamarlo a mano en cada task.
|
|
1077
|
+
|
|
1078
|
+
### Clasificadores de área (`classifiers`)
|
|
1079
|
+
|
|
1080
|
+
| Fábrica | Uso |
|
|
1081
|
+
|---|---|
|
|
1082
|
+
| `classifiers.byCommand({ docker: "containers", ... })` | Mapea el comando que falló (adjunto automáticamente por `shell.exec`) a un área. También funciona con `ServiceError`: unwrappea `cause.command` automáticamente. |
|
|
1083
|
+
| `classifiers.byPattern([[regex, area], ...])` | Matchea contra el `stderr`/`stdout`/mensaje del error. Unwrappea `ServiceError.cause` para extraer el texto. |
|
|
1084
|
+
| `classifiers.byService({ docker: "containers", ... })` | Mapea el nombre del **servicio** que falló a un área. Solo matchea `ServiceError` (generados por `ctx.wrap()`). |
|
|
1085
|
+
|
|
1086
|
+
### Formateadores de mensaje (`messages`)
|
|
1087
|
+
|
|
1088
|
+
| Fábrica | Uso |
|
|
1089
|
+
|---|---|
|
|
1090
|
+
| `messages.byPattern([[regex, mensaje], ...])` | Mismo mensaje sin importar el comando — solo mira el texto del error (`stderr`, o `stdout` si `stderr` viene vacío). Unwrappea `ServiceError.cause`. |
|
|
1091
|
+
| `messages.byCommand({ docker: "mensaje fijo" })` | Mensaje fijo por comando, sin importar el detalle del error. Unwrappea `ServiceError.cause.command`. |
|
|
1092
|
+
| `messages.byRule([{ command?, args?, pattern?, message }, ...])` | **La opción avanzada**: combina comando + sub-comando (`args`, distingue `docker push` de `docker build`) + patrón de texto, todo en modo AND. `message` puede ser un string fijo o una función `(error, ctx) => string`. Resuelve el caso de "el mismo 500 puede venir de docker, kubectl o terraform, y cada uno necesita su propio mensaje". Unwrappea `ServiceError.cause`. |
|
|
1093
|
+
| `messages.byService({ docker: "mensaje fijo" })` | Mensaje fijo por nombre de servicio. Solo matchea `ServiceError` (generados por `ctx.wrap()`). |
|
|
1094
|
+
|
|
1095
|
+
> **Errores que salen por `stdout` en vez de `stderr`:** algunos comandos (p. ej. `oc login`, o errores HTTP del API server) imprimen el mensaje en `stdout` y aun así salen con exit code ≠ 0. Como `shell.exec` rechaza con el `ExecResult` completo (que conserva `stdout` y `stderr`), todos los formateadores/clasificadores de `messages`/`classifiers` prueban primero `stderr` y, si viene vacío, caen a `stdout`. No hace falta configuración extra — el mismo `describeError`/`classify` que usás hoy funciona aunque el texto vaya por stdout:
|
|
1096
|
+
|
|
1097
|
+
```javascript
|
|
1098
|
+
ctx.notifier.describeError(messages.byPattern([
|
|
1099
|
+
[/500 Internal Server Error/, "Se ha reportado a infraestructura: falta de espacio en el registry"],
|
|
1100
|
+
[/unauthorized|403/i, "Credenciales inválidas contra el registry, revisa el secret"]
|
|
1101
|
+
]));
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
Si ningún classifier/formatter matchea, se usa el área `"unclassified"` y el mensaje crudo del error, respectivamente — nada se rompe si no configurás nada de esto.
|
|
1105
|
+
|
|
1106
|
+
### Senders incluidos
|
|
1107
|
+
|
|
1108
|
+
| Sender | Uso |
|
|
1109
|
+
|---|---|
|
|
1110
|
+
| `senders.log()` | Usa el logger interno (consola) |
|
|
1111
|
+
| `senders.file({ path })` | Agrega el evento como una línea JSON al archivo |
|
|
1112
|
+
| `senders.http({ url, method?, headers?, formatBody? })` | `POST` genérico del evento como JSON |
|
|
1113
|
+
| `senders.webhook({ url, format? })` | Como `http`, pero formatea `{ text: "❌ ..." }` por defecto — compatible con Slack/Discord y con **Microsoft Teams** vía Workflows (Power Automate), pasando un `format` que arme el payload de Adaptive Card que Teams espera |
|
|
1114
|
+
| `senders.websocket({ url, timeout? })` | Abre una conexión WS, manda el evento como JSON y cierra. Requiere Node ≥21 (usa el `WebSocket` global) |
|
|
1115
|
+
|
|
1116
|
+
Podés escribir tu propio sender: es cualquier función `(event) => void | Promise<void>` — recibe `{ type, taskId, area?, error?, result?, message, timestamp, service?, method?, args? }`.
|
|
1117
|
+
|
|
1118
|
+
Los campos `service`, `method` y `args` solo están presentes cuando el error viene de `ctx.wrap()` (un `ServiceError`).
|
|
1119
|
+
|
|
1120
|
+
## Notificaciones de servicios: `ctx.wrap()`
|
|
1121
|
+
|
|
1122
|
+
`ctx.wrap(service, serviceName)` envuelve un objeto de servicio en un **Proxy** que intercepta cada llamada a método. Si el método falla, el error se envuelve automáticamente en un `ServiceError` con metadata del servicio y se despacha al `ctx.notifier` antes de re-lanzarlo.
|
|
1123
|
+
|
|
1124
|
+
### Uso básico
|
|
1125
|
+
|
|
1126
|
+
```typescript
|
|
1127
|
+
// Envolver servicios que quieras monitorear
|
|
1128
|
+
const docker = ctx.wrap(ctx.services.docker, "docker");
|
|
1129
|
+
const kubectl = ctx.wrap(ctx.services.kubectl, "kubectl");
|
|
1130
|
+
|
|
1131
|
+
// Configurar classifiers y canales (igual que siempre)
|
|
1132
|
+
ctx.notifier
|
|
1133
|
+
.classify(classifiers.byCommand({ docker: "containers", kubectl: "kubernetes" }))
|
|
1134
|
+
.classify(classifiers.byService({ docker: "containers", kubectl: "kubernetes" }))
|
|
1135
|
+
.channel("containers", senders.webhook({ url: process.env.SLACK_WEBHOOK }))
|
|
1136
|
+
.channel("kubernetes", senders.webhook({ url: process.env.TEAMS_WEBHOOK }));
|
|
1137
|
+
|
|
1138
|
+
// Cualquier fallo auto-notifica con contexto completo
|
|
1139
|
+
await docker.push("myimage:latest");
|
|
1140
|
+
// → taskId: 'docker.push("myimage:latest")'
|
|
1141
|
+
// → error: ServiceError { service: "docker", method: "push", args: ["myimage:latest"], cause: ExecResult }
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
### Qué information llega al sender
|
|
1145
|
+
|
|
1146
|
+
Cuando `ctx.wrap()` captura un error, el `NotificationEvent` incluye:
|
|
1147
|
+
|
|
1148
|
+
| Campo | Tipo | Descripción |
|
|
1149
|
+
|---|---|---|
|
|
1150
|
+
| `taskId` | `string` | Nombre generado: `service.method(args serializados)`, ej. `docker.push("myimage:latest")` |
|
|
1151
|
+
| `service` | `string` | Nombre del servicio: `"docker"`, `"kubectl"`, `"http"`, etc. |
|
|
1152
|
+
| `method` | `string` | Método que falló: `"push"`, `"apply"`, `"request"`, etc. |
|
|
1153
|
+
| `args` | `unknown[]` | Argumentos originales pasados al método |
|
|
1154
|
+
| `error` | `ServiceError` | El error completo (`.cause` contiene el error original) |
|
|
1155
|
+
| `area` | `string` | Área de TI resuelta por los classifiers |
|
|
1156
|
+
| `message` | `string` | Mensaje resuelto por los formatters, o `ServiceError.message` |
|
|
1157
|
+
|
|
1158
|
+
### ServiceError
|
|
1159
|
+
|
|
1160
|
+
`ServiceError` extiende `Error` y contiene:
|
|
1161
|
+
|
|
1162
|
+
```typescript
|
|
1163
|
+
class ServiceError extends Error {
|
|
1164
|
+
readonly service: string; // "docker", "http", etc.
|
|
1165
|
+
readonly method: string; // "push", "request", etc.
|
|
1166
|
+
readonly args: unknown[]; // argumentos originales
|
|
1167
|
+
readonly cause: unknown; // error original (ExecResult, HttpError, Error, etc.)
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
La propiedad `cause` contiene el error original — los classifiers y formatters existentes (`byCommand`, `byPattern`, `byRule`) unwrappean `ServiceError.cause` automáticamente, así que la configuración que ya tenés sigue funcionando sin cambios.
|
|
1172
|
+
|
|
1173
|
+
### Combinación con classifiers existentes
|
|
1174
|
+
|
|
1175
|
+
`byCommand` y `byPattern` unwrappean `ServiceError.cause` automáticamente. Esto significa que un `docker.push()` que falla con un error de shell (que tiene `command: "docker"`) se clasifica correctamente vía `byCommand`, y un `http.request()` que falla con un `status: 504` se clasifica vía `byPattern`:
|
|
1176
|
+
|
|
1177
|
+
```typescript
|
|
1178
|
+
ctx.notifier
|
|
1179
|
+
// byCommand unwrappea ServiceError.cause.command → "docker" → "containers"
|
|
1180
|
+
.classify(classifiers.byCommand({ docker: "containers", kubectl: "kubernetes" }))
|
|
1181
|
+
|
|
1182
|
+
// byPattern unwrappea ServiceError.cause.stderr → /permission denied/ → "security"
|
|
1183
|
+
.classify(classifiers.byPattern([
|
|
1184
|
+
[/permission denied|unauthorized/i, "security"],
|
|
1185
|
+
[/timeout|ECONNREFUSED/i, "networking"]
|
|
1186
|
+
]))
|
|
1187
|
+
|
|
1188
|
+
// byService matchea directamente ServiceError.service → "http" → "networking"
|
|
1189
|
+
.classify(classifiers.byService({ http: "networking" }))
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
El orden importa: el primer classifier que matchea gana. Usá `byCommand`/`byPattern` primero (más específico) y `byService` como fallback.
|
|
1193
|
+
|
|
1194
|
+
### Mensajes personalizados para servicios
|
|
1195
|
+
|
|
1196
|
+
```typescript
|
|
1197
|
+
ctx.notifier
|
|
1198
|
+
// byService: mensaje fijo por nombre de servicio
|
|
1199
|
+
.describeError(messages.byService({
|
|
1200
|
+
docker: "Falló una operación de Docker, revisa el build/push del registry",
|
|
1201
|
+
http: "Falló una petición HTTP, revisa la conectividad"
|
|
1202
|
+
}))
|
|
1203
|
+
|
|
1204
|
+
// byRule unwrappea ServiceError.cause → distingue por comando + args + patrón
|
|
1205
|
+
.describeError(messages.byRule([
|
|
1206
|
+
{
|
|
1207
|
+
command: "docker", args: "push", pattern: /500 Internal Server Error/,
|
|
1208
|
+
message: "Falta espacio en el registry"
|
|
1209
|
+
},
|
|
1210
|
+
{
|
|
1211
|
+
command: "kubectl", pattern: /500/,
|
|
1212
|
+
message: "El API server de Kubernetes devolvió 500"
|
|
1213
|
+
}
|
|
1214
|
+
]));
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
### Propiedades no-función pasan sin proxy
|
|
1218
|
+
|
|
1219
|
+
El Proxy solo intercepta llamadas a métodos. Las propiedades que no son funciones (strings, números, objetos) pasan directamente:
|
|
1220
|
+
|
|
1221
|
+
```typescript
|
|
1222
|
+
const docker = ctx.wrap(ctx.services.docker, "docker");
|
|
1223
|
+
docker.version; // pasa directo, sin proxy
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
## Tests
|
|
1227
|
+
|
|
1228
|
+
```bash
|
|
1229
|
+
npm test
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
`pretest` corre el build automáticamente, así los tests validan el `dist/` real que se publica (no el código fuente). Usa `node:test`, sin dependencias externas — interceptando `shell.exec` en vez de ejecutar binarios reales:
|
|
1233
|
+
|
|
1234
|
+
- `context.test.js` — Context, flags, vars, parseArgv, dryRun global
|
|
1235
|
+
- `shell.test.js` — retry, timeout, dryRun del motor shell.exec
|
|
1236
|
+
- `services.test.js` — que docker/terraform/argocd arman bien sus argumentos
|
|
1237
|
+
- `menu-selector.test.js` — selección automática por flag, en cascada de varios niveles
|
|
1238
|
+
- `hooks-integration.test.js` — ctx.run() y items de menú con onSuccess/onError
|
|
1239
|
+
- `notifier.test.js` — classify/channel/onSuccess/describeError/byRule
|
|
1240
|
+
- `senders.test.js` — file/http/webhook/log contra servidores reales en localhost
|
|
1241
|
+
- `http.test.js` — servicio HTTP: métodos, headers, query, interceptors, abort, timeout, dryRun, múltiples instancias aisladas
|
|
1242
|
+
- `kubectl.test.js`, `oc.test.js` — kubeconfig/namespace, waitForDeployment (éxito, timeout, Failed, maxRestarts)
|
|
1243
|
+
- `exec-options-passthrough.test.js` — retry/timeout/dryRun (`exec`) llegando a todos los servicios, incluyendo un retry real que se recupera tras 2 fallos
|
|
1244
|
+
- `deployment-group.test.js` — waitForDeploymentGroup (éxito total, fallo parcial, label sin matches, oc con `dc`)
|
|
1245
|
+
- `azdo-api.test.js` — AzureDevOpsApi contra mock server: proyectos, repos, branches, commits, PRs, builds, pipelines, work items, overrides
|
|
1246
|
+
- `pipeline.test.js` — Pipeline motor: stages/jobs/tasks, dependencias cross-level con paths dotted, resultados jerárquicos (stage.job.task), fluent API, registry, reset, ctx access, detección de tipo por propiedad, PipelineResultsAccessor
|
|
1247
|
+
- `service-notify.test.js` — ServiceError, ctx.wrap(), Notifier con service/method/args, classifiers.byService/messages.byService, unwrap de ServiceError en byCommand/byPattern/byRule, integración completa, backward compat
|
|
1248
|
+
|
|
1249
|
+
## Siguientes pasos posibles
|
|
1250
|
+
|
|
1251
|
+
- Publicar en un registro privado (Verdaccio/Artifactory/GitHub Packages) para instalarlo con scope, p.ej. `@miorg/catops-cli`.
|
|
1252
|
+
- Agregar más plugins (`ansible-lint`, `trivy`, `sonar-scanner`) con el mismo patrón que `terraform.ts`/`docker.ts`.
|
|
1253
|
+
- CI propio (GitHub Actions/Azure Pipelines) que corra `npm test` en cada PR antes de `npm publish`.
|
|
1254
|
+
- `--catch=throw` (o similar) para que un item de menú fallido mate el proceso completo en vez de solo loguear y seguir — útil corriendo vía `--menu-selector` dentro de un step de Azure Pipelines.
|