ch-migrate-cli 0.5.0__py3-none-any.whl
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.
- ch_migrate/__init__.py +68 -0
- ch_migrate/authoring.py +163 -0
- ch_migrate/bootstrap.py +379 -0
- ch_migrate/cli.py +1049 -0
- ch_migrate/config.py +116 -0
- ch_migrate/connection.py +83 -0
- ch_migrate/deps.py +123 -0
- ch_migrate/diff.py +232 -0
- ch_migrate/display.py +450 -0
- ch_migrate/downgrade.py +103 -0
- ch_migrate/env.py +226 -0
- ch_migrate/helpers.py +162 -0
- ch_migrate/hooks.py +74 -0
- ch_migrate/introspect.py +732 -0
- ch_migrate/lint.py +601 -0
- ch_migrate/mv_validate.py +554 -0
- ch_migrate/py.typed +0 -0
- ch_migrate/rebase.py +308 -0
- ch_migrate/runner.py +188 -0
- ch_migrate/scaffold.py +253 -0
- ch_migrate/secrets.py +162 -0
- ch_migrate/skills/ch-migrate/SKILL.md +250 -0
- ch_migrate/sql.py +216 -0
- ch_migrate/statements.py +144 -0
- ch_migrate/templates/bootstrap/init_users.sql +56 -0
- ch_migrate/templates/project/alembic.ini.template +43 -0
- ch_migrate/templates/project/config.yaml.template +55 -0
- ch_migrate/templates/project/env.local.example.template +25 -0
- ch_migrate/templates/project/script.py.mako.template +30 -0
- ch_migrate/ui.py +79 -0
- ch_migrate_cli-0.5.0.dist-info/METADATA +448 -0
- ch_migrate_cli-0.5.0.dist-info/RECORD +36 -0
- ch_migrate_cli-0.5.0.dist-info/WHEEL +4 -0
- ch_migrate_cli-0.5.0.dist-info/entry_points.txt +2 -0
- ch_migrate_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- clickhouse_alembic/__init__.py +59 -0
ch_migrate/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ch-migrate-cli: Alembic-based migrations for ClickHouse Cloud.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from ch_migrate import read_sql, get_db, get_env_config, create_dictionary
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.5.0"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Lazy imports to avoid import errors before dependencies are created
|
|
15
|
+
def __getattr__(name: str) -> Any:
|
|
16
|
+
if name in ("read_sql", "get_db", "create_dictionary", "on_cluster", "get_cluster"):
|
|
17
|
+
from ch_migrate.helpers import (
|
|
18
|
+
create_dictionary,
|
|
19
|
+
get_cluster,
|
|
20
|
+
get_db,
|
|
21
|
+
on_cluster,
|
|
22
|
+
read_sql,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
"read_sql": read_sql,
|
|
27
|
+
"get_db": get_db,
|
|
28
|
+
"create_dictionary": create_dictionary,
|
|
29
|
+
"on_cluster": on_cluster,
|
|
30
|
+
"get_cluster": get_cluster,
|
|
31
|
+
}[name]
|
|
32
|
+
elif name == "run_sql":
|
|
33
|
+
from ch_migrate.sql import run_sql
|
|
34
|
+
|
|
35
|
+
return run_sql
|
|
36
|
+
elif name == "IrreversibleMigration":
|
|
37
|
+
from ch_migrate.downgrade import IrreversibleMigration
|
|
38
|
+
|
|
39
|
+
return IrreversibleMigration
|
|
40
|
+
elif name == "get_env_config":
|
|
41
|
+
from ch_migrate.config import get_env_config
|
|
42
|
+
|
|
43
|
+
return get_env_config
|
|
44
|
+
elif name in ("get_secret", "SSMSecretNotFoundError", "SSMJsonKeyError"):
|
|
45
|
+
from ch_migrate.secrets import SSMJsonKeyError, SSMSecretNotFoundError, get_secret
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"get_secret": get_secret,
|
|
49
|
+
"SSMSecretNotFoundError": SSMSecretNotFoundError,
|
|
50
|
+
"SSMJsonKeyError": SSMJsonKeyError,
|
|
51
|
+
}[name]
|
|
52
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"__version__",
|
|
57
|
+
"read_sql",
|
|
58
|
+
"run_sql",
|
|
59
|
+
"IrreversibleMigration",
|
|
60
|
+
"get_db",
|
|
61
|
+
"get_env_config",
|
|
62
|
+
"create_dictionary",
|
|
63
|
+
"on_cluster",
|
|
64
|
+
"get_cluster",
|
|
65
|
+
"get_secret",
|
|
66
|
+
"SSMSecretNotFoundError",
|
|
67
|
+
"SSMJsonKeyError",
|
|
68
|
+
]
|
ch_migrate/authoring.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The files `ch-migrate new` writes for a SQL-first migration.
|
|
2
|
+
|
|
3
|
+
Alembic generates the revision file; this module rewrites it to run an upgrade
|
|
4
|
+
SQL file and a downgrade SQL file, and creates those files in the
|
|
5
|
+
object-centric history layout under migrations/sql/history/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ch_migrate.rebase import _MISSING, _literal_assignment
|
|
18
|
+
|
|
19
|
+
OBJECT_DIRS = {"table": "tables", "view": "views", "dictionary": "dictionaries"}
|
|
20
|
+
OTHER_DIR = "other"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class NewOptions:
|
|
25
|
+
"""The authoring choices for one `new` invocation."""
|
|
26
|
+
|
|
27
|
+
table_name: str | None = None
|
|
28
|
+
view_name: str | None = None
|
|
29
|
+
dict_name: str | None = None
|
|
30
|
+
exchange: bool = False
|
|
31
|
+
python_migration: bool = False
|
|
32
|
+
irreversible_reason: str | None = None
|
|
33
|
+
|
|
34
|
+
def named_objects(self) -> list[tuple[str, str]]:
|
|
35
|
+
return [
|
|
36
|
+
(kind, name)
|
|
37
|
+
for kind, name in (
|
|
38
|
+
("table", self.table_name),
|
|
39
|
+
("view", self.view_name),
|
|
40
|
+
("dictionary", self.dict_name),
|
|
41
|
+
)
|
|
42
|
+
if name
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class RevisionHeader:
|
|
48
|
+
"""Identifiers Alembic wrote into a freshly generated revision file."""
|
|
49
|
+
|
|
50
|
+
message: str
|
|
51
|
+
revision: str
|
|
52
|
+
down_revision: Any
|
|
53
|
+
branch_labels: Any
|
|
54
|
+
depends_on: Any
|
|
55
|
+
docstring_source: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SqlFiles:
|
|
60
|
+
"""Paths of a migration's SQL files, relative to migrations/sql/."""
|
|
61
|
+
|
|
62
|
+
upgrade: str
|
|
63
|
+
downgrade: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_revision_header(path: Path) -> RevisionHeader:
|
|
67
|
+
"""Read the identifiers out of an Alembic-generated revision file."""
|
|
68
|
+
content = path.read_text()
|
|
69
|
+
module = ast.parse(content)
|
|
70
|
+
docstring = ast.get_docstring(module) or ""
|
|
71
|
+
|
|
72
|
+
def literal(name: str) -> Any:
|
|
73
|
+
value = _literal_assignment(content, name)
|
|
74
|
+
return None if value is _MISSING else value
|
|
75
|
+
|
|
76
|
+
return RevisionHeader(
|
|
77
|
+
message=docstring.splitlines()[0].strip() if docstring else path.stem,
|
|
78
|
+
revision=str(literal("revision")),
|
|
79
|
+
down_revision=literal("down_revision"),
|
|
80
|
+
branch_labels=literal("branch_labels"),
|
|
81
|
+
depends_on=literal("depends_on"),
|
|
82
|
+
docstring_source=ast.get_source_segment(content, module.body[0]) if docstring else '""""""',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def history_dir(object_type: str | None, object_name: str | None) -> PurePosixPath:
|
|
87
|
+
"""Where a migration's SQL files live, relative to migrations/sql/."""
|
|
88
|
+
if object_type and object_name:
|
|
89
|
+
return PurePosixPath("history", OBJECT_DIRS[object_type], object_name)
|
|
90
|
+
return PurePosixPath("history", OTHER_DIR)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def write_sql_files(sql_root: Path, header: RevisionHeader, options: NewOptions) -> SqlFiles:
|
|
94
|
+
"""Create the upgrade (and, if reversible, downgrade) SQL files."""
|
|
95
|
+
named = options.named_objects()
|
|
96
|
+
rel_dir = history_dir(*named[0]) if named else history_dir(None, None)
|
|
97
|
+
stem = f"{datetime.now().strftime('%Y_%m_%d_%H%M')}_{header.revision}_"
|
|
98
|
+
stem += _slug(header.message)
|
|
99
|
+
(sql_root / rel_dir).mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
upgrade = rel_dir / f"{stem}.up.sql"
|
|
102
|
+
(sql_root / upgrade).write_text(_sql_file_text(header, "upgrade"))
|
|
103
|
+
if options.irreversible_reason is not None:
|
|
104
|
+
return SqlFiles(upgrade=str(upgrade), downgrade=None)
|
|
105
|
+
|
|
106
|
+
downgrade = rel_dir / f"{stem}.down.sql"
|
|
107
|
+
(sql_root / downgrade).write_text(_sql_file_text(header, "downgrade"))
|
|
108
|
+
return SqlFiles(upgrade=str(upgrade), downgrade=str(downgrade))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_revision(header: RevisionHeader, files: SqlFiles, irreversible: str | None) -> str:
|
|
112
|
+
"""The revision file for a SQL-first migration."""
|
|
113
|
+
imports = "IrreversibleMigration, run_sql" if irreversible else "run_sql"
|
|
114
|
+
marker = ""
|
|
115
|
+
if irreversible:
|
|
116
|
+
marker = (
|
|
117
|
+
"\n# `ch-migrate down` refuses to revert past this migration.\n"
|
|
118
|
+
f"irreversible = {irreversible!r}\n"
|
|
119
|
+
)
|
|
120
|
+
downgrade_body = " raise IrreversibleMigration(revision, irreversible)"
|
|
121
|
+
else:
|
|
122
|
+
downgrade_body = f" run_sql({files.downgrade!r})"
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
f"{header.docstring_source}\n\n"
|
|
126
|
+
f"from ch_migrate import {imports}\n\n"
|
|
127
|
+
"# revision identifiers\n"
|
|
128
|
+
f"revision = {header.revision!r}\n"
|
|
129
|
+
f"down_revision = {header.down_revision!r}\n"
|
|
130
|
+
f"branch_labels = {header.branch_labels!r}\n"
|
|
131
|
+
f"depends_on = {header.depends_on!r}\n"
|
|
132
|
+
f"{marker}\n\n"
|
|
133
|
+
"def upgrade() -> None:\n"
|
|
134
|
+
f" run_sql({files.upgrade!r})\n\n\n"
|
|
135
|
+
"def downgrade() -> None:\n"
|
|
136
|
+
f"{downgrade_body}\n"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _slug(message: str) -> str:
|
|
141
|
+
return re.sub(r"\W+", "_", message).strip("_").lower()[:40] or "migration"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _sql_file_text(header: RevisionHeader, direction: str) -> str:
|
|
145
|
+
guidance = {
|
|
146
|
+
"upgrade": (
|
|
147
|
+
"-- Write the statements for this change, each ending with a semicolon.\n"
|
|
148
|
+
"-- They run one at a time, in order. Make each one safe to run twice\n"
|
|
149
|
+
"-- (IF NOT EXISTS / IF EXISTS), so a failed run can simply be re-run.\n"
|
|
150
|
+
),
|
|
151
|
+
"downgrade": (
|
|
152
|
+
"-- Statements end with a semicolon and run one at a time, in order.\n"
|
|
153
|
+
"-- If the change cannot be undone (dropped data does not come back),\n"
|
|
154
|
+
"-- mark the migration irreversible instead.\n"
|
|
155
|
+
),
|
|
156
|
+
}[direction]
|
|
157
|
+
return (
|
|
158
|
+
f"-- {header.message}: {direction}\n"
|
|
159
|
+
f"-- Revision: {header.revision}\n"
|
|
160
|
+
"--\n"
|
|
161
|
+
f"{guidance}"
|
|
162
|
+
"-- {db} is replaced with the environment's database name.\n\n"
|
|
163
|
+
)
|
ch_migrate/bootstrap.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bootstrap script for initializing ClickHouse database and users.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
ch-migrate bootstrap <environment>
|
|
6
|
+
ch-migrate bootstrap <environment> --dry-run
|
|
7
|
+
|
|
8
|
+
This script:
|
|
9
|
+
1. Connects using admin credentials
|
|
10
|
+
2. Creates the target database (if not exists)
|
|
11
|
+
3. Creates roles for migration, read-only, and dict access
|
|
12
|
+
4. Creates users and assigns roles
|
|
13
|
+
5. Is idempotent - safe to run multiple times
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from ch_migrate import ui
|
|
22
|
+
from ch_migrate.config import get_env_config
|
|
23
|
+
from ch_migrate.secrets import get_secret
|
|
24
|
+
|
|
25
|
+
# Pattern for valid SQL identifiers (database, user, role names)
|
|
26
|
+
_IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_identifier(name: str, label: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Validate a SQL identifier (database, user, role name).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
name: The identifier to validate
|
|
35
|
+
label: Human-readable label for error messages (e.g., "database name")
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The validated identifier (unchanged if valid)
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If the identifier contains invalid characters
|
|
42
|
+
"""
|
|
43
|
+
if not _IDENTIFIER_PATTERN.match(name):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Invalid {label}: {name!r}. "
|
|
46
|
+
f"Must start with letter/underscore and contain only alphanumeric/underscore."
|
|
47
|
+
)
|
|
48
|
+
return name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def escape_sql_string(s: str) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Escape a string for use in SQL single-quoted literals.
|
|
54
|
+
|
|
55
|
+
Note: This is for string literals (e.g., passwords) only, not identifiers.
|
|
56
|
+
Use validate_identifier() for database/user/role names.
|
|
57
|
+
"""
|
|
58
|
+
return s.replace("\\", "\\\\").replace("'", "''")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_bootstrap_sql(
|
|
62
|
+
*,
|
|
63
|
+
project: str,
|
|
64
|
+
db: str,
|
|
65
|
+
migration_user: str,
|
|
66
|
+
migration_password: str,
|
|
67
|
+
dict_reader_name: Optional[str] = None,
|
|
68
|
+
dict_reader_password: Optional[str] = None,
|
|
69
|
+
mcp_user_name: Optional[str] = None,
|
|
70
|
+
mcp_password: Optional[str] = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Build the bootstrap SQL with optional users.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
project: Project name (used for role prefixes)
|
|
77
|
+
db: Database name
|
|
78
|
+
migration_user: Migration user name
|
|
79
|
+
migration_password: Migration user password
|
|
80
|
+
dict_reader_name: Optional dict reader user name
|
|
81
|
+
dict_reader_password: Optional dict reader password
|
|
82
|
+
mcp_user_name: Optional MCP user name
|
|
83
|
+
mcp_password: Optional MCP user password
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Complete SQL string for bootstrap
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If any identifier contains invalid characters
|
|
90
|
+
"""
|
|
91
|
+
# Validate all identifiers to prevent SQL injection
|
|
92
|
+
project = validate_identifier(project, "project name")
|
|
93
|
+
db = validate_identifier(db, "database name")
|
|
94
|
+
migration_user = validate_identifier(migration_user, "migration user")
|
|
95
|
+
if dict_reader_name:
|
|
96
|
+
dict_reader_name = validate_identifier(dict_reader_name, "dict reader user")
|
|
97
|
+
if mcp_user_name:
|
|
98
|
+
mcp_user_name = validate_identifier(mcp_user_name, "MCP user")
|
|
99
|
+
|
|
100
|
+
lines = [
|
|
101
|
+
"-- Bootstrap script for ClickHouse database",
|
|
102
|
+
"-- Safe to run multiple times (idempotent)",
|
|
103
|
+
"",
|
|
104
|
+
"-- Create database",
|
|
105
|
+
f"CREATE DATABASE IF NOT EXISTS {db};",
|
|
106
|
+
"",
|
|
107
|
+
"-- =============================================================================",
|
|
108
|
+
"-- ROLES",
|
|
109
|
+
"-- =============================================================================",
|
|
110
|
+
"",
|
|
111
|
+
"-- Migration role: schema changes and data operations",
|
|
112
|
+
"-- (Explicit grants instead of ALL for ClickHouse Cloud compatibility)",
|
|
113
|
+
f"CREATE ROLE IF NOT EXISTS {project}_migration_role;",
|
|
114
|
+
"",
|
|
115
|
+
"-- Schema operations (RENAME is part of ALTER)",
|
|
116
|
+
f"GRANT CREATE TABLE, DROP TABLE, UNDROP TABLE, ALTER ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
117
|
+
f"GRANT CREATE VIEW, DROP VIEW ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
118
|
+
f"GRANT CREATE DICTIONARY, DROP DICTIONARY ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
119
|
+
f"GRANT CREATE FUNCTION, DROP FUNCTION ON *.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
120
|
+
"",
|
|
121
|
+
"-- User/role management (for migrations that create RLS users/roles)",
|
|
122
|
+
"-- CURRENT GRANTS: ClickHouse Cloud's admin cannot grant ALTER/DROP USER on its managed sql-console user",
|
|
123
|
+
f"GRANT CURRENT GRANTS(CREATE USER, ALTER USER, DROP USER ON *.*) TO {project}_migration_role WITH GRANT OPTION;",
|
|
124
|
+
f"GRANT CREATE ROLE, ALTER ROLE, DROP ROLE ON *.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
125
|
+
f"GRANT ROLE ADMIN ON *.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
126
|
+
"",
|
|
127
|
+
"-- Row-level security management",
|
|
128
|
+
f"GRANT ACCESS MANAGEMENT ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
129
|
+
f"GRANT CREATE ROW POLICY, ALTER ROW POLICY, DROP ROW POLICY, SHOW ROW POLICIES ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
130
|
+
"",
|
|
131
|
+
"-- Data operations",
|
|
132
|
+
f"GRANT SELECT, INSERT, DELETE, TRUNCATE, OPTIMIZE ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
133
|
+
"",
|
|
134
|
+
"-- Introspection (needed by Alembic)",
|
|
135
|
+
f"GRANT SHOW TABLES, SHOW COLUMNS, SHOW DICTIONARIES ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
136
|
+
"",
|
|
137
|
+
"-- Temp tables (for zero-downtime migrations with EXCHANGE TABLES)",
|
|
138
|
+
"-- Note: EXCHANGE TABLES requires DROP on both tables, already granted above",
|
|
139
|
+
f"GRANT CREATE TEMPORARY TABLE ON *.* TO {project}_migration_role WITH GRANT OPTION;",
|
|
140
|
+
"",
|
|
141
|
+
"-- System table access (monitoring migrations, RLS introspection, user/role management)",
|
|
142
|
+
"-- Use CURRENT GRANTS to avoid failure when admin lacks access to some system tables",
|
|
143
|
+
"-- (e.g., system.zookeeper on ClickHouse Cloud)",
|
|
144
|
+
f"GRANT CURRENT GRANTS(SELECT ON system.*) TO {project}_migration_role WITH GRANT OPTION;",
|
|
145
|
+
"-- ClickHouse Cloud requires explicit grants for individual system tables",
|
|
146
|
+
f"GRANT SELECT ON system.grants TO {project}_migration_role;",
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
# MCP readonly role (optional)
|
|
150
|
+
if mcp_user_name and mcp_password:
|
|
151
|
+
lines.extend(
|
|
152
|
+
[
|
|
153
|
+
"",
|
|
154
|
+
"-- Read-only role: for MCP tools",
|
|
155
|
+
f"CREATE ROLE IF NOT EXISTS {project}_readonly_role;",
|
|
156
|
+
f"GRANT SELECT ON {db}.* TO {project}_readonly_role;",
|
|
157
|
+
f"GRANT SHOW TABLES ON {db}.* TO {project}_readonly_role;",
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Dict reader role (optional)
|
|
162
|
+
if dict_reader_name and dict_reader_password:
|
|
163
|
+
lines.extend(
|
|
164
|
+
[
|
|
165
|
+
"",
|
|
166
|
+
"-- Dict reader role: for dictionary sources",
|
|
167
|
+
f"CREATE ROLE IF NOT EXISTS {project}_dict_role;",
|
|
168
|
+
"-- SELECT grants added per-table when dictionaries are created",
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
lines.extend(
|
|
173
|
+
[
|
|
174
|
+
"",
|
|
175
|
+
"-- =============================================================================",
|
|
176
|
+
"-- USERS",
|
|
177
|
+
"-- =============================================================================",
|
|
178
|
+
"",
|
|
179
|
+
"-- Migration user (required)",
|
|
180
|
+
f"CREATE USER IF NOT EXISTS {migration_user}",
|
|
181
|
+
f"IDENTIFIED BY '{escape_sql_string(migration_password)}';",
|
|
182
|
+
f"GRANT {project}_migration_role TO {migration_user};",
|
|
183
|
+
]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# MCP user (optional)
|
|
187
|
+
if mcp_user_name and mcp_password:
|
|
188
|
+
lines.extend(
|
|
189
|
+
[
|
|
190
|
+
"",
|
|
191
|
+
"-- MCP user: read-only access for AI tools",
|
|
192
|
+
f"CREATE USER IF NOT EXISTS {mcp_user_name}",
|
|
193
|
+
f"IDENTIFIED BY '{escape_sql_string(mcp_password)}';",
|
|
194
|
+
f"GRANT {project}_readonly_role TO {mcp_user_name};",
|
|
195
|
+
]
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Dict reader user (optional)
|
|
199
|
+
if dict_reader_name and dict_reader_password:
|
|
200
|
+
lines.extend(
|
|
201
|
+
[
|
|
202
|
+
"",
|
|
203
|
+
"-- Dict reader user: for dictionary sources",
|
|
204
|
+
f"CREATE USER IF NOT EXISTS {dict_reader_name}",
|
|
205
|
+
f"IDENTIFIED BY '{escape_sql_string(dict_reader_password)}';",
|
|
206
|
+
f"GRANT {project}_dict_role TO {dict_reader_name};",
|
|
207
|
+
]
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return "\n".join(lines)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def run_bootstrap(
|
|
214
|
+
env_name: str,
|
|
215
|
+
config_path: Optional[Path] = None,
|
|
216
|
+
dry_run: bool = False,
|
|
217
|
+
verbose: bool = False,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Run bootstrap for the specified environment.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
env_name: Environment name (dev, staging, production, etc.)
|
|
224
|
+
config_path: Path to config.yaml (defaults to ./config.yaml)
|
|
225
|
+
dry_run: If True, print SQL without executing
|
|
226
|
+
verbose: If True, print each SQL statement as it executes
|
|
227
|
+
"""
|
|
228
|
+
if config_path is None:
|
|
229
|
+
config_path = Path.cwd() / "config.yaml"
|
|
230
|
+
|
|
231
|
+
env_config = get_env_config(env_name, config_path)
|
|
232
|
+
|
|
233
|
+
# Get SSM paths if configured
|
|
234
|
+
ssm_config = env_config.get("ssm", {})
|
|
235
|
+
|
|
236
|
+
# Get AWS region for SSM lookups (optional, uses AWS default if not set)
|
|
237
|
+
aws_region = env_config.get("aws_region")
|
|
238
|
+
|
|
239
|
+
# Get required credentials
|
|
240
|
+
admin_password = get_secret(
|
|
241
|
+
env_name,
|
|
242
|
+
"admin_password",
|
|
243
|
+
ssm_path=ssm_config.get("admin_password"),
|
|
244
|
+
aws_region=aws_region,
|
|
245
|
+
required=True,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
migration_password = get_secret(
|
|
249
|
+
env_name,
|
|
250
|
+
"migration_password",
|
|
251
|
+
ssm_path=ssm_config.get("migration_password"),
|
|
252
|
+
aws_region=aws_region,
|
|
253
|
+
required=True,
|
|
254
|
+
)
|
|
255
|
+
assert admin_password is not None # required=True ensures this
|
|
256
|
+
assert migration_password is not None # required=True ensures this
|
|
257
|
+
|
|
258
|
+
# Get optional credentials
|
|
259
|
+
dict_reader_name = env_config.get("dict_reader_name")
|
|
260
|
+
dict_reader_password = None
|
|
261
|
+
if dict_reader_name:
|
|
262
|
+
dict_reader_password = get_secret(
|
|
263
|
+
env_name,
|
|
264
|
+
"dict_reader_password",
|
|
265
|
+
ssm_path=ssm_config.get("dict_reader_password"),
|
|
266
|
+
aws_region=aws_region,
|
|
267
|
+
required=True,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
mcp_user_name = env_config.get("mcp_user_name")
|
|
271
|
+
mcp_password = None
|
|
272
|
+
if mcp_user_name:
|
|
273
|
+
mcp_password = get_secret(
|
|
274
|
+
env_name,
|
|
275
|
+
"mcp_password",
|
|
276
|
+
ssm_path=ssm_config.get("mcp_password"),
|
|
277
|
+
aws_region=aws_region,
|
|
278
|
+
required=True,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Get migration user - support both new 'migration_user' and legacy 'user' field
|
|
282
|
+
migration_user = env_config.get("migration_user") or env_config.get("user")
|
|
283
|
+
if not migration_user:
|
|
284
|
+
raise ValueError(f"migration_user is required in config.yaml for environment '{env_name}'")
|
|
285
|
+
|
|
286
|
+
# Build SQL
|
|
287
|
+
project_config = env_config.get("project")
|
|
288
|
+
if project_config:
|
|
289
|
+
project = project_config
|
|
290
|
+
else:
|
|
291
|
+
# Derive from database name (e.g., myproject_dev -> myproject)
|
|
292
|
+
project = env_config["database"].split("_")[0]
|
|
293
|
+
|
|
294
|
+
sql = build_bootstrap_sql(
|
|
295
|
+
project=project,
|
|
296
|
+
db=env_config["database"],
|
|
297
|
+
migration_user=migration_user,
|
|
298
|
+
migration_password=migration_password,
|
|
299
|
+
dict_reader_name=dict_reader_name,
|
|
300
|
+
dict_reader_password=dict_reader_password,
|
|
301
|
+
mcp_user_name=mcp_user_name,
|
|
302
|
+
mcp_password=mcp_password,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if dry_run:
|
|
306
|
+
ui.step(f"Dry run for {env_name}: the SQL bootstrap would run")
|
|
307
|
+
# Mask passwords in output
|
|
308
|
+
masked_sql = sql
|
|
309
|
+
for pw in [migration_password, dict_reader_password, mcp_password]:
|
|
310
|
+
if pw:
|
|
311
|
+
masked_sql = masked_sql.replace(pw, "********")
|
|
312
|
+
print(masked_sql)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
host = env_config["host"]
|
|
316
|
+
admin_user = env_config.get("admin_user", "default")
|
|
317
|
+
secure = env_config.get("secure", True)
|
|
318
|
+
port = env_config.get("port", 8443 if secure else 8123)
|
|
319
|
+
|
|
320
|
+
ui.step(f"Bootstrapping {env_name}: database {env_config['database']} on {host}, as {admin_user}")
|
|
321
|
+
ui.detail(f"Migration user: {migration_user}")
|
|
322
|
+
if dict_reader_name:
|
|
323
|
+
ui.detail(f"Dict reader: {dict_reader_name}")
|
|
324
|
+
if mcp_user_name:
|
|
325
|
+
ui.detail(f"MCP user: {mcp_user_name}")
|
|
326
|
+
|
|
327
|
+
import clickhouse_connect
|
|
328
|
+
|
|
329
|
+
client = clickhouse_connect.get_client(
|
|
330
|
+
host=host,
|
|
331
|
+
port=port,
|
|
332
|
+
username=admin_user,
|
|
333
|
+
password=admin_password,
|
|
334
|
+
secure=secure,
|
|
335
|
+
interface="https" if secure else "http",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Collect passwords for masking in verbose output
|
|
339
|
+
passwords_to_mask = [p for p in [migration_password, dict_reader_password, mcp_password] if p]
|
|
340
|
+
|
|
341
|
+
# Execute each statement
|
|
342
|
+
for statement in sql.split(";"):
|
|
343
|
+
statement = statement.strip()
|
|
344
|
+
# Strip leading comment lines to get to the actual SQL
|
|
345
|
+
lines = statement.split("\n")
|
|
346
|
+
sql_lines = [line for line in lines if not line.strip().startswith("--")]
|
|
347
|
+
actual_sql = "\n".join(sql_lines).strip()
|
|
348
|
+
if actual_sql:
|
|
349
|
+
if verbose:
|
|
350
|
+
# Mask passwords in output
|
|
351
|
+
masked = statement
|
|
352
|
+
for pw in passwords_to_mask:
|
|
353
|
+
masked = masked.replace(pw, "********")
|
|
354
|
+
ui.detail(f"{masked};")
|
|
355
|
+
client.command(actual_sql)
|
|
356
|
+
|
|
357
|
+
ui.success(f"Bootstrap complete for {env_name}.")
|
|
358
|
+
ui.hint(f"Next: `ch-migrate new {env_name} NAME --table TABLE`, or `ch-migrate up {env_name}`.")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def main() -> None:
|
|
362
|
+
"""CLI entrypoint for bootstrap."""
|
|
363
|
+
if len(sys.argv) < 2:
|
|
364
|
+
print("Usage: ch-migrate bootstrap <environment> [--dry-run]")
|
|
365
|
+
print("Example: ch-migrate bootstrap dev")
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
|
|
368
|
+
dry_run = "--dry-run" in sys.argv
|
|
369
|
+
env_name = sys.argv[1]
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
run_bootstrap(env_name, dry_run=dry_run)
|
|
373
|
+
except Exception as e:
|
|
374
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
main()
|