# 将规约接入统一实验环境

LLMS 索引： [llms.txt](/llms.txt)

---

规约若只存在于 repository，就无法约束实际环境；平台若只负责“把 PostgreSQL 装起来”，又无法知道业务对象是否满足合同。Pigsty 与版本化 SQL 在这里承担不同职责：

```text
Pigsty inventory
  ├─ cluster / instance / service / HBA / pool
  ├─ role、database、schema 的基础声明
  └─ database/role GUC 默认

Versioned SQL
  ├─ object privileges / default privileges
  ├─ table / type / constraint / view / function
  ├─ migration history / schema version
  └─ fixture / positive / negative / post-state

Runtime verification
  ├─ catalog / pg_settings / session
  ├─ service route / pool behavior
  └─ metrics / logs / evidence checksum
```

这三层共同组成统一实验环境。平台声明不能替代业务 migration，migration 成功也不能证明 HAProxy/PgBouncer 路由正确。

## 6.6.1 角色、数据库与服务声明 {#item-6-6-1}

Pigsty 是配置驱动平台：inventory 的 global、cluster、host 层按覆盖顺序形成最终参数，再由 playbook 生成并应用 Patroni、PostgreSQL、PgBouncer、HAProxy 与相关配置。`pg_users` 和 `pg_databases` 允许在 cluster vars 中声明业务身份和数据库。

本章提供一个不含凭据的 [`pigsty-declaration.example.yml`](/labs/ch06/pigsty-declaration.example.yml)。它是应合并到目标 cluster `vars` 的片段，不是完整 inventory：

```yaml
pg_users:
  - name: pg36_owner
    login: false
    superuser: false
    createdb: false
    createrole: false
    replication: false
    bypassrls: false

  - name: pg36_app
    login: true
    superuser: false
    createdb: false
    createrole: false
    pgbouncer: true
    pool_mode: transaction

  - name: pg36_ro
    login: true
    superuser: false
    createdb: false
    createrole: false
    pgbouncer: true
    pool_mode: transaction

pg_databases:
  - name: pg36_shop
    owner: pg36_owner
    encoding: UTF8
    locale: C
    revokeconn: true
    pgbouncer: true
    pool_mode: transaction
    schemas:
      - { name: shop, owner: pg36_owner }
      - { name: shop_api, owner: pg36_owner }
      - { name: shop_private, owner: pg36_owner }
```

完整样例还包含连接池预算和 database-level timeout/UTC 默认。数值是教学起点，必须按真实 connection budget 与 workload 调整。

### 为什么先声明 role，再声明 database

PostgreSQL role 属于整个 cluster，不属于单个 database；database owner 在创建 database 时必须已经存在。Pigsty 的 `pg_users` 又按数组顺序创建，所以样例先创建 NOLOGIN owner，再创建 application/read-only LOGIN role，最后创建由 owner 持有的 database。

LOGIN role 的 credential 没有进入样例。实际 inventory 必须从受控 secret overlay 注入 SCRAM secret 或采用组织认证方案；不能把展开后凭据提交到本书 repository。若直接应用这份无密码片段，role 可以创建，但不能靠密码认证登录——这是有意的 fail-closed，不是可直接上线的完整安全配置。

`revokeconn: true` 会撤销 PUBLIC CONNECT，并保留 owner/管理/监控等受控入口。`pg36_app` 与 `pg36_ro` 的精确 CONNECT、schema USAGE、table/sequence privilege 和 default privilege 仍由 ch01 versioned SQL 授予。这里故意不把所有业务授权改成 Pigsty 内置全局 `dbrole_readwrite`：本书要验证 `pg36_shop` 的对象级最小权限，而不是让跨库角色隐式扩大范围。

### 为什么不把业务 schema 塞进一次性 baseline

Pigsty `pg_databases.baseline` 会在 database 首次创建时执行 SQL，已有 database 会跳过；`encoding`、locale、template 等字段又具有创建时不可变的边界。它适合明确的一次性引导，但不能单独承担持续 schema migration。

本书让：

```text
Pigsty: database/role/service 基础存在
SQL chain: ch01 → ch03 → ch04 → 后续版本
```

fresh install 与 upgrade 因此复用同一 migration authority。即使 `schemas` 已由 Pigsty 创建，SQL 使用 `CREATE SCHEMA IF NOT EXISTS` 后仍验证 owner/privilege；若同名 schema 形状或 owner 不符合合同，后验会失败，而不是因为“存在”就默认正确。

### 使用默认 service，而不是再造一个名字

Pigsty v4.5 每个 PostgreSQL cluster 默认提供：

| Service | Port | 本章用途 |
|---|---:|---|
| `primary` | 5433 | production read/write，经 primary PgBouncer |
| `replica` | 5434 | production read-only，经 replica PgBouncer |
| `default` | 5436 | admin/ETL/direct primary PostgreSQL |
| `offline` | 5438 | OLAP/ETL/个人只读类 direct workload |

`pg36_app` 的日常 OLTP 连接应使用 `primary:5433`；受审计 migration、catalog 诊断和本章 `SET ROLE` gate 使用 `default:5436` direct path。两者都指向当前 primary，但 pool/session 语义不同。read-only role 也不能仅凭名字就发送到 replica：调用方要选择 replica service，并接受复制延迟与 read-after-write 语义。

本章无需自定义 `pg_services`。只有默认 selector、health check、destination 或端口不能表达 workload 时才增加 service，并同时说明 failover、fallback 与容量边界。多一个 service 名不是更安全；没有调用合同的 service 只会增加误路由。

## 6.6.2 初始化、验证与重置入口 {#item-6-6-2}

统一环境需要把“基础设施声明”和“书中 SQL”排成可重复顺序。

### 第一次初始化

先在 Pigsty repository 中把样例片段合并到**已确认的目标 cluster**。不要照抄 cluster 名；先查看 inventory graph、最终 host vars 和 diff。对于已有 cluster，官方 v4.5 的精确入口是：

```text
bin/pgsql-user <cluster> pg36_owner
bin/pgsql-user <cluster> pg36_app
bin/pgsql-user <cluster> pg36_ro
bin/pgsql-db   <cluster> pg36_shop
```

这些命令只是说明 apply 顺序。真正执行前必须：

- 用 `-l`/wrapper 的 cluster 参数限制到单一已确认目标；
- 确认 secret overlay 已生效但不会打印到 evidence；
- 确认同名 role/database 没有另一业务含义；
- 对 immutable database 字段检查现状，不用 `state: recreate` 强制收敛；
- 保存 inventory commit、resolved target 和 playbook result。

新 cluster 可以在受控 `pgsql.yml -l <cluster>` 初始化中创建这些对象；已有 cluster 应用专用 `pgsql-user`/`pgsql-db`，不要为了新增一个 database 重新运行无范围的全局 playbook。

然后通过 `default:5436` 的私有 libpq service 执行书中版本链：

```text
ch01 setup        → role/database/schema/privilege baseline
ch03 setup + seed → logical model v0
ch04 migrate      → reliable physical model v1
ch04 verify       → catalog + data checksum
ch06 all          → session + query + baseline quality gate
```

实际目录中各章的 `task.sh` 固定 action 与 evidence。不要把这些步骤复制成一条不检查中间状态的长 shell command；每个 version boundary 成功后保存 summary，失败时停在已知状态。

### 每次任务只有一个 action 合同

action 名应表达风险和后置状态：

```text
setup / migrate / seed
verify / observe / negative / review
reset（仅专属可销毁 target）
```

统一入口负责：

1. 解析 action，未知值以 usage/exit 64 拒绝；
2. 检查依赖工具与 `PGSERVICEFILE`；
3. 创建 mode 0700/umask 077 evidence directory；
4. 写 source manifest；
5. 运行 context guard 和 verify-before；
6. 执行 action；
7. 即使预期报错，也核对精确 exit/SQLSTATE；
8. 写 verify-after 与 machine-readable summary；
9. 清理本次启动的精确 worker。

脚本不应根据“这是开发机”自动猜测 database 可以删除。环境分类可以决定是否允许 R1/R2，但 destructive target 与 token 仍要精确。

### reset 不属于正常升级路径

ch01/ch03/ch04 的 reset 用于放弃整个教学模型并重建，属于 R2，必须使用章节定义的双重令牌。它不能用于：

- 清理未知生产漂移；
- 让失败 migration 看起来重新成功；
- 在保留价值不明时重建 database；
- 替代 application/schema 兼容回退。

本章没有持久写入，所以不提供 reset。成功的 `all` 应保证 relation checksum 不变；若 checksum 漂移，正确动作是停下来调查，不是自动调用上一章 reset。

### 应用流量还要单独验收 pooled path

本章 quality gate 使用 `pg36-admin` direct service，因为它需要稳定 session、catalog visibility 与 `SET ROLE pg36_owner`。它没有证明 application 经 `primary:5433` 的行为。应用交付前还应使用 `pg36_app` service 测试：

```text
frontend endpoint = primary:5433
effective identity = pg36_app
read/write privilege = exact contract
owner/DDL privilege = denied
transaction pool reuse = no leaked session state
timeout/cancel = driver contract
application_name = attributable
```

这层将在 ch12 的“从数据库到服务”中成为 v1.0 验收项。

## 6.6.3 配置事实与运行事实分开审查 {#item-6-6-3}

一次平台变更至少有四类事实：

| 层次 | 证据 | 能证明什么 | 不能证明什么 |
|---|---|---|---|
| Git/inventory | reviewed YAML + commit | 期望状态和变更意图 | 已应用到哪个 target |
| apply | playbook target/diff/result | 某次动作在某批 host 执行 | 所有运行事实持续正确 |
| PostgreSQL | catalog、GUC、SQLSTATE、checksum | 当前数据库实际对象与语义 | 客户端经过哪个 frontend service |
| routing/observability | HAProxy/PgBouncer state、连接 endpoint、dashboard/log | service 路由、pool 与时间趋势 | 业务不变量全部正确 |

“配置里写了”只能回答第一行。一次严谨审查同时保留 desired、apply 和 actual。

### 从 YAML 回到 PostgreSQL catalog

`pg_users` 的后验不是搜索配置文本，而是：

```sql
SELECT
    rolname,
    rolcanlogin,
    rolsuper,
    rolcreatedb,
    rolcreaterole,
    rolreplication,
    rolbypassrls,
    rolconnlimit
FROM pg_catalog.pg_roles
WHERE rolname IN ('pg36_owner', 'pg36_app', 'pg36_ro');
```

database/schema 后验包括 owner、encoding、locale/collation、CONNECT、schema owner/USAGE/CREATE。role membership 在 Pigsty 中可能是 additive；从 inventory 删除一个 role name 不一定等于数据库里自动撤销已有 membership，必须用显式 absent/revoke 和 catalog 后验。

database immutable 参数若与 inventory 不同，不应自动 `state: recreate`。先把漂移记录为 change，评估数据保留、backup/PITR 和 application downtime，再决定迁移或接受有 expiry 的 waiver。

### 从参数声明回到生效值和来源

`ALTER DATABASE/ROLE SET` 通常只影响新 session。检查：

```sql
SELECT
    name,
    setting,
    unit,
    source,
    sourcefile,
    pending_restart
FROM pg_catalog.pg_settings
WHERE name IN (
    'statement_timeout',
    'lock_timeout',
    'idle_in_transaction_session_timeout'
);
```

再在目标 role/database 的**新连接**中 `SHOW`/`current_setting()`。`pg_settings` 的当前 backend 值与 source 能解释本会话，但不能仅凭 `postgresql.conf` 文件推断覆盖后的结果。pending restart、reload 与新连接边界也必须区分。

### 从 service 名回到真实路由

连接 `primary:5433` 时保存：

```text
client requested host/port/service
current_database / session_user / current_user
pg_is_in_recovery()
inet_server_addr / inet_server_port
application_name / backend_start
HAProxy/PgBouncer service state and timestamp
```

`pg_is_in_recovery()=false` 证明当前 backend 可写 primary，不证明客户端一定经过预期 HAProxy port；客户端 endpoint 证明请求入口，不证明 selector 在未来 failover 始终正确。要将两类事实与 PGSQL Service/Proxy/PgBouncer dashboard 或 HAProxy state 对齐。

连接 `replica:5434` 也不能只检查 `default_transaction_read_only`：健康 selector、实际 recovery state、replication lag 与 fallback policy共同决定读语义。对 read-after-write 敏感的请求通常应继续走 primary，或显式等待/携带一致性标记。

### 漂移处理不是“以谁为准”一句话

发现 inventory 与 actual 不同时，先分类：

```text
尚未 apply
apply failed/partial
manual hotfix 未回写
运行时临时 SET/override
版本/不可变属性导致不能收敛
检查器读错 target
```

然后选择：

- 重新 apply 并验证；
- 把合法 hotfix 回写 inventory/migration；
- 撤销未经授权的手工漂移；
- 为不可变差异设计迁移；
- 修正检查 target；
- 在有 owner/expiry 的 waiver 中暂时接受。

不能机械地让自动化“配置覆盖运行”，也不能把实际状态反向复制进 Git 就算解决。权威来源取决于对象：cluster/service desired state 通常在 Pigsty inventory，业务 schema version 在 migration ledger，当前故障处置可能暂时以 incident hotfix 为准，但结束后必须回写。

### 本节验收

把样例接入一个已确认 L1 后，应能提供三组独立证据：

```text
desired:
  inventory commit + resolved cluster vars（secret redacted）

applied:
  exact cluster target + pgsql-user/db playbook result

actual:
  pg_roles / pg_database / schemas / grants / GUC
  direct admin quality gate
  pooled application service probe
```

只有三组吻合，才能说“规约已经接入环境”。本章实验只完成 direct admin 和 PostgreSQL actual 部分；真正 Pigsty cluster 的 apply 与 pooled application probe必须在读者自己的 L1 中完成并保存 target-specific evidence。

## 参考资料

- [Pigsty v4.5：PostgreSQL Configuration](https://pigsty.io/docs/pgsql/config/)
- [Pigsty v4.5：User/Role](https://pigsty.io/docs/pgsql/config/user/)
- [Pigsty v4.5：Managing Users](https://pigsty.io/docs/pgsql/admin/user/)
- [Pigsty v4.5：Database](https://pigsty.io/docs/pgsql/config/db/)
- [Pigsty v4.5：Managing Databases](https://pigsty.io/docs/pgsql/admin/db/)
- [Pigsty v4.5：Service/Access](https://pigsty.io/docs/pgsql/service/)
- [Pigsty v4.5：PGSQL Playbooks](https://pigsty.io/docs/pgsql/playbook/)

---

[上一节：交付物与质量门](../05/) · [返回本章目录](../) · [下一节：实战：发布规约 baseline v0.1](../07/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
