Готовые проекты вместо стартеров: сборка настоящих открытых репозиториев - #37
Conversation
Демо-стартер (`api/demo_apps.go`) доказывает ровно одно: деплой случился.
Через несколько часов жнец его удаляет, и человек остаётся ровно там, где
был. У провайдеров VPS на этом месте стоит библиотека готовых решений — от
n8n до Битрикса, — потому что продукт, который человек оставит себе,
отвечает на тот же вопрос «мне нечего задеплоить» несопоставимо честнее.
Первая запись каталога — Hermes Agent: открытый автономный AI-агент с
терминалом, памятью и веб-дашбордом.
Каталог — замороженная переменная в коде (идиома `boxcatalog`/`profiles`),
а не таблица: запись реальна только тогда, когда её образ опубликован и
её compose-форма хоть раз отработала на VM, а деплой — ровно то событие,
которое двигает и то, и другое. Запись без digest'а образа видна в
каталоге и не ставится (409): кнопка «в один клик», ведущая на
несуществующий тег, падала бы внутри pull'а Portainer с ошибкой, которую
некому прочитать.
Установка не заводит нового рантайма. Решение раскладывается в обычные
first-class VM Applications — по одному на compose-сервис, — так что логи,
метрики, редактор переменных, рестарт и удаление работают с первого дня, а
человек со стороны не отличит приложение из каталога от сделанного руками.
Безопасность дашборда Hermes — не деталь оформления. Апстрим биндит его на
loopback именно потому, что он держит API-ключи, и с июньского ужесточения
отказывается стартовать на не-loopback адресе без провайдера аутентификации
(`--insecure` там больше ничего не значит). Поэтому установка сама
генерирует пароль и ключ подписи сессий, кладёт их шифрованными и отдаёт
пароль в ответе один раз: наружу выходит дашборд с аутентификацией, а не
открытый шелл. Ключи провайдера модели в payload операции не попадают —
только в `env_vars`, чтобы секрет не лежал в строке, которую читает аудит.
- backend/internal/solutions: каталог, валидация имени и параметров, тесты
на инварианты записи (один корневой сервис, публикуемый порт только у
primary, отсутствие коллизий env-ключей, никаких дефолтов у секретов)
- backend/internal/api/solutions.go: GET /solutions, GET /solutions/{slug},
POST /projects/{p}/environments/{e}/solutions/{slug}
- переменные и операция пишутся одной транзакцией: иначе возможен
контейнер без пароля, без которого он не стартует
- swagger перегенерён
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013CSUPisoFBFxh5E1vPHoED
…репозиториев
Витрина на пустом проекте предлагала три наших же стартера: Next.js, FastAPI,
статический сайт. Такая карточка доказывает ровно одно — что деплой
случился, — и доказывает это на репозитории, написанном так, чтобы наше
автоопределение сработало. Открыть её второй раз незачем, и через несколько
часов жнец её удаляет.
Вместо них — четыре настоящих открытых проекта (Excalidraw, IT-Tools,
Gitingest, DevDocs) и поле «вставьте ссылку на любой публичный
репозиторий».
Ключевое: каждая запись каталога — это РЕПОЗИТОРИЙ, а не опубликованный
образ. Карточка идёт обычным пользовательским путём: подключить публичный
репо → определить фреймворк → собрать у нас → задеплоить. Взять
`n8nio/n8n:2.34.2` было бы красивее в вёрстке и не доказывало бы ничего —
это наша платформа, запускающая чужую сборку.
У выбора есть цена, и она же — смысл. Настоящие репозитории это монорепы,
двухэтапные сборки, порт из переменной окружения и Dockerfile тремя
каталогами ниже. Там, где автоопределение промахнётся, карточка сломается
на глазах, и мы починим автоопределение — это дороже каталога, который его
никогда не касается. Обратная связь записана в
tasks/autodeploy-benchmark-50-oss.md: 50 открытых репозиториев, четыре
ступени (определилось / собралось / поднялось / отвечает), словарь причин
провала и порог, ниже которого каталог не расширяется.
Все четыре записи проверены: Dockerfile в корне, порт прочитан из него, а
не угадан по фреймворку (неверный порт даёт зелёный деплой и 502).
Записи v1 сознательно без состояния: приложение, созданное сборкой, берёт
спеку из git_repos, где тома нет, — проект с состоянием на диске молча
терял бы его при каждом передеплое. Это ограничение, а не вкусовщина, и оно
описано в пакете.
Жнец демо переучен: демо теперь — это репозиторий каталога (плюс старые
стартеры, у которых уже проставлен дедлайн, чтобы не осиротить их в чужих
проектах). Репозиторий, который пользователь вставил сам, демо не считается
никогда: он его выбрал, и ставить его выбор на таймер — другой продукт.
- backend/internal/solutions: каталог, разбор вставленной ссылки (браузерный
URL, clone URL, SSH-ремоут, голое owner/name), тесты
- GET /solutions, GET /solutions/{slug}, GET /git/parse-repo-url; отдельного
install-эндпоинта нет намеренно — деплой идёт существующим путём
- фронт: карточки из каталога вместо списка в файле + поле ссылки с
необязательной веткой (полно репозиториев с master по умолчанию)
- swagger перегенерён
Проверено: go build, go vet, go test ./internal/... зелёные. Фронт типами НЕ
проверен: npm ci в этом окружении упирается в приватный Nexus (401).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013CSUPisoFBFxh5E1vPHoED
…ой кнопкой Граница между облаком и VPS у нас почти стёрлась, значит каталог обязан работать и на VM — и не вторым каталогом, а тем же. Изучен Бегет: 59 приложений, поиск по названию, иконка, «Установить», затем «выбрать домен»; домен и Let's Encrypt — отдельным шагом в разделе «Сайты». Модель у них «приложение = образ, запечённый на VM», и её границы и есть наша возможность: собрать чужой исходник, приложить домен и БД внутри той же кнопки, положить результат в облако или в свою VM. Дизайн сводит три сценария (тык по картинке n8n с доменом и БД; «post…» → Postgres; URL произвольного репозитория в свой контур) к трём сущностям, ни одна из которых не знает про «облако против VPS»: запись каталога, нейтральная к рантайму, где единственное различие — поле delivery (image | source); цель — Environment, а рантайм выбирает рендерер уровнем ниже; и один резолвер строки, дающий каталог, ссылку и поиск по GitHub в одной выдаче. Отдельно — проверенный по коду список швов, которые мешают этому быть by design: доменов и TLS на VM-треке нет вовсе; у приложения из сборки нет тома; именованные тома на VM пинятся external, но никто их не создаёт; needs как декларации зависимости не существует; поиска по GitHub нет. Порядок работ идёт по швам, а не по экранам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CSUPisoFBFxh5E1vPHoED
Агрегатный compose пинит именованные тома `external: true` — чтобы свежий стек никогда не подсунул пустой том поверх живых данных. Но создавать их было некому: на чистой VM первый же деплой приложения с состоянием упирался в отсутствующий внешний том и падал ошибкой про неизвестный volume. Старые compose-приложения этого не замечали, потому что их тома достались от уже работавшей на машине нагрузки. Теперь список томов едет вместе с операцией деплоя, а воркер создаёт недостающие через Docker API Portainer. Идемпотентно по контракту самого Docker: создание тома с существующим именем возвращает его нетронутым, не пересоздаёт и не чистит, — поэтому вызов безопасно делать на каждом деплое, а не только на первом. Список берётся из рендерера (AuthoredNamedVolumes), а не выводится заново из compose-файла: рендерер и решает, какие тома внешние, и два независимых вывода рано или поздно разойдутся — а разойдутся они в виде упавшего деплоя на приложении с данными. Тест держит эти два места вместе: то, что пинит агрегат, ровно то и создаётся. Откат и adopt передают пустой список осознанно: там тома на машине уже есть (в adopt это вообще вся премиса сохранности данных). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CSUPisoFBFxh5E1vPHoED
Блокер: Jenkins не может запустить сборку, причина не в этом PR
Что проверено и исключено:
Чего не могу сделать сам: лог Jenkins недоступен — Что нужно, чтобы сдвинуть: кусок лога сборки #4 (или скрин страницы) либо доступ к Jenkins. Наиболее вероятные направления, судя по тому, что ошибка на уровне планирования сборки, а не выполнения: индексация ветки в multibranch-джобе, доступность агента/пода сборки, права джобы на PR-head. Со стороны содержимого PR оставшийся незакрытый пункт один и он записан в описании: фронт не прогнан через Generated by Claude Code |
Сборка PR-37 #4 падала не на 'this commit cannot be built', как считала прошлая сессия, а на стадии 'Go format check': gofmt -l показывал gitops-agent/internal/renderer/renderer_test.go. Стадия идёт до тестов, поэтому дальше пайплайн не доходил и выглядел как отказ запуститься. Not-tested: фронтовые стадии пайплайна (локально tsc и eslint зелёные).
Каталог отвечал только тем, кто уже знает, как называется запись. Новичок печатает «доска», человек за базой печатает «post», про вставляет ссылку — раньше два из трёх получали пустоту. Резолвер отвечает всем троим одним ранжированным списком: вставленная ссылка бьёт любое нечёткое совпадение (ссылка — это уже решение), ссылка на репозиторий каталога отдаёт проверенную запись с веткой и портом, а не голый репозиторий, каталог и управляемые ресурсы идут выше поиска, потому что у них есть проверенная спека сборки, а у находки — только звёзды. Поиск GitHub живёт за кешем: 30 запросов в минуту на весь кластер по IP источника, а интерактивное поле — самый быстрый способ их сжечь. Запросы короче трёх символов до поиска не доходят вовсе. Constraint: отказ поиска не роняет запрос — каталог остаётся, поднимается search_failed. Пустой список читается как «платформе нечего вам дать», а не как временная проблема наверху. Rejected: Redis в управляемых ресурсах — в k8s managed DB это Postgres, запись резолвилась бы на обоих рантаймах, а ставилась на одном. Not-tested: ни одна из четырёх записей каталога ещё не собиралась нашим пайплайном.
Блок ниже каталога умел ровно одно: принять ссылку на GitHub. Тот, кто не знает названия репозитория, и тот, кому нужна база, упирались в поле, на которое им нечего вставить. Поле теперь принимает что угодно и показывает ранжированный список от резолвера: проверенная запись каталога, управляемая база, репозиторий с поиска. У каждой строки честная плашка — «проверенный» значит, что спеку сборки кто-то проверял, «с GitHub» значит имя и звёзды, дальше пайплайн определяет сам. Запрос дебаунсится на 350 мс: у поиска GitHub 30 запросов в минуту на всю платформу, и каждое нажатие клавиши без задержки — прямой путь их сжечь. Управляемая строка ведёт на страницу баз, а не притворяется сборкой. Rejected: подставлять ?engine= в ссылку на базы — страница его не читает, получилась бы ложь в URL.
… тремя Консоль сама собирала последовательность: привязать репозиторий, заказать базу, запустить сборку. Это не решение, а порядок действий — и когда середина падала, разбирать половину установки приходилось клиенту. Теперь порядок живёт на сервере: POST /solutions/install линкует репозиторий, заказывает управляемую базу, если запись каталога объявила `needs: [postgres]`, и ставит первую сборку. Общие ядра вынесены, а не скопированы: linkGitRepo даёт те же дефолты и разбор installation_id, что и connect-repo, createManagedDatabase — ту же генерацию пароля и подстановку DATABASE_URL, что и ручной заказ базы. Вторая копия правил VM-трека молча разъезжается, а разъехавшись, выдаёт клиенту приложение, которое не видит собственную базу. Failure не откатывается: если база не заказалась, привязка остаётся, и ответ об этом говорит. Привязку клиент видит и может переиспользовать, а тихое удаление превращает чинимую половину установки в загадку. Constraint: имя базы выводится из имени приложения (`app-db` / `app`), потому что клиент, ставящий готовый проект, о нём мнения не имеет; префикс `db-` нужен, когда имя начинается с цифры — validatePgName требует букву. Rejected: заказывать базу до привязки репозитория — тогда падение привязки оставляет висящую базу, которую клиент в консоли не свяжет ни с чем. Not-tested: сборка реально не гонялась — тест проверяет строку builds в статусе queued, а не то, что Jenkins собрал образ.
Карточка сама держала порядок: linkRepo, потом trigger build, а при падении второго оставляла привязанный репозиторий и никакой сборки. Порядок переехал на сервер (POST /solutions/install), карточка теперь только называет приложение — имя с суффиксом остаётся на клиенте, иначе повторная установка того же проекта столкнётся с занятым именем. Каталожная строка передаёт только slug: спека сборки (ветка, порт, каталог, профиль) живёт в бэкенде, и консоль перестала возить её копию, которая может с ним разойтись. Вставленный репозиторий по-прежнему проходит детект порта на клиенте — там это единственное, что у нас про него есть.
Аттач кастомного домена на VM рендерил 443-vhost на путь /etc/nginx/certs/live/<host>/fullchain.pem, который до прихода оператора не существует. nginx на таком конфиге не стартует вовсе — падает весь ingress машины, а не один новый домен: соседние сайты уходят в 502 из-за чужого домена, который ещё даже не делегирован. VMExtraHost получает TLSReady. Пока сертификата нет, хост отдаётся по plain http на своё app:port — домен работает сразу, TLS доезжает потом. ACMEWebroot добавляет location /.well-known/acme-challenge/ во все listen-80 блоки, а редирект уезжает внутрь location / — серверный `return 301` отрабатывает в rewrite-фазе, до выбора локации, и съел бы http-01 челлендж целиком. Constraint: nginx не умеет условный include, поэтому «есть ли сертификат» решается на рендере, а не в контейнере. Not-tested: выдача сертификата и продление — отдельный шаг, dbwatcher пока TLSReady не выставляет, поведение прода не менялось.
…ется сам Домен привязывают раньше, чем на него делегирован DNS, и раньше, чем на http-01 вообще есть кому ответить: челлендж отдаёт тот самый vhost, которого ещё нет. Поэтому 443-блоки больше не едут в загрузочном конфиге — они доставляются отдельными файлами, а энтрипоинт кладёт в glob-включаемый /etc/nginx/tls.d только те, чей сертификат реально лежит на диске. Пустой glob для nginx не ошибка, и это единственное, что делает отсрочку безопасной. Тот же sync крутится по таймеру с reload, поэтому сертификат, приехавший после деплоя (или продлённый), включается без ре-рендера стека: у воркера нет способа посмотреть на файловую систему ВМ, значит решение обязано жить в контейнере. Битый блок не проходит nginx -t и просто не перезагружается — конфиг остаётся устаревшим, а не сайт мёртвым. Выпуск — сервис certbot рядом с ingress: появляется с первым доменом, исчезает с последним, отдельный сертификат на хост (общий SAN пришлось бы перевыпускать на каждый чужой аттач), падения не фатальны и повторяются раз в полчаса — это и укладывается в лимит LE 5 неудач на хост в час, и служит продлением. Rejected: считать наличие сертификата на рендере — воркер про диск ВМ ничего не знает, а у уже привязанных хостов это молча понизило бы рабочий TLS до http. Constraint: nginx не умеет условный include и не стартует при отсутствующем ssl_certificate — отсюда и tls.d, и отсрочка. Not-tested: живой выпуск LE на песочничной ВМ; проверено юнитами, включая прогон энтрипоинта настоящим /bin/sh с подменой только префикса путей и бинаря nginx.
…рена, а не вспомнена Формы (root Dockerfile / Procfile / подкаталог / ничего) взяты запросом к raw.githubusercontent.com по каждому пину: первичный список расходился с реальностью у трети записей — Stirling-PDF, gotify, uptime-kuma, n8n, mealie, linkding и другие не имеют Dockerfile в корне, хотя «по памяти» имели. Rejected: библиотеки и фреймворки (django, flask, starlette, vite, nuxt, kit, remix, hugo, tailscale) — сборка их корня не является подъёмом приложения, такие строки мерили бы шум, а не автоподниматель. Constraint: api.github.com с этой машины отвечает нестабильно, поэтому пины взяты через git ls-remote, а состав корня — через raw. Not-tested: сами ступени 2-4 ещё не гонялись, это следующий пункт очереди.
Ступень 1 гоняется через backend/cmd/autodeploy-detect, который зовёт тот же sourcedetect, что работает на загрузке исходников. Питоновская копия детекта согласилась бы с корпусом ровно там, где боевой код с ним расходится. Результат: 22/50. Найден настоящий баг: корневой Dockerfile-симлинк (vaultwarden, netdata) отбрасывается, потому что listTarGzEntries берёт только tar.TypeReg; снаружи дефект не виден, так как raw.githubusercontent разыменовывает симлинк и отдаёт 200. Constraint: ступени 2-4 через прод недоступны (нет кредов Console API, выписывать себе новые запрещено), а шаблоны Dockerfile живут в Jenkins shared library вне репозитория — такие строки помечены template_only, а не «провал». Rejected: версия «упёрлись в потолок maxEntries=500» — обе симлинк-записи лежат внутри потолка (позиции 28 и 271), проверено. Not-tested: сборка и подъём ни одного репозитория корпуса пока не гонялись.
| only add a dependency the run does not need. | ||
| """ | ||
| repos, cur = [], None | ||
| for line in open(path, encoding="utf-8"): |
Детектор аплоада отвечал «манифеста нет» там, где репозиторий говорит о себе прямо: корневой Dockerfile-симлинк, EXPOSE через переменную, порт в compose, go.mod и pom.xml. На корпусе из 50 открытых проектов ступень 1 выросла с 22/50 до 42/50; каждая правка — новый источник факта, а не новая догадка по стеку. Ранжирование на не-Dockerfile пути стало явным: маппинг compose (факт) → Procfile/railway.json (утверждение, что фиксированного порта нет) → дефолт фреймворка (конвенция). Rejected: угадывать порт по стеку там, где репозиторий его не заявляет — caddy, pocketbase, photoprism и n8n оставлены провалом сознательно. Constraint: шаблоны Dockerfile для репозиториев без своего живут в Jenkins shared library вне этого репозитория, поэтому ступени 2-4 локально не гонялись. Directive: имена фреймворков и дефолтные порты держать в синхроне с build-agent frameworkDefaultPort — иначе два пути платформы разойдутся. Not-tested: сборка и подъём приложений через прод-конвейер (нет кредов Console API, выписывать себе запрещено).
… пустую выдачу Два места, где подсказка врала о происхождении строки. Ссылка, вставленная руками, приходит без `from` и получала бейдж «проверенный» — тот же, что у каталожной записи с собранным нами build-spec. Теперь бейдж читает `kind`: каталог — «проверенный», поиск — «с GitHub», вставленная ссылка — «по ссылке». Поиск по GitHub при незаданном BUILD_AGENT_URL просто не выполнялся, а ответ шёл как `searched=true, search_failed=false` — то есть «мы искали и ничего нет» вместо «мы не искали». Локально это прятало весь сценарий «печатаю n8n». Проверено на локальной консоли (backend :18099 + build-agent :18098 против свежей console_demo): `n8n` даёт n8n-io/n8n первым с аватаркой, `post` — сначала управляемый PostgreSQL, потом GitHub, вставленная ссылка на gitbucket — одну строку «по ссылке». Not-tested: обработчик без билд-агента не покрыт go-тестом — тесты ResolveSolution требуют реальной БД.
Прогон на живой консоли, а не на API-заглушке: три сценария владельца прошли, и по дороге нашлись два места, где подсказка врала о происхождении строки — оба закрыты 34047d9. Отдельно записано, чего демо не доказывает: кнопка «Развернуть» не нажималась, установка уходит в Jenkins и требует прод-кредов.
main за 38 коммитов дописал в те же две ручки то, что ветка успела вынести в переиспользуемое ядро: квоты/шардирование баз и признак worker у репозитория. Переносить надо было не текст конфликта, а смысл — обе новые вещи легли внутрь createManagedDatabase и linkGitRepo, иначе установка готового проекта тихо теряла бы tier, shard и worker, которые видит обычная ручка. Rejected: оставить сторону main целиком — это откатило бы вынос ядра, ради которого ветка и существует. Constraint: docs/ — генерируемые, руками не сводились; пересобраны `swag init -g cmd/server/main.go --parseInternal -o internal/api/docs`. Directive: любая новая логика в CreateServiceDatabase/ConnectGitRepo идёт в ядро (createManagedDatabase/linkGitRepo), а не в тело ручки — иначе установка готового проекта снова разойдётся с ручным путём. Not-tested: worker-репозиторий и шардирование баз через живой стенд не гонялись — только сборка, go test ./... и tsc.
Что меняется
Витрина на пустом проекте предлагала три наших же стартера — Next.js, FastAPI, статический сайт. Такая карточка доказывает ровно одно: что деплой случился. И доказывает это на репозитории, написанном так, чтобы наше автоопределение сработало.
Вместо них — четыре настоящих открытых проекта и поле «вставьте ссылку на любой публичный репозиторий»:
Ключевое решение: репозиторий, а не образ
Каждая запись каталога — это репозиторий, а не опубликованный образ. Карточка идёт обычным пользовательским путём: подключить публичный репо → определить фреймворк → собрать у нас → задеплоить.
Взять
n8nio/n8n:2.34.2было бы красивее в вёрстке и не доказывало бы ничего — это была бы наша платформа, запускающая чужую сборку.У выбора есть цена, и она же — смысл. Настоящие репозитории это монорепы, двухэтапные сборки, порт из переменной окружения и Dockerfile тремя каталогами ниже. Там, где автоопределение промахнётся, карточка сломается на глазах — и мы починим автоопределение. Это дороже каталога, который его никогда не касается.
Проверенные факты
Все четыре записи проверены до того, как попали в каталог: Dockerfile в корне репозитория, порт прочитан из него, а не угадан по фреймворку. Неверный порт даёт зелёный деплой и 502 — симптом, который читается как «платформа сломана».
Что осознанно ограничено
Записи v1 без состояния. Приложение, созданное сборкой, берёт спеку из
git_repos, где тома нет, — проект с состоянием на диске молча терял бы его при каждом передеплое. Ограничение описано в пакете, тест на него есть; снимается вместе с поддержкой тома в build-спеке.Жнец демо
Демо теперь — это репозиторий каталога. Старые стартеры остаются в списке: у уже развёрнутых из них приложений проставлен дедлайн, и удаление записи осиротило бы их в чужих проектах.
Репозиторий, который пользователь вставил сам, демо не считается никогда: он его выбрал, и ставить его выбор на таймер — другой продукт.
Бенчмарк автоподнимателя
tasks/autodeploy-benchmark-50-oss.md— 50 открытых репозиториев, четыре ступени (определилось / собралось / поднялось / отвечает), словарь причин провала и порог, ниже которого каталог не расширяется за пределы проектов с корневым Dockerfile.После этого PR процент успеха автоопределения на чужих репозиториях — это напрямую процент успеха первого экрана. Такого числа у нас сейчас нет.
Состав
backend/internal/solutions— каталог, разбор вставленной ссылки (браузерный URL, clone URL, SSH-ремоут, голоеowner/name), тестыGET /solutions,GET /solutions/{slug},GET /git/parse-repo-url; отдельного install-эндпоинта нет намеренно — деплой идёт существующим путёмmasterпо умолчанию)Проверка
go build ./...,go vet,go test ./internal/...— зелёные, гейт покрытия OpenAPI проходитnpm ciв окружении сборки упирается в приватный Nexus (401). Требует прогонаtsc/lintна машине с доступом к рееструGenerated by Claude Code