From b8cdfc80dcf8aaee766e109acbf448409675a151 Mon Sep 17 00:00:00 2001 From: xxsc0529 Date: Fri, 10 Apr 2026 16:45:12 +0800 Subject: [PATCH] feat: add oceanbase-cli package, Agent skill, CI, and PyPI publish workflow Made-with: Cursor --- .../workflows/publish-oceanbase-cli-pypi.yml | 63 +++++ .github/workflows/workflow.yml | 33 ++- .gitignore | 1 + README.md | 10 + oceanbase-cli/README.md | 46 ++++ oceanbase-cli/docs/operations-guide-zh.md | 196 ++++++++++++++ oceanbase-cli/pyproject.toml | 35 +++ oceanbase-cli/src/oceanbase_cli/__init__.py | 3 + oceanbase-cli/src/oceanbase_cli/audit.py | 19 ++ oceanbase-cli/src/oceanbase_cli/clickutil.py | 15 ++ oceanbase-cli/src/oceanbase_cli/config.py | 68 +++++ .../src/oceanbase_cli/config_commands.py | 66 +++++ .../src/oceanbase_cli/connection_pool.py | 248 ++++++++++++++++++ oceanbase-cli/src/oceanbase_cli/constants.py | 3 + .../src/oceanbase_cli/credentials_store.py | 75 ++++++ oceanbase-cli/src/oceanbase_cli/dsn.py | 131 +++++++++ oceanbase-cli/src/oceanbase_cli/executor.py | 91 +++++++ oceanbase-cli/src/oceanbase_cli/main.py | 46 ++++ oceanbase-cli/src/oceanbase_cli/output.py | 180 +++++++++++++ oceanbase-cli/src/oceanbase_cli/paths.py | 32 +++ oceanbase-cli/src/oceanbase_cli/policy.py | 102 +++++++ .../src/oceanbase_cli/rules_commands.py | 80 ++++++ oceanbase-cli/src/oceanbase_cli/schema_cmd.py | 55 ++++ .../src/oceanbase_cli/sql_classification.py | 19 ++ oceanbase-cli/src/oceanbase_cli/sql_cmd.py | 88 +++++++ oceanbase-cli/src/oceanbase_cli/status_cmd.py | 60 +++++ oceanbase-cli/tests/test_connection_pool.py | 99 +++++++ .../tests/test_demo_obcli_and_skill.py | 241 +++++++++++++++++ oceanbase-cli/tests/test_dsn.py | 48 ++++ oceanbase-cli/tests/test_policy_rules.py | 57 ++++ skills/oceanbase-cli/SKILL.md | 92 +++++++ 31 files changed, 2301 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-oceanbase-cli-pypi.yml create mode 100644 oceanbase-cli/README.md create mode 100644 oceanbase-cli/docs/operations-guide-zh.md create mode 100644 oceanbase-cli/pyproject.toml create mode 100644 oceanbase-cli/src/oceanbase_cli/__init__.py create mode 100644 oceanbase-cli/src/oceanbase_cli/audit.py create mode 100644 oceanbase-cli/src/oceanbase_cli/clickutil.py create mode 100644 oceanbase-cli/src/oceanbase_cli/config.py create mode 100644 oceanbase-cli/src/oceanbase_cli/config_commands.py create mode 100644 oceanbase-cli/src/oceanbase_cli/connection_pool.py create mode 100644 oceanbase-cli/src/oceanbase_cli/constants.py create mode 100644 oceanbase-cli/src/oceanbase_cli/credentials_store.py create mode 100644 oceanbase-cli/src/oceanbase_cli/dsn.py create mode 100644 oceanbase-cli/src/oceanbase_cli/executor.py create mode 100644 oceanbase-cli/src/oceanbase_cli/main.py create mode 100644 oceanbase-cli/src/oceanbase_cli/output.py create mode 100644 oceanbase-cli/src/oceanbase_cli/paths.py create mode 100644 oceanbase-cli/src/oceanbase_cli/policy.py create mode 100644 oceanbase-cli/src/oceanbase_cli/rules_commands.py create mode 100644 oceanbase-cli/src/oceanbase_cli/schema_cmd.py create mode 100644 oceanbase-cli/src/oceanbase_cli/sql_classification.py create mode 100644 oceanbase-cli/src/oceanbase_cli/sql_cmd.py create mode 100644 oceanbase-cli/src/oceanbase_cli/status_cmd.py create mode 100644 oceanbase-cli/tests/test_connection_pool.py create mode 100644 oceanbase-cli/tests/test_demo_obcli_and_skill.py create mode 100644 oceanbase-cli/tests/test_dsn.py create mode 100644 oceanbase-cli/tests/test_policy_rules.py create mode 100644 skills/oceanbase-cli/SKILL.md diff --git a/.github/workflows/publish-oceanbase-cli-pypi.yml b/.github/workflows/publish-oceanbase-cli-pypi.yml new file mode 100644 index 00000000..92c3abf6 --- /dev/null +++ b/.github/workflows/publish-oceanbase-cli-pypi.yml @@ -0,0 +1,63 @@ +name: Publish OceanBase CLI to PyPI + +on: + push: + tags: + - 'release_oceanbase_cli_*' + +permissions: + contents: read + +jobs: + release-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Run tests + run: | + cd oceanbase-cli + pip install -e ".[dev]" + pytest tests/ -v + + - name: Build package + run: | + rm -rf dist + mkdir -p dist + python -m build ./oceanbase-cli -o dist + + - name: Check distributions + run: twine check dist/* + + - name: Upload dists + uses: actions/upload-artifact@v4 + with: + name: release-dists + path: dist/ + + pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + permissions: + id-token: write + + steps: + - name: Retrieve release distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: dist/ + + - name: Publish release distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 76d5b616..3ce53df9 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -112,4 +112,35 @@ jobs: uses: actions/upload-artifact@v4 with: name: langgraph-checkpoint-oceanbase-plugin - path: langgraph-checkpoint-oceanbase-plugin/dist/* \ No newline at end of file + path: langgraph-checkpoint-oceanbase-plugin/dist/* + + oceanbase-cli: + name: Test and Build OceanBase CLI + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies and run tests + run: | + cd oceanbase-cli + pip install --upgrade pip + pip install build twine wheel + pip install -e ".[dev]" + pytest tests/ -v + + - name: Build Python package + run: | + cd oceanbase-cli + python -m build + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: oceanbase-cli + path: oceanbase-cli/dist/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5a4e8ae1..693f7d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,7 @@ celerybeat.pid # Environments .env .venv +.venv-*/ env/ venv/ ENV/ diff --git a/README.md b/README.md index 6ba87ce1..f0c6e053 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ OceanBase is a high-performance database compatible with both MySQL and Oracle p | [LangGraph Checkpoint OceanBase Plugin](./langgraph-checkpoint-oceanbase-plugin/README.md) | LangGraph CheckpointSaver | Implementation of LangGraph CheckpointSaver in OceanBase MySQL mode | | [Fluent Plugin OceanBase Logs](./fluent-plugin-oceanbase-logs/README.md) | Log collection (Fluentd) | Fluentd input plugin that pulls slow SQL and top SQL diagnostics from OceanBase Cloud | | [PyObsql OceanBase Plugin](./pyobsql-oceanbase-plugin/README.md) | Python SDK | A Python SDK for OceanBase SQL with JSON Table support and SQLAlchemy dialect extensions | +| [OceanBase CLI](./oceanbase-cli/README.md) | CLI & Agent Skill | `obcli` for MySQL protocol (encrypted local DSN, optional `policy.json`); includes [Agent Skill](./skills/oceanbase-cli/SKILL.md) for Cursor / Claude Code | --- @@ -107,6 +108,14 @@ OceanBase is a high-performance database compatible with both MySQL and Oracle p --- +### ✅ OceanBase CLI (`obcli`) + +- **Function**: Command-line tool for OceanBase over the **MySQL protocol** (via **pymysql**): encrypted DSN under `~/.config/obcli/`, read-oriented `obcli sql` by default, optional local **`policy.json`** with **`block_rules`** for write-class SQL when enabled. +- **Use Case**: Local or scripted access to OceanBase tenants; pair with the **[Agent Skill](./skills/oceanbase-cli/SKILL.md)** so assistants invoke `obcli` only through the shell and do not modify on-disk config. +- **Documentation**: [OceanBase CLI](./oceanbase-cli/README.md) · **Skill contract**: [skills/oceanbase-cli/SKILL.md](./skills/oceanbase-cli/SKILL.md) + +--- + ## 📚 Full Documentation Links | Plugin Name | Documentation Link | @@ -121,6 +130,7 @@ OceanBase is a high-performance database compatible with both MySQL and Oracle p | LangGraph Checkpoint OceanBase Plugin | [LangGraph Checkpoint OceanBase Plugin](./langgraph-checkpoint-oceanbase-plugin/README.md) | | Fluent Plugin OceanBase Logs | [Fluent Plugin OceanBase Logs](./fluent-plugin-oceanbase-logs/README.md) | | PyObsql OceanBase Plugin | [PyObsql OceanBase Plugin](./pyobsql-oceanbase-plugin/README.md) | +| OceanBase CLI | [OceanBase CLI](./oceanbase-cli/README.md) · [Agent Skill](./skills/oceanbase-cli/SKILL.md) | --- diff --git a/oceanbase-cli/README.md b/oceanbase-cli/README.md new file mode 100644 index 00000000..d70a9a87 --- /dev/null +++ b/oceanbase-cli/README.md @@ -0,0 +1,46 @@ +# oceanbase-cli (`obcli`) + +**OceanBase / MySQL protocol** CLI (**pymysql**): + +- **Encrypted DSN** under `~/.config/obcli/` (or `$XDG_CONFIG_HOME/obcli/`). +- **`obcli sql`** — read-only without **`policy.json`**; with a valid **`policy.json`**, DML/DDL follow **`block_rules`** (no CLI flag or env to skip policy). +- **Chinese ops guide:** [docs/operations-guide-zh.md](./docs/operations-guide-zh.md) · **Policy sample:** [examples/policy.example.json](./examples/policy.example.json) + +Design note (historical): [docs/cloud-platform-token-cli-skills-design.md](../docs/cloud-platform-token-cli-skills-design.md) + +**Agent skill:** [skills/oceanbase-cli/SKILL.md](../skills/oceanbase-cli/SKILL.md) · **Architecture (中文):** [docs/architecture-components-zh.md](./docs/architecture-components-zh.md) + +## Install + +```bash +pip install -e ./oceanbase-cli +``` + +Dependencies: `click`, `cryptography`, `pymysql`. Optional **`oceanbase_cli.connection_pool`** for other tools; **`obcli`** uses one connection per command via `executor`. + +## Store DSN (stdin) + +```bash +printf '%s' 'oceanbase://user:pass@host:2881/db' | obcli config set-dsn +obcli config status +``` + +Avoid `obcli --dsn '...'` where argv is visible in `ps`. + +## Commands + +| Command | Purpose | +|---------|---------| +| `obcli config set-dsn` / `clear-dsn` / `status` | Encrypted DSN + paths | +| `obcli rules show` / `metadata` / `explain` | `policy.json` + **`policy_governs_writes`** 等 | +| `obcli sql`, `status`, `schema tables` | Data plane | + +## Tests + +```bash +cd oceanbase-cli && pip install -e ".[dev]" && pytest tests/ -v +``` + +## Debug + +`OBCLI_AUDIT_LOG` / `OBCLI_AUDIT_DISABLED` — audit JSONL. diff --git a/oceanbase-cli/docs/operations-guide-zh.md b/oceanbase-cli/docs/operations-guide-zh.md new file mode 100644 index 00000000..c573dea2 --- /dev/null +++ b/oceanbase-cli/docs/operations-guide-zh.md @@ -0,0 +1,196 @@ +# OceanBase CLI(`obcli`)操作手册 + +面向安装、配置与日常使用。策略示例:[examples/policy.example.json](../examples/policy.example.json);源码说明:[developer-guide-zh.md](./developer-guide-zh.md)。 + +--- + +## 1. 概述 + +`obcli` 通过 **pymysql** 连接 **OceanBase / MySQL 协议**(`oceanbase://`、`mysql://`): + +- DSN **加密**保存在本机配置目录(不必把连接串放环境变量)。 +- **`obcli sql`**:**无有效 `policy.json` 时仅允许只读类 SQL**;有效策略下由 **`block_rules`** 拦截写类语句(见第 6、7 节)。**不能**用 CLI 开关或环境变量跳过 `policy.json`。 +- 策略路径、当前是否托管写权限:用 **`obcli rules show`** 或 **`obcli rules metadata`**(字段 **`policy_governs_writes`** 等)。 + +**限制:** **`policy.json` 仅作用于 `obcli sql`。** `status`、`schema tables` 不读策略。 + +--- + +## 2. 安装 + +```bash +cd /path/to/repo +pip install -e ./oceanbase-cli +obcli --version +``` + +--- + +## 3. 配置目录 + +| 条件 | obcli 配置目录 | +|------|----------------| +| 未设置 `XDG_CONFIG_HOME` | `~/.config/obcli/` | +| 已设置 | `$XDG_CONFIG_HOME/obcli/` | + +常用文件:**`dsn.enc`**(加密 DSN)、**`.obcli-key`**(密钥)、**`policy.json`**(可选)。 + +持久化修改 `XDG_CONFIG_HOME` 可写入 `~/.zshrc` / `~/.bashrc`: + +```bash +export XDG_CONFIG_HOME="$HOME/my-config" +mkdir -p "$XDG_CONFIG_HOME/obcli" +``` + +查看路径与文件是否存在: + +```bash +obcli --format json config status +``` + +--- + +## 4. DSN + +**格式示例:** `oceanbase://user:password@host:2881/database_name` +URL 里未写端口时默认为 **3306**;OceanBase 常见为 **2881**,建议在 DSN 里写清。 + +**OceanBase 账号(二段式 / 三段式,未编码写法):** +- **二段式**(`用户@租户` + 密码):`oceanbase://mysqluser@mytenant:你的密码@主机:端口/库名` +- **三段式**(`用户@租户#集群` + 密码):`oceanbase://mysqluser@mytenant#mycluster:你的密码@主机:端口/库名` + +用户名与密码之间用**第一个** `:` 分隔。二段 / 三段**可直接写未转义**的 `@`、`#`,由 **`obcli` 内部编码**;触发条件:`oceanbase://` 且(含 **`#`**,或 `//` 后 **`@` 不少于 2 个**),再按「最后一个 `@主机:端口/库`」拆分。密码里若含**未编码的 `:`**,须写成 `%3A`。若整条 DSN 已用手工 **`%40` / `%23`** 写好,则走标准 URL 解析,**不会**二次编码。 + +**写入加密 DSN(推荐,避免出现在 `ps` 参数里):** + +```bash +printf '%s' 'oceanbase://user:pass@10.0.0.1:2881/mydb' | obcli config set-dsn +``` + +**注意:** DSN 里若含 **`%40`、`%23`** 等百分号编码,必须用 **`printf '%s' '整段URL'`**(先写 **`%s`**,URL 放在引号里)。不要写成 **`printf 'oceanbase://...%40...'`**,否则 shell 会把 **`%40`** 当成 `printf` 的格式指令,报错 **`invalid directive`**,且管道里可能只传了半截错误内容。 + +**本次进程临时覆盖:** + +```bash +obcli --dsn 'oceanbase://...' --format json sql "SELECT 1" +``` + +**清除:** + +```bash +obcli config clear-dsn +``` + +--- + +## 5. 全局选项 + +| 选项 | 说明 | +|------|------| +| `--format json\|table\|csv\|jsonl` | 输出格式,默认 `json` | +| `--dsn '...'` | 覆盖本次使用的 DSN | + +--- + +## 6. `obcli sql` + +1. 校验 DSN 与 SQL 非空。 +2. 若磁盘上存在且能解析为 **非空 JSON 对象**的 **`policy.json`**:先做 **`block_rules`**,命中 **`deny`** 则拒绝(**`POLICY_DENIED`**)。 +3. **「可托管写」**即上一步策略已加载:此时写类 SQL 才可能执行(仍受 **`block_rules`** 约束)。若**无文件**、**解析失败**,或文件解析结果是 **空对象 `{}`**:写类 SQL 一律 **`OBCLI-NO-WRITE`**。 +4. 执行 SQL;在已加载策略且配置了 **`limits.max_result_rows`** 时,结果行可能被截断(JSON 可出现 `truncated`、`max_result_rows`、`row_count_returned`)。 + +**写类 SQL(内置分类):** 以 `INSERT`、`UPDATE`、`DELETE`、`REPLACE`、`ALTER`、`CREATE`、`DROP`、`TRUNCATE`、`RENAME` 等开头的语句。要允许写,须部署合法 **`policy.json`**;要禁止某一类,在 **`block_rules`** 里写对应正则。 + +| 场景 | 写类 SQL | +|------|----------| +| 无文件,或 JSON 损坏无法解析 | 不允许(**`OBCLI-NO-WRITE`**) | +| 文件存在但内容为 **空对象 `{}`** | 不允许(**`OBCLI-NO-WRITE`**) | +| 有非空策略对象(例如含 **`block_rules`** / **`limits`**) | 由 **`block_rules`** 决定拦或放行 | + +--- + +## 7. `policy.json` + +路径:**`配置目录/policy.json`**。 + +**说明:** 无「跳过 `policy.json`」开关。文件存在、能解析为 **JSON 对象**(`{...}`,不能是数组等)且对象**非空**时走策略步骤;**`policy_governs_writes`** 在上述条件满足时为 `true`。 + +### 字段 + +| 字段 | 说明 | +|------|------| +| **`block_rules`** | 数组;每条含 **`id`**、**`action":"deny"`**、**`message`**、**`match.sql_pattern`**(对**整条 SQL** 做 `re.search`) | +| **`limits`**(可选) | **`max_result_rows`**:正整数时截断查询返回行数 | + +(若存在其它键名,当前版本会**忽略**;不要求也不校验 **`version`** 字段。) + +**JSON 里写正则:** 需要 `\s`、`\b` 时在字符串里写成 **`\\s`**、**`\\b`**。多写反斜杠会导致规则不命中。 + +**示例(禁止 DML、禁止 DDL):** + +```json +{ + "block_rules": [ + { + "id": "DENY-DML", + "match": { "sql_pattern": "(?i)^\\s*(INSERT|UPDATE|DELETE|REPLACE)\\b" }, + "action": "deny", + "message": "DML not allowed" + }, + { + "id": "DENY-DDL", + "match": { "sql_pattern": "(?i)^\\s*(ALTER|CREATE|DROP|TRUNCATE|RENAME)\\b" }, + "action": "deny", + "message": "DDL not allowed" + } + ], + "limits": { "max_result_rows": 5000 } +} +``` + +完整可复制示例:[policy.example.json](../examples/policy.example.json)。 + +--- + +## 8. 其他命令 + +| 命令 | 作用 | +|------|------| +| `obcli config set-dsn` | 从 stdin 读 DSN → 加密保存 | +| `obcli config clear-dsn` | 删除加密 DSN | +| `obcli config status` | 配置目录、密钥、DSN、策略文件是否存在 | +| `obcli rules show` / `metadata` / `explain` | 策略路径、`cli_version`、`policy_governs_writes` 等 | +| `obcli status` | 探测连接 | +| `obcli schema tables` | 列出当前库表名 | + +**`policy_governs_writes`:** `policy.json` 存在、能解析且根对象非空时为 `true`。 + +--- + +## 9. 环境变量 + +| 变量 | 作用 | +|------|------| +| `XDG_CONFIG_HOME` | 配置根目录(见第 3 节) | +| `OBCLI_AUDIT_LOG` | 审计 JSONL 路径;不设则默认 `配置目录/audit.jsonl` | +| `OBCLI_AUDIT_DISABLED` | 为 `1` 时不写审计 | + +--- + +## 10. 排错 + +| 现象 | 处理 | +|------|------| +| `NO_DSN` | `set-dsn` 或传 `--dsn` | +| `OBCLI-NO-WRITE` | 需要写类 SQL 时:在配置目录放置合法 **`policy.json`**(并由 **`block_rules`** 放行) | +| `POLICY_DENIED` | 命中 **`block_rules`** 的 **`deny`**;若以为该命中却未命中:检查 JSON 中正则 **反斜杠是否多写** | +| 以为 `status`/`schema` 也走策略 | 仅 **`sql`** 走 `policy.json` | + +退出码:成功 `0`;用法错误常见 `2`;其它错误见 CLI 输出中的 `exit_code`。 + +--- + +## 11. 推荐阅读 + +- [README.md](../README.md) +- [skills/oceanbase-cli/SKILL.md](../../skills/oceanbase-cli/SKILL.md)(Agent 约束) diff --git a/oceanbase-cli/pyproject.toml b/oceanbase-cli/pyproject.toml new file mode 100644 index 00000000..563e06af --- /dev/null +++ b/oceanbase-cli/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "oceanbase-cli" +version = "0.1.0" +description = "OceanBase CLI (obcli) — encrypted local DSN, read-only sql without policy.json; DML/DDL when policy.json is present (block_rules); MySQL protocol via pymysql" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +dependencies = [ + "click>=8.0", + "cryptography>=42.0.0", + "pymysql>=1.1.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[project.scripts] +obcli = "oceanbase_cli.main:cli" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +markers = [ + "integration: hits real network or local paths (skipped in default CI)", +] diff --git a/oceanbase-cli/src/oceanbase_cli/__init__.py b/oceanbase-cli/src/oceanbase_cli/__init__.py new file mode 100644 index 00000000..566e20e0 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/__init__.py @@ -0,0 +1,3 @@ +"""OceanBase CLI (obcli) — encrypted local DSN, default read-only SQL, MySQL protocol via pymysql.""" + +__version__ = "0.1.0" diff --git a/oceanbase-cli/src/oceanbase_cli/audit.py b/oceanbase-cli/src/oceanbase_cli/audit.py new file mode 100644 index 00000000..b235848d --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/audit.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import json +import os +from datetime import UTC, datetime +from typing import Any + +from oceanbase_cli.paths import default_audit_log_path + + +def emit(event: dict[str, Any]) -> None: + if os.environ.get("OBCLI_AUDIT_DISABLED") == "1": + return + line = dict(event) + line.setdefault("ts", datetime.now(UTC).isoformat().replace("+00:00", "Z")) + path = default_audit_log_path() + path.parent.mkdir(parents=True, mode=0o700, exist_ok=True) + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(line, ensure_ascii=False, default=str) + "\n") diff --git a/oceanbase-cli/src/oceanbase_cli/clickutil.py b/oceanbase-cli/src/oceanbase_cli/clickutil.py new file mode 100644 index 00000000..6a8262ce --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/clickutil.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import click + + +def root_context(ctx: click.Context) -> click.Context: + while ctx.parent is not None: + ctx = ctx.parent + return ctx + + +def output_fmt(ctx: click.Context) -> str: + r = root_context(ctx) + r.ensure_object(dict) + return str(r.obj.get("format") or "json") diff --git a/oceanbase-cli/src/oceanbase_cli/config.py b/oceanbase-cli/src/oceanbase_cli/config.py new file mode 100644 index 00000000..36069b23 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/config.py @@ -0,0 +1,68 @@ +"""Optional ``pool.json`` dataclass for connection-pool defaults. + +Not used by core ``obcli`` commands (``sql`` / ``status`` use ``executor`` per +invoke). Intended for experiments or tools that call ``connection_pool`` directly. +""" + +import os +import json +from pathlib import Path +from typing import Optional +from dataclasses import dataclass, asdict + + +@dataclass +class PoolConfig: + """Serializable pool-related defaults (separate from ``PoolConfig`` in ``connection_pool``).""" + + # Target database + host: str = "127.0.0.1" + port: int = 3306 + user: str = "root" + database: str = "oceanbase" + + # Pool sizing / timeouts (mirrors high-level knobs only) + max_connections: int = 20 + min_connections: int = 5 + max_lifetime: int = 3600 # seconds (informational; pool may not enforce yet) + idle_timeout: int = 300 # seconds + acquire_timeout: int = 30 # seconds + + # Health / cache toggles (informational for downstream tools) + enable_health_check: bool = True + health_check_interval: int = 300 # seconds + enable_query_cache: bool = False + + @classmethod + def load(cls, config_path: Optional[str] = None) -> "PoolConfig": + """Load from ``pool.json`` or return defaults.""" + if config_path: + path = Path(config_path) + else: + home_dir = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") + path = Path(home_dir) / "obcli" / "pool.json" + + if path.exists(): + try: + with open(path, "r") as f: + data = json.load(f) + return cls(**data) + except Exception as e: + print(f"Warning: failed to load pool config, using defaults: {e}") + + return cls() + + def save(self, config_path: Optional[str] = None): + """Write dataclass fields as JSON to ``pool.json``.""" + if config_path: + path = Path(config_path) + else: + home_dir = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") + path = Path(home_dir) / "obcli" / "pool.json" + + path.parent.mkdir(parents=True, exist_ok=True) + + with open(path, "w") as f: + json.dump(asdict(self), f, indent=2) + + print(f"Pool config saved to {path}") diff --git a/oceanbase-cli/src/oceanbase_cli/config_commands.py b/oceanbase-cli/src/oceanbase_cli/config_commands.py new file mode 100644 index 00000000..4373fd26 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/config_commands.py @@ -0,0 +1,66 @@ +"""`obcli config` — encrypted DSN on disk (no env vars for connection strings).""" + +from __future__ import annotations + +import sys + +import click + +from oceanbase_cli import output +from oceanbase_cli.clickutil import output_fmt +from oceanbase_cli.credentials_store import clear_encrypted_dsn, store_encrypted_dsn +from oceanbase_cli.paths import config_dir, encrypted_dsn_path, local_policy_path + + +@click.group("config") +def config_group() -> None: + """Local encrypted DSN and paths (no cloud platform).""" + + +@config_group.command("set-dsn") +@click.pass_context +def config_set_dsn(ctx: click.Context) -> None: + """Read DSN from stdin (one connection string). Do not pass the DSN on the command line (visible in ps).""" + fmt = output_fmt(ctx) + raw = sys.stdin.read() + try: + store_encrypted_dsn(raw) + except ValueError as e: + output.error("INVALID_DSN", str(e), fmt=fmt, exit_code=output.EXIT_USAGE_ERROR) + p = encrypted_dsn_path() + output.success( + { + "stored": True, + "path": str(p), + "hint": "Run obcli without --dsn to use this encrypted DSN.", + }, + fmt=fmt, + ) + + +@config_group.command("clear-dsn") +@click.pass_context +def config_clear_dsn(ctx: click.Context) -> None: + fmt = output_fmt(ctx) + clear_encrypted_dsn() + output.success({"cleared": True}, fmt=fmt) + + +@config_group.command("status") +@click.pass_context +def config_status(ctx: click.Context) -> None: + fmt = output_fmt(ctx) + dsn_file = encrypted_dsn_path() + key_file = config_dir() / ".obcli-key" + output.success( + { + "config_dir": str(config_dir()), + "sql_writes_supported": False, + "encrypted_dsn_present": dsn_file.is_file(), + "encrypted_dsn_path": str(dsn_file), + "key_present": key_file.is_file(), + "policy_file": str(local_policy_path()), + "policy_present": local_policy_path().is_file(), + }, + fmt=fmt, + ) diff --git a/oceanbase-cli/src/oceanbase_cli/connection_pool.py b/oceanbase-cli/src/oceanbase_cli/connection_pool.py new file mode 100644 index 00000000..c2ff08c2 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/connection_pool.py @@ -0,0 +1,248 @@ +"""Optional pymysql connection pool (thread-safe). + +The main ``obcli`` commands (``sql``, ``status``, …) open one connection per +invocation via ``executor``; they do **not** use this module. Kept for reuse in +long-running tools or tests. +""" + +import threading +import logging +from typing import Any, Optional +from dataclasses import dataclass +import time + +import pymysql + + +logger = logging.getLogger(__name__) + + +@dataclass +class PoolConfig: + """Connection pool settings (host, credentials, sizing, timeouts).""" + host: str + port: int + user: str + password: str + database: str + + # Pool sizing and timeouts + max_connections: int = 20 # Upper bound on pooled connections + min_connections: int = 5 # Pre-created connections at init + max_lifetime: int = 3600 # Max connection age in seconds (reserved for future use) + idle_timeout: int = 300 # Idle timeout in seconds (reserved for future use) + acquire_timeout: int = 30 # Max wait when acquiring a connection + + # Client session + charset: str = "utf8mb4" + autocommit: bool = True + + # pymysql connect_timeout + connect_timeout: int = 10 + + +class Connection: + """Wrapper that returns the underlying connection to the pool on ``close()``.""" + + def __init__(self, real_conn, pool): + self._conn = real_conn + self._pool = pool + self._closed = False + + def cursor(self, cursorclass=None): + return self._conn.cursor(cursorclass) + + def commit(self): + self._conn.commit() + + def rollback(self): + self._conn.rollback() + + def close(self): + if not self._closed: + self._closed = True + # Return to pool instead of closing the real connection + self._pool._return_connection(self._conn) + + def ping(self, reconnect=False): + return self._conn.ping(reconnect) + + @property + def open(self): + return not self._closed + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + +class SimpleConnectionPool: + """Minimal thread-safe pool over pymysql connections.""" + + def __init__(self, config: PoolConfig): + self.config = config + self._connections = [] + self._active_connections = set() + self._lock = threading.Lock() + self._stats = { + "created": 0, + "acquired": 0, + "released": 0, + "failed": 0, + } + self._initialized = False + self._stop = threading.Event() + + def initialize(self): + """Pre-create ``min_connections`` and mark the pool ready.""" + if self._initialized: + return True + + try: + for i in range(self.config.min_connections): + conn = self._create_connection() + if conn: + self._connections.append(conn) + + self._initialized = True + logger.info("Connection pool initialized: %s connections", len(self._connections)) + logger.info("Pool size range: %s .. %s", self.config.min_connections, self.config.max_connections) + return True + + except Exception as e: + logger.error("Connection pool init failed: %s", e) + return False + + def _create_connection(self) -> Optional[Any]: + """Open one pymysql connection and track stats.""" + try: + conn = pymysql.connect( + host=self.config.host, + port=self.config.port, + user=self.config.user, + password=self.config.password, + database=self.config.database, + charset=self.config.charset, + autocommit=self.config.autocommit, + connect_timeout=self.config.connect_timeout, + cursorclass=pymysql.cursors.DictCursor, + ) + self._stats["created"] += 1 + logger.debug("Created connection id=%s", id(conn)) + return conn + except Exception as e: + logger.error("Create connection failed: %s", e) + self._stats["failed"] += 1 + return None + + def get_connection(self, timeout: int = None) -> Optional[Connection]: + """Borrow a connection, wrapped for pool-safe ``close()``.""" + timeout = timeout or self.config.acquire_timeout + start_time = time.time() + + while time.time() - start_time < timeout: + with self._lock: + # Prefer an idle connection from the pool + for i, conn in enumerate(self._connections): + if conn not in self._active_connections: + try: + conn.ping(reconnect=False) + self._active_connections.add(conn) + self._stats["acquired"] += 1 + return Connection(conn, self) + except Exception: + # Stale connection: drop and retry outer loop + logger.debug("Removing dead connection id=%s", id(conn)) + try: + conn.close() + except Exception: + pass + self._connections.pop(i) + break + + if len(self._connections) < self.config.max_connections: + conn = self._create_connection() + if conn: + self._active_connections.add(conn) + self._stats["acquired"] += 1 + return Connection(conn, self) + + time.sleep(0.1) + + logger.error("Acquire connection timed out") + return None + + def _return_connection(self, conn: Any): + """Mark a connection idle again (internal).""" + with self._lock: + self._active_connections.discard(conn) + self._stats["released"] += 1 + logger.debug("Returned connection id=%s", id(conn)) + + def health_check(self) -> dict: + """Return pool size and counters for observability.""" + with self._lock: + return { + "status": "healthy" if self._initialized else "not_initialized", + "total": len(self._connections), + "active": len(self._active_connections), + "idle": len(self._connections) - len(self._active_connections), + "max_connections": self.config.max_connections, + "min_connections": self.config.min_connections, + "stats": self._stats.copy(), + } + + def close_all(self): + """Close every real connection and reset state.""" + with self._lock: + for conn in self._connections: + try: + conn.close() + except Exception: + pass + self._connections.clear() + self._active_connections.clear() + self._initialized = False + logger.info("Connection pool closed") + + +_pool_instance: Optional[SimpleConnectionPool] = None + + +def get_pool() -> Optional[SimpleConnectionPool]: + """Return the process-wide pool instance, if any.""" + return _pool_instance + + +def init_pool(config: PoolConfig) -> bool: + """Create and initialize the global pool.""" + global _pool_instance + _pool_instance = SimpleConnectionPool(config) + return _pool_instance.initialize() + + +def get_connection() -> Optional[Connection]: + """Convenience: borrow from the global pool.""" + global _pool_instance + if _pool_instance is None: + logger.warning("Pool not initialized; cannot get connection") + return None + return _pool_instance.get_connection() + + +def get_pool_stats() -> dict: + """Convenience: ``health_check()`` on the global pool.""" + global _pool_instance + if _pool_instance is None: + return {"status": "not_initialized"} + return _pool_instance.health_check() + + +def shutdown_pool() -> None: + """Close all connections and drop the global pool (for tests or clean exit).""" + global _pool_instance + if _pool_instance is not None: + _pool_instance.close_all() + _pool_instance = None diff --git a/oceanbase-cli/src/oceanbase_cli/constants.py b/oceanbase-cli/src/oceanbase_cli/constants.py new file mode 100644 index 00000000..285fe153 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/constants.py @@ -0,0 +1,3 @@ +"""Shared literals for CLI metadata (rules show / metadata).""" + +BUILTIN_RULE_SET_VERSION = "2026-03-30" diff --git a/oceanbase-cli/src/oceanbase_cli/credentials_store.py b/oceanbase-cli/src/oceanbase_cli/credentials_store.py new file mode 100644 index 00000000..bf72c44c --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/credentials_store.py @@ -0,0 +1,75 @@ +"""Encrypted DSN storage — plaintext never read from environment variables. + +The Fernet key lives alongside the config dir (file mode 0600). The encrypted +payload is written to ``dsn.enc``. Agents/models should not be given the config +directory contents or CLI flags containing raw DSNs in production workflows. +""" + +from __future__ import annotations + +from pathlib import Path + +from cryptography.fernet import Fernet, InvalidToken + +from oceanbase_cli.paths import config_dir, encrypted_dsn_path + +# Pytest: set via set_test_encryption_key() so tests do not touch real ~/.config. +_test_fernet: Fernet | None = None + + +def set_test_encryption_key(key: bytes | None) -> None: + """Use a fixed Fernet key for tests; pass None to use production key resolution.""" + global _test_fernet + if key is None: + _test_fernet = None + else: + _test_fernet = Fernet(key) + + +def _fernet() -> Fernet: + if _test_fernet is not None: + return _test_fernet + d = config_dir() + d.mkdir(parents=True, exist_ok=True) + key_path = d / ".obcli-key" + if not key_path.is_file(): + raw = Fernet.generate_key() + key_path.write_bytes(raw) + try: + key_path.chmod(0o600) + except OSError: + pass + else: + raw = key_path.read_bytes() + return Fernet(raw) + + +def store_encrypted_dsn(plaintext: str) -> None: + """Encrypt and write DSN (e.g. oceanbase://... or embedded:...).""" + text = plaintext.strip() + if not text: + raise ValueError("DSN must be non-empty") + token = _fernet().encrypt(text.encode("utf-8")) + p = encrypted_dsn_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(token) + try: + p.chmod(0o600) + except OSError: + pass + + +def load_encrypted_dsn() -> str | None: + p = encrypted_dsn_path() + if not p.is_file(): + return None + try: + return _fernet().decrypt(p.read_bytes()).decode("utf-8") + except InvalidToken: + return None + + +def clear_encrypted_dsn() -> None: + p = encrypted_dsn_path() + if p.is_file(): + p.unlink() diff --git a/oceanbase-cli/src/oceanbase_cli/dsn.py b/oceanbase-cli/src/oceanbase_cli/dsn.py new file mode 100644 index 00000000..9c07025e --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/dsn.py @@ -0,0 +1,131 @@ +"""Parse DSN strings: oceanbase:// / mysql:// (MySQL protocol) and embedded: (local SQLite for tests/demos).""" + +from __future__ import annotations + +import re +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import quote, unquote, urlparse + + +_EMBEDDED_RE = re.compile( + r"^embedded:(?P[^?]+?)(?:\?database=(?P[^&]+))?$", + re.I, +) + + +@dataclass(frozen=True) +class EmbeddedConfig: + """Local SQLite file URI (CI / demos only; not OceanBase).""" + + path: str + database: str | None # logical name for policy checks only + + +@dataclass(frozen=True) +class MySQLUrlConfig: + host: str + port: int + user: str + password: str + database: str | None + + +def is_embedded_dsn(dsn: str) -> bool: + return bool(dsn and dsn.lower().startswith("embedded:")) + + +def parse_embedded_dsn(dsn: str) -> EmbeddedConfig: + m = _EMBEDDED_RE.match(dsn.strip()) + if not m: + raise ValueError( + "Invalid embedded DSN. Expected: embedded:[?database=]", + ) + path = m.group("path").strip() + db = m.group("database") + return EmbeddedConfig(path=path, database=unquote(db) if db else None) + + +def is_mysql_protocol_dsn(dsn: str) -> bool: + if not dsn: + return False + p = dsn.split("://", 1)[0].lower() + return p in ("oceanbase", "mysql", "mariadb") + + +# Match trailing @host:port or @host:port/db (IPv4, hostname, localhost). +_OB_HOST_TAIL = re.compile( + r"@((?:\d{1,3}\.){3}\d{1,3}|localhost|[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?):(\d+)(/[^?#]*)?$" +) + + +def _should_try_oceanbase_loose(dsn: str) -> bool: + """Unencoded two-part (user@tenant:pass@host) or three-part (user@tenant#cluster:...) breaks urlparse.""" + if not dsn.lower().startswith("oceanbase://"): + return False + rest = dsn[len("oceanbase://") :] + if "#" in rest: + return True + return rest.count("@") >= 2 + + +def _oceanbase_loose_credentials_url(dsn: str) -> str | None: + """Split on last @host:port[/db]; credentials use first : for user / password; URL-encode both.""" + s = dsn.strip() + if not s.lower().startswith("oceanbase://"): + return None + rest = s[len("oceanbase://") :] + m = _OB_HOST_TAIL.search(rest) + if not m: + return None + cred = rest[: m.start()] + if not cred or ":" not in cred: + return None + user, password = cred.split(":", 1) + if not user: + return None + host, port, path = m.group(1), m.group(2), m.group(3) or "" + return ( + f"oceanbase://{quote(user, safe='')}:{quote(password, safe='')}" + f"@{host}:{port}{path}" + ) + + +def parse_mysql_url(dsn: str) -> MySQLUrlConfig: + if not is_mysql_protocol_dsn(dsn): + raise ValueError("DSN must use oceanbase://, mysql://, or mariadb://") + dsn = dsn.strip() + if _should_try_oceanbase_loose(dsn): + rebuilt = _oceanbase_loose_credentials_url(dsn) + if rebuilt: + dsn = rebuilt + u = urlparse(dsn) + if u.scheme.lower() not in ("oceanbase", "mysql", "mariadb"): + raise ValueError("Unsupported scheme") + host = u.hostname or "127.0.0.1" + port = u.port or 3306 + user = unquote(u.username or "") + password = unquote(u.password or "") if u.password else "" + db = (u.path or "").lstrip("/") or None + if db: + db = unquote(db.split("?")[0]) + return MySQLUrlConfig( + host=host, + port=int(port), + user=user, + password=password, + database=db, + ) + + +def database_from_dsn(dsn: str | None) -> str | None: + if not dsn: + return None + if is_embedded_dsn(dsn): + cfg = parse_embedded_dsn(dsn) + return cfg.database + try: + return parse_mysql_url(dsn).database + except ValueError: + return None diff --git a/oceanbase-cli/src/oceanbase_cli/executor.py b/oceanbase-cli/src/oceanbase_cli/executor.py new file mode 100644 index 00000000..1d0795db --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/executor.py @@ -0,0 +1,91 @@ +"""Execute SQL via pymysql (MySQL / OceanBase protocol) or sqlite3 (embedded: local file).""" + +from __future__ import annotations + +import sqlite3 +from datetime import date, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pymysql +from pymysql.cursors import DictCursor + +from oceanbase_cli.dsn import ( + is_embedded_dsn, + is_mysql_protocol_dsn, + parse_embedded_dsn, + parse_mysql_url, +) + + +def _jsonable_row(row: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for k, v in row.items(): + if isinstance(v, Decimal): + out[k] = str(v) + elif isinstance(v, (datetime, date)): + out[k] = v.isoformat() + elif isinstance(v, (bytes, bytearray)): + out[k] = v.decode("utf-8", errors="replace") + else: + out[k] = v + return out + + +def execute_sql(dsn: str, sql: str) -> tuple[list[str], list[dict[str, Any]], int]: + """Run a single statement; return (columns, rows, rowcount).""" + sql = sql.strip() + if not sql: + return [], [], 0 + if is_embedded_dsn(dsn): + return _execute_sqlite(dsn, sql) + if is_mysql_protocol_dsn(dsn): + return _execute_pymysql(dsn, sql) + raise ValueError( + "Unsupported DSN. Use oceanbase:// or mysql:// for remote, or embedded: for local SQLite.", + ) + + +def _execute_sqlite(dsn: str, sql: str) -> tuple[list[str], list[dict[str, Any]], int]: + cfg = parse_embedded_dsn(dsn) + path = Path(cfg.path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path)) + try: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(sql) + if cur.description: + columns = [d[0] for d in cur.description] + rows = [_jsonable_row(dict(r)) for r in cur.fetchall()] + return columns, rows, cur.rowcount + conn.commit() + return [], [], cur.rowcount + finally: + conn.close() + + +def _execute_pymysql(dsn: str, sql: str) -> tuple[list[str], list[dict[str, Any]], int]: + cfg = parse_mysql_url(dsn) + conn = pymysql.connect( + host=cfg.host, + port=cfg.port, + user=cfg.user, + password=cfg.password, + database=cfg.database or None, + charset="utf8mb4", + cursorclass=DictCursor, + ) + try: + with conn.cursor() as cur: + cur.execute(sql) + if cur.description: + columns = [d[0] for d in cur.description] + raw = cur.fetchall() + rows = [_jsonable_row(dict(r)) for r in raw] + return columns, rows, cur.rowcount + conn.commit() + return [], [], cur.rowcount + finally: + conn.close() diff --git a/oceanbase-cli/src/oceanbase_cli/main.py b/oceanbase-cli/src/oceanbase_cli/main.py new file mode 100644 index 00000000..daa11cd6 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/main.py @@ -0,0 +1,46 @@ +"""obcli — default read-only SQL, encrypted local DSN, MySQL protocol via pymysql.""" + +from __future__ import annotations + +import click + +from oceanbase_cli import __version__ +from oceanbase_cli.config_commands import config_group +from oceanbase_cli.credentials_store import load_encrypted_dsn +from oceanbase_cli.rules_commands import rules_group +from oceanbase_cli.schema_cmd import schema_group +from oceanbase_cli.sql_cmd import sql_cmd +from oceanbase_cli.status_cmd import status_cmd + + +@click.group(invoke_without_command=True) +@click.option( + "--dsn", + default=None, + help="Override DSN for this process only (visible in ps — avoid in agent workflows). " + "Default: decrypt ~/.config/obcli/dsn.enc (use `obcli config set-dsn` from stdin).", +) +@click.option( + "--format", + "fmt", + type=click.Choice(["json", "table", "csv", "jsonl"]), + default="json", + help="Output format.", +) +@click.version_option(__version__, prog_name="obcli") +@click.pass_context +def cli(ctx: click.Context, dsn: str | None, fmt: str) -> None: + """OceanBase CLI — encrypted local DSN, default read-only; data plane uses pymysql.""" + ctx.ensure_object(dict) + ctx.obj["dsn"] = (dsn.strip() if dsn else None) or load_encrypted_dsn() + ctx.obj["format"] = fmt + + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +cli.add_command(config_group, name="config") +cli.add_command(rules_group, name="rules") +cli.add_command(sql_cmd, name="sql") +cli.add_command(status_cmd, name="status") +cli.add_command(schema_group, name="schema") diff --git a/oceanbase-cli/src/oceanbase_cli/output.py b/oceanbase-cli/src/oceanbase_cli/output.py new file mode 100644 index 00000000..2a09ab94 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/output.py @@ -0,0 +1,180 @@ +"""Unified JSON / table / CSV / JSONL output for obcli.""" + +from __future__ import annotations + +import csv +import io +import json +import sys +import time +from typing import Any + +EXIT_OK = 0 +EXIT_BIZ_ERROR = 1 +EXIT_USAGE_ERROR = 2 + + +class Timer: + def __init__(self) -> None: + self.start: float = 0 + self.elapsed_ms: int = 0 + + def __enter__(self) -> Timer: + self.start = time.monotonic() + return self + + def __exit__(self, *_: Any) -> None: + self.elapsed_ms = int((time.monotonic() - self.start) * 1000) + + +def _json_dumps(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, default=str) + + +def success(data: Any, *, time_ms: int = 0, fmt: str = "json") -> None: + payload: dict[str, Any] = {"ok": True, "data": data} + if time_ms: + payload["time_ms"] = time_ms + _emit(payload, fmt=fmt) + sys.exit(EXIT_OK) + + +def success_rows( + columns: list[str], + rows: list[dict[str, Any]], + *, + affected: int = 0, + time_ms: int = 0, + fmt: str = "json", + extra: dict[str, Any] | None = None, +) -> None: + payload: dict[str, Any] = { + "ok": True, + "columns": columns, + "rows": rows, + "affected": affected, + "time_ms": time_ms, + } + if extra: + payload.update(extra) + _emit(payload, fmt=fmt, columns=columns, rows=rows) + sys.exit(EXIT_OK) + + +def error( + code: str, + message: str, + *, + fmt: str = "json", + exit_code: int = EXIT_BIZ_ERROR, + extra: dict[str, Any] | None = None, +) -> None: + payload: dict[str, Any] = { + "ok": False, + "error": {"code": code, "message": message}, + } + if extra: + payload.update(extra) + _emit(payload, fmt=fmt) + sys.exit(exit_code) + + +def _emit( + payload: dict[str, Any], + *, + fmt: str = "json", + columns: list[str] | None = None, + rows: list[dict[str, Any]] | None = None, +) -> None: + if fmt == "json": + print(_json_dumps(payload)) + elif fmt == "table": + _print_table(payload, columns, rows) + elif fmt == "csv": + _print_csv(payload, columns, rows) + elif fmt == "jsonl": + _print_jsonl(payload, rows) + else: + print(_json_dumps(payload)) + + +def _print_table( + payload: dict[str, Any], + columns: list[str] | None, + rows: list[dict[str, Any]] | None, +) -> None: + if not columns or not rows: + _print_table_from_data(payload) + return + col_widths = {c: len(c) for c in columns} + str_rows: list[dict[str, str]] = [] + for row in rows: + sr: dict[str, str] = {} + for c in columns: + val = str(row.get(c, "")) + sr[c] = val + col_widths[c] = max(col_widths[c], len(val)) + str_rows.append(sr) + header = " | ".join(c.ljust(col_widths[c]) for c in columns) + sep = "-+-".join("-" * col_widths[c] for c in columns) + print(header) + print(sep) + for sr in str_rows: + print(" | ".join(sr[c].ljust(col_widths[c]) for c in columns)) + + +def _print_table_from_data(payload: dict[str, Any]) -> None: + data = payload.get("data") + if isinstance(data, list) and data and isinstance(data[0], dict): + cols = list(data[0].keys()) + _print_table(payload, cols, data) + return + print(_json_dumps(payload)) + + +def _print_csv( + payload: dict[str, Any], + columns: list[str] | None, + rows: list[dict[str, Any]] | None, +) -> None: + if columns and rows: + _write_csv(columns, rows) + return + data = payload.get("data") + if isinstance(data, list) and data and isinstance(data[0], dict): + cols = list(data[0].keys()) + _write_csv(cols, data) + return + print(_json_dumps(payload)) + + +def _write_csv(columns: list[str], rows: list[dict[str, Any]]) -> None: + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=columns, extrasaction="ignore") + writer.writeheader() + for row in rows: + flat: dict[str, str] = {} + for c in columns: + val = row.get(c) + flat[c] = ( + json.dumps(val, ensure_ascii=False, default=str) + if isinstance(val, (dict, list)) + else str(val) + if val is not None + else "" + ) + writer.writerow(flat) + sys.stdout.write(buf.getvalue()) + + +def _print_jsonl(payload: dict[str, Any], rows: list[dict[str, Any]] | None) -> None: + if rows: + for row in rows: + print(_json_dumps(row)) + return + data = payload.get("data") + if isinstance(data, list): + for item in data: + print(_json_dumps(item)) + return + print(_json_dumps(payload)) diff --git a/oceanbase-cli/src/oceanbase_cli/paths.py b/oceanbase-cli/src/oceanbase_cli/paths.py new file mode 100644 index 00000000..9988fda1 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/paths.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def config_dir() -> Path: + base = os.environ.get("XDG_CONFIG_HOME") + if base: + return Path(base) / "obcli" + return Path.home() / ".config" / "obcli" + + +def encrypted_dsn_path() -> Path: + return config_dir() / "dsn.enc" + + +def local_policy_path() -> Path: + """Optional JSON: block_rules + optional limits.""" + return config_dir() / "policy.json" + + +def user_config_json_path() -> Path: + """Legacy path; obcli ignores allow_write_sql — kept for human reference only.""" + return config_dir() / "config.json" + + +def default_audit_log_path() -> Path: + p = os.environ.get("OBCLI_AUDIT_LOG") + if p: + return Path(p) + return config_dir() / "audit.jsonl" diff --git a/oceanbase-cli/src/oceanbase_cli/policy.py b/oceanbase-cli/src/oceanbase_cli/policy.py new file mode 100644 index 00000000..304e04ab --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/policy.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import click + +from oceanbase_cli import audit, output +from oceanbase_cli.clickutil import root_context +from oceanbase_cli.dsn import database_from_dsn +from oceanbase_cli.paths import local_policy_path + + +def _load_json_file(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def load_local_policy() -> dict[str, Any] | None: + """Optional ~/.config/obcli/policy.json: root must be a JSON object (dict).""" + data = _load_json_file(local_policy_path()) + return data if isinstance(data, dict) else None + + +def local_policy_governs_writes() -> bool: + """True when policy.json exists, parses, and is non-empty; writes allowed unless a block_rule matches.""" + pol = load_local_policy() + return bool(pol) + + +def get_policy_row_limit() -> int | None: + """Top-level limits.max_result_rows in policy.json, or None.""" + pol = load_local_policy() + if not pol: + return None + limits = pol.get("limits") + if not isinstance(limits, dict): + return None + raw = limits.get("max_result_rows") + if raw is None: + return None + try: + n = int(raw) + return n if n > 0 else None + except (TypeError, ValueError): + return None + + +def evaluate_local_block_rules(sql_text: str, policy: dict[str, Any]) -> tuple[str, str] | None: + for rule in policy.get("block_rules") or []: + if not isinstance(rule, dict): + continue + if rule.get("action") != "deny": + continue + pat = (rule.get("match") or {}).get("sql_pattern") + if not pat: + continue + try: + if re.search(pat, sql_text): + rid = rule.get("id") or "PLAT-RULE" + msg = rule.get("message") or "blocked by local policy" + return (str(rid), str(msg)) + except re.error: + continue + return None + + +def apply_pre_sql_policy(sql_text: str, ctx: click.Context) -> None: + """Evaluate policy.json block_rules when the file exists and parses.""" + root = root_context(ctx) + fmt = str(root.obj.get("format") or "json") + dsn = root.obj.get("dsn") + + pol = load_local_policy() + if not pol: + return + reason = evaluate_local_block_rules(sql_text, pol) + if not reason: + return + rid, msg = reason + audit.emit( + { + "action": "sql.execute", + "outcome": "denied", + "deny_reason": rid, + "message": msg, + "database": database_from_dsn(dsn), + } + ) + output.error( + "POLICY_DENIED", + msg, + fmt=fmt, + extra={"rule_id": rid}, + exit_code=output.EXIT_USAGE_ERROR, + ) diff --git a/oceanbase-cli/src/oceanbase_cli/rules_commands.py b/oceanbase-cli/src/oceanbase_cli/rules_commands.py new file mode 100644 index 00000000..67ff5d19 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/rules_commands.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import click + +from oceanbase_cli import __version__, output +from oceanbase_cli.clickutil import output_fmt +from oceanbase_cli.constants import BUILTIN_RULE_SET_VERSION +from oceanbase_cli.paths import ( + config_dir, + encrypted_dsn_path, + local_policy_path, + user_config_json_path, +) +from oceanbase_cli.policy import load_local_policy, local_policy_governs_writes + + +def _rules_common_fields() -> dict[str, object]: + return { + "builtin_rule_set_version": BUILTIN_RULE_SET_VERSION, + "sql_writes_supported": False, + "sql_writes_opt_in_flag": None, + "policy_governs_writes": local_policy_governs_writes(), + } + + +@click.group("rules") +def rules_group() -> None: + """Inspect optional policy.json and paths (writes require a valid policy.json).""" + + +@rules_group.command("show") +@click.pass_context +def rules_show(ctx: click.Context) -> None: + fmt = output_fmt(ctx) + pol = load_local_policy() + output.success( + { + **_rules_common_fields(), + "config_dir": str(config_dir()), + "user_config_json_path": str(user_config_json_path()), + "user_config_json_present": user_config_json_path().is_file(), + "encrypted_dsn_path": str(encrypted_dsn_path()), + "encrypted_dsn_present": encrypted_dsn_path().is_file(), + "policy_path": str(local_policy_path()), + "policy": pol, + }, + fmt=fmt, + ) + + +@rules_group.command("metadata") +@click.pass_context +def rules_metadata(ctx: click.Context) -> None: + fmt = output_fmt(ctx) + output.success( + { + "cli_version": __version__, + "entry": "obcli", + **_rules_common_fields(), + "config_dir": str(config_dir()), + "user_config_json_path": str(user_config_json_path()), + }, + fmt=fmt, + ) + + +@rules_group.command("explain") +@click.option("--action", type=click.Choice(["sql"]), default="sql", help="Execution pipeline to describe.") +@click.pass_context +def rules_explain(ctx: click.Context, action: str) -> None: + fmt = output_fmt(ctx) + if action == "sql": + checkpoints = [ + {"step": 1, "id": "DSN", "detail": "Resolve DSN from encrypted store or explicit --dsn (avoid argv for secrets)."}, + {"step": 2, "id": "POLICY.JSON", "detail": "If policy.json exists and parses: block_rules (regex deny) + optional limits.max_result_rows. No flag or env to skip."}, + {"step": 3, "id": "OBCLI-NO-WRITE", "detail": "Without policy.json (missing, empty, or invalid JSON): write-class SQL rejected. With policy: block_rules only; config.json never enables writes."}, + {"step": 4, "id": "EXECUTE", "detail": "pymysql / embedded SQLite; DML/DDL per step 3 and policy."}, + {"step": 5, "id": "AUDIT", "detail": "Denied path may emit JSONL (default: /audit.jsonl; see OBCLI_AUDIT_LOG)."}, + ] + output.success({"action": "sql", "checkpoints": checkpoints}, fmt=fmt) diff --git a/oceanbase-cli/src/oceanbase_cli/schema_cmd.py b/oceanbase-cli/src/oceanbase_cli/schema_cmd.py new file mode 100644 index 00000000..3f0af02e --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/schema_cmd.py @@ -0,0 +1,55 @@ +"""`obcli schema` — minimal table listing (MySQL / OceanBase or embedded SQLite).""" + +from __future__ import annotations + +import click + +from oceanbase_cli import output +from oceanbase_cli.clickutil import output_fmt, root_context +from oceanbase_cli.dsn import is_embedded_dsn, is_mysql_protocol_dsn +from oceanbase_cli.executor import execute_sql + + +@click.group("schema") +def schema_group() -> None: + """Schema introspection.""" + + +@schema_group.command("tables") +@click.pass_context +def schema_tables(ctx: click.Context) -> None: + root = root_context(ctx) + fmt = output_fmt(ctx) + dsn = root.obj.get("dsn") + if not dsn: + output.error( + "NO_DSN", + "No DSN: use `obcli config set-dsn` (stdin) or --dsn for local override.", + fmt=fmt, + exit_code=output.EXIT_USAGE_ERROR, + ) + + if is_embedded_dsn(dsn): + sql = ( + "SELECT name AS table_name FROM sqlite_master " + "WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + elif is_mysql_protocol_dsn(dsn): + sql = ( + "SELECT TABLE_NAME AS table_name FROM information_schema.tables " + "WHERE table_schema = DATABASE() ORDER BY TABLE_NAME" + ) + else: + output.error( + "BAD_DSN", + "Unsupported DSN for schema tables.", + fmt=fmt, + exit_code=output.EXIT_USAGE_ERROR, + ) + + try: + columns, rows, _ = execute_sql(dsn, sql) + except Exception as e: + output.error("SQL_ERROR", str(e), fmt=fmt, exit_code=output.EXIT_BIZ_ERROR) + + output.success_rows(columns, rows, fmt=fmt) diff --git a/oceanbase-cli/src/oceanbase_cli/sql_classification.py b/oceanbase-cli/src/oceanbase_cli/sql_classification.py new file mode 100644 index 00000000..5562e3b6 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/sql_classification.py @@ -0,0 +1,19 @@ +"""Classify SQL statements for policy (write vs read).""" + +from __future__ import annotations + +import re + +_WRITE_RE = re.compile( + r"^\s*(INSERT|UPDATE|DELETE|REPLACE|ALTER|CREATE|DROP|TRUNCATE|RENAME)\b", + re.I, +) +_SELECT_RE = re.compile(r"^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b", re.I) + + +def is_write_sql(sql: str) -> bool: + return bool(_WRITE_RE.match(sql)) + + +def is_select_sql(sql: str) -> bool: + return bool(_SELECT_RE.match(sql)) diff --git a/oceanbase-cli/src/oceanbase_cli/sql_cmd.py b/oceanbase-cli/src/oceanbase_cli/sql_cmd.py new file mode 100644 index 00000000..8159f5d4 --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/sql_cmd.py @@ -0,0 +1,88 @@ +"""`obcli sql` — read-only when policy.json is absent; DML/DDL only when policy.json is present and enforced.""" + +from __future__ import annotations + +import click + +from oceanbase_cli import audit, output +from oceanbase_cli.clickutil import output_fmt, root_context +from oceanbase_cli.dsn import database_from_dsn +from oceanbase_cli.executor import execute_sql +from oceanbase_cli.policy import apply_pre_sql_policy, get_policy_row_limit, local_policy_governs_writes +from oceanbase_cli.sql_classification import is_write_sql + + +@click.command("sql") +@click.argument("statement", required=False) +@click.pass_context +def sql_cmd(ctx: click.Context, statement: str | None) -> None: + root = root_context(ctx) + fmt = output_fmt(ctx) + dsn = root.obj.get("dsn") + if not dsn: + output.error( + "NO_DSN", + "No DSN: use `printf '%s' 'oceanbase://...' | obcli config set-dsn` or pass --dsn (not recommended for agents).", + fmt=fmt, + exit_code=output.EXIT_USAGE_ERROR, + ) + if not statement or not statement.strip(): + output.error("USAGE", "Missing SQL statement.", fmt=fmt, exit_code=output.EXIT_USAGE_ERROR) + + sql_text = statement.strip() + apply_pre_sql_policy(sql_text, ctx) + policy_governs_writes = local_policy_governs_writes() + + if is_write_sql(sql_text) and not policy_governs_writes: + audit.emit( + { + "action": "sql.execute", + "outcome": "denied", + "deny_reason": "OBCLI-NO-WRITE", + "message": "obcli only executes read-only SQL (SELECT/SHOW/DESC/EXPLAIN/…) without a valid policy.json.", + "database": database_from_dsn(dsn), + } + ) + output.error( + "POLICY_DENIED", + "obcli only executes read-only SQL without a valid policy.json. Add policy.json (block_rules) to allow DML/DDL; deny rules still apply.", + fmt=fmt, + extra={"rule_id": "OBCLI-NO-WRITE"}, + exit_code=output.EXIT_USAGE_ERROR, + ) + + try: + with output.Timer() as t: + columns, rows, affected = execute_sql(dsn, sql_text) + except Exception as e: + output.error("SQL_ERROR", str(e), fmt=fmt, exit_code=output.EXIT_BIZ_ERROR) + + row_limit = get_policy_row_limit() + extra_out: dict[str, object] | None = None + if row_limit is not None and len(rows) > row_limit: + rows = rows[:row_limit] + extra_out = { + "truncated": True, + "max_result_rows": row_limit, + "row_count_returned": len(rows), + } + + if is_write_sql(sql_text) and policy_governs_writes: + audit.emit( + { + "action": "sql.execute", + "outcome": "allowed", + "message": "write SQL executed (policy.json governs)", + "policy_governs_writes": True, + "database": database_from_dsn(dsn), + } + ) + + output.success_rows( + columns, + rows, + affected=max(affected, 0), + time_ms=t.elapsed_ms, + fmt=fmt, + extra=extra_out, + ) diff --git a/oceanbase-cli/src/oceanbase_cli/status_cmd.py b/oceanbase-cli/src/oceanbase_cli/status_cmd.py new file mode 100644 index 00000000..5fd8224e --- /dev/null +++ b/oceanbase-cli/src/oceanbase_cli/status_cmd.py @@ -0,0 +1,60 @@ +"""`obcli status` — connection probe (MySQL protocol or local embedded SQLite).""" + +from __future__ import annotations + +import click + +from oceanbase_cli import output +from oceanbase_cli.clickutil import output_fmt, root_context +from oceanbase_cli.dsn import is_embedded_dsn, is_mysql_protocol_dsn, parse_embedded_dsn +from oceanbase_cli.executor import execute_sql + + +@click.command("status") +@click.pass_context +def status_cmd(ctx: click.Context) -> None: + root = root_context(ctx) + fmt = output_fmt(ctx) + dsn = root.obj.get("dsn") + if not dsn: + output.error( + "NO_DSN", + "No DSN: use `obcli config set-dsn` (stdin) or pass --dsn for local override only.", + fmt=fmt, + exit_code=output.EXIT_USAGE_ERROR, + ) + + if is_embedded_dsn(dsn): + cfg = parse_embedded_dsn(dsn) + output.success( + { + "mode": "embedded_sqlite", + "path": cfg.path, + "logical_database": cfg.database, + "connected": True, + }, + fmt=fmt, + ) + + if is_mysql_protocol_dsn(dsn): + try: + cols, rows, _ = execute_sql(dsn, "SELECT VERSION() AS version, DATABASE() AS current_db") + except Exception as e: + output.error("CONNECT_ERROR", str(e), fmt=fmt, exit_code=output.EXIT_BIZ_ERROR) + row = rows[0] if rows else {} + output.success( + { + "mode": "mysql_protocol", + "connected": True, + "version": row.get("version"), + "current_db": row.get("current_db"), + }, + fmt=fmt, + ) + + output.error( + "BAD_DSN", + "Unsupported DSN for status. Use oceanbase://, mysql://, or embedded:.", + fmt=fmt, + exit_code=output.EXIT_USAGE_ERROR, + ) diff --git a/oceanbase-cli/tests/test_connection_pool.py b/oceanbase-cli/tests/test_connection_pool.py new file mode 100644 index 00000000..318cce17 --- /dev/null +++ b/oceanbase-cli/tests/test_connection_pool.py @@ -0,0 +1,99 @@ +"""Unit tests for optional connection_pool (mocked pymysql; no network).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from oceanbase_cli.connection_pool import ( + PoolConfig, + get_connection, + get_pool_stats, + init_pool, + shutdown_pool, +) + + +@pytest.fixture(autouse=True) +def _reset_pool() -> None: + yield + shutdown_pool() + + +def _mock_mysql_conn() -> MagicMock: + c = MagicMock() + c.ping = MagicMock() + c.close = MagicMock() + cur = MagicMock() + cur.__enter__ = MagicMock(return_value=cur) + cur.__exit__ = MagicMock(return_value=False) + c.cursor = MagicMock(return_value=cur) + return c + + +@patch("oceanbase_cli.connection_pool.pymysql.connect") +def test_init_pool_and_stats(mock_connect: MagicMock) -> None: + mock_connect.side_effect = lambda **kw: _mock_mysql_conn() + cfg = PoolConfig( + host="127.0.0.1", + port=3306, + user="u", + password="p", + database="d", + min_connections=2, + max_connections=3, + ) + assert init_pool(cfg) is True + stats = get_pool_stats() + assert stats["status"] == "healthy" + assert stats["min_connections"] == 2 + assert stats["max_connections"] == 3 + assert mock_connect.call_count >= 2 + + +@patch("oceanbase_cli.connection_pool.pymysql.connect") +def test_get_connection_acquire_release(mock_connect: MagicMock) -> None: + mock_connect.side_effect = lambda **kw: _mock_mysql_conn() + cfg = PoolConfig( + host="127.0.0.1", + port=3306, + user="u", + password="p", + database="d", + min_connections=1, + max_connections=2, + ) + assert init_pool(cfg) is True + conn = get_connection() + assert conn is not None + conn.close() + stats = get_pool_stats() + assert stats["stats"]["released"] >= 1 + + +def test_get_pool_stats_before_init() -> None: + shutdown_pool() + s = get_pool_stats() + assert s.get("status") == "not_initialized" + + +def test_get_connection_before_init() -> None: + shutdown_pool() + assert get_connection() is None + + +@patch("oceanbase_cli.connection_pool.pymysql.connect") +def test_pool_config_defaults(mock_connect: MagicMock) -> None: + mock_connect.side_effect = lambda **kw: _mock_mysql_conn() + cfg = PoolConfig( + host="h", + port=3306, + user="u", + password="p", + database="d", + ) + assert cfg.charset == "utf8mb4" + assert cfg.autocommit is True + assert cfg.max_connections == 20 + assert cfg.min_connections == 5 diff --git a/oceanbase-cli/tests/test_demo_obcli_and_skill.py b/oceanbase-cli/tests/test_demo_obcli_and_skill.py new file mode 100644 index 00000000..418a20b8 --- /dev/null +++ b/oceanbase-cli/tests/test_demo_obcli_and_skill.py @@ -0,0 +1,241 @@ +"""Tests: encrypted DSN, read-only sql only, optional policy.json; skill doc checklist. + +Run: cd oceanbase-cli && pip install -e ".[dev]" && pytest tests/test_demo_obcli_and_skill.py -v +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from click.testing import CliRunner +from cryptography.fernet import Fernet + +from oceanbase_cli import credentials_store +from oceanbase_cli.main import cli + +_WORKSPACE_ROOT = Path(__file__).resolve().parents[2] +_SKILL_MD = _WORKSPACE_ROOT / "skills" / "oceanbase-cli" / "SKILL.md" + + +def _json_line(s: str) -> dict: + s = s.strip() + if not s: + return {} + return json.loads(s) + + +@pytest.fixture +def isolated_obcli_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: + cfg = tmp_path / "config" + cache = tmp_path / "cache" + cfg.mkdir() + cache.mkdir() + db = cfg / "demo.db" + monkeypatch.setenv("XDG_CONFIG_HOME", str(cfg)) + monkeypatch.setenv("XDG_CACHE_HOME", str(cache)) + + key = Fernet.generate_key() + credentials_store.set_test_encryption_key(key) + try: + credentials_store.store_encrypted_dsn(f"embedded:{db}") + policy = { + "block_rules": [ + { + "id": "PLAT-MOCK-GRANT", + "match": {"sql_pattern": r"(?i)^\s*GRANT\b"}, + "action": "deny", + "message": "GRANT blocked in mock policy", + } + ], + } + (cfg / "obcli" / "policy.json").write_text(json.dumps(policy), encoding="utf-8") + yield os.environ.copy() + finally: + credentials_store.set_test_encryption_key(None) + + +def test_skill_md_exists_and_covers_agent_requirements() -> None: + assert _SKILL_MD.is_file(), f"Expected skill at {_SKILL_MD}" + text = _SKILL_MD.read_text(encoding="utf-8") + assert "name: oceanbase-cli" in text + required = [ + "obcli", + "shell", + "--format", + "config", + "set-dsn", + "rules show", + "OBCLI-NO-WRITE", + "encrypted", + "must not modify", + "Forbidden (agent)", + "sql_writes_supported", + ] + missing = [k for k in required if k not in text] + assert not missing, f"SKILL.md missing keywords: {missing}" + + +def test_rules_metadata_and_show(isolated_obcli_env: dict[str, str]) -> None: + runner = CliRunner() + r = runner.invoke(cli, ["--format", "json", "rules", "metadata"], env=isolated_obcli_env) + assert r.exit_code == 0, r.output + data = _json_line(r.output) + d0 = data.get("data") or {} + assert d0.get("entry") == "obcli" + assert d0.get("sql_writes_supported") is False + assert d0.get("sql_writes_opt_in_flag") is None + assert d0.get("policy_governs_writes") is True + + r = runner.invoke(cli, ["--format", "json", "rules", "show"], env=isolated_obcli_env) + assert r.exit_code == 0, r.output + data = _json_line(r.output) + d = data.get("data") or {} + assert d.get("encrypted_dsn_present") is True + assert d.get("sql_writes_supported") is False + assert d.get("sql_writes_opt_in_flag") is None + assert d.get("policy_governs_writes") is True + assert d.get("policy") is not None + + +def test_config_status(isolated_obcli_env: dict[str, str]) -> None: + runner = CliRunner() + r = runner.invoke(cli, ["--format", "json", "config", "status"], env=isolated_obcli_env) + assert r.exit_code == 0, r.output + data = _json_line(r.output) + assert (data.get("data") or {}).get("encrypted_dsn_present") is True + + +def test_sql_select_embedded(isolated_obcli_env: dict[str, str]) -> None: + runner = CliRunner() + r = runner.invoke(cli, ["--format", "json", "sql", "SELECT 1 AS n"], env=isolated_obcli_env) + assert r.exit_code == 0, r.output + data = _json_line(r.output) + assert data.get("ok") is True + rows = data.get("rows") or [] + assert rows and (rows[0].get("n") == 1 or rows[0].get("1") == 1) + + +def test_policy_blocks_grant(isolated_obcli_env: dict[str, str]) -> None: + runner = CliRunner() + r = runner.invoke( + cli, + ["--format", "json", "sql", "GRANT SELECT ON *.* TO x"], + env=isolated_obcli_env, + ) + assert r.exit_code == 2, r.output + assert "PLAT-MOCK-GRANT" in r.output or "POLICY_DENIED" in r.output + + +def test_write_sql_always_denied(isolated_obcli_env: dict[str, str]) -> None: + """Without policy.json, INSERT is rejected; config.json cannot enable writes.""" + cfg_home = Path(isolated_obcli_env["XDG_CONFIG_HOME"]) + (cfg_home / "obcli" / "policy.json").unlink(missing_ok=True) + (cfg_home / "obcli" / "config.json").write_text( + json.dumps({"allow_write_sql": True, "disable_write_sql": False}), + encoding="utf-8", + ) + runner = CliRunner() + r = runner.invoke( + cli, + ["--format", "json", "sql", "INSERT INTO t_obcli_demo (c) VALUES (1)"], + env=isolated_obcli_env, + ) + assert r.exit_code == 2, r.output + assert "OBCLI-NO-WRITE" in r.output + + +def test_write_sql_without_writer_when_policy_governs(isolated_obcli_env: dict[str, str]) -> None: + """With enforced policy.json, DML is allowed if block_rules do not match.""" + runner = CliRunner() + env = {**isolated_obcli_env, "OBCLI_AUDIT_DISABLED": "1"} + r = runner.invoke( + cli, + ["--format", "json", "sql", "CREATE TABLE IF NOT EXISTS t_gov (id INTEGER PRIMARY KEY)"], + env=env, + ) + assert r.exit_code == 0, r.output + r2 = runner.invoke( + cli, + ["--format", "json", "sql", "INSERT INTO t_gov (id) VALUES (42)"], + env=env, + ) + assert r2.exit_code == 0, r2.output + + +def test_write_sql_denied_when_policy_blocks_insert(isolated_obcli_env: dict[str, str]) -> None: + cfg = Path(isolated_obcli_env["XDG_CONFIG_HOME"]) / "obcli" + policy = { + "block_rules": [ + { + "id": "NO-INSERT", + "match": {"sql_pattern": r"(?i)^\s*INSERT\b"}, + "action": "deny", + "message": "INSERT blocked by policy", + } + ], + } + (cfg / "policy.json").write_text(json.dumps(policy), encoding="utf-8") + runner = CliRunner() + r = runner.invoke( + cli, + ["--format", "json", "sql", "INSERT INTO any_table (c) VALUES (1)"], + env=isolated_obcli_env, + ) + assert r.exit_code == 2, r.output + assert "NO-INSERT" in r.output or "POLICY_DENIED" in r.output + + +def test_max_result_rows_truncates(isolated_obcli_env: dict[str, str]) -> None: + cfg = Path(isolated_obcli_env["XDG_CONFIG_HOME"]) / "obcli" + pol = { + "block_rules": [], + "limits": {"max_result_rows": 2}, + } + (cfg / "policy.json").write_text(json.dumps(pol), encoding="utf-8") + runner = CliRunner() + env = {**isolated_obcli_env, "OBCLI_AUDIT_DISABLED": "1"} + assert runner.invoke( + cli, + ["--format", "json", "sql", "CREATE TABLE IF NOT EXISTS rtrunc (x INTEGER)"], + env=env, + ).exit_code == 0 + assert runner.invoke( + cli, + ["--format", "json", "sql", "INSERT INTO rtrunc (x) VALUES (1),(2),(3)"], + env=env, + ).exit_code == 0 + r = runner.invoke( + cli, + ["--format", "json", "sql", "SELECT x FROM rtrunc ORDER BY x"], + env=env, + ) + assert r.exit_code == 0, r.output + data = _json_line(r.output) + assert len(data.get("rows") or []) == 2 + assert data.get("truncated") is True + assert data.get("max_result_rows") == 2 + + +def test_set_dsn_from_stdin(isolated_obcli_env: dict[str, str], tmp_path: Path) -> None: + """Overwrite encrypted DSN via stdin; verify with a follow-up invoke (same isolated env).""" + runner = CliRunner() + new_db = tmp_path / "via_stdin.db" + dsn = f"embedded:{new_db}" + r = runner.invoke( + cli, + ["--format", "json", "config", "set-dsn"], + input=dsn, + env=isolated_obcli_env, + ) + assert r.exit_code == 0, r.output + r2 = runner.invoke( + cli, + ["--format", "json", "sql", "SELECT 2 AS n"], + env=isolated_obcli_env, + ) + assert r2.exit_code == 0, r2.output + row = (_json_line(r2.output).get("rows") or [{}])[0] + assert row.get("n") == 2 or row.get("2") == 2 diff --git a/oceanbase-cli/tests/test_dsn.py b/oceanbase-cli/tests/test_dsn.py new file mode 100644 index 00000000..0fafcf64 --- /dev/null +++ b/oceanbase-cli/tests/test_dsn.py @@ -0,0 +1,48 @@ +"""DSN parsing: OceanBase two-part / three-part without manual percent-encoding.""" + +from __future__ import annotations + +from oceanbase_cli.dsn import parse_mysql_url + + +def test_simple_oceanbase() -> None: + cfg = parse_mysql_url("oceanbase://u:p@10.0.0.1:3881/mydb") + assert cfg.user == "u" + assert cfg.password == "p" + assert cfg.host == "10.0.0.1" + assert cfg.port == 3881 + assert cfg.database == "mydb" + + +def test_two_part_unencoded() -> None: + cfg = parse_mysql_url( + "oceanbase://mysqluser@mytenant:mypass@192.168.0.5:3881/odb" + ) + assert cfg.user == "mysqluser@mytenant" + assert cfg.password == "mypass" + assert cfg.host == "192.168.0.5" + assert cfg.database == "odb" + + +def test_three_part_unencoded_hash() -> None: + cfg = parse_mysql_url( + "oceanbase://mysqluser@mytenant#mycluster:Secret@x@192.168.1.10:3881/appdb" + ) + assert cfg.user == "mysqluser@mytenant#mycluster" + assert cfg.password == "Secret@x" + assert cfg.host == "192.168.1.10" + assert cfg.database == "appdb" + + +def test_preencoded_no_double_encode() -> None: + cfg = parse_mysql_url( + "oceanbase://mysqluser%40tenant%23cluster:pw%40d@10.0.0.2:2881/db" + ) + assert cfg.user == "mysqluser@tenant#cluster" + assert cfg.password == "pw@d" + + +def test_mysql_scheme_unchanged() -> None: + cfg = parse_mysql_url("mysql://a:b@h:3306/d") + assert cfg.user == "a" + assert cfg.host == "h" diff --git a/oceanbase-cli/tests/test_policy_rules.py b/oceanbase-cli/tests/test_policy_rules.py new file mode 100644 index 00000000..0e8a6f30 --- /dev/null +++ b/oceanbase-cli/tests/test_policy_rules.py @@ -0,0 +1,57 @@ +"""Unit tests for policy.json evaluation (no real database).""" + +from __future__ import annotations + +from unittest.mock import patch + +from oceanbase_cli.policy import evaluate_local_block_rules, get_policy_row_limit + + +def test_block_rules_match_grant() -> None: + pol = { + "block_rules": [ + { + "id": "DENY-GRANT", + "match": {"sql_pattern": r"(?i)^\s*GRANT\b"}, + "action": "deny", + "message": "no grant", + } + ], + } + hit = evaluate_local_block_rules("GRANT SELECT ON t TO u", pol) + assert hit is not None + assert hit[0] == "DENY-GRANT" + + +def test_block_rules_no_match() -> None: + pol = { + "block_rules": [ + { + "id": "DENY-GRANT", + "match": {"sql_pattern": r"(?i)^\s*GRANT\b"}, + "action": "deny", + "message": "no grant", + } + ], + } + assert evaluate_local_block_rules("SELECT 1", pol) is None + + +def test_non_deny_action_skipped() -> None: + pol = { + "block_rules": [ + { + "id": "X", + "match": {"sql_pattern": ".*"}, + "action": "allow", + "message": "ignored", + } + ], + } + assert evaluate_local_block_rules("DROP TABLE x", pol) is None + + +def test_get_policy_row_limit_top_level() -> None: + pol = {"limits": {"max_result_rows": 5}} + with patch("oceanbase_cli.policy.load_local_policy", return_value=pol): + assert get_policy_row_limit() == 5 diff --git a/skills/oceanbase-cli/SKILL.md b/skills/oceanbase-cli/SKILL.md new file mode 100644 index 00000000..9590cfa8 --- /dev/null +++ b/skills/oceanbase-cli/SKILL.md @@ -0,0 +1,92 @@ +--- +name: oceanbase-cli +description: >- + Runs OceanBase (MySQL protocol) via `obcli` shell only; sql_writes_supported false for agents; without non-empty policy.json, OBCLI-NO-WRITE on DML/DDL; with policy.json, block_rules govern writes. + Agent must not modify obcli config or run config set-dsn/clear-dsn. Encrypted local DSN under ~/.config/obcli; optional policy.json; + pymysql-backed `sql`, `status`, `schema tables`. +license: MIT +--- + +# oceanbase-cli (`obcli`) — Agent invocation + +**Invoke via shell** (`obcli ...`). **`obcli sql`:** read-style statements by default; **DML/DDL** need a **present, non-empty** **`policy.json`** (parsed object), then **`block_rules`** decide allow/deny (**`policy_governs_writes`** in **`obcli rules show`** / **`metadata`**). No **`config.json`** write toggle, no opt-in flag, no env to skip policy. + +## Agent: must not modify obcli configuration + +The agent **must not modify** any obcli configuration on disk, and **must not** run commands that change it. + +**Forbidden (agent):** + +- Using **any file/read/write tool** on **`~/.config/obcli/`** or **`$XDG_CONFIG_HOME/obcli/`** (including reading `config.json` to “fix” settings). Use **`obcli config status`** / **`obcli rules show`** for safe summaries. +- Editing, creating, or deleting files there, including **`config.json`**, **`policy.json`**, **`dsn.enc`**, **`.obcli-key`**, **`audit.jsonl`**. +- Running **`obcli config set-dsn`**, **`obcli config clear-dsn`**, or shell redirection that writes to those paths. + +**Allowed (agent):** + +- **`obcli config status`**, **`obcli rules show`**, **`obcli rules metadata`**, **`obcli rules explain`** +- **`obcli --format … status`**, **`sql`** (read-only when no policy; **do not** attempt write SQL unless the user explicitly asks and policy allows — agent contract is still read-only), **`schema tables`** +- **`obcli --version`**, **`obcli --help`** + +**Human operator:** DSN via **`printf '…' | obcli config set-dsn`**, **`policy.json`** edits. Legacy **`config.json`** is ignored for write enablement. + +## Prerequisites + +```bash +obcli --version +``` + +```bash +pip install oceanbase-cli +# or: pip install -e ./oceanbase-cli +``` + +## DSN: encrypted at rest (human setup; agent does not touch) + +- **Do not** put `oceanbase://...` in environment variables for routine use. +- Human stores DSN (stdin); agent **does not** run: + + ```bash + printf '%s' 'oceanbase://user:pass@host:2881/db' | obcli config set-dsn + ``` + +- On disk the DSN is **encrypted** in **`~/.config/obcli/dsn.enc`**; key **`.obcli-key`** (0600). **Do not** paste into model context. +- Optional: **`obcli --dsn '...'`** (visible in `ps`). + +## Global options before subcommand + +```bash +obcli --format json sql "SELECT 1" +obcli --format json status +``` + +## Commands (agent-safe subset) + +| Goal | Command | Agent may run? | +|------|---------|----------------| +| Paths / flags | `obcli config status` | Yes | +| Policy | `obcli rules show`, `metadata`, `explain` | Yes | +| Read-only SQL | `obcli sql`, `status`, `schema tables` | Yes | +| Store/clear DSN | `config set-dsn`, `clear-dsn` | **No** (human) | + +## Write SQL (agent: not supported) + +- **`sql_writes_supported`:** **`false`** (agent contract); **`policy_governs_writes`** when local **`policy.json`** is enforced — then humans may run writes if **`block_rules`** allow. +- **`sql_writes_opt_in_flag`:** **`null`** — there is no CLI flag to opt into writes without **`policy.json`**. +- Without a non-empty parsed **`policy.json`**, write-class SQL → **`OBCLI-NO-WRITE`**. + +## Optional local policy (`policy.json`) + +**`~/.config/obcli/policy.json`** — human-maintained; agent **must not** edit. **`block_rules`** deny by regex; when enforced, they are the **primary** gate for write SQL (deny wins; no match → execute). + +## Environment (non-secret) + +| Variable | Role | +|----------|------| +| `XDG_CONFIG_HOME` | Config root | +| `OBCLI_AUDIT_LOG` | Audit JSONL path (human) | + +Do **not** use env for **`OCEANBASE_DSN`** / **`MYSQL_DSN`** for routine DSN. + +## Further reading + +- [oceanbase-cli/README.md](../../oceanbase-cli/README.md)