kcreate-scaffolding-gcp-script 0.0.0-stage → 0.2.1

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,788 @@
1
+ #!/usr/bin/env bash
2
+ # Funciones compartidas por los scripts numerados de este skill.
3
+ # Se importa con `source`, nunca se ejecuta directamente.
4
+
5
+ ksc_ok() { printf '\033[32m[OK]\033[0m %s\n' "$1"; }
6
+ ksc_info() { printf '[INFO] %s\n' "$1"; }
7
+ # Si $KSC_WARNINGS_FILE está definida (10_build_components.sh la fija),
8
+ # cada WARN también se guarda ahí — así sobreviven si la sesión del agente
9
+ # muere a mitad de la construcción (Review V1, faltante #17).
10
+ ksc_warn() {
11
+ printf '\033[33m[WARN]\033[0m %s\n' "$1"
12
+ [[ -n "${KSC_WARNINGS_FILE:-}" ]] && printf '%s\n' "$1" >> "$KSC_WARNINGS_FILE"
13
+ return 0
14
+ }
15
+ ksc_err() { printf '\033[31m[ERROR]\033[0m %s\n' "$1" >&2; }
16
+ ksc_die() { ksc_err "$1"; exit 1; }
17
+
18
+ # ksc_require_cmd <cmd> [<cmd>...] -> DETENTE con mensaje claro si falta
19
+ # alguno. Nunca falla a medias por un `command not found` confuso.
20
+ ksc_require_cmd() {
21
+ local missing=() c
22
+ for c in "$@"; do
23
+ command -v "$c" >/dev/null 2>&1 || missing+=("$c")
24
+ done
25
+ [[ ${#missing[@]} -eq 0 ]] || ksc_die "faltan estos comandos requeridos: ${missing[*]}"
26
+ }
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Ubicación del repo destino (donde este skill quedó instalado en
30
+ # .claude/skills/kcreate-scaffolding-gcp-script/). Todos los scripts se
31
+ # invocan con el cwd en la raíz de ese repo — lo validamos aquí.
32
+ # ---------------------------------------------------------------------------
33
+ ksc_repo_root() {
34
+ local root
35
+ root="$(pwd)"
36
+ [[ -d "$root/live" ]] || ksc_die \
37
+ "este script debe correrse desde la raíz del repo destino (debe existir ./live/). cwd actual: $root"
38
+ [[ -d "$root/.git" ]] || ksc_die "cwd actual no es la raíz de un repo git: $root"
39
+ printf '%s\n' "$root"
40
+ }
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Descubrimiento de componentes: nunca hardcodeado. Un componente es
44
+ # cualquier subdirectorio directo de live/<env>/ que contenga terragrunt.hcl.
45
+ # Esto generaliza el skill a cualquier entorno fuente, con cualquier
46
+ # combinación de componentes (base + opcionales), sin mantener una lista fija.
47
+ # ---------------------------------------------------------------------------
48
+ ksc_discover_components() {
49
+ local env_dir="$1"
50
+ [[ -d "$env_dir" ]] || ksc_die "no existe $env_dir"
51
+ find "$env_dir" -mindepth 1 -maxdepth 1 -type d | sort | while read -r d; do
52
+ [[ -f "$d/terragrunt.hcl" ]] && basename "$d"
53
+ done
54
+ }
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Lista de entornos "reales" de live/ para cualquier cómputo que compare
58
+ # contra "todo live/" (base de componentes, doctor, mayoría de IPs
59
+ # fijas): solo cuentan los que (a) siguen el patrón <app>-qa|prod> (no
60
+ # "shared" ni otro proyecto de infraestructura compartida) y (b) tienen
61
+ # AL MENOS UN archivo comiteado en git bajo su directorio — este skill
62
+ # nunca comitea nada, así que cualquier intento (completo o parcial,
63
+ # propio o de otro NEW_ENV) queda excluido sin tener que conocer su
64
+ # nombre. `exclude_env` (normalmente $NEW_ENV) se descarta explícito
65
+ # además, por claridad.
66
+ #
67
+ # Consolidado en un solo lugar (Review V2, N-1): la primera corrección
68
+ # solo aplicó este filtro a ksc_base_components; ksc_check_source_health
69
+ # y ksc_majority_fixed_network tenían el mismo filtro incompleto
70
+ # (solo por nombre) y se contaminaban igual con entornos de prueba sin
71
+ # comitear — encontrado probando el fix de N-1 con varios entornos
72
+ # escalonados en la misma copia de prueba.
73
+ # ---------------------------------------------------------------------------
74
+ ksc_real_envs() {
75
+ local repo_root="$1" exclude_env="${2:-}"
76
+ local env_dir env_name
77
+ for env_dir in "$repo_root"/live/*/; do
78
+ [[ -d "$env_dir" ]] || continue
79
+ env_name="$(basename "$env_dir")"
80
+ [[ -n "$exclude_env" && "$env_name" == "$exclude_env" ]] && continue
81
+ [[ "$env_name" =~ ^[a-z0-9]+-(qa|prod)$ ]] || continue
82
+ [[ -z "$(cd "$repo_root" && git ls-files -- "live/$env_name/" 2>/dev/null | head -1)" ]] && continue
83
+ printf '%s\n' "$env_name"
84
+ done
85
+ }
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Componentes "base": los que existen en TODOS los entornos de live/, sin
89
+ # hardcodear nombres (ni "14 componentes", ni la lista de los 4 opcionales
90
+ # de la guía). Un componente que solo existe en algunos entornos (ej.
91
+ # cloud_armor, secrets) queda fuera de la base por definición — exactamente
92
+ # los que la guía (§1.2) pide no incluir salvo pedido explícito. Esto
93
+ # resuelve la desviación real encontrada en Review V1 (sección 3.2, fila
94
+ # §1.2): antes se copiaba cualquier componente que SOURCE_ENV tuviera.
95
+ #
96
+ # ksc_base_components <repo_root> [exclude_env]
97
+ #
98
+ # Review V2 (N-1, CRÍTICO): la intersección contra TODO directorio de
99
+ # live/ que cumpla el patrón de nombre también cuenta un `live/<NEW_ENV>/`
100
+ # a medio construir (justo el estado que deja un reintento, que el fix de
101
+ # E-1 volvió el escenario normal) — y cualquier otra carpeta parcial
102
+ # vieja que haya quedado de un intento abandonado, de CUALQUIER nombre,
103
+ # contamina la base para TODAS las construcciones futuras, no solo la
104
+ # propia. Reproducido: un reintento con un parcial de 2 componentes
105
+ # reconstruía solo esos 2, y `FILE_PARITY_OK`/`PROJECT_PINNING_OK` le
106
+ # daban luz verde igual, porque la validación usa la misma función.
107
+ #
108
+ # Dos capas de defensa, no solo una:
109
+ # 1. `exclude_env` (normalmente $NEW_ENV) nunca cuenta — resuelve la
110
+ # auto-contaminación del propio reintento.
111
+ # 2. Un entorno solo cuenta si tiene AL MENOS UN archivo comiteado en
112
+ # git bajo su `live/<env>/` — este skill nunca comitea nada, así
113
+ # que cualquier intento abandonado (completo o parcial) es, por
114
+ # definición, 100% no comiteado y queda fuera. Esto resuelve la
115
+ # contaminación cruzada (un parcial de OTRO entorno, con OTRO
116
+ # nombre, ya no envenena nada) sin tener que listar nombres
117
+ # conocidos ni asumir que "bien formado" == "completo".
118
+ # ---------------------------------------------------------------------------
119
+ ksc_base_components() {
120
+ local repo_root="$1" exclude_env="${2:-}"
121
+ local base_set=() first=1 d comp env_name
122
+ local real_envs=()
123
+ mapfile -t real_envs < <(ksc_real_envs "$repo_root" "$exclude_env")
124
+ # Review V3 (observación, mejora #6): si no hay NINGÚN entorno
125
+ # comiteado, `ksc_real_envs` da vacío y la base también quedaría
126
+ # vacía en silencio — en un repo de infra real esto no pasa (siempre
127
+ # tiene historia), pero avisa explícito en vez de dejar que el build
128
+ # excluya todo sin explicación clara.
129
+ if [[ "${#real_envs[@]}" -eq 0 ]]; then
130
+ ksc_warn "no hay ningún entorno comiteado en git bajo live/ (¿repo recién creado sin commit inicial, o estás en la raíz equivocada?) — la base de componentes queda vacía; todo se excluirá salvo lo que EXTRA_COMPONENTS pida explícito."
131
+ fi
132
+ for env_name in "${real_envs[@]}"; do
133
+ mapfile -t comps_here < <(ksc_discover_components "$repo_root/live/$env_name")
134
+ if [[ "$first" -eq 1 ]]; then
135
+ base_set=("${comps_here[@]}")
136
+ first=0
137
+ else
138
+ local kept=()
139
+ for comp in "${base_set[@]}"; do
140
+ for d in "${comps_here[@]}"; do
141
+ [[ "$comp" == "$d" ]] && { kept+=("$comp"); break; }
142
+ done
143
+ done
144
+ base_set=("${kept[@]}")
145
+ fi
146
+ done
147
+ printf '%s\n' "${base_set[@]}" | sort
148
+ }
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Extractor simple de valores escalares de un terragrunt.hcl.
152
+ # Solo sirve para líneas `clave = "valor"` o `clave = valor` (sin
153
+ # expresiones). Si la clave aparece varias veces (ej. dentro de bloques
154
+ # repetidos), devuelve la primera coincidencia — para esos casos usar
155
+ # ksc_hcl_get_all en su lugar.
156
+ # ---------------------------------------------------------------------------
157
+ ksc_hcl_get() {
158
+ local file="$1" key="$2"
159
+ [[ -f "$file" ]] || return 1
160
+ grep -m1 -E "^[[:space:]]*${key}[[:space:]]*=" "$file" \
161
+ | sed -E 's/^[^=]*=[[:space:]]*//' \
162
+ | sed -E 's/^"(.*)"[[:space:]]*(#.*)?$/\1/' \
163
+ | sed -E 's/[[:space:]]*(#.*)?$//'
164
+ }
165
+
166
+ ksc_hcl_get_all() {
167
+ local file="$1" key="$2"
168
+ [[ -f "$file" ]] || return 1
169
+ grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" \
170
+ | sed -E 's/^[^=]*=[[:space:]]*//' \
171
+ | sed -E 's/^"(.*)"[[:space:]]*(#.*)?$/\1/' \
172
+ | sed -E 's/[[:space:]]*(#.*)?$//'
173
+ }
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # PROJECT_NAME inferido de NEW_ENV, según la convención real observada en
177
+ # los entornos existentes (Boletera-QA, Boletera-Prod, Cashless-QA,
178
+ # Xoloshop-QA): <App-con-primera-mayúscula>-<ENTORNO>, donde ENTORNO es
179
+ # "QA" en mayúsculas o "Prod" con mayúscula inicial.
180
+ # ---------------------------------------------------------------------------
181
+ ksc_infer_project_name() {
182
+ local new_env="$1"
183
+ local app="${new_env%-*}"
184
+ local entorno="${new_env##*-}"
185
+ local app_cap entorno_cap
186
+ app_cap="$(printf '%s' "${app:0:1}" | tr '[:lower:]' '[:upper:]')${app:1}"
187
+ case "$entorno" in
188
+ qa) entorno_cap="QA" ;;
189
+ prod) entorno_cap="Prod" ;;
190
+ *) entorno_cap="$(printf '%s' "${entorno:0:1}" | tr '[:lower:]' '[:upper:]')${entorno:1}" ;;
191
+ esac
192
+ printf '%s-%s\n' "$app_cap" "$entorno_cap"
193
+ }
194
+
195
+ # ksc_split_env <new_env> -> imprime "app entorno" (separados por espacio)
196
+ ksc_split_env() {
197
+ local new_env="$1"
198
+ printf '%s %s\n' "${new_env%-*}" "${new_env##*-}"
199
+ }
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # IP pública actual, obtenida en el momento (nunca cacheada ni reusada de
203
+ # una ejecución anterior — el ISP del usuario cambia).
204
+ # ---------------------------------------------------------------------------
205
+ ksc_fresh_public_ip() {
206
+ local ip
207
+ ip="$(curl -s --max-time 5 https://ifconfig.me 2>/dev/null)"
208
+ if [[ -z "$ip" ]]; then
209
+ ip="$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null)"
210
+ fi
211
+ [[ -n "$ip" ]] || ksc_die "no se pudo obtener la IP pública actual (ifconfig.me y api.ipify.org fallaron)"
212
+ printf '%s\n' "$ip"
213
+ }
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Validación/cálculo de CIDR: delegado a python3 (stdlib `ipaddress`), nunca
217
+ # a juicio del agente. Determinístico: mismos rangos existentes -> mismo
218
+ # resultado siempre.
219
+ #
220
+ # ksc_cidr_pick_free <existing_ranges_file>
221
+ # Busca un rango nuevo recorriendo por DÉCADA (10.<D>.0.0/16 para
222
+ # sql_private_access, D = 0,10,20,...) y, dentro de cada década, por
223
+ # offset de subred (10.<D+off>.0.0/22, off = 3,1,2,4..9 — 3 primero
224
+ # porque es la convención real dominante en este repo: boletera-qa,
225
+ # cashless-qa/prod usan subred = década+3). pods/svc se derivan del
226
+ # mismo <D+off> (+102/+106), igual que antes.
227
+ #
228
+ # Revisión real (Review V1, E-2): la versión anterior probaba un solo
229
+ # valor de N para los 4 bloques y solo lo validaba contra los rangos
230
+ # YA EXISTENTES, nunca entre sí — cuando N resultaba múltiplo de 10, la
231
+ # subred (10.N.0.0/22) quedaba contenida en su propio
232
+ # sql_private_access (10.N.0.0/16), un solapamiento real que solo se
233
+ # detecta desplegando. Al forzar off >= 1, la subred nunca cae en la
234
+ # década exacta de su propio /16 — el solapamiento queda excluido por
235
+ # construcción, no solo por un chequeo adicional (que también se agrega,
236
+ # por si la fórmula cambia en el futuro).
237
+ # Imprime 4 líneas: subred pods svc sql_private_access.
238
+ # ---------------------------------------------------------------------------
239
+ ksc_cidr_pick_free() {
240
+ local existing_file="$1"
241
+ python3 - "$existing_file" <<'PYEOF'
242
+ import ipaddress
243
+ import sys
244
+
245
+ existing_file = sys.argv[1]
246
+ existing = []
247
+ with open(existing_file) as f:
248
+ for line in f:
249
+ line = line.strip()
250
+ if not line:
251
+ continue
252
+ try:
253
+ existing.append(ipaddress.ip_network(line, strict=False))
254
+ except ValueError:
255
+ continue
256
+
257
+ def candidate(decada, off):
258
+ try:
259
+ subnet = ipaddress.ip_network(f"10.{decada + off}.0.0/22", strict=False)
260
+ pods = ipaddress.ip_network(f"10.{decada + off + 102}.0.0/17", strict=False)
261
+ svc = ipaddress.ip_network(f"10.{decada + off + 106}.0.0/23", strict=False)
262
+ priv = ipaddress.ip_network(f"10.{decada}.0.0/16", strict=False)
263
+ except ValueError:
264
+ return None
265
+ return subnet, pods, svc, priv
266
+
267
+ def collides(nets, pool):
268
+ for a in nets:
269
+ for b in pool:
270
+ if a.overlaps(b):
271
+ return True
272
+ for i, a in enumerate(nets):
273
+ for b in nets[i + 1:]:
274
+ if a.overlaps(b):
275
+ return True
276
+ return False
277
+
278
+ # Orden de búsqueda de décadas (Review V2, N-7): preferir la primera
279
+ # década libre POR ENCIMA de la mayor ya usada, y nunca usar la década 0
280
+ # (10.0.x.x se reserva convencionalmente para VPN/defaults/on-prem) salvo
281
+ # que ya no quede ninguna otra libre.
282
+ #
283
+ # Tope real de 140 (Review V3, P-4): svc = 10.<decada+off+106>.0.0/23
284
+ # exige decada+off <= 149 para que el segundo octeto no pase de 255; con
285
+ # el off mínimo (1), eso limita la década máxima usable a 140 — cualquier
286
+ # década >= 150 siempre da `candidate() == None` sin importar el off, así
287
+ # que ni vale la pena generarla. (Una versión anterior de este comentario
288
+ # decía "el siguiente lógico es 150" — eso es matemáticamente imposible
289
+ # con esta fórmula; corregido para no inducir a buscar un comportamiento
290
+ # que no existe.)
291
+ existing_decades = set()
292
+ for net in existing:
293
+ # Solo /16 cuentan como "década" de sql_private_access.
294
+ if net.prefixlen == 16:
295
+ existing_decades.add(int(str(net).split(".")[1]))
296
+ max_decada = max(existing_decades) if existing_decades else 0
297
+ MAX_USABLE_DECADA = 140
298
+ decada_order = list(range(max_decada + 10, MAX_USABLE_DECADA + 1, 10))
299
+ decada_order += [d for d in range(10, MAX_USABLE_DECADA + 1, 10) if d not in decada_order]
300
+ decada_order += [0] # última opción, nunca primera
301
+
302
+ for decada in decada_order:
303
+ for off in (3, 1, 2, 4, 5, 6, 7, 8, 9):
304
+ cand = candidate(decada, off)
305
+ if cand is None:
306
+ continue
307
+ if not collides(cand, existing):
308
+ subnet, pods, svc, priv = cand
309
+ print(subnet)
310
+ print(pods)
311
+ print(svc)
312
+ print(priv)
313
+ sys.exit(0)
314
+
315
+ print("NO_FREE_RANGE_FOUND", file=sys.stderr)
316
+ sys.exit(1)
317
+ PYEOF
318
+ }
319
+
320
+ # ksc_cidr_validate <cidr...> -> exit 0 si todos son válidos, exit 1 si no.
321
+ ksc_cidr_validate() {
322
+ python3 -c "
323
+ import ipaddress, sys
324
+ for c in sys.argv[1:]:
325
+ ipaddress.ip_network(c, strict=False)
326
+ " "$@"
327
+ }
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Archivo de configuración: campos todavía marcados __PENDIENTE__.
331
+ # ---------------------------------------------------------------------------
332
+ ksc_config_pending_fields() {
333
+ local file="$1"
334
+ grep -E '^[A-Z_]+="__PENDIENTE__"' "$file" | sed -E 's/^([A-Z_]+)=.*/\1/'
335
+ }
336
+
337
+ ksc_config_path() {
338
+ local repo_root="$1" new_env="$2"
339
+ printf '%s/.kcreate-scaffolding/%s.config\n' "$repo_root" "$new_env"
340
+ }
341
+
342
+ ksc_cidr_file_path() {
343
+ local repo_root="$1" new_env="$2"
344
+ printf '%s/.kcreate-scaffolding/%s.cidr\n' "$repo_root" "$new_env"
345
+ }
346
+
347
+ ksc_warnings_file_path() {
348
+ local repo_root="$1" new_env="$2"
349
+ printf '%s/.kcreate-scaffolding/%s.warnings\n' "$repo_root" "$new_env"
350
+ }
351
+
352
+ # Archivo de warnings SOLO de la Construcción (10_build_components.sh),
353
+ # separado del de 02_print_config.sh (doctor). Review V3 (P-3): cuando
354
+ # ambos compartían un único archivo en modo *append*, cada reintento de
355
+ # `10` repetía sus propios WARN sobre los de la corrida anterior (8 ->
356
+ # 14 líneas con un solo retry) — ruido que crece sin límite con cada
357
+ # reintento. Separar los archivos permite que `10` trunque el SUYO en
358
+ # cada corrida (sin repetir sus propios WARN) sin tocar los del doctor
359
+ # (que sí deben sobrevivir intactos entre 02 y 10).
360
+ ksc_build_warnings_file_path() {
361
+ local repo_root="$1" new_env="$2"
362
+ printf '%s/.kcreate-scaffolding/%s.build-warnings\n' "$repo_root" "$new_env"
363
+ }
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # Reemplaza la N-ésima coincidencia (1-indexada) de un regex con un grupo de
367
+ # captura en un archivo, sobre todo el contenido (no línea por línea) — para
368
+ # los casos donde la misma clave aparece varias veces en el mismo archivo
369
+ # (ip_cidr_range, principal_set_suffix) y hay que ubicar exactamente cuál.
370
+ #
371
+ # ksc_replace_nth <file> <regex_con_1_grupo_de_captura> <index> <valor_nuevo>
372
+ # ---------------------------------------------------------------------------
373
+ ksc_replace_nth() {
374
+ local file="$1" pattern="$2" index="$3" new_value="$4"
375
+ python3 - "$file" "$pattern" "$index" "$new_value" <<'PYEOF'
376
+ import re, sys
377
+ file, pattern, index, new_value = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
378
+ with open(file) as f:
379
+ text = f.read()
380
+ matches = list(re.finditer(pattern, text))
381
+ if len(matches) < index:
382
+ print(f"ksc_replace_nth: solo hay {len(matches)} coincidencias de "
383
+ f"'{pattern}' en {file}, se pidió la #{index}", file=sys.stderr)
384
+ sys.exit(1)
385
+ m = matches[index - 1]
386
+ start, end = m.span(1)
387
+ text = text[:start] + new_value + text[end:]
388
+ with open(file, "w") as f:
389
+ f.write(text)
390
+ PYEOF
391
+ }
392
+
393
+ # ksc_set_authorized_networks <file> <"name1=addr1;name2=addr2;...">
394
+ # Reemplaza el bloque authorized_networks completo de sql_postgres. Se
395
+ # reconstruye entero (no se edita a mano) para que nunca quede una coma mal
396
+ # puesta por una edición parcial.
397
+ ksc_set_authorized_networks() {
398
+ local file="$1" entries_str="$2"
399
+ python3 - "$file" "$entries_str" <<'PYEOF'
400
+ import re, sys
401
+ file, entries_str = sys.argv[1], sys.argv[2]
402
+ entries = []
403
+ for pair in entries_str.split(";"):
404
+ if not pair:
405
+ continue
406
+ name, addr = pair.split("=", 1)
407
+ entries.append((name, addr))
408
+ if not entries:
409
+ print("ksc_set_authorized_networks: lista de entradas vacia", file=sys.stderr)
410
+ sys.exit(1)
411
+ lines = [" authorized_networks = ["]
412
+ for name, addr in entries:
413
+ lines.append(f' {{ name = "{name}", address = "{addr}" }},')
414
+ lines.append(" ]")
415
+ block = "\n".join(lines)
416
+ with open(file) as f:
417
+ text = f.read()
418
+ new_text, n = re.subn(r"authorized_networks\s*=\s*\[.*?\]", block, text, count=1, flags=re.DOTALL)
419
+ if n != 1:
420
+ print(f"ksc_set_authorized_networks: no se encontro el bloque en {file}", file=sys.stderr)
421
+ sys.exit(1)
422
+ with open(file, "w") as f:
423
+ f.write(new_text)
424
+ PYEOF
425
+ }
426
+
427
+ # ksc_set_workload_identity_bindings <file> <terraform_suffix> <repo1;repo2;...>
428
+ # Reconstruye el bloque workload_identity_user_bindings completo: un
429
+ # binding fijo para terraform (preserva el suffix que ya tenía SOURCE_ENV,
430
+ # normalmente "infra-cloud") + un binding github-manager por cada repo en
431
+ # la lista (separada por ";" — soporta apps con más de un repo, Review V1
432
+ # mejora #6). Reemplaza el enfoque anterior de editar por POSICIÓN
433
+ # (ksc_replace_nth sobre la 2.ª coincidencia), que Review V1 señaló como
434
+ # frágil (E-5/mejora #8: si el orden de los bindings en SOURCE_ENV fuera
435
+ # distinto, se habría corrompido el binding de infra-cloud en vez del de
436
+ # la app). Aquí el binding de terraform se identifica por contenido
437
+ # (service_account_email que empieza con "terraform@"), no por posición.
438
+ # `principal_repository_segment` siempre queda como la referencia
439
+ # `local.github_organization` (no un literal) — no hace falta conocer el
440
+ # valor real para reescribir el bloque.
441
+ ksc_set_workload_identity_bindings() {
442
+ local file="$1" terraform_suffix="$2" repos_str="$3"
443
+ python3 - "$file" "$terraform_suffix" "$repos_str" <<'PYEOF'
444
+ import re, sys
445
+ file, terraform_suffix, repos_str = sys.argv[1], sys.argv[2], sys.argv[3]
446
+ repos = [r for r in repos_str.split(";") if r]
447
+ if not repos:
448
+ print("ksc_set_workload_identity_bindings: lista de repos vacia", file=sys.stderr)
449
+ sys.exit(1)
450
+
451
+ with open(file) as f:
452
+ text = f.read()
453
+
454
+ # No todos los entornos reales tienen un binding de terraform aqui (ej.
455
+ # cashless-qa real solo tiene el de github-manager) — en vez de fallar,
456
+ # se usa un patron por defecto. El binding de terraform->infra-cloud es
457
+ # el que la guia (SS3.15) pide siempre, independientemente de lo que
458
+ # SOURCE_ENV ya tuviera.
459
+ m = re.search(r'service_account_email\s*=\s*"(terraform@[^"]*)"', text)
460
+ if m:
461
+ terraform_sa = m.group(1)
462
+ else:
463
+ m2 = re.search(r'service_account_email\s*=\s*"(?:github-manager|terraform)@([^"]*)"', text)
464
+ project_suffix = m2.group(1) if m2 else "REEMPLAZAR.iam.gserviceaccount.com"
465
+ terraform_sa = f"terraform@{project_suffix}"
466
+ print(f"ksc_set_workload_identity_bindings: {file} no tenia un binding de terraform@... (SOURCE_ENV no lo incluia) -- se agrego uno nuevo con suffix 'infra-cloud'", file=sys.stderr)
467
+ github_manager_sa = terraform_sa.replace("terraform@", "github-manager@", 1)
468
+
469
+ lines = [" workload_identity_user_bindings = ["]
470
+ lines.append(" {")
471
+ lines.append(f' service_account_email = "{terraform_sa}"')
472
+ lines.append(" principal_repository_segment = local.github_organization")
473
+ lines.append(f' principal_set_suffix = "{terraform_suffix}"')
474
+ lines.append(" },")
475
+ for i, repo in enumerate(repos):
476
+ lines.append(" {")
477
+ lines.append(f' service_account_email = "{github_manager_sa}"')
478
+ lines.append(" principal_repository_segment = local.github_organization")
479
+ lines.append(f' principal_set_suffix = "{repo}"')
480
+ lines.append(" }" + ("," if i < len(repos) - 1 else ""))
481
+ lines.append(" ]")
482
+ block = "\n".join(lines)
483
+
484
+ new_text, n = re.subn(
485
+ r"[ \t]*workload_identity_user_bindings\s*=\s*\[.*?\n\s*\]",
486
+ block, text, count=1, flags=re.DOTALL,
487
+ )
488
+ if n != 1:
489
+ print(f"ksc_set_workload_identity_bindings: no encontre el bloque workload_identity_user_bindings en {file}", file=sys.stderr)
490
+ sys.exit(1)
491
+ with open(file, "w") as f:
492
+ f.write(new_text)
493
+ PYEOF
494
+ }
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # "Doctor" de salud de SOURCE_ENV: compara contra TODOS los entornos de
498
+ # live/ (sin hardcodear nombres de API ni de constraint) y emite [WARN]
499
+ # cuando SOURCE_ENV se queda corto respecto a lo que la MAYORÍA de los
500
+ # entornos reales ya tienen. Resuelve Review V1 E-4 (propagación
501
+ # silenciosa de org_policies/project_services incompletos) y el faltante
502
+ # #13 (typo de IP de cloudadmin) con un solo mecanismo genérico: "¿qué
503
+ # hace la mayoría?", no una lista fija de qué debería tener cada archivo.
504
+ # No bloquea nada — solo advierte, porque SOURCE_ENV minimalista a propósito
505
+ # es una posibilidad legítima que el humano puede confirmar.
506
+ # ---------------------------------------------------------------------------
507
+ ksc_check_source_health() {
508
+ local repo_root="$1" source_env="$2"
509
+ local envs=() e
510
+ mapfile -t envs < <(ksc_real_envs "$repo_root")
511
+ local total="${#envs[@]}"
512
+ [[ "$total" -le 1 ]] && return 0
513
+
514
+ # Entorno de referencia para project_services/org_policies: el que se
515
+ # modificó más recientemente (git log), no "la mayoría". Un requisito
516
+ # nuevo de la organización (ej. orgpolicy.googleapis.com, forzado desde
517
+ # 2026-02-17) por definición todavía NO lo tiene la mayoría de entornos
518
+ # viejos — una votación por mayoría nunca lo detectaría. El entorno más
519
+ # reciente es la mejor aproximación disponible, sin tener que hardcodear
520
+ # el nombre de ninguna API o constraint. Encontrado real probando esto
521
+ # contra infra-cloud: con mayoría, el hueco real de boletera-qa (falta
522
+ # orgpolicy.googleapis.com, que solo tiene 1 de 5 entornos) no se
523
+ # detectaba — con "más reciente" sí.
524
+ local newest_env="" newest_ts=0 ts
525
+ for e in "${envs[@]}"; do
526
+ ts="$(cd "$repo_root" && git log -1 --format=%ct -- "live/$e/project_services/terragrunt.hcl" 2>/dev/null)"
527
+ [[ -n "$ts" && "$ts" -gt "$newest_ts" ]] && { newest_ts="$ts"; newest_env="$e"; }
528
+ done
529
+
530
+ if [[ -n "$newest_env" && "$newest_env" != "$source_env" \
531
+ && -f "$repo_root/live/$source_env/project_services/terragrunt.hcl" \
532
+ && -f "$repo_root/live/$newest_env/project_services/terragrunt.hcl" ]]; then
533
+ local ref_apis source_apis api
534
+ ref_apis="$(grep -oE '"[a-zA-Z0-9.-]+\.googleapis\.com"' "$repo_root/live/$newest_env/project_services/terragrunt.hcl" | tr -d '"')"
535
+ source_apis="$(grep -oE '"[a-zA-Z0-9.-]+\.googleapis\.com"' "$repo_root/live/$source_env/project_services/terragrunt.hcl" | tr -d '"')"
536
+ while IFS= read -r api; do
537
+ [[ -n "$api" ]] || continue
538
+ grep -qx "$api" <<<"$source_apis" || \
539
+ ksc_warn "doctor: '$api' está habilitada en $newest_env (el entorno modificado más recientemente de live/), pero NO en $source_env — project_services de NEW_ENV va a heredar esta falta en silencio."
540
+ done <<<"$ref_apis"
541
+ fi
542
+
543
+ if [[ -n "$newest_env" && "$newest_env" != "$source_env" \
544
+ && -f "$repo_root/live/$source_env/org_policies/terragrunt.hcl" \
545
+ && -f "$repo_root/live/$newest_env/org_policies/terragrunt.hcl" ]]; then
546
+ local ref_count source_count
547
+ ref_count="$(grep -c 'constraint[[:space:]]*=' "$repo_root/live/$newest_env/org_policies/terragrunt.hcl")"
548
+ source_count="$(grep -c 'constraint[[:space:]]*=' "$repo_root/live/$source_env/org_policies/terragrunt.hcl")"
549
+ if [[ "$source_count" -lt "$ref_count" ]]; then
550
+ ksc_warn "doctor: org_policies de $source_env tiene $source_count entradas; $newest_env (el más reciente de live/) tiene $ref_count — NEW_ENV va a heredar un org_policies incompleto."
551
+ fi
552
+ fi
553
+
554
+ # cloudadmin (u otra IP fija no "dev-*"): aquí SÍ conviene la mayoría en
555
+ # vez del más reciente — una IP de oficina compartida rara vez cambia
556
+ # legítimamente, así que el valor mayoritario es el de confianza, no el
557
+ # del entorno más nuevo (que podría tener el typo).
558
+ if [[ -f "$repo_root/live/$source_env/sql_postgres/terragrunt.hcl" ]]; then
559
+ local best best_n _total_unused
560
+ read -r best best_n _total_unused < <(ksc_majority_fixed_network "$repo_root")
561
+ if [[ -n "$best" ]]; then
562
+ local best_name="${best%%=*}"
563
+ if ! grep -q "name = \"${best_name}\", address = \"${best#*=}\"" "$repo_root/live/$source_env/sql_postgres/terragrunt.hcl" 2>/dev/null; then
564
+ ksc_warn "doctor: la IP fija '$best' aparece en $best_n/$total entornos de live/, pero $source_env tiene un valor distinto para '$best_name' — verifica si es un typo (ver lessons-learned.md A4). El config de 01_init_config.sh ya adopta el valor mayoritario en vez del de $source_env (ver C-N6) — esto solo avisa, por si el valor de $source_env fuera el correcto a propósito."
565
+ fi
566
+ fi
567
+ fi
568
+ return 0
569
+ }
570
+
571
+ # ksc_majority_fixed_network <repo_root> -> imprime "name=addr n total"
572
+ # (la entrada fija más común de sql_postgres.authorized_networks entre
573
+ # todos los entornos reales de live/, excluyendo "dev-*"; en cuántos
574
+ # entornos aparece; y el total de entornos reales considerados). "name"
575
+ # y "addr" vienen vacíos si no hay ninguno. Compartida entre el doctor
576
+ # (ksc_check_source_health) y 01_init_config.sh (que la usa para preferir
577
+ # el valor mayoritario sobre el de SOURCE_ENV cuando difieren — Review
578
+ # V2, N-6: antes el doctor avisaba del typo pero el config igual
579
+ # adoptaba el valor roto de SOURCE_ENV).
580
+ ksc_majority_fixed_network() {
581
+ local repo_root="$1"
582
+ local envs=() e
583
+ mapfile -t envs < <(ksc_real_envs "$repo_root")
584
+ declare -A ip_count
585
+ for e in "${envs[@]}"; do
586
+ local f="$repo_root/live/$e/sql_postgres/terragrunt.hcl" line name addr
587
+ [[ -f "$f" ]] || continue
588
+ while IFS= read -r line; do
589
+ name="$(printf '%s' "$line" | grep -oE 'name[[:space:]]*=[[:space:]]*"[^"]*"' | sed -E 's/.*"([^"]*)"/\1/')"
590
+ [[ "$name" == dev-* ]] && continue
591
+ addr="$(printf '%s' "$line" | grep -oE 'address[[:space:]]*=[[:space:]]*"[^"]*"' | sed -E 's/.*"([^"]*)"/\1/')"
592
+ [[ -n "$name" && -n "$addr" ]] && ip_count["$name=$addr"]=$(( ${ip_count["$name=$addr"]:-0} + 1 ))
593
+ done < <(grep -E '\{[[:space:]]*name[[:space:]]*=' "$f")
594
+ done
595
+ local best="" best_n=0 key n
596
+ for key in "${!ip_count[@]}"; do
597
+ n="${ip_count[$key]}"
598
+ if [[ "$n" -gt "$best_n" ]]; then
599
+ best="$key"
600
+ best_n="$n"
601
+ fi
602
+ done
603
+ printf '%s %s %s\n' "$best" "$best_n" "${#envs[@]}"
604
+ }
605
+
606
+ # ksc_check_duplicate_keys <file> -> exit 1 y lista las claves repetidas
607
+ # DENTRO DE UN MISMO BLOQUE `{ ... }` (no solo mismo nivel de indentación
608
+ # — `locals { project_id = ... }` e `inputs { project_id = ... }` tienen
609
+ # la misma indentación pero son bloques distintos y NO cuentan como
610
+ # duplicado; esto se corrigió tras una primera versión que sí los
611
+ # confundía). Detecta defectos reales de SOURCE_ENV que `terragrunt hcl
612
+ # fmt`/`validate` no atrapan (ej. boletera-qa/sql_postgres trae
613
+ # `update_timeout` dos veces dentro del mismo bloque `inputs`).
614
+ ksc_check_duplicate_keys() {
615
+ local file="$1"
616
+ python3 - "$file" <<'PYEOF'
617
+ import re, sys
618
+ file = sys.argv[1]
619
+ with open(file) as f:
620
+ text = f.read()
621
+
622
+ stack = [set()]
623
+ dupes = []
624
+ i = 0
625
+ key_re = re.compile(r'[a-zA-Z_][a-zA-Z0-9_]*')
626
+ line_start = True
627
+ pending_key = None
628
+ n = len(text)
629
+ while i < n:
630
+ c = text[i]
631
+ if c == '{':
632
+ stack.append(set())
633
+ i += 1
634
+ continue
635
+ if c == '}':
636
+ if len(stack) > 1:
637
+ stack.pop()
638
+ i += 1
639
+ continue
640
+ if c == '\n':
641
+ line_start = True
642
+ i += 1
643
+ continue
644
+ if line_start and not c.isspace():
645
+ m = key_re.match(text, i)
646
+ if m:
647
+ rest = text[m.end():].lstrip(' \t')
648
+ if rest.startswith('=') and not rest.startswith('=='):
649
+ key = m.group(0)
650
+ if key in stack[-1]:
651
+ dupes.append(key)
652
+ stack[-1].add(key)
653
+ line_start = False
654
+ elif not c.isspace():
655
+ line_start = False
656
+ i += 1
657
+
658
+ if dupes:
659
+ print(f"{file}: clave(s) duplicada(s) dentro del mismo bloque: {', '.join(sorted(set(dupes)))}", file=sys.stderr)
660
+ sys.exit(1)
661
+ sys.exit(0)
662
+ PYEOF
663
+ }
664
+
665
+ # ksc_learn_artifact_registry_template <repo_root> -> imprime 2 líneas:
666
+ # la plantilla de repository_id y de description, con el nombre de la
667
+ # app de ESE entorno reemplazado por el marcador {{APP}}/{{APP_CAP}}.
668
+ # Vacío si ningún entorno real de live/ tiene un artifact_registry
669
+ # específico de su propia app (todos genéricos).
670
+ #
671
+ # Review V2 (sección 2.3/4.3): cuando SOURCE_ENV tiene valores genéricos
672
+ # (4 de 5 entornos reales), en vez de solo advertir se intenta "aprender"
673
+ # el patrón real de OTRO entorno que sí lo tenga específico (ej.
674
+ # xoloshop-qa: "club-tijuana-xoloshop-docker-repo") y aplicarlo con el
675
+ # nombre de la app nueva — sin hardcodear "club-tijuana" ni ningún otro
676
+ # literal de esta organización en el código del skill.
677
+ #
678
+ # Review V3 (P-1): la primera versión iteraba `live/*/` directamente, sin
679
+ # pasar por `ksc_real_envs` — exactamente la contaminación que D1/D2 ya
680
+ # habían corregido en las otras tres funciones, pero esta es nueva y se
681
+ # quedó fuera. Reproducido: un entorno sin comitear con
682
+ # `repository_id` conteniendo su propio nombre se "aprendía" como si
683
+ # fuera una plantilla real. Ahora usa `ksc_real_envs` (solo entornos
684
+ # comiteados) y, entre los que matcheen, prefiere el modificado más
685
+ # recientemente (git log) en vez del primero alfabético — mismo criterio
686
+ # que el doctor, para que "cuál entorno es la referencia" no dependa de
687
+ # en qué orden los liste el filesystem.
688
+ ksc_learn_artifact_registry_template() {
689
+ local repo_root="$1" real_envs=() env_name app id desc best_env="" best_ts=0 ts
690
+ mapfile -t real_envs < <(ksc_real_envs "$repo_root")
691
+ for env_name in "${real_envs[@]}"; do
692
+ [[ -f "$repo_root/live/$env_name/artifact_registry/terragrunt.hcl" ]] || continue
693
+ app="${env_name%-*}"
694
+ id="$(ksc_hcl_get "$repo_root/live/$env_name/artifact_registry/terragrunt.hcl" repository_id)"
695
+ [[ -n "$id" ]] || continue
696
+ grep -q "$app" <<<"$id" || continue
697
+ ts="$(cd "$repo_root" && git log -1 --format=%ct -- "live/$env_name/artifact_registry/terragrunt.hcl" 2>/dev/null)"
698
+ [[ -z "$ts" ]] && ts=0
699
+ if [[ "$ts" -gt "$best_ts" ]]; then
700
+ best_ts="$ts"
701
+ best_env="$env_name"
702
+ fi
703
+ done
704
+ if [[ -n "$best_env" ]]; then
705
+ app="${best_env%-*}"
706
+ id="$(ksc_hcl_get "$repo_root/live/$best_env/artifact_registry/terragrunt.hcl" repository_id)"
707
+ desc="$(ksc_hcl_get "$repo_root/live/$best_env/artifact_registry/terragrunt.hcl" description)"
708
+ if [[ -n "$id" ]]; then
709
+ local app_cap
710
+ app_cap="$(ksc_capitalize "$app")"
711
+ id="${id//$app/\{\{APP\}\}}"
712
+ desc="${desc//$app_cap/\{\{APP_CAP\}\}}"
713
+ desc="${desc//$app/\{\{APP\}\}}"
714
+ printf '%s\n%s\n' "$id" "$desc"
715
+ return 0
716
+ fi
717
+ fi
718
+ printf '\n\n'
719
+ return 1
720
+ }
721
+
722
+ # Capitaliza solo la primera letra (para "boletera" -> "Boletera").
723
+ ksc_capitalize() {
724
+ local s="$1"
725
+ printf '%s%s\n' "$(printf '%s' "${s:0:1}" | tr '[:lower:]' '[:upper:]')" "${s:1}"
726
+ }
727
+
728
+ # ksc_set_key <file> <clave> <valor_nuevo>
729
+ # Reemplaza el valor de una asignación `clave = "..."` de una sola línea,
730
+ # anclada al nombre exacto de la clave (no toca "project_id" al pedir
731
+ # "project", por ejemplo). Única forma en que este skill edita HCL — nunca
732
+ # con un editor de texto "a mano", siempre con esta función.
733
+ ksc_set_key() {
734
+ local file="$1" key="$2" value="$3"
735
+ local escaped_value
736
+ escaped_value="$(printf '%s' "$value" | sed -e 's/[&]/\\&/g')"
737
+ sed -i -E "s|^([[:space:]]*${key}[[:space:]]*=[[:space:]]*\")[^\"]*(\")|\1${escaped_value}\2|" "$file"
738
+ }
739
+
740
+ # Reemplazo literal (no-regex) de SOURCE_ENV/APP por NEW_ENV/APP en todo el
741
+ # archivo. Cubre la mayoría de los campos específicos del entorno, porque la
742
+ # convención de este repo embebe el nombre completo del entorno
743
+ # (<app>-<qa|prod>) literalmente en nombres de red, SA, cluster, buckets.
744
+ ksc_substr_replace() {
745
+ local file="$1" old="$2" new="$3"
746
+ sed -i "s/${old}/${new}/g" "$file"
747
+ }
748
+
749
+ # ksc_substr_replace_app <file> <app_source_lower> <app_new_lower>
750
+ #
751
+ # Reemplaza TODAS las variantes de capitalización del nombre corto de la
752
+ # app fuente (no solo minúscula exacta ni "Primera-mayúscula" exacta) por
753
+ # la variante equivalente del nombre nuevo. Caso real encontrado
754
+ # construyendo `coreservices-qa` desde `xoloshop-qa`: `service_accounts`
755
+ # usa el nombre de marca estilizado `"XoloShop"` (con S interna
756
+ # mayúscula) en `display_name`/`description` — ni
757
+ # `ksc_substr_replace "$APP_SOURCE"` (minúscula exacta) ni
758
+ # `ksc_substr_replace "$(ksc_capitalize "$APP_SOURCE")"` ("Xoloshop",
759
+ # solo la primera letra) hacen match contra "XoloShop", así que ese
760
+ # leftover pasaba completo a `coreservices-qa` en silencio. Resuelto
761
+ # haciendo el match insensible a mayúsculas/minúsculas y eligiendo la
762
+ # capitalización del REEMPLAZO según cómo estaba escrita la coincidencia
763
+ # encontrada (todo minúsculas -> nuevo en minúsculas; todo mayúsculas ->
764
+ # nuevo en mayúsculas; cualquier otra mezcla, incluidos estilos de marca
765
+ # como "XoloShop" -> primera letra mayúscula del nuevo, mejor esfuerzo).
766
+ ksc_substr_replace_app() {
767
+ local file="$1" app_source="$2" app_new="$3"
768
+ [[ -f "$file" ]] || return 0
769
+ python3 - "$file" "$app_source" "$app_new" <<'PYEOF'
770
+ import re, sys
771
+ file, app_source, app_new = sys.argv[1], sys.argv[2], sys.argv[3]
772
+ with open(file) as f:
773
+ text = f.read()
774
+ pattern = re.compile(re.escape(app_source), re.IGNORECASE)
775
+
776
+ def repl(m):
777
+ s = m.group(0)
778
+ if s.isupper() and len(s) > 1:
779
+ return app_new.upper()
780
+ if s.islower():
781
+ return app_new.lower()
782
+ return app_new[:1].upper() + app_new[1:].lower()
783
+
784
+ new_text = pattern.sub(repl, text)
785
+ with open(file, "w") as f:
786
+ f.write(new_text)
787
+ PYEOF
788
+ }