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

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,584 @@
1
+ #!/usr/bin/env bash
2
+ # Etapa: Construcción.
3
+ # Genera live/<NEW_ENV>/ completo a partir de live/<SOURCE_ENV>/, usando
4
+ # solo sustituciones de texto deterministas (nunca una edición "a mano" por
5
+ # el agente). Cada paso se registra con github-proyect-tracking para poder
6
+ # reanudar si el proceso se interrumpe — pero el tracking es un plus, no un
7
+ # requisito: si falla, el build sigue y queda solo sin reanudación remota
8
+ # (ver Review V1, E-6/E-8).
9
+ #
10
+ # Uso: 10_build_components.sh <NEW_ENV>
11
+ #
12
+ # Termina con exactamente uno de estos mensajes, para que el agente no
13
+ # tenga que interpretar nada:
14
+ # "BUILD_OK: <ruta>" (exit 0)
15
+ # "BUILD_FAILED: <paso> - <detalle>" (exit 1)
16
+ #
17
+ # Es seguro reintentar: cada build_component_* copia SIEMPRE desde
18
+ # SOURCE_DIR (nunca incrementa sobre lo que ya había en NEW_DIR), así que
19
+ # volver a correr este script después de un fallo a medias reconstruye
20
+ # cada componente desde cero con el mismo resultado — no hace falta un
21
+ # motor de reanudación por paso (Review V1, E-1/faltante #1: la versión
22
+ # anterior SÍ moría en el reintento porque un `mkdir -p "$NEW_DIR"` previo
23
+ # dejaba el directorio creado, y una guarda posterior lo trataba como "ya
24
+ # existe, no tocar". Esa guarda se quitó: ya no es necesaria).
25
+
26
+ set -uo pipefail # sin -e: queremos capturar el fallo de un paso y reportarlo via tracking antes de salir
27
+
28
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
29
+ # shellcheck source=lib/common.sh
30
+ source "$SCRIPT_DIR/lib/common.sh"
31
+
32
+ ksc_require_cmd python3 git curl sed
33
+
34
+ NEW_ENV="${1:-}"
35
+ [[ -n "$NEW_ENV" ]] || ksc_die "uso: 10_build_components.sh <NEW_ENV>"
36
+
37
+ REPO_ROOT="$(ksc_repo_root)"
38
+ CONFIG_FILE="$(ksc_config_path "$REPO_ROOT" "$NEW_ENV")"
39
+ [[ -f "$CONFIG_FILE" ]] || ksc_die "no existe $CONFIG_FILE — corre primero 01_init_config.sh y 02_print_config.sh"
40
+ PENDING=$(ksc_config_pending_fields "$CONFIG_FILE" || true)
41
+ [[ -z "$PENDING" ]] || ksc_die "el config todavía tiene campos __PENDIENTE__ — no se construye nada hasta que esté completo y confirmado (02_print_config.sh)."
42
+
43
+ # shellcheck disable=SC1090
44
+ source "$CONFIG_FILE"
45
+
46
+ SOURCE_DIR="$REPO_ROOT/live/$SOURCE_ENV"
47
+ NEW_DIR="$REPO_ROOT/live/$NEW_ENV"
48
+ [[ -d "$NEW_DIR" ]] && ksc_info "$NEW_DIR ya existe (reintento) — se reconstruye cada componente desde $SOURCE_ENV, no se acumula sobre lo que ya había."
49
+
50
+ read -r APP_SOURCE ENTORNO_SOURCE < <(ksc_split_env "$SOURCE_ENV")
51
+ read -r APP_NEW ENTORNO_NEW < <(ksc_split_env "$NEW_ENV")
52
+ APP_SOURCE_CAP="$(ksc_capitalize "$APP_SOURCE")"
53
+ APP_NEW_CAP="$(ksc_capitalize "$APP_NEW")"
54
+
55
+ CIDR_FILE="$(ksc_cidr_file_path "$REPO_ROOT" "$NEW_ENV")"
56
+ PLAN_FILE="$(dirname "$CONFIG_FILE")/${NEW_ENV}.plan.json"
57
+ # Archivo propio de la Construcción, SEPARADO del del doctor
58
+ # (ksc_warnings_file_path, que llena 02_print_config.sh) — Review V3
59
+ # (P-3): compartir un solo archivo en modo append hacía que cada
60
+ # reintento de este script repitiera sus propios WARN sobre los de la
61
+ # corrida anterior, inflando el archivo sin límite. Este SÍ se trunca al
62
+ # inicio de cada corrida (no repite sus propios WARN en un retry); el
63
+ # del doctor nunca se toca aquí, así que sigue intacto entre 02 y 10.
64
+ DOCTOR_WARNINGS_FILE="$(ksc_warnings_file_path "$REPO_ROOT" "$NEW_ENV")"
65
+ export KSC_WARNINGS_FILE="$(ksc_build_warnings_file_path "$REPO_ROOT" "$NEW_ENV")"
66
+ : > "$KSC_WARNINGS_FILE"
67
+
68
+ # (El "doctor" de SOURCE_ENV ya corrió en 02_print_config.sh, antes de que
69
+ # el humano confirmara — no se repite aquí.)
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Qué componentes se construyen: la base (intersección real de todos los
73
+ # entornos de live/, sin lista hardcodeada) + lo que EXTRA_COMPONENTS del
74
+ # config pida explícitamente. Antes se copiaba TODO lo que SOURCE_ENV
75
+ # tuviera, incluyendo opcionales (cloud_armor, secrets, etc.) — Review V1
76
+ # señaló que la guía (§1.2) pide no incluirlos salvo pedido explícito.
77
+ # ---------------------------------------------------------------------------
78
+ mapfile -t SOURCE_COMPONENTS < <(ksc_discover_components "$SOURCE_DIR")
79
+ [[ ${#SOURCE_COMPONENTS[@]} -gt 0 ]] || ksc_die "no se encontró ningún componente (subcarpeta con terragrunt.hcl) en $SOURCE_DIR"
80
+
81
+ mapfile -t BASE_COMPONENTS < <(ksc_base_components "$REPO_ROOT" "$NEW_ENV")
82
+ IFS=';' read -ra EXTRA_LIST <<<"${EXTRA_COMPONENTS:-}"
83
+
84
+ COMPONENTS=()
85
+ EXCLUDED=()
86
+ for c in "${SOURCE_COMPONENTS[@]}"; do
87
+ is_base=0; is_extra=0
88
+ for b in "${BASE_COMPONENTS[@]}"; do [[ "$c" == "$b" ]] && is_base=1 && break; done
89
+ for x in "${EXTRA_LIST[@]}"; do [[ -n "$x" && "$c" == "$x" ]] && is_extra=1 && break; done
90
+ if [[ "$is_base" -eq 1 || "$is_extra" -eq 1 ]]; then
91
+ COMPONENTS+=("$c")
92
+ else
93
+ EXCLUDED+=("$c")
94
+ fi
95
+ done
96
+ if [[ ${#EXCLUDED[@]} -gt 0 ]]; then
97
+ ksc_warn "componentes de $SOURCE_ENV excluidos por defecto (no son base y no están en EXTRA_COMPONENTS del config): ${EXCLUDED[*]}. Si NEW_ENV sí los necesita, agrégalos a EXTRA_COMPONENTS en $CONFIG_FILE y vuelve a correr este script."
98
+ fi
99
+
100
+ # vpc debe ir primero si existe: sql_private_access (y en principio
101
+ # cualquier otro componente que use rangos CIDR) depende del archivo que
102
+ # vpc genera, y el orden de descubrimiento es alfabético, no de
103
+ # dependencias.
104
+ if printf '%s\n' "${COMPONENTS[@]}" | grep -qx vpc; then
105
+ REORDERED=(vpc)
106
+ for c in "${COMPONENTS[@]}"; do
107
+ [[ "$c" == "vpc" ]] && continue
108
+ REORDERED+=("$c")
109
+ done
110
+ COMPONENTS=("${REORDERED[@]}")
111
+ fi
112
+
113
+ ksc_info "componentes a construir (orden): ${COMPONENTS[*]}"
114
+
115
+ mkdir -p "$NEW_DIR"
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Plan de pasos para tracking + arranque/reanudación. El tracking es
119
+ # best-effort: si falla, se avisa y se sigue sin él (Review V1 E-6/E-8 — la
120
+ # versión anterior moría si no podía inicializar tracking, aunque la
121
+ # construcción es 100% local).
122
+ # ---------------------------------------------------------------------------
123
+ {
124
+ printf '['
125
+ printf '"project.hcl",'
126
+ printf '"top-level-files",'
127
+ for c in "${COMPONENTS[@]}"; do printf '"%s",' "$c"; done
128
+ printf '"fmt"'
129
+ printf ']'
130
+ } > "$PLAN_FILE"
131
+
132
+ TRACKING_OK=1
133
+ if ! "$SCRIPT_DIR/run_tracking.sh" resume "$CONFIG_FILE" >/tmp/ksc_resume.$$ 2>&1; then
134
+ if "$SCRIPT_DIR/run_tracking.sh" init-plan "$CONFIG_FILE" "$PLAN_FILE" >/tmp/ksc_init.$$ 2>&1; then
135
+ ksc_info "tracking iniciado."
136
+ else
137
+ TRACKING_OK=0
138
+ ksc_warn "no se pudo inicializar github-proyect-tracking ($(tail -c 300 /tmp/ksc_init.$$ | tr '\n' ' ')) — la construcción sigue de todas formas, sin reanudación remota. Revisa TRACKING_GITHUB_PROJECT_URL/credenciales y vuelve a correr este script más tarde si quieres recuperar el tracking."
139
+ fi
140
+ rm -f /tmp/ksc_init.$$
141
+ else
142
+ ksc_info "reanudando ejecución existente:"
143
+ cat /tmp/ksc_resume.$$
144
+ fi
145
+ rm -f /tmp/ksc_resume.$$
146
+
147
+ step_start() {
148
+ [[ "$TRACKING_OK" -eq 1 ]] || return 0
149
+ "$SCRIPT_DIR/run_tracking.sh" step-start "$CONFIG_FILE" "$1" >/tmp/ksc_track.$$ 2>&1 \
150
+ || ksc_warn "tracking: no se pudo registrar el inicio de '$1' ($(tail -c 200 /tmp/ksc_track.$$ | tr '\n' ' ')) — el paso se ejecuta igual."
151
+ rm -f /tmp/ksc_track.$$
152
+ }
153
+ step_done() {
154
+ [[ "$TRACKING_OK" -eq 1 ]] || return 0
155
+ "$SCRIPT_DIR/run_tracking.sh" step-done "$CONFIG_FILE" "$1" >/tmp/ksc_track.$$ 2>&1 \
156
+ || ksc_warn "tracking: no se pudo registrar el fin de '$1' ($(tail -c 200 /tmp/ksc_track.$$ | tr '\n' ' ')) — el Issue puede quedar desincronizado."
157
+ rm -f /tmp/ksc_track.$$
158
+ }
159
+ step_fail() {
160
+ [[ "$TRACKING_OK" -eq 1 ]] && "$SCRIPT_DIR/run_tracking.sh" step-error "$CONFIG_FILE" "$1" "$2" >/dev/null 2>&1
161
+ echo "BUILD_FAILED: $1 - $2"
162
+ exit 1
163
+ }
164
+
165
+ ksc_copy_component_files() {
166
+ local comp="$1" f
167
+ mkdir -p "$NEW_DIR/$comp"
168
+ for f in Makefile .gitignore terragrunt.hcl; do
169
+ [[ -f "$SOURCE_DIR/$comp/$f" ]] && cp "$SOURCE_DIR/$comp/$f" "$NEW_DIR/$comp/$f"
170
+ done
171
+ # Sustitución estándar aplicada a TODO componente, conocido o no: el
172
+ # token completo del entorno, y el nombre corto de la app (en minúscula
173
+ # y capitalizado). Antes esto solo se hacía a mano en algunos
174
+ # componentes — Review V1 (E-5) encontró texto real de SOURCE_ENV
175
+ # filtrado en service_accounts (display_name) y project_iam (namespace
176
+ # de un principal) porque esos dos nunca aplicaban la sustitución de
177
+ # nombre de app, solo la del token completo.
178
+ f="$NEW_DIR/$comp/terragrunt.hcl"
179
+ [[ -f "$f" ]] || return 0
180
+ ksc_substr_replace "$f" "$SOURCE_ENV" "$NEW_ENV"
181
+ [[ "$APP_SOURCE" != "$APP_NEW" ]] && ksc_substr_replace "$f" "$APP_SOURCE" "$APP_NEW"
182
+ [[ "$APP_SOURCE_CAP" != "$APP_NEW_CAP" ]] && ksc_substr_replace "$f" "$APP_SOURCE_CAP" "$APP_NEW_CAP"
183
+ }
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # project.hcl (no es un "componente" con terragrunt.hcl propio)
187
+ # ---------------------------------------------------------------------------
188
+ build_project_hcl() {
189
+ cp "$SOURCE_DIR/project.hcl" "$NEW_DIR/project.hcl"
190
+ local f="$NEW_DIR/project.hcl"
191
+ ksc_set_key "$f" "project_id" "$NEW_ENV"
192
+ ksc_set_key "$f" "project_number" ""
193
+ }
194
+
195
+ # Archivos a nivel raíz de live/<entorno>/ (no de un componente): un
196
+ # Makefile genérico que corre `terragrunt run --all` y un README con la
197
+ # descripción del entorno. Se detectaron ausentes incluso en xoloshop-qa
198
+ # (generado a mano en una sesión anterior) — fácil de olvidar si no queda
199
+ # como un paso explícito de la construcción. Que SOURCE_ENV no tenga
200
+ # alguno de estos 3 archivos es normal, NO un error (Review V1, E-3: la
201
+ # versión anterior fallaba la construcción completa cuando faltaba
202
+ # cualquiera de los 3, justo el caso de xoloshop-qa).
203
+ build_top_level_files() {
204
+ local f
205
+ for f in Makefile README.md .gitignore; do
206
+ if [[ -f "$SOURCE_DIR/$f" ]]; then
207
+ cp "$SOURCE_DIR/$f" "$NEW_DIR/$f" || return 1
208
+ fi
209
+ done
210
+ if [[ -f "$NEW_DIR/README.md" ]]; then
211
+ ksc_substr_replace "$NEW_DIR/README.md" "$SOURCE_ENV" "$NEW_ENV"
212
+ fi
213
+ return 0
214
+ }
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Componentes con manejo específico (ver referencias/lessons-learned.md
218
+ # para el porqué de cada uno).
219
+ # ---------------------------------------------------------------------------
220
+ build_component_project() {
221
+ ksc_copy_component_files project
222
+ local f="$NEW_DIR/project/terragrunt.hcl"
223
+ ksc_set_key "$f" "project_name" "$PROJECT_NAME"
224
+ ksc_set_key "$f" "org_id" "$ORG_ID"
225
+ ksc_set_key "$f" "org_billing_account" "$ORG_BILLING_ACCOUNT"
226
+ ksc_set_key "$f" "project" "$APP_NEW"
227
+ ksc_set_key "$f" "environment" "$ENTORNO_NEW"
228
+ ksc_set_key "$f" "owner" "$LABEL_OWNER"
229
+ }
230
+
231
+ build_component_project_services() {
232
+ ksc_copy_component_files project_services
233
+ # La lista de APIs se copia tal cual (sin editarla a mano: así se
234
+ # elimina por diseño el bug real de "coma faltante"). El doctor
235
+ # (ksc_check_source_health, corrido antes de construir) ya avisó si a
236
+ # SOURCE_ENV le falta algo que el entorno más reciente de live/ sí
237
+ # tiene — aquí no se vuelve a repetir esa lógica.
238
+ }
239
+
240
+ build_component_org_policies() {
241
+ ksc_copy_component_files org_policies
242
+ # Igual que project_services: se copia tal cual, el doctor ya avisó si
243
+ # SOURCE_ENV tiene menos entradas que el entorno más reciente de live/.
244
+ }
245
+
246
+ build_component_service_accounts() {
247
+ ksc_copy_component_files service_accounts
248
+ local f="$NEW_DIR/service_accounts/terragrunt.hcl"
249
+ local n
250
+ n="$(grep -c 'account_id[[:space:]]*=' "$f")"
251
+ if [[ "$n" -gt 5 ]]; then
252
+ ksc_warn "service_accounts de $SOURCE_ENV tiene $n entradas (más de las 5 estándar: terraform, github-manager, gke-sa-external-secrets, <app>-node-sa, <app>-bastion-sa) — revisa a mano si las SAs extra son específicas de $SOURCE_ENV y no deberían existir en $NEW_ENV."
253
+ fi
254
+ }
255
+
256
+ build_component_project_iam() {
257
+ ksc_copy_component_files project_iam
258
+ local f="$NEW_DIR/project_iam/terragrunt.hcl"
259
+ # Se ignoran líneas de comentario: xoloshop-qa real tiene un comentario
260
+ # explicando por qué NO se puede usar roles/orgpolicy.policyAdmin aquí
261
+ # (precisamente la lección que este chequeo busca aplicar) — ese
262
+ # comentario mencionaba el string textualmente y disparaba un falso
263
+ # BUILD_FAILED real al probar esto con xoloshop-qa como SOURCE_ENV.
264
+ if grep -v '^[[:space:]]*#' "$f" | grep -q "orgpolicy.policyAdmin"; then
265
+ step_fail "project_iam" "el archivo copiado de $SOURCE_ENV incluye roles/orgpolicy.policyAdmin fuera de un comentario, que GCP rechaza a nivel proyecto. Quítalo de live/$SOURCE_ENV/project_iam/terragrunt.hcl primero."
266
+ fi
267
+ # Guía §3.12: dejar un comentario EN EL ARCHIVO, no solo en la consola,
268
+ # para que una revisión futura del propio HCL no asuma que faltó por
269
+ # descuido (Review V1, faltante #12).
270
+ python3 - "$f" "$SOURCE_ENV" <<'PYEOF'
271
+ import sys
272
+ file, source_env = sys.argv[1], sys.argv[2]
273
+ marker = "# NOTA kcreate-scaffolding-gcp-script: este archivo se copió completo de\n# live/" + source_env + "/project_iam/terragrunt.hcl SIN filtrar. Revisa a mano si\n# incluye bindings especificos de esa app/equipo (dominios externos,\n# roles personalizados) que NO deberian existir aqui.\n"
274
+ with open(file) as fh:
275
+ text = fh.read()
276
+ if marker not in text:
277
+ text = text.replace("inputs = {", marker + "inputs = {", 1)
278
+ with open(file, "w") as fh:
279
+ fh.write(text)
280
+ PYEOF
281
+ ksc_warn "project_iam copiado de $SOURCE_ENV sin filtrar — revisa a mano que no haya bindings específicos del equipo/app de $SOURCE_ENV (dominios externos, roles personalizados) antes de confiar en este archivo (queda anotado también dentro del propio archivo)."
282
+ }
283
+
284
+ build_component_vpc() {
285
+ ksc_copy_component_files vpc
286
+ local f="$NEW_DIR/vpc/terragrunt.hcl"
287
+
288
+ local subnet pods svc priv
289
+ if [[ -f "$CIDR_FILE" ]]; then
290
+ # Reintento: ya se había elegido un rango en una corrida anterior —
291
+ # reusarlo en vez de recalcular (determinismo; Review V1 E-1/E-2
292
+ # mencionan la importancia de que un reintento no produzca un
293
+ # resultado distinto).
294
+ ksc_info "reusando rango CIDR ya elegido en $CIDR_FILE (de una corrida anterior)."
295
+ subnet="$(sed -n 1p "$CIDR_FILE")"; pods="$(sed -n 2p "$CIDR_FILE")"
296
+ svc="$(sed -n 3p "$CIDR_FILE")"; priv="$(sed -n 4p "$CIDR_FILE")"
297
+ else
298
+ local existing_file="/tmp/ksc_existing_cidrs.$$"
299
+ # vpc/terragrunt.hcl ya guarda sus 3 rangos en notación CIDR completa
300
+ # ("x.x.x.x/nn"). sql_private_access, en cambio, guarda `address` sin
301
+ # sufijo y `prefix_length` aparte — hay que recombinarlos, si no, el
302
+ # picker nunca ve esos /16 como ocupados y podría asignar algo que
303
+ # colisiona con un sql_private_access existente.
304
+ grep -rhoE '"[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}"' "$REPO_ROOT"/live/*/vpc/terragrunt.hcl 2>/dev/null \
305
+ | tr -d '"' > "$existing_file"
306
+ local sp_file addr prefix
307
+ for sp_file in "$REPO_ROOT"/live/*/sql_private_access/terragrunt.hcl; do
308
+ [[ -f "$sp_file" ]] || continue
309
+ addr="$(ksc_hcl_get "$sp_file" address)"
310
+ prefix="$(ksc_hcl_get "$sp_file" prefix_length)"
311
+ [[ -n "$addr" && -n "$prefix" ]] && echo "${addr}/${prefix}" >> "$existing_file"
312
+ done
313
+
314
+ local cidrs
315
+ cidrs="$(ksc_cidr_pick_free "$existing_file")" || step_fail "vpc" "no se encontró un rango CIDR libre (ver $existing_file)"
316
+ rm -f "$existing_file"
317
+ subnet="$(sed -n 1p <<<"$cidrs")"; pods="$(sed -n 2p <<<"$cidrs")"
318
+ svc="$(sed -n 3p <<<"$cidrs")"; priv="$(sed -n 4p <<<"$cidrs")"
319
+ printf '%s\n%s\n%s\n%s\n' "$subnet" "$pods" "$svc" "$priv" > "$CIDR_FILE"
320
+ fi
321
+ ksc_ok "rangos CIDR para $NEW_ENV: subred=$subnet pods=$pods svc=$svc sql_private_access=$priv"
322
+
323
+ ksc_replace_nth "$f" 'ip_cidr_range\s*=\s*"([^"]*)"' 1 "$subnet" || return 1
324
+ ksc_replace_nth "$f" 'ip_cidr_range\s*=\s*"([^"]*)"' 2 "$pods" || return 1
325
+ ksc_replace_nth "$f" 'ip_cidr_range\s*=\s*"([^"]*)"' 3 "$svc" || return 1
326
+ }
327
+
328
+ build_component_firewall() {
329
+ ksc_copy_component_files firewall
330
+ }
331
+
332
+ build_component_sql_private_access() {
333
+ ksc_copy_component_files sql_private_access
334
+ local f="$NEW_DIR/sql_private_access/terragrunt.hcl"
335
+ [[ -f "$CIDR_FILE" ]] || step_fail "sql_private_access" "falta $CIDR_FILE — el paso 'vpc' debe correr antes."
336
+ local priv priv_addr priv_prefix
337
+ priv="$(sed -n 4p "$CIDR_FILE")"
338
+ priv_addr="${priv%%/*}"
339
+ priv_prefix="${priv##*/}"
340
+ # El módulo guarda la dirección sin sufijo y el prefijo en un campo
341
+ # separado (ver live/*/sql_private_access/terragrunt.hcl real) — nunca
342
+ # "x.x.x.x/16" junto en un solo campo.
343
+ ksc_set_key "$f" "address" "$priv_addr"
344
+ ksc_set_key "$f" "prefix_length" "$priv_prefix"
345
+ }
346
+
347
+ build_component_sql_postgres() {
348
+ ksc_copy_component_files sql_postgres
349
+ local f="$NEW_DIR/sql_postgres/terragrunt.hcl"
350
+ ksc_set_key "$f" "name" "$APP_NEW"
351
+ ksc_set_key "$f" "label_project" "$APP_NEW"
352
+ ksc_set_key "$f" "label_environment" "$ENTORNO_NEW"
353
+ ksc_set_key "$f" "label_owner" "$LABEL_OWNER"
354
+ ksc_set_key "$f" "database_version" "$DATABASE_VERSION_NEEDS_VERIFICATION"
355
+
356
+ # Guía §3.9: deletion_protection* debe ser "true" en *-prod, confirmar
357
+ # con el humano. Antes se copiaba de SOURCE_ENV sin aviso — si SOURCE_ENV
358
+ # es qa (="false") y NEW_ENV es prod, el generado quedaba "false" en
359
+ # prod sin que nadie lo notara (Review V1, E-4/faltante #11).
360
+ if [[ "$ENTORNO_NEW" == "prod" ]]; then
361
+ local dp dpe
362
+ dp="$(ksc_hcl_get "$f" deletion_protection)"
363
+ dpe="$(ksc_hcl_get "$f" deletion_protection_enabled)"
364
+ if [[ "$dp" == "false" || "$dpe" == "false" ]]; then
365
+ ksc_warn "NEW_ENV es *-prod pero deletion_protection/deletion_protection_enabled quedaron en \"false\" (heredado de $SOURCE_ENV) — la guía pide \"true\" en prod. Confirma con el humano y corrige $f si aplica."
366
+ fi
367
+ fi
368
+
369
+ local dev_ip dev_user
370
+ dev_ip="$(ksc_fresh_public_ip)"
371
+ dev_user="$(id -un)"
372
+ local entries="${FIXED_ADMIN_NETWORKS};dev-${dev_user}=${dev_ip}/32"
373
+ ksc_set_authorized_networks "$f" "$entries" || return 1
374
+ ksc_ok "authorized_networks: fijas=[$FIXED_ADMIN_NETWORKS] + dev-${dev_user}=${dev_ip}/32 (IP obtenida ahora, no reusada)"
375
+
376
+ # dns_record_name/dns_domain_name son opcionales (algunos entornos no los
377
+ # tienen). dns_record_name sigue un patrón seguro de generar siempre
378
+ # (<app>.db, no depende de qué dominio sea dueño nadie). dns_domain_name
379
+ # SÍ codifica un dominio real registrado (ej. "boletera.ninja") — si el
380
+ # humano ya lo confirmó en DNS_DOMAIN (config), se usa ese valor
381
+ # directo, sin WARN; si no, se genera un valor por patrón y SIEMPRE se
382
+ # marca para confirmación (Review V1, mejora #7).
383
+ if grep -q '^[[:space:]]*dns_record_name[[:space:]]*=' "$f"; then
384
+ ksc_set_key "$f" "dns_record_name" "${APP_NEW}.db"
385
+ if [[ -n "${DNS_DOMAIN:-}" ]]; then
386
+ ksc_set_key "$f" "dns_domain_name" "$DNS_DOMAIN"
387
+ ksc_ok "dns_domain_name = \"$DNS_DOMAIN\" (confirmado en DNS_DOMAIN del config, no es un guess)."
388
+ else
389
+ local dns_domain_guess
390
+ if [[ "$ENTORNO_NEW" == "qa" ]]; then
391
+ dns_domain_guess="qa.${APP_NEW}.ninja."
392
+ else
393
+ dns_domain_guess="${APP_NEW}.ninja."
394
+ fi
395
+ ksc_set_key "$f" "dns_domain_name" "$dns_domain_guess"
396
+ ksc_warn "dns_domain_name de $f se generó SOLO por patrón visual ($dns_domain_guess, copiando la convención de $SOURCE_ENV) porque DNS_DOMAIN no está definido en el config. CONFIRMA con el humano que '$APP_NEW' realmente usa ese dominio antes de desplegar — si no, define DNS_DOMAIN en el config y reconstruye."
397
+ fi
398
+ fi
399
+ }
400
+
401
+ build_component_gke_private() {
402
+ ksc_copy_component_files gke_private
403
+ local f="$NEW_DIR/gke_private/terragrunt.hcl"
404
+ ksc_set_key "$f" "label_project" "$APP_NEW"
405
+ ksc_set_key "$f" "label_environment" "$ENTORNO_NEW"
406
+ ksc_set_key "$f" "label_owner" "$LABEL_OWNER"
407
+ ksc_set_key "$f" "gke_version" "$GKE_VERSION_NEEDS_VERIFICATION"
408
+ }
409
+
410
+ build_component_storage_assets() {
411
+ ksc_copy_component_files storage_assets
412
+ local f="$NEW_DIR/storage_assets/terragrunt.hcl"
413
+ ksc_set_key "$f" "label_project" "$APP_NEW"
414
+ ksc_set_key "$f" "label_environment" "$ENTORNO_NEW"
415
+ ksc_set_key "$f" "label_owner" "$LABEL_OWNER"
416
+ }
417
+
418
+ build_component_storage_docs() {
419
+ ksc_copy_component_files storage_docs
420
+ local f="$NEW_DIR/storage_docs/terragrunt.hcl"
421
+ ksc_set_key "$f" "label_project" "$APP_NEW"
422
+ ksc_set_key "$f" "label_environment" "$ENTORNO_NEW"
423
+ ksc_set_key "$f" "label_owner" "$LABEL_OWNER"
424
+ }
425
+
426
+ build_component_artifact_registry() {
427
+ # Se decide ANTES de copiar (ksc_copy_component_files ya sustituye
428
+ # APP_SOURCE->APP_NEW automáticamente): si el archivo de SOURCE_ENV ya
429
+ # era genérico (sin el nombre de la app), la sustitución automática no
430
+ # tiene nada que reemplazar y el resultado seguiría siendo genérico.
431
+ local was_generic=1
432
+ grep -q "$APP_SOURCE" "$SOURCE_DIR/artifact_registry/terragrunt.hcl" && was_generic=0
433
+
434
+ ksc_copy_component_files artifact_registry
435
+
436
+ if [[ "$was_generic" -eq 1 ]]; then
437
+ local f="$NEW_DIR/artifact_registry/terragrunt.hcl"
438
+ local template_output id_tpl desc_tpl
439
+ template_output="$(ksc_learn_artifact_registry_template "$REPO_ROOT")"
440
+ id_tpl="$(sed -n '1p' <<<"$template_output")"
441
+ desc_tpl="$(sed -n '2p' <<<"$template_output")"
442
+ if [[ -n "$id_tpl" ]]; then
443
+ # Se "aprendió" el patrón de OTRO entorno real que sí tiene su
444
+ # artifact_registry específico (ej. xoloshop-qa) y se aplica con
445
+ # el nombre de la app nueva — sin hardcodear ningún literal de la
446
+ # organización en el código del skill (Review V2, mejora pendiente
447
+ # de la sección 2.3/4.3).
448
+ local new_id="${id_tpl//\{\{APP\}\}/$APP_NEW}"
449
+ local new_desc="${desc_tpl//\{\{APP_CAP\}\}/$APP_NEW_CAP}"
450
+ new_desc="${new_desc//\{\{APP\}\}/$APP_NEW}"
451
+ ksc_set_key "$f" "repository_id" "$new_id"
452
+ ksc_set_key "$f" "description" "$new_desc"
453
+ ksc_ok "artifact_registry: $SOURCE_ENV tenía valores genéricos; se generaron repository_id/description específicos aprendiendo el patrón de otro entorno real (repository_id=\"$new_id\")."
454
+ else
455
+ ksc_warn "artifact_registry de $SOURCE_ENV ya tiene repository_id/description genéricos (no contienen '$APP_SOURCE'), y ningún otro entorno de live/ tiene uno específico del que aprender el patrón. Se copió sin poder adaptar el nombre de la app: corrígelo a mano con valores específicos de $APP_NEW antes de confiar en él."
456
+ fi
457
+ fi
458
+ }
459
+
460
+ build_component_github_workload_identity() {
461
+ ksc_copy_component_files github_workload_identity
462
+ local f="$NEW_DIR/github_workload_identity/terragrunt.hcl"
463
+
464
+ # El binding de terraform se identifica por contenido
465
+ # (service_account_email que empieza con "terraform@"), nunca por
466
+ # posición — Review V1 (E-5/mejora #8) señaló que editar por posición
467
+ # (la 2.ª coincidencia de principal_set_suffix) corrompería el binding
468
+ # de infra-cloud si el orden en SOURCE_ENV fuera distinto.
469
+ #
470
+ # No todos los entornos reales tienen este binding: cashless-qa real
471
+ # solo tiene el de github-manager. Si falta, no es un error — se usa
472
+ # "infra-cloud" por defecto (el valor que la guía §3.15 pide siempre)
473
+ # y se avisa, en vez de fallar la construcción completa.
474
+ local terraform_suffix
475
+ terraform_suffix="$(python3 -c "
476
+ import re
477
+ with open('$f') as fh:
478
+ text = fh.read()
479
+ m = re.search(r'service_account_email\s*=\s*\"terraform@[^\"]*\"[^}]*principal_set_suffix\s*=\s*\"([^\"]*)\"', text, re.DOTALL)
480
+ print(m.group(1) if m else '')
481
+ ")"
482
+ if [[ -z "$terraform_suffix" ]]; then
483
+ terraform_suffix="infra-cloud"
484
+ ksc_warn "github_workload_identity de $SOURCE_ENV no tenía un binding de terraform@... — se agregó uno nuevo con suffix \"infra-cloud\" (necesario para que el CI/CD de este repo opere sobre $NEW_ENV). Confirma que ese es el repo correcto."
485
+ fi
486
+
487
+ # APP_GITHUB_REPO admite uno o varios repos separados por ';' (apps
488
+ # multi-repo, como boletera-qa con 5 — Review V1 mejora #6). El bloque
489
+ # completo se reconstruye siempre: terraform (preservando su suffix) +
490
+ # un binding github-manager por repo. Esto reemplaza por completo el
491
+ # enfoque anterior de "advertir y no tocar" cuando había más de 2
492
+ # bindings.
493
+ local repo_names="" repo entry
494
+ IFS=';' read -ra _repos <<<"$APP_GITHUB_REPO"
495
+ for entry in "${_repos[@]}"; do
496
+ [[ -z "$entry" ]] && continue
497
+ repo="${entry##*/}"
498
+ repo_names="${repo_names}${repo_names:+;}${repo}"
499
+ done
500
+ [[ -n "$repo_names" ]] || step_fail "github_workload_identity" "APP_GITHUB_REPO está vacío en el config"
501
+
502
+ ksc_set_workload_identity_bindings "$f" "$terraform_suffix" "$repo_names" || return 1
503
+
504
+ local n_repos
505
+ n_repos="$(tr ';' '\n' <<<"$repo_names" | grep -c .)"
506
+ if [[ "$n_repos" -gt 1 ]]; then
507
+ ksc_warn "github_workload_identity de $NEW_ENV quedó con $n_repos bindings github-manager (uno por repo en APP_GITHUB_REPO) — confirma que esa es la lista completa y correcta de repos de la app."
508
+ fi
509
+ }
510
+
511
+ # Fallback genérico para cualquier componente opcional sin manejo
512
+ # específico que de todas formas se incluyó (vía EXTRA_COMPONENTS).
513
+ build_component_generic() {
514
+ local comp="$1"
515
+ ksc_copy_component_files "$comp"
516
+ ksc_warn "'$comp' no tiene manejo específico en este skill (incluido vía EXTRA_COMPONENTS) — se copió con la sustitución estándar de entorno/nombre de app. Revísalo a mano antes de confiar en él."
517
+ }
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Ejecución del plan
521
+ # ---------------------------------------------------------------------------
522
+ step_start "project.hcl"
523
+ build_project_hcl || step_fail "project.hcl" "fallo copiando/editando project.hcl"
524
+ step_done "project.hcl"
525
+
526
+ step_start "top-level-files"
527
+ build_top_level_files || step_fail "top-level-files" "fallo copiando Makefile/README.md/.gitignore de nivel raíz"
528
+ step_done "top-level-files"
529
+
530
+ for comp in "${COMPONENTS[@]}"; do
531
+ step_start "$comp"
532
+ fn="build_component_${comp}"
533
+ if declare -f "$fn" >/dev/null; then
534
+ "$fn" || step_fail "$comp" "fallo en $fn"
535
+ else
536
+ build_component_generic "$comp" || step_fail "$comp" "fallo en build_component_generic"
537
+ fi
538
+ step_done "$comp"
539
+ done
540
+
541
+ step_start "fmt"
542
+ for comp in "${COMPONENTS[@]}"; do
543
+ tg="$NEW_DIR/$comp/terragrunt.hcl"
544
+ [[ -f "$tg" ]] || continue
545
+ if ! ksc_check_duplicate_keys "$tg" 2>/tmp/ksc_dupe.$$; then
546
+ ksc_warn "$(cat /tmp/ksc_dupe.$$) (heredado de SOURCE_ENV — terragrunt hcl fmt/validate no lo detectan por sí solos)"
547
+ fi
548
+ rm -f /tmp/ksc_dupe.$$
549
+ done
550
+
551
+ if command -v terragrunt >/dev/null 2>&1; then
552
+ (cd "$NEW_DIR" && terragrunt hcl fmt --working-dir . >/tmp/ksc_fmt.$$ 2>&1) \
553
+ || step_fail "fmt" "terragrunt hcl fmt falló: $(cat /tmp/ksc_fmt.$$)"
554
+ rm -f /tmp/ksc_fmt.$$
555
+
556
+ # Guía §3.17: `terragrunt hcl validate` por componente al cerrar la
557
+ # construcción (antes solo se corría `hcl fmt`; Review V1, faltante
558
+ # #10). La excepción conocida de gke_private (falso positivo sobre
559
+ # `dependency.vpc` sin remote state) se tolera; cualquier otro error de
560
+ # sintaxis real sí detiene la construcción.
561
+ for comp in "${COMPONENTS[@]}"; do
562
+ [[ -f "$NEW_DIR/$comp/terragrunt.hcl" ]] || continue
563
+ if ! (cd "$NEW_DIR/$comp" && terragrunt hcl validate >/tmp/ksc_val.$$ 2>&1); then
564
+ if grep -q 'does not have an attribute named "outputs"' /tmp/ksc_val.$$; then
565
+ ksc_info "$comp: falso positivo conocido de 'terragrunt hcl validate' sobre dependency sin remote state (ver lessons-learned.md) — ignorado."
566
+ else
567
+ step_fail "fmt" "$comp: terragrunt hcl validate falló: $(cat /tmp/ksc_val.$$)"
568
+ fi
569
+ fi
570
+ rm -f /tmp/ksc_val.$$
571
+ done
572
+ else
573
+ ksc_warn "terragrunt no está instalado en este entorno — se omite 'hcl fmt'/'hcl validate' (no es bloqueante, pero córrelos manualmente después)."
574
+ fi
575
+ step_done "fmt"
576
+
577
+ if [[ -s "$DOCTOR_WARNINGS_FILE" || -s "$KSC_WARNINGS_FILE" ]]; then
578
+ echo
579
+ echo "=== WARN acumulados (doctor: $DOCTOR_WARNINGS_FILE + construcción: $KSC_WARNINGS_FILE) ==="
580
+ [[ -s "$DOCTOR_WARNINGS_FILE" ]] && cat "$DOCTOR_WARNINGS_FILE"
581
+ [[ -s "$KSC_WARNINGS_FILE" ]] && cat "$KSC_WARNINGS_FILE"
582
+ fi
583
+
584
+ echo "BUILD_OK: $NEW_DIR"