feat(contracts/apps): smart-contract каталога приложений + docker pipeline
Контракт `apps` (координационная плоскость для apps-catalog): - 4 таблицы: packages, releases, subs, coops; ABI 21 KB, WASM 70 KB - 11 actions: regpackage, transferpkg, setrelease, reactivate, withdraw, cleanup, regsub, expsub, regcoop, setcoop, migrate - subs.chain_id и coops.chain_id — каноническая идентификация подсети - coops.signing_key — отдельный subnet-signing-key (не active), ротация через setcoop без потери ранее выпущенных JWT - releases с TTL retention 90 дней + inline cleanup до 50 записей - atomic supersede в setrelease — выполняет FR8 «approve → ACTIVE» на стороне blockchain'а CI контейнер `dicoop/contracts`: - упаковывает все 21 контракт (15 user + 6 system eosio.*) в alpine 10.8 MB - список контрактов берётся из CMakeLists (single source of truth) - триггер push на dev/testnet/main + workflow_dispatch - маппинг ветка→tag: dev/testnet/main + sha-pinned tag для воспроизводимости - manifest.json с sha256 каждого артефакта внутри образа - entrypoint: copy [name] | manifest | sha256 | list | ls - multi-stage потребление в ke-bootstrap = 0 MB на VPS Закрывает блокер Story 6.x (KE bootstrap) для apps-catalog.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
name: Build contracts container
|
||||
|
||||
# Сборка и публикация docker-образа `dicoop/contracts`, который содержит
|
||||
# wasm/abi всех контрактов кооперативной экономики (apps, marketplace,
|
||||
# ledger, meet, loan, wallet, capital, branch, contributor, fund, draft,
|
||||
# soviet, registrator, gateway, system, test). Образ потребляется
|
||||
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
|
||||
# контракты через `cleos set contract`.
|
||||
#
|
||||
# Триггер — push в одну из веток-окружений (dev / testnet / main).
|
||||
# Маппинг:
|
||||
# dev → IS_TESTNET=ON, tag=dev
|
||||
# testnet → IS_TESTNET=ON, tag=testnet
|
||||
# main → IS_TESTNET=OFF, tag=main + latest
|
||||
#
|
||||
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
|
||||
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
|
||||
# Артефакты копируются в slim alpine-образ.
|
||||
#
|
||||
# Секреты:
|
||||
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN — публикация в dicoop/*
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main]
|
||||
paths:
|
||||
- 'components/contracts/cpp/**'
|
||||
- 'components/contracts/CMakeLists.txt'
|
||||
- 'components/contracts/build.sh'
|
||||
- 'components/contracts/build-all.sh'
|
||||
- 'components/contracts/docker/**'
|
||||
- '.github/workflows/build-contracts.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build mode and docker tag
|
||||
run: |
|
||||
case "${{ github.ref_name }}" in
|
||||
main)
|
||||
echo "BUILD_MODE=prod" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=main" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
|
||||
;;
|
||||
testnet)
|
||||
echo "BUILD_MODE=test" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
;;
|
||||
dev)
|
||||
echo "BUILD_MODE=test" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported branch ${{ github.ref_name }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile all contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
- name: Stage docker context
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
ROOT="docker/.context"
|
||||
rm -rf "$ROOT"
|
||||
mkdir -p "$ROOT/contracts"
|
||||
|
||||
# Имена контрактов берём из CMakeLists (`add_contract_build(<name>)`)
|
||||
# — единая точка истины. Любое изменение списка там автоматически
|
||||
# подтянется в образ.
|
||||
# Имена контрактов = аргументы add_contract_build(...) только в
|
||||
# незакомменченных строках (CMake-комментарий начинается с `#`).
|
||||
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
|
||||
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
|
||||
echo "Contracts to package:"
|
||||
echo "$NAMES"
|
||||
|
||||
MANIFEST="$ROOT/contracts/manifest.json"
|
||||
|
||||
# Хелпер: упаковывает одну пару (wasm, abi) в /contracts/<name>/
|
||||
# и добавляет JSON-запись в STDOUT (caller склеивает в массив).
|
||||
pack_one() {
|
||||
local NAME="$1" WASM="$2" ABI="$3"
|
||||
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
|
||||
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ROOT/contracts/$NAME"
|
||||
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
|
||||
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
|
||||
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
|
||||
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
|
||||
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
|
||||
"$NAME" "$SHA_WASM" "$SHA_ABI"
|
||||
}
|
||||
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
|
||||
printf ' "git_sha": "%s",\n' "$GITHUB_SHA"
|
||||
printf ' "ref": "%s",\n' "$GITHUB_REF_NAME"
|
||||
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf ' "contracts": [\n'
|
||||
|
||||
FIRST=1
|
||||
emit() {
|
||||
entry=$(pack_one "$@") || return 0
|
||||
[ $FIRST -eq 0 ] && printf ',\n'
|
||||
FIRST=0
|
||||
printf '%s' "$entry"
|
||||
}
|
||||
|
||||
for c in $NAMES; do
|
||||
if [ "$c" = "system" ]; then
|
||||
# У system многоконтрактная сборка: разносим под-контракты
|
||||
# eosio.* как полноценные элементы каталога, а сам "system"
|
||||
# как имя пропускаем. test_contracts/* не упаковываем —
|
||||
# это тестовые stub'ы, не нужны на проде.
|
||||
for sub in build/contracts/system/contracts/eosio.*/; do
|
||||
[ -d "$sub" ] || continue
|
||||
sub_name=$(basename "$sub")
|
||||
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
|
||||
done
|
||||
else
|
||||
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
} > "$MANIFEST"
|
||||
|
||||
cp docker/Dockerfile "$ROOT/Dockerfile"
|
||||
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
|
||||
chmod +x "$ROOT/entrypoint.sh"
|
||||
|
||||
echo "--- manifest.json ---"
|
||||
cat "$MANIFEST"
|
||||
echo "--- context tree ---"
|
||||
find "$ROOT" -maxdepth 3 -type f | sort
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
IMAGE="dicoop/contracts"
|
||||
docker build \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$DOCKER_TAG" \
|
||||
./docker/.context
|
||||
docker push "$IMAGE:$DOCKER_TAG"
|
||||
|
||||
if [ -n "$EXTRA_TAG" ]; then
|
||||
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$EXTRA_TAG"
|
||||
docker push "$IMAGE:$EXTRA_TAG"
|
||||
fi
|
||||
|
||||
# SHA-tag для воспроизводимости (если нужно запинить конкретный
|
||||
# коммит, а не двигающийся branch-tag).
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
|
||||
docker push "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
|
||||
|
||||
- name: Verify pushed image
|
||||
run: |
|
||||
docker run --rm dicoop/contracts:$DOCKER_TAG list
|
||||
echo "---"
|
||||
docker run --rm dicoop/contracts:$DOCKER_TAG sha256
|
||||
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
COUNT=$(docker run --rm dicoop/contracts:$DOCKER_TAG list | wc -l)
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="✅ [GITHUB MONO] dicoop/contracts:$DOCKER_TAG ($COUNT контрактов, mode=$BUILD_MODE, sha=${GITHUB_SHA::7})"
|
||||
|
||||
- name: Telegram notify failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="❌ [GITHUB MONO] Ошибка сборки dicoop/contracts на ${{ github.ref_name }} (sha=${GITHUB_SHA::7})"
|
||||
@@ -57,6 +57,7 @@ add_contract_build(soviet)
|
||||
add_contract_build(registrator)
|
||||
add_contract_build(gateway)
|
||||
add_contract_build(marketplace)
|
||||
add_contract_build(apps)
|
||||
add_contract_build(system)
|
||||
|
||||
# Опции для тестов
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
|
||||
# Проект
|
||||
project(apps)
|
||||
|
||||
find_package(cdt)
|
||||
|
||||
# Добавление контракта
|
||||
add_contract(apps apps apps.cpp)
|
||||
if(IS_TESTNET)
|
||||
target_compile_definitions(apps PUBLIC IS_TESTNET=1)
|
||||
else()
|
||||
# Не определяем IS_TESTNET для продакшн режима
|
||||
endif()
|
||||
@@ -0,0 +1,75 @@
|
||||
# Контракт `apps` — каталог приложений
|
||||
|
||||
Координационная плоскость платформы Каталога приложений ВОСХОД
|
||||
(`apps-catalog`). Деплоится на корневой KE-цепи и обслуживает несколько
|
||||
подсетей кооперативов одновременно.
|
||||
|
||||
## Что хранит
|
||||
|
||||
| Таблица | PK | Назначение |
|
||||
| ---------- | ----------------- | ----------------------------------------------------------- |
|
||||
| `packages` | `package_id` | Реестр зарегистрированных пакетов (Antelope `name`) |
|
||||
| `releases` | auto-inc | Текущие active + недавние superseded релизы (TTL 90 дней) |
|
||||
| `subs` | auto-inc | Подписки `(coopname × package_id × chain_id)` на пакеты |
|
||||
| `coops` | `coopname` | Кооперативы каталога: chain_id подсети + subnet-signing-key |
|
||||
|
||||
## Жизненный цикл релиза
|
||||
|
||||
```
|
||||
regpackage(pkg, owner, subnets)
|
||||
│
|
||||
▼
|
||||
┌─────────┐ setrelease(scope=all) ┌────────────┐
|
||||
│ packages│ ─────────────────────────────► │ releases │
|
||||
│ pkg │ (creates active, supersedes │ active │
|
||||
└─────────┘ prior active с тем же scope) └────────────┘
|
||||
│
|
||||
setrelease(новая) │
|
||||
▼
|
||||
┌────────────┐
|
||||
│ superseded │ TTL 90d
|
||||
│ + auto-rm │ inline cleanup
|
||||
└────────────┘
|
||||
│
|
||||
│ reactivate(version=старая)
|
||||
▼
|
||||
┌────────────┐
|
||||
│ active │
|
||||
└────────────┘
|
||||
|
||||
withdraw(version) ┌────────────┐
|
||||
─────────────────────────►│ withdrawn │ не подпадает под TTL
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
## Authorization (MVP)
|
||||
|
||||
- Все mutating actions: `<coopname>@active`.
|
||||
- `cleanup` — без auth (доброкачественная операция).
|
||||
- `migrate` — `apps@active`.
|
||||
|
||||
## Authorization (план)
|
||||
|
||||
- `regsub`/`expsub` → выделенный `<coopname>@billing`-permission, когда
|
||||
биллинг отделится от провайдера каталога.
|
||||
- `regcoop`/`setcoop` → `<coopname>@subnet`-permission, когда onboarding
|
||||
кооперативов формализуется отдельным процессом.
|
||||
|
||||
Миграция через `eosio.msig`-предложение.
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
```bash
|
||||
# из components/contracts/
|
||||
./build.sh apps prod # production
|
||||
./build.sh apps test # с #define IS_TESTNET=1
|
||||
```
|
||||
|
||||
Артефакт — `build/contracts/apps/apps.{wasm,abi}`. Упаковка артефактов
|
||||
всех контрактов в docker-образ — см. `components/contracts/docker/`.
|
||||
|
||||
## Якоря
|
||||
|
||||
- `apps-catalog` epics.md Story 6.x — KE bootstrap.
|
||||
- `apps-catalog` docs/architecture.md §"KE blockchain (контракт apps)".
|
||||
- Memory `project_subnet_identification` — chain_id как идентификатор подсети.
|
||||
@@ -0,0 +1,24 @@
|
||||
#include "apps.hpp"
|
||||
#include <eosio/transaction.hpp>
|
||||
|
||||
#include "src/regpackage.cpp"
|
||||
#include "src/transferpkg.cpp"
|
||||
#include "src/setrelease.cpp"
|
||||
#include "src/reactivate.cpp"
|
||||
#include "src/withdraw.cpp"
|
||||
#include "src/cleanup.cpp"
|
||||
#include "src/regsub.cpp"
|
||||
#include "src/expsub.cpp"
|
||||
#include "src/regcoop.cpp"
|
||||
#include "src/setcoop.cpp"
|
||||
|
||||
/**
|
||||
* \brief Миграция контракта.
|
||||
* \details Пустышка для CDT-апгрейдов. Реальные миграции состояния
|
||||
* выполняются через `eosio.msig`-предложение с конкретным
|
||||
* планом (drop таблиц, переезд данных).
|
||||
* \note Авторизация: @ apps @ active.
|
||||
*/
|
||||
[[eosio::action]] void apps::migrate() {
|
||||
require_auth(_apps);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/contract.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/multi_index.hpp>
|
||||
#include <eosio/system.hpp>
|
||||
#include <eosio/time.hpp>
|
||||
#include <optional>
|
||||
|
||||
#include "../lib/index.hpp"
|
||||
|
||||
using namespace Apps;
|
||||
|
||||
/**
|
||||
\defgroup public_apps Контракт APPS
|
||||
|
||||
* Смарт-контракт каталога приложений `apps` — координационная плоскость
|
||||
* для платформы Каталога приложений ВОСХОД (`apps-catalog`). Контракт
|
||||
* деплоится на корневой KE-цепи и обслуживает несколько подсетей
|
||||
* (одна установка `CA-auth` → N подсетей кооперативов).
|
||||
*
|
||||
* Что хранит:
|
||||
* - `packages` — реестр зарегистрированных пакетов (PK = `name`).
|
||||
* - `releases` — текущие active + недавние superseded релизы (TTL 90 дней).
|
||||
* - `subs` — подписки кооперативов на пакеты (`coopname × package_id × chain_id`).
|
||||
* - `coops` — кооперативы, подключённые к каталогу, со своими
|
||||
* subnet-signing-key и chain_id подсети.
|
||||
*
|
||||
* Чего контракт НЕ делает (намеренно):
|
||||
* - Не проверяет ECDSA-подписи `signed-request`'ов от DC — это off-chain
|
||||
* в `CA-auth`, который читает `coops.signing_key` через RPC.
|
||||
* - Не выпускает JWT — это HS256 в `CA-auth`.
|
||||
* - Не валидирует tarball — `tarball_sha256` хранится для аудита.
|
||||
* - Не считает биллинг — биллинг живёт в кабинете ВОСХОД и `pay.coopenomics`,
|
||||
* сюда приходит уже факт «подписка активна».
|
||||
* - Не пишет audit-log — Antelope сам пишет в trace; `CA` ведёт свой
|
||||
* `audit_log_admin` для off-chain событий.
|
||||
*
|
||||
* Authorization model (MVP, упрощённая):
|
||||
* - Все mutating actions требуют `<coopname>@active`.
|
||||
* - Разделение прав на `@billing`, `@subnet-operator` и т.п. — TODO,
|
||||
* введём через `eosio.msig` миграцию когда биллинг отделится от
|
||||
* провайдера каталога.
|
||||
* - `cleanup` — без auth: операция идемпотентна и удаляет только
|
||||
* TTL-просроченные `superseded` записи.
|
||||
*
|
||||
* \see /home/admin/apps-catalog/docs/architecture.md
|
||||
* \see epics.md Story 6.x (KE bootstrap)
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_apps_processes Процессы
|
||||
\ingroup public_apps
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_apps_actions Действия
|
||||
\ingroup public_apps
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_apps_tables Таблицы
|
||||
\ingroup public_apps
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_apps_consts Константы
|
||||
\ingroup public_apps
|
||||
*/
|
||||
|
||||
namespace Apps {
|
||||
/**
|
||||
* Окно retention для `superseded`-релизов: 90 дней. Записи старше
|
||||
* этого окна удаляются inline в `setrelease` или явным `cleanup`.
|
||||
* `withdrawn` под TTL не подпадают — отзыв делается сознательно
|
||||
* и должен быть видим до явного purge.
|
||||
*/
|
||||
static constexpr uint64_t RELEASE_RETENTION_SECS = 90 * 86400;
|
||||
|
||||
/**
|
||||
* Бюджет cleanup'а внутри одного вызова `setrelease`: не удалять
|
||||
* больше N записей за раз, чтобы не упереться в CPU-limit транзакции.
|
||||
* Остаток подметёт следующий `setrelease` или явный `cleanup(package_id)`.
|
||||
*/
|
||||
static constexpr uint64_t CLEANUP_BUDGET_PER_CALL = 50;
|
||||
} // namespace Apps
|
||||
|
||||
class [[eosio::contract(APPS)]] apps : public eosio::contract {
|
||||
|
||||
public:
|
||||
apps(eosio::name receiver, eosio::name code,
|
||||
eosio::datastream<const char *> ds)
|
||||
: eosio::contract(receiver, code, ds) {}
|
||||
|
||||
[[eosio::action]] void migrate();
|
||||
|
||||
// ─── packages ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Зарегистрировать новый пакет в каталоге.
|
||||
* \param coopname провайдер каталога (тот, кто подписал транзакцию).
|
||||
* \param package_id кодовое имя пакета (Antelope name, ≤12 chars).
|
||||
* \param package_name внешнее имя (npm/go/oci) — может содержать `@scope/name`.
|
||||
* \param owner username владельца пакета.
|
||||
* \param compatible_subnets human-labels подсетей, в которых пакет совместим.
|
||||
* \note Авторизация: @p coopname @ active. Идемпотентность: фейл, если
|
||||
* package_id уже зарегистрирован (нужен явный transferpkg/withdraw
|
||||
* перед re-регистрацией).
|
||||
*/
|
||||
[[eosio::action]] void regpackage(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string package_name,
|
||||
eosio::name owner,
|
||||
std::vector<eosio::name> compatible_subnets);
|
||||
|
||||
/**
|
||||
* \brief Передать владение пакетом другому пользователю (FR3).
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void transferpkg(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
eosio::name new_owner);
|
||||
|
||||
// ─── releases ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Опубликовать новый release пакета.
|
||||
*
|
||||
* Атомарно (FR8 «approve → release.ACTIVE»):
|
||||
* 1. Создаёт row в `releases` со `status=active`, `superseded_at=0`.
|
||||
* 2. Любой существующий `active` с тем же `(package_id, scope)` →
|
||||
* `superseded`, `superseded_at=now`.
|
||||
* 3. Если `scope.kind == "all"` — обновляет `packages.last_active_version`.
|
||||
* 4. Inline-cleanup: удаляет до `CLEANUP_BUDGET_PER_CALL` самых старых
|
||||
* `superseded`-записей с `superseded_at < now - RELEASE_RETENTION_SECS`.
|
||||
*
|
||||
* \param scope область видимости (см. `scope_t`).
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void setrelease(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version,
|
||||
Apps::scope_t scope,
|
||||
eosio::checksum256 tarball_sha256,
|
||||
eosio::name moderated_by,
|
||||
std::string meta);
|
||||
|
||||
/**
|
||||
* \brief Реактивировать предыдущую версию пакета (FR43).
|
||||
* \details Находит release по `(package_id, version)`; ставит ему
|
||||
* `status=active`, `superseded_at=0`. Текущий `active` с тем же
|
||||
* scope → `superseded`. Если scope=all — обновляет
|
||||
* `packages.last_active_version`.
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void reactivate(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version);
|
||||
|
||||
/**
|
||||
* \brief Отозвать релиз (CVE / нарушение / юридический отзыв).
|
||||
* \details Ставит `status=withdrawn`. Если это был `active` со scope=all —
|
||||
* `packages.last_active_version` обнуляется ("") до следующего
|
||||
* setrelease/reactivate.
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void withdraw(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version,
|
||||
std::string reason);
|
||||
|
||||
/**
|
||||
* \brief Ручной cleanup TTL-просроченных `superseded`-записей пакета.
|
||||
* \details Удаляет до `CLEANUP_BUDGET_PER_CALL` записей с
|
||||
* `superseded_at < now - RELEASE_RETENTION_SECS`. Идемпотентен.
|
||||
* \note Без авторизации: операция доброкачественная, удаляет только
|
||||
* уже невидимые в продакшене записи. Любой может вызвать
|
||||
* (и заплатить за CPU).
|
||||
*/
|
||||
[[eosio::action]] void cleanup(eosio::name package_id);
|
||||
|
||||
// ─── subscriptions ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Зарегистрировать или продлить подписку кооператива на пакет.
|
||||
* \details Idempotent по (coopname, package_id):
|
||||
* - row не существует → создаём с `active=true`.
|
||||
* - row существует → обновляем `plan`, `end_at` = max(текущее, новое),
|
||||
* `active=true`, `chain_id` (на случай переноса
|
||||
* между подсетями).
|
||||
* \param coopname провайдер каталога (тот, кто выписывает подписку).
|
||||
* \param subscriber кооператив-подписчик.
|
||||
* \param chain_id подсеть, в которой действует подписка.
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void regsub(eosio::name coopname,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id,
|
||||
eosio::checksum256 chain_id,
|
||||
eosio::name plan,
|
||||
eosio::time_point_sec start_at,
|
||||
eosio::time_point_sec end_at);
|
||||
|
||||
/**
|
||||
* \brief Деактивировать подписку (`active=false`); row не удаляется
|
||||
* (нужен для аудита истечения).
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void expsub(eosio::name coopname,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id);
|
||||
|
||||
// ─── coops ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* \brief Зарегистрировать кооператив в каталоге.
|
||||
* \details Этот action — часть процесса подключения коопа к каталогу
|
||||
* приложений на Восходе (вне scope текущего MVP). Кооператив
|
||||
* сам подписывает транзакцию (`require_auth(coopname)`),
|
||||
* подтверждая, что владеет приватной частью `signing_key`.
|
||||
* \param coopname кооператив, который регистрируется.
|
||||
* \param chain_id подсеть, к которой принадлежит коопе.
|
||||
* \param subnet_label human-label подсети ("ru","by",...).
|
||||
* \param signing_key subnet-signing-key (отдельный от @ active).
|
||||
* \note Авторизация: @p coopname @ active. Идемпотентность: фейл, если
|
||||
* уже зарегистрирован — для обновления использовать `setcoop`.
|
||||
*/
|
||||
[[eosio::action]] void regcoop(eosio::name coopname,
|
||||
eosio::checksum256 chain_id,
|
||||
eosio::name subnet_label,
|
||||
eosio::public_key signing_key);
|
||||
|
||||
/**
|
||||
* \brief Обновить параметры кооператива (ротация ключа, перенос подсети,
|
||||
* деактивация).
|
||||
* \details Все параметры опциональны — обновляются только переданные.
|
||||
* При смене `signing_key` обновляется `key_rotated_at=now`.
|
||||
* Ранее выпущенные JWT (TTL 14 дней) остаются валидны;
|
||||
* новые signed-request'ы должны идти от нового приват-ключа.
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
[[eosio::action]] void setcoop(eosio::name coopname,
|
||||
std::optional<eosio::checksum256> chain_id,
|
||||
std::optional<eosio::name> subnet_label,
|
||||
std::optional<eosio::public_key> signing_key,
|
||||
std::optional<bool> active);
|
||||
|
||||
// ─── service tables ─────────────────────────────────────────────────
|
||||
|
||||
struct [[eosio::table, eosio::contract(APPS)]] counts : counts_base {};
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* \brief Ручной cleanup TTL-просроченных `superseded`-записей пакета.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Используется когда `setrelease` для пакета давно не вызывался, и
|
||||
* inline-cleanup в нём не подметает хвост. Удаляет до
|
||||
* `CLEANUP_BUDGET_PER_CALL` записей с `status=superseded` и
|
||||
* `superseded_at < now - RELEASE_RETENTION_SECS`.
|
||||
*
|
||||
* Идемпотентно. Многократные вызовы безопасны: каждый вызов чистит свой
|
||||
* batch до budget'а.
|
||||
*
|
||||
* \note БЕЗ авторизации: операция доброкачественная, удаляет только
|
||||
* уже невидимые в продакшене записи, не меняет логику. Любой
|
||||
* может вызвать (и заплатить за CPU). Это снижает оперативную
|
||||
* нагрузку на оператора каталога.
|
||||
*/
|
||||
void apps::cleanup(eosio::name package_id) {
|
||||
releases_index releases(_apps, _apps.value);
|
||||
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
|
||||
|
||||
auto now_sec = eosio::current_time_point().sec_since_epoch();
|
||||
uint64_t threshold = now_sec > Apps::RELEASE_RETENTION_SECS
|
||||
? now_sec - Apps::RELEASE_RETENTION_SECS
|
||||
: 0;
|
||||
|
||||
uint128_t key_super = ((uint128_t)package_id.value << 64) | "superseded"_n.value;
|
||||
uint64_t removed = 0;
|
||||
|
||||
auto it = by_pkgstat.lower_bound(key_super);
|
||||
while (it != by_pkgstat.end()
|
||||
&& ((uint128_t)it->package_id.value << 64 | it->status.value) == key_super
|
||||
&& removed < Apps::CLEANUP_BUDGET_PER_CALL) {
|
||||
if (it->superseded_at.sec_since_epoch() < threshold) {
|
||||
it = by_pkgstat.erase(it);
|
||||
++removed;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* \brief Деактивировать подписку.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Ставит `active=false`. Запись остаётся в таблице — она нужна для
|
||||
* аудита истечения и для повторной активации (если кооператив снова
|
||||
* подпишется, пойдёт через тот же row через `regsub`-upsert).
|
||||
*
|
||||
* Cleanup физически просроченных записей (например, истёкших год
|
||||
* назад и более не нужных в RAM) — задача отдельного периодического
|
||||
* действия. Вне scope MVP.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::expsub(eosio::name coopname,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id) {
|
||||
require_auth(coopname);
|
||||
|
||||
subs_index subs(_apps, _apps.value);
|
||||
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
|
||||
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
|
||||
auto sub_it = by_cooppkg.find(key);
|
||||
eosio::check(sub_it != by_cooppkg.end(), "Подписка не найдена");
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
by_cooppkg.modify(sub_it, coopname, [&](auto &s) {
|
||||
s.active = false;
|
||||
s.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* \brief Реактивировать предыдущую версию пакета (FR43).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Используется для отката broken release без full re-publish:
|
||||
* - Находит существующий release `(package_id, version)` со статусом
|
||||
* `superseded` или `withdrawn`.
|
||||
* - Текущий active с тем же `scope` → `superseded`.
|
||||
* - Найденный release → `active`, `superseded_at=0`.
|
||||
* - Если scope=all — обновляется `packages.last_active_version`.
|
||||
*
|
||||
* Окно реактивации ограничено TTL retention (90 дней): за пределами окна
|
||||
* superseded-записи уже удалены, и реактивация не возможна — нужен
|
||||
* полноценный re-publish.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::reactivate(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version) {
|
||||
require_auth(coopname);
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto pkg_it = packages.find(package_id.value);
|
||||
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
|
||||
|
||||
releases_index releases(_apps, _apps.value);
|
||||
auto by_pkg = releases.get_index<"bypackage"_n>();
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
|
||||
// Найти release под реактивацию.
|
||||
auto target = by_pkg.end();
|
||||
for (auto rit = by_pkg.lower_bound(package_id.value);
|
||||
rit != by_pkg.end() && rit->package_id == package_id;
|
||||
++rit) {
|
||||
if (rit->version == version) {
|
||||
target = rit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
eosio::check(target != by_pkg.end(),
|
||||
"Релиз для реактивации не найден — возможно вышел за TTL");
|
||||
eosio::check(target->status != "active"_n,
|
||||
"Релиз уже active");
|
||||
|
||||
Apps::scope_t target_scope = target->scope;
|
||||
|
||||
// Supersede current active с тем же scope.
|
||||
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
|
||||
uint128_t key_active = ((uint128_t)package_id.value << 64) | "active"_n.value;
|
||||
auto act_it = by_pkgstat.lower_bound(key_active);
|
||||
while (act_it != by_pkgstat.end()
|
||||
&& ((uint128_t)act_it->package_id.value << 64 | act_it->status.value) == key_active) {
|
||||
if (act_it->scope.kind == target_scope.kind && act_it->scope.targets == target_scope.targets) {
|
||||
auto stash = act_it;
|
||||
++act_it;
|
||||
releases.modify(*stash, coopname, [&](auto &r) {
|
||||
r.status = "superseded"_n;
|
||||
r.superseded_at = now_tps;
|
||||
});
|
||||
} else {
|
||||
++act_it;
|
||||
}
|
||||
}
|
||||
|
||||
// Поднять target в active.
|
||||
by_pkg.modify(target, coopname, [&](auto &r) {
|
||||
r.status = "active"_n;
|
||||
r.superseded_at = eosio::time_point_sec(0);
|
||||
});
|
||||
|
||||
if (target_scope.kind == "all"_n) {
|
||||
packages.modify(pkg_it, coopname, [&](auto &p) {
|
||||
p.last_active_version = version;
|
||||
p.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* \brief Регистрация кооператива в каталоге.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Кооператив сам подписывает транзакцию своим `@active`-ключом и
|
||||
* передаёт публичную часть `signing_key` (отдельный ключ для подписи
|
||||
* signed-request'ов от DC-оркестратора в `CA-auth`).
|
||||
*
|
||||
* Идемпотентность: если такой `coopname` уже зарегистрирован — фейл.
|
||||
* Для обновления полей — `setcoop`.
|
||||
*
|
||||
* \note Контекст: этот action — часть процесса подключения кооператива
|
||||
* к каталогу приложений на Восходе (вне scope текущего MVP
|
||||
* apps-catalog). Реальный flow: кабинет ВОСХОД проводит
|
||||
* onboarding, генерирует/принимает signing keypair, подписывает
|
||||
* и отправляет эту транзакцию.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::regcoop(eosio::name coopname,
|
||||
eosio::checksum256 chain_id,
|
||||
eosio::name subnet_label,
|
||||
eosio::public_key signing_key) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(subnet_label.value != 0, "subnet_label обязателен");
|
||||
|
||||
coops_index coops(_apps, _apps.value);
|
||||
auto it = coops.find(coopname.value);
|
||||
eosio::check(it == coops.end(), "Кооператив уже зарегистрирован — используйте setcoop");
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
coops.emplace(coopname, [&](auto &c) {
|
||||
c.coopname = coopname;
|
||||
c.chain_id = chain_id;
|
||||
c.subnet_label = subnet_label;
|
||||
c.signing_key = signing_key;
|
||||
c.active = true;
|
||||
c.registered_at = now_tps;
|
||||
c.key_rotated_at = now_tps;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* \brief Регистрация нового пакета в каталоге.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Контракт операции:
|
||||
* - PK = `package_id`. Если такой уже существует — фейлим («имя занято»).
|
||||
* - `package_name` — внешнее имя; не уникально (теоретически два пакета
|
||||
* могут иметь одинаковое npm-имя, но разные `package_id`; на практике
|
||||
* это ловится UI/правилами кабинета ВОСХОД).
|
||||
* - `compatible_subnets` — может быть пустой; в этом случае пакет
|
||||
* считается совместимым со всеми подсетями (resolver на стороне `CA`
|
||||
* обрабатывает пустой набор как «нет ограничений»).
|
||||
* - `last_active_version` инициализируется пустой строкой; заполнится
|
||||
* первым `setrelease` со scope=all.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::regpackage(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string package_name,
|
||||
eosio::name owner,
|
||||
std::vector<eosio::name> compatible_subnets) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(package_id.value != 0, "package_id не может быть пустым");
|
||||
eosio::check(owner.value != 0, "owner не может быть пустым");
|
||||
eosio::check(!package_name.empty(), "package_name не может быть пустым");
|
||||
eosio::check(package_name.size() <= 256, "package_name слишком длинный (макс 256)");
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto it = packages.find(package_id.value);
|
||||
eosio::check(it == packages.end(), "Пакет с таким package_id уже зарегистрирован");
|
||||
|
||||
auto now = eosio::current_time_point().sec_since_epoch();
|
||||
packages.emplace(coopname, [&](auto &p) {
|
||||
p.package_id = package_id;
|
||||
p.package_name = package_name;
|
||||
p.owner = owner;
|
||||
p.compatible_subnets = compatible_subnets;
|
||||
p.last_active_version = "";
|
||||
p.created_at = eosio::time_point_sec(now);
|
||||
p.updated_at = eosio::time_point_sec(now);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* \brief Регистрация или продление подписки кооператива на пакет.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Idempotent по `(subscriber, package_id)`:
|
||||
* - Row нет → создаём с `active=true`, `start_at`, `end_at`, `plan`, `chain_id`.
|
||||
* - Row есть → обновляем `plan`, `end_at` = `max(текущее, новое)`,
|
||||
* `active=true`, `chain_id` (на случай миграции коопа
|
||||
* между подсетями), `start_at` оставляем как был
|
||||
* (это маркер «когда вообще началась подписка»).
|
||||
*
|
||||
* Это поведение специально сознательное: биллинг кабинета ВОСХОД при
|
||||
* retry / повторной отправке не должен бояться дубликатов. История
|
||||
* выписки подписок остаётся в blockchain trace'ах (Antelope action history),
|
||||
* а не в таблице `subs`.
|
||||
*
|
||||
* \param coopname провайдер каталога (тот, кто выписывает подписку).
|
||||
* \param subscriber кооператив-подписчик.
|
||||
* \param chain_id подсеть, в которой действует подписка.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active. Проверки `chain_id` против
|
||||
* `coops.chain_id` НЕ делаем, потому что:
|
||||
* 1) `coops` row может ещё не существовать при первой выдаче подписки;
|
||||
* 2) подписка теоретически может быть выдана коопу из другой подсети
|
||||
* (миграция). Лучше доверять биллингу.
|
||||
*/
|
||||
void apps::regsub(eosio::name coopname,
|
||||
eosio::name subscriber,
|
||||
eosio::name package_id,
|
||||
eosio::checksum256 chain_id,
|
||||
eosio::name plan,
|
||||
eosio::time_point_sec start_at,
|
||||
eosio::time_point_sec end_at) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(subscriber.value != 0, "subscriber не может быть пустым");
|
||||
eosio::check(plan.value != 0, "plan не может быть пустым");
|
||||
eosio::check(end_at >= start_at, "end_at должен быть >= start_at");
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto pkg_it = packages.find(package_id.value);
|
||||
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
|
||||
|
||||
subs_index subs(_apps, _apps.value);
|
||||
auto by_cooppkg = subs.get_index<"bycooppkg"_n>();
|
||||
uint128_t key = ((uint128_t)subscriber.value << 64) | package_id.value;
|
||||
auto sub_it = by_cooppkg.find(key);
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
|
||||
if (sub_it == by_cooppkg.end()) {
|
||||
uint64_t new_id = get_global_id(_apps, "sub"_n);
|
||||
subs.emplace(coopname, [&](auto &s) {
|
||||
s.id = new_id;
|
||||
s.coopname = subscriber;
|
||||
s.package_id = package_id;
|
||||
s.chain_id = chain_id;
|
||||
s.plan = plan;
|
||||
s.active = true;
|
||||
s.start_at = start_at;
|
||||
s.end_at = end_at;
|
||||
s.created_at = now_tps;
|
||||
s.updated_at = now_tps;
|
||||
});
|
||||
} else {
|
||||
by_cooppkg.modify(sub_it, coopname, [&](auto &s) {
|
||||
s.chain_id = chain_id;
|
||||
s.plan = plan;
|
||||
s.active = true;
|
||||
s.end_at = end_at > s.end_at ? end_at : s.end_at;
|
||||
s.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* \brief Обновление параметров кооператива.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Все параметры опциональны — обновляются только переданные. Сценарии
|
||||
* использования:
|
||||
* - Ротация подписи: `setcoop(coopname, signing_key=<new>)`. Меняет
|
||||
* `signing_key` и `key_rotated_at=now`.
|
||||
* - Деактивация: `setcoop(coopname, active=false)`. CA-auth перестанет
|
||||
* отвечать на запросы от этого коопа.
|
||||
* - Миграция между подсетями: `setcoop(coopname, chain_id=<new>, subnet_label=<new>)`.
|
||||
* - Обновление human-label: `setcoop(coopname, subnet_label=<new>)`.
|
||||
*
|
||||
* \note Ранее выпущенные JWT (TTL 14 дней в CA-auth) остаются валидны
|
||||
* после ротации `signing_key`. Только новые signed-request'ы должны
|
||||
* идти от нового приват-ключа. Это даёт zero-downtime ротации.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active. Если `@active`-ключ
|
||||
* коопаккаунта скомпрометирован, эта операция не доступна злоумышленнику —
|
||||
* сначала кооператив должен ротировать `@active` через
|
||||
* `eosio::updateauth` (это отдельная операция, владелец-ключ).
|
||||
*/
|
||||
void apps::setcoop(eosio::name coopname,
|
||||
std::optional<eosio::checksum256> chain_id,
|
||||
std::optional<eosio::name> subnet_label,
|
||||
std::optional<eosio::public_key> signing_key,
|
||||
std::optional<bool> active) {
|
||||
require_auth(coopname);
|
||||
|
||||
coops_index coops(_apps, _apps.value);
|
||||
auto it = coops.find(coopname.value);
|
||||
eosio::check(it != coops.end(), "Кооператив не зарегистрирован — используйте regcoop");
|
||||
|
||||
bool nothing_to_change = !chain_id.has_value()
|
||||
&& !subnet_label.has_value()
|
||||
&& !signing_key.has_value()
|
||||
&& !active.has_value();
|
||||
eosio::check(!nothing_to_change, "Нет полей для обновления");
|
||||
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
coops.modify(it, coopname, [&](auto &c) {
|
||||
if (chain_id.has_value()) c.chain_id = *chain_id;
|
||||
if (subnet_label.has_value()) {
|
||||
eosio::check(subnet_label->value != 0, "subnet_label не может быть пустым");
|
||||
c.subnet_label = *subnet_label;
|
||||
}
|
||||
if (signing_key.has_value()) {
|
||||
c.signing_key = *signing_key;
|
||||
c.key_rotated_at = now_tps;
|
||||
}
|
||||
if (active.has_value()) c.active = *active;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* \brief Опубликовать новый release пакета — атомарный transition.
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Алгоритм (выполняется в одной транзакции):
|
||||
*
|
||||
* 1. Проверки: пакет существует; статус — известный; scope согласован
|
||||
* (kind=all → targets пустой; kind=subnet/canary → targets непустой).
|
||||
* 2. Выполнить supersede предыдущего active с тем же scope (точное
|
||||
* совпадение `kind` + `targets`). Записывается `superseded_at=now`.
|
||||
* Если кто-то уже superseded — пропускаем.
|
||||
* 3. Создать новый row со `status=active`, `superseded_at=0`.
|
||||
* 4. Если `scope.kind == "all"_n` — обновить `packages.last_active_version`.
|
||||
* 5. Inline-cleanup: пройтись по `bysuperseded`-индексу для этого
|
||||
* пакета и удалить до `CLEANUP_BUDGET_PER_CALL` записей с
|
||||
* `superseded_at < now - RELEASE_RETENTION_SECS`.
|
||||
*
|
||||
* Это ядро «atomic moderation approve» (FR8): индекс CA + KE.releases
|
||||
* — либо обе, либо ни одна. Со стороны blockchain'а — гарантия атомарности
|
||||
* в пределах одной транзакции.
|
||||
*
|
||||
* \note Re-publish одной и той же `version` запрещён: если releases
|
||||
* уже содержит row для (package_id, version) — фейл. Версии
|
||||
* публикуются монотонно вверх; для отката используется
|
||||
* `reactivate(version=старая)`.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::setrelease(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version,
|
||||
Apps::scope_t scope,
|
||||
eosio::checksum256 tarball_sha256,
|
||||
eosio::name moderated_by,
|
||||
std::string meta) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(!version.empty() && version.size() <= 64, "Некорректная version");
|
||||
eosio::check(scope.kind == "all"_n || scope.kind == "subnet"_n || scope.kind == "canary"_n,
|
||||
"scope.kind должен быть all|subnet|canary");
|
||||
if (scope.kind == "all"_n) {
|
||||
eosio::check(scope.targets.empty(), "Для scope=all targets должен быть пустым");
|
||||
} else {
|
||||
eosio::check(!scope.targets.empty(), "Для scope=subnet|canary targets обязателен");
|
||||
}
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto pkg_it = packages.find(package_id.value);
|
||||
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
|
||||
|
||||
releases_index releases(_apps, _apps.value);
|
||||
auto by_pkgstat = releases.get_index<"bypkgstat"_n>();
|
||||
|
||||
auto now_sec = eosio::current_time_point().sec_since_epoch();
|
||||
auto now_tps = eosio::time_point_sec(now_sec);
|
||||
|
||||
// Запретить дубликат version — пройтись по всем релизам пакета.
|
||||
auto by_pkg = releases.get_index<"bypackage"_n>();
|
||||
for (auto rit = by_pkg.lower_bound(package_id.value);
|
||||
rit != by_pkg.end() && rit->package_id == package_id;
|
||||
++rit) {
|
||||
eosio::check(rit->version != version,
|
||||
"Релиз с такой version уже опубликован — используйте reactivate");
|
||||
}
|
||||
|
||||
// Supersede существующих active с тем же scope.
|
||||
uint128_t key_active = ((uint128_t)package_id.value << 64) | "active"_n.value;
|
||||
auto act_it = by_pkgstat.lower_bound(key_active);
|
||||
while (act_it != by_pkgstat.end()
|
||||
&& ((uint128_t)act_it->package_id.value << 64 | act_it->status.value) == key_active) {
|
||||
if (act_it->scope.kind == scope.kind && act_it->scope.targets == scope.targets) {
|
||||
// Найден active с тем же scope → supersede.
|
||||
auto stash = act_it;
|
||||
++act_it;
|
||||
releases.modify(*stash, coopname, [&](auto &r) {
|
||||
r.status = "superseded"_n;
|
||||
r.superseded_at = now_tps;
|
||||
});
|
||||
} else {
|
||||
++act_it;
|
||||
}
|
||||
}
|
||||
|
||||
// Создать новый active.
|
||||
uint64_t new_id = get_global_id(_apps, "release"_n);
|
||||
releases.emplace(coopname, [&](auto &r) {
|
||||
r.id = new_id;
|
||||
r.package_id = package_id;
|
||||
r.version = version;
|
||||
r.scope = scope;
|
||||
r.status = "active"_n;
|
||||
r.published_at = now_tps;
|
||||
r.superseded_at = eosio::time_point_sec(0);
|
||||
r.moderated_by = moderated_by;
|
||||
r.tarball_sha256 = tarball_sha256;
|
||||
r.meta = meta;
|
||||
});
|
||||
|
||||
// Обновить last_active_version только для scope=all.
|
||||
if (scope.kind == "all"_n) {
|
||||
packages.modify(pkg_it, coopname, [&](auto &p) {
|
||||
p.last_active_version = version;
|
||||
p.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
|
||||
// Inline-cleanup TTL-просроченных superseded для этого пакета.
|
||||
uint128_t key_super = ((uint128_t)package_id.value << 64) | "superseded"_n.value;
|
||||
uint64_t threshold = now_sec > Apps::RELEASE_RETENTION_SECS
|
||||
? now_sec - Apps::RELEASE_RETENTION_SECS
|
||||
: 0;
|
||||
uint64_t removed = 0;
|
||||
auto sup_it = by_pkgstat.lower_bound(key_super);
|
||||
while (sup_it != by_pkgstat.end()
|
||||
&& ((uint128_t)sup_it->package_id.value << 64 | sup_it->status.value) == key_super
|
||||
&& removed < Apps::CLEANUP_BUDGET_PER_CALL) {
|
||||
if (sup_it->superseded_at.sec_since_epoch() < threshold) {
|
||||
sup_it = by_pkgstat.erase(sup_it);
|
||||
++removed;
|
||||
} else {
|
||||
++sup_it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* \brief Передача владения пакетом другому пользователю (FR3).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Меняет `owner` на `new_owner`. Прав на сами релизы это не меняет —
|
||||
* `setrelease` всё равно выполняется провайдером каталога (`coopname`),
|
||||
* `owner` — это поле для аудита и UI «чей это пакет».
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::transferpkg(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
eosio::name new_owner) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(new_owner.value != 0, "new_owner не может быть пустым");
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto it = packages.find(package_id.value);
|
||||
eosio::check(it != packages.end(), "Пакет не найден");
|
||||
eosio::check(it->owner != new_owner, "Пакет уже принадлежит этому владельцу");
|
||||
|
||||
auto now = eosio::current_time_point().sec_since_epoch();
|
||||
packages.modify(it, coopname, [&](auto &p) {
|
||||
p.owner = new_owner;
|
||||
p.updated_at = eosio::time_point_sec(now);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* \brief Отозвать релиз (CVE / нарушение / юридический отзыв).
|
||||
* \ingroup public_apps_actions
|
||||
*
|
||||
* Семантика отзыва:
|
||||
* - Релиз становится `withdrawn` и НЕ может быть установлен через CA-auth.
|
||||
* - Запись остаётся в таблице — TTL на withdrawn не действует.
|
||||
* - Если withdrawn-релиз был active со scope=all, `last_active_version`
|
||||
* обнуляется ("") — UI должен показать «нет активной версии», пока
|
||||
* оператор не выпустит новый setrelease или не реактивирует старую.
|
||||
* - Если был active со scope=subnet/canary, `last_active_version` не
|
||||
* трогается — она показывает версию для основной массы кооперативов.
|
||||
*
|
||||
* \note Авторизация: @p coopname @ active.
|
||||
*/
|
||||
void apps::withdraw(eosio::name coopname,
|
||||
eosio::name package_id,
|
||||
std::string version,
|
||||
std::string reason) {
|
||||
require_auth(coopname);
|
||||
|
||||
eosio::check(reason.size() <= 512, "reason слишком длинный (макс 512)");
|
||||
|
||||
packages_index packages(_apps, _apps.value);
|
||||
auto pkg_it = packages.find(package_id.value);
|
||||
eosio::check(pkg_it != packages.end(), "Пакет не зарегистрирован");
|
||||
|
||||
releases_index releases(_apps, _apps.value);
|
||||
auto by_pkg = releases.get_index<"bypackage"_n>();
|
||||
|
||||
auto target = by_pkg.end();
|
||||
for (auto rit = by_pkg.lower_bound(package_id.value);
|
||||
rit != by_pkg.end() && rit->package_id == package_id;
|
||||
++rit) {
|
||||
if (rit->version == version) {
|
||||
target = rit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
eosio::check(target != by_pkg.end(), "Релиз не найден");
|
||||
eosio::check(target->status != "withdrawn"_n, "Релиз уже отозван");
|
||||
|
||||
bool was_active_all = target->status == "active"_n
|
||||
&& target->scope.kind == "all"_n;
|
||||
auto now_tps = eosio::time_point_sec(eosio::current_time_point().sec_since_epoch());
|
||||
|
||||
by_pkg.modify(target, coopname, [&](auto &r) {
|
||||
r.status = "withdrawn"_n;
|
||||
// superseded_at оставляем как есть; meta обогащаем reason'ом.
|
||||
if (!reason.empty()) {
|
||||
if (!r.meta.empty()) r.meta += "\n";
|
||||
r.meta += "withdraw_reason: " + reason;
|
||||
}
|
||||
});
|
||||
|
||||
if (was_active_all) {
|
||||
packages.modify(pkg_it, coopname, [&](auto &p) {
|
||||
p.last_active_version = "";
|
||||
p.updated_at = now_tps;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
#define LOAN "loan"
|
||||
#define MEET "meet"
|
||||
#define LEDGER "ledger"
|
||||
#define APPS "apps"
|
||||
|
||||
|
||||
/**
|
||||
@@ -161,6 +162,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
static constexpr eosio::name _loan = "loan"_n;
|
||||
static constexpr eosio::name _meet = "meet"_n;
|
||||
static constexpr eosio::name _ledger = "ledger"_n;
|
||||
static constexpr eosio::name _apps = "apps"_n;
|
||||
static constexpr eosio::name _power_account = "eosio.power"_n;
|
||||
static constexpr eosio::name _saving_account = "eosio.saving"_n;
|
||||
|
||||
@@ -187,7 +189,8 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
"eosio.power"_n,
|
||||
"eosio.saving"_n,
|
||||
"ledger"_n,
|
||||
"capital"_n
|
||||
"capital"_n,
|
||||
"apps"_n
|
||||
// Добавьте остальные стандартные или пользовательские контракты по необходимости
|
||||
};
|
||||
|
||||
|
||||
@@ -60,3 +60,9 @@
|
||||
#include "table_marketplace_requests.hpp"
|
||||
#include "table_marketplace_segments.hpp"
|
||||
#include "table_marketplace_shipments.hpp"
|
||||
|
||||
// apps (каталог приложений)
|
||||
#include "table_apps_packages.hpp"
|
||||
#include "table_apps_releases.hpp"
|
||||
#include "table_apps_subs.hpp"
|
||||
#include "table_apps_coops.hpp"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Запись каталога: кооператив, зарегистрированный в каталоге.
|
||||
*
|
||||
* Регистрация кооператива в каталоге — это часть процесса подключения
|
||||
* к каталогу приложений на Восходе (вне scope текущего MVP). Шаги:
|
||||
*
|
||||
* 1. Кооператив проходит onboarding в кабинете ВОСХОД.
|
||||
* 2. Кабинет генерирует/принимает выделенный subnet-signing-keypair.
|
||||
* Приватная часть остаётся у DC-оркестратора кооператива.
|
||||
* 3. Кабинет вызывает `regcoop(coopname, chain_id, subnet_label, signing_key)`
|
||||
* с публичной частью.
|
||||
*
|
||||
* Поле `signing_key` — это **отдельный** ключ, не равный `eosio::active`-ключу
|
||||
* аккаунта. Семантика:
|
||||
*
|
||||
* - Используется только off-chain в `CA-auth` для проверки подписи
|
||||
* `signed-request`-ов от DC-оркестратора (заголовок `X-Signature`).
|
||||
* - НЕ имеет on-chain прав (`active` остаётся отдельно).
|
||||
* - Ротация — отдельным action `setcoop(signing_key=new)`. Компрометация
|
||||
* `active` не лопает signed-request-цепочку и наоборот.
|
||||
* - Ранее выпущенные JWT (TTL 14 дней) остаются валидны после ротации;
|
||||
* только новые signed-request'ы должны идти от нового приват-ключа.
|
||||
*
|
||||
* `chain_id` (`checksum256`) — каноническая идентификация подсети, в которой
|
||||
* зарегистрирован кооператив (как в memory `subnet_identification` в
|
||||
* apps-catalog). `subnet_label` (`ru`/`by`/...) — human-label, удобный
|
||||
* для UI, scope-фильтрации в `releases`, и логирования.
|
||||
*
|
||||
* Контракт сам подпись не проверяет — он только хранит публичный ключ.
|
||||
* Проверка ECDSA secp256k1 — на стороне `CA-auth` через RPC
|
||||
* `get_table_rows apps coops`.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] coop {
|
||||
name coopname; // PK = Antelope name
|
||||
checksum256 chain_id; // подсеть (каноническая идентификация)
|
||||
name subnet_label; // human-label "ru"/"by" для UI/scope
|
||||
public_key signing_key; // subnet-signing-key (НЕ active)
|
||||
bool active;
|
||||
time_point_sec registered_at;
|
||||
time_point_sec key_rotated_at; // последняя ротация signing_key
|
||||
|
||||
uint64_t primary_key() const { return coopname.value; }
|
||||
uint64_t by_subnet() const { return subnet_label.value; }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"coops"_n, coop,
|
||||
eosio::indexed_by<"bysubnet"_n, eosio::const_mem_fun<coop, uint64_t, &coop::by_subnet>>>
|
||||
coops_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../consts.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Запись каталога: один зарегистрированный пакет.
|
||||
*
|
||||
* Идентификация: PK = `package_id` (Antelope `name`, ≤12 символов, [a-z1-5.]).
|
||||
* Имя выбирает разработчик при публикации; уникальность гарантируется блокчейном
|
||||
* (`emplace` фейлится, если такой `package_id` уже зарегистрирован).
|
||||
*
|
||||
* Имена пакета:
|
||||
* - `package_id` — кодовое неизменяемое имя в каталоге (этот PK).
|
||||
* - `package_name` — внешнее человекочитаемое имя (например, npm
|
||||
* `@voskhod/sports-club-tracker`, go-module path или OCI-image name).
|
||||
* Меняется через `setpkgname` (если потребуется) или re-publish; в текущей
|
||||
* схеме допускается изменение через rename-action в будущем.
|
||||
*
|
||||
* `compatible_subnets` — human-labels подсетей (`ru`, `by`, ...). Реальная
|
||||
* идентификация подсети — через `chain_id`, который хранится в `coops`/`subs`;
|
||||
* здесь labels достаточно, потому что они используются только для display
|
||||
* и грубой фильтрации в UI.
|
||||
*
|
||||
* `last_active_version` — semver самого свежего active-релиза c `scope.kind=all`.
|
||||
* Поле обновляется только при `setrelease` со scope=all и используется для
|
||||
* быстрого ответа `GET /v1/package/:id` без сканирования таблицы `releases`.
|
||||
* Canary-релизы это поле не трогают — оно отражает «версию по умолчанию»
|
||||
* для кооперативов вне canary-целей.
|
||||
*
|
||||
* \see lib/domain/table_apps_releases.hpp — связанные релизы (FK by_package).
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] package {
|
||||
name package_id; // PK, кодовое имя
|
||||
std::string package_name; // внешнее имя (npm/go/oci)
|
||||
name owner; // владелец (username разработчика)
|
||||
std::vector<name> compatible_subnets; // labels подсетей: "ru","by",...
|
||||
std::string last_active_version; // semver последнего scope=all active
|
||||
time_point_sec created_at;
|
||||
time_point_sec updated_at;
|
||||
|
||||
uint64_t primary_key() const { return package_id.value; }
|
||||
uint64_t by_owner() const { return owner.value; }
|
||||
uint64_t by_subnet() const {
|
||||
return compatible_subnets.empty() ? 0 : compatible_subnets[0].value;
|
||||
}
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"packages"_n, package,
|
||||
eosio::indexed_by<"byowner"_n, eosio::const_mem_fun<package, uint64_t, &package::by_owner>>,
|
||||
eosio::indexed_by<"bysubnet"_n, eosio::const_mem_fun<package, uint64_t, &package::by_subnet>>>
|
||||
packages_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "../core/utils.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Описание области видимости релиза.
|
||||
*
|
||||
* Возможные `kind`:
|
||||
* - `"all"_n` — релиз доступен всем кооперативам всех совместимых подсетей.
|
||||
* `targets` — пустой.
|
||||
* - `"subnet"_n` — релиз ограничен набором подсетей (по human-label
|
||||
* `subnet_label` из `coops`). `targets` — список labels.
|
||||
* - `"canary"_n` — релиз раскатывается на конкретные кооперативы.
|
||||
* `targets` — список `coopname`.
|
||||
*
|
||||
* Контракт scope-resolver не делает: он хранит scope как есть, а правило
|
||||
* приоритизации `canary > subnet > all` применяется на стороне `CA-auth`
|
||||
* при выпуске JWT.
|
||||
*/
|
||||
struct scope_t {
|
||||
name kind; // "all" | "subnet" | "canary"
|
||||
std::vector<name> targets; // labels подсетей или coopname'ы
|
||||
|
||||
EOSLIB_SERIALIZE(scope_t, (kind)(targets))
|
||||
};
|
||||
|
||||
/**
|
||||
* \brief Запись каталога: один релиз пакета.
|
||||
*
|
||||
* Жизненный цикл записи:
|
||||
*
|
||||
* 1. `setrelease` создаёт row со `status=active`, `superseded_at=0`.
|
||||
* 2. Любой существующий active с тем же `(package_id, scope)` переходит
|
||||
* в `superseded`, `superseded_at=now`.
|
||||
* 3. `withdraw` ставит `status=withdrawn` (релиз скомпрометирован /
|
||||
* отозван юридически). `withdrawn` НЕ удаляются по TTL — храним до
|
||||
* явной чистки.
|
||||
* 4. TTL retention: `superseded` старше 90 дней удаляются inline в
|
||||
* `setrelease` (до 50 за вызов) или по явному `cleanup(package_id)`.
|
||||
*
|
||||
* Решение НЕ хранить полную историю принято намеренно: каталог — это
|
||||
* source-of-truth текущего состояния, история живёт в git/docker registry.
|
||||
* RAM-budget при 1000 пакетов × ~10 релизов в окне = ~40K записей.
|
||||
*
|
||||
* Indexing strategy:
|
||||
* - PK `id` — auto-inc через `get_global_id(_apps, "release"_n)`.
|
||||
* - `bypackage` — выборка всех релизов пакета (`CA` для scope-resolver).
|
||||
* - `bypkgstat` — выборка active/superseded/withdrawn внутри пакета
|
||||
* (composite `package_id<<8 | status_code`); удобно для cleanup
|
||||
* «найти все superseded для пакета X».
|
||||
* - `bysuperseded` — sorted by `superseded_at`; используется в cleanup
|
||||
* для нахождения самых старых записей под удаление.
|
||||
*
|
||||
* \note `tarball_sha256` — содержательный хэш артефакта, нужен для
|
||||
* integrity-check при скачивании. Сама проверка — на `CA-auth`,
|
||||
* контракт хэш только хранит.
|
||||
*
|
||||
* \see lib/domain/table_apps_packages.hpp
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] release {
|
||||
uint64_t id;
|
||||
name package_id; // FK → packages.package_id
|
||||
std::string version; // semver
|
||||
scope_t scope;
|
||||
name status; // "active" | "superseded" | "withdrawn"
|
||||
time_point_sec published_at;
|
||||
time_point_sec superseded_at; // 0 пока active
|
||||
name moderated_by; // имя модератора (audit)
|
||||
checksum256 tarball_sha256;
|
||||
std::string meta; // JSON-строка: changelog, релиз-нотес и т.п.
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint64_t by_package() const { return package_id.value; }
|
||||
uint128_t by_pkg_status() const { return ((uint128_t)package_id.value << 64) | status.value; }
|
||||
uint64_t by_superseded() const { return superseded_at.sec_since_epoch(); }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"releases"_n, release,
|
||||
eosio::indexed_by<"bypackage"_n, eosio::const_mem_fun<release, uint64_t, &release::by_package>>,
|
||||
eosio::indexed_by<"bypkgstat"_n, eosio::const_mem_fun<release, uint128_t, &release::by_pkg_status>>,
|
||||
eosio::indexed_by<"bysuperseded"_n, eosio::const_mem_fun<release, uint64_t, &release::by_superseded>>>
|
||||
releases_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "../core/utils.hpp"
|
||||
|
||||
namespace Apps {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* \brief Запись каталога: подписка кооператива на пакет.
|
||||
*
|
||||
* Идентификация подписки — composite `(coopname, package_id, chain_id)`:
|
||||
* - `coopname` — кто подписан (Antelope name).
|
||||
* - `package_id` — на какой пакет.
|
||||
* - `chain_id` — в какой подсети действует подписка. Контракт `apps`
|
||||
* деплоится на корневой координационной KE-цепи и обслуживает
|
||||
* несколько подсетей; chain_id привязывает подписку к конкретной
|
||||
* подсети, в которой кооператив находится.
|
||||
*
|
||||
* `plan` — имя плана (`basic`, `premium`, ...). Это **только метка**:
|
||||
* биллинг (расчёт стоимости, приём оплаты, выставление счёта) — задача
|
||||
* кабинета ВОСХОД и контракта `pay.coopenomics`. Сюда приходит уже факт
|
||||
* «подписка активна с X по Y по плану Z».
|
||||
*
|
||||
* `regsub` идемпотентно по `(coopname, package_id)`: повторный вызов с
|
||||
* тем же ключом продлевает `end_at` (= `max(текущее, новое)`) и
|
||||
* обновляет `plan`/`active`. Это поведение сознательное — биллинг при
|
||||
* retry не должен бояться дубликатов.
|
||||
*
|
||||
* `expsub` НЕ удаляет row, а ставит `active=false`. Запись остаётся
|
||||
* для аудита истечения подписки. Cleanup просроченных записей — задача
|
||||
* отдельного периодического действия (вне MVP).
|
||||
*
|
||||
* Indexing:
|
||||
* - PK `id` — auto-inc.
|
||||
* - `bycooppkg` — composite (coopname, package_id) для idempotent upsert
|
||||
* и проверки «есть ли активная подписка коопа X на пакет Y».
|
||||
* - `bycoop` — все подписки одного кооператива.
|
||||
* - `byexpires` — sorted by `end_at` для будущего auto-cleanup.
|
||||
*
|
||||
* \see lib/domain/table_apps_coops.hpp — chain_id ↔ кооператив.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(APPS)]] sub {
|
||||
uint64_t id;
|
||||
name coopname;
|
||||
name package_id;
|
||||
checksum256 chain_id;
|
||||
name plan;
|
||||
bool active;
|
||||
time_point_sec start_at;
|
||||
time_point_sec end_at;
|
||||
time_point_sec created_at;
|
||||
time_point_sec updated_at;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint128_t by_cooppkg() const { return ((uint128_t)coopname.value << 64) | package_id.value; }
|
||||
uint64_t by_coop() const { return coopname.value; }
|
||||
uint64_t by_expires() const { return end_at.sec_since_epoch(); }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"subs"_n, sub,
|
||||
eosio::indexed_by<"bycooppkg"_n, eosio::const_mem_fun<sub, uint128_t, &sub::by_cooppkg>>,
|
||||
eosio::indexed_by<"bycoop"_n, eosio::const_mem_fun<sub, uint64_t, &sub::by_coop>>,
|
||||
eosio::indexed_by<"byexpires"_n, eosio::const_mem_fun<sub, uint64_t, &sub::by_expires>>>
|
||||
subs_index;
|
||||
|
||||
} // namespace Apps
|
||||
@@ -0,0 +1,44 @@
|
||||
# Образ-артефакт для всей пачки контрактов кооперативной экономики.
|
||||
#
|
||||
# Назначение: переносить wasm/abi всех контрактов между этапами CI/CD
|
||||
# и потребителями (ke-bootstrap, локальные dev-окружения, аудит).
|
||||
# Сам по себе образ ничего не запускает на блокчейне — его пара —
|
||||
# отдельный образ ke-bootstrap, который при старте ноды деплоит
|
||||
# контракты на неё через `cleos set contract`.
|
||||
#
|
||||
# Содержимое:
|
||||
# /contracts/<name>/<name>.wasm
|
||||
# /contracts/<name>/<name>.abi
|
||||
# /contracts/manifest.json — список контрактов + sha256 + build_mode
|
||||
#
|
||||
# Способы потребления:
|
||||
#
|
||||
# 1) Multi-stage в bootstrap-образе (production):
|
||||
# FROM dicoop/contracts:testnet AS contracts
|
||||
# ...
|
||||
# COPY --from=contracts /contracts /work/contracts
|
||||
#
|
||||
# 2) Volume-mount (debug / ad-hoc):
|
||||
# docker run --rm -v $PWD/out:/out dicoop/contracts:testnet
|
||||
# → ./out/<name>/{wasm,abi} + manifest.json
|
||||
#
|
||||
# 3) Точечно один контракт:
|
||||
# docker run --rm -v $PWD/out:/out dicoop/contracts:testnet copy apps
|
||||
# → ./out/apps/{wasm,abi}
|
||||
#
|
||||
# Сборка артефактов идёт в CI до docker build через CDT-образ
|
||||
# `dicoop/blockchain_v5.1.1:dev` (см. components/contracts/build-all.sh).
|
||||
# Этот Dockerfile только упаковывает результат в slim alpine.
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN mkdir -p /contracts
|
||||
COPY contracts/ /contracts/
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
LABEL org.opencontainers.image.title="coopenomics-contracts"
|
||||
LABEL org.opencontainers.image.description="WASM+ABI артефакты всех контрактов кооперативной экономики"
|
||||
LABEL org.opencontainers.image.source="https://github.com/coopenomics/mono"
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["copy"]
|
||||
@@ -0,0 +1,111 @@
|
||||
# `dicoop/contracts` — образ-артефакт всех контрактов
|
||||
|
||||
Контейнер собирает в один slim alpine-образ wasm/abi всех контрактов
|
||||
кооперативной экономики (`apps, marketplace, ledger, meet, loan, wallet,
|
||||
capital, branch, contributor, fund, draft, soviet, registrator, gateway,
|
||||
system, test`).
|
||||
|
||||
## Зачем отдельный образ
|
||||
|
||||
- Нода (`dicoop/blockchain_v5.1.1`) — это runtime с `nodeos`. Она
|
||||
одна и не должна повторно собираться вместе с контрактами.
|
||||
- Сборка контрактов (через CDT) — отдельный шаг CI, выполняется в том
|
||||
же `dicoop/blockchain_v5.1.1`-образе но в режиме toolchain.
|
||||
- Деплой — третий артефакт (`ke-bootstrap`), который читает контракты
|
||||
отсюда и применяет их к ноде через `cleos set contract`.
|
||||
|
||||
Эта декомпозиция даёт переиспользование: одна нода + N версий контрактов
|
||||
+ один bootstrap.
|
||||
|
||||
## Layout внутри образа
|
||||
|
||||
```
|
||||
/contracts/
|
||||
├── apps/
|
||||
│ ├── apps.wasm
|
||||
│ └── apps.abi
|
||||
├── marketplace/
|
||||
│ ├── marketplace.wasm
|
||||
│ └── marketplace.abi
|
||||
├── ...
|
||||
└── manifest.json # имена + sha256 + build_mode + git sha + timestamp
|
||||
```
|
||||
|
||||
## Тэги
|
||||
|
||||
| Ветка | Tag | `IS_TESTNET` |
|
||||
| --- | --- | --- |
|
||||
| `dev` | `dicoop/contracts:dev` | ON |
|
||||
| `testnet` | `dicoop/contracts:testnet` | ON |
|
||||
| `main` | `dicoop/contracts:main` + `:latest` | OFF |
|
||||
|
||||
Дополнительно для воспроизводимости каждый push публикуется как
|
||||
`dicoop/contracts:<tag>-<short_sha>`.
|
||||
|
||||
## Использование
|
||||
|
||||
### 1. Multi-stage в bootstrap-образе (production)
|
||||
|
||||
```dockerfile
|
||||
FROM dicoop/contracts:testnet AS contracts
|
||||
|
||||
FROM dicoop/blockchain_v5.1.1:dev
|
||||
COPY --from=contracts /contracts /work/contracts
|
||||
# ... bootstrap-script использует /work/contracts при cleos set contract
|
||||
```
|
||||
|
||||
### 2. Volume-mount (debug, локальные dev-окружения)
|
||||
|
||||
```bash
|
||||
mkdir -p ./out
|
||||
docker run --rm -v "$PWD/out:/out" dicoop/contracts:testnet
|
||||
ls out/
|
||||
# apps/ marketplace/ ledger/ ... manifest.json
|
||||
```
|
||||
|
||||
### 3. Точечно один контракт
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD/out:/out" dicoop/contracts:testnet copy apps
|
||||
ls out/apps/
|
||||
# apps.wasm apps.abi
|
||||
```
|
||||
|
||||
### 4. Аудит / диагностика
|
||||
|
||||
```bash
|
||||
# Список всех контрактов в образе:
|
||||
docker run --rm dicoop/contracts:testnet list
|
||||
|
||||
# Контрольные суммы:
|
||||
docker run --rm dicoop/contracts:testnet sha256
|
||||
|
||||
# Манифест с метаданными сборки:
|
||||
docker run --rm dicoop/contracts:testnet manifest | jq .
|
||||
```
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
```bash
|
||||
# 1. Скомпилировать контракты:
|
||||
cd components/contracts
|
||||
./build-all.sh prod # или ./build-all.sh test для testnet-режима
|
||||
|
||||
# 2. Подготовить docker context:
|
||||
mkdir -p docker/.context/contracts
|
||||
for c in apps marketplace ledger meet loan wallet capital branch \
|
||||
contributor fund draft soviet registrator gateway system test; do
|
||||
if [ -f build/contracts/$c/$c.wasm ]; then
|
||||
mkdir -p docker/.context/contracts/$c
|
||||
cp build/contracts/$c/$c.wasm docker/.context/contracts/$c/
|
||||
cp build/contracts/$c/$c.abi docker/.context/contracts/$c/
|
||||
fi
|
||||
done
|
||||
cp docker/Dockerfile docker/entrypoint.sh docker/.context/
|
||||
|
||||
# 3. Build:
|
||||
docker build -t dicoop/contracts:local ./docker/.context
|
||||
```
|
||||
|
||||
В CI этот pipeline автоматизирован — см.
|
||||
`.github/workflows/build-contracts.yaml`.
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint образа dicoop/contracts.
|
||||
# Команды:
|
||||
# copy [<name>] — копировать в $OUT_DIR (по умолчанию /out):
|
||||
# без имени — всё дерево /contracts;
|
||||
# с именем — только /contracts/<name>/.
|
||||
# manifest — вывести manifest.json в stdout.
|
||||
# sha256 [<name>] — sha256 wasm/abi для всех или одного контракта.
|
||||
# ls [<name>] — листинг /contracts (рекурсивно для всего, или один).
|
||||
# list — печать имён контрактов через newline (для скриптов).
|
||||
#
|
||||
# Default: copy (всё дерево).
|
||||
set -eu
|
||||
|
||||
cmd="${1:-copy}"
|
||||
shift || true
|
||||
|
||||
list_names() {
|
||||
# Имена контрактов = имена subdirectories в /contracts.
|
||||
for d in /contracts/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
basename "$d"
|
||||
done
|
||||
}
|
||||
|
||||
assert_contract() {
|
||||
name="$1"
|
||||
if [ ! -d "/contracts/$name" ]; then
|
||||
echo "Контракт '$name' не найден в образе. Доступные:" >&2
|
||||
list_names >&2
|
||||
exit 65
|
||||
fi
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
copy)
|
||||
DEST="${OUT_DIR:-/out}"
|
||||
mkdir -p "$DEST"
|
||||
if [ $# -ge 1 ]; then
|
||||
name="$1"
|
||||
assert_contract "$name"
|
||||
mkdir -p "$DEST/$name"
|
||||
cp /contracts/"$name"/*.wasm "$DEST/$name/" 2>/dev/null || true
|
||||
cp /contracts/"$name"/*.abi "$DEST/$name/" 2>/dev/null || true
|
||||
echo "[contracts] copied $name → $DEST/$name"
|
||||
else
|
||||
cp -R /contracts/. "$DEST/"
|
||||
echo "[contracts] copied all artifacts → $DEST"
|
||||
fi
|
||||
ls -la "$DEST" 2>/dev/null || true
|
||||
;;
|
||||
manifest)
|
||||
cat /contracts/manifest.json
|
||||
;;
|
||||
sha256)
|
||||
if [ $# -ge 1 ]; then
|
||||
name="$1"
|
||||
assert_contract "$name"
|
||||
sha256sum /contracts/"$name"/*.wasm /contracts/"$name"/*.abi
|
||||
else
|
||||
find /contracts -name '*.wasm' -o -name '*.abi' | sort | xargs sha256sum
|
||||
fi
|
||||
;;
|
||||
ls)
|
||||
if [ $# -ge 1 ]; then
|
||||
name="$1"
|
||||
assert_contract "$name"
|
||||
ls -la /contracts/"$name"/
|
||||
else
|
||||
ls -la /contracts/
|
||||
echo "---"
|
||||
find /contracts -mindepth 2 -maxdepth 2 | sort
|
||||
fi
|
||||
;;
|
||||
list)
|
||||
list_names
|
||||
;;
|
||||
*)
|
||||
cat >&2 <<EOF
|
||||
Usage: docker run --rm dicoop/contracts <cmd> [name]
|
||||
|
||||
Commands:
|
||||
copy [<name>] Copy all contracts (or one) to \$OUT_DIR (default: /out).
|
||||
manifest Print manifest.json (list of contracts, sha256, build mode).
|
||||
sha256 [<name>] sha256 of wasm/abi (all or one contract).
|
||||
ls [<name>] List directory contents.
|
||||
list Print contract names, one per line.
|
||||
EOF
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user