sql-harness 0.2.3__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {sql_harness-0.2.3 → sql_harness-0.3.1}/PKG-INFO +1 -1
  2. {sql_harness-0.2.3 → sql_harness-0.3.1}/SKILL.md +18 -5
  3. sql_harness-0.3.1/interaction-skills/mysql/json-columns.md +43 -0
  4. sql_harness-0.3.1/interaction-skills/postgres/json-columns.md +46 -0
  5. sql_harness-0.3.1/interaction-skills/postgres/migrations.md +105 -0
  6. sql_harness-0.3.1/interaction-skills/ssh/auth-and-tunnels.md +145 -0
  7. {sql_harness-0.2.3/interaction-skills → sql_harness-0.3.1/interaction-skills/ssh}/docker-via-ssh.md +41 -15
  8. {sql_harness-0.2.3 → sql_harness-0.3.1}/pyproject.toml +1 -5
  9. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/cli.py +15 -5
  10. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_cli.py +54 -3
  11. {sql_harness-0.2.3 → sql_harness-0.3.1}/uv.lock +1 -1
  12. sql_harness-0.2.3/docker-services/coturn/README.md +0 -69
  13. sql_harness-0.2.3/docker-services/coturn/docker-compose.yml +0 -29
  14. sql_harness-0.2.3/docker-services/coturn/turnserver.conf +0 -36
  15. sql_harness-0.2.3/docker-services/mysql/docker-compose.yml +0 -35
  16. sql_harness-0.2.3/docker-services/redis/docker-compose.yml +0 -24
  17. sql_harness-0.2.3/interaction-skills/json-columns.md +0 -30
  18. sql_harness-0.2.3/practice/pgexercises/README.md +0 -63
  19. sql_harness-0.2.3/skills/sql-harness/SKILL.md +0 -1
  20. sql_harness-0.2.3/src/sql_harness/SKILL.md +0 -1
  21. {sql_harness-0.2.3 → sql_harness-0.3.1}/.claude-plugin/marketplace.json +0 -0
  22. {sql_harness-0.2.3 → sql_harness-0.3.1}/.claude-plugin/plugin.json +0 -0
  23. {sql_harness-0.2.3 → sql_harness-0.3.1}/.env.example +0 -0
  24. {sql_harness-0.2.3 → sql_harness-0.3.1}/.github/ISSUE_TEMPLATE/bug-report.yml +0 -0
  25. {sql_harness-0.2.3 → sql_harness-0.3.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  26. {sql_harness-0.2.3 → sql_harness-0.3.1}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  27. {sql_harness-0.2.3 → sql_harness-0.3.1}/.gitignore +0 -0
  28. {sql_harness-0.2.3 → sql_harness-0.3.1}/AGENTS.md +0 -0
  29. {sql_harness-0.2.3 → sql_harness-0.3.1}/README.md +0 -0
  30. {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/README.md +0 -0
  31. {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/agent_helpers.example.py +0 -0
  32. {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/agent_helpers.py +0 -0
  33. {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/zones/example_pg/scripts/sh_demo_crud.py +0 -0
  34. {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/zones/example_pg/skills/sh_demo-schema.md +0 -0
  35. {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/zones/meta/skills/pool.md +0 -0
  36. {sql_harness-0.2.3 → sql_harness-0.3.1}/agent-workspace/zones/meta/skills/workspace.md +0 -0
  37. {sql_harness-0.2.3 → sql_harness-0.3.1}/docs/connections-toml-example.toml +0 -0
  38. {sql_harness-0.2.3 → sql_harness-0.3.1}/docs/headless-notes.md +0 -0
  39. {sql_harness-0.2.3 → sql_harness-0.3.1}/docs/maintainer.md +0 -0
  40. {sql_harness-0.2.3 → sql_harness-0.3.1}/docs/standalone-install.md +0 -0
  41. {sql_harness-0.2.3 → sql_harness-0.3.1}/install.md +0 -0
  42. {sql_harness-0.2.3/interaction-skills/references → sql_harness-0.3.1/interaction-skills}/aggregations-advanced.md +0 -0
  43. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/aggregations.md +0 -0
  44. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/encoding-and-charset.md +0 -0
  45. {sql_harness-0.2.3/interaction-skills/references → sql_harness-0.3.1/interaction-skills}/joins-advanced.md +0 -0
  46. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/joins.md +0 -0
  47. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/large-result-sets.md +0 -0
  48. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/migrations.md +0 -0
  49. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/pooling.md +0 -0
  50. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/btree-indexes.md +0 -0
  51. {sql_harness-0.2.3/interaction-skills → sql_harness-0.3.1/interaction-skills/postgres}/indexes-and-explain.md +0 -0
  52. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/plan-reading.md +0 -0
  53. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/slow-queries-joins.md +0 -0
  54. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/specialized-indexes.md +0 -0
  55. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/postgres/table-optimization.md +0 -0
  56. /sql_harness-0.2.3/interaction-skills/references/recursive-ctes-patterns.md → /sql_harness-0.3.1/interaction-skills/recursive-ctes-advanced.md +0 -0
  57. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/recursive-ctes.md +0 -0
  58. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/save-run-cycle.md +0 -0
  59. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/schema-introspection.md +0 -0
  60. {sql_harness-0.2.3/interaction-skills → sql_harness-0.3.1/interaction-skills/ssh}/ssh.md +0 -0
  61. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/timeouts-and-cancellation.md +0 -0
  62. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/transactions.md +0 -0
  63. /sql_harness-0.2.3/interaction-skills/references/window-functions-catalog.md → /sql_harness-0.3.1/interaction-skills/window-functions-advanced.md +0 -0
  64. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/window-functions.md +0 -0
  65. {sql_harness-0.2.3 → sql_harness-0.3.1}/interaction-skills/zone-skill-auto-surface.md +0 -0
  66. {sql_harness-0.2.3 → sql_harness-0.3.1}/skills/sql-harness/README.txt +0 -0
  67. {sql_harness-0.2.3 → sql_harness-0.3.1}/skills/sql-harness/references/install.md +0 -0
  68. {sql_harness-0.2.3 → sql_harness-0.3.1}/sql-harness +0 -0
  69. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/__init__.py +0 -0
  70. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/agent_loader.py +0 -0
  71. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/config.py +0 -0
  72. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/__init__.py +0 -0
  73. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/mysql.py +0 -0
  74. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/postgres.py +0 -0
  75. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/redis.py +0 -0
  76. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/sqlite.py +0 -0
  77. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/drivers/ssh.py +0 -0
  78. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/helpers.py +0 -0
  79. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/manager.py +0 -0
  80. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/paths.py +0 -0
  81. {sql_harness-0.2.3 → sql_harness-0.3.1}/src/sql_harness/run.py +0 -0
  82. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/__init__.py +0 -0
  83. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/conftest.py +0 -0
  84. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/__init__.py +0 -0
  85. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_mysql.py +0 -0
  86. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_pg_optimization.py +0 -0
  87. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_pgexercises_practice.py +0 -0
  88. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_postgres.py +0 -0
  89. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/integration/test_ssh.py +0 -0
  90. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/__init__.py +0 -0
  91. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_agent_loader.py +0 -0
  92. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_config.py +0 -0
  93. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_helpers.py +0 -0
  94. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_manager.py +0 -0
  95. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_paths.py +0 -0
  96. {sql_harness-0.2.3 → sql_harness-0.3.1}/tests/unit/test_ssh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-harness
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace.
5
5
  Project-URL: Source, https://github.com/zhaoliuxue/much_bigpy/tree/master/lab/sql_harness
6
6
  Project-URL: Issues, https://github.com/zhaoliuxue/much_bigpy/issues
@@ -33,31 +33,44 @@ PY
33
33
  Stuck-point mechanics — one file per topic, bare filenames only:
34
34
 
35
35
  - `interaction-skills/aggregations.md`
36
- - `interaction-skills/docker-via-ssh.md`
36
+ - `interaction-skills/aggregations-advanced.md`
37
37
  - `interaction-skills/encoding-and-charset.md`
38
- - `interaction-skills/indexes-and-explain.md`
39
38
  - `interaction-skills/joins.md`
40
- - `interaction-skills/json-columns.md`
39
+ - `interaction-skills/joins-advanced.md`
41
40
  - `interaction-skills/large-result-sets.md`
42
41
  - `interaction-skills/migrations.md`
43
42
  - `interaction-skills/pooling.md`
44
43
  - `interaction-skills/recursive-ctes.md`
44
+ - `interaction-skills/recursive-ctes-advanced.md`
45
45
  - `interaction-skills/save-run-cycle.md`
46
46
  - `interaction-skills/schema-introspection.md`
47
- - `interaction-skills/ssh.md`
48
47
  - `interaction-skills/timeouts-and-cancellation.md`
49
48
  - `interaction-skills/transactions.md`
50
49
  - `interaction-skills/window-functions.md`
50
+ - `interaction-skills/window-functions-advanced.md`
51
51
  - `interaction-skills/zone-skill-auto-surface.md`
52
52
 
53
- PG optimization depth — `interaction-skills/postgres/`:
53
+ PG-specific depth — `interaction-skills/postgres/`:
54
54
 
55
55
  - `interaction-skills/postgres/btree-indexes.md`
56
+ - `interaction-skills/postgres/indexes-and-explain.md`
57
+ - `interaction-skills/postgres/json-columns.md`
58
+ - `interaction-skills/postgres/migrations.md`
56
59
  - `interaction-skills/postgres/plan-reading.md`
57
60
  - `interaction-skills/postgres/slow-queries-joins.md`
58
61
  - `interaction-skills/postgres/specialized-indexes.md`
59
62
  - `interaction-skills/postgres/table-optimization.md`
60
63
 
64
+ MySQL-specific — `interaction-skills/mysql/`:
65
+
66
+ - `interaction-skills/mysql/json-columns.md`
67
+
68
+ SSH-specific — `interaction-skills/ssh/`:
69
+
70
+ - `interaction-skills/ssh/auth-and-tunnels.md`
71
+ - `interaction-skills/ssh/docker-via-ssh.md`
72
+ - `interaction-skills/ssh/ssh.md`
73
+
61
74
  ## Cross-DSN strategy skills
62
75
 
63
76
  Read these when working across multiple connections, tuning pool behavior, or operating remote hosts. Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skills/` is the passive fallback layer (the legacy global `agent-workspace/skills/` was collapsed into a regular zone so `apply_skill()` has a single shape: active zone or meta).
@@ -0,0 +1,43 @@
1
+ # JSON columns — MySQL (`JSON`)
2
+
3
+ MySQL 5.7+ has a native `JSON` type. PyMySQL serializes Python `dict`/`list` to `JSON`.
4
+
5
+ ## Insert
6
+
7
+ SQLAlchemy handles the conversion when passing a dict as a parameter:
8
+
9
+ ```python
10
+ execute("INSERT INTO events (data) VALUES (:d)", {"d": {"event": "click"}})
11
+ ```
12
+
13
+ ## Query
14
+
15
+ Use `JSON_EXTRACT(col, '$.path')` or the `->` / `->>` shorthand (MySQL 8.0+):
16
+
17
+ ```python
18
+ rows = query("SELECT data->>'$.event' AS event FROM events WHERE JSON_EXTRACT(data, '$.event') = :e",
19
+ {"e": "click"})
20
+ ```
21
+
22
+ Note `->>` in MySQL returns `TEXT` (unquoted). In PostgreSQL `->>` returns `text` of a jsonb path. The `'$.event'` path syntax is MySQL-specific.
23
+
24
+ ## Indexing
25
+
26
+ MySQL 8.0+ supports functional indexes on JSON expressions:
27
+
28
+ ```sql
29
+ CREATE INDEX idx_events_event ON events ((CAST(data->>'$.event' AS CHAR(64))));
30
+ ```
31
+
32
+ Pre-8.0: index a generated column instead.
33
+
34
+ ## Gotchas
35
+
36
+ - `JSON_EXTRACT` returns JSON; `->>` returns the unquoted scalar. For comparisons, match the operator.
37
+ - `->>` shorthand requires MySQL 8.0+. On 5.7 use `JSON_UNQUOTE(JSON_EXTRACT(...))`.
38
+ - MySQL `JSON` does NOT auto-compress (unlike PG `jsonb` TOAST). Large docs blow up row size.
39
+ - Whitespace handling: MySQL normalizes on input but may reformat on output (PG normalizes the same way).
40
+
41
+ ## See also
42
+
43
+ - `interaction-skills/postgres/json-columns.md` — PostgreSQL `jsonb` syntax (`@>`, `?`, no `$.path`)
@@ -0,0 +1,46 @@
1
+ # JSON columns — PostgreSQL (`jsonb`)
2
+
3
+ Use PostgreSQL's binary `jsonb` type (not `json` — `jsonb` is binary-comparable and indexable).
4
+
5
+ ## Insert
6
+
7
+ `psycopg3` auto-serializes Python `dict`/`list` to `jsonb`:
8
+
9
+ ```python
10
+ execute("INSERT INTO events (data) VALUES (:d)", {"d": {"event": "click", "ts": 1234}})
11
+ ```
12
+
13
+ ## Query
14
+
15
+ Use `->` (returns `jsonb`), `->>` (returns `text`), and the containment operator `@>`:
16
+
17
+ ```python
18
+ rows = query("SELECT data->>'event' AS event FROM events WHERE data @> :filter",
19
+ {"filter": {"event": "click"}})
20
+ ```
21
+
22
+ ## Indexing
23
+
24
+ `jsonb` supports GIN indexes on the whole document (fast for `@>` / `?` / `?&` / `?|`):
25
+
26
+ ```sql
27
+ CREATE INDEX idx_events_data ON events USING GIN (data);
28
+ ```
29
+
30
+ For paths you hit often, a btree expression index:
31
+
32
+ ```sql
33
+ CREATE INDEX idx_events_event ON events ((data->>'event'));
34
+ ```
35
+
36
+ ## Gotchas
37
+
38
+ - Prefer `jsonb` over `json` — `json` is text-stored, no index support beyond expression indexes, whitespace-preserved.
39
+ - Comparing `data = '{"event":"click"}'::jsonb` works (SQLAlchemy normalizes JSON whitespace).
40
+ - `JSONB` aggregates (`jsonb_agg`, `jsonb_object_agg`) exist; for plain text JSON use `string_agg`.
41
+ - GIN on `jsonb` is large — measure before adding to high-write tables.
42
+
43
+ ## See also
44
+
45
+ - `interaction-skills/postgres/specialized-indexes.md` — GIN/GiST/BRIN tradeoffs
46
+ - `interaction-skills/mysql/json-columns.md` — MySQL JSON syntax (different operators)
@@ -0,0 +1,105 @@
1
+ # PG Data Movement
2
+
3
+ Move data in/out/around PostgreSQL: logical dump + restore, bulk COPY, and
4
+ logical replication. (Schema migrations -- ALTER / CREATE under transaction
5
+ -- live in `migrations.md`; this file is for moving data across databases or
6
+ in/out of tables.)
7
+
8
+ ## Detection
9
+
10
+ - **Move a whole database** to a new server -> `pg_dump` + `pg_restore`.
11
+ - **Bulk-load** a big table (>1M rows) -> `COPY` (10-100x faster than `INSERT`).
12
+ - **Live replication** of changes from one PG to another (zero-downtime
13
+ migration, read replica, aggregation) -> logical replication (publication
14
+ + subscription).
15
+ - **One-shot export** for analysis / downstream pipeline -> `COPY` /
16
+ `psql \copy`.
17
+
18
+ ## Approach
19
+
20
+ ### 1. Logical dump + restore (server-side CLI)
21
+
22
+ `pg_dump` and `pg_restore` live on the PG host, not in SQL. Run them via
23
+ the SSH workspace that hosts your PG:
24
+
25
+ ```python
26
+ use_workspace("2026aliyun") # SSH workspace on the PG host
27
+
28
+ ssh_exec("pg_dump -Fc -d sourcedb -f /tmp/sourcedb.dump") # custom format, compressed
29
+ ssh_exec("createdb targetdb") # empty target
30
+ ssh_exec("pg_restore -d targetdb --no-owner --jobs=4 /tmp/sourcedb.dump")
31
+ ```
32
+
33
+ Flags worth knowing:
34
+
35
+ - `-Fc` custom format (compressed, parallel-restoreable). `-Fp` plain SQL.
36
+ - `-s` schema-only / `-a` data-only. `-t public.orders` one table.
37
+ - `--no-owner` strips `OWNED BY` from objects (safer cross-cluster).
38
+
39
+ Cross-version note: `pg_dump` from the **target's** major is safest.
40
+ `pg_dump` 16 can read PG 15 / 14; restore with the target's own
41
+ `pg_restore`. Going major-down (PG 16 dump -> PG 14) is **not supported**.
42
+
43
+ ### 2. Bulk COPY
44
+
45
+ `COPY` bypasses per-row parse/plan/execute -- it's the right tool for
46
+ anything >10k rows. `sql-harness`'s `execute` covers server-side file
47
+ reads; client-streaming needs psql's `\copy` via SSH.
48
+
49
+ ```python
50
+ use_workspace("2026aliyun")
51
+
52
+ # (a) COPY TO -- export a query to a server-side CSV. psql \copy wraps
53
+ # COPY and lets a client feed/collect the stream.
54
+ ssh_exec(r"""psql -U $PG_USER sourcedb -c "\copy (SELECT id,email FROM users WHERE created_at>'2025-01-01') TO '/tmp/users.csv' WITH CSV HEADER" """)
55
+ ssh_download("/tmp/users.csv", "./users.csv") # pull it local
56
+
57
+ # (b) COPY FROM file -- server reads directly. Needs superuser OR
58
+ # `GRANT pg_read_server_files TO <role>` (PG 14+).
59
+ execute("COPY users (id, email) FROM '/var/lib/pg/imports/users.csv' WITH CSV HEADER")
60
+
61
+ # (c) COPY FROM STDIN -- client streams. `execute` can't do streaming;
62
+ # psql \copy from a *local* file is the practical path:
63
+ ssh_exec(r"""psql -h $PG_HOST -U $PG_USER targetdb -c "\copy users (id,email) FROM '/local/users.csv' WITH CSV HEADER" """)
64
+ ```
65
+
66
+ ### 3. Logical replication (publication + subscription)
67
+
68
+ For live, ongoing PG-to-PG replication of row changes. SQL-only on both
69
+ sides; the only server config is `wal_level = logical` on the source.
70
+
71
+ ```python
72
+ use_workspace("source_pg")
73
+ # Source: declare what to publish. Default = all DML on the named tables.
74
+ execute("CREATE PUBLICATION pub_users FOR TABLE users")
75
+ # Add `WITH (publish = 'insert,update,delete,truncate')` to include TRUNCATEs.
76
+
77
+ use_workspace("target_pg")
78
+ # Target: subscribe. Initial copy runs, then changes stream.
79
+ execute("""CREATE SUBSCRIPTION sub_users
80
+ CONNECTION 'host=src.db port=5432 user=repl password=... dbname=source'
81
+ PUBLICATION pub_users""")
82
+
83
+ # Monitor catch-up from the TARGET:
84
+ for r in query("SELECT subname, status, received_lsn, latest_end_lsn FROM pg_stat_subscription"):
85
+ print(r) # status='streaming' + latest_end_lsn advancing = caught up
86
+ ```
87
+
88
+ ## Gotchas
89
+
90
+ - **Logical replication does NOT replicate**: schema (DDL), sequence state
91
+ (manually sync with `pg_dump --section=pre-data` on the source +
92
+ `pg_restore --section=post-data` on target), or `TRUNCATE` unless the
93
+ publication includes `publish='truncate'`.
94
+ - **Tables need a PK** (or explicit `REPLICA IDENTITY`) -- without it,
95
+ `UPDATE`/`DELETE` can't replicate.
96
+ - **`COPY FROM` file path** is server-side (`SHOW data_directory`).
97
+ Permissions: superuser OR `pg_read_server_files` (PG 14+).
98
+ - **`pg_dump` cross-major** only goes *up* (or equal). PG 14 dump -> PG 16
99
+ is fine; PG 16 dump -> PG 14 is rejected.
100
+ - **`pg_restore --jobs=N`** uses N cores but each job = one transaction
101
+ per table -- mid-restore failures leave the DB half-loaded. For
102
+ restartability, restore one table at a time.
103
+ - **Logical replication initial copy** puts write load on the source; for
104
+ very large DBs, stop writes briefly or use `ALTER SUBSCRIPTION ...
105
+ DISABLE` then re-enable after catching up.
@@ -0,0 +1,145 @@
1
+ # SSH — auth 选型与端口转发
2
+
3
+ `ssh.md` 写了怎么用 helpers(`ssh_exec / upload / download`)和 CLI。本篇覆盖两块它没写的:(1) 选哪种 auth(key / password / ssh-agent / `ssh+key` / env fallback),(2) 用 SSH workspace 做端口转发把"躲在堡垒机后面的 DB"暴露成本地端口。
4
+
5
+ > 全文代码块已注入的 helper:`ssh_exec / ssh_upload / ssh_download / use_workspace / ssh_info`。
6
+
7
+ ## Detection
8
+
9
+ - `ssh_exec` 弹 `RuntimeError("...no tables...")` 或 paramiko `Auth failed` / `No valid authenticators` → 跳 [§1](#1-auth-选型)
10
+ - PG/MySQL workspace 直连 `internal-db-1.corp:5432` 超时,但 SSH workspace 能登堡垒机 → 跳 [§2](#2-端口转发tunneling)
11
+ - 远程主机有多个 SSH key 想用 ssh-agent 托管 → §1.3
12
+
13
+ ---
14
+
15
+ ## 1. Auth 选型
16
+
17
+ ### 1.1 三种 scheme 对照
18
+
19
+ `drivers/ssh.py:_parse_url` 支持的三种 URL 形式:
20
+
21
+ | URL 形式 | 何时用 | 取 key 顺序 |
22
+ |---|---|---|
23
+ | `ssh://user@host:port` | 默认 key auth | `?key=...` → `$BH_SSH_KEY` → `~/.ssh/id_ed25519` → `id_rsa` → `id_ecdsa`(`KEY_FALLBACK`) |
24
+ | `ssh+key://user@host:port?key=/abs/path` | key 不在默认路径 | URL 里的 `?key=` 单独覆盖 |
25
+ | `ssh+password://user:pw@host:port` | 跳板 / 临时主机,没配 key | URL 嵌入密码;也可用 `$BH_SSH_PASSWORD` |
26
+
27
+ **注意**:`ssh+password` 要求密码嵌入 URL。不想明文落 TOML,把密码放 `$BH_SSH_PASSWORD` 并用 `ssh://user@host`,但驱动**不会**自动回退到 env 密码(仅 key 走 env fallback)。这是常见混淆点。
28
+
29
+ ### 1.2 用哪个连接串
30
+
31
+ ```
32
+ 有 ed25519 key 在 ~/.ssh/ 且 authorized_keys 已加?
33
+ ├─ 是 → ssh://user@host (默认 key path)
34
+ └─ 否:
35
+ 有密码但没 key? → ssh+password://user:pw@host
36
+ key 在非默认路径? → ssh://user@host?key=/path/to/k
37
+ key 在 ssh-agent? → §1.3
38
+ ```
39
+
40
+ ### 1.3 ssh-agent
41
+
42
+ paramiko 默认不会主动连 ssh-agent。两条路:
43
+
44
+ ```python
45
+ # 路径 A(最常用):导出私钥文件路径,给驱动
46
+ # connections.toml:
47
+ # url = "ssh://app@bastion.corp?key=/home/me/.ssh/bastion_ed25519"
48
+ # 这是 ssh.md 默认推荐的。
49
+
50
+ # 路径 B(agent 转发):用 ssh-agent 把 key 放进 socket,paramiko 通过
51
+ # SSHAgentKey 适配。当前 ssh-harness 没适配;如需要 agent 转发,得
52
+ # 自己 patch driver。阶段性建议 → 用路径 A。
53
+ ```
54
+
55
+ ### 1.4 key 不被接受(`Auth failed` / `No valid authenticators`)
56
+
57
+ 8 成是这几个原因,按频率排:
58
+
59
+ 1. **key 文件权限太开放**(paramiko 严格遵循 OpenSSH 规则)
60
+ ```
61
+ chmod 600 ~/.ssh/id_ed25519
62
+ ```
63
+ 2. **authorized_keys 里这 key 被加过 passphrase**——paramiko 不弹 UI。
64
+ 解决:`ssh-keygen -p -f ~/.ssh/id_ed25519` 清掉 passphrase,或 `ssh-add ~/.ssh/id_ed25519`。
65
+ 3. **`?key=` 路径拼错**:相对路径相对 pwd——绝对路径最稳。
66
+ 4. **用户名错了**:`ssh://root@host` vs `ssh://ubuntu@host`,老 OpenSSH 默认禁 root。
67
+
68
+ 排查:
69
+
70
+ ```python
71
+ use_workspace("bastion")
72
+ print(ssh_info()) # 看 user/host/port/key_path 解析对不对
73
+ ssh_exec("whoami") # 验证通了
74
+ ```
75
+
76
+ ### 1.5 env fallback 默认 key 优先级
77
+
78
+ 看 `drivers/ssh.py:KEY_FALLBACK = ("~/.ssh/id_ed25519", "~/.ssh/id_rsa", "~/.ssh/id_ecdsa")`:
79
+
80
+ ```python
81
+ r = ssh_exec("ls -l ~/.ssh/id_ed25519 ~/.ssh/id_rsa 2>&1")
82
+ print(r["stdout"])
83
+ # 第一个存在的就是 driver 选的
84
+ ```
85
+
86
+ ---
87
+
88
+ ## 2. 端口转发(Tunneling)
89
+
90
+ 业务 DB 不对公网开、只对堡垒机的内网暴露时。三条路:
91
+
92
+ ### 2.1 远程 `ssh -L`(堡垒机侧启端口)
93
+
94
+ ```python
95
+ use_workspace("bastion")
96
+ ssh_exec(
97
+ "ssh -fN -L 0.0.0.0:15432:internal-db.corp:5432 jump@internal-db.corp",
98
+ timeout=5,
99
+ )
100
+ r = ssh_exec("ss -lntp | grep 15432", timeout=5)
101
+ assert "15432" in r["stdout"]
102
+ ```
103
+
104
+ > 局限:转发启在堡垒机上,**你本机** psql 连不到堡垒机的 `15432`(除非堡垒机做了 reverse tunnel)。适合堡垒机里跑 docker-compose;不适合"我本地 psql 直连"。
105
+
106
+ ### 2.2 paramiko `open_channel` 反向隧道
107
+
108
+ 让堡垒机把 internal-db 的 5432 反向暴露到堡垒机的 15432,然后用堡垒机侧 `psql` / `mysql` 客户端工具调试:
109
+
110
+ ```python
111
+ use_workspace("bastion")
112
+ import paramiko
113
+ ws = current_workspace()
114
+ transport = ws.engine.client.get_transport()
115
+ reverse = transport.request_port_forward("0.0.0.0", 15432,
116
+ ("internal-db.corp", 5432))
117
+ try:
118
+ r = ssh_exec("PGPASSWORD=... psql -h 127.0.0.1 -p 15432 -U app -c 'select 1'")
119
+ finally:
120
+ transport.cancel_port_forward("0.0.0.0", 15432)
121
+ ```
122
+
123
+ ### 2.3 真要让你本机 SQL 客户端连
124
+
125
+ sql-harness 不内置 SSH SOCKS 代理;本机 `psql`/`TablePlus` 直连躲在堡垒机后的 DB,最干净是用 OpenSSH 客户端 `ssh -L 15432:internal-db.corp:5432 bastion`(sql-harness 之外的工具)。
126
+
127
+ 如果坚持在 sql-harness 里搞,用 §2.2 + 堡垒机启端口 + 跑 `psql`。
128
+
129
+ ---
130
+
131
+ ## Gotchas
132
+
133
+ - **paramiko 不会自动用 ssh-agent**——见 §1.3。
134
+ - **`ssh+password://` 必须嵌入密码**——`BH_SSH_PASSWORD` env 不会回退(仅 key 走 env fallback)。
135
+ - **堡垒机转发时 `ssh -fN` 在某些堡垒机受限**——`AllowTcpForwarding no`。失败就 §2.2 走 paramiko `request_port_forward`。
136
+ - **`request_port_forward` 返回的对象必须在 finally 里 cancel**,否则下次 `use_workspace` 复用 transport 报端口占用。
137
+ - **workspace disconnect 不自动清 tunnel**——硬规矩:tunnel 用 try/finally 包起来。
138
+ - **Windows → Linux 堡垒机的 key 权限**:`scp` 把 600 丢没的话 OpenSSH 服务端会拒。`scp` 后 `chmod 600` 或用 `ssh-copy-id`(用 sftp,保留权限)。
139
+ - **堡垒机禁用 SFTP subsystem**:`ssh_upload/ssh_download` 报 `RuntimeError("SFTP subsystem not available")`。回退 `tar -czf - | ssh host tar -xzf -` 经 `ssh_exec` 流式传。
140
+
141
+ ## See also
142
+
143
+ - `interaction-skills/ssh/ssh.md` — mechanic(helpers / CLI / 基础 gotchas)
144
+ - `interaction-skills/ssh/docker-via-ssh.md` — 通过 SSH workspace 把 docker-compose 部署到远程
145
+ - `docs/connections-toml-example.toml` — `ssh://` / `ssh+password://` / `ssh+key://` 三种真实示例
@@ -1,9 +1,10 @@
1
1
  # Docker-via-SSH — deploy a docker-compose service to a REMOTE host
2
2
 
3
- > **The root logic for all docker operations in sql-harness.** Templates live
4
- > locally (shipped with the skill); `sql-harness ssh` uploads them to the remote
5
- > host and runs `docker compose`. Everything runs **on the remote**, not locally.
6
- > Mirrors how you'd deploy by hand over SSH, but scripted + repeatable.
3
+ > **The root logic for all docker operations in sql-harness.** You bring the
4
+ > `docker-compose.yml` (and config files) locally; `sql-harness ssh` uploads
5
+ > them to the remote host and runs `docker compose`. Everything runs **on the
6
+ > remote**, not locally. Mirrors how you'd deploy by hand over SSH, but
7
+ > scripted + repeatable.
7
8
 
8
9
  ## Detection
9
10
 
@@ -34,11 +35,12 @@ self-contained (compose + config + data + logs all relative to that dir):
34
35
 
35
36
  ## The 4-step deploy loop
36
37
 
37
- ### Step 1 — write the compose locally (template shipped with the skill)
38
+ ### Step 1 — write the compose locally
38
39
 
39
- Templates live in `lab/sql_harness/docker-services/<service>/` (local repo).
40
- Each ships a `docker-compose.yml` + config files, all mounts relative to the
41
- service dir (`./data`, `./config` — NOT `./<service>/data`, which double-nests).
40
+ This mechanic assumes you have a local `docker-compose.yml` + any config files
41
+ you want to ship. The shipped template dir is gone in this build — bring your
42
+ own. All mounts must be **relative** to the service dir (`./data`, `./config`
43
+ — NOT `./<service>/data`, which double-nests).
42
44
 
43
45
  ### Step 2 — upload to the remote
44
46
 
@@ -122,15 +124,39 @@ the save→run cycle applied to infrastructure.
122
124
  yaml parse errors on the remote. Always strip CRLF before upload
123
125
  (`sed -i 's/\r$//' file.yml` or set `git config core.autocrlf false`).
124
126
 
125
- ## Service-specific gotchas
127
+ ## Service-specific gotchas — coturn
126
128
 
127
- coturn has its own war stories (NAT `external-ip`, 49152-65535 UDP port-range
128
- iptables OOM / exit-137, `network_mode: host`, config-as-dir vs config-as-file).
129
- Those live in `docker-services/coturn/README.md` — read before deploying coturn.
129
+ coturn (TURN/STUN relay) has war stories that bit production. All live here in the mechanic — not duplicated in the template dir.
130
+
131
+ **Key config knobs** (`turnserver.conf`):
132
+ - `realm` — your domain / public hostname (clients see this in their URL).
133
+ - `external-ip` — server's PUBLIC ip when behind NAT (Aliyun ECS is). Set as `AUTO` in shipped template; deploy script substitutes real public ip before `up -d`.
134
+ - `listening-ip=0.0.0.0` — bind interface (private is fine; only `external-ip` needs to be public).
135
+ - `listening-port=3478` (TURN/STUN), `tls-listening-port=5349` (TURNS over TLS).
136
+ - `min-port=49152` / `max-port=65535` — relay UDP range. Must match firewall.
137
+ - `user=<u>:<p>` + `lt-cred-mech` — long-term credential mechanism.
138
+
139
+ Test creds after deploy:
140
+ ```bash
141
+ turnutils_uclient -u webrtc -w webrtc <server-ip> 3478
142
+ ```
143
+
144
+ **Gotchas**:
145
+
146
+ - **`external-ip` behind NAT**: Aliyun ECS is behind NAT — coturn's `turnserver.conf` `external-ip=` must be the **public** ip, not the private one `listening-ip=0.0.0.0` binds. Templated as `AUTO`; substitute at deploy time (`sed -i "s/external-ip=AUTO/external-ip=$PUBIP/"`).
147
+
148
+ - **Firewall / security group**: docker publishes ports on the host, but Aliyun security group + host firewall (ufw/firewalld) must ALSO allow them. The coturn relay UDP range (49152-65535) is the easy one to forget. Open in security group, NOT via docker `ports:`.
149
+
150
+ - **Don't publish the relay port range via `ports:`** — publishing coturn's relay range `49152-65535` (~16k ports) makes Docker create ~16k iptables DNAT rules, which **OOM-kills the container start** (exit 137) on small hosts (Aliyun ECS). Use `network_mode: host` instead — coturn binds the range natively, zero iptables overhead. This is the official coturn/docker recommendation.
151
+
152
+ - **`network_mode: host` disables `ports:`** — the container binds host ports directly; don't also list `ports:` (compose rejects the combo). All port management moves to the host firewall + cloud security group.
153
+
154
+ - **Config-as-dir gotcha**: if `./turnserver.conf` doesn't exist locally when docker tries to mount it, Docker creates a **directory** at that path (not a file) → coturn fails to read config. Always upload the file BEFORE `up -d`, or pre-`touch` it on the remote.
155
+
156
+ **Why host networking (rationale)**: coturn's relay UDP range (49152-65535 = ~16k ports) must NOT be published via docker `-p` — Docker would create ~16k iptables DNAT rules, OOM-killing the daemon on small hosts. Host networking lets coturn bind the range natively, zero iptables overhead. Ports to open in host firewall / cloud security group: `3478/tcp+udp`, `5349/tcp+udp`, `49152-65535/udp` (relay range — open in security group, NOT in docker).
130
157
 
131
158
  ## See also
132
159
 
133
160
  - This file's "Detection" / "Approach" sections (decision P: the legacy strategy stub in `agent-workspace/zones/meta/skills/docker-deploy.md` was collapsed into the mechanic; no separate doc).
134
- - `interaction-skills/ssh.md` — the raw ssh_exec/upload/download helpers
135
- - `docker-services/<svc>/`shipped templates (coturn, mysql, redis, ...)
136
- - `docker-services/<svc>/README.md` — service-specific gotchas (e.g. coturn)
161
+ - `interaction-skills/ssh/ssh.md` — the raw ssh_exec/upload/download helpers
162
+ - `interaction-skills/ssh/auth-and-tunnels.md`SSH auth scheme 选型 + 端口转发
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-harness"
3
- version = "0.2.3"
3
+ version = "0.3.1"
4
4
  description = "Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -42,7 +42,6 @@ build-backend = "hatchling.build"
42
42
  # Mirrors browser-harness's setuptools src-layout. The package source is
43
43
  # under `src/sql_harness/`; the package name (top-level import) is `sql_harness`.
44
44
  packages = ["src/sql_harness"]
45
- artifacts = ["src/sql_harness/SKILL.md"]
46
45
 
47
46
  [tool.hatch.build.targets.wheel.force-include]
48
47
  # Bundle the full skill docs into the wheel so `sql-harness skill install`
@@ -53,9 +52,6 @@ artifacts = ["src/sql_harness/SKILL.md"]
53
52
  "SKILL.md" = "sql_harness/_skills/SKILL.md"
54
53
  "interaction-skills" = "sql_harness/_skills/interaction-skills"
55
54
  "agent-workspace/zones/meta/skills" = "sql_harness/_skills/zones-meta-skills"
56
- # Docker service templates (coturn/mysql/redis/...) — the docker-via-ssh mechanic
57
- # uploads these to ~/sql-harness/<svc>/ on a remote host.
58
- "docker-services" = "sql_harness/_skills/docker-services"
59
55
 
60
56
  [tool.pytest.ini_options]
61
57
  # Mirrors browser-harness's `pythonpath = ["src"]` — tests import `sql_harness`
@@ -343,17 +343,17 @@ def _packaged_skill_body() -> str | None:
343
343
 
344
344
  Mirrors browser-harness's run.py:_print_skill, which reads the packaged
345
345
  SKILL.md via importlib.resources so it works from both a source checkout
346
- and a built wheel. Falls back to the repo-level skills/sql-harness/SKILL.md.
346
+ and a built wheel. Falls back to the repo-level SKILL.md (root).
347
347
  """
348
- # 1. in-package: src/sql_harness/SKILL.md (force-included in the wheel)
348
+ # 1. in-package: sql_harness/_skills/SKILL.md (force-included in the wheel)
349
349
  try:
350
- ref = importlib_resources.files("sql_harness").joinpath("SKILL.md")
350
+ ref = importlib_resources.files("sql_harness").joinpath("_skills", "SKILL.md")
351
351
  if ref.is_file():
352
352
  return ref.read_text(encoding="utf-8")
353
353
  except (ModuleNotFoundError, FileNotFoundError):
354
354
  pass
355
- # 2. repo-level fallback (source checkout)
356
- repo = _package_root_dir() / "skills" / "sql-harness" / "SKILL.md"
355
+ # 2. repo-level fallback (source checkout): lab/sql_harness/SKILL.md
356
+ repo = _package_root_dir() / "SKILL.md"
357
357
  if repo.is_file():
358
358
  return repo.read_text(encoding="utf-8")
359
359
  return None
@@ -558,6 +558,13 @@ def cmd_save(args, _harness: SqlHarness) -> int:
558
558
  conn, code = _require_connection_or_fail(args)
559
559
  if code:
560
560
  return code
561
+ # Read stdin as UTF-8 on Windows (default locale is GBK/CP936, which
562
+ # mangles non-ASCII bytes into surrogates that write_text rejects).
563
+ # Mirrors run.py's heredoc-mode stdin handling.
564
+ try:
565
+ sys.stdin.reconfigure(encoding="utf-8", errors="replace")
566
+ except (AttributeError, OSError):
567
+ pass
561
568
  body = sys.stdin.read()
562
569
  if not body.strip():
563
570
  print("error: stdin is empty; nothing to save", file=sys.stderr)
@@ -616,6 +623,9 @@ def cmd_run(args, harness: SqlHarness) -> int:
616
623
  for n, v in vars(helpers).items():
617
624
  if not n.startswith("_"):
618
625
  namespace[n] = v
626
+ # Parity with heredoc mode (run.py globals): expose `sys` so saved
627
+ # scripts can use sys.stdout.write etc. without importing it.
628
+ namespace["sys"] = sys
619
629
  code_text = path.read_text(encoding="utf-8")
620
630
  try:
621
631
  exec(compile(code_text, str(path), "exec"), namespace)
@@ -10,8 +10,14 @@ from pathlib import Path
10
10
  import pytest
11
11
 
12
12
 
13
- def _run_cli(*args: str, env: dict | None = None) -> subprocess.CompletedProcess:
14
- """Run sql-harness in a subprocess; capture UTF-8 output."""
13
+ def _run_cli(
14
+ *args: str, env: dict | None = None, input: str | None = None
15
+ ) -> subprocess.CompletedProcess:
16
+ """Run sql-harness in a subprocess; capture UTF-8 output.
17
+
18
+ `input` (when given) is piped to the child's stdin as UTF-8 — used to
19
+ exercise `save`, which reads its heredoc body from stdin.
20
+ """
15
21
  import os
16
22
 
17
23
  full_env = os.environ.copy()
@@ -24,6 +30,7 @@ def _run_cli(*args: str, env: dict | None = None) -> subprocess.CompletedProcess
24
30
  encoding="utf-8",
25
31
  errors="replace",
26
32
  env=full_env,
33
+ input=input,
27
34
  )
28
35
 
29
36
 
@@ -219,4 +226,48 @@ def test_open_scripts_requires_connection(tmp_path: Path) -> None:
219
226
  """`open scripts` without --connection errors with guidance."""
220
227
  p = _run_cli("open", "scripts", env={"BH_SQL_HOME": str(tmp_path)})
221
228
  assert p.returncode == 2
222
- assert "zone-scoped" in p.stderr
229
+ assert "zone-scoped" in p.stderr
230
+
231
+
232
+ def _write_sqlite_cfg(tmp_path: Path) -> None:
233
+ """Seed an isolated BH_SQL_HOME with a single sqlite connection."""
234
+ (tmp_path / "connections.toml").write_text(
235
+ '[[connections]]\n'
236
+ 'name = "mem"\n'
237
+ 'driver = "sqlite"\n'
238
+ 'url = "sqlite:///:memory:"\n',
239
+ encoding="utf-8",
240
+ )
241
+
242
+
243
+ def test_save_preserves_utf8_stdin(tmp_path: Path) -> None:
244
+ """`save` reads stdin as UTF-8 even on Windows (cp936 default locale).
245
+
246
+ Regression: non-ASCII bytes in the heredoc body were decoded into
247
+ surrogates and write_text(utf-8) raised UnicodeEncodeError.
248
+ """
249
+ _write_sqlite_cfg(tmp_path)
250
+ body = "# 服务器内存检查\nprint('ok')\n"
251
+ p = _run_cli(
252
+ "save", "utf8", "-c", "mem", input=body, env={"BH_SQL_HOME": str(tmp_path)}
253
+ )
254
+ assert p.returncode == 0, p.stderr
255
+ saved = Path(json.loads(p.stdout)["path"])
256
+ assert "服务器内存检查" in saved.read_text(encoding="utf-8")
257
+
258
+
259
+ def test_run_namespace_exposes_sys(tmp_path: Path) -> None:
260
+ """`run` exec namespace includes `sys`, matching heredoc mode.
261
+
262
+ Regression: saved scripts that used `sys.stdout.write` without
263
+ `import sys` (as ssh.md documents) failed with NameError.
264
+ """
265
+ _write_sqlite_cfg(tmp_path)
266
+ body = "sys.stdout.write('ok-from-sys')\n" # relies on pre-imported sys
267
+ p = _run_cli(
268
+ "save", "usesys", "-c", "mem", input=body, env={"BH_SQL_HOME": str(tmp_path)}
269
+ )
270
+ assert p.returncode == 0, p.stderr
271
+ p2 = _run_cli("run", "usesys", "-c", "mem", env={"BH_SQL_HOME": str(tmp_path)})
272
+ assert p2.returncode == 0, p2.stderr
273
+ assert "ok-from-sys" in p2.stdout
@@ -446,7 +446,7 @@ wheels = [
446
446
 
447
447
  [[package]]
448
448
  name = "sql-harness"
449
- version = "0.2.2"
449
+ version = "0.3.0"
450
450
  source = { editable = "." }
451
451
  dependencies = [
452
452
  { name = "paramiko" },