Свой репозиторий бинарников: залил сборку — получил стабильную ссылку на скачивание.
Место, куда складывают сборки своих приложений и откуда их забирают: веб-интерфейс
и JSON API, версии по semver, проверка SHA-256 при загрузке, постоянная ссылка на
latest, роли отдельно для людей и для CI. Один бинарник на Go, метаданные в
SQLite, файлы на диске — ни Docker, ни внешней базы, ни CGO.
# 1. Взять архив под свою платформу и распаковать
# https://github.com/KiboMibo/mini-repo/releases/latest
tar -xzf apprepo_*_linux_amd64.tar.gz && cd apprepo_*_linux_amd64
# 2. Завести первого пользователя (он же admin) — пароль спросит интерактивно
./apprepo user add alice -data-dir ./data
# 3. Запустить и открыть http://localhost:8080
./apprepo serve -data-dir ./data -addr :8080Из исходников — make build (нужен Go ≥ 1.24). Целостность скачанного архива
проверяется файлом SHA256SUMS из того же релиза: sha256sum -c SHA256SUMS
(на macOS — shasum -a 256 -c SHA256SUMS). Установка «по-взрослому», с systemd и
отдельным пользователем, — в разделе Деплой (systemd).
- Что умеет
- Сборка и запуск
- Конфигурация
- Доступ
- Роли и права
- Веб-UI
- Управление учётками из CLI
- Платформа версии
- Многофайловые приложения (архивы)
- Примеры curl
- Деплой (systemd)
- Обновление существующей установки
- Разработка
Репозиторий бинарников приложений: веб-UI и JSON API для заведения приложений,
загрузки версий (semver) с проверкой SHA-256 и скачивания по стабильным ссылкам,
включая latest. Версия — один файл любого содержания: одиночный бинарник или
архив многофайлового приложения (см. «Многофайловые приложения»); у каждой версии
есть платформа — под какую ОС и архитектуру она собрана (см. «Платформа версии»).
Приложения можно переименовывать и редактировать, приложения и
отдельные версии — удалять (жёстко, вместе с файлами на диске). Доступ разграничен
глобальными ролями (deployer, developer, maintainer, admin), учётки
заводятся и блокируются из интерфейса, API и CLI. Один бинарник на Go, метаданные
в SQLite (без CGO), файлы на диске.
make build # -> ./apprepo
./apprepo user add alice # первый пользователь — admin; пароль спросит
# интерактивно (или APPREPO_PASSWORD=…)
./apprepo serve -data-dir /opt/apps -addr :8080serve — подкоманда по умолчанию: ./apprepo -addr :8080 тоже работает.
Пока в базе нет ни одного пользователя, serve пишет в лог подсказку про
user add. GET /healthz отвечает ok без аутентификации.
Требования: Go ≥ 1.24 (только для сборки). CGO не нужен (SQLite — чистый Go,
WASM-сборка через wazero), кросс-компиляция свободная:
GOOS=linux GOARCH=amd64 go build ./cmd/apprepo. Готовые архивы под все пять
поддерживаемых платформ собирает make dist (см. Релизные
сборки).
Сборка без доступа к golang.org/gitlab.com: в go.mod стоят replace
зависимостей golang.org/x/* на официальные зеркала github.com/golang/* —
проект собирается в средах, где наружу выпущен только github.com
(GOPROXY=direct). В среде с нормальной сетью эти replace можно удалить,
код от них не зависит.
Флаг приоритетнее переменной окружения.
| Флаг | Env | Дефолт |
|---|---|---|
-addr |
APPREPO_ADDR |
:8080 |
-data-dir |
APPREPO_DATA_DIR |
/opt/apps |
-db |
APPREPO_DB |
{data-dir}/apprepo.db |
-base-url |
APPREPO_BASE_URL |
http://localhost:8080 |
-max-upload |
APPREPO_MAX_UPLOAD |
2147483648 (2 GiB) |
Анонимного доступа нет. UI — логин/пароль (сессионная кука); API и скачивание —
HTTP Basic Auth с теми же учётками. Завести пользователя можно тремя путями:
из CLI (apprepo user add), из веб-интерфейса (страница /users) и через API
(POST /api/users); первого пользователя — только из CLI, войти-то ещё некому.
Пароль — от 8 до 72 байт, одинаково во всех трёх интерфейсах (CLI,
веб-интерфейс, API). Нижняя граница есть потому, что ограничения частоты входа
сервис пока не делает и цена перебора равна стоимости bcrypt; верхняя — предел
bcrypt, который длинный пароль отвергает, а не молча обрезает. Других
требований — классов символов, словарей, несовпадения с именем — нет намеренно.
Слишком короткий или слишком длинный пароль отвергается на входе: 400 в API,
сообщение на форме в UI, выход с кодом 1 в CLI.
Важно: Basic Auth передаёт пароль в открытом виде — в проде обязателен reverse-proxy с TLS перед сервисом.
У каждой учётки ровно одна роль. Роль читается из базы на каждом запросе, так что выданное или отобранное право действует немедленно — перелогиниваться не надо.
| Роль | Что может |
|---|---|
deployer |
только читать и скачивать по API и прямым ссылкам. Машинная учётка для CI: в веб-интерфейс не пускается вовсе |
developer |
всё, что deployer, плюс заливать и удалять версии, закреплять latest, и работать в веб-интерфейсе |
maintainer |
всё, что developer, плюс создавать, переименовывать и удалять приложения |
admin |
всё, что maintainer, плюс управление учётками |
Подробнее по каждому праву:
| Право | deployer |
developer |
maintainer |
admin |
|---|---|---|---|---|
| Смотреть списки по API, скачивать бинарники | ✔ | ✔ | ✔ | ✔ |
Залить версию, удалить версию, закрепить latest |
— | ✔ | ✔ | ✔ |
| Создать, переименовать, удалить приложение | — | — | ✔ | ✔ |
| Управлять учётками | — | — | — | ✔ |
| Заходить в веб-интерфейс | — | ✔ | ✔ | ✔ |
| Сменить себе пароль | ✔ (по API) | ✔ | ✔ | ✔ |
Почему deployer не заходит в интерфейс. Это учётка для CI и прямых ссылок,
её пароль лежит в переменных сборки и часто в нескольких местах сразу; страницы
с кнопками «удалить приложение» ей не нужны. Если такой учёткой всё-таки войти
на /login, вход пройдёт, но любая страница ответит 403 со страницей-объяснением:
роль даёт доступ к /api/… и /download/… по Basic Auth, и только к ним. Свой
пароль deployer меняет через API — POST /api/me/password.
Матрица прав задана в одном месте — rolePerms в internal/auth/roles.go;
и маршруты, и кнопки в шаблонах спрашивают право, а не имя роли.
Последнего активного администратора нельзя разжаловать, заблокировать или
удалить — API отвечает 409 last_admin, интерфейс показывает ошибку, CLI
пишет refused: no active administrator would be left in the system. Чтобы
снять с себя роль админа, сначала выдайте admin кому-то ещё. Свою учётку
удалить нельзя ни при каком числе админов.
Полное описание прав по маршрутам API и коды ошибок — в docs/api.md.
Серверные HTML-страницы, всё встроено в бинарник (без CDN и JS-зависимостей):
/login— вход; после логина возвращает на исходную страницу (?next=)./— список приложений (имя, описание, latest, число версий) и форма создания приложения./apps/{name}— страница приложения: таблица версий (semver по убыванию, платформа, размер, дата, SHA-256, прямая ссылка на скачивание, бейджlatest), форма загрузки версии (файл, semver-версия, опциональный SHA-256 для сверки, выпадающие списки ОС и архитектуры) и управление latest: закрепить конкретную версию или вернутьauto(максимальный semver)./users— управление учётками, только для ролиadmin(см. ниже).
На странице приложения и на /users слева от заголовка есть стрелка «назад»
(<) — обычная ссылка на список приложений, а не history.back(): работает без
JavaScript, открывается в новой вкладке и всегда ведёт в одно и то же место.
Платформа в интерфейсе. В таблице версий у неё своя колонка; неизвестная
платформа показана прочерком —. В форме загрузки списки ОС и архитектуры по
умолчанию стоят на detect automatically — для бинарника менять ничего не
надо, для архива пару придётся выбрать (или any), иначе загрузка отклоняется.
Кнопка Platform у строки версии открывает то же окно для уже залитой версии:
там платформа проставляется задним числом и сбрасывается обратно в «неизвестно»
пунктом unknown. Подробности — в разделе «Платформа версии».
Прогресс загрузки. У формы загрузки версии есть полоса прогресса с процентом и объёмом («42% — 350.1 MB of 812.4 MB») и кнопка Cancel upload, которая обрывает начатую загрузку — на архивах в сотни мегабайт без этого непонятно, идёт дело или зависло. Когда файл ушёл целиком и сервер досчитывает хеш, надпись меняется на «finishing on the server…», а кнопка отмены убирается: отменять с этого момента уже нечего. Если соединение оборвалось после того, как тело ушло, интерфейс не утверждает, что ничего не сохранено, — он честно просит перезагрузить страницу и посмотреть список версий.
Сделано это прогрессивным улучшением: форма остаётся обычной HTML-формой, а встроенный скрипт лишь перехватывает отправку и шлёт то же тело тем же адресом. Без JavaScript форма работает ровно как раньше — обычной отправкой, без полосы прогресса. Внешних библиотек и CDN по-прежнему нет.
Сессия — HttpOnly-кука на 7 суток, переживает рестарт сервиса (хранится в SQLite). Все POST-формы защищены CSRF-токеном (double-submit cookie); ошибки загрузки (несовпадение хеша, дубль версии, превышение лимита) показываются на странице приложения.
Интерфейс показывает только то, что роль может. Кнопки скрыты по праву, а
не по имени роли: developer не видит «New application», «Edit» и «Delete
application», maintainer не видит ссылку «Users». Скрытая кнопка — не защита:
тот же запрос напрямую отвечает 403 со страницей «Not available for your
role». В шапке всегда видно, кто вошёл и с какой ролью.
Кнопка Change password в шапке — на любой странице, у любой роли, которую
пускают в интерфейс. Форма спрашивает текущий пароль и новый дважды. После
смены все сессии пользователя гасятся, и браузер уезжает на /login с
пояснением «Your password has been changed. Please log in again.» — это
нормально, а не ошибка. Неверный текущий пароль — 401 и текст на месте,
введённое не пропадает; несовпадение повтора — 400.
Роль deployer в интерфейс не заходит и меняет пароль через
POST /api/me/password (см. docs/api.md).
Таблица всех учёток: имя, роль, статус (активна/заблокирована), дата создания. По каждой строке доступны:
- Set рядом с выпадающим списком ролей — сменить роль. Действует сразу, в том числе для того, кто прямо сейчас сидит в интерфейсе.
- Block / Unblock — закрыть доступ, не удаляя учётку. Блокировка мгновенно гасит сессии пользователя и закрывает Basic Auth; имя, роль и история остаются. Unblock возвращает доступ с тем же паролем.
- Reset password — админский сброс: текущий пароль не спрашивается. Сессии
пользователя гасятся. Сброс пароля самому себе выкидывает на
/login. - Delete — удаление с строгим подтверждением: нужно вручную ввести имя
пользователя, как при удалении приложения. Не совпало —
400и ничего не удаляется. - New user — модалка создания: имя, пароль, роль (роль обязательна).
Своей строке кнопка Delete не рисуется, а прямой запрос отвечает 409
(«you cannot delete your own account»). Разжалование, блокировка и удаление
последнего активного админа отвечают 409 с подсказкой «give the admin role to
another account first». Все операции журналируются в лог сервиса с именем
того, кто их выполнил; пароли и хеши в лог не попадают.
Формы живут в модальных окнах на странице приложения; при ошибке модалка открывается заново рядом с баннером ошибки, чтобы введённое не пропало.
- Edit — правка приложения: имя и описание. Сохранение переименовывает и
каталог на диске, после чего браузер уезжает на
/apps/{новое-имя}. В форме висит предупреждение: переименование ломает старые ссылки —/download/старое-имя/…и/apps/старое-имяпосле этого отдают404, алиасов и редиректов со старого имени нет. Занятое имя —409с текстом «application … already exists», невалидное —400с правилом именования; в обоих случаях приложение остаётся как было. - Delete у строки версии — удаляет версию с файлом на диске. Подтверждение простое (кнопка в модалке), вводить ничего не нужно: версия одна и её всегда можно залить заново. Если удалялась закреплённая latest, закрепление снимается и latest снова считается автоматически.
- Delete application — удаляет приложение со всеми версиями и файлами.
Подтверждение строгое: нужно вручную ввести имя приложения в поле формы;
сравнение точное, лишний пробел не подойдёт. Не совпало —
400и текст «type "…" exactly to confirm deletion — nothing was deleted», ничего не удаляется. Совпало — приложение и его каталог исчезают, браузер уходит на/.
Удаление жёсткое: soft-delete и корзины нет, файлы действительно уходят с диска. Все три операции требуют сессии и CSRF-токена и журналируются в лог сервиса с именем пользователя.
CLI работает с базой напрямую, сессия ему не нужна — это аварийный вход в установку, в которую больше некому войти: забыт пароль единственного админа, все заблокированы, свежая установка без пользователей.
apprepo user add <username> [-role <role>] [flags]
apprepo user list [flags]
apprepo user role <username> <role> [flags]
apprepo user password <username> [flags]
apprepo user disable <username> [flags]
apprepo user enable <username> [flags]
apprepo user delete <username> -yes [flags]
[flags]— те же флаги конфигурации, что уserve(-data-dir,-db, …); их можно писать в любом порядке после позиционных аргументов.-role— одна изdeployer,developer,maintainer,admin. Опустить его можно только для самого первого пользователя в базе: он становитсяadmin, иначе установка была бы неуправляемой. Для второго и дальше без-role— ошибка с подсказкой.- Пароль для
addиpasswordберётся изAPPREPO_PASSWORD, а на терминале спрашивается интерактивно (дважды). Он обязан быть длиной 8–72 байта — та же политика, что в API и UI; короткий пароль даётpassword must be at least 8 bytes, got …и выход с кодом1, учётка не создаётся и не меняется. Пароли и хеши не печатаются никогда, в том числе вuser list. user deleteтребует-yes: CLI гоняют из скриптов, где спросить некого, а удаление необратимо.- Коды выхода:
0— успех,1— ошибка выполнения (нет пользователя, дубль, негодный пароль, отказ по последнему админу),2— ошибка вызова (неизвестная подкоманда, нет-role, нет-yes) — вместе со справкой.
D=/opt/apps
APPREPO_PASSWORD=… apprepo user add alice -data-dir $D # первый — admin
APPREPO_PASSWORD=… apprepo user add ci -role deployer -data-dir $D # учётка для CI
apprepo user list -data-dir $D
apprepo user role ci developer -data-dir $D
APPREPO_PASSWORD=… apprepo user password alice -data-dir $D # сброс пароля
apprepo user disable ci -data-dir $D # заблокировать
apprepo user enable ci -data-dir $D
apprepo user delete ci -yes -data-dir $Duser role, user disable и user delete отказываются тронуть последнего
активного админа:
apprepo: refused: no active administrator would be left in the system
У каждой версии есть платформа — под какую операционную систему и
архитектуру собран её файл. Выглядит она как linux/amd64, darwin/arm64,
windows/386: имена те же, что у GOOS/GOARCH, так что строку можно
подставить в скрипт сборки без перевода. Нужна она затем, что «версия 1.4.0»
у приложения, которое собирают под три платформы, — это три разных файла, и по
имени файла отличить их можно только на честном слове.
Два значения стоят особняком:
any— файл от ОС и архитектуры не зависит: jar, набор скриптов, конфиги. Ставится только руками: угадать «переносимость» по содержимому нельзя.darwin/universal— macOS universal binary (fat Mach-O), в котором лежит сразу несколько срезов; назвать одну пару значило бы соврать.
Полный словарь допустимых значений — в docs/api.md, раздел
«Платформа версии». Платформа может быть и неизвестной (в интерфейсе — прочерк
—): так живут версии, залитые до появления платформы, и всё, у чего её не
удалось определить и никто не проставил.
Что определяется само. Голый исполняемый файл: ELF (Linux, FreeBSD), Mach-O (macOS), PE (Windows), включая universal binary. Сервис читает заголовок файла и берёт платформу оттуда — заливать бинарники можно ровно так же, как раньше, ничего указывать не нужно.
Что не определяется и почему. Архив (.tar.gz, .zip), jar, скрипт — в
таком файле платформы просто нет: это контейнер, и сервис внутрь него не
заглядывает (он вообще не распаковывает архивы, см. следующий раздел). Не
определяется и бинарник под машину вне словаря. Угадывать сервис не пытается:
молча приписанная неверная платформа хуже, чем честное «не знаю».
Как указать платформу для архива. В API — параметром ?platform= при
заливке; без него архив залить не получится, ответ будет
400 platform_required (ломающее изменение, см. «Обновление существующей
установки»):
# архив под конкретную платформу
curl -u $CRED -X PUT "$BASE/api/apps/myapp/versions/1.4.0?filename=myapp-1.4.0.tar.gz&platform=linux/amd64" \
--data-binary @myapp-1.4.0.tar.gz
# архив, которому платформа безразлична
curl -u $CRED -X PUT "$BASE/api/apps/tools/versions/1.0.0?filename=tools-1.0.0.jar&platform=any" \
--data-binary @tools-1.0.0.jarЕсли указанная платформа спорит с тем, что сервис прочитал из файла (в артефакте
с меткой linux/amd64 лежит windows-бинарник), заливка отклоняется с
409 platform_mismatch и на диске не остаётся ничего. Это же означает, что
пометить настоящий бинарник как any нельзя.
В интерфейсе. На странице приложения у таблицы версий есть колонка
Platform (у неизвестной — прочерк). В форме загрузки — два выпадающих списка,
ОС и архитектура; по умолчанию выбрано detect automatically, и для бинарника
их трогать не нужно. У каждой версии есть кнопка Platform: она открывает
окно с теми же списками, где платформу можно проставить задним числом или
сбросить обратно в «неизвестно» (пункт unknown). Интерфейс отвечает теми же
кодами, что API: 400 на архив без платформы, 409 на расхождение с файлом.
Тем же занимается PATCH /api/apps/{name}/versions/{version} в API — единственная
операция правки версии; право нужно то же, что на заливку (PermVersion).
Версия — это ровно один файл, и его содержание сервису безразлично. Если
приложение состоит из нескольких файлов (бинарник, конфиги, статика, миграции),
упакуйте их в архив — .tar.gz, .zip, любой другой — и залейте архив как
версию. Отдельной поддержки архивов не нужно и её нет: сервис хранит байты как
есть и отдаёт их обратно байт в байт, вместе с посчитанным SHA-256.
Имя файла обязательно и обязано нести расширение. Именно на архивах это
критично: имя, с которым версия залита, — это то самое имя, под которым файл
уедет клиенту в Content-Disposition, и myapp-1.4.0 вместо
myapp-1.4.0.tar.gz даёт скачанный файл, который ни tar, ни файловый менеджер
не опознают. В API имя задаётся параметром ?filename= и он обязателен
(до этого изменения было умолчательное {имя}-{версия} без расширения — см.
раздел «Обновление существующей установки»). В веб-интерфейсе указывать ничего
не нужно: имя берётся из выбранного файла.
Платформу у архива тоже придётся назвать. Внутрь архива сервис не
заглядывает, читать платформу ему негде, а версию без платформы он молча не
заводит: PUT без ?platform= отвечает 400 platform_required. Укажите пару
os/arch, под которую собрано содержимое, или any, если оно от платформы не
зависит (см. «Платформа версии»).
# собрать архив многофайлового приложения
tar -czf myapp-1.4.0.tar.gz -C build myapp
# залить через API: ?filename= и ?platform= обязательны,
# расширение и правдивость платформы — ваша ответственность
curl -u $CRED -X PUT "$BASE/api/apps/myapp/versions/1.4.0?filename=myapp-1.4.0.tar.gz&platform=linux/amd64" \
-H "X-Checksum-Sha256: $(sha256sum myapp-1.4.0.tar.gz | cut -d' ' -f1)" \
--data-binary @myapp-1.4.0.tar.gz
# скачать и распаковать: -J сохраняет файл под именем из Content-Disposition
curl -u $CRED -O -J "$BASE/download/myapp/1.4.0"
tar -xzf myapp-1.4.0.tar.gzЧерез веб-интерфейс: страница приложения → Upload version → указать версию
(semver), выбрать файл архива, выбрать ОС и архитектуру в выпадающих списках
(или any), при желании вставить SHA-256 для сверки. Имя файла возьмётся из
выбранного, ?filename= тут ни при чём.
Сервис архивы не распаковывает и не заглядывает внутрь. В списке версий и в
API видно имя файла, размер, SHA-256 и дату — но не список файлов внутри архива.
Скачать отдельный файл из архива нельзя: единица хранения и скачивания — версия
целиком. Проверка SHA-256 (X-Checksum-Sha256 при заливке, тот же заголовок при
скачивании) сверяет архив целиком, а не файлы в нём.
Размер. Тело загрузки ограничено APPREPO_MAX_UPLOAD (-max-upload),
по умолчанию 2 GiB — сборки с зависимостями и статикой в этот предел упираются
быстрее, чем одиночный бинарник. Превышение — 413 (в API too_large, в UI
текст «file is too large» на странице), файл на диск не попадает. Лимит
поднимается переменной окружения или флагом:
APPREPO_MAX_UPLOAD=8589934592 (8 GiB). Загрузка потоковая — файл не буферится
в памяти целиком, поэтому упирается это в диск, а не в RAM.
Полное описание API с кодами ошибок — в docs/api.md.
BASE=http://localhost:8080
CRED=alice:s3cret-pass # пароль не короче 8 байт — короче сервис не примет
# создать приложение
curl -u $CRED -X POST $BASE/api/apps \
-H 'Content-Type: application/json' -d '{"name":"myapp","description":"demo"}'
# залить версию (тело — сырые байты файла; хеш опционален, но сверяется).
# ?filename= обязателен: под этим именем файл уедет клиенту при скачивании.
# ?platform= у голого бинарника не нужен — платформа читается из файла
curl -u $CRED -X PUT "$BASE/api/apps/myapp/versions/1.2.3?filename=myapp-linux-amd64" \
-H "X-Checksum-Sha256: $(sha256sum myapp-linux-amd64 | cut -d' ' -f1)" \
--data-binary @myapp-linux-amd64
# то же для архива многофайлового приложения — расширение и платформу
# указываем сами: у архива её читать неоткуда (any — если её вовсе нет)
curl -u $CRED -X PUT "$BASE/api/apps/myapp/versions/1.4.0?filename=myapp-1.4.0.tar.gz&platform=linux/amd64" \
--data-binary @myapp-1.4.0.tar.gz
# проставить платформу задним числом или сбросить её в «неизвестно»
curl -u $CRED -X PATCH $BASE/api/apps/myapp/versions/1.4.0 \
-H 'Content-Type: application/json' -d '{"platform":"linux/arm64"}'
curl -u $CRED -X PATCH $BASE/api/apps/myapp/versions/1.4.0 \
-H 'Content-Type: application/json' -d '{"platform":""}'
# списки и latest
curl -u $CRED $BASE/api/apps # все приложения
curl -u $CRED $BASE/api/apps/myapp # приложение + его версии
curl -u $CRED $BASE/api/apps/myapp/versions # только версии
curl -u $CRED $BASE/api/apps/myapp/versions/1.2.3 # конкретная версия
curl -u $CRED $BASE/api/apps/myapp/latest # текущая latest
# скачать конкретную версию и latest
curl -u $CRED -O -J $BASE/download/myapp/1.2.3
curl -u $CRED -O -J $BASE/download/myapp/latest
# закрепить latest вручную / вернуть авто (максимальный semver)
curl -u $CRED -X POST $BASE/api/apps/myapp/latest \
-H 'Content-Type: application/json' -d '{"version":"1.2.3"}'
curl -u $CRED -X POST $BASE/api/apps/myapp/latest \
-H 'Content-Type: application/json' -d '{"version":"auto"}'
# изменить описание (поле name не передаём — имя не меняется)
curl -u $CRED -X PATCH $BASE/api/apps/myapp \
-H 'Content-Type: application/json' -d '{"description":"new text"}'
# переименовать: каталог на диске переезжает, ссылки /download/myapp/... ломаются
curl -u $CRED -X PATCH $BASE/api/apps/myapp \
-H 'Content-Type: application/json' -d '{"name":"myapp2"}'
curl -u $CRED -O -J $BASE/download/myapp2/1.2.3 # работает
curl -u $CRED -O -J $BASE/download/myapp/1.2.3 # 404: старого имени больше нет
# удалить версию (204; файл и каталог версии уходят с диска)
curl -u $CRED -X DELETE $BASE/api/apps/myapp2/versions/1.2.3 -o /dev/null -w '%{http_code}\n'
# удалить приложение со всеми версиями (204; подтверждения в API нет — оно только в UI)
curl -u $CRED -X DELETE $BASE/api/apps/myapp2 -o /dev/null -w '%{http_code}\n'
# кто я (любая роль) и смена своего пароля — работает и у deployer
curl -u $CRED $BASE/api/me
curl -u $CRED -X POST $BASE/api/me/password \
-H 'Content-Type: application/json' \
-d '{"current_password":"s3cret-pass","new_password":"new-s3cret-pass"}'
# управление учётками — только admin
curl -u $CRED $BASE/api/users
curl -u $CRED -X POST $BASE/api/users \
-H 'Content-Type: application/json' -d '{"username":"ci","password":"s3cret-pass","role":"deployer"}'
curl -u $CRED -X PATCH $BASE/api/users/ci \
-H 'Content-Type: application/json' -d '{"role":"developer"}' # сменить роль
curl -u $CRED -X PATCH $BASE/api/users/ci \
-H 'Content-Type: application/json' -d '{"disabled":true}' # заблокировать
curl -u $CRED -X POST $BASE/api/users/ci/password \
-H 'Content-Type: application/json' -d '{"password":"reset-pass"}'
curl -u $CRED -X DELETE $BASE/api/users/ci -o /dev/null -w '%{http_code}\n'Удаление жёсткое и необратимое. Повторное удаление отдаёт 404, а не 204;
409 already_exists на удалении или переименовании означает, что путь в
data-dir занят посторонним объектом и диск не тронут — подробности и полный
список кодов в docs/api.md.
sudo useradd -r -s /usr/sbin/nologin apprepo
sudo install -m 700 -o apprepo -g apprepo -d /opt/apps
sudo install -m 755 apprepo /usr/local/bin/apprepo
sudo install -D -m 640 deploy/apprepo.env.example /etc/apprepo/apprepo.env
sudo install -m 644 deploy/apprepo.service /etc/systemd/system/apprepo.service
sudo -u apprepo APPREPO_PASSWORD=… apprepo user add alice -data-dir /opt/apps
sudo systemctl enable --now apprepoUnit-файл deploy/apprepo.service целиком; hardening-директивы и UMask=0077 —
часть модели прав из секьюрити-ревью (файлы данных недоступны другим локальным
пользователям). EnvironmentFile указывает на /etc/apprepo/apprepo.env
с переменными APPREPO_* (см. таблицу конфигурации выше). Отдельно стоит пара
MemoryMax=1G и GOMEMLIMIT=768MiB — предел памяти процесса; зачем он и когда
его поднимать, разобрано сразу после юнита:
[Unit]
Description=apprepo binary artifact repository
After=network.target
[Service]
Type=simple
DynamicUser=no
User=apprepo
Group=apprepo
EnvironmentFile=/etc/apprepo/apprepo.env
ExecStart=/usr/local/bin/apprepo serve
Restart=on-failure
RestartSec=2
# Страховочный предел, а не рабочий: определение платформы в internal/platform
# читает заголовок фиксированного размера и в память не упирается вовсе. Эти
# строки нужны на случай будущей ошибки — промах любого нового ограничения
# должен стоить рестарта сервиса, а не свопа и OOM-killer'а, который выбирает
# жертву на всей машине. GOMEMLIMIT ниже MemoryMax, чтобы сборщик мусора
# начинал стараться раньше, чем в дело вступает ядро. Заливки идут потоком на
# диск и в RSS не превращаются, так что гигабайта хватает с запасом;
# поднимаешь один — поднимай и второй.
MemoryMax=1G
Environment=GOMEMLIMIT=768MiB
# Hardening
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
# Обязан совпадать с APPREPO_DATA_DIR из EnvironmentFile: переменные окружения
# systemd в своих директивах не раскрывает, а ProtectSystem=strict делает ФС
# read-only вне этого списка. Меняешь data-dir — правь обе строки + daemon-reload.
# Каталог APPREPO_DB, если БД вынесена из data-dir, тоже обязан быть здесь:
# несколько путей пишутся через пробел — ReadWritePaths=/data/repo/apps /data/repo/db
ReadWritePaths=/opt/apps
[Install]
WantedBy=multi-user.targetПредел памяти: MemoryMax=1G и GOMEMLIMIT=768MiB. Это страховка, а не
рабочее ограничение: сервис читает у залитого файла только заголовок, около
сотни байт независимо от его размера, и в память тут упираться нечему. Строки
стоят на случай будущей ошибки — если какое-нибудь новое ограничение
промахнётся, цена должна быть рестарт сервиса, а не своп и OOM-killer, который
выбирает жертву на всей машине. GOMEMLIMIT намеренно ниже MemoryMax, чтобы
сборщик мусора начинал стараться раньше, чем в дело вступает ядро.
Гигабайта хватает с запасом: заливки идут потоком на диск и в память целиком не
попадают, поэтому от -max-upload этот предел не зависит — двухгигабайтный архив
кладётся в него спокойно. Если предел всё же понадобилось поднять, поднимайте
оба значения и сохраняйте зазор между ними; после правки — daemon-reload и
рестарт. Срабатывание видно как status=137 / oom-kill в
systemctl status apprepo.
Путь data-dir задан в двух местах и обязан совпадать: ReadWritePaths=
в юните и APPREPO_DATA_DIR= в /etc/apprepo/apprepo.env. systemd строит
mount-неймспейс до запуска процесса и переменные из EnvironmentFile в свои
директивы не подставляет, а ProtectSystem=strict запрещает запись всюду,
кроме перечисленного в ReadWritePaths=. Если пути разошлись (или каталог
из ReadWritePaths= не создан), сервис не стартует:
apprepository.service: Failed to set up mount namespacing: /opt/apps: No such file or directory
Failed at step NAMESPACE spawning /usr/local/bin/apprepo: No such file or directory
Main process exited, code=exited, status=226/NAMESPACE
APPREPO_DB — второй независимый путь. По умолчанию БД лежит внутри
data-dir и отдельной строки в юните не требует, но если её вынесли (например
APPREPO_DATA_DIR=/data/repo/apps и APPREPO_DB=/data/repo/db/apps.db), то
каталог БД тоже обязан быть в ReadWritePaths= — пути перечисляются через
пробел: ReadWritePaths=/data/repo/apps /data/repo/db. Иначе неймспейс
построится (data-dir на месте), сервис стартует и упадёт уже на открытии БД:
apprepo[20278]: open database /data/repo/db/apps.db: apply schema: sqlite3: unable to open database file (check directory permissions; under systemd also ReadWritePaths=)
Main process exited, code=exited, status=1/FAILURE
Симптом обманчив: каталог и файл БД существуют и принадлежат apprepo,
ls -l показывает корректные права — но под ProtectSystem=strict каталог
примонтирован read-only, и записать в него не может даже владелец.
Что реально применилось — systemctl show apprepo -p ReadWritePaths; после
правки юнита нужен systemctl daemon-reload. Если свой путь не нужен, вместо
ReadWritePaths= можно поставить StateDirectory=apprepo — systemd сам создаст
/var/lib/apprepo с нужными правами, а в env-файле указать
APPREPO_DATA_DIR=/var/lib/apprepo.
Обновление — это замена бинарника и рестарт сервиса. Отдельной команды миграции нет и запускать вручную нечего.
Ломающее изменение: ?filename= при заливке версии стал обязательным.
Раньше параметр можно было не передавать, и имя собиралось как {имя}-{версия}
— без расширения, из-за чего залитый архив скачивался файлом, который система не
опознаёт. Теперь запрос без параметра отвечает 400 filename_required и версию
не создаёт.
- Сломается всё, что заливало версии без
?filename=: шаги CI, скрипты деплоя, прямыеcurlиз старых инструкций. Падение шумное —400вместо201, на диске и в БД не остаётся ничего. - Чинится добавлением одного параметра, тело и заголовки менять не нужно:
?filename=myapp-1.2.3.tar.gz. Текст ошибки самодостаточен — он называет прежнее умолчательное имя и содержит готовую строкуcurl, которую можно скопировать из лога CI прямо в скрипт. Если расширение не нужно и прежнее имя устраивало — передайте его явно:?filename=myapp-1.2.3. - Уже залитые версии не затронуты. Они сохраняют имена, с которыми были
залиты (включая прежние умолчательные
{имя}-{версия}), и скачиваются по-прежнему. Перезаливать, переименовывать и мигрировать ничего не нужно. - Загрузка через веб-интерфейс не затронута: там имя всегда бралось из выбранного файла.
Пройдитесь по пайплайнам до обновления — проще добавить параметр заранее, чем ловить красный деплой. Подробнее: docs/api.md, раздел «Совместимость».
Ломающее изменение: архив теперь нельзя залить без ?platform=. У версии
появилась платформа (см. «Платформа версии»), и сервис не заводит версию, про
которую не знает, под что она собрана. У голого бинарника он читает платформу из
самого файла — такие пайплайны не меняются вовсе. У архива читать нечего, и
PUT без параметра отвечает 400 platform_required.
- Сломается всё, что заливает по API не бинарники: архивы
.tar.gz/.zip, jar'ы, наборы скриптов. Падение шумное —400вместо201; на диске и в БД не остаётся ничего. - Чинится одной строкой в URL, тело и заголовки менять не нужно: добавьте
&platform=linux/amd64(пара, под которую собрано содержимое) или&platform=any, если оно от ОС и архитектуры не зависит. - Текст ошибки самодостаточен: он перечисляет допустимые значения и содержит
готовую строку
curl, которую можно скопировать из лога CI прямо в скрипт. - Заливка через веб-интерфейс не ломается молча: там появились выпадающие списки ОС и архитектуры, и на архиве без выбранной платформы форма отвечает тем же отказом на странице.
- Уже залитые версии не затронуты. Их платформа считается неизвестной
(в интерфейсе — прочерк), скачивание и
Content-Dispositionне меняются.
Схема БД мигрирует автоматически при старте. store.Open (а значит и
serve, и любая подкоманда user …) досоздаёт недостающие колонки: role и
disabled в таблице users, platform в таблице versions. Операция
идемпотентна: на уже обновлённой базе выполняется одна проверка и ничего не
меняется, повторные запуски безопасны. Новая колонка platform у всех
существующих версий заполняется пустой строкой — «неизвестно».
Платформа старых версий подбирается при старте (backfill). Сразу после
миграции serve проходит по версиям с пустой платформой и пробует определить её
по файлу на диске. Отдельной команды нет, запускать вручную нечего, повторный
прогон безопасен — берутся только версии без платформы.
- Подберутся голые бинарники: ELF, Mach-O, PE. Архивы, jar'ы и скрипты
останутся пустыми навсегда — из файла их платформу взять неоткуда, проставьте
её руками (кнопка Platform в интерфейсе или
PATCHв API). - У прохода есть потолок в 5 секунд. Старт сервиса не должен зависеть от содержимого data-dir: на репозитории в десятки тысяч версий (или там, где часть файлов не опознаётся никогда и перечитывается каждый рестарт) проход обрывается по времени, а недоделанное достаётся следующему запуску или проставляется руками. Ничего при этом не теряется и не ломается.
- В журнал уходит одна итоговая строка на старт, а не строка на версию:
app: platform backfill: checked 11 version(s), set 6, skipped 5. Если проход оборвался по времени, в строке это сказано прямо. На пустой базе не пишется ничего. - Битый, отсутствующий или враждебный файл — это пропуск, а не отказ старта.
Все существующие учётки становятся admin. До появления ролей пользователи
были равны и могли всё, и миграция сохраняет ровно это поведение: никто не
теряет доступ в момент обновления. Обратная сторона — установка, где раньше было
пять человек, после обновления получает пять администраторов. Сразу после
обновления пройдитесь по списку и раздайте настоящие роли:
apprepo user list -data-dir /opt/apps
apprepo user role ci deployer -data-dir /opt/apps
apprepo user role bob developer -data-dir /opt/appsУчётки со старыми именами придётся разобрать вручную. Имена проверяются
(^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$, пробелы по краям обрезаются) только с
волны 6, поэтому в базе, пережившей обновление, могут лежать имена, которые
сегодня не завести: с пробелами, кириллицей, двоеточием. Такая учётка
из API и CLI по имени больше не адресуется — управлять ею можно только в
веб-интерфейсе, где учётки адресуются числовым id.
Насколько она при этом жива, зависит от имени, и разница существенная:
- Имя с пробелом или не-ASCII (
admin,аlice) — учётка рабочая: вход в веб-интерфейс проходит (форма входа сверяет имя как есть), Basic Auth к API и скачиванию тоже работает. Неудобство ровно одно — ею нельзя управлять по имени из API и CLI. - Имя с двоеточием (
ci:bot) — учётка не может войти по Basic Auth вообще: HTTP Basic кодирует пару какuser:password, так что сервер прочитает такое имя какciс паролемbot:…и ответит401. В веб-интерфейс она войдёт, но если её роль —deployer, в интерфейс её не пускают, и учётка мертва полностью: ни API, ни скачивания, ни UI. Такие разбирайте первыми — за ними, скорее всего, стоит уже сломанный пайплайн.
Смены имени учётки в продукте нет (переименовать можно только приложение), и
автоматической тоже нет намеренно: она молча сломала бы Basic-креды, уже
прописанные в чужих пайплайнах. Единственный путь — удалить учётку и завести
заново под нормальным именем; у новой будут новые Basic-креды, их придётся
переставить везде, где они прописаны (CI). Такие имена перечисляет
apprepo user list и журнал при старте, разделяя те же два случая (фраза про
двоеточие появляется, только если такие учётки есть):
warning: 2 account(s) have legacy names ("admin ", "аlice"): they still work and
can sign in, but are not addressable by name from the API or CLI. 1 account(s)
have a colon in the name ("ci:bot"): HTTP Basic encodes user:password, so these
cannot sign in over Basic at all — with the deployer role, which has no web UI
either, such an account is dead; fix these first. There is no way to rename an
account, so the only fix is to delete such an account on the /users page of the
web UI (where accounts are addressed by numeric id) and create it again under a
valid name; the new account gets new Basic credentials, so update them wherever
they are used (CI)
На странице /users такие учётки помечены значком с именем в кавычках — без
кавычек admin не отличить от admin. Значки разные: рабочее старое имя —
legacy name, имя с двоеточием — broken name (красный, как у блокировки).
Ищется учётка всегда по нормализованному имени, поэтому и отказ CLI называет
его: apprepo user role "admin " developer отвечает user "admin" not found —
строка "admin " из user list по своему имени недостижима.
Перед обновлением снимите копию файла БД. Останавливаете сервис, копируете
{data-dir}/apprepo.db (по умолчанию; путь может быть переопределён
APPREPO_DB) вместе с файлами -wal и -shm, если они есть, — и только потом
подменяете бинарник:
sudo systemctl stop apprepo
sudo cp -a /opt/apps/apprepo.db /opt/apps/apprepo.db.bak-$(date +%F)
sudo cp -a /opt/apps/apprepo.db-wal /opt/apps/apprepo.db-shm /root/ 2>/dev/null || true
sudo install -m 755 apprepo /usr/local/bin/apprepo
sudo systemctl start apprepo
sudo journalctl -u apprepo -n 20 # убедиться, что стартовал
apprepo user list -data-dir /opt/apps # роли на местеКопия нужна потому, что автоматической миграции «назад» нет: команды, которая сняла бы колонки ролей, в сервисе не предусмотрено. Восстановление — это возврат файла БД из копии при остановленном сервисе. Бинарники приложений в data-dir миграция не трогает вовсе, так что копировать их не нужно.
make test # go test ./...
bash scripts/smoke.sh # сквозной сценарий на временном каталогеmake dist # версия из `git describe`
make dist VERSION=v1.0.0 # версия задана явноСобирает пять платформ (linux/amd64, linux/arm64, darwin/amd64,
darwin/arm64, windows/amd64) в dist/: .tar.gz для linux и macOS, .zip
для windows, внутри — бинарник, README.md и LICENSE, если он есть. Рядом
кладётся SHA256SUMS. Версия и коммит зашиваются через -ldflags -X и
печатаются по apprepo -version; обычная make build собирается без них и
показывает dev. Сам dist/ в репозиторий не едет. Нужен zip в системе —
всё остальное делает Go.