feat: add telemetry build provenance
Stamp official Docker builds with a low-cardinality provenance marker and report build_provenance/image_modified in anonymous telemetry. Local and fork builds default to custom, while official GitHub/GitLab release paths mark images as official.
This commit is contained in:
+4
-3
@@ -79,9 +79,10 @@ TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
|
||||
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
||||
# maintainer can see how many installs are active and which versions/OSes are
|
||||
# used. It contains an opaque random install id and coarse facts only:
|
||||
# version, OS/arch, Python version, language, enabled payment providers and a
|
||||
# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any
|
||||
# personal information is ever sent. Full details: docs/configuration/telemetry.md
|
||||
# version, official/custom image provenance, OS/arch, Python version, language,
|
||||
# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot
|
||||
# token, domain, user data or any personal information is ever sent.
|
||||
# Full details: docs/configuration/telemetry.md
|
||||
#
|
||||
# Set to False to disable, or toggle it any time in Admin -> System ->
|
||||
# "Anonymous install analytics" (applies without a restart).
|
||||
|
||||
@@ -132,6 +132,7 @@ jobs:
|
||||
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||
build-args: |
|
||||
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||
REMNAWAVE_MINISHOP_BUILD_PROVENANCE=${{ github.repository == '3252a8/remnawave-minishop' && 'official' || 'custom' }}
|
||||
cache-from: type=gha,scope=${{ matrix.target }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||
provenance: false
|
||||
|
||||
@@ -43,12 +43,22 @@ docker-dev:
|
||||
exit 0
|
||||
fi
|
||||
- |
|
||||
provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}"
|
||||
if [ -z "$provenance" ]; then
|
||||
dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')"
|
||||
if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then
|
||||
provenance="official"
|
||||
else
|
||||
provenance="custom"
|
||||
fi
|
||||
fi
|
||||
docker buildx build \
|
||||
--load \
|
||||
--platform linux/amd64 \
|
||||
--file deploy/docker/Dockerfile \
|
||||
--target "$TARGET" \
|
||||
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
|
||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \
|
||||
--build-arg "BUILDKIT_INLINE_CACHE=1" \
|
||||
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
||||
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
||||
|
||||
@@ -170,6 +170,7 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def _persist_appearance_upload(
|
||||
request: web.Request,
|
||||
updates: Dict[str, Any],
|
||||
|
||||
@@ -707,10 +707,11 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"bool",
|
||||
"system",
|
||||
"Анонимная статистика установки",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
||||
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
||||
"доменов. Помогает понять число активных установок и какие версии "
|
||||
"используются. Можно отключить здесь без перезапуска.",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, маркер образа "
|
||||
"official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных "
|
||||
"данных, токенов и доменов. Помогает понять число активных установок, какие "
|
||||
"версии используются и долю изменённых сборок. Можно отключить здесь без "
|
||||
"перезапуска.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -29,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.utils.app_version import resolve_app_version, resolve_app_version_tag
|
||||
from bot.utils.app_version import (
|
||||
resolve_app_version,
|
||||
resolve_app_version_tag,
|
||||
resolve_build_provenance,
|
||||
resolve_image_modified,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import app_settings_dal, user_dal
|
||||
|
||||
@@ -169,11 +174,15 @@ class TelemetryWorker:
|
||||
|
||||
version = resolve_app_version()
|
||||
version_tag = resolve_app_version_tag()
|
||||
build_provenance = resolve_build_provenance()
|
||||
image_modified = resolve_image_modified()
|
||||
# Person properties (``$set``) snapshot the latest state per install, so
|
||||
# "version breakdown" in PostHog is a person-property breakdown.
|
||||
person_props = {
|
||||
"app_version": version,
|
||||
"app_version_tag": version_tag,
|
||||
"build_provenance": build_provenance,
|
||||
"image_modified": image_modified,
|
||||
"os": platform.system().lower() or "unknown",
|
||||
"arch": platform.machine().lower() or "unknown",
|
||||
"python_version": platform.python_version(),
|
||||
|
||||
@@ -6,6 +6,9 @@ the runtime container agree on the value:
|
||||
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
|
||||
> ``dev+unknown``
|
||||
|
||||
Build provenance is intentionally separate from the version: official release
|
||||
automation stamps official images, while local/fork builds default to custom.
|
||||
|
||||
The same value powers the admin sidebar (web process) and the anonymous
|
||||
telemetry beacon (worker process), so "active installs" and version
|
||||
breakdowns line up across both.
|
||||
@@ -24,6 +27,16 @@ from typing import Optional
|
||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
_APP_VERSION_CACHE: Optional[str] = None
|
||||
_APP_BUILD_PROVENANCE_CACHE: Optional[str] = None
|
||||
|
||||
BUILD_PROVENANCE_OFFICIAL = "official"
|
||||
BUILD_PROVENANCE_CUSTOM = "custom"
|
||||
BUILD_PROVENANCE_UNKNOWN = "unknown"
|
||||
_BUILD_PROVENANCE_VALUES = {
|
||||
BUILD_PROVENANCE_OFFICIAL,
|
||||
BUILD_PROVENANCE_CUSTOM,
|
||||
BUILD_PROVENANCE_UNKNOWN,
|
||||
}
|
||||
|
||||
|
||||
def _run_git_command(*args: str) -> str:
|
||||
@@ -87,6 +100,29 @@ def _read_build_file(name: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_build_provenance(raw: str) -> str:
|
||||
value = str(raw or "").strip().lower()
|
||||
if not value:
|
||||
return ""
|
||||
aliases = {
|
||||
"true": BUILD_PROVENANCE_OFFICIAL,
|
||||
"1": BUILD_PROVENANCE_OFFICIAL,
|
||||
"yes": BUILD_PROVENANCE_OFFICIAL,
|
||||
"upstream": BUILD_PROVENANCE_OFFICIAL,
|
||||
"release": BUILD_PROVENANCE_OFFICIAL,
|
||||
"false": BUILD_PROVENANCE_CUSTOM,
|
||||
"0": BUILD_PROVENANCE_CUSTOM,
|
||||
"no": BUILD_PROVENANCE_CUSTOM,
|
||||
"fork": BUILD_PROVENANCE_CUSTOM,
|
||||
"modified": BUILD_PROVENANCE_CUSTOM,
|
||||
"local": BUILD_PROVENANCE_CUSTOM,
|
||||
}
|
||||
value = aliases.get(value, value)
|
||||
if value in _BUILD_PROVENANCE_VALUES:
|
||||
return value
|
||||
return BUILD_PROVENANCE_CUSTOM
|
||||
|
||||
|
||||
def resolve_app_version() -> str:
|
||||
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
|
||||
global _APP_VERSION_CACHE
|
||||
@@ -124,3 +160,28 @@ def resolve_app_version_tag() -> str:
|
||||
if tag:
|
||||
return tag
|
||||
return resolve_app_version()
|
||||
|
||||
|
||||
def resolve_build_provenance() -> str:
|
||||
"""Low-cardinality image provenance: ``official``, ``custom`` or ``unknown``."""
|
||||
global _APP_BUILD_PROVENANCE_CACHE
|
||||
if _APP_BUILD_PROVENANCE_CACHE:
|
||||
return _APP_BUILD_PROVENANCE_CACHE
|
||||
|
||||
env_value = _normalize_build_provenance(os.getenv("REMNAWAVE_MINISHOP_BUILD_PROVENANCE", ""))
|
||||
if env_value:
|
||||
_APP_BUILD_PROVENANCE_CACHE = env_value
|
||||
return env_value
|
||||
|
||||
build_value = _normalize_build_provenance(_read_build_file(".build-provenance"))
|
||||
if build_value:
|
||||
_APP_BUILD_PROVENANCE_CACHE = build_value
|
||||
return build_value
|
||||
|
||||
_APP_BUILD_PROVENANCE_CACHE = BUILD_PROVENANCE_CUSTOM
|
||||
return _APP_BUILD_PROVENANCE_CACHE
|
||||
|
||||
|
||||
def resolve_image_modified() -> bool:
|
||||
"""True for non-official builds, including forks and local rebuilds."""
|
||||
return resolve_build_provenance() != BUILD_PROVENANCE_OFFICIAL
|
||||
|
||||
@@ -1256,9 +1256,10 @@ class Settings(BaseSettings):
|
||||
TELEMETRY_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Send an anonymous daily install heartbeat (version, OS, locale, "
|
||||
"user-count range). No personal data. Opt out here, via the web "
|
||||
"admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
||||
"Send an anonymous daily install heartbeat (version, official/custom "
|
||||
"image provenance, OS, locale, user-count range). No personal data. "
|
||||
"Opt out here, via the web admin, or by clearing "
|
||||
"TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
||||
),
|
||||
)
|
||||
TELEMETRY_ENDPOINT: str = Field(
|
||||
|
||||
@@ -139,9 +139,7 @@ async def find_recent_pending_provider_payment(
|
||||
conditions = [
|
||||
Payment.user_id == user_id,
|
||||
Payment.provider == provider,
|
||||
func.lower(Payment.status).in_(
|
||||
tuple({str(pending_status).lower(), "pending"})
|
||||
),
|
||||
func.lower(Payment.status).in_(tuple({str(pending_status).lower(), "pending"})),
|
||||
or_(
|
||||
Payment.provider_payment_id.isnot(None),
|
||||
Payment.yookassa_payment_id.isnot(None),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# Resolve the application version from .git at build time and emit a tiny
|
||||
# .build-version file. The .git tree is consumed in this throwaway stage and
|
||||
# never copied into the runtime image; only the tag + commit version string
|
||||
# ships. Non-main builds include the branch name so they are visibly distinct
|
||||
# from release builds. This matches the runtime fallback chain in _resolve_app_version
|
||||
# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git >
|
||||
# "dev+unknown") so the admin sidebar always shows a tag / sha even though the
|
||||
# runtime images have no git tooling and no .git tree.
|
||||
# never copied into the runtime image; only the tag + commit version string and
|
||||
# a low-cardinality build provenance marker ship. Non-main builds include the
|
||||
# branch name so they are visibly distinct from release builds. This matches the
|
||||
# runtime fallback chain in _resolve_app_version (REMNAWAVE_MINISHOP_VERSION env
|
||||
# > .build-version file > live git > "dev+unknown") so the admin sidebar always
|
||||
# shows a tag / sha even though the runtime images have no git tooling and no
|
||||
# .git tree.
|
||||
FROM alpine:3.20 AS version-builder
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /repo
|
||||
@@ -14,6 +15,7 @@ ARG GIT_BRANCH=""
|
||||
ARG BRANCH_NAME=""
|
||||
ARG GITHUB_REF_NAME=""
|
||||
ARG CI_COMMIT_REF_NAME=""
|
||||
ARG REMNAWAVE_MINISHOP_BUILD_PROVENANCE="custom"
|
||||
COPY .git ./.git
|
||||
RUN set -eu; \
|
||||
git config --global --add safe.directory /repo; \
|
||||
@@ -40,9 +42,17 @@ RUN set -eu; \
|
||||
else \
|
||||
version="dev${branch_suffix}+unknown"; \
|
||||
fi; \
|
||||
provenance=$(printf '%s' "$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" | tr '[:upper:]' '[:lower:]'); \
|
||||
case "$provenance" in \
|
||||
official|custom|unknown) ;; \
|
||||
true|1|yes|upstream|release) provenance="official" ;; \
|
||||
false|0|no|fork|modified|local|"") provenance="custom" ;; \
|
||||
*) provenance="custom" ;; \
|
||||
esac; \
|
||||
printf '%s' "$version" > /build-version; \
|
||||
printf '%s' "${tag:-unknown}" > /build-tag; \
|
||||
printf '%s' "${sha:-unknown}" > /build-commit
|
||||
printf '%s' "${sha:-unknown}" > /build-commit; \
|
||||
printf '%s' "$provenance" > /build-provenance
|
||||
|
||||
|
||||
FROM python:3.12-slim AS python-base
|
||||
@@ -82,6 +92,7 @@ RUN mkdir -p /app/logs /app/data \
|
||||
COPY --from=version-builder /build-version /app/.build-version
|
||||
COPY --from=version-builder /build-tag /app/.build-tag
|
||||
COPY --from=version-builder /build-commit /app/.build-commit
|
||||
COPY --from=version-builder /build-provenance /app/.build-provenance
|
||||
|
||||
USER appuser
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
| `installation_id` | `f47ac10b-...` (UUIDv4) | Случайный идентификатор установки. Генерируется один раз, хранится в БД. Не выводится из токена, домена или ID администраторов. |
|
||||
| `app_version` | `v3.4.6+gabc1234` | Полная версия сборки. |
|
||||
| `app_version_tag` | `v3.4.6` | Релизный тег для разбивки по версиям. |
|
||||
| `build_provenance` | `official` / `custom` | Маркер происхождения образа. `official` ставится только официальной сборкой проекта; локальные сборки и форки считаются `custom`. |
|
||||
| `image_modified` | `false` / `true` | Удобный булевый флаг для разбивки: `false` только у официальных образов, `true` у локальных/форк-сборок. |
|
||||
| `os` / `arch` | `linux` / `x86_64` | Платформа. |
|
||||
| `python_version` | `3.12.7` | Версия рантайма. |
|
||||
| `locale` | `ru` | Язык по умолчанию. |
|
||||
@@ -22,7 +24,7 @@
|
||||
|
||||
## Чего там нет
|
||||
|
||||
Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент.
|
||||
Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент, registry образа или URL форка.
|
||||
|
||||
## Как выключить
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
provider_webhook_needs_base_url:
|
||||
"Провайдеру {provider} нужен WEBHOOK_BASE_URL для приёма вебхуков, а он не задан.",
|
||||
no_payment_methods: "Не включён ни один способ оплаты.",
|
||||
mini_app_url_missing: "SUBSCRIPTION_MINI_APP_URL не задан — кнопка Mini App в боте не появится.",
|
||||
mini_app_url_not_https: "SUBSCRIPTION_MINI_APP_URL должен начинаться с https:// (сейчас {url}).",
|
||||
mini_app_url_missing:
|
||||
"SUBSCRIPTION_MINI_APP_URL не задан — кнопка Mini App в боте не появится.",
|
||||
mini_app_url_not_https:
|
||||
"SUBSCRIPTION_MINI_APP_URL должен начинаться с https:// (сейчас {url}).",
|
||||
redis_not_configured:
|
||||
"REDIS_URL не задан — состояния диалогов бота и кэш не переживут перезапуск.",
|
||||
smtp_incomplete: "SMTP настроен не полностью — вход по email работать не будет.",
|
||||
@@ -58,7 +60,10 @@
|
||||
}
|
||||
|
||||
function alertText(alert) {
|
||||
const fallback = interpolate(MESSAGE_FALLBACKS[alert.message_key] || alert.message_key, alert.params);
|
||||
const fallback = interpolate(
|
||||
MESSAGE_FALLBACKS[alert.message_key] || alert.message_key,
|
||||
alert.params
|
||||
);
|
||||
return at(`health_${alert.message_key}`, alert.params || {}, fallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -6057,7 +6057,7 @@
|
||||
"section_order": 12,
|
||||
"subsection": null,
|
||||
"label": "Анонимная статистика установки",
|
||||
"description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает понять число активных установок и какие версии используются. Можно отключить здесь без перезапуска.",
|
||||
"description": "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает понять число активных установок, какие версии используются и долю изменённых сборок. Можно отключить здесь без перезапуска.",
|
||||
"i18n_label_key": "admin_settings_field_telemetry_enabled_label",
|
||||
"i18n_description_key": "admin_settings_field_telemetry_enabled_description",
|
||||
"i18n_subsection_key": null,
|
||||
|
||||
+1
-1
@@ -1178,7 +1178,7 @@
|
||||
"admin_settings_section_system": "System",
|
||||
"admin_settings_section_migrations": "Migrations",
|
||||
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics",
|
||||
"admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge how many installs are active and which versions are in use. Toggling this off takes effect without a restart.",
|
||||
"admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, official/custom image marker, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge active installs, versions in use and the share of modified builds. Toggling this off takes effect without a restart.",
|
||||
"admin_settings_subsection_common": "Common",
|
||||
"admin_settings_subsection_checkout": "Checkout",
|
||||
"admin_settings_subsection_trial": "Trial",
|
||||
|
||||
+1
-1
@@ -1178,7 +1178,7 @@
|
||||
"admin_settings_section_system": "Система",
|
||||
"admin_settings_section_migrations": "Миграции",
|
||||
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
||||
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
|
||||
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок, используемые версии и долю изменённых сборок. Отключение применяется без перезапуска.",
|
||||
"admin_settings_subsection_common": "Общие",
|
||||
"admin_settings_subsection_checkout": "Оформление оплаты",
|
||||
"admin_settings_subsection_trial": "Пробный период",
|
||||
|
||||
@@ -5,12 +5,18 @@ $imageNamespace = if ($env:IMAGE_NAMESPACE) { $env:IMAGE_NAMESPACE } else { "325
|
||||
$imageTag = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "local" }
|
||||
$imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-minishop" }
|
||||
$dockerfile = if ($env:DOCKERFILE) { $env:DOCKERFILE } else { "deploy/docker/Dockerfile" }
|
||||
$buildProvenance = if ($env:REMNAWAVE_MINISHOP_BUILD_PROVENANCE) { $env:REMNAWAVE_MINISHOP_BUILD_PROVENANCE } else { "custom" }
|
||||
|
||||
function Build-Image {
|
||||
param([string]$Target)
|
||||
$image = "$imageRegistry/$imageNamespace/$imagePrefix-$Target`:$imageTag"
|
||||
Write-Host "Building $image" -ForegroundColor Cyan
|
||||
docker build -f $dockerfile --target $Target -t $image .
|
||||
docker build `
|
||||
-f $dockerfile `
|
||||
--target $Target `
|
||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$buildProvenance" `
|
||||
-t $image `
|
||||
.
|
||||
}
|
||||
|
||||
Build-Image backend
|
||||
|
||||
@@ -6,12 +6,18 @@ IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-3252a8}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-local}"
|
||||
IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}"
|
||||
DOCKERFILE="${DOCKERFILE:-deploy/docker/Dockerfile}"
|
||||
REMNAWAVE_MINISHOP_BUILD_PROVENANCE="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-custom}"
|
||||
|
||||
build_image() {
|
||||
local target="$1"
|
||||
local image="$IMAGE_REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX-$target:$IMAGE_TAG"
|
||||
echo "Building $image"
|
||||
docker build -f "$DOCKERFILE" --target "$target" -t "$image" .
|
||||
docker build \
|
||||
-f "$DOCKERFILE" \
|
||||
--target "$target" \
|
||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" \
|
||||
-t "$image" \
|
||||
.
|
||||
}
|
||||
|
||||
build_image backend
|
||||
|
||||
@@ -12,6 +12,7 @@ $imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-mi
|
||||
$dockerfile = if ($env:DOCKERFILE) { $env:DOCKERFILE } else { "deploy/docker/Dockerfile" }
|
||||
$targetsRaw = if ($env:TARGETS) { $env:TARGETS } else { "backend,worker,frontend" }
|
||||
$targets = @($targetsRaw -split "[,;\s]+" | Where-Object { $_ })
|
||||
$buildProvenance = if ($env:REMNAWAVE_MINISHOP_BUILD_PROVENANCE) { $env:REMNAWAVE_MINISHOP_BUILD_PROVENANCE } else { "custom" }
|
||||
|
||||
function Get-ImageName {
|
||||
param(
|
||||
@@ -31,7 +32,12 @@ function Build-Image {
|
||||
}
|
||||
|
||||
Write-Host "Building $Target for: $($imageRegistries -join ', ')" -ForegroundColor Cyan
|
||||
docker build -f $dockerfile --target $Target @tagArgs .
|
||||
docker build `
|
||||
-f $dockerfile `
|
||||
--target $Target `
|
||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$buildProvenance" `
|
||||
@tagArgs `
|
||||
.
|
||||
}
|
||||
|
||||
function Push-Image {
|
||||
|
||||
@@ -7,6 +7,7 @@ IMAGE_TAG="${IMAGE_TAG:?Set IMAGE_TAG to the release tag you want to build and p
|
||||
IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}"
|
||||
DOCKERFILE="${DOCKERFILE:-deploy/docker/Dockerfile}"
|
||||
TARGETS="${TARGETS:-backend worker frontend}"
|
||||
REMNAWAVE_MINISHOP_BUILD_PROVENANCE="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-custom}"
|
||||
|
||||
normalize_list() {
|
||||
local value="$1"
|
||||
@@ -34,7 +35,12 @@ build_image() {
|
||||
done
|
||||
|
||||
echo "Building $target for: ${registries[*]}"
|
||||
docker build -f "$DOCKERFILE" --target "$target" "${tags[@]}" .
|
||||
docker build \
|
||||
-f "$DOCKERFILE" \
|
||||
--target "$target" \
|
||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" \
|
||||
"${tags[@]}" \
|
||||
.
|
||||
}
|
||||
|
||||
push_image() {
|
||||
|
||||
@@ -28,9 +28,11 @@ from unittest.mock import patch
|
||||
# Importing the webapp facade populates the runtime helpers we need.
|
||||
import bot.app.web.subscription_webapp # noqa: F401
|
||||
from bot.app.web.webapp import assets as assets_module
|
||||
from bot.utils import app_version as app_version_module
|
||||
|
||||
_VERSION_ENV_NAMES = (
|
||||
"REMNAWAVE_MINISHOP_VERSION",
|
||||
"REMNAWAVE_MINISHOP_BUILD_PROVENANCE",
|
||||
"REMNAWAVE_MINISHOP_BRANCH",
|
||||
"GIT_BRANCH",
|
||||
"BRANCH_NAME",
|
||||
@@ -42,6 +44,8 @@ _VERSION_ENV_NAMES = (
|
||||
def _reset_cache() -> None:
|
||||
# The resolver memoizes the first result in a module-level global.
|
||||
assets_module._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||
app_version_module._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||
app_version_module._APP_BUILD_PROVENANCE_CACHE = None # type: ignore[attr-defined]
|
||||
# Some callers reach through the facade re-export; clear that too.
|
||||
runtime = importlib.import_module("bot.app.web.webapp._runtime")
|
||||
runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||
@@ -251,5 +255,55 @@ class CacheBehaviourTests(unittest.TestCase):
|
||||
self.assertEqual(first_calls, second_calls)
|
||||
|
||||
|
||||
class BuildProvenanceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_cache()
|
||||
self.addCleanup(_reset_cache)
|
||||
|
||||
def test_env_var_short_circuits_build_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / ".build-provenance").write_text("custom", encoding="utf-8")
|
||||
env = _clean_version_env()
|
||||
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "official"
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
|
||||
):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "official")
|
||||
self.assertFalse(app_version_module.resolve_image_modified())
|
||||
|
||||
def test_reads_baked_build_provenance_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / ".build-provenance").write_text("custom\n", encoding="utf-8")
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
|
||||
):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
|
||||
self.assertTrue(app_version_module.resolve_image_modified())
|
||||
|
||||
def test_missing_marker_defaults_to_custom(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(app_version_module, "APP_ROOT", Path(tmp)),
|
||||
):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
|
||||
self.assertTrue(app_version_module.resolve_image_modified())
|
||||
|
||||
def test_legacy_boolean_aliases_are_normalized(self):
|
||||
env = _clean_version_env()
|
||||
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "true"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "official")
|
||||
|
||||
_reset_cache()
|
||||
env["REMNAWAVE_MINISHOP_BUILD_PROVENANCE"] = "fork"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertEqual(app_version_module.resolve_build_provenance(), "custom")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
|
||||
@@ -51,6 +51,8 @@ def test_build_payload_shape(settings):
|
||||
for key in (
|
||||
"app_version",
|
||||
"app_version_tag",
|
||||
"build_provenance",
|
||||
"image_modified",
|
||||
"os",
|
||||
"arch",
|
||||
"python_version",
|
||||
@@ -63,13 +65,44 @@ def test_build_payload_shape(settings):
|
||||
assert key in props, f"missing property: {key}"
|
||||
|
||||
assert isinstance(props["payment_providers"], list)
|
||||
assert props["build_provenance"] in {"official", "custom", "unknown"}
|
||||
assert isinstance(props["image_modified"], bool)
|
||||
# No DB session -> user count degrades to the smallest bucket.
|
||||
assert props["users_bucket"] == "0"
|
||||
# Person properties mirror the event properties so PostHog breakdowns work.
|
||||
assert props["$set"]["app_version"] == props["app_version"]
|
||||
assert props["$set"]["build_provenance"] == props["build_provenance"]
|
||||
assert props["$set"]["image_modified"] == props["image_modified"]
|
||||
assert props["$lib"] == "remnawave-minishop"
|
||||
|
||||
|
||||
def test_payload_marks_official_images_not_modified(settings, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"bot.services.telemetry_worker.resolve_build_provenance",
|
||||
lambda: "official",
|
||||
)
|
||||
monkeypatch.setattr("bot.services.telemetry_worker.resolve_image_modified", lambda: False)
|
||||
|
||||
worker = TelemetryWorker(settings, None)
|
||||
payload = asyncio.run(worker._build_payload(None, "install-123"))
|
||||
props = payload["properties"]
|
||||
|
||||
assert props["build_provenance"] == "official"
|
||||
assert props["image_modified"] is False
|
||||
|
||||
|
||||
def test_payload_marks_custom_images_modified(settings, monkeypatch):
|
||||
monkeypatch.setattr("bot.services.telemetry_worker.resolve_build_provenance", lambda: "custom")
|
||||
monkeypatch.setattr("bot.services.telemetry_worker.resolve_image_modified", lambda: True)
|
||||
|
||||
worker = TelemetryWorker(settings, None)
|
||||
payload = asyncio.run(worker._build_payload(None, "install-123"))
|
||||
props = payload["properties"]
|
||||
|
||||
assert props["build_provenance"] == "custom"
|
||||
assert props["image_modified"] is True
|
||||
|
||||
|
||||
def test_payload_contains_no_secrets_or_pii(settings):
|
||||
worker = TelemetryWorker(settings, None)
|
||||
payload = asyncio.run(worker._build_payload(None, "install-123"))
|
||||
|
||||
@@ -355,18 +355,23 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
|
||||
with patch(
|
||||
with (
|
||||
patch(
|
||||
"bot.payment_providers.shared.callbacks.safe_store_provider_payment_id",
|
||||
AsyncMock(return_value=True),
|
||||
) as store_id, patch(
|
||||
) as store_id,
|
||||
patch(
|
||||
"bot.payment_providers.shared.callbacks.render_payment_link",
|
||||
AsyncMock(),
|
||||
) as render_link, patch(
|
||||
) as render_link,
|
||||
patch(
|
||||
"bot.payment_providers.shared.callbacks.safe_mark_failed_creation",
|
||||
AsyncMock(),
|
||||
) as mark_failed, patch(
|
||||
) as mark_failed,
|
||||
patch(
|
||||
"bot.payment_providers.shared.callbacks.notify_payment_gateway_failure",
|
||||
AsyncMock(),
|
||||
),
|
||||
):
|
||||
await render_link_or_fail(
|
||||
callback,
|
||||
|
||||
Reference in New Issue
Block a user