${escapeHtml(card.title)}
+ ${escapeHtml(card.metric)} + ${escapeHtml(card.description || "No description")} +PocketBase + Deno
+diff --git a/backend b/backend index 32a5f94..e8e2fe6 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 32a5f949d955bdd7e34b16349644d14569acf23c +Subproject commit e8e2fe6f7f76ef4d8c1bf088c00b53e7073fbb8d diff --git a/docs/adr/0002-pocketbase-gvisor-microapps.md b/docs/adr/0002-pocketbase-gvisor-microapps.md new file mode 100644 index 0000000..3bc2877 --- /dev/null +++ b/docs/adr/0002-pocketbase-gvisor-microapps.md @@ -0,0 +1,80 @@ +# ADR-0002: PocketBase + gVisor Platform For Agent-Built Microapps + +Дата: 2026-03-23 +Статус: accepted + +## Контекст + +Нужен минимальный платформенный слой, в котором агент может собирать микро-приложения для клиентов по жёсткому шаблону: + +- данные и auth живут в PocketBase; +- код приложения исполняется изолированно в Deno runtime; +- delivery идёт только из шаблонного репозитория и после валидации; +- control-plane хранится в основной Prisma-базе проекта. + +Ключевые ограничения: + +- агент не должен деплоить произвольный код вне шаблона; +- приложения разных клиентов должны быть логически и операционно изолированы; +- база данных и execution host разводятся в разные контуры; +- rollout и rollback должны опираться на versioned deploy records. + +## Решение + +Принимаем MVP-схему из двух data-plane компонентов и одного control-plane: + +1. `PocketBase` +- отдельный сервис данных для микро-приложений; +- каждое приложение получает собственный `pocketbaseInstanceId` и URL; +- доступ из микро-приложения идёт только через разрешённый service account. + +2. `Deno runner` в `gVisor` +- каждое приложение исполняется как контейнер с `runsc`; +- runtime получает лимиты по CPU/RAM/PIDs и whitelist по env/network; +- runner запускает только Deno entrypoint из шаблона микро-приложения. + +3. `backend` как control-plane +- хранит `MicroAppProject`, `MicroAppRuntimeConfig`, `MicroAppDeployment`; +- выдаёт конфиг для provisioning и delivery; +- знает путь репозитория, ветку, template checksum, preview/prod URL. + +## Поток + +1. Агент создаёт проект только из `microapps/templates/pocketbase-deno-dashboard`. +2. Валидатор проверяет структуру репозитория и `app.config.ts`. +3. Control-plane создаёт или обновляет запись `MicroAppProject`. +4. Delivery pipeline фиксирует новую версию в `MicroAppDeployment`. +5. Runner поднимает Deno-контейнер через `gVisor`. +6. Публикация идёт через preview domain, а promotion в основной домен выполняется отдельно. + +## Delivery + +- source of truth: git-репозиторий приложения; +- deploy unit: versioned template-compliant app directory; +- preview создаётся автоматически после успешной валидации; +- production переключается через отдельный promote шаг; +- rollback выполняется redeploy предыдущей записи `MicroAppDeployment`. + +## Домены + +На первом этапе платформа хранит: + +- `primaryDomain` +- `previewDomain` + +Привязка доменов и DNS-автоматизация выносятся в отдельный шаг интеграции с Cloudflare и не блокируют запуск control-plane MVP. + +## Последствия + +Плюсы: + +- появляется единый control-plane для клиентских микро-приложений; +- есть жёсткий шаблон и проверяемый runtime policy; +- база данных и execution host разделены; +- есть основа для preview/prod flow и rollback. + +Минусы: + +- runtime по-прежнему требует отдельного delivery automation поверх git; +- provisioning PocketBase instance пока остаётся внешней операцией; +- домены и TLS-автоматизация ещё не включены в MVP-каркас. diff --git a/microapps/runtime/Dockerfile b/microapps/runtime/Dockerfile new file mode 100644 index 0000000..7b9b3d1 --- /dev/null +++ b/microapps/runtime/Dockerfile @@ -0,0 +1,22 @@ +FROM debian:bookworm-slim + +ARG DENO_VERSION=2.6.4 + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl tini unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL "https://dl.deno.land/release/v${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \ + && unzip /tmp/deno.zip -d /usr/local/bin \ + && chmod +x /usr/local/bin/deno \ + && rm /tmp/deno.zip + +WORKDIR /workspace + +COPY microapps/runtime/entrypoint.sh /usr/local/bin/microapp-entrypoint + +RUN chmod +x /usr/local/bin/microapp-entrypoint + +ENV DENO_DIR=/deno-dir + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/microapp-entrypoint"] diff --git a/microapps/runtime/entrypoint.sh b/microapps/runtime/entrypoint.sh new file mode 100644 index 0000000..29a2477 --- /dev/null +++ b/microapps/runtime/entrypoint.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +APP_DIR="${APP_DIR:-/workspace/app}" +ENTRYPOINT_FILE="${ENTRYPOINT_FILE:-$APP_DIR/main.ts}" +ALLOW_READ="${ALLOW_READ:-$APP_DIR}" +ALLOW_ENV="${ALLOW_ENV:-PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG}" +ALLOW_NET="${ALLOW_NET:-127.0.0.1,localhost}" + +exec deno run \ + --no-prompt \ + --allow-read="$ALLOW_READ" \ + --allow-env="$ALLOW_ENV" \ + --allow-net="$ALLOW_NET" \ + "$ENTRYPOINT_FILE" diff --git a/microapps/runtime/run-with-gvisor.sh b/microapps/runtime/run-with-gvisor.sh new file mode 100644 index 0000000..71a25b8 --- /dev/null +++ b/microapps/runtime/run-with-gvisor.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env sh +set -eu + +IMAGE_NAME="${IMAGE_NAME:-clientflow/microapp-runtime}" +APP_DIR="${APP_DIR:-$(pwd)/microapps/templates/pocketbase-deno-dashboard}" +ALLOW_NET="${ALLOW_NET:-127.0.0.1,localhost}" + +docker run \ + --rm \ + --runtime=runsc \ + --memory=256m \ + --cpus=0.5 \ + --pids-limit=128 \ + -e APP_DIR=/workspace/app \ + -e ALLOW_NET="$ALLOW_NET" \ + -v "$APP_DIR:/workspace/app:ro" \ + "$IMAGE_NAME" diff --git a/microapps/templates/pocketbase-deno-dashboard/app.config.ts b/microapps/templates/pocketbase-deno-dashboard/app.config.ts new file mode 100644 index 0000000..2fc4d17 --- /dev/null +++ b/microapps/templates/pocketbase-deno-dashboard/app.config.ts @@ -0,0 +1,28 @@ +export default { + manifestVersion: 1, + name: "PocketBase Deno Dashboard", + slug: "pocketbase-deno-dashboard", + runtime: "deno-gvisor", + entrypoint: "./main.ts", + pocketbase: { + collections: ["dashboard_cards"], + collectionPrefix: "dashboard", + }, + permissions: { + allowEnv: [ + "PORT", + "POCKETBASE_URL", + "POCKETBASE_SERVICE_EMAIL", + "POCKETBASE_SERVICE_PASSWORD", + "APP_OWNER_TEAM_ID", + "APP_SLUG", + ], + allowNet: ["pocketbase.internal"], + allowRead: ["./"], + allowWrite: [], + }, + libraries: [ + "internal/pocketbase-client", + "internal/dashboard-ui", + ], +} as const; diff --git a/microapps/templates/pocketbase-deno-dashboard/deno.json b/microapps/templates/pocketbase-deno-dashboard/deno.json new file mode 100644 index 0000000..22350fb --- /dev/null +++ b/microapps/templates/pocketbase-deno-dashboard/deno.json @@ -0,0 +1,17 @@ +{ + "tasks": { + "dev": "deno run --watch --allow-read=. --allow-env=PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG --allow-net=127.0.0.1,localhost,pocketbase.internal main.ts", + "check": "deno check main.ts pocketbase/client.ts app.config.ts" + }, + "fmt": { + "lineWidth": 100, + "singleQuote": false + }, + "lint": { + "rules": { + "tags": [ + "recommended" + ] + } + } +} diff --git a/microapps/templates/pocketbase-deno-dashboard/main.ts b/microapps/templates/pocketbase-deno-dashboard/main.ts new file mode 100644 index 0000000..1f85030 --- /dev/null +++ b/microapps/templates/pocketbase-deno-dashboard/main.ts @@ -0,0 +1,162 @@ +import appConfig from "./app.config.ts"; +import { createPocketBaseClient } from "./pocketbase/client.ts"; + +const port = Number(Deno.env.get("PORT") || "8080"); +const pocketbaseUrl = mustGetEnv("POCKETBASE_URL"); +const pocketbaseEmail = mustGetEnv("POCKETBASE_SERVICE_EMAIL"); +const pocketbasePassword = mustGetEnv("POCKETBASE_SERVICE_PASSWORD"); +const ownerTeamId = mustGetEnv("APP_OWNER_TEAM_ID"); +const appSlug = Deno.env.get("APP_SLUG") || appConfig.slug; + +const pocketbase = createPocketBaseClient({ + baseUrl: pocketbaseUrl, + email: pocketbaseEmail, + password: pocketbasePassword, +}); + +type DashboardCard = { + id: string; + title: string; + metric: string; + description: string | null; +}; + +function renderHtml(cards: DashboardCard[]) { + const cardsMarkup = cards.length === 0 + ? `
No dashboard cards yet.
` + : cards.map((card) => ` +${escapeHtml(card.title)}
+ ${escapeHtml(card.metric)} + ${escapeHtml(card.description || "No description")} +PocketBase + Deno
+