---
title: "Bugsink 實跑驗證結果 (FR-133 B0)"
brand: "Guidant AI · **FR-133** 錯誤追蹤站隨產品出貨"
eyebrow: "FR-133 · B0 實跑結果 · 2026-10-09"
h1: "B0 實跑結果：D1、D2 都成立"
subtitle: "十項全過，兩項走設計已備的退路：內網 Host 用 ALLOWED_HOSTS 覆寫、tag 篩選改客端過濾"
---

# B0 可行性實跑結果（Bugsink 2.6.1）

## 結論

**D1、D2 都成立，後續卡照原設計走。** 十項全數通過，其中兩項走了設計已備好的退路：

| # | 項目 | 結果 | 後續卡照做 |
|---|---|---|---|
| ① | 接 Postgres 起得來 | ✅ 通過 | `cap_drop: [ALL]`、`no-new-privileges`、`mem_limit: 1g` 照原設計不必放寬 |
| ② | 管理員 | ✅ 通過 | `CREATE_SUPERUSER` 只在 `auth_user` 零人時生效 |
| ③ | ORM 建 team／project | ✅ 通過（**D1 成立**） | 片段見 `b0-bootstrap-snippet.sh` |
| ④ | 讀 DSN | ✅ 通過 | 表 `projects_project`，欄 `id`、`sentry_key` |
| ⑤ | 建 API token | ✅ 通過 | 表 `bsmain_authtoken`，三個布林欄 |
| ⑥ | 443 子路徑 | ✅ 通過（**D2 成立，不必退 8443**） | 照 design §5.3.2 的 location |
| ⑦ | sentry-sdk 直送 | ✅ 通過 | — |
| ⑧ | 內網形狀 DSN | ⚠️ 原樣 400，**退路一**後通過 | compose 加 `ALLOWED_HOSTS` |
| ⑨ | tunnel 轉送 | ✅ 通過 | — |
| ⑩ | 讀取 API 與主機名 | ⚠️ 列得出；**tag 不能篩** | T-D 改客端過濾；改網址要重寫 `SENTRY_PUBLIC_DSN` |

### 給後續卡的四個定案

- **內網 Host 問題選退路一**（T-B1）：compose 的 `guidant-bugsink` 加一行 `ALLOWED_HOSTS: <站台主機名>,guidant-bugsink,localhost,127.0.0.1`。不必開 guidant-fe 的 8081 內部 server。後端 `SENTRY_DSN` 維持 `http://<key>@guidant-bugsink:8000/<id>`。
- **tag 篩選不可行**（T-D）：`/api/canonical/0/issues/` 只吃 `project`、`sort`、`order`，其他參數一律忽略、回全部。`request_id` 在 event 詳情的 `data.tags.request_id`，診斷包要逐筆拉 event 詳情自己比對。
- **改網址會讓舊對外 DSN 失效**（T-B2）：`ALLOWED_HOSTS` 跟著新主機名換，舊主機名的請求回 400。內網 DSN 不受影響。`SENTRY_PUBLIC_DSN` 的主機部分必須跟著站台網址重寫，不能用「缺了才補」。
- **`bugsink-set-password` 走 ORM 不走 `changepassword`**（T-B4）：兩者都能從 stdin 讀密碼，但 `changepassword` 遇到不合格密碼會重問、stdin 讀完就丟 `EOFError` 堆疊。ORM 寫法可以先驗證再印乾淨訊息，寫法見附錄。

## 測試環境

全部在 DEV（本機），沒碰 STG／POC。

| 項目 | 值 |
|---|---|
| Postgres | 本機 `localhost:5432`，PostgreSQL 16.10（Homebrew） |
| 測試庫與帳號 | 庫 `bugsink`、`bugsink_b0_fresh`，角色 `bugsink`（`NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS`，兩庫的 owner） |
| docker network | `b0-net` |
| 站台容器 | `guidant-bugsink`（image `bugsink/bugsink:2.6.1`，digest `sha256:c5e3657451f4…`），volume `b0-bugsink-data` |
| 空白重跑容器 | `b0-bugsink-fresh`（接 `bugsink_b0_fresh`） |
| 前門 | `b0-nginx`（`nginx:1.27-alpine`，自簽憑證，`-p 443:443`） |
| 後端替身 | `b0-backend`（`python:3.11-slim`＋sentry-sdk 2.71.0，同網段） |
| 轉送端點 | `b0-tunnel`（`python:3.11-slim`＋Flask 3.1，`127.0.0.1:5099`） |
| 站台網址 | `BASE_URL=https://guidant-b0.test/bugsink`（瀏覽器與 SDK 以解析規則把該名稱對到 127.0.0.1） |

憑證值（DB 密碼 24 字元、`SECRET_KEY` 64 字元、管理員密碼 20 字元）都放在本 session 的暫存目錄，未進任何檔案。下文所有 key、token 一律遮成長度。

## 逐項紀錄

### ① 接 Postgres 起得來

```bash
docker run -d --name guidant-bugsink --network b0-net --env-file <暫存 env 檔> \
  -v b0-bugsink-data:/data --cap-drop ALL --security-opt no-new-privileges:true --memory 1g \
  bugsink/bugsink:2.6.1
# env 檔內容：SECRET_KEY=<64 字元>、BASE_URL=https://guidant-b0.test/bugsink、
#   DATABASE_URL=postgresql://bugsink:<24 字元>@host.docker.internal:5432/bugsink、
#   CREATE_SUPERUSER=admin@guidantai.com:<20 字元>、BEHIND_HTTPS_PROXY=True、
#   USER_REGISTRATION=CB_NOBODY、SINGLE_USER=True、PHONEHOME=false、TIME_ZONE=Asia/Taipei
```

```text
$ docker inspect -f "{{.State.Health.Status}}" guidant-bugsink
healthy
$ docker exec guidant-bugsink python -c "...urlopen(http://localhost:8000/health/ready)..."
200 b'OK'
$ psql -d bugsink -tAc "select count(*) from pg_tables where schemaname='public'"
38
$ psql -d bugsink（主要表）
auth_user, bsmain_authtoken, django_migrations, events_event, issues_issue, projects_project, teams_team
$ psql -d bugsink（表 owner）
bugsink
```

啟動 log 摘錄：`Apply all migrations: admin, alerts, auth, bsmain, …, teams, users` → `Superuser created: admin@guidantai.com` → gunicorn `Listening at: http://0.0.0.0:8000` → health 200。約 25 秒內 healthy。

image 自帶的 CMD 會跑 `check --deploy --fail-level WARNING`，`SECRET_KEY` 太短或 `BASE_URL` 不合法會直接起不來，等同免費的設定檢查。

**通過。** `cap_drop: [ALL]`、`no-new-privileges`、`--memory 1g` 下照常起來，§5.3.1 不必放寬。

### ② 管理員

```text
$ psql -d bugsink -tAc "select id, username, is_superuser, is_active from auth_user"
1|admin@guidantai.com|t|t
```

瀏覽器（headless Chromium，經 ⑥ 的 nginx）以 `admin@guidantai.com` 登入成功，落到 `/bugsink/projects/other/`（見 ⑥ 的輸出）。

`CREATE_SUPERUSER` 的實際行為（`bsmain/management/commands/prestart.py`）：

- 只要 `auth_user` 有任何一人就跳過，印 `Superuser not created: _any_ user(s) already exist(s)`。
- 格式 `email:password`，以第一個冒號切，密碼可含冒號。
- **不跑密碼強度驗證**（直接 `create_superuser`）。
- 第二次啟動（帶舊的 `CREATE_SUPERUSER`）不會報錯、不改密碼。

**通過。** T-B3 判斷首次建站用 `select count(*) from auth_user`。

### ③ ORM 建 team／project（D1）

model 路徑與欄位（從 image 內 `/usr/local/lib/python3.12/site-packages/` 查）：

| model | import | 表 | 唯一鍵 |
|---|---|---|---|
| Team | `from teams.models import Team, TeamVisibility` | `teams_team` | `name`（全站唯一），PK 是 UUID |
| Project | `from projects.models import Project, ProjectVisibility` | `projects_project` | `slug` 唯一；`(team, name)` 唯一約束 `unique_project_name_per_team`；PK 是整數 |

片段（`b0-bootstrap-snippet.sh`）以 `Team.objects.get_or_create(name="Guidant AI")`、`Project.objects.get_or_create(team=team, name="guidant-ai", defaults={"slug": "guidant-ai"})` 建，整段包在 `transaction.atomic()`。

```text
$ bash b0-bootstrap-snippet.sh guidant-bugsink   # 第 1 次
exit=0
BUGSINK_PROJECT_ID=1
BUGSINK_SENTRY_KEY=<32 字元>
BUGSINK_API_TOKEN=<40 字元>
$ bash b0-bootstrap-snippet.sh guidant-bugsink   # 第 2 次
exit=0
BUGSINK_PROJECT_ID=1
BUGSINK_SENTRY_KEY=<32 字元>
BUGSINK_API_TOKEN=<40 字元>
兩次輸出完全相同
$ psql -d bugsink（筆數）
teams|1
projects|1
tokens|1
$ psql -d bugsink（team／project）
Guidant AI|99|1|guidant-ai|guidant-ai|99|32
```

畫面確認（⑥ 的瀏覽器測試）：`teams 頁含「Guidant AI」: true`、`projects 頁含「guidant-ai」: true`。

visibility 取 `HIDDEN`(99)／`TEAM_MEMBERS`(99)：單一管理員是 superuser，看得到全部；不讓日後若開了其他帳號時自動看到。

**通過，D1 成立。**

### ④ 讀 DSN

DSN 組法（image 內 `compat/dsn.py` 的 `build_dsn`）：`<BASE_URL 的 scheme>://<sentry_key 去掉連字號>@<主機>[:埠]<BASE_URL 的 path>/<project id>`。

```bash
psql -d bugsink -tAc "select 'https://'||replace(sentry_key::text,'-','')||'@guidant-b0.test/bugsink/'||id from projects_project where name='guidant-ai'"
docker exec -i guidant-bugsink bugsink-manage shell --no-imports -c "from projects.models import Project;print(Project.objects.get(name='guidant-ai').dsn)"
```

```text
SQL 組出 DSN == ORM project.dsn
https://<32 字元 key>@guidant-b0.test/bugsink/1
```

畫面上 DSN 顯示在 `/bugsink/projects/1/sdk-setup/`；瀏覽器測試 `畫面上出現的 DSN == SQL 組出的 DSN: true`。

- 表名 `projects_project`；欄位 `id`（integer）、`sentry_key`（uuid，DB 存帶連字號，DSN 用 `.hex` 去連字號共 32 字元）。
- 片段直接從 ORM 印出 `sentry_key.hex`，install.sh 不必自己做連字號轉換。

**通過。**

### ⑤ 建 API token

model：`from bsmain.models import AuthToken`，表 `bsmain_authtoken`。token 是 40 字元 hex，由 model 預設值產生。

權限是一組布林欄位，名稱＝權限字串把 `:`、`-` 換成 `_`（`bugsink/api_capabilities.py` 的 `CAPABILITY_FIELD_NAMES`）：

| 權限 | 欄位 | 本卡設定 |
|---|---|---|
| `issues:read` | `issues_read` | True |
| `events:read` | `events_read` | True |
| `projects:read` | `projects_read` | True |
| 其餘九個（`issues:triage`、`projects:manage`、`teams:read`…） | 同規則 | False（預設） |

另有 `is_user_bound`、`is_project_bound`、`expires_at`。內建的 `bugsink-manage create_auth_token` 會發**全權限**token，所以不用它，改在片段裡以 ORM 只開三個讀權限。

```text
$ psql -d bugsink -tAc "select description, is_user_bound, is_project_bound, issues_read, events_read, projects_read, issues_triage, projects_manage, teams_read, length(token) from bsmain_authtoken"
guidant-ai installer (read-only)|f|f|t|t|t|f|f|f|40
```

API 實測（⑩）：`/projects/` 200、`/teams/`（無 `teams:read`）403、無 token 401、錯 token 401。

冪等：以 `description="guidant-ai installer (read-only)"` 且未撤銷（`expires_at is null`）找回既有那支；重跑不多發。

**通過。**

### ⑥ 443 子路徑（D2）

nginx（`b0-nginx`）照 design §5.3.2 的 location 一字不改，外加 `resolver 127.0.0.11`（與 FE `nginx.onprem.conf` 同），`listen 443 ssl`。

```text
$ curl -sk https://guidant-b0.test/bugsink/   （--resolve 到 127.0.0.1）
302 https://guidant-b0.test/bugsink/accounts/login/?next=/bugsink/
```

headless Chromium（playwright 1.58.2，`compliance-manager-test` 的 node_modules）整段操作：

```text
未登入首頁轉到: https://guidant-b0.test/bugsink/accounts/login/?next=/bugsink/
登入後網址: https://guidant-b0.test/bugsink/projects/other/
stylesheet 連結: https://guidant-b0.test/bugsink/static/css/dist/styles.css
  靜態檔 /bugsink/static/css/dist/styles.css: 200
teams 頁含「Guidant AI」: true
projects 頁含「guidant-ai」: true
projects 頁站內連結數: 18
不帶 /bugsink 前綴的連結: 無
DSN 頁網址: https://guidant-b0.test/bugsink/projects/1/sdk-setup/
畫面上出現的 DSN == SQL 組出的 DSN: true
projects 頁站內連結逐一 GET: 13 條已試
登出表單 action: /bugsink/accounts/logout/
登出後網址: https://guidant-b0.test/bugsink/accounts/logout/
登出後再進 projects 轉到: https://guidant-b0.test/bugsink/accounts/login/?next=/bugsink/projects/
過程中 4xx/5xx 回應: 無
```

envelope 經子路徑（⑦ 的送件），nginx access log：

```text
"POST /bugsink/api/1/envelope/ HTTP/1.1" 200 42 "-" "sentry.python/2.71.0"
```

子路徑能用的原因：`BASE_URL` 帶 path 時，Bugsink 自動設 `FORCE_SCRIPT_NAME=/bugsink`、`STATIC_URL=/bugsink/static/`（`/app/bugsink_conf.py` 的 `deduce_script_name`），連結、靜態檔、登入登出轉址都帶前綴；nginx 只要負責把 `/bugsink` 剝掉再送上游。

**通過，D2 成立，不必退 8443。**

### ⑦ sentry-sdk 直送

Python 3.11.9＋sentry-sdk 2.71.0（主專案 poetry 環境），用 ④ 的對外 DSN、帶自簽憑證的 CA，送一個 `RuntimeError` 並設 tag `request_id`。探針腳本攔 transport 回應碼印出。

```text
$ PUBLIC_DSN=https://<32 字元>@guidant-b0.test/bugsink/1 poetry run python send_sdk.py PUBLIC_DSN b0-07-direct <ca>
sentry_sdk=2.71.0 python=3.11.9 marker=b0-07-direct http_status=200
$ psql -d bugsink -tAc "select id, calculated_type, calculated_value, digested_event_count from issues_issue"
128ec7ff-…|RuntimeError|B0 probe b0-07-direct|1
```

**通過。**

### ⑧ 內網形狀 DSN

`b0-backend` 與 `guidant-bugsink` 同在 `b0-net`，DSN 改 `http://<32 字元>@guidant-bugsink:8000/1`。

**8a：原樣（不設 `ALLOWED_HOSTS`）——不通，而且 SDK 不報錯：**

```text
sentry_sdk=2.71.0 python=3.11.17 marker=b0-08a-inner-noallowed http_status=400
health 經容器名: HTTP Error 400: Bad Request
# guidant-bugsink log：
"POST /api/1/envelope/ HTTP/1.1" 400 1945 "-" "sentry.python/2.71.0"
django.core.exceptions.DisallowedHost: 'Host: guidant-bugsink' as sent by browser/proxy not in ALLOWED_HOSTS=['guidant-b0.test', 'localhost', '127.0.0.1']. …
$ psql（b0-08a 的 issue）
（0 筆）
```

設計預告的陷阱屬實：`ALLOWED_HOSTS` 由 `BASE_URL` 推成「主機名＋localhost＋127.0.0.1」，容器名被拒。`capture_exception` 不丟例外、程式照跑，事件靜默消失。

**8b：退路一，`ALLOWED_HOSTS` 環境變數覆寫——通過：**

image 支援此變數（`/app/bugsink_conf.py`：有 `ALLOWED_HOSTS` 就以逗號切開取代推導值）。重建容器加 `ALLOWED_HOSTS=guidant-b0.test,guidant-bugsink,localhost,127.0.0.1`（同 volume、同庫）：

```text
sentry_sdk=2.71.0 python=3.11.17 marker=b0-08b-inner-allowed http_status=200
"POST /api/1/envelope/ HTTP/1.1" 200 42 "-" "sentry.python/2.71.0"
$ psql（b0-08b 的 issue）
B0 probe b0-08b-inner-allowed|1
--- 偽造 Host（evil.example）仍被拒
偽造 Host: HTTP Error 400: Bad Request
--- 子路徑不受影響
302 https://guidant-b0.test/bugsink/accounts/login/?next=/bugsink/
```

另一個預告的陷阱**不成立**：`BEHIND_HTTPS_PROXY=True` 下，內網 http 請求沒帶 `X-Forwarded-Proto` 並不會被轉址。Bugsink 沒開 `SECURE_SSL_REDIRECT`（`bugsink/settings/default.py` 把 `security.W008` 列入靜默），這個旗標只影響 cookie 的 secure 屬性與「請求是否算 https」的判斷。

**選定退路一。** T-B1 的 compose 加：

```yaml
    ALLOWED_HOSTS: ${GUIDANT_BUGSINK_ALLOWED_HOSTS:?必填——由 installer 依站台主機名寫入}
```

值由 install.sh 從 `GUIDANT_BUGSINK_BASE_URL` 取出主機名，組成 `<主機名>,guidant-bugsink,localhost,127.0.0.1`。主機名若是 IP 也照放。變數名已 grep 確認全 codebase 無同義名。

### ⑨ tunnel 轉送

轉送端點 `b0-tunnel`：Flask，22 行（含頁面與 bundle 兩條供測試用的 GET），核心 `POST /api/1.0/error-tracking/tunnel` 做三件事：讀 envelope 第一行 JSON 的 `dsn`，比對 key 與 project id 是否為本站那一個（不符回 400），原樣 POST 到 `http://guidant-bugsink:8000/api/<id>/envelope/`。

前端：`@sentry/vue` 11.6.0＋vue 3.5.43，esbuild 打包，`Sentry.init({ dsn: <對外 DSN>, tunnel: '/api/1.0/error-tracking/tunnel' })`。headless Chromium 開頁、`captureException` 一個 `Error`。

```text
POST 回應 200 /api/1.0/error-tracking/tunnel
Sentry.flush 結果 true
瀏覽器發出的 POST 目標 127.0.0.1:5099/api/1.0/error-tracking/tunnel
# b0-tunnel log：
"POST /api/1.0/error-tracking/tunnel HTTP/1.1" 200 -
$ psql（b0-09 的 issue 與 event）
Error|B0 browser probe b0-09-tunnel|1
javascript|sentry.javascript.vue|11.6.0
```

瀏覽器只對轉送端點發 POST，沒有直接打站台。轉送端點收到的 envelope 第一行帶的是對外 DSN（`https://…@<主機>/bugsink/<id>`），所以 T-C 比對時只比 key 與 project id，不比主機。

**通過。**

### ⑩ 讀取 API 與主機名

token 打 API（由 `b0-backend` 經內網）：

```text
GET /issues/?project=1 -> 200 筆數 3 鍵 ['next', 'previous', 'results']
  issue 欄位 ['calculated_type', 'calculated_value', 'digest_order', 'digested_event_count', 'first_seen', 'friendly_id', 'id', 'is_muted', 'is_resolved', 'is_resolved_by_next_release', 'is_resolved_unconditionally', 'last_seen', 'project', 'stored_event_count', 'transaction']
GET /events/?issue=<id> -> 200 筆數 1 event 欄位 ['digest_order', 'digested_at', 'event_id', 'grouping', 'id', 'ingested_at', 'issue', 'project', 'timestamp']
GET /events/<id>/ -> 200 含 tags: True 含 request_id: True
  tag 篩選嘗試 '&query=request_id:b0-07-direct'         -> 200 筆數 3
  tag 篩選嘗試 '&q=request_id:b0-07-direct'             -> 200 筆數 3
  tag 篩選嘗試 '&tag=request_id:b0-07-direct'           -> 200 筆數 3
  tag 篩選嘗試 '&request_id=b0-07-direct'               -> 200 筆數 3
無 token -> 401
錯 token -> 401
GET /projects/ (projects:read) -> 200 ['guidant-ai']
GET /teams/ (無 teams:read，預期 403) -> 403
event 詳情頂層鍵 ['data', 'digest_order', 'digested_at', 'event_id', 'grouping', 'id', 'ingested_at', 'issue', 'project', 'stacktrace_md', 'timestamp']
request_id 出現在 .data.tags.request_id = b0-07-direct
```

- issue 列表只認 `project`（必填）、`sort`（`digest_order`／`last_seen`／`digested_event_count`）、`order`（`asc`／`desc`）。event 列表只認 `issue`（必填）、`order`。其他參數不報錯、直接忽略。分頁是 cursor 式（`next`／`previous`）。
- tag 搜尋（`request_id:xxx` 語法）只存在網頁介面（`tags/search.py`），沒開到 API。

**tag 不能篩 → T-D 改客端過濾**：取 issue 列表（`sort=last_seen&order=desc`）→ 逐 issue 取 event 列表 → 逐筆取 event 詳情比對 `data.tags.request_id`。請求數會隨 issue 數成長，T-D 要設上限（例如只看最近 N 個 issue）。

**換主機名**：`BASE_URL` 改 `https://guidant-b0-renamed.test/bugsink`、`ALLOWED_HOSTS` 跟著換，重建容器（同庫同 volume）：

```text
--- 換主機名後：project.dsn 變成
https://<32字元key>@guidant-b0-renamed.test/bugsink/1
--- 舊內網 DSN（key／id 不變）
sentry_sdk=2.71.0 python=3.11.17 marker=b0-10-rename-inner http_status=200
--- 舊對外 DSN（主機 guidant-b0.test，已不在 ALLOWED_HOSTS）
sentry_sdk=2.71.0 python=3.11.9 marker=b0-10-rename-oldpublic http_status=400
$ psql（b0-10 的 issue）
B0 probe b0-10-rename-inner
```

- key 與 project id 存在 DB，不隨 `BASE_URL` 變，**內網 DSN 改網址後照常送得進去**。
- 舊對外 DSN 的主機已不在 `ALLOWED_HOSTS`，回 400、SDK 不報錯。前端走轉送端點時不受影響（轉送端點只比 key 與 id、往內網送），但 `SENTRY_PUBLIC_DSN` 回給前端的字串若沒更新，事件仍會到，只是 envelope 標頭裡的網址是舊的。**T-B2 建議**：`SENTRY_PUBLIC_DSN` 每次 `setup_bugsink` 都依當下 `GUIDANT_BUGSINK_BASE_URL` 重寫，不走「缺了才補」；`ALLOWED_HOSTS` 同理。

測完已換回原 `BASE_URL`。

**通過（列表可用；tag 篩選不可行，走退路）。**

## 順手驗的三件事

### 清舊資料的子命令：`vacuum`

討論稿寫的名稱正確。`bugsink-manage help` 列在 `[bsmain]` 下。

```text
$ bugsink-manage vacuum   （不帶參數＝預設集合）
Vacuuming files...
Vacuuming old events...
Skipping old events; MAX_EVENT_AGE_DAYS is not configured.
Vacuuming deleted issues...
Vacuuming eventless issuetags...
Vacuuming tags...
Checking for stale temporary ingest files...
Temporary ingest directory does not exist: /tmp/bugsink/ingestion
Vacuum complete.
```

- 不帶參數跑預設集合（files、old-events、deleted-issues、eventless-issuetags、tags、ingest-dir）；`--orphaned-issues` 不在預設內。
- **刪舊事件要另設上限才會動**：`MAX_EVENT_AGE_DAYS` 未設時跳過，也可用 `--max-event-age-days N` 臨時指定。每個 project 另有 `retention_max_event_count`（預設 10,000 筆），超過由 Bugsink 自己在收件時淘汰，與 vacuum 無關。T-B4 的 `bugsink-vacuum` 若要真的砍空間，需決定是否帶 `--max-event-age-days`。
- 事件數可用 `select count(*) from events_event` 印前後對照。

### `changepassword` 可從 stdin 讀密碼，但不建議用

`changepassword` 用 `getpass`；`docker exec -i`（不配 TTY）時 getpass 退回讀 stdin：

```text
$ { echo <16 字元>; echo <16 字元>; } | docker exec -i guidant-bugsink bugsink-manage changepassword admin@guidantai.com
Password:
Warning: Password input may be echoed.
Password (again):
Password changed successfully for user 'admin@guidantai.com'
密碼雜湊已變更；authenticate(新密碼) -> True
```

但密碼不合格時會重問，stdin 讀完就崩：

```text
$ printf 'abc\nabc\n' | docker exec -i guidant-bugsink bugsink-manage changepassword admin@guidantai.com
  File "/usr/local/lib/python3.12/getpass.py", line 148, in _raw_input
    raise EOFError
EOFError
```

驗證器四支：`UserAttributeSimilarityValidator`、`MinimumLengthValidator`（Django 預設 8）、`CommonPasswordValidator`、`NumericPasswordValidator`。

**T-B4 改用 ORM**（附錄 B），密碼只經 stdin、先跑驗證器，不合格印清楚訊息、exit 1。已實測 `set_password` 後 `authenticate` 為 True，並已把管理員密碼還原。

另：出貨烙印密碼走 `CREATE_SUPERUSER`，**不經驗證器**。`gen_bugsink_admin_bake.sh` 已要求 ≥12 字元，但仍可能產出過於常見或與帳號相似的值而在日後 `set-password` 時被拒，建議烙印腳本產隨機值而非手設。

### 使用者表：`auth_user`

`users.models.User`（`AUTH_USER_MODEL = "users.User"`），`Meta.db_table = 'auth_user'`。T-B3 用 `select count(*) from auth_user`。

## 空白容器重跑片段

另開空庫 `bugsink_b0_fresh`、容器 `b0-bugsink-fresh`（不帶 `CREATE_SUPERUSER`、不帶 `ALLOWED_HOSTS`），等 healthy 後跑片段兩次：

```text
auth_user 人數（未帶 CREATE_SUPERUSER）: 0
$ b0-bootstrap-snippet.sh b0-bugsink-fresh   # 第 1 次
exit=0
BUGSINK_PROJECT_ID=1
BUGSINK_SENTRY_KEY=<32 字元>
BUGSINK_API_TOKEN=<40 字元>
$ b0-bootstrap-snippet.sh b0-bugsink-fresh   # 第 2 次
exit=0
BUGSINK_PROJECT_ID=1
BUGSINK_SENTRY_KEY=<32 字元>
BUGSINK_API_TOKEN=<40 字元>
兩次輸出完全相同
與主站台的 key／token 不同（各站台各自產生）
teams=1 projects=1 tokens=1
$ 容器不存在時
exit=1
```

- 片段不需要使用者存在就能建 team、project、token（零人站台也可先跑）。
- 容器內 Python 出錯（`SystemExit`、`ZeroDivisionError`）時 `bugsink-manage shell` 回 exit 1；片段有 `set -o pipefail`，install.sh 會拿到非零。

## 附錄 A：建站片段

檔案 `b0-bootstrap-snippet.sh`（同目錄），可整段搬進 `setup_bugsink()`：

- 輸入：容器名（預設 `guidant-bugsink`）。DB 連線由容器自己的 `DATABASE_URL` 帶，片段不經手任何密碼，所以卡片寫的「DB 連線參數」輸入不需要。
- 輸出三行：`BUGSINK_PROJECT_ID=`、`BUGSINK_SENTRY_KEY=`、`BUGSINK_API_TOKEN=`。
- 冪等：team 以名稱、project 以 `(team, name)`、token 以 description＋未撤銷 找回既有那筆。

install.sh 讀法示意：

```bash
local kv; kv="$(bash b0-bootstrap-snippet.sh guidant-bugsink)" || fail "錯誤追蹤站初始化失敗，請看 docker logs guidant-bugsink"
local bs_id bs_key bs_token
bs_id="$(sed -n 's/^BUGSINK_PROJECT_ID=//p' <<<"$kv")"
bs_key="$(sed -n 's/^BUGSINK_SENTRY_KEY=//p' <<<"$kv")"
bs_token="$(sed -n 's/^BUGSINK_API_TOKEN=//p' <<<"$kv")"
[ -n "$bs_id" ] && [ -n "$bs_key" ] && [ -n "$bs_token" ] || fail "錯誤追蹤站初始化沒拿到完整結果"
```

## 附錄 B：改管理員密碼（給 T-B4）

密碼只走 stdin，不進命令列參數：

```bash
printf '%s\n' "$new_pw" | docker exec -i guidant-bugsink bugsink-manage shell --no-imports -c '
import sys
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
pw = sys.stdin.readline().rstrip("\n")
user = get_user_model().objects.get(username="admin@guidantai.com")
try:
    validate_password(pw, user)
except ValidationError as e:
    print("密碼不合格：" + "；".join(e.messages)); raise SystemExit(1)
user.set_password(pw); user.save(update_fields=["password"]); print("OK")
'
```

## 測試資源與清理

留著供 T-B1／T-D 用：

| 資源 | 名稱 |
|---|---|
| 容器 | `guidant-bugsink`（目前 `BASE_URL=https://guidant-b0.test/bugsink`、有 `ALLOWED_HOSTS`）、`b0-bugsink-fresh`、`b0-nginx`、`b0-backend`、`b0-tunnel` |
| network | `b0-net` |
| volume | `b0-bugsink-data` |
| DEV Postgres | 庫 `bugsink`、`bugsink_b0_fresh`，角色 `bugsink` |

全部清掉：

```bash
docker rm -f guidant-bugsink b0-bugsink-fresh b0-nginx b0-backend b0-tunnel
docker network rm b0-net
docker volume rm b0-bugsink-data
psql -h localhost -d postgres -c "DROP DATABASE IF EXISTS bugsink" -c "DROP DATABASE IF EXISTS bugsink_b0_fresh" -c "DROP ROLE IF EXISTS bugsink"
```

`b0-nginx` 佔著本機 443；之後要在本機起別的 443 服務前先停它。
