
Комментарии 4
Интересно, что вы отказались от перекодировки v5 -> v6 из-за конфликта номеров полей (write_only/nested_type) и пошли на дублирование адаптеров. А не рассматривали генерацию адаптеров из самих .proto-файлов через кодогенерацию, раз структура сообщений всё равно почти идентична? Или овчинка выделки не стоит на объёме в ~200 строк?
Механизм с ValidateResourceConfig и последовательным удалением конфликтующих optional-полей по одному за раунд — красивое решение для проблемы, которая в принципе не решается статическим анализом схемы. Держите в уме, что maxFixRounds может не хватить на ресурсах с длинными цепочками зависимостей между атрибутами (A конфликтует с B -> после удаления A, C конфликтует с B)? Есть какой-то fallback на этот случай, кроме варнинга в лог?
Спасибо, оба вопроса по делу.
Про кодогенерацию. Номера полей мешают только перекодированию на уровне байтов. Генератор, который сопоставляет поля по имени, был бы от этого защищён, так что идея рабочая. Я не пошёл на неё потому, что различия не только в номерах: вложенные атрибуты (nested_type) есть только в v6, и эту ветку всё равно пришлось бы писать руками. На ~300 строк генератор с исключениями выходит дороже самих адаптеров. Terraform и OpenTofu, насколько я знаю, тоже держат для v5 и v6 два отдельных конвертера, написанных вручную. Настоящий риск в другом: протокол обновится, а адаптер тихо пропустит новое поле. От этого лучше защищает не генератор, а тест, который сверяет поля обоих .proto с тем, что адаптеры реально переносят, и падает на незнакомом поле. Добавлю такой.
Про maxFixRounds. Вы нашли дыру, и она хуже, чем вы предположили: предупреждения на этот случай нет. Если 16 раундов кончились, цикл выходит молча, и ресурс остаётся с ошибкой до tofu plan. Цепочки вида «A конфликтует с B, после удаления A — C с B» цикл проходит: каждый шаг — один раунд. На реальных ресурсах, которые я гонял, хватало 1–3 раундов, но 16 — число с потолка. Исправлю так:
число раундов — по количеству заданных необязательных аргументов: каждый раунд убирает один, так что цикл гарантированно закончится, а искусственный предел не нужен;
в первую очередь убирать аргументы с нулевыми значениями — именно их пишет в state старый SDK, и из-за них чаще всего конфликты;
если ошибки остались, ресурс не отдаётся молча: над ним в сгенерированном файле появляется комментарий с текстом ошибок провайдера, плюс итоговый список таких ресурсов в конце запуска.
Про молчаливый выход из maxFixRounds — рад, что пригодилось, это как раз тот случай, когда баг всплывает не в тестах, а на проде у кого-то ещё через полгода ).
По новому плану на «убирать сначала нулевые значения»: а как вы отличаете «реально нулевое, потому что unset» от «пользователь осознанно поставил 0/false/""» в SDKv2-состоянии? Если я правильно понял суть проблемы из статьи, стейт старого SDK в принципе не хранит эту разницу — тогда эвристика «нулевые первыми» может задеть валидные явные нули. Или на практике это не встречается, потому что осмысленный 0 обычно идёт в Required-поле, а не в Optional, и до вашего цикла просто не доходит?
И по тесту, сверяющему поля адаптеров с .proto — он у вас будет генерироваться из самих proto-файлов (structural diff по номерам полей), или руками поддерживаемый список ожидаемых полей на каждую версию протокола? Второе тоже сработает, но первое само себя обновит при апгрейде terraform-plugin-go.
По нулям вы правы: из state старого SDK «не задано» и «явно 0» не различить. SDKv2 пишет нулевое значение в обоих случаях, а в схеме протокола нет даже значения по умолчанию (Default), так что провайдер нам ничего не подскажет.
Но эвристика не решает, что удалять, — она только выбирает порядок. Цикл трогает лишь аргументы, на которые провайдер вернул ошибку валидации. Необязательный desired_count = 0 без конфликта до цикла просто не доходит, и неважно, осмысленный он или нет. Обязательные поля не удаляются никогда. «Нулевые первыми» решает только, какой из отклонённых аргументов убрать раньше. Типичный случай — пара с ConflictsWith, где один аргумент нулевой, а второй нет. Явный ноль здесь почти исключён: SDKv2 проверяет ConflictsWith по сырому конфигу, и явный 0 рядом с заданным партнёром не прошёл бы валидацию ещё у самого пользователя. Раз ресурс существует, такой пары в его конфиге не было, и нулевой член пары — артефакт state.
Где эвристика реально может ошибиться: оба аргумента нулевые или оба нет (тогда порядок ничего не меняет), и поля с ненулевым Default, которые мы не видим. Если убрать явный 0 у поля с Default = 5, провайдер подставит 5, и plan покажет изменение. От этого страхует не эвристика, а проверка результата. В CI есть e2e: Unclick импортирует набор ресурсов из moto, и tofu plan должен показать только импорт — без изменений, созданий и удалений. Так что ваш сценарий упадёт там, а не у пользователя через полгода. Хорошее дополнение: в итоговом списке показывать, какие аргументы были убраны, чтобы при расхождении в plan было видно, откуда оно.
По тесту — гибрид. Множество полей тест будет брать из самих .proto автоматически, через дескрипторы сгенерированных Go-пакетов (protoreflect). Сравнивать v5 с v6 нужно по именам, а не по номерам: номера расходятся намеренно, это и была исходная проблема. А решение «это поле переносим или сознательно пропускаем» автоматизировать нельзя: это смысл, а не структура. Поэтому рядом лежит список пропускаемых полей. Тест падает, если в дескрипторе появилось поле, которого нет ни среди перенесённых, ни среди пропускаемых. При обновлении протокола он сам найдёт новые поля — ровно то, что вы описали, — но требует от человека решения по каждому. Одна оговорка: .proto у нас не подтягиваются из terraform-plugin-go, а скопированы в репозиторий, как советует сам HashiCorp в шапке файла («copy this definition into your own codebase»). Значит, обновление — это копирование новой версии и перегенерация, и тест срабатывает на этом шаге.
Из ClickOps в код: как я оживил Terraformer и научил его говорить с провайдерами напрямую