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:
3252a8
2026-06-10 15:18:32 +03:00
parent fd931581f5
commit 6bb2709244
22 changed files with 261 additions and 44 deletions
+4 -3
View File
@@ -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).
+1
View File
@@ -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
+10
View File
@@ -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, ОС, локаль и число пользователей в виде диапазона. Без персональных "
"данных, токенов и доменов. Помогает понять число активных установок, какие "
"версии используются и долю изменённых сборок. Можно отключить здесь без "
"перезапуска.",
),
]
+10 -1
View File
@@ -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(),
+61
View File
@@ -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
+4 -3
View File
@@ -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(
+1 -3
View File
@@ -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),
+18 -7
View File
@@ -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
+3 -1
View File
@@ -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 форка.
## Как выключить
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Пробный период",
+7 -1
View File
@@ -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
+7 -1
View File
@@ -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
+7 -1
View File
@@ -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 -1
View File
@@ -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() {
+54
View File
@@ -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()
+33
View File
@@ -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"))
+9 -4
View File
@@ -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,