Пишем плагин по шагам
Статья собирает рабочий плагин хаба с нуля: rgstr-lite, учебный плагин к реестру образов rgstr. Его можно скачать, прочитать и загрузить в свой хаб: rgstr-lite.tar.gz. Исходник — examples/_samples/rgstr-lite. Шаги ниже совпадают с комментариями «шаг 1…6» в коде.
Полная версия того же плагина — rgstr из каталога, о ней статья rgstr и rgstroperator в nkt. Справочник по протоколу и правилам — Как написать плагин и Плагины → Протокол.
Что получится
- раздел «Сводка» — версия rgstr, вход, число репозиториев и находки (вход выключен, реестр по HTTP);
- раздел «Репозитории» с кнопками «Теги», «Собрать мусор» и «Робот для конвейера»;
- скрытый раздел «Теги» с удалением тега — с подтверждением и сухим прогоном;
- робот: секрет один раз окну или сразу в конвейер — в
.envили ключом registry (@registry); - команды сценария
rgstr-lite gcиrgstr-lite robot …; - инструменты MCP
rgstr_lite_*для агентов.
Плагин — один файл Python на стандартной библиотеке, около 330 строк. Писать можно на любом языке: хабу нужен только HTTP-сервер.
Папка плагина
rgstr-lite/
README.md, README.en.md
deploy/
nkt-plugin.json пакет: имя, версия, порт, настройки — для установки на хост
nkt-plugin.py сам плагин
test_plugin.py тест на поддельном rgstr (хаб его не хранит)nkt-plugin.json читает хаб, когда ставит плагин на хост:
{
"name": "rgstr-lite", "version": "1.0.0", "min_nkt": "1.17.15", "protocol": 1,
"title": {"ru": "rgstr (учебный)", "en": "rgstr (tutorial)"},
"run": "python3", "file": "nkt-plugin.py", "port": 9281,
"settings": [
{"env": "RGSTR_URL", "title": {"ru": "Адрес rgstr", "en": "rgstr address"}, "default": "http://127.0.0.1:5000"},
{"env": "RGSTR_USER", "title": {"ru": "Администратор rgstr", "en": "rgstr administrator"}, "default": "admin"},
{"env": "RGSTR_PASSWORD", "title": {"ru": "Пароль администратора", "en": "Administrator password"}, "secret": true},
{"env": "RGSTR_PUBLIC", "title": {"ru": "Адрес реестра для docker", "en": "Registry address for docker"}}
]
}settings— поля формы «Установить на хост». Они становятся окружением службы (/etc/nkt-plugins/rgstr-lite.env,0600).secret: true— поле скрытое, в истории и журналах значения нет.versionдолжна совпадать сVERSIONв коде: это проверяет lint.- Ещё хаб сам передаёт
NKT_PLUGIN_SECRET,NKT_PLUGIN_PORTиNKT_PLUGIN_LISTEN.
Шаг 1. Сервер, подпись, манифест
Хаб ходит к плагину двумя запросами: GET /nkt/manifest и POST /nkt/call. Каждый подписан: заголовок X-NKT-Signature — это HMAC-SHA256 строки отметка.путь.тело общим секретом. Отметка — в X-NKT-Timestamp, она не старше 5 минут.
def signed(self, body):
ts = self.headers.get("X-NKT-Timestamp", "")
try:
fresh = abs(time.time() - int(ts)) < 300
except ValueError:
return False
want = hmac.new(SECRET.encode(), f"{ts}.{urllib.parse.urlsplit(self.path).path}.".encode() + body,
hashlib.sha256).hexdigest()
return fresh and hmac.compare_digest(want, self.headers.get("X-NKT-Signature", ""))- Без подписи — ответ
401, плагин ничего не делает. - Тело больше 1 МБ — ответ
413. - Без секрета длиной от 16 знаков плагин не запускается.
Манифест описывает, что плагин умеет. Хаб строит по нему разделы, кнопки, инструменты MCP и команды сценариев:
"ops": [
{"name": "summary", "title": c.t("summary"), "read_only": True, "mcp": True, "description": "…"},
{"name": "tag_delete", "title": c.t("delete"), "mcp": True, "destructive": True,
"input": {"type": "object", "properties": {"repo": s, "tag": s}, "required": ["repo", "tag"]}},
{"name": "robot_create", "title": c.t("robot"), "mcp": True, "reveals": True, …},
],
"pages": [{"id": "summary", "title": c.t("summary"), "op": "summary"},
{"id": "repos", "title": c.t("repos"), "op": "repos"},
{"id": "tags", "title": c.t("tags"), "op": "tags", "hidden": True}],
"commands": [{"word": "rgstr-lite", "usage": c.t("usage"), "example": "rgstr-lite gc"}],| Поле операции | Что значит для хаба |
|---|---|
read_only | читать может любой; токен чтения тоже |
mcp | операция видна агенту как инструмент rgstr_lite_<op> (дефис в имени — подчёркивание) |
destructive | в MCP помечена как удаляющая |
reveals | ответ может нести секрет — токену только с to_pipeline/env |
input | JSON Schema аргументов: по ней строится инструмент MCP |
Тексты — на двух языках. Язык приходит заголовком X-NKT-Lang, поэтому манифест и ответы плагин отдаёт на языке пользователя (T["ru"], T["en"]).
Шаг 2. Раздел-сводка с находками
Раздел — это операция, которая возвращает вид (view): таблицу, текст или sections (несколько видов подряд).
def summary_view(c):
health = api(c, "GET", "/healthz", auth=False)
me = api(c, "GET", "/ui/api/me")
…
if not me.get("auth_enabled"):
found.append(c.t("f_auth_off"))
return {"type": "sections", "items": [table(c.t("summary"), …), table(c.t("findings"), …)]}Откуда брать данные — из API программы, к которой плагин подключён. У rgstr это /ui/api/*:
- вход — Basic или Bearer;
- у каждого POST должно быть JSON-тело, хотя бы
{}; - ошибка —
{"error": "…"}.
Всё это видно в исходниках rgstr; функция api() учитывает каждое правило. Ответ 401 превращается в понятный текст «вход не принят — проверьте RGSTR_USER и RGSTR_PASSWORD».
Находки — то, что администратор должен исправить. Пишите их утверждением о проблеме («Вход выключен: любой может загружать и удалять образы»), а не названием проверки.
Шаг 3. Таблица и скрытый раздел
rows = [{"cells": {"repo": r["name"], "n_tags": len(r.get("tags") or [])},
"actions": [{"title": c.t("open"), "open": "tags", "args": {"repo": r["name"]}}]}
for r in repositories if REPO_RE.match(r["name"])]actionsу строки — кнопки."open": "tags"открывает разделtagsс аргументами. Раздел помечен"hidden": true, поэтому в меню его нет — попасть туда можно только из строки.actionsу таблицы — кнопки над ней.{"op": "gc_run"}вызывает операцию; сfieldsсначала открывается форма (так устроен «Робот для конвейера»).
Всё, что пришло снаружи, проверяется до использования — даже имя репозитория из ответа самого rgstr. Регулярные выражения REPO_RE, TAG_RE, NAME_RE стоят в начале файла. Значение, которое не прошло проверку, не попадает ни в путь, ни в тело запроса.
Шаг 4. Изменения: подтверждение и сухой прогон
{"title": c.t("delete"), "op": "tag_delete", "args": {"repo": repo, "tag": tag}, "danger": True,
"confirm": c.t("confirm", repo=repo, tag=tag)}confirm— хаб спросит «Удалить тег shop/api:1.0?»;danger— красная кнопка.
Каждая операция, которая что-то меняет, понимает dry_run. Хаб передаёт его в запросе: в сухом прогоне сценария, у агента с dry_run: true, в API.
def tag_delete(c, args, dry):
repo, tag = check(c, REPO_RE, args.get("repo")), check(c, TAG_RE, args.get("tag"))
what = c.t("deleted", repo=repo, tag=tag)
if dry:
return c.t("would", what=what) # «(сухой прогон) shop/api:1.0 удалён»
api(c, "POST", "/ui/api/tags/delete", {"repo": repo, "tags": [tag]})
return whatСухой прогон проверяет ввод так же, как настоящий вызов, — и ничего не меняет.
Шаг 5. Секрет: окну или в конвейер
Робот rgstr отдаёт секрет один раз. Плагин не хранит его и не пишет в журнал. У секрета два пути:
Человеку в окне — ответ с reveal. Хаб покажет секрет один раз, с кнопкой «Скопировать»:
return {"ok": True, "text": c.t("robot_done", name=name),
"reveal": {"title": c.t("secret", name=name), "value": secret,
"note": c.t("note", host=PUBLIC, name=name)}}В конвейер — ответ с store. Хаб сам запишет значение и уберёт его из ответа:
store = {"pipeline": pipe, "env": env, "value": secret}
if env == "@registry": # не в .env, а ключом registry конвейера
store.update({"registry": PUBLIC, "user": name})
return {"ok": True, "text": c.t("robot_done", name=name), "store": store}env— имя переменной: значение уходит в.envконвейера с историей версий.env: "@registry"— значение уходит в «Секреты» → «Реестры» конвейера как ключ registryregistryс логиномuser. Ключ с тем же адресом заменяется, его CA сохраняется. Конвейер скачивает образы этим ключом —docker loginна хостах не нужен.
Правила, которые хаб проверяет за плагин:
pipelineиenvв ответе должны совпадать с тем, что было в запросе (to_pipeline,env). Плагин не может выбрать конвейер сам.- Право писать в этот конвейер хаб проверяет до вызова плагина.
- Токену (API, MCP) операция с
revealsдоступна только сto_pipelineиenv: агент значение не увидит. - Из сценария секрет идёт только в конвейер, поэтому
_commandвыбрасываетreveal.
Шаг 6. Команда сценария
Хаб передаёт плагину слова команды (argv) двумя служебными операциями:
_check— «Проверить» сценарий: ответ — ошибка текстом илиok;_command— выполнение (сdry_runв сухом прогоне);textуходит в журнал задания.
def command(c, argv, dry, only_check):
a = argv[1:] # argv[0] — само слово «rgstr-lite»
if a == ["gc"]:
return {"ok": True} if only_check else {"ok": True, "text": gc_run(c, dry)}
if len(a) == 8 and a[0] == "robot" and a[2] == "project" and a[4] == "to-pipeline" and a[6] == "env":
…
raise Fail(c.t("usage"))В сценарии:
rgstr-lite gc
rgstr-lite robot ci project shop to-pipeline shop env @registry
deploy shopЕсли слово не занято другим плагином, оно работает само по себе; иначе — plugin rgstr-lite gc.
Шаг 7. Тест на подделке
deploy/test_plugin.py поднимает поддельный rgstr (ответы — как у настоящего, по его исходникам) и сам плагин отдельным процессом. Затем он вызывает плагин подписанными запросами так же, как хаб:
python3 -I examples/_samples/rgstr-lite/deploy/test_plugin.pyТест проверяет:
- манифест и разделы;
- отказ на плохой ввод (
../x,a b,x;rm); - что сухой прогон не шлёт rgstr ни одного POST;
revealиstore(в том числе@registry), отказ на плохое имя переменной;- команды
_checkи_command— и что_commandне отдаётreveal; 401без подписи и отказ на неизвестную операцию.
Шаг 8. Проверка и архив
go run ./cmd/nkt-catalog lint -dir examples/_samples/rgstr-lite
tar czf rgstr-lite.tar.gz -C examples/_samples rgstr-litelint делает ту же проверку, что хаб при загрузке:
- пакет и версия =
VERSIONв коде; - тексты на ru и en;
- секретные настройки;
- запрещённый код (
shell=True,os.system,eval, команда строкой).
Шаг 9. Загрузить и поставить
- «О системе» → «Плагины» → «Каталог» → «Загрузить свой плагин» → архив. Хаб покажет код целиком. Прочитайте его, отметьте «Код просмотрен» → «Сохранить». На хабе плагин не запускается.
- «rgstr (учебный)» (метка «свой») → «Установить на хост» → хост, с которого виден rgstr, → настройки → задание. Появится служба
nkt-plugin-rgstr-liteна порту 9281. - Разделы «Сводка» и «Репозитории» плагина. «Для агентов» — инструменты
rgstr_lite_summary,rgstr_lite_repos,rgstr_lite_tag_delete,rgstr_lite_gc_run,rgstr_lite_robot_create.
Вызов из API:
curl -sS -X POST -H "Authorization: Bearer $NKT_TOKEN" -H 'Content-Type: application/json' \
-d '{"op":"tag_delete","args":{"repo":"shop/api","tag":"1.0"},"dry_run":true}' \
https://hub.example.com/api/hub/plugins/rgstr-lite/callНовая версия: поднимите VERSION в коде и version в nkt-plugin.json, затем загрузите архив снова. Хаб покажет дифф с прежней версией. Потом нажмите «Обновить на хосте».
Дальше
- Как написать плагин — все правила:
- формы и документы с историей (
edit); - записи DNS от плагина;
provides: dnsиprovides: hosts;_connectс токеном хаба;- чек-лист безопасности.
- формы и документы с историей (
- Полный
rgstr(examples/rgstr/deploy/nkt-plugin.py) — то же на шагах 1–6, плюс:- проекты;
- политики с правкой текстом;
- вебхуки и журнал;
- десять находок.