inthub-cli 0.1.4__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.
- inthub/__init__.py +0 -0
- inthub/__main__.py +4 -0
- inthub/bulk/__init__.py +0 -0
- inthub/bulk/csv_state.py +118 -0
- inthub/bulk/ddl_converter.py +86 -0
- inthub/bulk/gcp_secrets.py +23 -0
- inthub/bulk/snowflake_client.py +61 -0
- inthub/bulk/sqlserver.py +51 -0
- inthub/cli.py +27 -0
- inthub/client.py +85 -0
- inthub/commands/__init__.py +0 -0
- inthub/commands/auth.py +57 -0
- inthub/commands/bulk/__init__.py +11 -0
- inthub/commands/bulk/manage.py +98 -0
- inthub/commands/bulk/progress.py +46 -0
- inthub/commands/bulk/src_sqlserver_sink_snowflake/__init__.py +123 -0
- inthub/commands/bulk/src_sqlserver_sink_snowflake/connectors.py +134 -0
- inthub/commands/bulk/src_sqlserver_sink_snowflake/orchestrator.py +326 -0
- inthub/commands/connectors.py +186 -0
- inthub/config.py +48 -0
- inthub_cli-0.1.4.dist-info/METADATA +17 -0
- inthub_cli-0.1.4.dist-info/RECORD +24 -0
- inthub_cli-0.1.4.dist-info/WHEEL +4 -0
- inthub_cli-0.1.4.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from ulid import ULID
|
|
6
|
+
|
|
7
|
+
from inthub import config
|
|
8
|
+
from inthub.bulk import csv_state
|
|
9
|
+
from inthub.client import InthubClient
|
|
10
|
+
from inthub.commands.bulk.src_sqlserver_sink_snowflake import orchestrator
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
help="Create SQL Server source and Snowflake sink connectors in bulk.",
|
|
14
|
+
add_completion=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _client() -> InthubClient:
|
|
19
|
+
return InthubClient(config.require_token())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.callback(invoke_without_command=True)
|
|
23
|
+
def src_sqlserver_sink_snowflake(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
csv_file: Annotated[Path | None, typer.Option("--csv", help="Path to input CSV.")] = None,
|
|
26
|
+
sqlserver_source_plugin: Annotated[
|
|
27
|
+
str | None, typer.Option("--sqlserver-source-plugin")
|
|
28
|
+
] = None,
|
|
29
|
+
snowflake_sink_plugin: Annotated[
|
|
30
|
+
str | None, typer.Option("--snowflake-sink-plugin")
|
|
31
|
+
] = None,
|
|
32
|
+
tags: Annotated[
|
|
33
|
+
str | None, typer.Option("--tags", help="Comma-separated tags.")
|
|
34
|
+
] = None,
|
|
35
|
+
gcp_project: Annotated[
|
|
36
|
+
str | None, typer.Option("--gcp-project", help="GCP project ID for Secret Manager.")
|
|
37
|
+
] = None,
|
|
38
|
+
resume: Annotated[
|
|
39
|
+
str | None, typer.Option("--resume", help="ULID of a previous run to resume.")
|
|
40
|
+
] = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Provision SQL Server source and Snowflake sink connectors from a CSV."""
|
|
43
|
+
if ctx.invoked_subcommand is not None:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if resume and any(x is not None for x in [csv_file, sqlserver_source_plugin,
|
|
47
|
+
snowflake_sink_plugin, tags, gcp_project]):
|
|
48
|
+
typer.echo(
|
|
49
|
+
"Error: --resume is mutually exclusive with --csv, "
|
|
50
|
+
"--sqlserver-source-plugin, --snowflake-sink-plugin, --tags, --gcp-project.",
|
|
51
|
+
err=True,
|
|
52
|
+
)
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
if resume:
|
|
56
|
+
try:
|
|
57
|
+
state = csv_state.load_state(resume)
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
typer.echo(f"Error: state for ULID '{resume}' not found in ~/.inthub/.", err=True)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
progress_path = csv_state._progress_path(resume)
|
|
62
|
+
if not progress_path.exists():
|
|
63
|
+
typer.echo(f"Error: progress file '{progress_path}' not found.", err=True)
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
source_plugin = state["sqlserver_source_plugin"]
|
|
66
|
+
sink_plugin = state["snowflake_sink_plugin"]
|
|
67
|
+
tag_list = [t for t in state.get("tags", "").split(",") if t]
|
|
68
|
+
resolved_gcp_project = state["gcp_project"]
|
|
69
|
+
else:
|
|
70
|
+
missing = [
|
|
71
|
+
name for name, val in [
|
|
72
|
+
("--csv", csv_file),
|
|
73
|
+
("--sqlserver-source-plugin", sqlserver_source_plugin),
|
|
74
|
+
("--snowflake-sink-plugin", snowflake_sink_plugin),
|
|
75
|
+
("--tags", tags),
|
|
76
|
+
("--gcp-project", gcp_project),
|
|
77
|
+
] if val is None
|
|
78
|
+
]
|
|
79
|
+
if missing:
|
|
80
|
+
typer.echo(f"Error: missing required options: {', '.join(missing)}", err=True)
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
assert csv_file is not None
|
|
84
|
+
assert sqlserver_source_plugin is not None
|
|
85
|
+
assert snowflake_sink_plugin is not None
|
|
86
|
+
assert gcp_project is not None
|
|
87
|
+
|
|
88
|
+
if not csv_file.exists():
|
|
89
|
+
typer.echo(f"Error: CSV file '{csv_file}' not found.", err=True)
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
csv_state.validate_columns(csv_file)
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
ulid = str(ULID())
|
|
99
|
+
progress_path = csv_state.init_progress_csv(csv_file, ulid)
|
|
100
|
+
tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()]
|
|
101
|
+
source_plugin = sqlserver_source_plugin
|
|
102
|
+
sink_plugin = snowflake_sink_plugin
|
|
103
|
+
resolved_gcp_project = gcp_project
|
|
104
|
+
|
|
105
|
+
csv_state.save_state(
|
|
106
|
+
ulid,
|
|
107
|
+
gcp_project=resolved_gcp_project,
|
|
108
|
+
sqlserver_source_plugin=source_plugin,
|
|
109
|
+
snowflake_sink_plugin=sink_plugin,
|
|
110
|
+
tags=tags or "",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
typer.echo(ulid)
|
|
114
|
+
|
|
115
|
+
orchestrator.run(
|
|
116
|
+
progress_path=progress_path,
|
|
117
|
+
client=_client(),
|
|
118
|
+
company_slug=config.require_company_slug(),
|
|
119
|
+
source_plugin=source_plugin,
|
|
120
|
+
sink_plugin=sink_plugin,
|
|
121
|
+
tags=tag_list,
|
|
122
|
+
gcp_project=resolved_gcp_project,
|
|
123
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from inthub.client import InthubClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_source_config(
|
|
8
|
+
row: dict[str, str],
|
|
9
|
+
cluster_slug: str,
|
|
10
|
+
company_slug: str,
|
|
11
|
+
kcc_bootstrap: str,
|
|
12
|
+
) -> str:
|
|
13
|
+
secret_ref = (
|
|
14
|
+
f"${{secrets:inthub-{company_slug}/"
|
|
15
|
+
f"{row['sqlserver_gcp_secret']}-{cluster_slug}"
|
|
16
|
+
)
|
|
17
|
+
cfg: dict[str, Any] = {
|
|
18
|
+
"database.hostname": row["sqlserver_instance"],
|
|
19
|
+
"database.port": row["sqlserver_port"],
|
|
20
|
+
"database.user": f"{secret_ref}:username}}",
|
|
21
|
+
"database.password": f"{secret_ref}:password}}",
|
|
22
|
+
"database.names": row["sqlserver_db"],
|
|
23
|
+
"database.encrypt": False,
|
|
24
|
+
"topic.prefix": row["connector_name"],
|
|
25
|
+
"table.include.list": ", ".join(
|
|
26
|
+
f"dbo.{t.strip()}" for t in row["tables"].split(",")
|
|
27
|
+
),
|
|
28
|
+
"decimal.handling.mode": "string",
|
|
29
|
+
"tombstones.on.delete": False,
|
|
30
|
+
"schema.history.internal.kafka.bootstrap.servers": kcc_bootstrap,
|
|
31
|
+
"schema.history.internal.kafka.topic": f"sh_{row['connector_name']}",
|
|
32
|
+
"data.query.mode": "direct",
|
|
33
|
+
"key.converter": "io.confluent.connect.avro.AvroConverter",
|
|
34
|
+
"key.converter.schema.registry.url": "http://schema-registry.inthub:8081",
|
|
35
|
+
"key.converter.schemas.enable": True,
|
|
36
|
+
"value.converter": "io.confluent.connect.avro.AvroConverter",
|
|
37
|
+
"value.converter.schema.registry.url": "http://schema-registry.inthub:8081",
|
|
38
|
+
"value.converter.schemas.enable": True,
|
|
39
|
+
"snapshot.locking.mode": "none",
|
|
40
|
+
"snapshot.isolation.mode": "read_committed",
|
|
41
|
+
"snapshot.max.threads": 5,
|
|
42
|
+
}
|
|
43
|
+
return json.dumps(cfg)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_sink_config(
|
|
47
|
+
row: dict[str, str],
|
|
48
|
+
table: str,
|
|
49
|
+
cluster_slug: str,
|
|
50
|
+
company_slug: str,
|
|
51
|
+
) -> str:
|
|
52
|
+
secret_ref = (
|
|
53
|
+
f"${{secrets:inthub-{company_slug}/"
|
|
54
|
+
f"{row['snowflake_gcp_secret']}-{cluster_slug}"
|
|
55
|
+
)
|
|
56
|
+
topic = f"{row['connector_name']}.{row['sqlserver_db']}.dbo.{table}"
|
|
57
|
+
url = (
|
|
58
|
+
f"jdbc:snowflake://{row['snowflake_account']}"
|
|
59
|
+
f"?schema={row['sqlserver_db']}&db={row['snowflake_db']}"
|
|
60
|
+
f"&warehouse={row['snowflake_wh']}&CLIENT_SESSION_KEEP_ALIVE=TRUE&tracing=WARNING"
|
|
61
|
+
)
|
|
62
|
+
cfg: dict[str, Any] = {
|
|
63
|
+
"topics": topic,
|
|
64
|
+
"url": url,
|
|
65
|
+
"user": f"{secret_ref}:username}}",
|
|
66
|
+
"password": f"{secret_ref}:password}}",
|
|
67
|
+
"schema": row["sqlserver_db"],
|
|
68
|
+
"key.converter": "io.confluent.connect.avro.AvroConverter",
|
|
69
|
+
"key.converter.schema.registry.url": "http://schema-registry.inthub:8081",
|
|
70
|
+
"key.converter.schemas.enable": True,
|
|
71
|
+
"value.converter": "io.confluent.connect.avro.AvroConverter",
|
|
72
|
+
"value.converter.schema.registry.url": "http://schema-registry.inthub:8081",
|
|
73
|
+
"value.converter.schemas.enable": True,
|
|
74
|
+
"consumer.override.max.poll.records": 1000,
|
|
75
|
+
}
|
|
76
|
+
return json.dumps(cfg)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_source_connector(
|
|
80
|
+
client: InthubClient,
|
|
81
|
+
row: dict[str, str],
|
|
82
|
+
cluster_slug: str,
|
|
83
|
+
company_slug: str,
|
|
84
|
+
kcc_bootstrap: str,
|
|
85
|
+
kcc_name: str,
|
|
86
|
+
plugin: str,
|
|
87
|
+
tags: list[str],
|
|
88
|
+
) -> str:
|
|
89
|
+
name = f"{row['connector_name']}-{cluster_slug}"
|
|
90
|
+
configs = build_source_config(row, cluster_slug, company_slug, kcc_bootstrap)
|
|
91
|
+
body = client.post("/connectors", json={
|
|
92
|
+
"name": name,
|
|
93
|
+
"kafkaConnectCluster": kcc_name,
|
|
94
|
+
"plugin": plugin,
|
|
95
|
+
"tags": tags,
|
|
96
|
+
"configs": configs,
|
|
97
|
+
"tasksMax": 1,
|
|
98
|
+
}).json()
|
|
99
|
+
connector_id: str = body["id"]
|
|
100
|
+
client.post(f"/connectors/{connector_id}/apply", json={
|
|
101
|
+
"branch": "main",
|
|
102
|
+
"commitMessage": f"Bulk: create source connector {name}",
|
|
103
|
+
"authorName": "inthub-cli",
|
|
104
|
+
})
|
|
105
|
+
return connector_id
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def create_sink_connector(
|
|
109
|
+
client: InthubClient,
|
|
110
|
+
row: dict[str, str],
|
|
111
|
+
table: str,
|
|
112
|
+
cluster_slug: str,
|
|
113
|
+
company_slug: str,
|
|
114
|
+
kcc_name: str,
|
|
115
|
+
plugin: str,
|
|
116
|
+
tags: list[str],
|
|
117
|
+
) -> str:
|
|
118
|
+
name = f"{row['connector_name']}-{cluster_slug}"
|
|
119
|
+
configs = build_sink_config(row, table, cluster_slug, company_slug)
|
|
120
|
+
body = client.post("/connectors", json={
|
|
121
|
+
"name": name,
|
|
122
|
+
"kafkaConnectCluster": kcc_name,
|
|
123
|
+
"plugin": plugin,
|
|
124
|
+
"tags": tags,
|
|
125
|
+
"configs": configs,
|
|
126
|
+
"tasksMax": 1,
|
|
127
|
+
}).json()
|
|
128
|
+
connector_id: str = body["id"]
|
|
129
|
+
client.post(f"/connectors/{connector_id}/apply", json={
|
|
130
|
+
"branch": "main",
|
|
131
|
+
"commitMessage": f"Bulk: create sink connector {name} for table {table}",
|
|
132
|
+
"authorName": "inthub-cli",
|
|
133
|
+
})
|
|
134
|
+
return connector_id
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.rule import Rule
|
|
7
|
+
|
|
8
|
+
from inthub.bulk import csv_state, ddl_converter, gcp_secrets, snowflake_client, sqlserver
|
|
9
|
+
from inthub.client import InthubClient
|
|
10
|
+
from inthub.commands.bulk.src_sqlserver_sink_snowflake import connectors as conn_builder
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
err = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
_ARGOCD_TIMEOUT = 600
|
|
16
|
+
_ARGOCD_POLL = 10
|
|
17
|
+
_CONNECTOR_TIMEOUT = 600
|
|
18
|
+
_CONNECTOR_POLL = 15
|
|
19
|
+
|
|
20
|
+
_OK = "[bold green]✓[/]"
|
|
21
|
+
_FAIL = "[bold red]✗[/]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _step_ok(n: int, total: int, msg: str) -> None:
|
|
25
|
+
console.print(f" [dim][{n}/{total}][/] {msg} {_OK}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _step_fail(n: int, total: int, msg: str, reason: str) -> None:
|
|
29
|
+
console.print(f" [dim][{n}/{total}][/] {msg} {_FAIL}")
|
|
30
|
+
console.print(f" [bold red]Error:[/] {reason}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _unwrap_list(body: Any) -> list[dict[str, Any]]:
|
|
34
|
+
if isinstance(body, list):
|
|
35
|
+
return [item for item in body if isinstance(item, dict)]
|
|
36
|
+
if isinstance(body, dict):
|
|
37
|
+
for val in body.values():
|
|
38
|
+
if isinstance(val, list):
|
|
39
|
+
return [item for item in val if isinstance(item, dict)]
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_kcc(client: InthubClient, kcc_name: str) -> dict[str, Any]:
|
|
44
|
+
body = client.get("/kafka-connect-clusters").json()
|
|
45
|
+
for c in _unwrap_list(body):
|
|
46
|
+
if c.get("name") == kcc_name:
|
|
47
|
+
return c
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _list_connector_names(client: InthubClient) -> set[str]:
|
|
52
|
+
body = client.get("/connectors").json()
|
|
53
|
+
return {str(c.get("name", "")) for c in _unwrap_list(body)}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _list_secret_names(client: InthubClient) -> dict[str, dict[str, Any]]:
|
|
57
|
+
body = client.get("/secrets").json()
|
|
58
|
+
return {str(s.get("name", "")): s for s in _unwrap_list(body)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _poll_argocd(
|
|
62
|
+
client: InthubClient,
|
|
63
|
+
resource_name: str,
|
|
64
|
+
timeout: int,
|
|
65
|
+
poll: int,
|
|
66
|
+
) -> bool:
|
|
67
|
+
deadline = time.time() + timeout
|
|
68
|
+
while time.time() < deadline:
|
|
69
|
+
resp = client.get(f"/observability/argocd-status?name={resource_name}").json()
|
|
70
|
+
if resp.get("argoHealth") == "Healthy" and resp.get("argoSync") == "Synced":
|
|
71
|
+
return True
|
|
72
|
+
time.sleep(poll)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ensure_secret(
|
|
77
|
+
client: InthubClient,
|
|
78
|
+
suffixed_name: str,
|
|
79
|
+
existing: dict[str, dict[str, Any]],
|
|
80
|
+
) -> str | None:
|
|
81
|
+
if suffixed_name in existing:
|
|
82
|
+
s = existing[suffixed_name]
|
|
83
|
+
if s.get("argoHealth") == "Healthy" and s.get("argoSync") == "Synced":
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
if suffixed_name not in existing:
|
|
87
|
+
client.post("/secrets", json={"name": suffixed_name})
|
|
88
|
+
|
|
89
|
+
if not _poll_argocd(client, suffixed_name, _ARGOCD_TIMEOUT, _ARGOCD_POLL):
|
|
90
|
+
return f"secret '{suffixed_name}' did not sync within timeout"
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _validate_plugins(client: InthubClient, source_plugin: str, sink_plugin: str) -> str | None:
|
|
95
|
+
body = client.get("/kc-plugins").json()
|
|
96
|
+
names = {p.get("name") for p in _unwrap_list(body)}
|
|
97
|
+
if source_plugin not in names:
|
|
98
|
+
return f"plugin '{source_plugin}' not found"
|
|
99
|
+
if sink_plugin not in names:
|
|
100
|
+
return f"plugin '{sink_plugin}' not found"
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_TOTAL_STEPS = 9
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def process_row(
|
|
108
|
+
row: dict[str, str],
|
|
109
|
+
progress_path: Path,
|
|
110
|
+
client: InthubClient,
|
|
111
|
+
company_slug: str,
|
|
112
|
+
source_plugin: str,
|
|
113
|
+
sink_plugin: str,
|
|
114
|
+
tags: list[str],
|
|
115
|
+
gcp_project: str,
|
|
116
|
+
) -> None:
|
|
117
|
+
name = row["connector_name"]
|
|
118
|
+
csv_state.update_row(progress_path, name, "working")
|
|
119
|
+
|
|
120
|
+
def fail(n: int, msg: str, reason: str) -> None:
|
|
121
|
+
csv_state.update_row(progress_path, name, "error", reason)
|
|
122
|
+
_step_fail(n, _TOTAL_STEPS, msg, reason)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# ── Step 1: Resolve KCCs ─────────────────────────────────────────
|
|
126
|
+
_msg1 = f"Resolving KCCs [cyan]{row['kcc_source']}[/] / [cyan]{row['kcc_sink']}[/]"
|
|
127
|
+
kcc_src = _resolve_kcc(client, row["kcc_source"])
|
|
128
|
+
if not kcc_src:
|
|
129
|
+
fail(1, _msg1, f"KCC source '{row['kcc_source']}' not found")
|
|
130
|
+
return
|
|
131
|
+
kcc_snk = _resolve_kcc(client, row["kcc_sink"])
|
|
132
|
+
if not kcc_snk:
|
|
133
|
+
fail(1, _msg1, f"KCC sink '{row['kcc_sink']}' not found")
|
|
134
|
+
return
|
|
135
|
+
src_cluster_slug: str = str(kcc_src.get("slug", ""))
|
|
136
|
+
kcc_bootstrap: str = str(kcc_src.get("bootstrapServers", ""))
|
|
137
|
+
src_kcc_name: str = str(kcc_src.get("name", row["kcc_source"]))
|
|
138
|
+
snk_cluster_slug: str = str(kcc_snk.get("slug", ""))
|
|
139
|
+
snk_kcc_name: str = str(kcc_snk.get("name", row["kcc_sink"]))
|
|
140
|
+
_step_ok(1, _TOTAL_STEPS, _msg1)
|
|
141
|
+
|
|
142
|
+
# ── Step 2: Fetch GCP secrets ────────────────────────────────────
|
|
143
|
+
_msg2 = "Fetching GCP secrets"
|
|
144
|
+
try:
|
|
145
|
+
ss_creds = gcp_secrets.fetch_secret(
|
|
146
|
+
gcp_project, f"{row['sqlserver_gcp_secret']}-{src_cluster_slug}"
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
fail(2, _msg2, str(exc))
|
|
150
|
+
return
|
|
151
|
+
try:
|
|
152
|
+
sf_creds = gcp_secrets.fetch_secret(
|
|
153
|
+
gcp_project, f"{row['snowflake_gcp_secret']}-{snk_cluster_slug}"
|
|
154
|
+
)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
fail(2, _msg2, str(exc))
|
|
157
|
+
return
|
|
158
|
+
_step_ok(2, _TOTAL_STEPS, _msg2)
|
|
159
|
+
|
|
160
|
+
# ── Step 3: Connect to SQL Server and Snowflake ──────────────────
|
|
161
|
+
_msg3 = "Connecting to SQL Server and Snowflake"
|
|
162
|
+
try:
|
|
163
|
+
ss_conn = sqlserver.connect(
|
|
164
|
+
row["sqlserver_instance"], int(row["sqlserver_port"]),
|
|
165
|
+
row["sqlserver_db"], ss_creds["username"], ss_creds["password"],
|
|
166
|
+
)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
fail(3, _msg3, f"SQL Server connection failed: {exc}")
|
|
169
|
+
return
|
|
170
|
+
try:
|
|
171
|
+
sf_conn = snowflake_client.connect(
|
|
172
|
+
row["snowflake_account"], sf_creds["username"], sf_creds["password"],
|
|
173
|
+
)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
fail(3, _msg3, f"Snowflake connection failed: {exc}")
|
|
176
|
+
return
|
|
177
|
+
_step_ok(3, _TOTAL_STEPS, _msg3)
|
|
178
|
+
|
|
179
|
+
# ── Step 4: Validate tables (CDC) and provision Snowflake ────────
|
|
180
|
+
tables = [t.strip() for t in row["tables"].split(",")]
|
|
181
|
+
_msg4 = f"Validating {len(tables)} table(s) and provisioning Snowflake"
|
|
182
|
+
for table in tables:
|
|
183
|
+
if not sqlserver.table_exists(ss_conn, row["sqlserver_db"], table):
|
|
184
|
+
fail(4, _msg4, f"Table '{table}' not found in SQL Server")
|
|
185
|
+
return
|
|
186
|
+
if not sqlserver.cdc_enabled(ss_conn, row["sqlserver_db"], table):
|
|
187
|
+
fail(4, _msg4, f"CDC not enabled on table '{table}'")
|
|
188
|
+
return
|
|
189
|
+
if not snowflake_client.ingest_table_exists(
|
|
190
|
+
sf_conn, row["snowflake_db"], row["sqlserver_db"], table
|
|
191
|
+
):
|
|
192
|
+
columns = sqlserver.get_columns(ss_conn, row["sqlserver_db"], table)
|
|
193
|
+
ddl = ddl_converter.to_create_table_ddl(
|
|
194
|
+
row["snowflake_db"], row["sqlserver_db"], table, columns
|
|
195
|
+
)
|
|
196
|
+
snowflake_client.create_ingest_table(sf_conn, ddl)
|
|
197
|
+
col_names = [str(c["COLUMN_NAME"]) for c in columns]
|
|
198
|
+
snowflake_client.create_view(
|
|
199
|
+
sf_conn, row["snowflake_db"], row["sqlserver_db"], table, col_names
|
|
200
|
+
)
|
|
201
|
+
snowflake_client.create_stage(
|
|
202
|
+
sf_conn, row["snowflake_db"], row["sqlserver_db"], table
|
|
203
|
+
)
|
|
204
|
+
_step_ok(4, _TOTAL_STEPS, _msg4)
|
|
205
|
+
|
|
206
|
+
# ── Step 5: Check connectors don't already exist ──────────────────
|
|
207
|
+
src_connector_name = f"{name}-{src_cluster_slug}"
|
|
208
|
+
snk_connector_name = f"{name}-{snk_cluster_slug}"
|
|
209
|
+
_msg5 = (
|
|
210
|
+
f"Checking connector availability "
|
|
211
|
+
f"[cyan]{src_connector_name}[/] / [cyan]{snk_connector_name}[/]"
|
|
212
|
+
)
|
|
213
|
+
existing_connectors = _list_connector_names(client)
|
|
214
|
+
for cname in (src_connector_name, snk_connector_name):
|
|
215
|
+
if cname in existing_connectors:
|
|
216
|
+
fail(5, _msg5, f"connector '{cname}' already exists")
|
|
217
|
+
return
|
|
218
|
+
_step_ok(5, _TOTAL_STEPS, _msg5)
|
|
219
|
+
|
|
220
|
+
# ── Step 6: Ensure secrets exist and are synced ──────────────────
|
|
221
|
+
ss_secret_name = f"{row['sqlserver_gcp_secret']}-{src_cluster_slug}"
|
|
222
|
+
sf_secret_name = f"{row['snowflake_gcp_secret']}-{snk_cluster_slug}"
|
|
223
|
+
_msg6 = "Ensuring secrets synced in ArgoCD"
|
|
224
|
+
existing_secrets = _list_secret_names(client)
|
|
225
|
+
for secret_name in (ss_secret_name, sf_secret_name):
|
|
226
|
+
err_msg = _ensure_secret(client, secret_name, existing_secrets)
|
|
227
|
+
if err_msg:
|
|
228
|
+
fail(6, _msg6, err_msg)
|
|
229
|
+
return
|
|
230
|
+
_step_ok(6, _TOTAL_STEPS, _msg6)
|
|
231
|
+
|
|
232
|
+
# ── Step 7: Create source connector ──────────────────────────────
|
|
233
|
+
_msg7 = f"Creating source connector [cyan]{src_connector_name}[/]"
|
|
234
|
+
try:
|
|
235
|
+
src_id = conn_builder.create_source_connector(
|
|
236
|
+
client, row, src_cluster_slug, company_slug, kcc_bootstrap, src_kcc_name,
|
|
237
|
+
source_plugin, tags,
|
|
238
|
+
)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
fail(7, _msg7, f"failed to create source connector: {exc}")
|
|
241
|
+
return
|
|
242
|
+
_step_ok(7, _TOTAL_STEPS, _msg7)
|
|
243
|
+
|
|
244
|
+
# ── Step 8: Create sink connectors ───────────────────────────────
|
|
245
|
+
_msg8 = f"Creating {len(tables)} sink connector(s)"
|
|
246
|
+
sink_ids: list[str] = []
|
|
247
|
+
for table in tables:
|
|
248
|
+
try:
|
|
249
|
+
sid = conn_builder.create_sink_connector(
|
|
250
|
+
client, row, table, snk_cluster_slug, company_slug, snk_kcc_name,
|
|
251
|
+
sink_plugin, tags,
|
|
252
|
+
)
|
|
253
|
+
sink_ids.append(sid)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
fail(8, _msg8, f"failed to create sink connector for '{table}': {exc}")
|
|
256
|
+
return
|
|
257
|
+
_step_ok(8, _TOTAL_STEPS, _msg8)
|
|
258
|
+
|
|
259
|
+
# ── Step 9: Wait for ArgoCD and verify data ───────────────────────
|
|
260
|
+
_msg9 = "ArgoCD convergence and Snowflake data verified"
|
|
261
|
+
for cid in [src_id, *sink_ids]:
|
|
262
|
+
if not _poll_argocd(client, cid, _CONNECTOR_TIMEOUT, _CONNECTOR_POLL):
|
|
263
|
+
fail(9, _msg9, "ArgoCD convergence timeout")
|
|
264
|
+
return
|
|
265
|
+
for table in tables:
|
|
266
|
+
count = snowflake_client.row_count(
|
|
267
|
+
sf_conn, row["snowflake_db"], row["sqlserver_db"], table
|
|
268
|
+
)
|
|
269
|
+
if count < 1:
|
|
270
|
+
fail(9, _msg9, f"No records in '{table}' after convergence")
|
|
271
|
+
return
|
|
272
|
+
_step_ok(9, _TOTAL_STEPS, _msg9)
|
|
273
|
+
|
|
274
|
+
csv_state.update_row(progress_path, name, "done")
|
|
275
|
+
console.print(f" {_OK} [bold green]{name} completed[/]")
|
|
276
|
+
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
csv_state.update_row(progress_path, name, "error", str(exc))
|
|
279
|
+
console.print(f" {_FAIL} Unexpected error")
|
|
280
|
+
console.print(f" [bold red]Error:[/] {exc}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def run(
|
|
284
|
+
progress_path: Path,
|
|
285
|
+
client: InthubClient,
|
|
286
|
+
company_slug: str,
|
|
287
|
+
source_plugin: str,
|
|
288
|
+
sink_plugin: str,
|
|
289
|
+
tags: list[str],
|
|
290
|
+
gcp_project: str,
|
|
291
|
+
) -> None:
|
|
292
|
+
err_msg = _validate_plugins(client, source_plugin, sink_plugin)
|
|
293
|
+
if err_msg:
|
|
294
|
+
err.print(f"[bold red]Plugin validation failed:[/] {err_msg}")
|
|
295
|
+
raise SystemExit(1)
|
|
296
|
+
|
|
297
|
+
rows = csv_state.load_rows(progress_path)
|
|
298
|
+
for row in rows:
|
|
299
|
+
if row.get("status") == "working":
|
|
300
|
+
csv_state.update_row(progress_path, row["connector_name"], "pending")
|
|
301
|
+
|
|
302
|
+
rows = csv_state.load_rows(progress_path)
|
|
303
|
+
pending = [r for r in rows if r.get("status") != "done"]
|
|
304
|
+
total = len(rows)
|
|
305
|
+
|
|
306
|
+
for i, row in enumerate(rows):
|
|
307
|
+
if row.get("status") == "done":
|
|
308
|
+
continue
|
|
309
|
+
pos = i + 1
|
|
310
|
+
console.print(Rule(
|
|
311
|
+
f"[bold]Row {pos}/{total}: {row['connector_name']}[/]",
|
|
312
|
+
style="cyan",
|
|
313
|
+
))
|
|
314
|
+
process_row(
|
|
315
|
+
row, progress_path, client, company_slug,
|
|
316
|
+
source_plugin, sink_plugin, tags, gcp_project,
|
|
317
|
+
)
|
|
318
|
+
console.print()
|
|
319
|
+
|
|
320
|
+
done = sum(1 for r in csv_state.load_rows(progress_path) if r.get("status") == "done")
|
|
321
|
+
errors = sum(1 for r in csv_state.load_rows(progress_path) if r.get("status") == "error")
|
|
322
|
+
console.print(Rule(style="dim"))
|
|
323
|
+
console.print(
|
|
324
|
+
f" Finished: [green]{done} done[/] [red]{errors} error(s)[/]"
|
|
325
|
+
f" out of {len(pending)} processed"
|
|
326
|
+
)
|