From 6bb27092441bdaf9433f9cca50b1ab5b80b470c2 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 10 Jun 2026 15:18:32 +0300 Subject: [PATCH] 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. --- .env.example | 7 ++- .github/workflows/_docker-build-push.yml | 1 + .gitlab-ci.yml | 10 +++ backend/bot/app/web/admin_api_impl/themes.py | 1 + .../bot/app/web/admin_settings_manifest.py | 9 +-- backend/bot/services/telemetry_worker.py | 11 +++- backend/bot/utils/app_version.py | 61 +++++++++++++++++++ backend/config/settings.py | 7 ++- backend/db/dal/payment_dal.py | 4 +- deploy/docker/Dockerfile | 25 +++++--- docs/configuration/telemetry.md | 4 +- frontend/src/admin/ConfigAlertsBanner.svelte | 11 +++- .../webapp/settingsManifest.generated.json | 2 +- locales/en.json | 2 +- locales/ru.json | 2 +- scripts/docker-build-images.ps1 | 8 ++- scripts/docker-build-images.sh | 8 ++- scripts/docker-build-push-images.ps1 | 8 ++- scripts/docker-build-push-images.sh | 8 ++- tests/test_app_version_resolution.py | 54 ++++++++++++++++ tests/test_telemetry_worker.py | 33 ++++++++++ tests/test_webapp_payment_status.py | 29 +++++---- 22 files changed, 261 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index 0f1d331..0c00ca2 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/.github/workflows/_docker-build-push.yml b/.github/workflows/_docker-build-push.yml index 08909d0..d9137a8 100644 --- a/.github/workflows/_docker-build-push.yml +++ b/.github/workflows/_docker-build-push.yml @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 40ff033..2285851 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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" \ diff --git a/backend/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py index 6f0db78..7fe6be2 100644 --- a/backend/bot/app/web/admin_api_impl/themes.py +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -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], diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 76570b9..3fee918 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -707,10 +707,11 @@ SETTINGS_MANIFEST: List[SettingField] = [ "bool", "system", "Анонимная статистика установки", - "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число " - "пользователей в виде диапазона. Без персональных данных, токенов и " - "доменов. Помогает понять число активных установок и какие версии " - "используются. Можно отключить здесь без перезапуска.", + "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа " + "official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных " + "данных, токенов и доменов. Помогает понять число активных установок, какие " + "версии используются и долю изменённых сборок. Можно отключить здесь без " + "перезапуска.", ), ] diff --git a/backend/bot/services/telemetry_worker.py b/backend/bot/services/telemetry_worker.py index c6b6b54..7d9e966 100644 --- a/backend/bot/services/telemetry_worker.py +++ b/backend/bot/services/telemetry_worker.py @@ -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(), diff --git a/backend/bot/utils/app_version.py b/backend/bot/utils/app_version.py index 3695062..7f78655 100644 --- a/backend/bot/utils/app_version.py +++ b/backend/bot/utils/app_version.py @@ -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 diff --git a/backend/config/settings.py b/backend/config/settings.py index a8f56bc..9225def 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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( diff --git a/backend/db/dal/payment_dal.py b/backend/db/dal/payment_dal.py index bdcf73c..32f32b6 100644 --- a/backend/db/dal/payment_dal.py +++ b/backend/db/dal/payment_dal.py @@ -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), diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 8b1b1f5..6f46da8 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -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 diff --git a/docs/configuration/telemetry.md b/docs/configuration/telemetry.md index 64fafbb..ae17a9d 100644 --- a/docs/configuration/telemetry.md +++ b/docs/configuration/telemetry.md @@ -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 форка. ## Как выключить diff --git a/frontend/src/admin/ConfigAlertsBanner.svelte b/frontend/src/admin/ConfigAlertsBanner.svelte index 365e1de..9aff0ae 100644 --- a/frontend/src/admin/ConfigAlertsBanner.svelte +++ b/frontend/src/admin/ConfigAlertsBanner.svelte @@ -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); } diff --git a/frontend/src/lib/webapp/settingsManifest.generated.json b/frontend/src/lib/webapp/settingsManifest.generated.json index 05be4fb..9f1751c 100644 --- a/frontend/src/lib/webapp/settingsManifest.generated.json +++ b/frontend/src/lib/webapp/settingsManifest.generated.json @@ -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, diff --git a/locales/en.json b/locales/en.json index 86e4a37..1c60826 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/ru.json b/locales/ru.json index 8e4430f..a643216 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Пробный период", diff --git a/scripts/docker-build-images.ps1 b/scripts/docker-build-images.ps1 index e4baa86..1008170 100644 --- a/scripts/docker-build-images.ps1 +++ b/scripts/docker-build-images.ps1 @@ -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 diff --git a/scripts/docker-build-images.sh b/scripts/docker-build-images.sh index 3c1e315..20106fd 100644 --- a/scripts/docker-build-images.sh +++ b/scripts/docker-build-images.sh @@ -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 diff --git a/scripts/docker-build-push-images.ps1 b/scripts/docker-build-push-images.ps1 index 1f4b2e6..292c055 100644 --- a/scripts/docker-build-push-images.ps1 +++ b/scripts/docker-build-push-images.ps1 @@ -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 { diff --git a/scripts/docker-build-push-images.sh b/scripts/docker-build-push-images.sh index 129a0d5..eb8ae97 100644 --- a/scripts/docker-build-push-images.sh +++ b/scripts/docker-build-push-images.sh @@ -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() { diff --git a/tests/test_app_version_resolution.py b/tests/test_app_version_resolution.py index defb55d..bc05dc2 100644 --- a/tests/test_app_version_resolution.py +++ b/tests/test_app_version_resolution.py @@ -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() diff --git a/tests/test_telemetry_worker.py b/tests/test_telemetry_worker.py index 138d75e..fe75541 100644 --- a/tests/test_telemetry_worker.py +++ b/tests/test_telemetry_worker.py @@ -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")) diff --git a/tests/test_webapp_payment_status.py b/tests/test_webapp_payment_status.py index 8c371f1..32ca56b 100644 --- a/tests/test_webapp_payment_status.py +++ b/tests/test_webapp_payment_status.py @@ -355,18 +355,23 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): answer=AsyncMock(), ) - with patch( - "bot.payment_providers.shared.callbacks.safe_store_provider_payment_id", - AsyncMock(return_value=True), - ) as store_id, patch( - "bot.payment_providers.shared.callbacks.render_payment_link", - AsyncMock(), - ) as render_link, patch( - "bot.payment_providers.shared.callbacks.safe_mark_failed_creation", - AsyncMock(), - ) as mark_failed, patch( - "bot.payment_providers.shared.callbacks.notify_payment_gateway_failure", - AsyncMock(), + with ( + patch( + "bot.payment_providers.shared.callbacks.safe_store_provider_payment_id", + AsyncMock(return_value=True), + ) as store_id, + patch( + "bot.payment_providers.shared.callbacks.render_payment_link", + AsyncMock(), + ) as render_link, + patch( + "bot.payment_providers.shared.callbacks.safe_mark_failed_creation", + AsyncMock(), + ) as mark_failed, + patch( + "bot.payment_providers.shared.callbacks.notify_payment_gateway_failure", + AsyncMock(), + ), ): await render_link_or_fail( callback,