Kitobni o'qish: «Не верь. Не бойся. Не проси»

Shrift:

От автора

Приветствую тебя, уважаемый читатель.

Для начала расскажу немного о себе - а ты уже решай сам, верить тому, что я расскажу в книге, или нет.

Если считать по годам - а считаю я именно так, потому что цифры без контекста ничего не значат, - у меня за тридцать лет работы в ИТ набралось больше двухсот проектов и три десятка команд, которые я собирал с нуля. Пять из них были стартапами, доведёнными от пустого репозитория до первых клиентов.

Я начал свой путь в IT в далёком 1996 году. Да, я был тем «компьютерщиком», который отвечал за всё, к чему было подключено что-то сложнее лампочки: АТС, камеры, сигнализация. А так как я начинал работать в медицинской организации, то сайт, база данных пациентов, эхоскопы (о да, я видел все свои органы изнутри) и рентгеновские аппараты - тоже была моя зона ответственности.

Но это были ихие 90е, когда разделения на senior/junior, agile, frontend/backend не то что бы не было - их ещё не придумали. Так что опыт я получал на модемах 28.8 с платной тарификацией, а первые сайты делали одной рукой в чистом HTML.

Да что я говорю, в то время юный Линус Торвальдс только начал развивать свой «just a hobby, won't be big»...

Однако времена менялись - менялись и технологии. Я видел, как менялся интернет, разработка, как начали появляться некие градации в ИТ - одним из первых разделений, пожалуй, стало появление «сисадминов» и их отделение от «программистов». Начали появляться Visual-программирование, интерфейсы становились сложнее, веб-разработка уже начала быть не просто «сайтом», а полноценной инфраструктурой.

Вместе с этим развивался и я. Сама разработка софта меня никогда не привлекала - а вот всякий хард и сети - да. Я начал постепенно изучать BSD, Solaris, Linux (да, уже). Осваивал настройку telnet и удалённое управление.

Серьёзно занимался кибербезопасностью - а надо сказать, что в то время, начало 2000х, я уже возглавлял ИТ-отдел авиакомпании, и было бы сложно отделить безопасность от реальности. И это не был разовый эпизод. Через несколько лет я откроюсь уже отдельной компанией именно под это - SOC/Cybersec/Security, реагирование на инциденты, пентесты, zero tolerance, защита периметра - моими клиентами станут банки и нефтянка, медицина и госслужбы, которым, поверьте, есть что терять. А ещё позже, уже в финтехе, буду выстраивать антифрод-мониторинг и DevSecOps-практики.

Вы спросите: «Ты ж в медицине был». Ну да. Просто в то время такого чёткого разделения на fintech/smartech/enterprise IT не было. Если ты разбирался, допустим, в настройках маршрутизации Cisco - для тебя не было разницы, чем занимается организация, на которую ты работаешь. Значение имело «откуда, куда и что должно быть». То же касалось и почти всего остального.

2000е, пожалуй, были самыми взрывными годами становления ИТ как отдельной профессии. Начали появляться полноценные онлайн-магазины, приложения. Появление первых смартфонов и вообще понятия самой мобильной разработки, облачные технологии - да и вообще облачная инфраструктура.

Как я мог при моей любви к железу и ОС отказать себе в удовольствии не быть одним из пионеров DevOps? Да, само понятие появилось в 2009 по сути, но моменты того, что вот тут что-то надо автоматизировать и запустить без участия лишних рук, назревали давно. Разумеется, у нас не было таких средств, как Jira/GitLab и Kubernetes, но у нас были bash/sed/awk и немного магии FreeBSD jails.

Время неумолимо стремилось вперёд, автоматизация становилась на поток, появились git, CI/CD, Docker, k8s.

И AI.

И тут мы, пожалуй, подходим к главному моменту. Как ИИ сочетается с DevOps? Что оно даёт? Да и вообще зачем?

Ответ тут достаточно прост. Написание хорошего Terraform или Ansible по сути даже в наше время мало отличается от HTML 1996 года. То есть это жёсткий язык инструкций, который делается руками. Инфраструктура, если в ней больше пяти физических серверов с VM, или тем более гибридная инфраструктура, - чаще всего это реально зарисованная «от руки» карта. Это всё время, энергия и, главное, психологическая нагрузка, которая чаще всего ведёт к выгоранию.

Выгоревший на работе DevOps, особенно если он senior, - это трагедия для всей компании. Цена его ошибки может исчисляться миллионами евро. Один из примеров:

Knight Capital Group (2012) - банкротство за 45 минут

Что произошло: финансовая компания обновляла ПО на своих серверах. Из восьми серверов DevOps-инженер случайно забыл обновить один. На этом сервере остался старый, неиспользуемый код. Когда система запустилась, этот сервер начал отправлять на биржу миллионы ошибочных ордеров.

Цена ошибки: $440 млн убытков за 45 минут. Компания не смогла оправиться от финансового удара и обанкротилась.

ИИ же помогает снять эту нагрузку. Помнить, что «там» было. Помочь быстро «прочитать» документацию. Найти «вот там я делал это руками, посмотри по history». Накидать типовую конфигурацию redis-cluster в Ansible.

Да в конце концов проверить теорию или идею намного быстрее, чем ты успеешь допить свой кофе.

Я уже молчу о возможностях ИИ-агентов для автоматизации мониторинга и алертинга.

И если честно, я использую ИИ в своей работе каждый день. Начиная от разбора Jira и заканчивая «слушай, мне надо Playbook для автоматизации mysql user/pass».

Но эта книга посвящена тем, кто только начинает или решил начать строить свою карьеру VibeDevOps.

Вы спросите: почему выбрано решение on-prem? Ну хотя бы потому, что оно не сожрёт годовой бюджет маленькой страны за незакрытые ресурсы. И даёт вам право совершать ошибки, изучать всё, что вы сделали «руками», и, главное, контролировать процесс с нуля.

Удачи в изучении и happy vibecoding!

Глава 1. Место для AI

Откройте любой чат с современной нейросетью и напишите:

«Сделай мне production-ready Kubernetes-кластер».

Если вам повезёт, AI нарисует вам несколько сотен строк священного YAML, щедро сдобренных комментариями о том, как его архитектура выдержит миллион запросов в секунду. Если не повезёт - он сделает то же самое, но ещё и с чувством глубокого морального превосходства.

Так работать нельзя.

И не потому, что AI глуп: он не глуп, он - стажёр, который прочитал все книжки по инфраструктуре, но ни разу не держал в руках ни одного реального сервера. Он не знает, что у вас в шкафу два старых сервера и один коммутатор, который чихает при включении, равно как не знает и того, что ваши файлы, ваши секреты и ваши правила безопасности живут по своим, неведомым ему законам. Поскольку ни одно из этих обстоятельств до него не дошло, он заполнит пробелы сам - красиво и уверенно, но неправильно. Поэтому не верьте AI на слово: он скажет вам то, что вы хотите услышать, а хотите вы услышать, что всё готово. Он это и скажет, после чего разгребать последствия придётся вам.

Злого умысла тут нет, потому что так устроена вероятностная модель, предсказывающая наиболее вероятное продолжение текста; а наиболее вероятное продолжение текста про кластер - это примеры из документации, где всё идеально: три гипервизора, общее хранилище и отдельные VLAN. Ничего из этого у вас нет, но AI об этом не знает и не спросит. Впрочем, это лишь половина механизма, и сама по себе она безобидна, тогда как вторая половина - ваша.

Вы хотите быстро, и не от лени: задача горит, а ответ выглядит готовым. Именно здесь два дефекта складываются в замок, поскольку я выдаю правдоподобное, вы хотите быстрое - а в момент выдачи «правдоподобно и быстро» ничем не отличается от «верно и быстро», и разница проявится позже, в другом месте. Порознь ни один из дефектов не смертелен: правдоподобную ерунду человек, привыкший проверять, ловит на первой же команде, тогда как спешащий человек, получивший явную чушь, просто её отбросит. Убивает стык.

Выглядит это обычно скучно. Агент называет вам флаг, которого нет: имя составлено по всем правилам этого инструмента, флаг встаёт в конфиг без синтаксической ошибки, проходит ревью и молча ничего не делает. Ошибка обнаружится через полгода, когда кто-то заметит, что ограничение, на которое все ссылались, никогда не работало.

Хуже того, замок затягивает себя сам: каждый раз, оказавшись случайно верным, быстрый правдоподобный ответ повышает доверие, отчего проверок становится меньше, а выборка «ну работало же» - больше, и механизм поощряет сам себя ровно до первого дорогого промаха. Ничего специфически искусственно-интеллектуального тут нет, потому что та же конструкция работает у автопилота, у бэкапа, который никто не восстанавливал, и у сигнализации, которая год не срабатывала и именно поэтому считалась исправной. Но самое дорогое следствие состоит не в том, что вы пропустите проверку в конце, а в том, что вы испортите постановку в начале.

Человеку, которому нужен готовый ответ, и задача формулируется как готовый ответ: не «расширь control-plane», а «подними count до пяти»; не «разберись, почему секрет не доехал до пода», а «перезапусти оператор». Решение сидит внутри вопроса, и агенту остаётся его выполнить. Спросить, свободны ли адреса, которые формула выдаст следующим двум узлам, ему в такой постановке негде: его об этом не спрашивали. Поиск кнопки «сделай хорошо» ломает саму кнопку, причём механически, через сужение вопроса. В главе 18 вы увидите обе формулировки на одной задаче: при второй cidrhost выдаст новым узлам управления адреса .12 и .13, а это worker-01 и worker-02, которые в этот момент работают.

Браузерный чат хорош ровно для одного: спросить, почему systemd завершил сервис с кодом 203/EXEC, получить объяснение про отсутствующий executable и закрыть вкладку - на этом его полезность заканчивается. Инфраструктура же живёт в файлах, в Git diff, в планах terraform plan, в логах проверок и в архитектурных решениях, которые мучительно понадобятся через неделю в три часа ночи, а жить в этом мире браузерный чат не умеет, потому что умеет только говорить. Поэтому нам нужен простой рабочий контур, состоящий из редактора с открытым проектом, Git-репозитория, терминала, AI-агента, умеющего читать и предлагать изменения файлов, и Markdown-файлов, в которых хранится память проекта. Конкретный редактор и агент при этом не важны, поскольку названия продуктов меняются быстрее, чем инженеры успевают обновлять скриншоты в документации; важны возможности: агент должен видеть разрешённые файлы, показывать изменения до применения, работать в ограниченном каталоге и спрашивать подтверждение перед командами, способными изменить внешнюю систему.

Вот как это выглядит в схеме:


Вручную печатать каждую строку человек не обязан, зато обязан понимать границу изменения, увидеть diff и решить, можно ли нажимать Enter. Разрешения для этого полезно сразу разделить на три класса, и делается это не из мании контроля, а в качестве страховки: классы отличаются друг от друга тем, сколько стоит вернуть всё назад, если решение окажется неверным.

Чтение - просмотр файлов, поиск по репозиторию, чтение локальных логов. Обычно это можно разрешить без подтверждения, если в область чтения не попадают секреты. Если агент может прочитать ~/.ssh/id_rsa - вы уже проиграли.

Локальные изменения - редактирование файлов проекта. Разрешаем в пределах текущей области задачи, но обязательно проверяем diff: агент его показывает, вы его смотрите и вы же решаете, принять или отклонить. Без этого шага агент - просто очень быстрый вандал.

Внешнее воздействие - terraform apply, kubectl apply, изменение Proxmox, установка пакетов, отправка коммита, обращение к production API. Здесь требуется явное подтверждение человека, причём сознательное и осознанное, а не «ну, вроде всё правильно, давай».

Разница между классами измеряется не строгостью, а обратимостью: файл можно вернуть через Git, удалённый volume иногда тоже можно вернуть - если у вас был backup, если restore проверяли и если сегодня ваш счастливый день, - но строить процесс вокруг последнего условия не стоит. Отсюда следует, что интеграция агента в редактор - это ещё не повод выдавать ему все разрешения. Многие инструменты предлагают удобный режим автоматического подтверждения, в котором агент сам читает файлы, запускает команды и принимает изменения; на демонстрации это выглядит волшебно, тогда как в инфраструктурном репозитории волшебство заканчивается примерно там, где в shell history появляется первая команда с правами администратора.

Начните с минимального режима, открыв агенту только каталог проекта и ничего сверх него. Не добавляйте домашний каталог целиком: рядом могут лежать ~/.ssh, kubeconfig, конфиги облачных CLI и другие вещи, которые совершенно не нужны для написания README. Отключите автоматический запуск терминальных команд и автоматическое принятие diff, а если инструмент умеет отдельно подтверждать чтение и запись, чтение внутри проекта можно разрешить постоянно, оставив запись с просмотром.

Проведите скучный, но полезный тест. Назвав корень доступного workspace, попросите агента показать список файлов верхнего уровня и заранее запретите ему что-либо создавать или запускать команды вне репозитория; в пустом проекте ответ должен получиться почти пустым. Затем попросите его предложить создание файла permission-test.txt, но изменение не принимайте: diff обязан появиться до того, как файл окажется в рабочем дереве, после чего предложение следует отменить. Так мы проверяем не интеллект модели, а механику инструмента - где проходит граница workspace, что считается подтверждением и можете ли вы остановить изменение. Если же агент без вопроса читает соседние каталоги или выполняет команды, не утешайте себя тем, что «там пока ничего секретного», а исправьте разрешения сейчас, поскольку эта фраза обладает удивительной способностью переживать миграции, смену сотрудников и появление production kubeconfig в том же каталоге.

Теперь, когда границы понятны, стоит разобраться с тем, что за этими границами лежит. Агент, которого мы только что настроили, видит файлы, и этого достаточно, пока вы пишете код. Но инфраструктура тем и отличается от кода, что её состояние живёт не в файлах: сколько подов в CrashLoopBackOff прямо сейчас, какой узел держит виртуальный адрес, что лежит в таблице pg_stat_replication - ничего из этого нет в репозитории, всё это есть только в кластере.

И вот здесь начинается то, что превращает работу с агентом в утомительный ритуал. Вы выполняете kubectl describe, копируете вывод, вставляете в чат. Агент отвечает: «покажите ещё логи оператора». Вы выполняете, копируете, вставляете. Он просит kubectl get events. Вы выполняете, копируете, вставляете. Заметьте, кем вы стали в этой схеме. Транспортом. Живым проводом между терминалом и моделью, который вручную переносит байты туда и обратно; это не вайбкодинг, это работа курьером при собственном ассистенте — и на третьем круге вы поймаете себя на мысли, что быстрее было разобраться самому.

Решение называется MCP (Model Context Protocol) — открытый стандарт, по которому агент получает не доступ к вашему терминалу, а набор объявленных инструментов. Между агентом и системой встаёт отдельный процесс, MCP-сервер: он умеет ровно то, что заявил, и ничего сверх того, поэтому агент не выполняет kubectl — он вызывает инструмент «прочитать поды в namespace», а сервер сам решает, во что это превратить. Обратите внимание, как это ложится на три класса разрешений, которые мы только что разобрали: MCP-сервер, умеющий только читать состояние кластера, — это ровно первый класс, то есть чтение без подтверждения. Сервер, умеющий применять манифесты, — уже третий, и подключать его надо с тем же выражением лица, с каким вы выдаёте кому-то права на terraform apply. Разница между этими двумя серверами не в модели и не в промпте: у одного в объявленном наборе инструментов есть запись в кластер, у другого её нет, и выяснить это можно, открыв конфиг и прочитав четыре строки.

Конфигурация живёт в настройках вашего агентского клиента и в общем виде выглядит так:

TERMINAL

{

"mcpServers": {

"k8s-readonly": {

"command": "npx",

"args": ["-y", "mcp-server-kubernetes"],

"env": {

"KUBECONFIG": "/home/operator/.kube/config-readonly",

"ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS": "true"

}

},

"platform-db": {

"command": "postgres-mcp",

"args": ["--access-mode=restricted"],

"env": {

"DATABASE_URI": "${PLATFORM_DB_URI}"

}

}

}

}

Три детали здесь важнее самих строк.

Первая: KUBECONFIG указывает на отдельный kubeconfig, а не на ваш административный. Агент получает те права, которые лежат в этом файле, и ни одним больше. Если вы дадите ему свой admin.conf, никакой MCP не спасёт - вы просто вручили ключи от кластера процессу, который запускается через npx. Флаг ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS убирает из объявленного набора всё разрушающее, но это второй рубеж, а не первый: права в kubeconfig решают, флаг лишь сужает то, что и так разрешено.

Вторая: у сервера базы выставлен --access-mode=restricted - режим, в котором доступны только читающие транзакции. Тот же принцип, что и с кластером: не «мы договорились, что агент не пишет», а «пишущих инструментов в наборе нет».

Третья: DATABASE_URI - это ссылка на переменную окружения, а не строка подключения. Значение приходит из окружения, в котором стартует клиент, а туда попадает из менеджера паролей. Пароль в этом файле не появляется никогда. К главе 14 мы заведём для этого Vault и поставим пре-коммит хук, который такую строку не пропустит, - и было бы неловко, если бы он сработал на примере из первой главы. Синтаксис подстановки у клиентов разный: где-то ${VAR}, где-то переменные наследуются из окружения и в конфиге ключ вообще не пишут. Сверьтесь со своим; неизменно одно - в файле лежит ссылка, а не значение.

Рабочий вариант этого конфига лежит в labs/mcp/config.json - там же записано, какого сервера в нём намеренно нет и почему. Экосистема шевелится быстро: серверы переименовываются, меняют флаги, иногда их бросают. Летом 2025-го авторы протокола вынесли часть своих же эталонных серверов в отдельный архив как неготовые к эксплуатации - в том числе тот, что работал с PostgreSQL, и в нём к тому моменту нашли внедрение SQL. Поэтому проверяйте, что пакет, который вы прописываете, жив и поддерживается, - а не копируйте листинг из книги, включая эту. Важна форма: команда запуска, окружение, объявленный набор инструментов.

И сразу о том, чего MCP не даёт. Он не отбирает у агента доступ к терминалу. Если ваш клиент умеет запускать shell - он продолжит уметь, MCP тут ни при чём. Ограничение работает только на своём канале: агент не может через MCP-сервер сделать то, чего этот сервер не объявил. Разница между «агент ограничен» и «агент ограничен вот в этом одном месте» существенная, и путать их дорого. К главе 23 мы вернёмся к ней всерьёз, когда будем строить структурные заборы вокруг агента, которому доверено действовать самостоятельно.

Пока запомните одно: без MCP агент в инфраструктуре слеп, и вы обречены быть его глазами, тогда как с MCP он смотрит сам - в пределах того, что вы ему открыли. И раз уж речь зашла об открытом и закрытом, сделаем последнее различение, без которого дальше будет путаница: слово «агент» обозначает в этой книге две разные вещи.

Есть агент-консультант - тот, с кем вы обсуждаете. Он читает репозиторий, задаёт вопросы, предлагает план и ошибается дёшево: его ошибка живёт в тексте, пока вы её не приняли, и стоит она ровно тех двух минут, которые вы потратили на чтение. Широта - его работа, и чем шире он смотрит, тем полезнее; половина того, что он предложит, вам не понадобится, и это нормальная цена.

И есть агент-исполнитель - то, что применяет уже принятое решение. Он должен быть буквальным и скучным: делать ровно сказанное и ничего сверх того. Его достоинство не в сообразительности, а в предсказуемости, потому что пока он не проявляет инициативы, всё мышление остаётся в одном месте и его можно отревьюить целиком.

Как только исполнитель начинает быть полезным - по дороге чинить, дополнять, улучшать «раз уж я здесь», - проверять приходится не результат, а его суждение, а суждение не показывает ни terraform plan, ни git diff. Хорошая новость, впрочем, состоит в том, что исполнителей не надо изобретать, поскольку вы их и так построите: AWX прогоняет роль ровно так, как записано в Job Template, а Argo CD приводит кластер к тому, что лежит в Git, не имея собственного мнения о том, правильно ли это. Идеальные исполнители - тупые, буквальные, с полным журналом сделанного, и к главе 20 консультант будет отдавать работу им, а не выполнять её сам.

Создадим пустую директорию, инициализируем Git и откроем проект в редакторе:

TERMINAL

mkdir vibecode-onprem

cd vibecode-onprem

git init code .

Если команда code у вас не установлена, откройте каталог через интерфейс редактора, поскольку на инженерный смысл эксперимента это не влияет. Предположим теперь, что мы строим учебную on-prem платформу на Proxmox: она должна быть похожа на реальную, но мы пока не выбрали сети, домены, версии ОС, модель хранения и даже количество гипервизоров, и это не досадные пробелы, которые агенту следует заполнить. Это непринятые решения.

Открыв агента, ставим задачу - и не просим его сделать кластер, а просим подготовиться к этому.

ИНЖЕНЕР

Мы начинаем новый DevOps-проект в текущем репозитории. Цель первой сессии - подготовить рабочую память проекта и правила взаимодействия. Не пиши Terraform, Ansible, Kubernetes-манифесты и shell-скрипты. Сначала изучи только текущее содержимое репозитория и покажи краткий план. Затем предложи содержимое файлов README.md, ai_initial.md, docs/architecture.md, docs/decisions.md, docs/incidents.md, docs/runbooks.md и plans/current-task.md. Не придумывай IP-адреса, домены, версии, секреты, существующие узлы и уже принятые решения. Все неизвестные вынеси в открытые вопросы. В ai_initial.md зафиксируй порядок чтения проектной памяти, разделение read-only и mutating actions, запрет destructive-команд и внешних изменений без подтверждения, обязательные plan, diff, verification и handoff. В plans/current-task.md должны быть Goal, Scope, Out of Scope, Done, To Do и Open Questions. Не дублируй один текст во всех документах: у каждого файла одна роль. После подготовки файлов покажи diff и остановись. Ничего не устанавливай, не обращайся к внешней инфраструктуре и не создавай дополнительные файлы.

Это не заклинание и не «идеальный промпт», а инженерная задача, в которой определены результат, область работы, неизвестные, запреты и точка остановки. Особенно важна последняя фраза: без неё деятельный агент может решить, что семь документов - лишь разминка перед созданием main.tf. При этом хороший агент не обязан немедленно создавать файлы, потому что сначала он должен обнаружить решения, которые нельзя принять из доступного контекста.

AI

Агент: Я понял границы первой сессии. Перед созданием документов уточню три вещи. Проект останется учебным или его правила должны быть пригодны для production-like среды? Нужны ли сейчас правила для нескольких агентов или оставляем одну роль? Какой язык документов предпочтителен?

Вопросы полезны, потому что ответы меняют артефакты: строгость предохранителей зависит от назначения проекта, язык - от команды, а роли агентов - от будущего рабочего процесса. Вопрос «Какой ваш любимый цвет YAML?» можно было бы не задавать.

ИНЖЕНЕР

Инженер: Стенд учебный, но процесс проектируем как production-like. Добавь роли исследователя, исполнителя и ревьюера, но без автоматической параллельной записи в одни файлы. Создавай семь согласованных файлов, покажи diff и остановись.

Теперь квота доверия выдана ровно на одну локальную операцию, а значит - не запускать Terraform, не стучаться в Proxmox API и не готовить «заодно» кластер, поскольку область задачи есть бетонный забор вокруг неё, а не декоративная рамка в Jira. Документы памяти при этом не должны быть клонами друг друга, ведь если одна и та же архитектура полностью пересказана в пяти местах, через месяц у вас будет пять разных архитектур. И все они будут называться актуальными.

README.md отвечает на вопрос: «Куда я попал и что читать первым?»

FILE README . md

# Vibecode On-Prem Platform

Учебный путь от пустого Proxmox к управляемой on-prem платформе.

## Start here

1. Прочитай `ai_initial.md`.

2. Открой `plans/current-task.md`.

3. Проверь факты в `docs/architecture.md`.

4. Посмотри решения и открытые вопросы в `docs/decisions.md`.

## Current status

Stage zero. Инфраструктура и сетевой план ещё не определены.

README не обязан содержать историю цивилизации, полный inventory и инструкцию по восстановлению etcd, потому что если точку входа приходится читать пятнадцать минут - это уже не карта, а экскурсия. Следующий файл, ai_initial.md, назван намеренно нейтрально, поскольку разные агенты распознают собственные служебные файлы; если ваш инструмент требует AGENTS.md, CLAUDE.md или другой файл - перенесите туда тот же смысл.

FILE ai_initial.md

# AI operating contract

## Before work

1. Прочитай `README.md`, `ai_initial.md` и `plans/current-task.md`.

2. Прочитай только релевантные документы и исходники.

3. Сверь Goal, Scope, Out of Scope и Open Questions.

4. До редактирования покажи план.

## Permissions

- Read-only checks внутри scope разрешены.

- Локальные изменения разрешены только после согласования плана.

- Внешние и mutating actions требуют явного подтверждения.

- Destructive actions требуют отдельного предупреждения, плана восстановления и подтверждения.

## Never

- не выдумывай отсутствующие факты;

- не показывай и не записывай секреты;

- не расширяй scope словом «заодно»;

- не объявляй задачу готовой без verification.

## Session close

Покажи diff и результаты проверок. Обнови только изменившиеся документы памяти.

Зафиксируй незавершённое и назови следующий безопасный шаг.

«Обнови память» не означает «перепиши все документы свежими словами»: если архитектура не изменилась, docs/architecture.md трогать не нужно, иначе факты начнут мутировать при каждом handoff, как рыба в рассказе человека, вернувшегося с рыбалки. Сам же architecture.md содержит подтверждённые факты, тогда как гипотезы и варианты живут в decisions.md.

FILE decisions.md

# Architecture

## Current state

- Stage: zero.

- Managed infrastructure: none.

- Confirmed nodes: none.

## Target direction

Proxmox → bastion → Terraform-managed VM → Ansible/AWX → Kubernetes → platform services → GitOps.

## Unknowns

- network and VLAN plan;

- DNS zones;

- Ubuntu and Proxmox versions;

- storage model;

- failure domains.

Разделение Current state и Target direction обязательно, поскольку «мы хотим три control plane» не означает «у нас есть три control plane», а в инфраструктуре будущее время особенно любит маскироваться под настоящее. Следующий файл, decisions.md, хранит решения и их цену: не только итог, но и причину выбора, альтернативы и последствия, - а пока решения нет, туда записывается открытый вопрос.

FILE decisions.md

# Decisions

## ADR template

- Date:

- Status: proposed | accepted | superseded

- Context:

- Alternatives:

- Decision:

- Consequences:

- Verification:

Последствия нужны потому, что архитектурное решение без цены подозрительно похоже на рекламу. Что до incidents.md, это память о том, что уже болело, и пока инцидентов нет - единственный момент проекта, когда данный файл выглядит оптимистично.

FILE incidents.md

# Incidents

## Template

- Symptom:

- Hypotheses:

- Checks:

- Root cause:

- Fix:

- Guardrail:

Формат здесь важнее полноты: не «Kubernetes сломался», а что увидел оператор, какой командой проверил гипотезу и что изменилось после исправления. Следом идёт runbooks.md - действия под давлением, и регламент это не лекция, а последовательность команд с условиями запуска, ожидаемым выводом и точкой остановки.


FILE runbooks.md

# Runbooks

## Repository bootstrap check

1. Выполнить `git status --short`.

2. Убедиться, что изменены только файлы текущего scope.

3. Просмотреть `git diff --check` и `git diff`.

4. Запустить настроенный secret scanner, если он есть.

5. Не коммитить, пока Open Questions выданы за факты.

Человек открывает регламент не для духовного роста: обычно у него уже красный мониторинг, два сообщения от руководителя и коллега, который спрашивает: «Ну что там?» Последний файл, current-task.md, работает как оперативная память, и в нём всегда должен быть ответ на вопрос «что мы делаем сейчас и что мешает закончить?»; меняется он чаще остальных именно потому, что играет роль оперативной памяти, а не семейного архива.

FILE current-task.md

# Current task: bootstrap project memory

## Goal

Создать проектную память и контракт работы с AI.

## Scope

- семь согласованных Markdown-файлов;

- протокол начала и закрытия сессии.

## Out of scope

- IaC и Kubernetes-код;

- установка инструментов;

- внешняя инфраструктура и секреты.

## Done

- [x] Согласованы роли документов.

## To do

- [ ] Проверить diff.

- [ ] Проверить, что неизвестные не выданы за факты.

## Open questions

- сеть, DNS, версии, storage и topology.

Представим вполне реалистичное продолжение, в котором агент создал семь файлов, а затем сообщает: «Для удобства я также добавил setup.sh, базовый .github/workflows/validate.yml и шаблон Terraform provider. Это ускорит следующий этап». Звучит разумно - и именно поэтому опасно, ведь дополнительные файлы не входили в область задачи, их требования не обсуждались, а workflow к тому же способен выполнять действия при будущем push. Выяснять, хороший ли там код, нам не нужно. Нарушен сам контракт.

ИНЖЕНЕР

Инженер: Стоп. Дополнительные файлы не были разрешены. Покажи их diff отдельно, удали только созданные тобой вне области задачи файлы и не меняй семь согласованных документов. Затем добавь в ai_initial.md guardrail: новые артефакты вне списка требуют отдельного согласования.

Получилась первая учебная цепочка. Антипаттерн: агент расширяет задачу «полезной» инициативой. Инцидент: в репозитории появляются нерассмотренные исполняемые файлы. Исправление: лишние артефакты удалены после проверки их происхождения. Предохранитель: список разрешённых файлов считается закрытым, расширение требует подтверждения. Важно не превращать это в истерику «AI снова всё испортил», поскольку агент сделал то же, что иногда делает увлечённый инженер, решивший немного улучшить соседнюю систему. Предохранитель нужен не потому, что одна сторона глупая, а потому, что обе стороны деятельные.

Остановившись, проверяем рабочее дерево:

TERMINAL

git status --short

git diff --check

git diff -- README.md ai_initial.md docs plans

Ожидаем ровно семь новых файлов, отсутствие ошибок пробелов и понятный diff. Затем ищем очевидные маркеры секретов и случайно придуманные адреса:

TERMINAL

rg -n -i 'password|token|secret|private.?key|BEGIN .* KEY' README.md ai_initial.md docs plans

rg -n '10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.' README.md ai_initial.md docs plans

Это sanity check - быстрая проверка здравого смысла, в силу того что регулярное выражение не знает всех форматов токенов, DNS-имён и внутренних идентификаторов. В реальном проекте понадобятся secret scanner, правила исключения чувствительных каталогов из контекста и внимательный просмотр diff. Наконец, проверяем, усвоил ли агент собственный контракт:

ИНЖЕНЕР

Прочитай ai_initial.md. Назови действия, которые разрешены без подтверждения, действия, требующие подтверждения, и три причины, по которым ты обязан остановиться.

Если ответ расплывчатый - проблема не обязательно в модели: возможно, правила написаны как корпоративные ценности на стене, то есть звучат благородно, но понять, что делать руками, по ним невозможно. Впрочем, самая честная проверка наших документов происходит не сейчас, а после закрытия чата, потому что новая сессия не знает устных договорённостей, интонации и того, что вчера под словом «кластер» вы имели в виду учебный стенд. Дайте новому агенту короткую команду:

ИНЖЕНЕР

Восстанови контекст проекта. Сначала прочитай README.md, ai_initial.md и current-task.md, затем только те документы, на которые они ссылаются для текущей задачи. Ничего не меняй. Верни Goal, Scope, Out of Scope, подтверждённые факты, неизвестные и следующий безопасный шаг.

Хороший ответ помещается в несколько абзацев и ссылается на файлы, тогда как плохой начинает заново интервьюировать вас о назначении проекта или уверенно сообщает параметры, которых в документах нет; в первом случае память работает, во втором - найдите, какой факт потерян или в каких двух документах он записан по-разному. Проектная память, кстати, тоже требует уборки: завершённая задача не должна вечно лежать в current-task.md, её результат переносится в архитектуру, решение, инцидент или историю Git, устаревшее решение получает статус superseded, а не исчезает, а регламент удаляется или исправляется, если команды больше не соответствуют системе. Иначе через полгода агент будет экономно читать четыре файла и экономно приходить к неверному выводу.

После проверки не закрываем ноутбук со словами «вроде нормально»: завтрашний агент не помнит сегодняшний разговор, а завтрашний вы, строго говоря, тоже помните его несколько оптимистичнее, чем было на самом деле.

Команда на завершение:

ИНЖЕНЕР

Закрой сессию и подготовь handoff. Проверь diff всех изменённых файлов и перечисли выполненные проверки. Обнови current-task.md, а остальные документы - только если содержащиеся в них факты действительно изменились. Зафиксируй открытые вопросы. Ничего не коммить и не переходи к следующей задаче. Назови один следующий безопасный шаг.

Хороший ответ содержит не настроение, а свидетельства:

FILE current - task . md

## Handoff

- Созданы семь файлов проектной памяти.

- Удалены три неразрешённых артефакта вне scope.

- В ai_initial.md добавлен guardrail на создание новых файлов.

- Выполнены git status --short, git diff --check и ручной просмотр diff.

- Внешняя инфраструктура не изменялась.

- Открыты вопросы сети, DNS, версий, storage и topology.

- Следующий шаг: описать ownership и desired state до выбора инструментов автоматизации.

Слово «готово» без списка проверок ничего не стоит. Это просто хорошее настроение агента.

За одну сессию мы не построили ни одной виртуальной машины, зато получили рабочее место, память проекта, границы разрешений, протокол вопросов, проверку diff, первый инцидент и предохранитель. Теперь новый агент способен за несколько минут понять, где находится проект, что считается фактом и к чему ему нельзя прикасаться. Проверьте результат руками: закройте текущий чат, начните новую сессию и попросите агента прочитать точку входа, назвать текущую цель, неизвестные и запрещённые действия. Если для восстановления контекста требуется пересказать всю вчерашнюю беседу, память проекта не работает; если достаточно нескольких файлов, первый слой платформы уже построен. Он не отвечает ни на одном сетевом порту. Это пока его лучшее свойство.

И раз уж рабочее место готово, назовём три правила, вокруг которых собрана вся остальная книга. Они вынесены в её название, и у каждого два чтения: про инфраструктуру и про того, кто вам в ней помогает.

Не верь. В инфраструктуре - временным решениям: они переживают своих авторов, свои тикеты и иногда своих создателей. В работе с агентом - его интонации. Он одинаково уверенно пишет проверенное и выдуманное, потому что уверенность в его тексте не измерение, а выученная манера. Скидку делайте в обе стороны - и на «безусловно», и на «пожалуй». Единственный неподдельный сигнал: может ли он предъявить проверку, которая упадёт, если он неправ.

Не бойся. В инфраструктуре - делать руками и лезть внутрь, вместо того чтобы верить зелёному индикатору. В работе с агентом - того, что проверка выглядит как недоверие. Она не отношения выясняет, она и есть форма работы. Вопрос «с чем ты сверялся?» продуктивен. Вопрос «ты уверен?» - нет: у агента нет доступа к собственной уверенности, и он ответит «да» ровно так же убедительно, как отвечал всё остальное.

Не проси. В инфраструктуре - сделать за тебя то, за что отвечаешь ты. В работе с агентом - определить, что значит «готово». Вот это нельзя отдавать никогда: критерий готовности агент задаст как «то, что я произвёл», и не из хитрости - у него просто нет другого источника. Сюда же относится вторая подмена, менее заметная: он с той же готовностью определит за вас и то, о чём именно его попросили.

Дальше эти три правила не повторяются мантрой - они проверяются, и в каждой главе найдётся место, где одно из них можно нарушить и посмотреть, что из этого выйдет.

В следующей главе мы разберём desired state и владельцев изменений. До появления Terraform, Ansible и Kubernetes нужно решить, кто отвечает за VM, операционную систему, кластерные объекты и приложения. Иначе три замечательных инструмента начнут исправлять друг друга, а инженер получит редкую возможность наблюдать вечный двигатель в пределах собственного дата-центра.

Глава 2. Не просите AI чинить: научите его расследовать

Инженер показывает AI одну строку ошибки и пишет: «Почини».

AI отвечает мгновенно и предлагает очистить кэш, перезапустить сервис, переустановить пакет, отключить файрвол и добавить запись в /etc/hosts, причём какое-нибудь из этих действий иногда даже помогает. После этого система снова работает, причина остаётся неизвестной, а в инфраструктуре появляется ещё одно временное решение с прекрасными шансами пережить автора.

Так рождаются технические легенды. «Этот сервис по вторникам надо рестартовать два раза». «DNS здесь особенный». «Строку не трогай, без неё всё падает». Никто уже не помнит, откуда взялась строка, зато она заботливо переносится из одного поколения конфигурации в другое, как фамильное проклятие, и через три года, когда вы будете мигрировать на новый кластер, кто-нибудь обязательно спросит: «А мы перенесли эту магическую строку?» - и ответить не сможет никто. Но перенесут. На всякий случай.

AI особенно опасен в таком режиме вовсе не оттого, что плохо ищет ошибки; беда ровно в обратном - он очень быстро находит правдоподобные объяснения, и когда контекста мало, правдоподобие начинает подменять доказательство. Модель узнаёт знакомый симптом и достраивает остальную историю по наиболее вероятному шаблону, иногда угадывая. Вот здесь и важно понять, что в production слово «иногда» перестаёт быть статистической погрешностью и становится бомбой с часовым механизмом, которая просто ждёт своего часа.

Задача этой главы - не устанавливать софт: мы снова ничего не будем ставить. Да, опять. Да, так надо. Научиться нужно другому - расследовать сбой вместе с AI: собрать свидетельства, отделить факт от гипотезы, проверить версии по одной, найти не только непосредственную причину, но и системную, а затем сохранить знание в проекте. Это ещё не Linux-диагностика, ею займёмся позже; сейчас мы строим сам процесс мышления, которым потом будем разбирать Linux, сеть, Terraform, Kubernetes и всё остальное, что умеет ломаться с гораздо большим размахом. А начинать придётся с терминов, потому что слово «ошибка» обычно используют сразу для пяти разных вещей.

Симптом - то, что заметил человек или мониторинг: страница не открывается, команда завершилась неуспешно, файл не появился. Симптом сообщает, где болит, но редко объясняет почему.

Свидетельство - наблюдаемый факт: точная команда, время, код возврата, строка лога, diff, состояние процесса. Свидетельство можно повторно проверить или привязать к источнику.

Гипотеза - возможное объяснение свидетельств: «имя не резолвится из-за отсутствующей DNS-записи» - гипотеза, которая становится полезной только вместе с проверкой, способной её опровергнуть.

Непосредственная причина - конкретное условие, из-за которого операция не выполнилась именно сейчас: например, в конфигурации остался «enter_your_ip_here».

Системная причина - объяснение того, почему неправильное условие прошло через процесс незамеченным: скрипт не проверил заглушку, проигнорировал ошибку curl, напечатал сообщение об успехе и вернул код 0. Исправить только имя узла - значит убрать непосредственную причину и сохранить фабрику следующих инцидентов.

Исправление (fix) восстанавливает правильное поведение, тогда как предохранитель (guardrail) меняет код или процесс так, чтобы тот же класс ошибки не проходил бесшумно. Перезапустить сервис можно считать исправлением; добавить проверку конфигурации до запуска и функциональный health check после - предохранитель.

AI должен работать именно с этой цепочкой, и если агент перескакивает от симптома прямо к исправлению, он не расследует. Он участвует в лотерее с очень убедительными комментариями.

В будущем нам понадобится stage-zero bootstrap - минимальный ручной этап, который подготовит вход в инфраструктуру до появления полноценной автоматизации; поскольку никакой инфраструктуры пока нет, проверим только локальную механику будущей предстартовой проверки. Представим, что агент предложил такой черновик:

TERMINAL

#!/usr/bin/env bash

MANAGEMENT_HOST="${MANAGEMENT_HOST:-control.example.invalid}"

echo "Checking management endpoint..."

curl -s "https://${MANAGEMENT_HOST}/health" || true

echo "Stage-zero preflight completed successfully"

Домен .invalid зарезервирован именно для примеров и не должен разрешаться в реальный адрес. Запускаем скрипт:

TERMINAL

$ ./stage-zero-preflight.sh

Checking management endpoint...

Stage-zero preflight completed successfully

$ echo $?

0

На экране нет даже ошибки: curl работает в тихом режиме, а || true превращает любой его результат в успех. Скрипт не проверил точку подключения, но сообщил, что проверил, и это хуже честного падения, поскольку красный статус хотя бы мешает идти дальше, тогда как ложный зелёный открывает ворота следующему шагу. || true в продакшене - это как заклеить лампочку Check Engine на приборной панели изолентой. Машина едет, индикатор не горит, все счастливы. До первого перегрева.

Не исправляйте код сразу: сейчас важнее правильно поставить задачу агенту. Новая сессия начинается с восстановления памяти проекта, после чего агенту назначается роль исследователя - read-only, без редактирования и выполнения команд. Мы не написали «ты Senior SRE с тридцатилетним опытом и IQ размером с дата-центр», поскольку ролевая биография не заменяет протокол, а по факту является продуктом маркетинга и попыткой сыграть на «комплексе менеджера». Настоящий Senior SRE не говорит «я всё знаю» - он говорит «я не знаю, но знаю, как это проверить». Именно этому мы учим агента.

ИНЖЕНЕР

Прочитай ai_initial.md, current-task.md, architecture.md, incidents.md и приведённый ниже вывод запуска stage-zero-preflight.sh. Работай как исследователь. Ничего не меняй и не запускай. Сначала составь краткую хронологию наблюдаемого поведения. Отдели факты от гипотез. Для каждой гипотезы предложи минимальную read-only проверку и укажи, какой результат её подтвердит или опровергнет. Не предлагай fix, пока мы не подтвердим причину. Не проси весь репозиторий: если не хватает конкретного файла или вывода, назови его и объясни зачем.

Хороший агент не начинает с совета добавить DNS-запись. Он спрашивает:

AI

Агент: Нужны сам скрипт без секретов, точный способ запуска и код возврата, снятый сразу после него. Также уточните ожидаемое поведение: должен ли отсутствие MANAGEMENT_HOST останавливать предстартовую проверку или допустима заглушка? Было ли имя узла намеренно примером?

Все три вопроса меняют расследование. Если значение по умолчанию разрешено, проверяем его корректность; если переменная обязательна, само наличие такого значения уже дефект; если вывод скопировали после другой команды, код 0 не доказывает ничего.

ИНЖЕНЕР

Инженер: Скрипт приведён полностью. Запускался без переменных окружения. echo $? выполнен сразу после него. В реальной сессии MANAGEMENT_HOST обязан быть задан явно; заглушка допустима только в документации. Предстартовая проверка должна завершаться ненулевым кодом при недоступной точке подключения и не должна печатать success.

Теперь у агента есть не только наблюдение, но и ожидаемое поведение, а без ожидания расхождение нельзя назвать ошибкой: компьютер мог в точности выполнить плохо сформулированное намерение.

Для расследования собираем пакет свидетельств, который похож на контекстный пакет из первой главы, но отвечает на конкретный сбой. В него входят: что ожидалось, что произошло, точная команда запуска, код возврата, релевантный код или конфигурация, несколько строк вывода до и после симптома, сведения о среде (только если они влияют на гипотезу) и что менялось непосредственно перед сбоем.

Не нужно сразу отдавать агенту весь /var/log, домашний каталог и переменные окружения, поскольку большой лог не означает хороший контекст: он означает, что модель потратит внимание на повторяющийся шум, старые ошибки и строки, которые выглядят важнее свежего симптома. Особенно опасны команды вроде env, set, history, полный вывод задания CI и трассировка shell с секретами в аргументах, ведь в них могут оказаться токены, пароли, адрес с учётными данными, внутренние адреса и персональные данные. AI не должен получать секрет только потому, что он случайно стоял рядом с ошибкой.

Для нашего сбоя пакет короткий:

OUTPUT

Expected:

Without MANAGEMENT_HOST the script exits non-zero and explains what is missing.

Observed:

Script prints success and exits 0.

Command:

./stage-zero-preflight.sh

Environment fact:

MANAGEMENT_HOST was not set.

Relevant source:

MANAGEMENT_HOST="${MANAGEMENT_HOST:-control.example.invalid}"

curl -s "https://${MANAGEMENT_HOST}/health" || true

echo "Stage-zero preflight completed successfully"

Этого достаточно, чтобы построить первые гипотезы, тогда как сетевые таблицы, версия ядра и список USB-устройств пока не нужны. Если агент попросит их «для полноты», попросите связать каждый новый факт с конкретной гипотезой. Контекст выдаётся по причинной связи, а не вёдрами. Но прежде чем переходить к гипотезам, полезно восстановить короткую хронологию, потому что люди рассказывают об ошибках в порядке важности - «скрипт соврал, DNS не работает, вчера агент что-то менял», - в то время как компьютер исполняет события по времени. Если поменять порядок в рассказе, причина легко превращается в последствие, поэтому просим агента построить хронологию только из свидетельств:

OUTPUT

T0 MANAGEMENT_HOST отсутствует в среде запуска.

T1 Shell подставляет default control.example.invalid.

T2 Скрипт запускает curl в silent mode.

T3 curl завершается non-zero из-за name resolution.

T4 || true заменяет неуспешный статус на 0.

T5 echo печатает Stage-zero preflight completed successfully.

T6 Скрипт возвращает статус последней команды: 0.

У такой хронологии есть полезное свойство: каждую строку можно связать с кодом или выводом. Если агент добавит T2.5 «файрвол заблокировал запрос», мы сразу спросим, откуда это известно, ведь пока DNS не вернул адрес, запрос даже не дошёл до стадии соединения. Файрвол может существовать, но в данной цепочке у него ещё не было возможности проявить характер.

Для реального инцидента временная шкала содержит абсолютное время и источник:

TERMINAL

10:41:52 commit abc123 deployed source=git/ci

10:42:11 preflight started source=job log

10:42:12 name resolution failed source=curl code 6

10:42:12 job marked successful source=ci status

10:47:30 operator noticed missing VM source=incident note

Не надо имитировать точность, которой нет: если оператор помнит только «примерно после обеда», так и записываем, поскольку выдуманная секунда выглядит научно, но сопоставить события не помогает. AI иногда заполняет пробелы красивой последовательностью, особенно если попросить его «восстановить наиболее вероятную хронологию», тогда как нам нужна наблюдаемая хронология и отдельный список пробелов, а не исторический роман.

Полезный формат ответа исследователя состоит из четырёх блоков:

Известно - подтверждено источником.

Неизвестно - информации нет.

Гипотезы - объяснения неизвестного.

Следующее свидетельство - минимальное наблюдение, которое различит гипотезы.

Так агент не смешивает отсутствие данных с отрицательным результатом: «в логе нет сообщения о тайм-ауте» не означает «тайм-аута не было», если логирование выключено, а означает лишь то, что в переданном фрагменте нет соответствующей строки.Агент возвращает таблицу:

Использован default

- Гипотеза: MANAGEMENT_HOST не задан

- Проверка: printf '%s\n' "${MANAGEMENT_HOST-unset}" до запуска

- Опровергающий результат: переменная содержит подтверждённый хост

Проверка не останавливает скрипт

- Гипотеза: ошибка подавлена `|

- Проверка: true`

- Опровергающий результат: временно выполнить тот же curl без подавления и сразу снять $?

Ошибка не видна

- Гипотеза: curl -s скрывает диагностику

- Проверка: повторить с --silent --show-error

- Опровергающий результат: диагностика всё равно отсутствует

Скрипт возвращает 0

- Гипотеза: последняя команда echo успешна

- Проверка: прочитать control flow и exit status каждой команды

- Опровергающий результат: до завершения есть явный exit 1

Хорошая гипотеза рискует быть опровергнутой, а «наверное, проблема в сети» таким свойством не обладает: сеть достаточно велика, чтобы объяснить почти всё и не ответить ни за что. Модель может написать: «С вероятностью 95% причина в DNS». Число выглядит как результат измерения, хотя чаще является стилистическим способом сказать «эта версия кажется мне наиболее знакомой», ведь у AI нет внутреннего прибора, который прогнал девяносто пять одинаковых инцидентов в вашей сети, и процент без методики не добавляет доказательств. Если бы у AI был такой прибор, он бы уже работал в Google SRE, а не отвечал на ваш вопрос про curl.

Вместо рейтинга уверенности просим ранжировать гипотезы по наблюдаемым признакам:

TERMINAL

Hypothesis A: required variable was unset.

Supports: default syntax is present; engineer reports no exported value.

Contradicts: nothing observed yet.

Check: print this variable only.

Hypothesis B: valid host was supplied but DNS failed.

Supports: curl could fail on resolution.

Contradicts: supplied evidence says variable was unset.

Check: inspect the exact invocation and variable value without dumping env.

Теперь видно, почему A проверяется первой: не потому, что агент назначил ей 95%, а потому, что одна дешёвая read-only команда различает версии. Порядок диагностических шагов выбирают по сочетанию трёх свойств: проверка безопасна, дёшева и сильно сокращает пространство причин.

AI также любит преждевременно повышать гипотезу до первопричины: поймав || true, он может объявить расследование завершённым, хотя || true объясняет ложный код возврата, а не появление заглушки. У инцидента способны существовать несколько причин на разных уровнях: непосредственная причина активировала дефект именно сейчас, дефект кода допустил неправильное поведение, пробел процесса позволил дефекту пройти ревью, а пробел в обнаружении задержал обнаружение. Для нашего примера непосредственная причина - отсутствующая переменная, дефекты кода - заглушка по умолчанию и подавление ошибки, пробел процесса - отсутствие негативных тестов, пробел в обнаружении - успех определялся только кодом возврата скрипта. Такое разложение защищает от слабого предохранителя: если исправить значение по умолчанию, следующий сетевой сбой снова станет зелёным, а если удалить || true, но оставить заглушку, скрипт хотя бы честно упадёт, однако всё ещё отправит человека искать DNS-проблему вместо незаданного параметра.

В ответе исследователя требуем ссылки вида E1, E2 на свидетельства:

TERMINAL

E1: MANAGEMENT_HOST was unset in the exact invocation.

E2: source contains a .invalid default.

E3: standalone curl returned non-zero.

E4: source suppresses curl status with || true.

Conclusion: false success is explained by E3 + E4; placeholder selection by E1 + E2.

Это немного похоже на занудство - ровно до первого сложного инцидента, после которого выясняется, что ссылка на четыре строки быстрее сорока сообщений «кажется, мы уже это проверяли». Проверки выполняет человек, и для начала мы смотрим только значение переменной, не печатая всё окружение:

TERMINAL

printf 'MANAGEMENT_HOST=%s\n' "${MANAGEMENT_HOST-unset}"

Получаем unset. Затем проверяем имя узла отдельно:

TERMINAL

getent ahosts control.example.invalid

echo $?

Вывода нет, код возврата не равен нулю, что подтверждает непосредственную причину: скрипт взял заведомо нерабочую заглушку. Ложный успех это ещё не объясняет, поэтому запускаем запрос отдельно, уже не скрывая ошибку:

TERMINAL

curl --silent --show-error --fail https://control.example.invalid/health

echo $?

Теперь видим ошибку разрешения имени и ненулевой код возврата, а прочитав три последние строки скрипта, убеждаемся, что || true поглощает неуспех и кодом возврата всего скрипта становится ноль от последнего успешного echo. Причин две, и они независимы: даже с правильным именем узла недоступная точка подключения всё равно будет объявлена здоровой, и даже после удаления || true заглушка в переменной останется плохим значением по умолчанию.

На этом месте деятельный агент может предложить:

Добавьте control.example.invalid в /etc/hosts, чтобы имя разрешалось, и оставьте || true, чтобы временная недоступность точки подключения управления не блокировала bootstrap.

Останавливаемся. Это типичное исправление симптома, которое закрепляет обе системные ошибки: заглушка превращается в локальную магию на одном ноутбуке, а предстартовая проверка сохраняет право лгать об успехе.

ИНЖЕНЕР

Инженер: Не применяй этот fix. Он создаёт недокументированную зависимость от /etc/hosts и оставляет ложный код возврата. Пересчитай решение из ожидаемого контракта: хост обязателен, заглушка запрещена, недоступная проверка здоровья обязана остановить предстартовую проверку. Сначала предложи минимальный diff и проверки негативных сценариев.

Цепочка инцидента теперь ясна:

Антипаттерн:

обязательный параметр имеет правдоподобное значение-заглушку по умолчанию, а ошибка проверки подавлена.

Симптом:

предстартовая проверка сообщает успех при недоступной точке подключения.

Непосредственная причина:

использован .invalid хост.

Системная причина:

вход не валидируется, код возврата curl уничтожается, успех не связан с проверкой.

Отклонённое исправление:

запись в /etc/hosts и сохранение || true

Правильное исправление:

обязательная переменная, проверка и явная обработка отказа.

Предохранитель:

негативные тесты и запрет сообщения об успехе без успешной проверки здоровья.

Минимальный исправленный фрагмент выглядит так:

TERMINAL

: "${MANAGEMENT_HOST:?Set MANAGEMENT_HOST to the approved hostname}"

case "$MANAGEMENT_HOST" in

*.invalid) echo "Refusing placeholder MANAGEMENT_HOST" >&2; exit 64 ;;

esac

if ! curl --silent --show-error --fail \

"https://${MANAGEMENT_HOST}/health"; then

echo "Management endpoint check failed" >&2

exit 1

fi

echo "Stage-zero preflight completed successfully"

Здесь нет магии set -e, на которую часто пытаются переложить всю ответственность: set -euo pipefail полезен для строгих скриптов, однако его поведение имеет исключения и не заменяет осмысленную обработку критической проверки. Мы явно связали проверку здоровья, отказ и успех, а проверяем сначала негативные сценарии, поскольку они дешевле и как раз доказывают предохранитель.

TERMINAL

Case 1: MANAGEMENT_HOST unset

Expected: non-zero; message requires the variable; curl is not called.

Case 2: MANAGEMENT_HOST=control.example.invalid

Expected: exit 64; placeholder rejected; curl is not called.

Case 3: approved hostname, endpoint unavailable

Expected: curl diagnostic; exit 1; no success message.

Case 4: approved hostname, health endpoint returns 2xx

Expected: exit 0; success message printed once.

У нас пока нет ни утверждённого реального имени узла, ни поднятой точки подключения, поэтому четвёртый сценарий нельзя выполнить честно: мы оставляем его открытой проверкой, а не подставляем случайный адрес ради зелёной галочки. Первые три сценария уже проверяют важную часть - предстартовая проверка больше не умеет бесшумно врать. Чат для такого поиска удобен, но плох как единственное хранилище результата. Через неделю сессия исчезнет из рабочего контекста, а через месяц никто не вспомнит, почему .invalid запрещён отдельной проверкой. Поэтому после подтверждения причины агент готовит запись в incidents.md.

Не каждую опечатку нужно объявлять инцидентом: запись оправдана, если ошибка дала ложный успешный результат, могла перейти во внешнее изменение, заняла заметное время на расследование, повторяется или представляет целый класс проблем, породила предохранитель, который должен пережить текущую задачу. Наш случай подходит по четырём пунктам из пяти, хотя спорить о том, тянет ли восьмистрочный учебный скрипт на «заметное время расследования», можно долго. Просим агента:

ИНЖЕНЕР

Подготовь запись для incidents.md только по подтверждённым свидетельствам. Для каждого вывода укажи свидетельство. Не включай полное окружение, абсолютные домашние пути и любые секреты. Отдельно запиши отклонённую гипотезу, fix, guardrail и незавершённую функциональную проверку. Пока не редактируй файл - покажи текст.

Получается компактная запись:

FILE incidents . md

## Stage-zero preflight returned false success

- **Symptom:** запуск без `MANAGEMENT_HOST` напечатал success и вернул `0`.

- **Expected:** отсутствие обязательного host или недоступный endpoint завершают preflight non-zero.

- **Evidence:** default `control.example.invalid`; `curl ... || true`; последний успешный `echo`.

- **Trigger:** обязательная переменная не была задана, использован placeholder.

- **Systemic cause:** вход не валидировался, ошибка health check подавлялась.

- **Rejected fix:** запись placeholder в `/etc/hosts`; сохраняет локальную магию и false success.

- **Fix:** required variable, placeholder validation, explicit curl failure handling.

- **Guardrail:** три негативных теста; success печатается только после успешного check.

- **Open verification:** positive test после появления approved management endpoint.

Такая запись не пересказывает весь разговор, она сохраняет путь от наблюдения к защите; логи и команды прикладываются точечно, если без них вывод невозможно проверить.

Если записывать в incidents.md каждый неверный флаг команды, документ быстро станет длиннее книги и перестанет читаться. Если записывать только многочасовые аварии, мы потеряем ранние дефекты, которые случайно поймали до выхода в инфраструктуру. Критерий не в драматичности, а в переносимом знании. Хорошая incident-запись отвечает хотя бы на один будущий вопрос: по какому симптому узнать этот класс сбоя; какая очевидная версия оказалась неправильной; какая проверка быстро разделила гипотезы; какой предохранитель теперь должен существовать в коде; какую команду опасно выполнять как универсальное исправление. Если ответов нет, возможно, перед нами обычная заметка текущей задачи. Она остаётся в current-task.md до завершения и не засоряет постоянную память. Если из заметки вырос повторяемый порядок действий, он переезжает в регламент, а если принято архитектурное решение - в decisions.md. Один факт не следует копировать во все документы для надёжности: противоречия тоже получатся отказоустойчивыми.

Из нашего инцидента рождается короткий регламент для ложного успеха:

FILE decisions.md

## False-success investigation

1. Сохранить точную команду и сразу снять exit code.

2. Записать Expected и Observed без объяснений причины.

3. Найти последнюю реально выполненную команду скрипта.

4. Проверить `|| true`, отключённые error handlers и безусловные success-сообщения.

5. Повторить критическую проверку отдельно с видимым stderr.

6. Выполнить негативный сценарий после fix.

7. Не объявлять root cause без evidence для trigger и false-success path.

Регламент не содержит конкретного имени узла и потому применим к будущей обёртке Terraform, заданию бэкапа или скрипту проверки здоровья. Именно так одна маленькая ошибка начинает окупать время расследования: она создаёт средство поиска целого класса дефектов. Когда таких средств накопится несколько, AI можно попросить сравнить новый инцидент с прежними записями и предложить общий предохранитель, но решение о слиянии принимает инженер. Две ошибки с одинаковым словом «тайм-аут» могут иметь совершенно разные механизмы - переполненный пул соединений и потерянный ARP-ответ выглядят в логе одинаково, - а один и тот же по смыслу ложный успех способен приходить со стороны DNS, бэкапа или развёртывания. Поиск по словам находит соседей. Причинная схема находит родственников.

Когда мы начнём писать реальные bootstrap-скрипты, логирование станет частью интерфейса, ведь AI легче анализировать последовательный лог, если каждая важная строка содержит время, этап, действие и результат. Человеку, внезапно, тоже.

OUTPUT

2026-07-17T10:42:11Z level=info stage=preflight check=management_host result=present

2026-07-17T10:42:12Z level=error stage=preflight check=management_health result=failed curl_code=6

Но структурный журнал не должен превращаться в склад всего, что знает процесс. Не печатаем токены, пароли, приватные ключи, полный адрес с учётными данными внутри, содержимое файлов с секретами и бездумный дамп переменных окружения. Для чувствительного значения достаточно факта «есть» или «нет», версии, безопасного идентификатора или контрольной суммы, если она действительно нужна для сравнения. Ещё одно правило: сообщение об отказе должно содержать наблюдаемый этап, но не выдуманную причину. Скрипт может честно написать name resolution failed по коду curl, однако он не знает, что «DNS-сервер сломан», - возможно, имя узла просто набрано с опечаткой. Причину устанавливает расследование, а не самоуверенная строка лога.

AI полезно просить составить хронологию по логам, но каждое утверждение должно ссылаться на строку или команду. Если агент пишет «после перезапуска сервис восстановился», а в пакете свидетельств нет ни перезапуска, ни повторной проверки, это не сводка. Это фанфик по мотивам эксплуатации. Соблазн его написать растёт вместе с размером инцидента, а большие инциденты быстро съедают контекст: тысячи строк, несколько гипотез, повторные команды. Не таскайте всю историю в каждый новый запрос, а ведите в current-task.md короткую сводку расследования:

FILE current-task.md

## Investigation state

- Symptom: preflight false success.

- Confirmed: variable unset; placeholder selected; curl failure suppressed.

- Rejected: local `/etc/hosts` fix.

- Current hypothesis: two independent defects in input validation and control flow.

- Next check: negative cases after proposed diff.

- Do not repeat: full source already reviewed at revision `<commit>`.

После каждой проверки агент обновляет только подтверждённое и следующий вопрос. Старые сырые логи остаются вне постоянной памяти либо в безопасном артефакте, если нужны для аудита. Новому ревьюеру передаём текущую сводку, diff и конкретные свидетельства, а не стенограмму всех наших сомнений. Саму же работу удобно разделить на три короткие сессии:

Исследователь строит гипотезы и просит свидетельства.

Исполнитель получает подтверждённую причину и готовит минимальный diff.

Ревьюер видит исходный симптом, diff и тесты, но не обязан верить выводам исполнителя.

Это не театральное изображение большой команды: разные роли уменьшают привязку к первой версии - anchoring. Агент, который придумал исправление, склонен объяснять, почему исправление хорошо, тогда как отдельному ревьюеру проще спросить, не замаскировали ли мы ошибку новым сообщением. После негативных проверок обновляем память проекта: в incidents.md появляется подтверждённая запись, в runbooks.md - короткий порядок расследования ложного успеха (снять команду и код возврата, сравнить ожидаемое и наблюдаемое, проверить подавление ошибок, выполнить негативные сценарии), а в current-task.md остаётся открытой позитивная проверка здоровья, которая станет возможна после появления точки подключения управления.

Команда на завершение:

ИНЖЕНЕР

Закрой расследование. Покажи diff изменённых документов, список выполненных проверок и свидетельства для первопричины. Отдельно назови то, что ещё не доказано. Подтверди, что внешняя инфраструктура не изменялась и секреты не попадали в контекст. Следующим шагом укажи ручную Linux/network verify-сессию.

Хороший итог выглядит так:

TERMINAL

- Confirmed: unset input selected placeholder; curl failure was suppressed; final echo returned 0.

- Fixed in draft: required input, placeholder rejection, explicit failure path.

- Verified: unset, placeholder and unavailable endpoint return non-zero without false success.

- Not verified: positive request to an approved endpoint; endpoint does not exist yet.

- Logged: incident, rejected fix and guardrail.

- External changes: none.

- Secrets shared: none.

Фраза Not verified не портит отчёт, она делает его инженерным: скрытый пробел всё равно существует, и лучше хранить его в списке задач, чем под ковром рядом с остальными достижениями. Мы научили AI не угадывать исправление по одной строке, а вести расследование: симптом отделён от причины, гипотеза обязана иметь опровергающую проверку, пакет свидетельств ограничивает контекст, а журнал сохраняет не только исправление, но и предохранитель. Агент помогает быстрее читать код и строить версии, однако факты появляются только после команд и наблюдений.

Главный результат главы - вовсе не исправленный shell-фрагмент: мы поймали ложный зелёный статус до того, как он получил доступ к Proxmox, VM и реальным данным. Пока ошибка живёт в восьми строках учебного скрипта, она очень воспитанная. Позже ошибки становятся распределёнными, отказоустойчивыми и защищёнными паролем.

В следующей главе применим этот процесс к настоящей диагностике рабочей машины: Linux, маршруты, DNS, порты и сервисы. AI будет строить гипотезы, но команды выполним мы. Не потому, что не доверяем технологиям. Потому что именно так появляются свидетельства.

Глава 3. Архитектура платформы до первой VM

Попросите AI спроектировать отказоустойчивый Kubernetes на Proxmox, и через несколько секунд у вас появятся три гипервизора, общее хранилище, отдельные VLAN, два файрвола, три control plane, пять worker-узлов и схема, на которой стрелки выглядят так убедительно, будто оборудование уже оплачено.

Есть одна небольшая проблема: у вас два физических сервера, локальные диски и один свободный сетевой диапазон.

AI не пытался вас обмануть: он решил задачу, которую обычно подразумевают словами «production-ready HA», и заполнил неизвестные наиболее типичными компонентами, а красивый результат не соответствует вашей реальности ровно потому, что сама реальность в запрос не попала. Архитектурная галлюцинация редко выглядит как бред. Обычно она выглядит как диаграмма из презентации поставщика.

В этой главе мы ничего не устанавливаем и не создаём. После первых двух глав у нас уже есть рабочий AI-контур и процесс расследования ошибок, и теперь мы используем их до появления ошибки: описываем физические ограничения, строим путь управления, сравниваем варианты размещения и проверяем отказовые сценарии на бумаге. К концу сессии появится архитектурная карта будущей платформы, где факт не смешан с желанием, а три прямоугольника не выдаются за три независимых сервера.

Архитектуру удобно начинать с Target state - с того, что хочется получить. Это приятная часть работы: там всё масштабируется, резервируется и когда-нибудь покрывается мониторингом. В этой версии реальности у вас всегда найдётся третий гипервизор, а etcd падает только в презентациях конкурентов. Проектировать же нужно с Current state, с того, что существует и подтверждено сейчас: мечта не платит за железо и не поднимает кворум.

Для учебного пути фиксируем исходные ограничения: доступны два физических Proxmox-хоста; третий хост возможен позже, но не куплен и потому не существует; у каждого хоста локальное хранилище, так как общее хранилище не подтверждено; есть одна внутренняя RFC1918-подсеть, которую можно разбить на логические диапазоны; административные интерфейсы нельзя публиковать в интернет, поскольку операторов мало, а отдельного identity provider пока нет; VM, шаблонов, bastion, Terrakube, AWX и Kubernetes ещё нет, так что первый контур придётся поднять руками или небольшим stage-zero скриптом; точные IP, имена узлов, размеры VM и версии программ пока не приняты.

Последние два пункта особенно важны: мы имеем право нарисовать будущий Terrakube как роль в Target architecture, но не имеем права записать в Current state, что он уже работает, назначить ему случайный адрес и ссылаться на него как на источник Terraform state. В architecture.md состояния разделяются физически:

FILE architecture.md

## Current state

- Confirmed Proxmox hosts: 2.

- Storage: local per host.

- Managed VM: none.

- Operator entry point: not built.

## Target state

- Controlled operator path through VPN and bastion.

- External management plane for Terraform and configuration automation.

- HA Kubernetes control plane within documented physical limits.

- Platform and stateful services added in later stages.

## Open questions

- Exact network and DNS plan.

- Third failure domain and shared storage.

- VM sizing and placement constraints.

Слова planned, confirmed, unknown выглядят менее впечатляюще, чем цветные иконки, зато не превращаются в ложные зависимости через три главы. Новая сессия начинается с короткого контекстного пакета: ai_initial.md, текущий план, архитектурные факты и принятые решения. Каталоги Terraform и Ansible не нужны - их ещё нет; документация будущих продуктов тоже не нужна, поскольку сначала определяются роли и границы, а реализация выбирается потом.

Мы снова ограничили не только действия, но и воображение: архитектору разрешено предлагать варианты, а записывать предположение как существующий ресурс - нет.

ИНЖЕНЕР

Восстанови контекст проекта по README.md, ai_initial.md, current-task.md, architecture.md и docs/decisions.md. Спроектируй архитектуру on-prem платформы до создания первой VM. Начни с Current state и ограничений. Затем предложи два варианта Target state: честный вариант на двух доступных Proxmox-хостах и вариант после появления третьего физического failure domain. Для каждого варианта покажи путь оператора, роли будущих VM, bootstrap dependencies, размещение control plane и точки отказа. Не назначай точные IP, версии, размеры дисков и имя узла. Не считай общее хранилище, третий хост и отдельные VLAN существующими. Все компоненты помечай как current, planned или open. Ничего не создавай, не пиши IaC и не обращайся к Proxmox. Сначала задай вопросы, ответы на которые меняют архитектуру.

AI

Агент: Сколько физических хостов должно пережить полный отказ без потери Kubernetes API: любой из двух уже на старте или это целевое свойство после третьего хоста? Локальное хранилище означает, что VM нельзя автоматически запустить на соседнем хосте без репликации? Кто управляет сетью и VPN вне Proxmox? Должны ли средства восстановления кластера работать при полном падении Kubernetes? Допустима ли ручная stage-zero VM, которую позже придётся принять под управление?

Это правильные вопросы, и показательно, чего в них нет: ни одного «Calico или Cilium», ни одного «какой ingress лучше». До выбора компонентов агент проверяет физику и зависимости.

ИНЖЕНЕР

Инженер: На двух хостах мы строим production-like учебную платформу, но не называем её устойчивой к потере любого физического хоста. Полноценную single-host fault tolerance считаем целью после появления третьего домена. VM используют локальные диски; автоматическую миграцию не предполагаем. Сеть и VPN существуют вне будущего Kubernetes и управляются отдельно. Инструменты bootstrap и восстановления должны оставаться доступными при падении основного кластера. Ручной stage zero допустим, но каждый созданный им ресурс обязан иметь документированного владельца и план дальнейшего управления.

Ответ снимает главное архитектурное искушение - нарисовать симметричное HA поверх несимметричной физики. Два хоста позволяют распределить нагрузку, переживать отказ отдельных VM и проводить полезные упражнения, однако честного большинства для трёх участников etcd при потере любого хоста они не дают, если два участника неизбежно окажутся вместе.

Первый вариант агента выглядит знакомо: на трёх физических хостах control plane действительно можно распределить по одному на домен, отказ любого одного хоста оставляет два участника etcd из трёх и сохраняет кворум, балансировщики живут на разных доменах, а общее хранилище или продуманная репликация упрощает восстановление VM. Но сегодня Proxmox-3 и общее хранилище существуют только в тексте, поэтому вариант полезен как Target architecture и план развития, тогда как в качестве текущего проекта он опасен: следующие агенты начнут рассчитывать на миграцию VM, третий шаблон и свободное место, которого нет. В docs/decisions.md он получает статус proposed, а не accepted:

FILE docs/decisions.md

## Proposed: third physical failure domain

- Motivation: preserve etcd quorum after loss of any one Proxmox host.

- Requires: third host, template availability, placement rules and verified recovery.

- Current status: not available; architecture must not depend on it.

Target state нужен не для самообмана, а для того, чтобы сегодняшние решения не закрыли дорогу к завтрашнему. Текущий вариант строится на двух доступных физических серверах, и один хост неизбежно содержит два из трёх control-plane VM: потеря хоста с двумя участниками etcd уничтожает кворум, тогда как потеря второго оставляет двоих и может быть пережита. Это асимметричная устойчивость, а не HA на уровне любого физического отказа. Можно переставлять cp-03 между колонками сколько угодно, но математика большинства не проникнется нашей настойчивостью. Etcd не знает, что вы старались, и на ваши старания ему плевать: ему нужны три независимых домена отказа, а не три VM, стоящие на одном гипервизоре. Дополнительный четвёртый участник тоже не лечит ситуацию, поскольку кворум станет три, а потеря двоих по-прежнему остановит запись.

Значит ли это, что стенд бессмысленен? Нет: на нём можно построить отказоустойчивую точку входа, потерять один балансировщик, выключить отдельный процесс control plane, увести нагрузку с worker-узла, восстановить VM и увидеть поведение системы. Просто в ограничениях архитектуры честно записывается предел - отказ конкретного физического хоста может остановить Kubernetes API. Агент между тем формулирует промежуточный вывод: три control-plane VM обеспечивают отказоустойчивость control plane, поскольку etcd сохраняет кворум при потере одного участника. Фраза технически верна и архитектурно неполна, поэтому мы спрашиваем:

Покажи не потерю одной VM, а потерю каждого физического Proxmox-хоста целиком. Для каждого сценария перечисли исчезающие etcd members и оставшийся quorum. Не используй количество VM как замену количеству failure domains.

После пересчёта ложное HA исчезает, и это архитектурный стоп-момент: мы не исправили YAML, а изменили вопрос. AI охотно оптимизирует внутри заданной модели, тогда как инженер обязан проверить, соответствует ли сама модель физическому миру. Учебный инцидент можно описать заранее:

•Антипаттерн:считать независимыми VM, расположенные на одном физическом хосте.

Симптом будущего инцидента: после отказа одного Proxmox -хоста все оставшиеся control -plane VM запущены, но etcd не имеет quorum

Причина: логическая избыточность не совпала с физическими доменами отказа.

Исправление: восстановить потерянный домен или участника по процедуре восстановления.

Предохранитель: матрица отказов по физическим хостам, явное размещение и запрет на слово HAбез указанного уровня отказа.

Последний предохранитель стоит сформулировать жёстко: «HA чего и против какого отказа?» Два HAProxy могут защищать от падения одного процесса. Две VM на одном хосте не защищают от его выключения. Три копии данных в одном хранилище не защищают от потери хранилища. Аббревиатура HA без модели отказа - это как надпись «водонепроницаемые» на часах, которые вы не решаетесь окунуть в воду. Красиво, но не проверено.

Теперь рисуем не серверы, а путь управления, и административные интерфейсы остаются при этом во внутренней сети, поскольку публичный Proxmox UI с «очень хорошим паролем» не становится безопасной архитектурой - он становится удобной целью с хорошим паролем. Для маленькой команды базовым вариантом может быть WireGuard, дающий сетевой доступ к нескольким внутренним UI и SSH без отдельного туннеля на каждый порт. Но название VPN здесь вторично. Архитектурное решение звучит так: оператор сначала проходит аутентифицированную границу доступа, а интерфейсы управления не публикуются напрямую.

Задаём агенту не вопрос «какой VPN лучший», а сравнение по ограничениям: три оператора, отдельного identity provider нет, нужны SSH и несколько внутренних web UI. Сравни WireGuard и только SSH ProxyJump. Не перечисляй продукты сверх этих двух. Покажи operational cost, отзыв доступа, работу с web UI и что останется доступным при падении bastion.

AI отмечает, что ProxyJump удобен для SSH, но web UI потребуют отдельных туннелей или SOCKS-прокси, тогда как WireGuard даёт доступ на сетевом уровне, зато требует управления ключами узлов и маршрутами. Потеря bastion при отдельном шлюзе VPN не обязательно лишает доступа к сети, однако если VPN находится на самом bastion, перед нами общий домен отказа. Значит, даже «VPN + bastion» нельзя рисовать одним неразделимым облачком без решения о размещении.

На текущем этапе принимаем controlled operator path как требование, а точное размещение VPN оставляем ADR с открытой проверкой. Хорошая архитектурная сессия не обязана закончить все решения. Она обязана сделать незаконченные решения видимыми - и размещение VPN как раз такое: сегодня оно выглядит вопросом удобства, а через несколько глав окажется вопросом того, останется ли у вас доступ в сеть, когда упадёт bastion.

Следующий риск - bootstrap paradox: мы хотим, чтобы Terraform создавал VM, но планируем хранить Terraform state и выполнять прогоны в management plane, которого пока нет, и хотим запускать конфигурационную автоматизацию из AWX, которому тоже нужна VM. Курица, яйцо и remote backend могут долго смотреть друг на друга, не создавая ни одного ресурса. Исторический путь проекта превращаем в явную последовательность:

TERMINAL

workstation | v manual bastion access |

v

small reviewed stage-zero shell script

|

+--> planned Terraform control plane VM

+--> planned automation control plane VM

|

v

first managed cluster VM

Названия будущих реализаций можно записать рядом - Terrakube для Terraform control plane, AWX для автоматизации, - но это ещё не инструкция установки и не факт Current state. В этой главе нас интересуют зависимости: stage zero должен работать без Terrakube и AWX; stage zero не должен зависеть от Kubernetes, которого он помогает создать; management plane должен жить вне основного Kubernetes-кластера, чтобы оставаться средством восстановления; созданные вручную ресурсы должны быть перечислены и иметь путь резервного копирования и пересоздания; переход к управляемому состоянию нельзя считать автоматическим - его нужно отдельно спроектировать. AI получает проверочный запрос:

ИНЖЕНЕР

Построй directed dependency graph только для bootstrap. Найди циклы вида «инструмент нужен, чтобы создать собственную среду выполнения» и «средство восстановления живёт внутри восстанавливаемой системы». Для каждого цикла предложи минимальный разрыв, не добавляя новых продуктов.

Такое ревью полезнее просьбы «сделай архитектуру надёжнее», поскольку агент ищет конкретный класс дефекта - циклическую зависимость. Target architecture содержит роли, которые будут появляться поэтапно, и таблица не назначает ни IP, ни размеры: она показывает, зачем существует роль и что должно появиться раньше. Stateful-сервисы, реестр образов, GitOps и мониторинг названы только для проверки границ, а их архитектура будет приниматься в собственных главах после появления соответствующих зависимостей. Строка со статусом planned - признание, что мы уже придумали название для того, чего пока не существует. В production такие строки живут годами и им даже перестают удивляться.

Важно при этом не складывать все будущие инструменты восстановления внутрь Kubernetes. Если GitOps, дашборд и мониторинг живут в кластере - нормально: они управляют его нагрузкой и наблюдают за ним. Но если единственный инвентарь, Terraform state, метаданные бэкапов и исполнитель автоматизации тоже живут там, полное падение кластера лишает нас и инструкции, и рычага восстановления. Система не должна держать собственную лестницу внутри горящего здания.

После таблиц хочется получить диаграмму, а AI прекрасно рисует Mermaid и особенно любит добавлять на схему логичные связи, которые никто не принимал. Поэтому диаграмма не создаёт новые факты, она визуализирует уже согласованные: каждая вершина получает статус, а каждая стрелка - смысл.

ИНЖЕНЕР

Построй Mermaid flowchart только из подтверждённого Current state и согласованного Target state. В label каждого будущего компонента добавь planned, открытого решения - open. Не добавляй базы данных, storage, controllers и сетевые устройства, которых нет в таблице ролей. Подпиши стрелки operator access, creates, configures или runtime traffic; безымянных связей быть не должно.

Получается не рекламная картинка, а проверяемая модель:



Даже эта схема вызывает вопрос: стрелка от Terraform control plane к Proxmox означает API-вызов, а не создание физического гипервизора, и если бы подпись была просто creates, читатель мог понять её буквально. Архитектурная диаграмма обязана быть менее двусмысленной, чем разговор у доски, иначе через полгода её будут трактовать по памяти самого уверенного участника. После генерации запускаем машинально скучную, но полезную сверку:

ИНЖЕНЕР

Сравни вершины диаграммы с таблицей ролей и Current state. Выведи три списка: missing in diagram, only in diagram, status mismatch. Не исправляй автоматически.

Так AI проверяет собственный артефакт другой операцией. Если на схеме внезапно появилось общее хранилище, оно попадёт в only in diagram; если два Proxmox-хоста подписаны planned, возникнет status mismatch, ведь они уже существуют; если потерялся путь восстановления, это станет missing in diagram. Сама диаграмма хранится рядом с текстовым описанием или генерируется из него, но не живёт отдельной жизнью в презентации, поскольку две несвязанные версии архитектуры почти гарантируют, что обновляться будет только та, которую никто не использует во время инцидента.

Точные адреса пока неизвестны, но и голый лист бумаги не повод раздавать IP наугад по мере появления идей, поэтому сначала мы делим пространство по назначению, а конкретные цифры проставим тогда, когда за них будет кому отвечать. Для учебного примера используем приватную подсеть 192.168.50.0/24, не связанную с исходной production-средой. Мы пока не пишем .12 рядом с API и не раздаём адреса каждой VM, а лишь проверяем, что диапазоны не пересекаются, оставляют резерв и различают точку подключения управления и ingress приложений. В прошлых инфраструктурах формула «следующий адрес плюс count.index» неизбежно дорастала до уже занятого сервиса, причём выяснялось это обычно не в момент планирования, а в момент, когда Terraform показывал to destroy напротив работающего узла. Поэтому будущие постоянные адреса будут жить в явной таблице соответствий, а не вычисляться надеждой.

Kubernetes API в Target state получит стабильный виртуальный IP перед control plane, а два будущих балансировщика будут принимать TCP на порту API и направлять трафик к control-plane узлам. Ingress приложений получит другой адрес и другое DNS-имя, потому что если оба назвать «кластерным IP», диагностика быстро превратится в разговор двух людей о разных вещах с одинаковым названием.

Архитектура не проверяется тем, что Mermaid успешно нарисовал стрелки, поэтому строим матрицу отказов - таблицу ожидаемого поведения при потере каждого домена, где каждая строка ждёт будущей проверки. Мы не пишем «VIP переключается», пока не проведена проверка переключения: сейчас у строки статус planned, а после реализации ожидаемое поведение станет либо проверенным фактом, либо новым инцидентом. Архитектурная документация должна переживать встречу с реальностью, а не сохранять первоначальную уверенность автора - реальность славится тем, что не читает наши markdown-файлы перед тем, как что-то сломать. Ревьюеру даём отдельный промпт:

ИНЖЕНЕР

Проведи adversarial review архитектуры. Не предлагай новые технологии. Ищи: выдуманные ресурсы, один физический failure domain под несколькими VM, bootstrap cycles, средство восстановления внутри основной системы, публичные management endpoints и сценарии без recovery access. Для каждого замечания укажи конкретную строку Current state или Target state.

Разделение ролей работает и здесь: первый агент строил схему, и всё, что он скажет дальше, будет продолжением уже принятых им допущений, тогда как второй получает прямую задачу эти допущения разрушить. Агент не испытывает обиды за собственную архитектуру, и этим преимуществом стоит пользоваться.

После ревью агент не переписывает все семь файлов. Изменились три области. В architecture.md появляются Current state с двумя Proxmox-хостами и локальным хранилищем, Target state с путём оператора, management plane и будущим кластером, граф зависимостей bootstrap, роли VM со статусами planned и future, матрица отказов и ограничение двух физических доменов. В docs/decisions.md фиксируются принятые решения: интерфейсы управления остаются внутренними; нужен контролируемый путь оператора; средства bootstrap и восстановления находятся вне основного Kubernetes; третий физический домен остаётся предложенной целью, а не текущим фактом. Точное размещение VPN, адреса, размеры VM и стратегия принятия под управление stage-zero ресурсов остаются в Open Questions.

В current-task.md архитектурная сессия переходит в Done, но создание VM ещё не начинается: следующий шаг - подготовить stage zero, проверив рабочую машину, доступ к Proxmox, сеть и минимальный shell-контракт. Это уже будет граница между схемой и действием.

Команда на завершение:

ИНЖЕНЕР

Закрой архитектурную сессию. Покажи diff документов, перечисли принятые решения, assumptions и Open Questions. Отдельно подтверди, что все будущие компоненты помечены planned/future, третий хост и общее хранилище не выданы за существующие, а внешняя инфраструктура не изменялась. В итоговой сводке укажи известный предел HA на двух физических хостах и следующий безопасный шаг.

Хороший итог не пишет «спроектирован production-ready HA cluster». Он честнее:

TERMINAL

- Confirmed current state: 2 Proxmox hosts, local storage, 0 managed VM.

- Accepted: private management plane, controlled operator path, recovery tools outside main cluster.

- Planned: bastion, Terraform/automation control plane, 2 LB, 3 CP, workers.

- Known limitation: loss of the host containing 2/3 etcd members removes quorum.

- Proposed target: third physical failure domain; not available today.

- Open: VPN placement, exact addressing, VM sizing, stage-zero adoption strategy.

- External changes: none.

Мы получили архитектуру, которая признаёт существование физического мира: в ней два доступных хоста не превращаются в три, локальное хранилище не называется общим, planned VM не участвуют в Current state, а HA всегда сопровождается названием отказа, который система должна пережить. AI помог сравнить варианты, построить граф зависимостей и атаковать схему сценариями отказа, тогда как человек задал ограничения и отказался принимать красивый вариант за текущую реальность. Это нормальное разделение труда: модель быстро исследует пространство решений, а инженер отвечает за ту его часть, где действительно стоят серверы. Именно такой документ можно безопасно использовать как контекстный пакет следующей практической сессии.

В следующей главе начнётся stage zero: мы проверим путь до Proxmox, подготовим контролируемую точку входа и первый минимальный bootstrap. Пока это всего лишь стрелка на схеме. Скоро у неё появятся SSH, коды возврата и все остальные признаки взрослой жизни.

Глава4. Golden image


Виртуальная машина, однажды настроенная руками и аккуратно выключенная, ещё не шаблон. Это просто сервер, который пока никто не успел испортить, - как холодильник, который только что помыли. Он чистый. Но это не значит, что в нём ничего не протухнет через неделю.

Настоящий golden image - проверенный источник одинаковых VM, в котором нет ни чужого имени узла, ни старого machine-id, ни приватного ключа автора, ни следов предыдущего cloud-init, ни случайного пакета, установленного ночью для диагностики. Из него можно создать клон, загрузить, получить сеть, войти по SSH, увидеть работающий guest agent, перезагрузить и повторить результат. В предыдущей главе мы спроектировали путь управления, но не создали ни одной VM, а теперь появляется первый реальный артефакт платформы. Terraform мы всё ещё не запускаем и Kubernetes не устанавливаем: сначала руками собираем шаблон Ubuntu на Proxmox и вместе с AI доказываем, что он действительно пригоден для клонирования.

Слово «руками» здесь не означает «без правил» - наоборот: destructive-команды выполняет человек, тогда как AI готовит план, читает обезличенный вывод, ищет пропущенные проверки и помогает оформить результат. Автоматизировать сборку шаблона мы сможем позже, а сначала полезно увидеть весь жизненный цикл без чёрного ящика. Не бойтесь делать руками; бойтесь делать руками то, чего вы не записали и не сможете повторить.

В этой главе легко запутаться, где должна выполняться очередная команда, поскольку контуров у нас три: рабочая машина инженера, Proxmox-хост и временная гостевая VM. На рабочей машине лежит репозиторий, работает AI-агент и просматривается diff; на Proxmox-хосте доступны qm, pvesm и локальные хранилища; внутри временной гостевой VM выполняются cloud-init, установка qemu-guest-agent и очистка идентичности перед превращением в шаблон. Если вставить команду не в тот терминал, результат бывает познавательным: qm внутри Ubuntu сообщит, что команды нет, тогда как cloud-init clean на Proxmox-хосте способен превратить простую ошибку контекста в гораздо более содержательный вечер. Поэтому каждый блок в плане обязан указывать, где именно он выполняется.

Обычный путь оператора после появления bastion будет выглядеть так: laptop → VPN → bastion → SSH к Proxmox-хосту, но bastion ещё не создан, и для него как раз нужен шаблон. Первый доступ к Proxmox выполняется через существующий путь восстановления и управления, подтверждённый владельцем среды; мы не выдумываем его в книге и не публикуем UI ради удобства. Bootstrap начинается с контролируемого исключения, а не с отмены правил безопасности.

Агенту не нужен интерактивный root-доступ к гипервизору - ему нужны результаты конкретных read-only команд, которые человек запускает сам и передаёт очищенный вывод. Начальный промпт:

ИНЖЕНЕР

Мы начинаем первую mutating-сессию проекта: вручную собираем Ubuntu golden image на одном Proxmox-хосте. Работай сначала как read-only планировщик. Прочитай ai_initial.md, current-task.md, architecture.md и docs/decisions.md. Не выполняй SSH, не обращайся к Proxmox и не генерируй большой shell-скрипт. Подготовь список команд предстартовой проверки, работающих только на чтение. Для каждой укажи execution target, зачем нужен вывод и какие значения нельзя публиковать. После получения свидетельств составь пошаговый plan с отдельными точками подтверждения перед download, созданием VM, очисткой идентичности guest, преобразованием в template и удалением тестового clone.

Хороший агент сначала спрашивает: какие Proxmox-узлы реально существуют, хранилище локальное или общее, какой bridge подключён к учебной сети, есть ли свободный VMID, разрешён ли исходящий HTTPS для загрузки образа и какой выпуск Ubuntu принят. Назначать local-lvm, vmbr0 и ID 9000 только потому, что эти значения часто встречаются в примерах, он не должен.

ИИ: Ubuntu Server 24.04 LTS принят как baseline. Начинаем с первого Proxmox-хоста. Storage и bridge ещё нужно подтвердить. Template ID выбираем только после qm list. На втором хосте local storage; стратегию копирования template проверим отдельно. Приватные адреса и имена узлов в книгу не переносим.

Теперь неизвестные отделены от решений, и можно собирать свидетельства. На выбранном Proxmox-хосте выполняем:

TERMINAL

pveversion

pvesm status

qm list

ip -br link

df -h

Если Proxmox-узлы объединены в cluster, дополнительно смотрим:

TERMINAL

pvecm status

pvecm nodes

Эти команды ничего не меняют, а отвечают они на разные вопросы: pveversion фиксирует фактическую платформу, а не версию из памяти; pvesm status показывает хранилища, их тип и доступность; qm list защищает от выбора занятого VMID; ip -br link помогает найти фактический bridge, не раскрывая при этом всю сетевую конфигурацию; df -h показывает, хватит ли места для образа, импортированного диска и тестового клона; pvecm подтверждает членство в кластере и имена физических узлов.

Не надо отправлять агенту полный /etc/pve, содержимое учётных данных хранилища, приватные ключи и дамп настроек сетевого файрвола: для выбора хранилища нужны имя, тип, признак shared/local и доступное место, а остальное добавляется только под конкретную гипотезу. Можно сократить до таблицы:

TERMINAL

Proxmox node: host-a (anonymized)

VM storage: local-lvm, active, local

Image storage: local, active, local

Bridge: vmbr0, confirmed

Free template VMID: 9000, confirmed by qm list

Second node storage: local, no shared volumes

Outbound HTTPS: available

AI возвращает план, и до первого изменения мы проверяем, что в нём нет удаления, перезаписи существующего VMID, смены bridge, увеличения хранилища и попытки «заодно» обновить Proxmox. Наша задача - создать один шаблон. Обновление гипервизора не становится разумнее от того, что модель предложила его вежливо. Рабочий план состоит из семи этапов:

Скачать официальный cloud image и checksum-файл.

Проверить checksum и metadata образа.

Создать временную VM с новым VMID и импортировать disk.

Настроить cloud-init, serial console и guest agent integration.

Один раз загрузить guest, проверить сеть и установить недостающее.

Остановить guest, очистить его уникальную идентичность и превратить в template.

Создать отдельный тестовый clone и проверить полный жизненный цикл.

Точки остановки находятся перед этапами 3, 6 и удалением тестового клона, потому что каждая из этих команд меняет мир по-своему: создание VM меняет Proxmox, очистка идентичности необратимо меняет гостя, qm template меняет тип объекта, а qm destroy удаляет тестовую VM. Объединять их в одну длинную команду удобно только до первой опечатки в VMID.

Агент получает дополнительную задачу:

ИНЖЕНЕР

Классифицируй команды плана как read-only, mutating-reversible и destructive. Для destructive укажи ручную проверку объекта и критерий, после которого команда разрешена. Не объединяй destructive-команды через &&.

Так AI помогает не только написать команду, но и поставить вокруг неё тормоза. Для курса выбран Ubuntu Server 24.04 LTS с кодовым именем noble, а официальный каталог Ubuntu Cloud Images публикует и сам образ, и SHA256SUMS. Ссылка current удобна, но её содержимое способно обновляться, поэтому контрольная сумма должна скачиваться в ту же сессию, а выбранный digest - записываться в журнал сборки. На Proxmox-хосте:

TERMINAL

install -d -m 0755 /var/lib/vz/template/qcow2

cd /var/lib/vz/template/qcow2

curl --fail --location --remote-name \

https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img

curl --fail --location --remote-name \

https://cloud-images.ubuntu.com/noble/current/SHA256SUMS

grep 'noble-server-cloudimg-amd64.img$' SHA256SUMS | sha256sum --check -

qemu-img info noble-server-cloudimg-amd64.img

Ожидаем OK от sha256sum и корректно читаемый qcow2 от qemu-img. Если grep не нашёл строку, конвейер способен завершиться не так очевидно, как хотелось бы, поэтому проверяем и код возврата, и сам вывод; для будущего скрипта понадобятся set -o pipefail и явная проверка, что строка с контрольной суммой ровно одна. Путь /var/lib/vz/template/qcow2 в примере предполагает стандартное хранилище типа directory local, и если предстартовая проверка показала другой путь, используем его фактическую конфигурацию, а не создаём параллельный каталог только потому, что он был напечатан в книге. Синтаксис доступных опций сверяем с установленной версией через qm help.

AI не должен «подсказать известный SHA256» из памяти: current меняется, а память модели тоже не реестр выпусков, поэтому источник истины находится в официальном файле контрольных сумм текущей загрузки. В docs/decisions.md записываем выпуск и фактический digest, но не коммитим многогигабайтный образ в Git. Git - система контроля версий, а не чулан, куда неудобно ходить с внешним диском.

Настало время создать временную VM. Перед mutating-командой ещё раз проверяем VMID:

TERMINAL

qm status 9000

Ожидаемый результат - сообщение, что VM не существует. Если же объект найден, мы не выбираем опцию --force и не удаляем его ради красивого номера, а возвращаемся к qm list, выбираем свободный ID и обновляем план. Создаём VM-основу:

TERMINAL

qm create 9000 \

--name ubuntu-noble-template-build \

--memory 2048 \

--cores 2 \

--net0 virtio,bridge=vmbr0 \

--ostype l26 \

--scsihw virtio-scsi-pci

qm importdisk 9000 \

/var/lib/vz/template/qcow2/noble-server-cloudimg-amd64.img \

local-lvm

qm importdisk сообщает имя созданного тома и обычно добавляет его как unused disk, поэтому имя мы не угадываем по примеру из интернета, а смотрим фактическую конфигурацию:

TERMINAL

qm config 9000

Затем подставляем именно показанный imported volume:

TERMINAL

qm set 9000 --scsi0 '<imported-volume-from-qm-config>'

qm resize 9000 scsi0 32G

qm set 9000 --ide2 local-lvm:cloudinit

qm set 9000 --boot order=scsi0

qm set 9000 --serial0 socket --vga serial0

qm set 9000 --agent enabled=1

Угловые скобки - явная заглушка, а не команда для копирования: агент обязан назвать этот том неизвестным значением, а в рабочем регламенте мы записываем фактический том после qm config. Размер 32G - базовый минимум, а не универсальная рекомендация: он должен быть больше исходного облачного диска и достаточен для управления VM первого этапа, причём уменьшить диск этой командой нельзя. При первом запуске cloud-init расширит раздел и файловую систему, что отдельно подтверждаем через lsblk и df -h на тестовом клоне.

Настраиваем временный cloud-init для первого запуска:

TERMINAL

qm set 9000 --ciuser ubuntu

qm set 9000 --sshkeys /root/.ssh/template-build.pub

qm set 9000 --ipconfig0 ip=dhcp

qm cloudinit dump 9000 user

qm cloudinit dump 9000 network

В шаблон передаётся только публичный ключ, созданный для bootstrap-процесса, тогда как приватный ключ не копируется в образ и не попадает в Git. Дамп cloud-init проверяем до запуска: правильный YAML ещё не гарантирует рабочую VM, но неправильный YAML уже позволяет не тратить время на загрузку. Запускаем сборочную VM:

TERMINAL

qm start 9000

qm status 9000

Ждём адрес через DHCP/guest agent либо смотрим console согласно возможностям среды. Подключившись по SSH, выполняем внутри guest:

TERMINAL

cloud-init status --wait

cat /etc/os-release

hostnamectl

systemctl is-enabled qemu-guest-agent

systemctl is-active qemu-guest-agent

Если qemu-guest-agent отсутствует, устанавливаем его в сборочной VM:

TERMINAL

sudo apt-get update

sudo apt-get install -y qemu-guest-agent

sudo systemctl enable --now qemu-guest-agent

Проверяем с Proxmox-хоста:

TERMINAL

qm agent 9000 ping

Именно здесь заканчивается допустимая настройка golden image: мы не устанавливаем ни kubelet, ни Docker, ни клиент VPN, ни агент мониторинга, ни любимый shell prompt. Golden image должен быть скучным до безобразия - cloud-init, guest agent, базовая ОС и только те системные настройки, которые нужны каждой будущей VM. Чем богаче шаблон, тем дороже любое его ошибочное предположение.

AI помогает вести build log:

TERMINAL

Image URL: official Ubuntu noble/current

SHA256: <verified digest>

Build VMID: 9000

Storage: local-lvm on host-a

Bridge: vmbr0

Cloud-init: done

Guest agent: active; qm agent ping OK

Extra packages: qemu-guest-agent only

Каждая строка должна опираться на команду: Guest agent: configured недостаточно, поскольку конфиг может существовать при остановленном сервисе. Перед преобразованием агент предлагает вынести очистку в универсальный prepare-template.sh и запускать его на любой VM, которую нужно «привести к baseline». Звучит удобно. Внутри скрипта будут cloud-init clean, удаление SSH host keys, очистка логов и обнуление machine-id.

На сборочной VM это правильно, но на живом сервере такой запуск уничтожит часть его идентичности и заставит следующую загрузку вести себя как первую. Универсальная очистка быстро становится универсальным инцидентом.

ИНЖЕНЕР

Инженер: Стоп. Template cleanup не является baseline reconcile. Он разрешён только для отдельной build VM перед qm template. Не создавай роль или скрипт, применимый к inventory. Добавь precondition: VMID совпадает с согласованным build ID, VM не входит в рабочий inventory, внутри нет kubelet/container runtime/application data, после очистки VM не запускается до преобразования в template.

Будущий инцидент:

очистка попадает на живой узел и удаляет его уникальную идентичность.

Исправление:

Сейчас проверяем объект вручную. На Proxmox-хосте:

TERMINAL

qm config 9000

qm status 9000

Внутри guest проверяем отсутствие неожиданных данных и сервисов. Только затем выполняем:

TERMINAL

sudo cloud-init clean --logs

sudo truncate -s 0 /etc/machine-id

sudo rm -f /var/lib/dbus/machine-id

sudo ln -s /etc/machine-id /var/lib/dbus/machine-id

sudo rm -f /etc/ssh/ssh_host_*

sudo shutdown -h now

Ubuntu cloud-init должен сгенерировать SSH host keys при первом запуске клона, и это поведение мы проверим, а не предположим. После выключения VM снова не запускаем: на Proxmox-хосте убеждаемся, что она остановлена, и только затем:

TERMINAL

qm status 9000

qm template 9000

qm config 9000

Ожидаем статус template в конфигурации. Но это ещё не критерий готовности. Слово «идентичность» легко превратить в мистический список файлов, кочующий из одного блога в другой, тогда как у клона есть несколько независимых уровней идентичности, и каждый нужно понимать отдельно.

/etc/machine-id идентифицирует установленную Linux-систему для systemd и программ, которые на него опираются. Два одновременно работающих клона с одинаковым machine-id выглядят как две машины только на уровне гипервизора, тогда как внутри части инфраструктурных сервисов это один и тот же узел, появившийся в двух местах. Поэтому шаблон хранит пустой файл, а первый запуск клона создаёт новое значение.

SSH host keys доказывают клиенту идентичность сервера, и если все клоны унаследуют один набор ключей, SSH перестанет различать их криптографически. Удаляем host keys только в сборочной VM перед финальным выключением и проверяем их генерацию на тестовом клоне. Публичный ключ оператора из конфигурации cloud-init не удаляем: это другой ключ с другой задачей.

Cloud-init хранит сведения об уже обработанном экземпляре, и cloud-init clean заставляет следующий клон пройти стадии первой загрузки заново. Без очистки новая VM может сохранить старое имя узла, пропустить user-data или счесть первоначальную настройку завершённой. При этом команда не делает образ «чистым вообще»: если вы вручную положили пароль в /opt/notes.txt, cloud-init его не найдёт и не испытает морального потрясения.

Имя узла, аренда DHCP, история командной строки, временные файлы и кэш пакетов требуют отдельного решения, поскольку очищаем мы только то, что действительно появилось во время сборки и не должно размножаться. Бездумное rm -rf /var/log/* /tmp/* /var/cache/* не является профессиональной очисткой: оно способно удалить диагностические данные, сломать ожидаемую структуру каталогов и скрыть действия сборки. Минимальный образ - не тот, из которого удалили всё знакомое, а тот, где каждый оставшийся компонент имеет причину.

Просим AI составить identity matrix:

machine-id

- Template before shutdown: пустой

- Test clone after first boot: новый, непустой

- Проверка: cat /etc/machine-id

SSH host keys

- Template before shutdown: отсутствуют

- Test clone after first boot: сгенерированы

- Проверка: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

имя узла

- Template before shutdown: generic build value

- Test clone after first boot: значение clone

- Проверка: hostnamectl --static

cloud-init state

- Template before shutdown: cleaned

- Test clone after first boot: status done

- Проверка: cloud-init status --long

operator приватный ключ

- Template before shutdown: отсутствует

- Test clone after first boot: отсутствует

- Проверка: review image/build inputs

Для сравнения двух клонов мало проверить, что значения непустые. Они должны различаться. Позже smoke test можно расширить вторым клоном и сравнить machine-id и отпечатки SSH-ключей, поскольку два одинаковых значения будут прямым доказательством дефекта шаблона, а не поводом добавить исключение в known_hosts.

qm template проверяет изменение типа объекта, но не доказывает, что cloud-init создаст идентичность, что сеть поднимется и что SSH примет ключ. Поэтому создаём отдельный тестовый клон с заранее проверенными свободными VMID и адресом учебной сети.

TERMINAL

qm clone 9000 9100 --name template-smoke-test --full true

qm set 9100 --ciuser ubuntu

qm set 9100 --sshkeys /root/.ssh/template-build.pub

qm set 9100 \

--ipconfig0 ip=192.168.50.90/24,gw=192.168.50.1 \

--nameserver 192.168.50.1

qm start 9100

Адрес здесь учебный, а в реальной сессии он сначала проверяется по карте адресов, поскольку фраза «это всего на пять минут» не защищает от конфликта ARP: временная VM умеет конфликтовать с постоянной без всякого снижения качества. Внутри клона проверяем:

TERMINAL

cloud-init status --wait

hostname

cat /etc/machine-id

systemctl is-active qemu-guest-agent

ip -br address

ip route

lsblk

df -h /

С Proxmox-хоста:

TERMINAL

qm agent 9100 ping

qm reboot 9100

После перезагрузки повторяем проверки SSH и guest agent. Шаблон проходит smoke test, если:

• clone загрузился без ручной правки

console; • cloud-init завершился успешно;

•имя узла соответствует конфигурации клона;

• machine-id непустой и создан заново;

• SSH host keys существуют;

•сеть и маршрут по умолчанию соответствуют плану;

•корневая файловая система выросла до согласованного размера;

• guest agent отвечает и после перезагрузки;

• VM-шаблон осталась неизменной.

Просим AI ревьюить не статус, а свидетельства:

ИНЖЕНЕР

Сравни verification output с criteria of done. Выведи confirmed, failed и not checked. Не считай qm clone доказательством SSH, сети или guest agent. Не предлагай удаление тестовой VM, пока есть not checked.

Так агент не завершает задачу фразой «клон успешно создан». Создание - середина проверки. После успешного smoke test на первом хосте хочется сразу проверить клон на втором:

TERMINAL

requested node: host-b

source template: 9000 on host-a/local-lvm

result: source volume is not available on target node

Первый ответ AI может уйти в права доступа, здоровье кластера или несовместимость версий, поэтому возвращаем его к свидетельствам:

Проверяем pvesm status и qm list на каждом узле. На втором хосте template отсутствует. Причина не в правах: linked/local volume физически не доступен соседнему хосту. Storage local-lvm помечен local на обоих хостах. Template volume существует только на host-a. Построй гипотезы, не предполагая общего хранилища. Какая минимальная проверка отличит permissions от физического отсутствия source volume?

Вариантов несколько: отдельно собрать шаблон на каждом хосте, перенести бэкап и восстановить его как шаблон либо внедрить общее хранилище. Последний вариант расширяет архитектуру и «заодно» не появляется, поэтому для учебного пути выбираем воспроизводимую копию шаблона на каждом хосте и ведём явную карту:

host-a

- Template VMID: 9000

- Image SHA256: <digest>

- Smoke test: passed

host-b

- Template VMID: 9001

- Image SHA256: <same digest>

- Smoke test: pending

Разные VMID допустимы и должны быть известны будущему Terraform, тогда как использовать один случайный template_id для всех узлов опасно: план выглядит единообразно, а apply падает в момент клонирования. Ещё опаснее позже поменять ID шаблона в Terraform, не посмотрев на replacement, - эту ловушку разберём в соответствующей главе. Инцидент оформляется так:

•Симптом: клон на соседнем Proxmox -узле не видит исходный том.

Гипотеза: права доступа либо граница локального хранилища.

Свидетельства: хранилище локальное, шаблон присутствует только на первом узле.

Причина: архитектура клонирования предполагала доступность локального тома между хостами.

Исправление: создать или восстановить проверенный шаблон на целевом хосте.

Предохранитель: карта шаблонов по узлам и предстартовая проверка наличия шаблона до создания VM После завершения проверок тестовый клон больше не нужен, но перед удалением мы смотрим его идентичность:

TERMINAL

qm status 9100

qm config 9100

Сверяем VMID, имя, узел и отсутствие данных. Останавливаем тестовую VM отдельной командой, снова проверяем статус и только затем удаляем:

TERMINAL

qm shutdown 9100 --timeout 60

qm status 9100

qm destroy 9100 --purge

Если корректное выключение не завершилось, не заменяем его автоматически на qm stop и destroy, а сначала выясняем, почему не отработал guest agent: это часть проверки шаблона. Принудительно удалить симптом - быстрый способ завершить тест, который должен был этот симптом обнаружить. Предохранитель для destructive-команд простой: AI готовит команду, человек повторно читает подставленный ID и объект, а команда удаления не объединяется с клонированием и сборкой в один сценарий. Чем привычнее становится qm destroy, тем важнее формальная пауза перед Enter.

Через месяц выйдут обновления безопасности, изменится облачный образ или понадобится новый базовый пакет. Самый удобный путь - запустить шаблон, поставить обновления и выключить обратно. После нескольких таких сеансов никто уже не знает, какие команды выполнялись, какая контрольная сумма была у исходного образа и почему клон с тем же VMID сегодня отличается от вчерашнего.

Golden image лучше считать неизменяемым артефактом: новая сборка получает новую запись и версию, проходит тот же smoke test, а затем становится утверждённым шаблоном. Старый шаблон не удаляется, пока не проверены его потребители и не готов план отката. Имя может включать базовый выпуск и дату сборки, но VMID остаётся лишь техническим идентификатором Proxmox, а не единственным описанием версии.

noble-2026-07-a

- Ubuntu image digest: <digest-a>

- Built at: 2026-07-18

- host-a VMID: 9000

- host-b VMID: 9001

- Status: approved

noble-2026-08-a

- Ubuntu image digest: <digest-b>

- Built at: planned

- host-a VMID: 9010

- host-b VMID: 9011

- Status: candidate

Сначала создаём кандидата, клонируем тестовую VM, прогоняем первую загрузку, перезагрузку и необходимые проверки, и только затем будущая конфигурация VM начинает ссылаться на новые ID шаблонов по узлам. Этот переход будет отдельным Terraform plan, потому что смена исходного шаблона способна повлиять на lifecycle ресурса: «мы лишь обновили образ» - недостаточное описание изменения, если инструмент видит повод заменить VM. Если же агент обнаружил пакет, который ставился «для проверки» и не записан в журнал сборки, кандидат не становится утверждённым до пересборки или явного решения оставить пакет. Мы не лечим неизвестное происхождение красивым комментарием задним числом.

Удаление старого шаблона тоже проходит отдельной задачей, и до него нужно доказать, что ни Terraform, ни конфигурация stage zero больше не ссылаются на старые ID, что на каждом узле есть утверждённая замена и что из новой версии поднят хотя бы один реальный потребитель. Иначе экономия нескольких гигабайт превращается в невозможность воспроизвести работающие VM при аварии.

В architecture.md записываем фактическую стратегию шаблонов: локальное хранилище, шаблон нужен на каждом Proxmox-узле, ID хранятся в явной карте; там же появляется подтверждённый базовый выпуск Ubuntu, но не полный журнал сборки. В docs/decisions.md фиксируем выбранный выпуск Ubuntu и официальный источник образа, контрольную сумму конкретной сборки, минимальный состав golden image, решение не включать Kubernetes и прикладные пакеты, а также стратегию шаблонов по узлам до появления общего хранилища. В incidents.md сохраняем отказ клонирования на локальном хранилище и предохранитель, в runbooks.md появляется короткий порядок smoke-теста клона, а current-task.md получает статус первого хоста, ожидание второго шаблона и следующий безопасный шаг.

Команда на завершение:

ИНЖЕНЕР

Закрой сессию golden image. Покажи изменённые документы и свидетельства для контрольных сумм, cloud-init, machine-id, SSH, guest agent, reboot и удаления тестового clone. Отдельно перечисли непроверенные Proxmox-узлы. Подтверди, что cleanup выполнялся только на build VM, тестовый clone удалён после criteria of done, а template остался. Не переходи к созданию bastion.

Хороший ответ выглядит так:

TERMINAL

- host-a template 9000 created from verified Ubuntu noble image.

- SHA256 recorded; cloud-init and guest agent verified.

- Smoke clone passed first boot, SSH, identity, network and reboot checks.

- Test VM 9100 removed after verification.

- host-b template not created; local storage prevents cross-node clone.

- Guardrail: explicit per-node template map and preflight.

- Next safe step: reproduce the same template on host-b, then begin stage zero.

Впервые появился настоящий инфраструктурный артефакт: не схема и не обещание, а шаблон, из которого поднялась и пережила перезагрузку отдельная VM. Мы знаем происхождение образа, контрольную сумму, минимальный состав, границы хранилища и список проверок, так что если завтра клон не стартует, у нас будут свидетельства исходного состояния, а не воспоминание «вчера вроде работало». AI помог составить план, классифицировать опасные команды, сравнить ожидаемое и наблюдаемое, расследовать отказ клонирования между узлами и оформить предохранители, но qm template и qm destroy выполнил инженер после проверки объекта. Ответственность не делегируется вместе с генерацией текста команды.

В следующей главе этот шаблон впервые станет строительным материалом: stage-zero скрипт создаст bastion, а затем management plane для Terrakube и AWX. Там мы встретим следующий неудобный вопрос - как сделать одноразовый ручной bootstrap достаточно воспроизводимым, чтобы он не превратился в пожизненное шаманство.

Глава 5. Stage zero: bastion, Terrakube и AWX


Terraform должен создать виртуальные машины. State должен храниться в Terrakube. Terrakube должна работать на виртуальной машине. Эту виртуальную машину должен создать Terraform.

Круг замкнулся быстро и аккуратно. Можно ещё добавить AWX, которая настроит Terrakube, но сама живёт на VM, которую позже создаёт Terraform и настраивает AWX; получится архитектурный вечный двигатель, который энергии не производит, зато диаграмма занята полностью. Так выглядит bootstrap paradox - система управления зависит от инфраструктуры, которую ещё только должна создать, и решается он не попыткой спрятать первый ручной шаг, а его строгим ограничением. Мы создадим минимальный management plane вне Terraform: bastion, отдельную VM для Terrakube и отдельную VM для AWX, а всё остальное начнётся после них.

Stage zero - не альтернативная система управления инфраструктурой, а временный мост с известным началом, концом и списком ресурсов. Если по нему продолжать возить production через два года, проблема не в мосте. Проблема в том, что никто не построил дорогу. В этой главе мы используем golden image из предыдущей сессии, исправляем labs/scripts/stage-zero.sh, создаём три management VM и поднимаем на двух из них изолированные K3s-инсталляции для Terrakube и AWX. Terraform-код здесь не появится: сначала инструментам нужно предоставить место для жизни, проверки здоровья и план восстановления. Ставим агенту задачу:

ИНЖЕНЕР:

Подготовь stage-zero сессию для трёх VM: bastion-01, terrakube-01, awx-01. Используй только проверенный Proxmox template и существующий labs/scripts/stage-zero.sh. Сначала работай read-only: прочитай ai_initial.md, текущий план, архитектуру, решения, incident log, главу о golden image и сам скрипт. Проверь execution target, повторный запуск, конфликт VMID, частично созданную VM, размеры, secrets и критерии готовности. Не пиши Terraform, не создавай узлы кластера, registry, storage и приложения. Не удаляй конфликтующие VM автоматически. До mutating-команд покажи inventory, plan, откат boundaries и команды проверки.

Stage zero притягивает слово «заодно». Раз уж получили root-доступ к Proxmox, почему не создать балансировщик? Раз уж ставим K3s для AWX, почему не развернуть там мониторинг? Так временный bootstrap превращается в теневую платформу, которую нельзя ни повторить, ни удалить.

AI

Агент: На каком Proxmox-хосте находится approved template? Подтверждены ли VMID и IP каждой роли? Какой CPU type и размеры нужны management VM? Где лежит public SSH key на Proxmox-хосте? Скрипт запускается непосредственно на Proxmox или должен выполнять qm удалённо с bastion? Как будут храниться сгенерированные учётные данные Terrakube и AWX?

ИНЖЕНЕР

Инженер: Template и значения берём из подтверждённой per-node карты. VMID и IP проверяются перед запуском по фактическому inventory. Скрипт выполняется на Proxmox-хосте: сначала через recovery access, после появления bastion - через контролируемый SSH path. Public key копируется отдельно, приватный ключ остаётся у оператора. Учётные данные генерируются локально и не передаются AI; в документах остаются только имена secret inputs и место хранения.

Теперь можно обсуждать код, не выдумывая инфраструктуру. Старая версия stage-zero.sh создавала bastion на Proxmox-хосте, а затем текст предлагал запускать тот же скрипт «с bastion», хотя внутри вызывается qm, которого на обычной Ubuntu VM попросту нет. Bastion - путь к гипервизору, а не гипервизор, переодетый для удобства автора. Контракт исправлен:

TERMINAL

workstation --SSH/recovery--> Proxmox host: stage-zero.sh creates bastion

workstation --ProxyJump> bastion --SSH--> Proxmox host: stage-zero.sh creates management VM

Сам скрипт всегда работает там, где существует qm и локальный шаблон:

TERMINAL

command -v qm >/dev/null 2>&1 || {

echo "qm not found: run this script on the target Proxmox host" >&2

exit 1

}

Можно было бы превратить его в удалённую обёртку, которая сама подключается по SSH, копирует ключи и вызывает команды, но тогда маленький bootstrap получил бы в нагрузку управление транспортом, экранированием, временными файлами и передачей учётных данных. В учебной версии границу видно глазами: человек входит на нужный хост, а затем запускает локальный скрипт. Простота здесь является предохранителем, а не недостатком зрелости. В рабочей сессии агент получает обезличенную таблицу, а инженер сверяет каждую строку с qm list, картой адресов и картой шаблонов:

bastion

- Proxmox-узел: host-a

- Template ID: confirmed

- New VMID: confirmed

- IP: confirmed

- CPU: host

- Cores: 2

- RAM: 2 GiB

Terrakube

- Proxmox-узел: host-a

- Template ID: confirmed

- New VMID: confirmed

- IP: confirmed

- CPU: host

- Cores: 4

- RAM: 8 GiB

AWX

- Proxmox-узел: host-b

- Template ID: confirmed

- New VMID: confirmed

- IP: confirmed

- CPU: host

- Cores: 4

- RAM: 8 GiB

Это учебная таблица, а не универсальная рекомендация, так что перед запуском сверяем и требования выбранных версий, и доступные ресурсы гипервизора. Явные --cores и --memory появились в скрипте не для красоты, а потому, что старая версия молча наследовала от шаблона 2 GiB для всех ролей, независимо от того, что этой роли предстояло делать. Bastion мог выжить. AWX получил бы шанс продемонстрировать, насколько разнообразно Kubernetes умеет сообщать о своих ошибках.

Несекретные defaults находятся в labs/scripts/stage-zero.env.example:

TERMINAL

TEMPLATE_ID=9000

STORAGE=local-lvm

BRIDGE=vmbr0

GATEWAY=192.168.50.1

NAMESERVER=192.168.50.1

CPU_TYPE=host

SSH_PUBLIC_KEY_FILE=/root/.ssh/stage-zero.pub

Пример копируется в локальный файл вне Git или экспортируется в shell на Proxmox-хосте, причём публичный ключ секретом не является, хотя путь к нему зависит от конкретного хоста. Proxmox токен, приватный ключ, пароли и реальные production-адреса здесь не появляются. Перед каждым запуском проверяем:

TERMINAL

qm list

qm status "$TEMPLATE_ID"

qm config "$TEMPLATE_ID"

pvesm status

ip route

Если выбранный VMID занят, stage zero не решает, что существующая VM менее важна, и не ищет обходного пути: план просто останавливается. На несколько минут stage-zero процесс получает очень широкие возможности - root shell на Proxmox, доступ к шаблону, право создавать VM и публичный ключ будущего оператора. Называть этот этап «временным» и поэтому неопасным - примерно как считать строительный кран безопаснее лифта, потому что после стройки его увезут. Кран от этого не перестаёт быть краном, а несколько минут root-доступа не перестают быть несколькими минутами root-доступа. Перед запуском AI составляет не абстрактный обзор безопасности, а таблицу активов и разрешений:

Proxmox root shell

- Нужен скрипту: да, только на время bootstrap

- Не должен получать: постоянный внешний доступ

approved template

- Нужен скрипту: read/clone

- Не должен получать: право удалить или изменить template

VMID/IP inventory

- Нужен скрипту: read

- Не должен получать: право автоматически освобождать конфликты

SSH public key

- Нужен скрипту: read

- Не должен получать: приватный ключ

passwords Terrakube/AWX

- Нужен скрипту: нет на этапе VM

- Не должен получать: вывод в log или AI context

Terraform provider токен

- Нужен скрипту: ещё не нужен

- Не должен получать: создание «на будущее»

Из этой таблицы следует несколько простых правил: скрипт не принимает пароль Proxmox аргументом и не пишет учётные данные в список процессов, приватный ключ SSH остаётся на рабочей машине, а на гипервизор передаётся только публичный. Логи stage zero содержат роль, VMID, этап и результат, но не дамп переменных окружения, тогда как root-доступ после появления bastion ограничивается новым путём оператора, и временное исключение либо закрывается, либо остаётся явным Open Question. Просим AI провести ревью:

Найди в stage-zero.sh места, где входные данные попадают в команду, лог или имя объекта. Для каждого укажи проверка и возможный разрушающий эффект. Отдельно найди учётные данные, которые скрипту не нужны. Не добавляй secret manager и новые продукты.

Такой запрос ловит scope creep лучше фразы «проверь безопасность»: например, Proxmox API токен вообще не нужен скрипту, который работает через локальный qm, и раз он не нужен, создать его заранее - значит расширить поверхность учётных данных, не получив взамен ни единого полезного действия. Stage-zero каталог на Proxmox-хосте тоже не становится постоянным складом: после успешного завершения сохраняем проверенный скрипт в Git, факты сборки и инвентаря - в документах, а временный env и публичный ключ удаляем или оставляем только по отдельному решению. Источником истины не должен быть /root/vibecode-stage-zero на одном сервере, о котором знает только shell history автора.

Старый код считал: если qm status "$VMID" успешен, нужная VM уже существует. Это не идемпотентность. Это узнавание человека исключительно по номеру стула.

VMID мог принадлежать другой VM; клонирование могло завершиться, а следующий qm set - упасть; VM могла существовать с правильным именем, но быть остановленной. Поэтому обновлённый скрипт:

проверяет, что шаблон существует и действительно имеет template: 1;

при существующем VMID сверяет имя с ожидаемой ролью;

отказывается менять конфликтующий объект;

повторно применяет безопасные настройки cloud-init и размеров;

не переносит существующий NIC на другой bridge автоматически;

запускает остановленную VM;

ждёт ответа guest agent и только после этого возвращает 0.

Ключевой фрагмент:

TERMINAL

ACTUAL_NAME="$(qm config "$VMID" | awk -F': ' '$1 == "name" { print $2 }')"

if [[ "$ACTUAL_NAME" != "$EXPECTED_NAME" ]]; then

echo "VMID $VMID belongs to '$ACTUAL_NAME', expected '$EXPECTED_NAME'; refusing" >&2

exit 1

fi

Существование объекта - лишь начало согласования состояния, и критерий готовности остаётся функциональным: guest agent ответил, VM получила ожидаемую сеть, SSH доступен по правильному пути. Копируем скрипт, локальный env и публичный ключ на выбранный Proxmox-хост через подтверждённый путь восстановления. На самом хосте:

TERMINAL

cd /root/vibecode-stage-zero

set -a

source ./stage-zero.env

set +a

./stage-zero.sh \

--role bastion \

--vmid 100 \

--ip 192.168.50.5 \

--cores 2 \

--memory 2048

Значения принадлежат учебной подсети, тогда как в своей среде читатель использует утверждённую строку inventory; команда не копируется до сверки VMID, адреса и шаблона на конкретном хосте.

Скрипт заканчивает работу только после qm agent ping. Дальше человек проверяет путь с рабочей машины:

TERMINAL

ssh ubuntu@192.168.50.5

Внутри bastion:

TERMINAL

cloud-init status --long

hostnamectl --static

ip -br address

ip route

getent hosts example.com

systemctl is-active qemu-guest-agent

Затем проверяем, что bastion выполняет архитектурную роль, а не просто отвечает по SSH: с него доступен адрес управления Proxmox по нужному порту, а публичный доступ к административному UI не создавался. Если VPN ещё не настроен, путь восстановления остаётся записанным до отдельной проверки. Не закрываем единственную работающую дверь сразу после установки новой, пока не попробовали открыть новую снаружи.

Представим, что VMID 101 уже занят старой тестовой VM. Первый вариант агента предлагает удалить её и повторить команду, ведь роль Terrakube должна использовать именно 101 из плана.

ИНЖЕНЕР

Инженер: Стоп. План не является доказательством права на удаление фактического объекта. Покажи qm config 101, источник записи inventory и дату последней проверки. До выяснения владельца не предлагай qm destroy, reuse или rename.

Получаем stage-zero incident:

•Антипаттерн:считать запланированный VMID свободным без проверки live state

Симптом: скрипт обнаруживает имя, не совпадающее с terrakube -01.

Неправильное исправление: удалить конфликтующий объект ради соответствия плану.

Правильное исправление: установить владельца VM , выбрать другой ID либо оформить отдельное удаление.

Предохранитель: сверка inventory с qm list перед запуском и fail - closed поведение скрипта.

Жёсткий отказ лучше «умной» коррекции: stage zero работает с тремя ресурсами, и нет причин оптимизировать три минуты проверки ценой чужой VM. Другая ситуация: qm clone завершился, VM появилась с правильным именем, но настройка cloud-init упала из-за неверного пути к публичному ключу, и при повторном запуске нельзя ни считать задачу готовой, ни удалять клон.

TERMINAL

VMID exists: yes

name: terrakube-01

status: stopped

cloud-init drive: inherited

SSH public key file: missing

guest agent: unavailable

Исправляем путь и повторяем ту же команду stage zero: скрипт подтверждает идентичность, повторно задаёт cloud-init, размеры и сеть, запускает VM и ждёт агента. Это практическая идемпотентность, при которой повторный запуск сходится к ожидаемому состоянию после частичного сбоя, тогда как фраза «VM уже существует, пропускаем» была бы ложным зелёным статусом из второй главы, только теперь стоимость ошибки измеряется виртуальными машинами.

После проверки bastion создаём остальные VM тем же скриптом, но по одной, причём команда выполняется на том Proxmox-хосте, где существует нужный шаблон, а не на том, где удобнее сидеть. С рабочей машины можно пройти через bastion с ProxyJump, но stage-zero.sh всё равно запускается на гипервизоре.

TERMINAL

./stage-zero.sh \

--role terrakube --vmid 101 --ip 192.168.50.6 \

--cores 4 --memory 8192

./stage-zero.sh \

--role awx --vmid 102 --ip 192.168.50.7 \

--cores 4 --memory 8192

Между командами проверяем конкретную VM и не создаём обе сразу, для того чтобы потом не разбираться, какая из них не получила сеть. Для каждой подтверждаем cloud-init, размер диска, флаги CPU, guest agent, SSH и перезагрузку. CPU_TYPE=host передаёт гостю возможности физического CPU, но ограничивает простую живую миграцию между несовместимыми хостами, и это осознанный компромисс среды с локальным хранилищем. Необходимые флаги проверяем до установки сервисов: если они отсутствуют, исправляем конфигурацию VM, а не ждём загадочного Illegal instruction внутри pod.

Terrakube и AWX в учебной архитектуре живут каждая в собственной небольшой K3s-инсталляции, что не делает их частью основного Kubernetes-кластера: их домен отказа - отдельные VM, а основного кластера ещё не существует. Сначала фиксируем контракт K3s:

• approved and pinned K3s version;

• Traefik disabled

, если продукт требует другой ingress ;

• kubeconfig доступен только оператору bootstrap ;

• узел в статусе Ready;

• systemd unit enabled;

• reboot test passed.

Не выполняем слепо curl | sh из ответа AI, а скачиваем официальный установщик, сохраняем digest, просматриваем источник и URL, передаём закреплённую INSTALL_K3S_VERSION и только затем запускаем. После установки:

TERMINAL

sudo systemctl is-active k3s

sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes -o wide

sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods -A

K3s active не доказывает готовность продукта, но без узла в статусе Ready продолжать бессмысленно. Helm chart меняется, поэтому вместо просьбы вспомнить values из обучающих данных получаем их из выбранной версии:

TERMINAL

helm repo add terrakube-repo https://charts.terrakube.io

helm repo update

helm search repo terrakube-repo/terrakube --versions

helm show values terrakube-repo/terrakube \

--version '<approved-chart-version>' > /tmp/terrakube-defaults.yaml

Передаём агенту chart version, defaults и ограничения:

Подготовь минимальный override для отдельной Terrakube VM. Используй только ключи из переданного helm show values. Отметь обязательные domains, persistence, database/auth и ingress. Не генерируй реальные пароли. Secret values обозначь именами inputs и исключи из diff. Сначала выполни helm template и перечисли persistent resources и публичные endpoints.

Инженер генерирует учётные данные, сохраняет их в менеджере паролей и создаёт Kubernetes Secret, не передавая агенту открытый текст; сам override хранится в Git только без значений, а рендер проверяется до установки:

TERMINAL

helm template terrakube terrakube-repo/terrakube \

--namespace terrakube \

--version '<approved-chart-version>' \

--values terrakube-values.yaml > /tmp/terrakube-rendered.yaml

rg -n 'LoadBalancer|NodePort|host:|storageClass|password' \

/tmp/terrakube-rendered.yaml

Поиск не доказывает безопасность, но заставляет увидеть сетевую доступность, persistence и подозрительные значения, вписанные прямо в манифест; только после этого выполняется helm upgrade --install --wait. Критерии готовности для Terrakube:

• K3s Ready после reboot;

• workloads Terrakube Running/Ready;

• API health отвечает через внутреннюю точку подключения;

• UI доступен через operator path, но не из public internet;

• persistence location записан;

• admin login проверен человеком, пароль не появился в логе;

• chart version и override сохранены;

• backup Open Question зафиксирован до первого настоящего state

Один Terrakube на одной VM не становится HA от того, что внутри есть Kubernetes, и пока восстановление не проверено, management plane восстанавливается пересборкой и отдельно сохранёнными данными. Для AWX агент тоже не пишет манифест по памяти: выбираем и фиксируем версию AWX Operator, берём upstream kustomization этой версии и сначала рендерим:

TERMINAL

kubectl kustomize ./awx-operator > /tmp/awx-operator-rendered.yaml

rg -n 'image:|namespace:|serviceAccount|ClusterRole' \

/tmp/awx-operator-rendered.yaml

После применения ждём CRD и контроллер оператора, и только затем создаём минимальный AWX custom resource. AI получает CRD и схему выбранной версии:

Подготовь AWX CR для одной internal management VM. Не публикуй LoadBalancer и не выводи admin password. Покажи persistence, service type, resource requests и предположения об именах узлов. Диагностику отдели от install.

Проверка идёт слоями:

TERMINAL

kubectl get crd awxs.awx.ansible.com

kubectl -n awx rollout status deploy/awx-operator-controller-manager

kubectl -n awx get awx,pods,svc,pvc

kubectl -n awx get events --sort-by=.lastTimestamp | tail -n 30

Затем проверяем API и UI через внутреннюю точку подключения. Пароль администратора читает человек из Secret и переносит в менеджер паролей, причём значение не вставляется ни в чат, ни в журнал инцидентов, ни в отчёт о завершении: AI достаточно фактов secret exists, login passed и rotation pending|done. AWX готова, когда UI и API отвечают, синхронизацию проекта можно настроить, среда исполнения определена, границы доступа записаны и перезагрузка VM не уничтожает сервис. Зелёный pod оператора - только начало: оператор может быть здоров и прекрасно наблюдать за сломанным экземпляром AWX.

До установки продуктов скрипт stage zero работал только с публичным ключом SSH, тогда как Terrakube и AWX добавляют учётные данные администратора, пароли баз, ключи подписи и шифрования, учётные данные репозитория и будущий токен провайдера Proxmox. Если свалить всё в один values.yaml ради удобства bootstrap, management plane начнёт жизнь с утечки, которую пока некому обнаружить. Разделяем три формы данных:

• secret material - само значение; генерируется и хранится вне Git и AI context ;

• secret reference - имя Kubernetes Secret, credential field или путь; может быть в коде;

• secret fact - present , версия, дата ротации, проверенный consumer ; попадает в

handoff .

Диалог с агентом строится без значений:

ИНЖЕНЕР

Инженер: Chart требует databasePassword, encryptionKey и initial admin credential. Подготовь secret contract: имя, потребитель, способ создания, проверка наличия и ротация. Значения не генерируй и не проси.

AI

Агент: При рендере использую ссылки на заранее созданные Secret. Проверю наличие и готовность потребителя, но не буду читать .data или декодировать Secret. Initial password после login должен быть заменён либо помещён в password manager согласно принятой процедуре.

Команда вроде kubectl get secret ... -o yaml не передаётся AI целиком, поскольку для проверки достаточно метаданных:

TERMINAL

kubectl -n awx get secret '<admin-secret-name>' \

-o custom-columns='NAME:.metadata.name,TYPE:.type,CREATED:.metadata.creationTimestamp'

Если человеку нужно извлечь первоначальный пароль, он делает это в своём терминале, не записывая ни команду, ни результат в общий лог, затем проверяет вход и сообщает агенту только login passed. Модель не становится безопасным хранилищем от того, что очень убедительно обещает забыть значение. Предохранитель записываем в ai_initial.md только если общего запрета ещё нет, и не переписываем контракт ради каждого нового имени Secret: конкретная карта учётных данных принадлежит регламентам и решениям, а не системному промпту.

Продукты управления разворачивают много компонентов, и у каждого свой персональный способ сообщить, что что-то не так, поэтому просьба «вот все логи, найди ошибку» быстро сжигает контекст и почти гарантированно возвращает уверенный, но выдуманный диагноз. Используем процесс второй главы:

OUTPUT

Expected: AWX instance becomes Ready within agreed timeout.

Observed: operator Ready; postgres pod Pending; AWX service absent.

Last change: first AWX CR applied.

Evidence: pod status, describe Pending pod, recent events, PVC/storage state.

Unknown: whether default storage class can bind requested PVC.

AI получает роль исследователя и не предлагает переустановить K3s. Если pod в состоянии Pending, сначала смотрим describe и events, ведь логи незапущенного контейнера ничего не сообщат: процесс ещё не существовал. Проверка идёт от планировщика и хранилища к приложению, а после того как исправление найдено, в журнал инцидентов попадает не километровый вывод, а причина и предохранитель: например, явный storage class в утверждённых values и предстартовая проверка динамического выделения томов до установки. Большой продукт не отменяет дисциплину свидетельств.

Заключительная проверка stage zero - повторяемость без разрушения, поэтому снова запускаем stage-zero.sh для каждой роли с теми же параметрами. Ожидаем:

• конфликтов идентичности нет;

• clone не выполняется повторно;

• safe settings сходятся к тем же значениям;

• остановленная VM запускается;

• guest agent отвечает;

• NIC не получает новый MAC при каждом rerun ;

• сервисы после reboot остаются доступны.

Затем отдельно рендерим и повторно применяем закреплённые установки Terrakube и AWX: helm upgrade --install не должен терять данные, а повторный apply манифестов AWX - пересоздавать persistence. Если второй запуск безопасен только при условии «не запускайте его второй раз», у нас не bootstrap, а церемония. До первого Terraform state проводим ещё одну архитектурную проверку: что произойдёт, если завтра исчезнет VM с Terrakube или VM с AWX? Пока реальных управляемых ресурсов нет, ответ можно проверить почти бесплатно. После сотни VM эксперимент станет заметно эмоциональнее. Для каждой системы создаём контракт восстановления:

bastion

- Что является кодом: stage-zero config и OS baseline

- Что является данными: authorised keys, operator config

- Минимальный recovery result: SSH path восстановлен

Terrakube

- Что является кодом: pinned chart и overrides

- Что является данными: database, workspace/state, encryption material

- Минимальный recovery result: существующий state читается без создания ресурсов

AWX

- Что является кодом: operator/CR, project config

- Что является данными: база, метаданные учётных данных, inventory

- Минимальный recovery result: синхронизация проекта и read-only job работают


В этой главе полноценный конвейер резервного копирования ещё может отсутствовать, но граница данных обязана быть известна до того, как Terrakube получит единственную копию state. Фраза «VM можно пересоздать» верна только для кода. Пустая новая Terrakube прекрасно запустится и ничего не будет знать о созданной инфраструктуре. AI получает задачу pre-mortem:

Предположи полную потерю каждой management VM по отдельности. Используй только известную архитектуру. Для восстановления перечисли code artifact, data artifact, credential dependency и способ проверки. Не утверждай, что backup существует, если restore ещё не выполнен.

Результат маркируется честно: rebuild tested, backup configured и restore tested - три разных состояния. Снимок VM не равен согласованному на уровне приложения бэкапу. Наличие файла бэкапа не равно возможности его расшифровать. Успешное восстановление процесса не равно доступности workspace и истории заданий. Первый же безопасный рубеж перед Terraform звучит так: установка Terrakube воспроизводима, место её данных известно, задача на бэкап заведена, а риск потери state явно принят до отдельного учения по восстановлению. Для учебного стенда это допустимый долг. Невидимым он быть не имеет права.

В runbooks.md появляется пока короткий порядок: как заново создать management VM из шаблона, какую закреплённую установку применить, где искать бэкап данных и какой read-only запрос подтверждает восстановление. Команду terraform apply при проверке восстановления не используем: потерю control plane не следует проверять созданием ещё одной инфраструктуры. В architecture.md Current state меняется впервые заметно: существуют bastion, VM с Terrakube и VM с AWX, а внутри двух management VM работают отдельные K3s; в Target state остаётся основной кластер, которого ещё нет. В docs/decisions.md фиксируем зоны ответственности stage zero, место запуска скрипта, размеры, компромисс по CPU, закреплённые версии, внутренние точки подключения и место хранения учётных данных без значений.

В incidents.md сохраняем уроки о конфликте VMID и частичном создании, а также фактический инцидент установки, если он произошёл; в runbooks.md появляются проверки повторного запуска и восстановления, а в current-task.md остаются бэкап и восстановление management plane и следующий шаг - первые VM под управлением Terraform. Команда на завершение:

ИНЖЕНЕР

Закрой stage-zero сессию. Покажи diff кода и документов. Для каждой VM перечисли идентичность, размеры, Proxmox-узел, template ID и результаты проверок без секретов. Для Terrakube и AWX укажи pinned versions, persistence, внутренняя точка подключения, reboot result и незакрытые backup questions. Подтверди безопасный повторный запуск stage-zero.sh, отсутствие Terraform и автоматически удалённых VM.

FILE stage-zero.sh

- bastion-01: SSH path and recovery access verified.

- terrakube-01: VM, K3s and internal API/UI verified after reboot.

- awx-01: VM, K3s, operator and AWX instance verified after reboot.

- stage-zero rerun: no clone, no MAC change, guest agents healthy.

- credentials: stored outside Git and AI context.

- known debt: management-plane restore drill remains open.

- Terraform resources created: none.

- next safe step: configure remote state and plan first managed VM.

Мы решили bootstrap paradox, не делая вид, что ручного этапа не было: три VM появились через маленький ограниченный скрипт, где bastion дал контролируемый путь, Terrakube - будущее место запуска и state Terraform, а AWX - будущую точку автоматизации конфигурации. Все три всё ещё требуют бэкапа и эксплуатационной дисциплины; слово management plane не выдаёт им бесплатную отказоустойчивость. AI помог разобрать скрипт, обнаружить ложную идемпотентность, построить путь для частичного отказа, читать chart выбранной версии и собирать свидетельства, тогда как человек проверил живую идентичность, нажал Enter на mutating-командах и сохранил учётные данные вне контекста модели.

В следующей главе Terraform впервые получит право создать VM. Именно одну - чтобы мы успели прочитать plan, увидеть replacement до apply и доказать post-apply no-op раньше, чем автоматизация размножит первую ошибку на весь будущий кластер.

Глава 6. Terraform : сначала одна VM

Terraform умеет создать восемь виртуальных машин почти так же легко, как одну. Именно поэтому начинать с восьми - плохая идея. Ошибка в bridge размножится восемь раз, неверный ID шаблона даст восемь неудачных клонов, а забытый replacement в плане превращает обычное изменение памяти в коллективную перезагрузку инфраструктуры.

В прошлой главе мы вручную подняли bastion, Terrakube и AWX, а теперь Terraform впервые получает право менять Proxmox - правда, не весь дата-центр, а сначала одну canary VM lb-01. На ней мы проверим provider, remote state, карту шаблонов, cloud-init, guest agent, сеть, сохранённый план и сходимость после Apply, и только после этого расширим конфигурацию до второго балансировщика, control plane и workers.

AI в этой главе не нажимает Apply: он читает код, строит карту изменений, ищет replacement и сравнивает план с архитектурой, тогда как человек подтверждает live inventory, запускает gate и принимает mutating action. Terraform не опасен. Опасна скорость, с которой он исполняет непрочитанное намерение. Граница первой версии узкая:

• идентичность VM : имя и VMID ;

• физический Proxmox-узел;

• source template на этом узле;

• CPU и RAM;

• network interface и bridge;

• cloud-init user, public key и статический IP;

• lifecycle VM.

Terraform пока не устанавливает пакеты, HAProxy, Kubernetes и прикладные сервисы: он создаёт предсказуемую Ubuntu VM и доказывает сходимость, а следующая глава передаст готовые хосты Ansible и AWX. Stage-zero VM при этом не импортируем «заодно», поскольку у bastion, Terrakube и AWX отдельный владелец и собственный контракт восстановления; принятие их под управление потребует своей сессии с import, неразрушающим планом и проверкой повторного запуска. Сегодня область задачи - новые VM кластера.

Перед первым планом полезно выкинуть из головы школьное определение «Terraform создаёт инфраструктуру»: оно верное примерно как «автомобиль перемещает человека» - начать можно, доехать не всегда. В работе одновременно существуют три версии платформы:

Конфигурация - что написано в HCL.

State - что Terraform считает принадлежащим ему.

Реальность - что сейчас находится в Proxmox.

terraform plan пытается вычислить переход от второй версии к первой, предварительно посмотрев на третью. Если state потерян, Terraform может предложить создать уже существующую VM; если VM поправили руками, он увидит дрейф; если provider не умеет корректно прочитать поле, diff будет появляться после каждого Apply. А если инженер посмотрел только на зелёную надпись Plan, то все три правды торжественно объявляются одной и той же. Ненадолго.

Именно здесь AI действительно полезен, поскольку способен сравнить HCL, адреса в state, live inventory и архитектурную карту, но сначала ему нужно запретить смешивать факты:

Составь таблицу из четырёх колонок: объект, declared in code, present in state, present in Proxmox. Не считай совпадением только одинаковое имя: сравни VMID, узел и IP. Не предлагай import, create или destroy, пока не покажешь расхождения.

У такого запроса есть важная деталь: сначала диагностика, потом действие. Если попросить «синхронизировать Terraform с Proxmox», агент вполне способен выбрать один из трёх технически рабочих способов и два организационно катастрофических. Учебный каталог намеренно невелик:

TERMINAL

labs/terraform/proxmox/

├── versions.tf

├── provider.tf

├── variables.tf

├── terraform.tfvars.example

├── main.tf

├── outputs.tf

└── modules/vm/

├── versions.tf

├── variables.tf

├── main.tf

└── outputs.tf

Корневой модуль отвечает за состав платформы: какие VM существуют, где стоят и какие идентичности получают, - тогда как дочерний модуль описывает одну VM. Это не попытка построить внутренний Terraform Registry имени себя: один повторяемый ресурс уже заслуживает небольшого модуля, а пять уровней абстракции пока не заслуживает никто. Отдельный versions.tf внутри modules/vm обязателен, потому что требование provider не наследуется как семейная фамилия: если дочерний модуль использует тип ресурса, он должен явно назвать источник provider, иначе Terraform по историческому соглашению попробует найти его в пространстве имён HashiCorp. Машина не «догадалась неправильно» - мы не сказали.

terraform.tfvars.example хранит структуру, но не секреты, а рабочий terraform.tfvars не становится документацией и не отправляется агенту целиком. Особенно забавно выглядят репозитории, где токен вынесли из HCL в tfvars, закоммитили tfvars и после этого торжественно написали в README: «секреты хранятся отдельно». Перед работой просим AI объяснить каждый файл одним предложением и показать поток данных: от объекта узла через for_each до proxmox_virtual_environment_vm. Если объяснение расползается на три экрана, значит, либо модуль уже сложнее задачи, либо агент не понял код. Оба варианта требуют остановки.

ИНЖЕНЕР

Прочитай ai_initial.md, текущий план, architecture, decisions, incident log и labs/terraform/proxmox. Работай read-only. Цель первой сессии - получить validated plan только для lb-01. Проверь provider source/version, backend, template map, VMID/IP uniqueness, secret inputs, replacement risks и criteria of done. Не выполняй terraform apply, не импортируй stage-zero VM и не добавляй Kubernetes configuration. Сначала перечисли неизвестные и предложи минимальный diff, который ограничит plan одной VM.

Агент : создан ли workspace в Terrakube, работает ли уже remote backend, совпадает ли имя организации, есть ли токен provider с минимальными правами, существует ли шаблон на выбранном Proxmox-узле и свободны ли VMID и адрес. Это не вопросы для литературного диалога: неверное имя узла backend остановит init, неверный шаблон проявится только во время клонирования, а занятый адрес может успешно пройти Terraform и сломать соседнюю систему. Ответы берём из живого состояния и документов, а не из памяти.

Terrakube уже существует, но пустой UI ещё не означает готовый backend. До Terraform проверяем:

• organization и workspace созданы;

• execution mode принят;

• provider учётные данные находятся в sensitive variables;

• state encryption/backup status записан;

• доступ к workspace есть через operator path;

• тестовый read-only run не раскрывает secrets.

proxmox_token_secret не лежит в terraform.tfvars.example: для локальной проверки он передаётся через TF_VAR_proxmox_token_secret, а в Terrakube - через sensitive workspace variable, так что AI видит имя входа, но не значение. Backend и provider - разные доверительные границы, поскольку Terrakube хранит state и запускает Terraform, тогда как токен Proxmox разрешает менять гипервизор. Один пароль «для всей автоматизации» удобен ровно до момента, когда нужно отозвать только одно право.

State содержит больше, чем принято ожидать от файла с таким скучным именем: в нём могут оказаться адреса, сгенерированные значения, идентификаторы provider и то, что конфигурация помечала как sensitive. Сама метка sensitive скрывает вывод в CLI, но не превращает state в зашифрованную чёрную дыру, поэтому доступ к backend есть доступ к инфраструктурной памяти. Отсюда четыре правила:

• state не прикладываем к промту «для контекста»;

• JSON plan сначала проверяем на sensitive data;

• backup state проверяем восстановлением, а не наличием файла;

• локальный state после эксперимента не оставляем неизвестным владельцем рядом с remote state .

Если terraform init сообщает о миграции, копировании state или смене backend, это отдельный стоп-момент, ведь ответ yes без понимания направления способен сделать локальный файл новой официальной реальностью. Агенту даём вывод команды и конфигурацию backend, но просим только объяснить варианты: миграцию подтверждает человек после бэкапа.

Валидация проверяет типы и наши условия, но она не звонит владельцу сети, не спрашивает у коллеги, зачем тот вчера руками поднял тестовую VM, и не выясняет, почему VMID 200 внезапно занят. Terraform свято верит собственному state ровно до момента, когда реальность решает иначе, так что перед canary собираем короткий пакет фактов:

TERMINAL

pvesh get /cluster/resources --type vm

qm list

ip neigh show

Команды запускаются там, где у оператора есть соответствующий доступ, а их вывод не нужно бездумно копировать в чат: имена пользователей, адреса и комментарии сначала обезличиваются. В локальном IDE-агенте достаточно дать путь к сохранённым артефактам и разрешить read-only анализ. Проверяем при этом не только отсутствие VMID: для lb-01 должны одновременно выполняться условия:

• имя не занято в Proxmox и DNS ;

• VMID свободен во всём кластере;

• IP не выдан DHCP и не закреплён за другим объектом;

• bridge существует на целевом хосте;

• VLAN разрешён на физическом пути;

• template действительно находится на этом узле;

• storage поддерживает выбранный тип clone ;

• хост имеет запас CPU , RAM disk .

ping не доказывает, что адрес свободен: узел может быть выключен, файрвол может отбрасывать ICMP, а запись ARP - устареть. Нужны IPAM или единая карта адресов, проверки DNS и DHCP и знание локальной сети, поскольку инфраструктура редко ломается оттого, что инженер проверил лишний источник. Хороший промт для предстартовой проверки выглядит так:

Вот анонимизированные live inventory, IP map и будущий объект lb-01. Найди конфликты идентичности, placement и capacity. Для каждого вывода укажи источник. Отсутствие записи не называй доказательством свободы адреса. Если данных недостаточно, сформулируй конкретную проверку и остановись.

Мы не просим «оцени готовность», поскольку такая формулировка приглашает написать уверенное резюме, тогда как нам нужны доказательства, а не настроение агента. До живого плана запускаем локальные проверки без backend:

TERMINAL

terraform fmt -recursive

terraform init -backend=false

terraform validate

Этот скучный шаг уже поймал реальные дефекты черновика: дочерний модуль не объявлял bpg/proxmox, из-за чего Terraform искал несуществующий hashicorp/proxmox; цепочка тернарных операторов в output-блоке была перенесена без скобок и не парсилась; file("~/.ssh/...") не раскрывал домашний каталог, так что понадобился pathexpand. Все три ошибки выглядели мелкими, и все три делали обещание «рабочий каркас» ложным. Предохранитель очевиден: глава не называет код рабочим, пока validate действительно не завершился успешно.

Здесь важно различать проверенную часть и непроверенную: terraform validate доказывает, что конфигурация синтаксически корректна и согласуется со схемой установленного provider, но не доказывает ни доступ к Proxmox, ни существование шаблона, ни права токена, ни успешное клонирование. Запись «Terraform проверен» в итогах сессии слишком широка; правильно будет «configuration validated locally with provider version X; backend and live plan not tested». Такая точность кажется занудством только до передачи работы другому инженеру, для которого одна эта фраза определяет, начинать ли с плана или сначала разгребать последствия Apply.

Provider фиксируется lock-файлом: ограничение ~> 0.96 разрешает более новые выпуски 0.x в пределах правила Terraform, а фактическая выбранная версия записывается в .terraform.lock.hcl. Обновление lock - отдельное ревью, поскольку provider является кодом, который отправляет команды в Proxmox, а не декоративной зависимостью. Каждая VM описана объектом:

TERMINAL

{

name = "lb-01"

vm_id = 200

ip = "192.168.50.10"

proxmox_node = "pve-01"

cores = 2

memory_mb = 2048

}

VMID и адреса не вычисляются по позиции: for_each индексирует ресурсы по имени, поэтому добавление worker-04 не заставляет существующие VM играть в музыкальные стулья. Правило это в первой версии применено не везде - worker'ы переехали на явную карту, а control-plane группа пока осталась на count с вычисляемыми адресами; ограничение записано в architecture.md как известное, и в главе 18 мы узнаем, сколько стоит слово «пока». В конфигурации есть проверки уникальности имён, VMID и адресов, но они ничего не знают о VM вне Terraform, поэтому перед планом всё равно сверяем qm list и общую карту адресов. Валидация защищает от конфликта внутри нашего кода; живая предстартовая проверка - от конфликта с реальностью.

Template IDs задаются для каждого Proxmox-узла:

TERMINAL

template_ids = {

pve-01 = 9000

pve-02 = 9001

}

Это продолжение инцидента прошлой главы: локальное хранилище не телепортируется, а один глобальный template_id выглядит проще и падает дальше от причины. В первой сессии оставляем в переменных только lb-01, а остальные списки пусты, после чего AI проверяет diff и подтверждает: планируемый ресурс ровно один, имена, VMID и адреса stage zero отсутствуют.

Это можно сделать временным canary-файлом переменных или отдельным входом workspace, но нельзя оставлять полный список и надеяться, что оператор не забудет -target. Сам -target полезен при восстановлении и локализованной операции, тогда как в качестве постоянного механизма раскатки он строит неполный граф и приучает Terraform видеть инфраструктуру кусками. Ограничиваем сам desired state сессии, а не зрение планировщика.

Инициализируем рабочий backend и создаём saved plan:

TERMINAL

terraform init

terraform validate

terraform plan -out=lb01.tfplan

terraform show lb01.tfplan

Apply без сохранённого плана запрещён, поскольку между просмотром и применением конфигурация или refresh могут измениться: мы применяем именно тот бинарный план, который прошёл ревью. Сохранённый план тоже имеет срок годности, и если после ревью кто-то изменил VM, шаблон или конфигурацию, старый план не становится исторической реликвией с правом на исполнение. Terraform обычно обнаружит часть расхождений при Apply, но операционное правило проще: изменился код или живой контекст - строим и проверяем новый план.

Не отправляем агенту бинарный файл и не просим «всё нормально?», а передаём текстовую сводку или JSON без sensitive-значений и прямо называем угрозы, которые он должен искать:

Проверь plan как ревьюер. Сначала назови количество create/update/delete/replace. Затем для lb-01 сверь VMID, узел, template ID, IP, bridge, CPU, RAM и source address. Отдельно перечисли unknown values и всё, чего план не доказывает. Не одобряй Apply одним словом.

Хороший ответ отделяет:

• Terraform собирается создать один resource ;

• delete/replace отсутствуют;

• template ID выбран по целевому узлу;

• secret помечен sensitive;

• фактическая свобода IP не доказана plan ;

• загрузка, SSH и guest agent будут проверены после Apply .

План показывает намерение Terraform, а не здоровье будущей VM: он не знает ни того, что кто-то вручную занял адрес пять минут назад, ни того, поднимется ли гость после клонирования. Чтобы ревью не превращалось в равномерное чтение сотни строк, сортируем изменения по риску.

Красные: delete, replace, смена VMID, целевого узла, шаблона, диска или сетевой идентичности. Они блокируют Apply до отдельного объяснения.

Жёлтые: CPU, RAM, tags, startup order, timeout и поля с provider-computed values. Они могут быть штатными, но требуют понимания простоя и поведения provider.

Зелёные: ожидаемые create в утверждённой области, если идентичность и placement совпали с архитектурой. Зелёный не означает «не читать»; он означает, что причина уже известна.

AI получает это правило прямо в промте, иначе он часто тратит половину ответа на косметическое поле и одной строкой сообщает, что VM будет заменена: языковая модель любит объяснять то, что легко объясняется. Разбор по риску устроен наоборот - сначала самое дорогое. Полезный формат ответа:

TERMINAL

Verdict: BLOCKED / READY FOR HUMAN APPROVAL

Scope: 1 create, 0 update, 0 delete, 0 replace

Red findings: ...

Yellow findings: ...

Unknowns outside plan: ...

Required live checks: ...

Слово READY здесь не делегирует решение, оно означает лишь, что автоматическая проверка не нашла оснований остановиться, а последнее подтверждение остаётся у оператора. Так же и stop_on_destroy = true сначала остановит VM перед уничтожением, но самого уничтожения не запретит: название звучит успокаивающе, хотя это порядок операции, а не бронежилет. Перед Apply запускаем labs/scripts/tf-plan-replace-gate.sh:

TERMINAL

../../scripts/tf-plan-replace-gate.sh lb01.tfplan

Скрипт читает terraform show -json через jq и блокирует любое действие, содержащее delete, а поскольку replacement в JSON обычно выглядит как сочетание create и delete, gate ловит его независимо от того, как красиво это нарисовано в выводе для человека. Сам gate не решает, что replacement никогда не допустим: он требует отдельного решения о том, почему ресурс заменяется, где данные, какой простой, как откатывать и кто подтвердил. Для первой VM допустим только create.

Представим, что план показывает изменение сети или cloud-init, которого инженер не ожидал. Быстрый совет - добавить поле в lifecycle ignore_changes. План станет зелёным немедленно. Это лечение сигнала путём отключения датчика: ignore_changes иногда нужен - например, когда диск сознательно растёт отдельной процедурой, а provider Terraform трактует изменение опасно, - но каждое игнорируемое поле означает передачу владения наружу, и у него должны быть другой источник истины, регламент и проверка на дрейф.

Старый учебный модуль игнорировал network_device, disk, initialization и cpu целиком, и для production-репозитория это выросло из реальных инцидентов с заменой ресурсов, тогда как для нового курса такая основа скрыла бы ошибки ещё до первой VM. Мы удалили общий список игнорируемых полей и будем возвращать отдельное поле только после конкретного доказанного конфликта lifecycle.

ИНЖЕНЕР

Инженер: Не скрывай diff. Объясни, какое поле ForceNew, почему изменилось и кто должен им владеть. Предложи fix в конфигурации или отдельный ADR для передачи владения. До этого Apply заблокирован.

Тот самый доказанный конфликт нашёлся через две недели, и оказался он ровно про диск: provider трактовал любое изменение disk как замену ресурса. Не увеличение - замену: снести VM и создать заново. Для шаблонной машины это ничего не значит. Для машины с данными это катастрофа, оформленная как обычный apply.

Агент предложил очевидное - вынести disk в ignore_changes, а рост диска проводить отдельной процедурой. И сразу назвал цену:

Оговорка, которую надо записать до применения, а не после. Поле под ignore_changes перестаёт быть управляемым из Terraform, но продолжает выглядеть управляемым: значение в .tfvars останется на месте, и plan будет показывать No changes независимо от того, совпадает оно с реальностью или нет. Это не «Terraform молчит, потому что всё хорошо». Это «Terraform молчит, потому что ему запретили смотреть». Предлагаю вместе с ignore_changes завести проверку дрейфа: раз в сутки сравнивать фактический размер диска с записанным в .tfvars и ругаться на расхождение.

Первая половина предложения была принята за минуту. Вторая - нет.

• Проверку потом. Сейчас есть три задачи важнее, а расхождение мы и так заметим: диск растёт руками, руками же и обновим переменную.

Решение разумное. Так действительно рассуждают, и в тот день оно не стоило ничего. Записываем его целиком - вместе с обоснованием и вместе с отложенной проверкой - в docs/decisions.md и идём дальше.

Счёт придёт в главе 16. Диск MinIO вырастет, переменную никто не обновит, terraform plan покажет No changes, и это будет выглядеть как «всё в порядке» ровно до момента, когда выяснится, что код и железо разошлись на сто гигабайт. Проверка дрейфа, отложенная здесь на «потом», - единственное, что поймало бы расхождение в тот же день.

Заметьте, что в этом обмене никто не ошибся в технике. Агент предложил верное решение и верную к нему страховку. Инженер принял первое и отложил второе - по причинам, которые в тот момент были настоящими, а не ленивыми. Было бы удобно написать эту сцену так, чтобы инженер выглядел глупым; но он не глуп, он занят, и именно так накапливаются долги, за которые платят через десять глав. Единственное, что можно было сделать иначе, - записать отложенную проверку как долг, а не как «потом». Мы её записали. Поэтому в главе 16 расследование займёт не день, а десять минут: там будет сказано, что мы сами себе это запретили и почему.

После живой предстартовой проверки, ревью и gate:

TERMINAL

terraform apply lb01.tfplan

AI не выполняет команду и не получает токен Proxmox: человек смотрит сводку ещё раз и подтверждает. Успешный код возврата означает только завершение операции provider, поэтому проверяем четыре уровня:

Delivery: resource есть в state и Proxmox.

Convergence: повторный plan не предлагает изменений.

Guest: agent отвечает, cloud-init завершён.

Function: VM доступна по SSH через bastion.

TERMINAL

terraform state show 'module.vm["lb-01"].proxmox_virtual_environment_vm.this'

ssh ubuntu@192.168.50.10 'cloud-init status --long'

ssh ubuntu@192.168.50.10 'systemctl is-active qemu-guest-agent'

Затем из каталога Terraform:

TERMINAL

../../scripts/tf-post-apply-noop-gate.sh

terraform plan -detailed-exitcode возвращает 0 при no-op, 2 при наличии diff и другой код при ошибке, причём скрипт трактует двойку как отказ. Если сразу после Apply остаётся diff, мы не добавляем остальные VM, а сначала выясняем, почему конфигурация не сходится.

Терминал закрылся, VPN упал, runner Terrakube умер посередине клонирования. Первое человеческое желание - запустить Apply ещё раз, и первое агентское желание часто совпадает с человеческим, потому что агент учился на людях. Сначала выясняем четыре вещи:

завершён ли run в Terrakube;

удерживается ли state lock;

появился ли resource в state;

существует ли VM в Proxmox и в каком она состоянии.

Повторный план допустим после проверки блокировки и refresh, тогда как ручные state rm, import и force-unlock не являются универсальным «починить Terraform»: force-unlock безопасен только тогда, когда доказано, что никакой run больше не пишет state. Два писателя с одним backend - уже не автоматизация, а конкурс альтернативной истории. Промт для инцидента:

Apply оборвался после сообщения <последняя строка>. Работай только диагностически. По статусу run'а, информации о блокировке, state list и живому inventory построй хронологию. Не предлагай force-unlock, import, state rm или новый apply, пока не определишь владельца VM и не докажешь отсутствие активного writer.

Если VM есть в Proxmox, но отсутствует в state, мы не удаляем её автоматически и не импортируем по одному совпавшему имени, а сверяем VMID, MAC, время создания, шаблон и журнал задач, после чего выбираем путь восстановления и записываем решение в журнал инцидентов. Главное правило хирургии по state: сначала бэкап, затем команда state с точным адресом, затем план. И никакого массового state rm по совету из ответа, где агент ни разу не произнёс имя workspace в backend.

Только после no-op на canary возвращаем lb-02, три control plane и три workers, а перед планом AI сравнивает список с архитектурной матрицей отказов: на двух физических хостах один из них неизбежно получает два участника etcd. Это известное ограничение, а не ошибка Terraform. Проверяем таблицу:

VMID свободен

- Источник: Proxmox live inventory

IP свободен

- Источник: единая IP map и network check

template существует

- Источник: per-node template map

placement соответствует плану

- Источник: architecture/failure matrix

размеры помещаются на хост

- Источник: Proxmox capacity

name уникален

- Источник: Terraform check

Создаём новый сохранённый план и ожидаем семь create и ноль изменений lb-01: если canary внезапно обновляется, расширение останавливается. В исходном проекте workers однажды получали адреса через cidrhost(cidr, offset + count.index), и пока их было мало, формула выглядела безупречно, но при расширении она дошла до диапазона, где уже жили вспомогательные сервисы. Terraform успешно создал новые VM, после чего ARP решил провести собственный эксперимент с высокой доступностью.

Причина не в функции cidrhost - она честно вычислила то, что у неё попросили, - а в предположении, будто диапазон непрерывен и принадлежит только одному модулю, тогда как на самом деле поверх него уже работали чужие сервисы, о которых формула ничего не знала.

• Антипаттерн:IP -идентичность выводится из позиции в растущем списке.

• Симптом: новая VM конфликтует с существующим сервисом.

• Причина: формула не знает глобальную карту распределения адресов.

• Исправление: явный адрес у каждого узла.

• Предохранитель: проверка уникальности в коде плюс сверка с общей картой адресов до плана.

AI полезно попросить:

Смоделируй добавление ещё пяти workers и покажи все вычисленные IP. Сравни с reserved ranges. Если у тебя нет полной IP map, остановись и запроси её.

Способность остановиться здесь важнее способности быстро написать HCL. После gate на замену применяем сохранённый план, причём даже набор, состоящий из одних create, создаём с контрольными точками: сначала балансировщики, затем control plane, затем workers - отдельными проверенными прогонами или ограниченными изменениями конфигурации. Один Apply на всё технически возможен, но лишает нас удобной точки остановки при проблеме с шаблоном на втором хосте. После каждого слоя:

• ресурсы появились на ожидаемых хостах;

• guest agents отвечают;

• IP не конфликтуют;

• SSH проходит через bastion;

• post-apply plan no-op;

• current-task.md обновлён.

VM пока не становятся узлами Kubernetes: они остаются чистыми Ubuntu-хостами, готовыми к передаче AWX. К концу главы недостаточно понять, почему автор осторожен, - нужно самостоятельно пройти короткий цикл:

скопировать terraform.tfvars.example в незакоммиченный рабочий файл;

заменить учебные hostnames, VMID, IP и template IDs фактами своей площадки;

передать токен через sensitive environment/workspace variable;

выполнить fmt, init и validate;

получить план только для одной canary VM;

отдать AI обезличенный plan на risk review;

запустить replacement gate;

применить saved plan вручную;

проверить cloud-init, guest agent и SSH;

добиться post-apply no-op;

записать свидетельства и только потом расширять область.

Если пункты 8-10 невозможно выполнить на учебном Proxmox, глава всё равно даёт честную точку остановки - проверенную конфигурацию и разобранный план. Нельзя писать в отчёте, что VM создана, если вы только увидели строку will be created. В инфраструктуре будущее время особенно плохо подходит для раздела «результат». Для упражнения сохраняем не километры терминального вывода, а небольшой пакет свидетельств:

TERMINAL

terraform-version.txt

validate.txt

plan-summary.txt

replacement-gate.txt

guest-checks.txt

post-apply-plan.txt

session-summary.md

Секреты, полный state и бинарный сохранённый план в публичный репозиторий не попадают: пакет свидетельств должен позволить другому инженеру понять, что проверено, но не получить заодно ключи от площадки. Скриншот зелёного прогона Terrakube без адреса workspace и состава изменений красив, но практически бесполезен. Мы всё-таки строим инфраструктуру, а не галерею достижений.

Lock-файл зафиксировал фактический provider, а обновление provider способно изменить схему, значения по умолчанию и поведение refresh даже без diff в HCL, поэтому terraform init -upgrade не запускается «перед планом на всякий случай». Порядок отдельной сессии:

прочитать release notes между версиями;

обновить constraint/lock в отдельном diff;

выполнить init и validate;

получить plan без изменения HCL ресурсов;

расследовать каждый неожиданный diff;

применить только после canary/replacement review.

AI получает release notes и текущий модуль, а не вопрос «безопасно ли обновиться?»: без плана и живой схемы ответ на такой вопрос является литературным жанром. architecture.md получает фактические VM и их размещение, docs/decisions.md - явную идентичность, шаблоны по узлам, canary-раскатку и владение исключениями lifecycle, incidents.md хранит конфликт адресов как класс ошибки, а runbooks.md - разбор плана, gate на замену и проверку no-op после Apply.

Команда на завершение:

ИНЖЕНЕР

Закрой Terraform-сессию. Покажи сводку create/update/delete/replace применённого saved plan, результаты gate, functional checks и post-apply no-op. Перечисли provider version из lock, созданные VM, placement и открытые вопросы. Подтверди, что stage-zero VM не импортировались, secrets не попали в diff и Ansible ещё не запускался.

Хороший итог не ограничивается фразой «Apply successful»:

TERMINAL

- provider lock reviewed; configuration validated.

- lb-01 canary: create-only plan, gate passed, SSH/agent/cloud-init verified.

- post-apply plan: no-op.

- remaining LB/CP/workers: explicit VMID/IP/template placement applied in checkpoints.

- delete/replace: none.

- known limitation: two physical failure domains remain.

- next safe step: generate AWX inventory and bootstrap OS roles.

Terraform создал первый управляемый слой платформы и после Apply перестал предлагать изменения. Это важнее количества VM: система сошлась. AI помог найти синтаксические дефекты, проверить источник provider, читать план как модель риска и не глушить дрейф через ignore_changes, тогда как человек подтвердил live inventory и применил сохранённый план после gate. В следующей главе AWX получит inventory этих VM. Там автоматизация впервые войдёт внутрь операционной системы - и мы увидим, как одна совершенно правильная задача для шаблона способна выключить kubelet на всех живых узлах, если забыть спросить, к кому именно применяется роль.

Bepul matn qismi tugad.

Yosh cheklamasi:
12+
Litresda chiqarilgan sana:
28 sentyabr 2026
Yozilgan sana:
2026
Hajm:
421 Sahifa 19 illyustratsiayalar
Mualliflik huquqi egasi:
Автор
Yuklab olish formati: