fix(tools): безопасная генерация YAML — staging + атомарная замена, без легаси-фолбэков

01_generate_yamls.sh:
- генерация в staging-каталог; рабочий каталог не удаляется заранее;
  атомарная замена (mv) только при полном успехе, старый каталог → бэкап
  resources_yaml.bak-<UTC> (ротация KEEP_BACKUPS=5);
- убран легаси-фолбэк токена 'ls -t ROOT/*.token' (подхватывал чужой токен);
- NUBES_API_ENDPOINT обязателен (убран молчаливый PROD-дефолт);
- имя сервиса берётся из 2-го поля services_list.txt (убран лишний HTTP-запрос);
- убран безусловный rm старого YAML перед генерацией (неатомарность);
- удалён дубль SERVICES_FILE_DEFAULT; маркер .stand защищает от чужого стенда;
- синхронизированы комментарии (REQUEST_DELAY 0.5, пути, формат списка).

yaml-generator/internal/config:
- NUBES_API_ENDPOINT обязателен (убран PROD-дефолт);
- убран легаси-поиск последнего *.token в корне репо (loadToken/findLatestToken);
- убран несуществующий путь provider/devops/config/services_list.txt
  (теперь требуется явный NUBES_SERVICES_FILE);
- удалены мёртвые getenvDefault/findLatestToken.

Проверено: bash -n OK, go vet/go build OK.
This commit is contained in:
Repinoid
2026-09-30 09:28:41 +03:00
parent 190fc68f93
commit 12b3932817
2 changed files with 96 additions and 138 deletions
+84 -84
View File
@@ -43,10 +43,9 @@ resolve_root_path() {
echo "${ROOT_DIR}/${path_value}"
}
# Список сервисов — только из профиля стенда (TOOLS/config/<stand>/services_list.txt).
# Общий TOOLS/config/services_list.txt («объединение») не используется ни одним профилем.
SERVICES_FILE_DEFAULT="${PROFILE_DIR}/services_list.txt"
if [[ -f "${PROFILE_DIR}/services_list.txt" ]]; then
SERVICES_FILE_DEFAULT="${PROFILE_DIR}/services_list.txt"
fi
SERVICES_FILE="${SERVICES_FILE:-$SERVICES_FILE_DEFAULT}"
SERVICES_FILE="$(resolve_root_path "$SERVICES_FILE")"
@@ -54,7 +53,7 @@ SERVICES_FILE="$(resolve_root_path "$SERVICES_FILE")"
REQUEST_DELAY="${REQUEST_DELAY:-0.5}"
ATTEMPTS="${ATTEMPTS:-3}"
# Auth: either an explicit token or a token file (latest *.token if not set).
# Auth: токен из NUBES_API_TOKEN или из файла TOKEN_FILE (profile.env).
TOKEN_FILE="${TOKEN_FILE:-}"
NUBES_API_TOKEN="${NUBES_API_TOKEN:-}"
if [[ -n "$TOKEN_FILE" ]]; then
@@ -62,37 +61,39 @@ if [[ -n "$TOKEN_FILE" ]]; then
fi
# Назначение:
# - Берет список сервисов из services_list.txt.
# - Берет список сервисов из services_list.txt профиля стенда.
# - Для каждого сервиса запрашивает единый spec через API и пишет YAML в
# ${ROOT_DIR}/provider/resources_yaml.
# ${ROOT_DIR}/generated/<stand>/resources_yaml (per-stand, см. ниже).
# - Формат имени файла: ID_имя.yaml (например, 13_s3bucket.yaml).
#
# Безопасность записи (best practice, с 2026-09-30):
# - Генерация идёт во временный каталог (staging), старый каталог не удаляется заранее.
# - Замена каталога атомарна и делается ТОЛЬКО при полном успехе; предыдущий
# каталог уезжает в бэкап resources_yaml.bak-<UTC>, хранятся последние KEEP_BACKUPS.
# - При любом сбое рабочий каталог остаётся нетронутым, частичный результат — в staging.
#
# Источники данных:
# - Список сервисов: TOOLS/config/{stand}/services_list.txt
# - API endpoint: ${API_ENDPOINT}
# - Токен: переменная NUBES_API_TOKEN или файл .token.
# - API endpoint: ${API_ENDPOINT} (обязателен, только из profile.env)
# - Токен: переменная NUBES_API_TOKEN или файл TOKEN_FILE (из profile.env).
#
# Формат services_list.txt:
# - В начале строки: service_id.
# - Остальное в строке — произвольная заметка для девопса (не используется).
# - Поле 1: service_id (обязательно).
# - Поле 2: имя сервиса (используется для лога; можно опустить).
# - Всё после # — заметка для девопса (не используется).
#
# Задержки и попытки:
# - REQUEST_DELAY (секунды) между запросами и ретраями (по умолчанию 0.2).
# - REQUEST_DELAY (секунды) между запросами и ретраями (по умолчанию 0.5).
# - ATTEMPTS — число попыток на сервис (по умолчанию 3).
#
# Логи ошибок:
# - Список неуспешных сервисов пишется в /tmp/yaml_gen_failures.txt.
# - Список неуспешных сервисов пишется в generated/<stand>/tmp/yaml_gen_failures.txt.
#
# Токен берется из переменной NUBES_API_TOKEN.
# Если переменная пуста, берется файл .token из ${ROOT_DIR}:
# - "последний" = файл с самым новым временем изменения (ls -t | head -n 1).
if [[ -z "$NUBES_API_TOKEN" ]]; then
if [[ -z "$TOKEN_FILE" ]]; then
TOKEN_FILE=$(ls -t "${ROOT_DIR}"/*.token 2>/dev/null | head -n 1 || true)
fi
if [[ -n "$TOKEN_FILE" && -f "$TOKEN_FILE" ]]; then
NUBES_API_TOKEN=$(cat "$TOKEN_FILE")
fi
# Токен: NUBES_API_TOKEN, иначе файл TOKEN_FILE (из profile.env).
# Легаси-поиск «последнего» *.token в корне репо удален (2026-09-30): токенов там
# нет, а такой поиск мог молча подхватить чужой токен.
if [[ -z "$NUBES_API_TOKEN" && -n "$TOKEN_FILE" && -f "$TOKEN_FILE" ]]; then
NUBES_API_TOKEN=$(cat "$TOKEN_FILE")
fi
if [[ -z "$NUBES_API_TOKEN" ]]; then
@@ -105,12 +106,18 @@ if [[ ! -f "$SERVICES_FILE" ]]; then
exit 2
fi
# ⛔ LEGACY: deck-api.ngcloud.ru ЗАКРЫВАЕТСЯ. Default = Gateway. Override via NUBES_API_ENDPOINT.
API_ENDPOINT="${NUBES_API_ENDPOINT:-https://lk-api-gateway.ngcloud.ru/api/v1/svc}"
# Endpoint ОБЯЗАТЕЛЕН: берется ТОЛЬКО из NUBES_API_ENDPOINT (profile.env).
# Легаси-фолбэк на PROD gateway удален (2026-09-30): молчаливый уход в прод недопустим.
if [[ -z "${NUBES_API_ENDPOINT:-}" ]]; then
echo "Error: NUBES_API_ENDPOINT is required (set it in ${PROFILE_DIR}/profile.env)." >&2
exit 2
fi
API_ENDPOINT="$NUBES_API_ENDPOINT"
# Auto-detect API style: if endpoint contains "index.cfm" → legacy proxy (?endpoint=),
# otherwise → new REST gateway (direct paths). No forced /index.cfm normalization.
GENERATED_DIR="${ROOT_DIR}/generated/$(basename "$PROFILE_DIR")"
STAND="$(basename "$PROFILE_DIR")"
GENERATED_DIR="${ROOT_DIR}/generated/${STAND}"
YAML_OUTPUT_DIR_DEFAULT="${GENERATED_DIR}/resources_yaml"
FAILURES_FILE_DEFAULT="${GENERATED_DIR}/tmp/yaml_gen_failures.txt"
@@ -119,12 +126,27 @@ FAILURES_FILE="${FAILURES_FILE:-$FAILURES_FILE_DEFAULT}"
YAML_OUTPUT_DIR="$(resolve_root_path "$YAML_OUTPUT_DIR")"
FAILURES_FILE="$(resolve_root_path "$FAILURES_FILE")"
# Убедимся, что папка есть. Полной очистки нет: обрабатываем только сервисы из списка.
mkdir -p "$YAML_OUTPUT_DIR"
YAML_PARENT_DIR="$(dirname "$YAML_OUTPUT_DIR")"
YAML_BASE_NAME="$(basename "$YAML_OUTPUT_DIR")"
mkdir -p "$YAML_PARENT_DIR"
mkdir -p "$(dirname "$FAILURES_FILE")"
if [[ ! -f "${YAML_OUTPUT_DIR}/embed.go" ]]; then
cat > "${YAML_OUTPUT_DIR}/embed.go" <<'EOF'
# Защита от генерации не в тот стенд: каталог помечается маркером .stand.
if [[ -d "$YAML_OUTPUT_DIR" && -f "${YAML_OUTPUT_DIR}/.stand" ]]; then
existing_stand="$(cat "${YAML_OUTPUT_DIR}/.stand" 2>/dev/null || true)"
if [[ -n "$existing_stand" && "$existing_stand" != "$STAND" ]]; then
echo "Error: ${YAML_OUTPUT_DIR} belongs to stand '${existing_stand}', not '${STAND}'. Refusing." >&2
exit 2
fi
fi
# Staging: старый каталог НЕ удаляется заранее, замена — атомарная (mv в конце).
STAGING_DIR="${YAML_OUTPUT_DIR}.staging.$$"
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
# embed.go обязателен: пакет resources_yaml используется через go:embed *.yaml.
cat > "${STAGING_DIR}/embed.go" <<'EOF'
package resources_yaml
import "embed"
@@ -134,7 +156,7 @@ import "embed"
//go:embed *.yaml
var Files embed.FS
EOF
fi
printf '%s\n' "$STAND" > "${STAGING_DIR}/.stand"
rm -f "$FAILURES_FILE"
@@ -164,7 +186,12 @@ while IFS= read -r line; do
continue
fi
sid=$(echo "$line" | awk '{print $1}')
svc_name=""
# Имя сервиса берётся из 2-го поля services_list.txt. API ради имени не дёргаем:
# Go-генератор сам запрашивает spec и нормализует имя (это убирает лишний запрос).
svc_name="$(echo "$line" | awk '{print $2}')"
if [[ "$svc_name" == \#* ]]; then
svc_name=""
fi
if [[ -z "$sid" ]]; then
continue
@@ -176,59 +203,13 @@ while IFS= read -r line; do
continue
fi
# Если имя не указано в списке — подтягиваем по API.
if [[ -z "$svc_name" ]]; then
svc_name=$(python3 - <<PY
import json, sys, time, urllib.parse, urllib.request
sid = "${sid}"
endpoint = "${API_ENDPOINT}"
token = "${NUBES_API_TOKEN}"
max_retries = 3
# Auto-detect API style: "index.cfm" → legacy proxy, otherwise → REST gateway.
if "index.cfm" in endpoint:
params = urllib.parse.urlencode({"endpoint": f"/services/{sid}"})
url = f"{endpoint}?{params}"
else:
url = f"{endpoint}/services/{sid}"
for attempt in range(1, max_retries + 1):
try:
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"Bearer {token}")
req.add_header("User-Agent", "Mozilla/5.0 (compatible; Terraform-Provider-Nubes/BashGenerator)")
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
svc = data.get("svc", {})
name = svc.get("svcShort") or svc.get("name") or svc.get("title") or f"service_{sid}"
print(name)
sys.exit(0)
except Exception as e:
if attempt < max_retries:
time.sleep(2 * attempt)
else:
print(f"ERROR: failed to fetch service {sid} after {max_retries} attempts: {e}", file=sys.stderr)
sys.exit(1)
PY
)
if [[ "$svc_name" == ERROR:* ]]; then
echo "$svc_name" >&2
echo "${sid} api_error" >> "$FAILURES_FILE"
continue
fi
fi
# Один YAML на сервис формируется Go-генератором.
# Один YAML на сервис формируется Go-генератором — пишем во временный каталог.
echo "Generating unified spec for ${sid} (${svc_name})"
success=0
# Generator normalizes names, so verify output by ID prefix only.
output_glob="${YAML_OUTPUT_DIR}/${sid}_*.yaml"
# Для выбранного сервиса удаляем старый YAML и генерируем заново с ретраями.
rm -f "$output_glob"
output_glob="${STAGING_DIR}/${sid}_*.yaml"
# Старый YAML НЕ удаляем заранее: полная замена каталога — атомарная, в конце.
# Генерация YAML для выбранного сервиса с ретраями.
for attempt in $(seq 1 "$ATTEMPTS"); do
(
@@ -237,7 +218,7 @@ PY
NUBES_SERVICE_ID="$sid" \
NUBES_SERVICE_NAME="$svc_name" \
NUBES_API_ENDPOINT="$API_ENDPOINT" \
NUBES_OUTPUT_DIR="$YAML_OUTPUT_DIR" \
NUBES_OUTPUT_DIR="$STAGING_DIR" \
"${ROOT_DIR}/TOOLS/bin/yaml-generator"
) && success=1 || success=0
@@ -265,9 +246,28 @@ PY
done < "$SERVICES_FILE"
echo "Done. YAML files are in ${YAML_OUTPUT_DIR}"
if [[ -f "$FAILURES_FILE" ]]; then
if [[ -s "$FAILURES_FILE" ]]; then
echo "Failed services (see $FAILURES_FILE):" >&2
cat "$FAILURES_FILE" >&2
echo "" >&2
echo "Замена каталога ОТМЕНЕНА: рабочий ${YAML_OUTPUT_DIR} не тронут." >&2
echo "Частичный результат оставлен для разбора: $STAGING_DIR" >&2
exit 1
fi
fi
# Атомарная замена: старый каталог уезжает в бэкап, новый встаёт на его место.
BACKUP_DIR="${YAML_PARENT_DIR}/${YAML_BASE_NAME}.bak-$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -d "$YAML_OUTPUT_DIR" ]]; then
mv "$YAML_OUTPUT_DIR" "$BACKUP_DIR"
echo "Backup of previous specs: $BACKUP_DIR"
fi
mv "$STAGING_DIR" "$YAML_OUTPUT_DIR"
# Ротация бэкапов: держим последние KEEP_BACKUPS (по умолчанию 5).
KEEP_BACKUPS="${KEEP_BACKUPS:-5}"
mapfile -t stale_backups < <(ls -1dt "${YAML_PARENT_DIR}/${YAML_BASE_NAME}.bak-"* 2>/dev/null | tail -n +"$((KEEP_BACKUPS + 1))")
if [[ "${#stale_backups[@]}" -gt 0 ]]; then
rm -rf -- "${stale_backups[@]}"
fi
echo "Done. YAML files are in ${YAML_OUTPUT_DIR}"
+12 -54
View File
@@ -30,7 +30,12 @@ type ServiceRef struct {
// Load читает конфигурацию из переменных окружения и файлов.
func Load() (Config, error) {
apiEndpoint := getenvDefault("NUBES_API_ENDPOINT", "https://lk-api-gateway.ngcloud.ru/api/v1/svc")
// Endpoint ОБЯЗАТЕЛЕН. Легаси-дефолт на PROD gateway удален (2026-09-30):
// провайдер не должен молча ходить в прод при незаданном endpoint.
apiEndpoint := strings.TrimSpace(os.Getenv("NUBES_API_ENDPOINT"))
if apiEndpoint == "" {
return Config{}, errors.New("NUBES_API_ENDPOINT is required (no default: refusing to guess a stand endpoint)")
}
apiEndpoint = normalize.APIEndpoint(apiEndpoint)
apiToken, err := loadToken()
if err != nil {
@@ -51,13 +56,11 @@ func Load() (Config, error) {
services := []ServiceRef{}
if singleID == 0 {
// Без per-service ID нужен явный список. Угадывание пути удалено (2026-09-30):
// старый дефолт указывал на несуществующий provider/devops/config/services_list.txt.
listPath := strings.TrimSpace(os.Getenv("NUBES_SERVICES_FILE"))
if listPath == "" {
repoRoot, err := FindRepoRoot()
if err != nil {
return Config{}, err
}
listPath = filepath.Join(repoRoot, "devops", "config", "services_list.txt")
return Config{}, errors.New("NUBES_SERVICES_FILE is required when NUBES_SERVICE_ID is not set")
}
list, err := ReadServicesList(listPath)
if err != nil {
@@ -76,14 +79,6 @@ func Load() (Config, error) {
}, nil
}
func getenvDefault(key string, def string) string {
val := strings.TrimSpace(os.Getenv(key))
if val == "" {
return def
}
return val
}
func loadToken() (string, error) {
if tok := strings.TrimSpace(os.Getenv("NUBES_API_TOKEN")); tok != "" {
return tok, nil
@@ -95,46 +90,9 @@ func loadToken() (string, error) {
}
return strings.TrimSpace(string(b)), nil
}
repoRoot, err := FindRepoRoot()
if err != nil {
return "", err
}
latest, err := findLatestToken(repoRoot)
if err != nil {
return "", err
}
if latest == "" {
return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required")
}
b, err := os.ReadFile(latest)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
func findLatestToken(dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var latest string
var latestTime int64
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".token") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
mt := info.ModTime().Unix()
if mt > latestTime {
latestTime = mt
latest = filepath.Join(dir, e.Name())
}
}
return latest, nil
// Легаси-фолбэк «найти последний *.token в корне репо» удален (2026-09-30):
// токенов там нет, а такой поиск мог молча подхватить чужой токен.
return "", errors.New("NUBES_API_TOKEN or TOKEN_FILE is required")
}
// ReadServicesList читает список сервисов из файла.