From 1e97905a08ca2fbb224ca8a762bde1c66bcbb2d0 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:03:54 +0800 Subject: [PATCH] fix(analytics): preserve deployment identity and Helm secrets Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .github/workflows/pr-helm-chart.yml | 3 + charts/skillhub/README.md | 18 +- charts/skillhub/tests/analytics-smoke-test.sh | 190 ++++++++++++++++++ charts/skillhub/tests/analytics-smoke.sh | 5 +- docs/frontend-analytics-integration.md | 51 +++-- docs/frontend-analytics.md | 10 +- .../design.md | 8 +- .../configurable-frontend-analytics/spec.md | 28 +++ .../tasks.md | 7 + scripts/runtime.sh | 62 ++++-- scripts/tests/runtime-analytics-test.sh | 108 +++++++++- 11 files changed, 448 insertions(+), 42 deletions(-) create mode 100755 charts/skillhub/tests/analytics-smoke-test.sh diff --git a/.github/workflows/pr-helm-chart.yml b/.github/workflows/pr-helm-chart.yml index b9d919741..fc4a1fd1b 100644 --- a/.github/workflows/pr-helm-chart.yml +++ b/.github/workflows/pr-helm-chart.yml @@ -50,6 +50,9 @@ jobs: - name: Validate configuration contracts run: bash tests/configuration-contracts.sh + - name: Verify analytics smoke isolation + run: bash tests/analytics-smoke-test.sh + - name: Validate chart metadata run: | CHART_VERSION=$(helm show chart . | grep '^version:' | awk '{print $2}') diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index d73dfa8dd..4c269f9bd 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -108,10 +108,26 @@ Sentinel、实际 MinIO S3 连接,以及由 Kubernetes API 接受的 TLS Ingre ### 可选前端动作统计 默认关闭。`web.analytics.config` 接受 JSON 字符串,`web.analytics.upstream` 接受仅部署可见的 -HTTP(S) origin。支持 Umami,不需要编辑 JS 或 Nginx;先在平台创建网站并取得 UUID。 +HTTP(S) origin。支持 Umami 和 DataFinder,不需要编辑 JS 或 Nginx;Umami 先创建网站取得 UUID, +DataFinder 先取得应用 app_id 和采集域名。 值放在 Web 专用 ConfigMap,只改统计配置时仅 Web 滚动更新、后端 Pod 不替换;旧标签页不会自动更新。字段、授权接口、初始化、隐私及验收见 [前端动作统计接入指南](../../docs/frontend-analytics-integration.md)。 +统计专项验收脚本会发送测试事件、查询 `stats` 中的独立数据库,并对指定的已安装 Release +执行真实 `helm upgrade --reuse-values`,将统计规则改为空对象;它不是只读检查,也不会自动恢复规则。 +仅用于隔离验收安装,要求单个 Ready Web/Server Pod、独立 `stats` 平台和提前配置好的 Web port-forward。 +从仓库根目录运行(先替换网站 UUID、values 和安装名称): + +```sh +ANALYTICS_SMOKE_WEB_URL=http://127.0.0.1:18082 \ + bash charts/skillhub/tests/analytics-smoke.sh "<网站UUID>" values-acceptance.yaml sh1037 skillhub +``` + +第二个参数必须是该安装使用的配置;脚本复用已有 Release 值,不会隐式加载 +`tests/test-values.yaml`。显式文件仍可覆盖原值,勿混用其他环境的 values 或重新生成 Secret。 +脚本成功只证明代理入库及本次统计配置升级时 Web 滚动、Server 未替换;浏览器授权、 +业务回归和真实生产配置仍须按接入指南验证。验收后用原安装配置再次升级以恢复规则。 + ### 高可用模式 ```bash diff --git a/charts/skillhub/tests/analytics-smoke-test.sh b/charts/skillhub/tests/analytics-smoke-test.sh new file mode 100755 index 000000000..083c0e1ab --- /dev/null +++ b/charts/skillhub/tests/analytics-smoke-test.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Exercise the installed-release smoke without a cluster or third-party packages. +set -euo pipefail +TEST_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +python3 - "${ANALYTICS_SMOKE_SCRIPT:-$TEST_DIR/analytics-smoke.sh}" <<'PY' +import copy +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +script = Path(sys.argv[1]).resolve() +website = '00000000-0000-4000-8000-000000000037' +# JSON is valid Helm YAML. The caller file deliberately omits settings supplied +# at installation through other files/--set, especially private credentials. +original = { + 'secrets': {'bootstrapAdminPassword': 'synthetic-private-bootstrap', + 'downloadAnonCookieSecret': 'synthetic-private-cookie-secret'}, + 'postgresql': {'auth': {'password': 'synthetic-private-postgres'}}, + 'redis': {'auth': {'password': 'synthetic-private-redis'}}, + 'server': {'replicaCount': 1, 'image': {'tag': 'installed-server-tag'}}, + 'web': {'basePath': '/registry/', 'analytics': { + 'upstream': 'http://original-stats.internal:3000', 'config': '{}'}}, + 'existingSecret': '', +} +caller = {'web': {'analytics': {'upstream': 'http://original-stats.internal:3000'}}} + +# These fakes model Helm value layering and process state, not chart rendering. +# They fail on unexpected operations so this test cannot reach a real cluster. +fake = r'''#!/usr/bin/env python3 +import copy, json, os, sys +from pathlib import Path +root = Path(os.environ['FAKE_STATE']) +name = Path(sys.argv[0]).name +args = sys.argv[1:] +scenario = os.environ['FAKE_SCENARIO'] + +def check(condition, message): + if not condition: + sys.exit(message) + +def record(value): + with (root / 'calls').open('a') as f: + f.write(value + '\n') + +def merge(base, values): + for key, value in values.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + merge(base[key], value) + else: + base[key] = copy.deepcopy(value) + +if name == 'helm': + check(args[:2] == ['upgrade', os.environ['FAKE_RELEASE']], 'unexpected Helm operation') + check(args[args.index('-n') + 1] == os.environ['FAKE_NAMESPACE'], 'wrong release namespace') + check('--wait' in args and args[args.index('--timeout') + 1] == '5m', 'upgrade must wait') + original = json.loads((root / 'original.json').read_text()) + effective = copy.deepcopy(original) if '--reuse-values' in args else {} + files = [Path(args[i + 1]) for i, arg in enumerate(args) if arg == '-f'] + check(Path(os.environ['FAKE_CALLER']) in files, 'caller values were omitted') + for path in files: + # Reading the known repository fixture models the old bug as well. + text = path.read_text() + if text.startswith('{'): + values = json.loads(text) + else: + values = {} + stack = [(-1, values)] + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith('#'): + continue + indent = len(line) - len(line.lstrip()) + key, value = line.strip().split(':', 1) + while stack[-1][0] >= indent: + stack.pop() + parent = stack[-1][1] + value = value.strip() + parent[key] = value.strip("'\"") if value else {} + if not value: + stack.append((indent, parent[key])) + merge(effective, values) + expected = copy.deepcopy(original) + expected['web']['analytics']['config'] = effective.get('web', {}).get('analytics', {}).get('config') + if scenario == 'generated-profile': + check(effective.get('secrets') == original['secrets'], + 'generated secret profile must retain empty values for lookup') + check(effective == expected, 'upgrade changed unrelated release values or secrets') + check(all(p.name != 'test-values.yaml' for p in files), 'repository fixture values injected') + config = json.loads(effective['web']['analytics']['config']) + check(config == {'enabled': True, 'adapter': 'umami', 'consent': 'granted', + 'rules': {}, 'adapterConfig': {'websiteId': os.environ['FAKE_WEBSITE']}}, + 'wrong analytics upgrade') + (root / 'upgraded').touch() + record('upgrade: analytics only; installed values and secrets retained; no test fixture') +elif name == 'kubectl': + check(args[0] == '-n', 'namespace must be explicit') + namespace, command = args[1], args[2:] + if command[:2] == ['get', 'pods']: + check(namespace == os.environ['FAKE_NAMESPACE'], 'wrong pod namespace') + selector = command[command.index('-l') + 1] + component = selector.rsplit('=', 1)[-1] + check(component in ('server', 'web'), 'unexpected component') + upgraded = (root / 'upgraded').exists() + changed = upgraded and ((component == 'web' and scenario != 'web-stable') or + (component == 'server' and scenario == 'server-roll')) + uid = component + ('-after' if changed else '-before') + record('uid: ' + uid) + print(json.dumps({'items': [{'metadata': {'uid': uid}, 'status': { + 'conditions': [{'type': 'Ready', 'status': 'True'}]}}]})) + elif command[:3] == ['exec', 'deployment/umami-postgres', '--']: + check(namespace == 'stats', 'event query must use independent stats namespace') + check(os.environ['FAKE_WEBSITE'] in command[-1] and "event_name='skill.card.open'" in command[-1], + 'wrong event query') + print(11 if (root / 'sent').exists() and scenario != 'no-event' else 10) + record('event count') + elif command[:2] == ['rollout', 'status']: + check(command[2] == 'deployment/' + os.environ['FAKE_RELEASE'] + '-skillhub-web', 'wrong rollout') + check((root / 'upgraded').exists(), 'rollout before upgrade') + record('rollout: web') + else: + sys.exit('unexpected kubectl operation') +elif name == 'curl': + check(args[-1].startswith('http://127.0.0.1:18082/') or '--data' in args, 'unexpected Web URL') + if '-X' in args: + check(args[args.index('-X') + 1] == 'POST', 'expected event POST') + payload = json.loads(args[args.index('--data') + 1])['payload'] + check(payload['website'] == os.environ['FAKE_WEBSITE'] and payload['name'] == 'skill.card.open', + 'wrong event payload') + (root / 'sent').touch() + record('HTTP: event POST') + else: + check(args[-1].endswith('/_skillhub-analytics/script.js'), 'wrong tracker path') + record('HTTP: tracker GET') + print('200', end='') +else: + sys.exit('unexpected fake command') +''' + +with tempfile.TemporaryDirectory(prefix='analytics-smoke-test.') as tmp: + root = Path(tmp) + bin_dir = root / 'bin' + bin_dir.mkdir() + shim = bin_dir / 'fake' + shim.write_text(fake) + shim.chmod(0o755) + for name in ('helm', 'kubectl', 'curl'): + (bin_dir / name).symlink_to(shim) + caller_file = root / 'installed-overrides.json' + caller_file.write_text(json.dumps(caller)) + for scenario, failure in ( + ('generated-profile', None), + ('success', None), + ('server-roll', 'Server pod rolled unexpectedly'), + ('web-stable', 'Web pod did not roll'), + ('no-event', 'HTTP 200 but event not stored'), + ): + state = root / scenario + state.mkdir() + installed = copy.deepcopy(original) + if scenario == 'generated-profile': + # Empty application-secret values preserve the chart's existing-Secret + # lookup path. This value-layer model does not perform that lookup. + installed['secrets'] = {'allowAutoGenerated': True, + 'bootstrapAdminPassword': '', + 'downloadAnonCookieSecret': ''} + (state / 'original.json').write_text(json.dumps(installed)) + env = dict(os.environ, PATH=str(bin_dir) + os.pathsep + os.environ['PATH'], + FAKE_STATE=str(state), FAKE_SCENARIO=scenario, + FAKE_WEBSITE=website, FAKE_CALLER=str(caller_file), + FAKE_RELEASE='original-release', FAKE_NAMESPACE='original-namespace', + ANALYTICS_SMOKE_WEB_URL='http://127.0.0.1:18082', TMPDIR=str(state)) + result = subprocess.run(['bash', str(script), website, str(caller_file), + env['FAKE_RELEASE'], env['FAKE_NAMESPACE']], + env=env, text=True, capture_output=True) + if failure is None: + assert result.returncode == 0, result.stdout + result.stderr + calls = (state / 'calls').read_text().splitlines() + assert calls.count('event count') == 2, calls + for call in ('uid: server-before', 'uid: web-before', 'uid: web-after', 'rollout: web'): + assert call in calls, call + assert calls.count('uid: server-before') == 2, calls + assert 'server UID unchanged' in result.stdout, result.stdout + else: + assert result.returncode != 0 and failure in result.stderr, result.stdout + result.stderr + assert not list(state.glob('skillhub-analytics-smoke.*')), 'temporary override was not cleaned' + print('PASS: ' + scenario) +print('Analytics smoke regression tests passed (fake Helm/kubectl/curl; no deployment).') +PY diff --git a/charts/skillhub/tests/analytics-smoke.sh b/charts/skillhub/tests/analytics-smoke.sh index 3251bec64..3520835ef 100644 --- a/charts/skillhub/tests/analytics-smoke.sh +++ b/charts/skillhub/tests/analytics-smoke.sh @@ -31,12 +31,13 @@ send_code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$WEB_URL/_skillhub- after=$(count_events) [[ "$after" -gt "$before" ]] || { echo 'HTTP 200 but event not stored' >&2; exit 1; } echo "Independent stats DB: event count $before -> $after (script=$script_code send=$send_code)" -# Change only Web analytics. All other values, including secrets, come from the same files. +# Reuse installed values (including secrets); the caller file must reproduce the install. +# Never apply chart test fixtures to an existing release. changed="{\"enabled\":true,\"adapter\":\"umami\",\"consent\":\"granted\",\"rules\":{},\"adapterConfig\":{\"websiteId\":\"$WEBSITE_ID\"}}" change_values=$(mktemp "${TMPDIR:-/tmp}/skillhub-analytics-smoke.XXXXXX") trap 'rm -f "$change_values"' EXIT printf "web:\n analytics:\n config: '%s'\n" "$changed" >"$change_values" -helm upgrade "$RELEASE" "$CHART_DIR" -n "$NAMESPACE" -f "$CHART_DIR/tests/test-values.yaml" -f "$VALUES_FILE" \ +helm upgrade "$RELEASE" "$CHART_DIR" -n "$NAMESPACE" --reuse-values -f "$VALUES_FILE" \ -f "$change_values" --wait --timeout 5m >/dev/null kubectl -n "$NAMESPACE" rollout status "deployment/$RELEASE-skillhub-web" --timeout=180s >/dev/null server_after=$(pod_uid server) diff --git a/docs/frontend-analytics-integration.md b/docs/frontend-analytics-integration.md index a53469426..838aa292c 100644 --- a/docs/frontend-analytics-integration.md +++ b/docs/frontend-analytics-integration.md @@ -117,14 +117,18 @@ product 只接受小写字母开头,后接小写字母、数字或下划线, ## 3. 一键脚本:新建独立 Umami -无需克隆仓库。从发布分支下载脚本执行;`--ref` 固定脚本和配套文件的同一版本。 +无需克隆仓库。选择包含本功能的已审核 tag 或提交 SHA,下载同版本脚本和配套文件; +下面的 `SKILLHUB_REF` 须替换为实际版本,并在后续所有命令的 shell 中保留。 +`--ref`(或环境变量 `SKILLHUB_REF`)固定文件来源,`--version` 单独选择 SkillHub 镜像标签;两者需匹配,不能把文件版本当作镜像版本。 `runtime.sh` 从 GitHub raw 获取 `compose.release.yml`、`.env.release.example`,在启用时获取 `compose.release.analytics.yml`,在部署统计平台时获取 `compose.analytics-umami.yml`。 这些文件保存在 `--home` 指定目录。使用 `--aliyun` 时镜像 OSS 根目录须同时发布两个新增 Compose 文件; -旧镜像仅可运行不含统计的 `up`。生产环境先锁定版本、备份数据,核对管理端访问控制。 +旧版 OSS 文件集仅可运行不含统计的 `up`,不能假定 GitHub 的 `--ref` 会固定 OSS 镜像文件版本。生产环境先锁定版本、备份数据,核对管理端访问控制。 ```sh -curl -fsSL https://raw.githubusercontent.com/iflytek/skillhub/main/scripts/runtime.sh -o runtime.sh +export SKILLHUB_REF="<包含本功能的已审核tag或提交SHA>" +export SKILLHUB_VERSION="<与该版本匹配的已发布镜像标签>" +curl -fsSL "https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF/scripts/runtime.sh" -o runtime.sh sh runtime.sh up --home "$HOME/skillhub-runtime" sh runtime.sh analytics-up --home "$HOME/skillhub-runtime" ``` @@ -133,23 +137,33 @@ sh runtime.sh analytics-up --home "$HOME/skillhub-runtime" 启动独立 Compose 项目 `skillhub-analytics`:Postgres 使用独立数据卷,Umami 管理端默认只绑定 `127.0.0.1:8300`。重复执行不重置密钥或数据。远程机器通过 SSH 端口转发管理。 如需改端口,编辑 `analytics/.env` 中的 `UMAMI_ADMIN_PORT`,再运行 `analytics-up` 使端口生效。 -若同机部署多个实例,先在 `.env.release` 中为每个实例指定唯一的 -`SKILLHUB_ANALYTICS_PROJECT`、`SKILLHUB_ANALYTICS_NETWORK`;已有平台不需要此网络。 +若同机部署多个实例,首次 `analytics-up` 前在各自 `.env.release` 中指定唯一的 +`SKILLHUB_ANALYTICS_PROJECT`、`SKILLHUB_ANALYTICS_NETWORK`。首次初始化会将这两个标识 +写入 `analytics/.env`;此后以独立统计配置中的标识为准,覆盖外层文件和 shell 中的同名值。 +不要通过修改标识迁移已有数据;它们决定实际 Compose 项目、网络和数据卷归属。已有平台不需要此网络。 打开 `http://127.0.0.1:8300`,用初始账号 `admin` / `umami` 登录,**立即修改密码**。 重新登录,在网站管理中新建网站并复制网站 **UUID**。这一步由管理员完成,脚本不会代建网站。 密码或 API token 不得填入 Web 配置。拿到 UUID 后运行: ```sh -sh runtime.sh analytics-enable --home "$HOME/skillhub-runtime" --website-id <网站UUID> +sh runtime.sh analytics-enable --home "$HOME/skillhub-runtime" --website-id "<网站UUID>" ``` +`analytics-enable` 会立即以 `consent=granted` 开启采集;仅在部署方确认允许后执行。 +若需要逐用户同意,先按第 2、5 节配置 `denied` 并接入同意组件,不先运行此开启命令。 脚本把公开 JSON 和内部 origin `http://umami:3000` 写入 `.env.release`,只重建 Web。 Web 和 Umami 通过外部网络互通;SkillHub server/数据库只留在 SkillHub 默认网络。 以后 `runtime.sh up` 自动带 Web 网络 overlay,容器重建仍可连通。 两个项目各自执行 `up`/`analytics-up`、`down`/`analytics-down`、`pull`/`analytics-pull`。 `analytics-down` 仅 stop,数据卷和密钥保留;重新运行 `analytics-up` 即恢复。 -`down` 不停 Umami,`clean` 只删除 SkillHub 运行目录,不删 Compose 数据卷;清理前备份 `analytics/.env`。 +`down` 不停 Umami;`clean` 停止 SkillHub 并删除其生成的运行文件,存在 `analytics/` 时保留该目录、 +凭证和独立项目标识,不停止统计平台、不删数据卷。执行前分别备份业务配置、 +`analytics/.env` 和统计数据库;`clean` 后重新 `up` 需恢复业务配置,再启用采集。 +旧版本生成的 `analytics/.env` 若缺少标识,会从原 `.env.release` 迁入;若原文件已经丢失, +脚本会在生成默认配置前拒绝运行管理命令,包括重复重试。此时恢复原文件,或核实原项目/网络后补入 +`analytics/.env`,不要猜测默认名称。可通过 `docker compose ls --all` 确认仍存在的项目, +再检查对应 Umami 容器实际加入的网络;不要直接改项目名来尝试连接原数据卷。 不要用 `down -v`,除非已备份且明确要删除数据。 ## 3.1 一键脚本:接入已有统计平台 @@ -159,7 +173,7 @@ Web 和 Umami 通过外部网络互通;SkillHub server/数据库只留在 Skil ```sh sh runtime.sh analytics-enable --home "$HOME/skillhub-runtime" \ - --website-id <网站UUID> --upstream https://stats.example.com + --website-id "<网站UUID>" --upstream https://stats.example.com ``` 使用已有 DataFinder 时,把平台应用的数字 app_id 和采集域名(非管理域名)填入: @@ -169,12 +183,14 @@ sh runtime.sh analytics-enable --home "$HOME/skillhub-runtime" \ --adapter datafinder --app-id 123456 --upstream https://collector.example.com ``` +上述 `analytics-enable` 同样立即使用 `consent=granted`,逐用户同意请用第 2、5 节。 两种已有平台均不创建本地统计服务或共享网络。关闭采集运行 `sh runtime.sh analytics-disable --home "$HOME/skillhub-runtime"`,仅重建 Web,平台仍运行。 现有手工 Compose 部署也可按第 2 节直接填写 `.env.release` 并重建 Web。 ## 4. Helm:独立统计部署单元 +以下命令从检出的同一已审核版本仓库根目录运行;Chart、values schema 和 Kubernetes 清单应来自该版本。 SkillHub Chart 不依赖 Umami。自建平台可先部署独立的 `deploy/k8s/analytics-umami/umami.yaml`。 示例以 `stats` namespace、默认 StorageClass 的 PVC 为基础。先创建独立 namespace 和 Secret: @@ -199,7 +215,8 @@ PVC 独立于 SkillHub Release;更新 Umami 镜像或密钥须单独滚动统 Postgres 大版本升级先备份数据库并按其升级流程迁移。删除 Deployment 不删除 PVC; 删除 namespace/PVC 会删除数据,须先做可恢复备份。 -保留已有生产 values 和 Secret。在 values 中增加以下内容: +以下针对已安装的 SkillHub Release。首次安装先按 [Chart README](https://github.com/iflytek/skillhub/blob/main/charts/skillhub/README.md) 完成业务安装, +已有平台可跳过上面的自建平台步骤。保留安装时的生产 values 和固定 Secret,在原 values 中增加以下内容: ```yaml web: @@ -210,11 +227,14 @@ web: ```sh helm lint --strict charts/skillhub -f values-production.yaml -helm upgrade --install skillhub ./charts/skillhub -n skillhub -f values-production.yaml +helm upgrade skillhub ./charts/skillhub -n skillhub --reuse-values -f values-production.yaml kubectl -n skillhub rollout status deployment/skillhub-web kubectl -n skillhub logs deployment/skillhub-web --tail=100 ``` +`helm upgrade` 是实际升级,`--reuse-values` 保留 Release 的已有值;显式传入的文件仍会覆盖旧值, +必须使用该安装的配置,不要传入 `charts/skillhub/tests/test-values.yaml` 等测试夹具。 +保留现有 Secret 引用和固定凭证,不能为开启统计重新生成业务密钥。 deployment 名称使用 Chart 默认 release 名 `skillhub`;自定义名称时替换命令中的名称。 值写入 Web 专用的 `-web-analytics` ConfigMap,只改统计配置时仅 Web 滚动更新, 后端 Pod 不会被替换。 @@ -338,11 +358,17 @@ curl -i http://localhost/_skillhub-analytics/api/websites | 有点击但没有统计 | 规则未配置、宿主不支持、超过 Umami 名称 50 字符,或 best-effort 丢失。 | | 上传 413 | 检查整条外层代理链,正常 Web 仍是 100M,统计未降低它。 | +runtime 升级时先备份 `.env.release`、`analytics/.env` 和数据库,下载目标版本的脚本并更新 +`SKILLHUB_REF` 和匹配的 `SKILLHUB_VERSION`;`pull` 只拉业务镜像,`analytics-pull` 只拉统计镜像,拉取不替换运行容器。 +分别运行 `up` 与 `analytics-up` 才应用变化,沿用原凭证和项目标识。不要以删除配置重建作为升级方法。 + 升级先比较[manifest](https://github.com/iflytek/skillhub/blob/main/docs/frontend-analytics-manifest.json),已有 ID 含义冻结,新 ID 不自动加入默认规则。 消失的配置 ID 不再触发。SDK 固定 Umami 3.4.1;升级需重测真实 payload、stop、并发和 根/子路径业务。回滚 Web 镜像要同时使用该版本支持的配置;先关闭统计最稳妥。 -持久关闭:把配置留空或 enabled 改 false,按第 2 节重新创建 Web。新页面应零模块/SDK/请求。 +持久关闭:runtime 用户运行 `analytics-disable`(第 3.1 节);手工 Compose 用户将配置留空 +或 enabled 改 false,再按第 2 节重建 Web;Helm 用户将 `web.analytics.config` 留空或 +enabled 改 false,使用原生产 values 按第 4 节升级。新页面应零模块/SDK/请求。 老标签页不自动更新;需让用户关闭/刷新,或在现有同意组件调用 stop。 紧急全局阻断可在平台或独立采集路由禁用接收,再处理旧页;不能撤回已发出的请求。 @@ -353,7 +379,8 @@ sh runtime.sh analytics-down --home "$HOME/skillhub-runtime" ``` Helm 自建平台可单独缩容 `kubectl -n stats scale deployment/umami --replicas=0`, -恢复后扩容;SkillHub Release 保持运行。数据库卷保留。删除统计数据需另行备份、确认后只处理 `analytics_postgres_data` 对应的卷, +恢复时将副本数改回 1;SkillHub Release 保持运行,PVC 保留。 +Compose 删除统计数据需另行备份、确认后只处理对应项目的 `analytics_postgres_data` 卷, 不要使用整个项目的 `down -v`。 ## 8. 隐私和自定义开发边界 diff --git a/docs/frontend-analytics.md b/docs/frontend-analytics.md index 7f97e856d..ad16414d5 100644 --- a/docs/frontend-analytics.md +++ b/docs/frontend-analytics.md @@ -1,12 +1,10 @@ # Passive frontend analytics contract -For deployment, use the [Chinese configuration guide][analytics-guide]. -The runtime script can install an independent Umami/PostgreSQL Compose project or -connect Web to an existing platform; a separate Kubernetes unit provides Umami for -Helm installations. Create a website UUID in Umami before enabling collection. -Stopping analytics does not remove its data; refresh old tabs after disabling. -Outage events are not replayed, and a well-formed but nonexistent UUID can still fail. +For installation, platform initialization, upgrades, shutdown and acceptance, use the +[Chinese configuration guide][analytics-guide]. It is the authoritative operator +procedure for runtime Compose and Kubernetes/Helm. This document owns the event, +module, configuration and privacy contracts; it does not duplicate deployment steps. Docker/Compose and Helm ship optional analytics with Web. No configuration means no controller/adapter chunk, SDK or collection request. The small configuration gate is part of bootstrap; the controller and SDK are not. Business components remain passive. diff --git a/openspec/changes/add-configurable-frontend-analytics/design.md b/openspec/changes/add-configurable-frontend-analytics/design.md index a91a99158..6785572a1 100644 --- a/openspec/changes/add-configurable-frontend-analytics/design.md +++ b/openspec/changes/add-configurable-frontend-analytics/design.md @@ -49,7 +49,11 @@ Nginx 通过独立 include 片段选择已注册适配器的精确端点:Umami ### 4. Deployment and lifecycle -现有平台接入:填写 JSON 和内部 upstream,更新 Web。可选自托管组合:在 release Compose 上叠加统计服务与其持久化数据库,使用显式凭证,不新增 Nginx;创建网站/取得 UUID 等一次性平台步骤必须诚实说明。入口和 Helm 均默认关闭。 +现有平台接入:填写 JSON 和内部 upstream,更新 Web。可选自托管组合:统计服务与其持久化数据库使用独立 Compose 项目,仅 Web 和 Umami 加入共享私网,不新增 Nginx;创建网站/取得 UUID 等一次性平台步骤必须诚实说明。入口和 Helm 均默认关闭。 + +独立统计目录中的 `analytics/.env` 同时保存凭据、项目名和网络名,初始化后成为这些标识的权威来源;外层业务配置和继承的 shell 环境不能将统计命令或 Web 接线重定向到其他实例。`clean` 删除业务运行文件但保留独立统计目录。旧配置缺少标识时,只从仍存在的原业务配置迁入;若原配置已丢失,则所有命令在生成默认文件之前明确失败,要求恢复已确认的标识,不猜测默认项目。 + +针对已安装 Release 的 Helm 统计验收仅覆盖统计字段,复用现有 Release 值和 Secret;不得隐式叠加固定测试凭据。调用者提供的 values 仍必须与安装配置一致。该脚本会执行真实升级并改变事件规则,只用于有明确边界的隔离验收环境,不能描述为只读检查。 部署配置变更需重新创建 Web(Compose restart 不重新注入环境变量;Helm 统计值放在 Web 专用 ConfigMap,只滚动 Web)使新页面生效;旧标签页不自动接收新环境参数。动态停止通过当前页面接口生效;紧急全局停止可关闭独立采集路由。文档不得宣称仅修改 env 即刻撤销已在发送的请求。模块使用正常发布构建产物与缓存策略,不把可变配置放入 immutable JS。 @@ -59,7 +63,7 @@ PR-2 不新增 jq 或 Node 运行时依赖。镜像已有 BusyBox awk 负责有 bootstrap 仅静态包含小型 gate 和注册参数校验;controller/helper 与 adapter 保持独立懒加载 chunk。在首个 React DOM commit 后的独立任务中启动 gate,不把正常挂载与 SDK 绑定。enabled=true 且 consent=denied 时不加载模块,但代理保留精确端点,供当前页显式 start() 授权后使用。静态 adapter factory 在动态导入返回后检查取消信号,迟到的导入不能重新创建 SDK。 -旧示例目录仅保留指南指针。helper/adapter 与全部断言迁入产品测试树;历史 edge/Compose 只保留为测试 fixture,不是部署路径,不维护第二份采集实现。 +旧示例目录仅保留指南指针。helper/adapter 与全部断言迁入产品测试树;部署回归直接检查当前生产配置,不保留已删除的历史 edge/Compose 副本。 ### PR-3: second registered adapter diff --git a/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md b/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md index db8da76f7..1ed4f3a2b 100644 --- a/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md +++ b/openspec/changes/add-configurable-frontend-analytics/specs/configurable-frontend-analytics/spec.md @@ -150,3 +150,31 @@ Delivered by: PR-1 (scope declaration); PR-2 (completion gate). - **WHEN** PR-1 merges independently - **THEN** Issue #989 remains open until PR-2 configuration-only deployment is complete and verified + +### Requirement: Preserve independent analytics deployment identity +Delivered by: PR-2. + +The system SHALL persist the analytics project and network alongside its independent credentials and use this identity consistently for analytics lifecycle commands and local Web attachment. + +#### Scenario: Clean and resume a custom analytics deployment + +- **WHEN** an operator initializes a custom analytics project, cleans SkillHub runtime files, and later manages or reconnects analytics +- **THEN** the original analytics project, network, credentials and data remain selected, regardless of outer configuration or shell variables +- **AND** normal stop operations do not delete analytics volumes + +#### Scenario: Legacy identity cannot be established + +- **WHEN** existing analytics configuration lacks required identity and its original SkillHub configuration is missing +- **THEN** runtime commands fail with restoration guidance before generating defaults or invoking Docker, including repeated attempts +- **AND** the system does not guess a default project or regenerate analytics credentials + +### Requirement: Keep installed-release verification isolated +Delivered by: PR-2. + +The analytics Helm verification script SHALL preserve existing release values and secrets while changing only its intended analytics settings. + +#### Scenario: Verify a release with generated secrets + +- **WHEN** the operator supplies installation values that omit secrets originally generated by the chart +- **THEN** verification reuses the existing release values and secret lookup instead of injecting deterministic test credentials +- **AND** event storage and Web/server rollout assertions remain enforced diff --git a/openspec/changes/add-configurable-frontend-analytics/tasks.md b/openspec/changes/add-configurable-frontend-analytics/tasks.md index 34e4fd394..65bd292dd 100644 --- a/openspec/changes/add-configurable-frontend-analytics/tasks.md +++ b/openspec/changes/add-configurable-frontend-analytics/tasks.md @@ -50,3 +50,10 @@ - [x] 8.4 Verify isolated Compose from an empty runtime directory, repeat secrets/data, Web recreation/network, analytics stop/restart, existing-platform enable, disable and business routes. Evidence: `skillhub-1037-oneclick/evidence/compose-smoke.log`. - [x] 8.5 Verify real kind: separate stats unit and SkillHub Helm release, Umami DB event increase via Web proxy, analytics-only Helm upgrade rolls Web while server Pod UID remains unchanged. Evidence: `skillhub-1037-oneclick/evidence/kind-smoke.log`. - [ ] 8.6 Independent maintainer review and PR acceptance remain external; do not close Issue #989 here. + +## 9. Maintainer deployment guard fixes +- [x] 9.1 Keep installed-release smoke values isolated: reuse release values, remove implicit test credentials, and cover successful and rejected event/rollout paths plus incomplete-fix mutations. +- [x] 9.2 Persist independent project/network identity, migrate only from known original configuration, and reject ambiguous legacy state before defaults are generated; cover custom/default multi-instance lifecycle and secret preservation. +- [x] 9.3 Clarify file/image version pinning, the three operator paths, independent configuration ownership, cleanup/upgrade boundaries and smoke upgrade side effects; remove duplicate operational descriptions from the contract. +- [ ] 9.4 Complete one consolidated independent review and the focused source/deployment validation matrix for these fixes. +- [ ] 9.5 Build the affected documentation and verify the rendered operator instructions; keep real-cluster and publication status explicit. diff --git a/scripts/runtime.sh b/scripts/runtime.sh index 78f61155a..e2afdfad3 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -194,12 +194,13 @@ download_file() { set_env_value() { key="$1" value="$2" + target_env_file="${3:-$ENV_FILE}" - if [ ! -f "$ENV_FILE" ]; then + if [ ! -f "$target_env_file" ]; then return 0 fi - tmp="$ENV_FILE.tmp" + tmp="$target_env_file.tmp" found=false old_umask="$(umask)" umask 077 @@ -214,14 +215,14 @@ set_env_value() { printf '%s\n' "$line" ;; esac - done <"$ENV_FILE" + done <"$target_env_file" if [ "$found" = "false" ]; then printf '%s=%s\n' "$key" "$value" fi } >"$tmp" umask "$old_umask" - mv "$tmp" "$ENV_FILE" - secure_env_file + mv "$tmp" "$target_env_file" + chmod 600 "$target_env_file" } secure_env_file() { @@ -233,7 +234,7 @@ secure_env_file() { get_env_value() { key="$1" default_value="${2:-}" - value="$(grep "^$key=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true)" + value="$(grep "^$key=" "${3:-$ENV_FILE}" | tail -n 1 | cut -d= -f2- || true)" if [ -n "$value" ]; then printf '%s' "$value" @@ -341,6 +342,11 @@ SQL } prepare_runtime_files() { + # Migrate legacy identity before creating a replacement release file. Failed + # migration must not leave fresh defaults that a later retry could trust. + if [ -f "$ANALYTICS_ENV_FILE" ]; then + ensure_analytics_identity + fi mkdir -p "$SKILLHUB_HOME" download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE" # GitHub and OSS both publish this root-level overlay; fetch only when used. @@ -415,6 +421,7 @@ run_compose() { compose_cmd="$(find_compose)" if [ "$(get_env_value SKILLHUB_ANALYTICS_NETWORK_ENABLED false)" = "true" ]; then ensure_analytics_network + set_env_value SKILLHUB_ANALYTICS_NETWORK "$SKILLHUB_ANALYTICS_NETWORK" # Preserve the normal default network and attach only Web to analytics. set -- -f "$OVERLAY_FILE" "$@" fi @@ -422,26 +429,43 @@ run_compose() { $compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" } -analytics_project() { - get_env_value SKILLHUB_ANALYTICS_PROJECT skillhub-analytics -} - -analytics_network() { - get_env_value SKILLHUB_ANALYTICS_NETWORK skillhub-analytics +ensure_analytics_identity() { + # Once initialized, the independent directory owns both identifiers. Neither + # a recreated release file nor inherited Compose variables may redirect it. + for identity_key in SKILLHUB_ANALYTICS_PROJECT SKILLHUB_ANALYTICS_NETWORK; do + identity_value="$(get_env_value "$identity_key" "" "$ANALYTICS_ENV_FILE")" + if [ -z "$identity_value" ]; then + if [ "$ORIGINAL_ENV_FILE_EXISTS" != "true" ]; then + echo "Missing $identity_key in $ANALYTICS_ENV_FILE. Restore the original .env.release or set the verified original identifier in analytics/.env before retrying; no project discovery is performed." >&2 + exit 1 + fi + identity_value="$(get_env_value "$identity_key" skillhub-analytics)" + set_env_value "$identity_key" "$identity_value" "$ANALYTICS_ENV_FILE" + fi + case "$identity_key" in + SKILLHUB_ANALYTICS_PROJECT) SKILLHUB_ANALYTICS_PROJECT="$identity_value" ;; + SKILLHUB_ANALYTICS_NETWORK) SKILLHUB_ANALYTICS_NETWORK="$identity_value" ;; + esac + done + chmod 600 "$ANALYTICS_ENV_FILE" + # Shell variables take precedence over --env-file in Compose interpolation. + export SKILLHUB_ANALYTICS_PROJECT SKILLHUB_ANALYTICS_NETWORK } ensure_analytics_network() { - network_name="$(analytics_network)" + ensure_analytics_identity + network_name="$SKILLHUB_ANALYTICS_NETWORK" if ! docker network inspect "$network_name" >/dev/null 2>&1; then docker network create "$network_name" >/dev/null fi } run_analytics_compose() { + ensure_analytics_identity compose_cmd="$(find_compose)" # Explicit -p also protects against an inherited COMPOSE_PROJECT_NAME. # shellcheck disable=SC2086 - $compose_cmd -p "$(analytics_project)" --env-file "$ANALYTICS_ENV_FILE" -f "$ANALYTICS_COMPOSE_FILE" "$@" + $compose_cmd -p "$SKILLHUB_ANALYTICS_PROJECT" --env-file "$ANALYTICS_ENV_FILE" -f "$ANALYTICS_COMPOSE_FILE" "$@" } prepare_analytics_files() { @@ -455,11 +479,12 @@ prepare_analytics_files() { printf 'UMAMI_DB_PASSWORD=%s\n' "$(generate_secret)" printf 'UMAMI_APP_SECRET=%s\n' "$(generate_secret)" printf 'UMAMI_ADMIN_PORT=8300\n' - printf 'SKILLHUB_ANALYTICS_NETWORK=%s\n' "$(analytics_network)" + printf 'SKILLHUB_ANALYTICS_PROJECT=%s\n' "$(get_env_value SKILLHUB_ANALYTICS_PROJECT skillhub-analytics)" + printf 'SKILLHUB_ANALYTICS_NETWORK=%s\n' "$(get_env_value SKILLHUB_ANALYTICS_NETWORK skillhub-analytics)" } >"$ANALYTICS_ENV_FILE" umask "$old_umask" fi - chmod 600 "$ANALYTICS_ENV_FILE" + ensure_analytics_identity } validate_analytics_input() { @@ -565,6 +590,9 @@ print_runtime_command() { build_runtime_lifecycle_args +# Remember this before preparation can recreate defaults after clean. +ORIGINAL_ENV_FILE_EXISTS=false +[ ! -f "$ENV_FILE" ] || ORIGINAL_ENV_FILE_EXISTS=true prepare_runtime_files case "$COMMAND" in @@ -615,8 +643,8 @@ EOF run_compose pull ;; analytics-up) - ensure_analytics_network prepare_analytics_files + ensure_analytics_network run_analytics_compose up -d admin_port="$(grep '^UMAMI_ADMIN_PORT=' "$ANALYTICS_ENV_FILE" | cut -d= -f2-)" printf 'Umami management: http://127.0.0.1:%s\nChange the default admin/umami password, create a website, copy its UUID, then run analytics-enable --website-id .\nAnalytics files: %s\n' "$admin_port" "$ANALYTICS_HOME" diff --git a/scripts/tests/runtime-analytics-test.sh b/scripts/tests/runtime-analytics-test.sh index bb8215753..6b1e5d364 100644 --- a/scripts/tests/runtime-analytics-test.sh +++ b/scripts/tests/runtime-analytics-test.sh @@ -1,17 +1,21 @@ #!/usr/bin/env bash set -euo pipefail ROOT=$(cd "$(dirname "$0")/../.." && pwd) +RUNTIME_SCRIPT=${RUNTIME_SCRIPT:-$ROOT/scripts/runtime.sh} TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT mkdir -p "$TMP/bin" "$TMP/home" cat >"$TMP/bin/docker" <<'SH' #!/bin/sh printf '%s\n' "$*" >>"$DOCKER_LOG" +printf 'identity project=%s network=%s\n' "${SKILLHUB_ANALYTICS_PROJECT:-}" "${SKILLHUB_ANALYTICS_NETWORK:-}" >>"$DOCKER_LOG" +# Exercise network creation without contacting a daemon. +if [ "$1 $2" = "network inspect" ]; then exit 1; fi exit 0 SH chmod +x "$TMP/bin/docker" export DOCKER_LOG="$TMP/docker.log" SKILLHUB_HOME="$TMP/home" SKILLHUB_RAW_BASE="file://$ROOT" PATH="$TMP/bin:$PATH" -run() { sh "$ROOT/scripts/runtime.sh" "$@" >"$TMP/output"; } +run() { sh "$RUNTIME_SCRIPT" "$@" >"$TMP/output"; } fail() { echo "FAIL: $*" >&2; exit 1; } run analytics-up [ -f "$TMP/home/analytics/compose.analytics-umami.yml" ] || fail 'missing platform file' @@ -37,7 +41,107 @@ grep -q '^SKILLHUB_ANALYTICS_NETWORK_ENABLED=false$' "$TMP/home/.env.release" || run analytics-down grep -q -- 'compose.analytics-umami.yml stop' "$DOCKER_LOG" || fail 'analytics down removed volumes' ! grep -q -- 'compose.analytics-umami.yml down' "$DOCKER_LOG" || fail 'analytics down removed project' -DOCKER_LOG="$TMP/docker.log" SKILLHUB_HOME="$TMP/home" SKILLHUB_RAW_BASE="file://$ROOT" PATH="$TMP/bin:$PATH" sh "$ROOT/scripts/runtime.sh" clean >"$TMP/output" +DOCKER_LOG="$TMP/docker.log" SKILLHUB_HOME="$TMP/home" SKILLHUB_RAW_BASE="file://$ROOT" PATH="$TMP/bin:$PATH" sh "$RUNTIME_SCRIPT" clean >"$TMP/output" [ -f "$TMP/home/analytics/.env" ] || fail 'clean deleted independent analytics credentials' [ ! -f "$TMP/home/.env.release" ] || fail 'clean left SkillHub credentials' + + +# Each home is an independent instance, including custom project/network names. +# External environment must never override its saved independent identity. +for instance in default tenant-a tenant-b; do + export SKILLHUB_HOME="$TMP/$instance" + run ps + project=skillhub-analytics + network=skillhub-analytics + if [ "$instance" != default ]; then + project="$instance-analytics" + network="$instance-network" + printf 'SKILLHUB_ANALYTICS_PROJECT=%s\nSKILLHUB_ANALYTICS_NETWORK=%s\n' "$project" "$network" >>"$SKILLHUB_HOME/.env.release" + fi + run analytics-up + config="$SKILLHUB_HOME/analytics/.env" + grep -qx "SKILLHUB_ANALYTICS_PROJECT=$project" "$config" || fail "$instance project not saved" + grep -qx "SKILLHUB_ANALYTICS_NETWORK=$network" "$config" || fail "$instance network not saved" + config_hash=$(shasum -a 256 "$config" | cut -d' ' -f1) + run clean + [ ! -f "$SKILLHUB_HOME/.env.release" ] || fail "$instance clean retained main config" + for command in analytics-ps analytics-logs analytics-pull analytics-down analytics-up analytics-up; do + : >"$DOCKER_LOG" + SKILLHUB_ANALYTICS_PROJECT=unrelated-project SKILLHUB_ANALYTICS_NETWORK=unrelated-network COMPOSE_PROJECT_NAME=unrelated-main run "$command" + grep -q -- "-p $project --env-file $config" "$DOCKER_LOG" || fail "$instance $command changed project after clean" + grep -qx "identity project=$project network=$network" "$DOCKER_LOG" || fail "$instance $command lost Compose identity" + ! grep -q -- '-p unrelated-project' "$DOCKER_LOG" || fail 'inherited project override' + [ "$(shasum -a 256 "$config" | cut -d' ' -f1)" = "$config_hash" ] || fail "$instance config or secrets changed" + done + # A newly generated release file has defaults; later unrelated edits are also + # harmless. Local Web must attach to the saved analytics network in both cases. + for outer in recreated conflicting; do + if [ "$outer" = conflicting ]; then + printf 'SKILLHUB_ANALYTICS_PROJECT=other-project\nSKILLHUB_ANALYTICS_NETWORK=other-network\n' >>"$SKILLHUB_HOME/.env.release" + fi + : >"$DOCKER_LOG" + SKILLHUB_ANALYTICS_NETWORK=unrelated-network run analytics-enable --website-id 4398de8e-40df-4483-9eea-ed213643525f + grep -q "network create $network" "$DOCKER_LOG" || fail "$instance Web created wrong network" + grep -qx "identity project=$project network=$network" "$DOCKER_LOG" || fail "$instance Web interpolation wrong" + grep -qx "SKILLHUB_ANALYTICS_NETWORK=$network" "$SKILLHUB_HOME/.env.release" || fail 'Web network not synchronized' + : >"$DOCKER_LOG" + SKILLHUB_ANALYTICS_NETWORK=unrelated-network run up --no-scanner + grep -q "network create $network" "$DOCKER_LOG" || fail "$instance main up lost network" + grep -q -- 'compose.release.analytics.yml up -d postgres' "$DOCKER_LOG" || fail "$instance main up lost overlay" + run analytics-disable + done + chmod 644 "$config" + run analytics-ps + [ "$(file_mode "$config")" = 600 ] || fail 'existing analytics permissions not secured' + [ "$(file_mode "$SKILLHUB_HOME/.env.release")" = 600 ] || fail 'main config permissions' + [ "$(shasum -a 256 "$config" | cut -d' ' -f1)" = "$config_hash" ] || fail "$instance secret changed after enable/up/disable" + ! grep -q -- 'compose.analytics-umami.yml down' "$DOCKER_LOG" || fail 'analytics volumes removed' +done + +# Legacy files always contained the network, but not the project. Preserve the +# original custom project before clean deletes its only previous source. +export SKILLHUB_HOME="$TMP/legacy" +run ps +printf 'SKILLHUB_ANALYTICS_PROJECT=legacy-project\nSKILLHUB_ANALYTICS_NETWORK=legacy-network\n' >>"$SKILLHUB_HOME/.env.release" +mkdir -p "$SKILLHUB_HOME/analytics" +printf 'UMAMI_DB_PASSWORD=test-db-secret\nUMAMI_APP_SECRET=test-app-secret\nUMAMI_ADMIN_PORT=8300\nSKILLHUB_ANALYTICS_NETWORK=legacy-network\n' >"$SKILLHUB_HOME/analytics/.env" +legacy_secrets=$(sed -n '/^UMAMI_/p' "$SKILLHUB_HOME/analytics/.env" | shasum -a 256) +run clean +grep -qx 'SKILLHUB_ANALYTICS_PROJECT=legacy-project' "$SKILLHUB_HOME/analytics/.env" || fail 'legacy project not migrated before clean' +[ "$(sed -n '/^UMAMI_/p' "$SKILLHUB_HOME/analytics/.env" | shasum -a 256)" = "$legacy_secrets" ] || fail 'legacy secrets changed' +: >"$DOCKER_LOG" +run analytics-ps +grep -q -- '-p legacy-project ' "$DOCKER_LOG" || fail 'legacy project reset' + +# If the original main config is already lost, do not trust newly generated +# defaults as migration evidence. Operators must restore a verified identifier. +for command in analytics-ps analytics-logs analytics-pull analytics-down analytics-up clean analytics-enable analytics-disable up down ps logs pull; do + export SKILLHUB_HOME="$TMP/lost-$command" + mkdir -p "$SKILLHUB_HOME/analytics" + printf 'UMAMI_DB_PASSWORD=test-db-secret\nUMAMI_APP_SECRET=test-app-secret\nSKILLHUB_ANALYTICS_NETWORK=legacy-network\n' >"$SKILLHUB_HOME/analytics/.env" + : >"$DOCKER_LOG" + if run "$command" --website-id 4398de8e-40df-4483-9eea-ed213643525f 2>"$TMP/error"; then fail "$command guessed lost project"; fi + grep -q 'Restore the original .env.release' "$TMP/error" || fail 'missing actionable legacy error' + [ ! -s "$DOCKER_LOG" ] || fail "$command contacted Docker for unidentified deployment" + ! grep -q 'test-.*-secret' "$TMP/error" || fail 'error disclosed secrets' + ! grep -q '^SKILLHUB_ANALYTICS_PROJECT=' "$SKILLHUB_HOME/analytics/.env" || fail 'lost legacy project overwritten' + [ ! -f "$SKILLHUB_HOME/.env.release" ] || fail 'failed migration created misleading defaults' + if run "$command" --website-id 4398de8e-40df-4483-9eea-ed213643525f 2>"$TMP/error"; then fail "$command retry guessed lost project"; fi +done + +# No configured local platform is needed to use an existing external service. +export SKILLHUB_HOME="$TMP/external-only" +run analytics-enable --website-id 4398de8e-40df-4483-9eea-ed213643525f --upstream https://stats.example.com +[ ! -e "$SKILLHUB_HOME/analytics/.env" ] || fail 'external enable created local platform' +grep -qx 'SKILLHUB_ANALYTICS_NETWORK_ENABLED=false' "$SKILLHUB_HOME/.env.release" || fail 'external enable attached network' +run analytics-disable + +# An original release file without explicit identifiers used historical defaults. +export SKILLHUB_HOME="$TMP/legacy-default" +mkdir -p "$SKILLHUB_HOME/analytics" +printf 'POSTGRES_USER=skillhub\n' >"$SKILLHUB_HOME/.env.release" +printf 'UMAMI_DB_PASSWORD=test-db-secret\nUMAMI_APP_SECRET=test-app-secret\nSKILLHUB_ANALYTICS_NETWORK=skillhub-analytics\n' >"$SKILLHUB_HOME/analytics/.env" +run analytics-ps +grep -qx 'SKILLHUB_ANALYTICS_PROJECT=skillhub-analytics' "$SKILLHUB_HOME/analytics/.env" || fail 'legacy default project migration' + echo 'runtime-analytics-test passed'