fix(analytics): preserve deployment identity and Helm secrets

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-03 11:03:54 +08:00
parent ffc9c8341a
commit 1e97905a08
11 changed files with 448 additions and 42 deletions

View file

@ -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}')

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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 专用的 `<release>-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. 隐私和自定义开发边界

View file

@ -1,12 +1,10 @@
# Passive frontend analytics contract
<!-- #region analytics-body -->
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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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 <UUID>.\nAnalytics files: %s\n' "$admin_port" "$ANALYTICS_HOME"

View file

@ -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'