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.
- package/LICENSE +21 -0
- package/README.md +429 -2
- package/bin/build.js +70 -0
- package/bin/cli.js +441 -0
- package/package.json +36 -4
- package/skill/SKILL.md +262 -0
- package/skill/references/lessons-learned.md +706 -0
- package/skill/scripts/01_init_config.sh +151 -0
- package/skill/scripts/02_print_config.sh +60 -0
- package/skill/scripts/10_build_components.sh +596 -0
- package/skill/scripts/20_validate_file_parity.sh +107 -0
- package/skill/scripts/21_validate_project_pinning.sh +105 -0
- package/skill/scripts/22_validate_no_app_name_leftover.sh +62 -0
- package/skill/scripts/lib/common.sh +788 -0
- package/skill/scripts/run_tracking.sh +93 -0
- package/skill/templates/scaffolding.config.template +81 -0
- package/vendor/github-proyect-tracking/SKILL.md +180 -0
- package/vendor/github-proyect-tracking/references/api.md +185 -0
- package/vendor/github-proyect-tracking/references/architecture.md +171 -0
- package/vendor/github-proyect-tracking/scripts/tracking.py +214 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/__init__.py +1 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/commands.py +940 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/common.py +208 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/events.py +233 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/github.py +324 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/state.py +379 -0
|
@@ -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
|
+
}
|