datamasque-cli 1.0.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.
- datamasque_cli/__init__.py +0 -0
- datamasque_cli/client.py +109 -0
- datamasque_cli/commands/__init__.py +0 -0
- datamasque_cli/commands/auth.py +154 -0
- datamasque_cli/commands/connections.py +325 -0
- datamasque_cli/commands/discovery.py +136 -0
- datamasque_cli/commands/files.py +77 -0
- datamasque_cli/commands/ruleset_libraries.py +156 -0
- datamasque_cli/commands/rulesets.py +303 -0
- datamasque_cli/commands/runs.py +526 -0
- datamasque_cli/commands/seeds.py +56 -0
- datamasque_cli/commands/system.py +118 -0
- datamasque_cli/commands/users.py +86 -0
- datamasque_cli/config.py +82 -0
- datamasque_cli/main.py +58 -0
- datamasque_cli/output.py +133 -0
- datamasque_cli/py.typed +0 -0
- datamasque_cli-1.0.0.dist-info/METADATA +269 -0
- datamasque_cli-1.0.0.dist-info/RECORD +22 -0
- datamasque_cli-1.0.0.dist-info/WHEEL +4 -0
- datamasque_cli-1.0.0.dist-info/entry_points.txt +2 -0
- datamasque_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
|
File without changes
|
datamasque_cli/client.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Authenticated DataMasque client factory.
|
|
2
|
+
|
|
3
|
+
Resolves credentials from environment variables or the active profile,
|
|
4
|
+
builds a `DataMasqueClient`, and authenticates it before returning.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from datamasque.client import DataMasqueClient
|
|
12
|
+
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError
|
|
13
|
+
from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
|
|
14
|
+
|
|
15
|
+
from datamasque_cli.config import Config, Profile, load_config
|
|
16
|
+
from datamasque_cli.output import abort
|
|
17
|
+
|
|
18
|
+
ENV_URL = "DATAMASQUE_URL"
|
|
19
|
+
ENV_USERNAME = "DATAMASQUE_USERNAME"
|
|
20
|
+
ENV_PASSWORD = "DATAMASQUE_PASSWORD"
|
|
21
|
+
ENV_VERIFY_SSL = "DATAMASQUE_VERIFY_SSL"
|
|
22
|
+
|
|
23
|
+
# `false`-y env values that disable TLS verification.
|
|
24
|
+
_FALSE_VALUES = frozenset({"false", "0", "no", "off"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _verify_ssl_from_env(default: bool) -> bool:
|
|
28
|
+
"""Resolve `verify_ssl` from `DATAMASQUE_VERIFY_SSL`, falling back to `default`."""
|
|
29
|
+
raw = os.environ.get(ENV_VERIFY_SSL)
|
|
30
|
+
if raw is None:
|
|
31
|
+
return default
|
|
32
|
+
return raw.strip().lower() not in _FALSE_VALUES
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def profile_from_env() -> Profile | None:
|
|
36
|
+
"""Build a profile from environment variables, or return None if not set."""
|
|
37
|
+
url = os.environ.get(ENV_URL)
|
|
38
|
+
username = os.environ.get(ENV_USERNAME)
|
|
39
|
+
password = os.environ.get(ENV_PASSWORD)
|
|
40
|
+
if url and username and password:
|
|
41
|
+
return Profile(
|
|
42
|
+
url=url.rstrip("/"),
|
|
43
|
+
username=username,
|
|
44
|
+
password=password,
|
|
45
|
+
verify_ssl=_verify_ssl_from_env(default=True),
|
|
46
|
+
)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
|
|
51
|
+
profile = config.get_profile(profile_name)
|
|
52
|
+
if not profile.is_configured:
|
|
53
|
+
name = profile_name or config.active_profile
|
|
54
|
+
abort(
|
|
55
|
+
f"Profile '{name}' is not configured. "
|
|
56
|
+
f"Run: dm auth login --profile {name} --url <URL> --username <USER>\n"
|
|
57
|
+
f"Or set {ENV_URL}, {ENV_USERNAME}, and {ENV_PASSWORD} environment variables."
|
|
58
|
+
)
|
|
59
|
+
return profile
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_client(profile_name: str | None = None) -> DataMasqueClient:
|
|
63
|
+
"""Build and authenticate a `DataMasqueClient`.
|
|
64
|
+
|
|
65
|
+
Credential resolution order:
|
|
66
|
+
1. Environment variables (DATAMASQUE_URL, DATAMASQUE_USERNAME, DATAMASQUE_PASSWORD)
|
|
67
|
+
2. Named profile (--profile flag)
|
|
68
|
+
3. Active profile from config file
|
|
69
|
+
"""
|
|
70
|
+
# Env vars take precedence unless a specific profile was requested.
|
|
71
|
+
env_profile = profile_from_env() if profile_name is None else None
|
|
72
|
+
if env_profile is not None:
|
|
73
|
+
profile = env_profile
|
|
74
|
+
else:
|
|
75
|
+
config = load_config()
|
|
76
|
+
profile = _resolve_profile(config, profile_name)
|
|
77
|
+
|
|
78
|
+
# `DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
|
|
79
|
+
# flip TLS verification per-call without re-running `dm auth login`.
|
|
80
|
+
verify_ssl = _verify_ssl_from_env(default=profile.verify_ssl)
|
|
81
|
+
instance_config = DataMasqueInstanceConfig(
|
|
82
|
+
base_url=profile.url,
|
|
83
|
+
username=profile.username,
|
|
84
|
+
password=profile.password,
|
|
85
|
+
verify_ssl=verify_ssl,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
client = DataMasqueClient(instance_config)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
client.authenticate()
|
|
92
|
+
except DataMasqueTransportError as e:
|
|
93
|
+
abort(_format_transport_error(profile.url, e, verify_ssl=verify_ssl))
|
|
94
|
+
except DataMasqueApiError as e:
|
|
95
|
+
abort(f"Authentication failed: {e}")
|
|
96
|
+
|
|
97
|
+
return client
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Substrings that suggest the underlying error was a TLS failure rather than
|
|
101
|
+
# a plain network outage, so we can point local-build users at `--insecure`.
|
|
102
|
+
_SSL_HINT_TERMS = ("ssl", "certificate", "verify")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _format_transport_error(url: str, error: Exception, *, verify_ssl: bool) -> str:
|
|
106
|
+
message = f"Could not connect to {url}: {error}"
|
|
107
|
+
if verify_ssl and any(term in str(error).lower() for term in _SSL_HINT_TERMS):
|
|
108
|
+
message += "\nIf this is a self-signed local build, retry with --insecure or set DATAMASQUE_VERIFY_SSL=false."
|
|
109
|
+
return message
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Authentication and profile management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from datamasque_cli.client import get_client, profile_from_env
|
|
8
|
+
from datamasque_cli.config import DEFAULT_PROFILE, Profile, load_config, save_config
|
|
9
|
+
from datamasque_cli.output import abort, print_info, print_success, print_table
|
|
10
|
+
|
|
11
|
+
# `login` and `status` handle connection errors locally
|
|
12
|
+
# because they need softer behaviour than `get_client`'s hard abort:
|
|
13
|
+
# login saves credentials even when the server is unreachable,
|
|
14
|
+
# and status prints profile info before attempting connection.
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Authentication and profile management.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def login(
|
|
21
|
+
profile: str = typer.Option("default", help="Profile name to store credentials under"),
|
|
22
|
+
is_insecure: bool = typer.Option(
|
|
23
|
+
False,
|
|
24
|
+
"--insecure",
|
|
25
|
+
help="Skip TLS verification when talking to this instance (self-signed / expired cert).",
|
|
26
|
+
),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Save credentials for a DataMasque instance.
|
|
29
|
+
|
|
30
|
+
Fully interactive: prompts for URL, username, and password. Credentials
|
|
31
|
+
are saved to `~/.config/datamasque-cli/config.toml` (mode 0600). For
|
|
32
|
+
non-interactive / CI use, set `DATAMASQUE_URL`, `DATAMASQUE_USERNAME`,
|
|
33
|
+
and `DATAMASQUE_PASSWORD` env vars instead — `dm` reads them directly
|
|
34
|
+
without a saved profile. Pair with `DATAMASQUE_VERIFY_SSL=false` to
|
|
35
|
+
skip TLS verification on a per-call basis.
|
|
36
|
+
"""
|
|
37
|
+
url = typer.prompt("DataMasque URL").rstrip("/")
|
|
38
|
+
if not url.startswith(("http://", "https://")):
|
|
39
|
+
abort(f"URL must start with http:// or https:// (got '{url}').")
|
|
40
|
+
|
|
41
|
+
username = typer.prompt("Username")
|
|
42
|
+
password = typer.prompt("Password", hide_input=True)
|
|
43
|
+
config = load_config()
|
|
44
|
+
|
|
45
|
+
config.set_profile(
|
|
46
|
+
profile,
|
|
47
|
+
Profile(url=url, username=username, password=password, verify_ssl=not is_insecure),
|
|
48
|
+
)
|
|
49
|
+
config.active_profile = profile
|
|
50
|
+
save_config(config)
|
|
51
|
+
print_success(f"Credentials saved to profile '{profile}'.")
|
|
52
|
+
|
|
53
|
+
print_info("Verifying connection...")
|
|
54
|
+
try:
|
|
55
|
+
get_client(profile)
|
|
56
|
+
except SystemExit:
|
|
57
|
+
# Credentials are already saved; connection verification is best-effort.
|
|
58
|
+
return
|
|
59
|
+
print_success("Authentication successful.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def logout(
|
|
64
|
+
profile: str | None = typer.Option(None, help="Profile to remove. Removes active profile if omitted."),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Remove stored credentials for a profile."""
|
|
67
|
+
config = load_config()
|
|
68
|
+
name = profile or config.active_profile
|
|
69
|
+
|
|
70
|
+
if not config.delete_profile(name):
|
|
71
|
+
abort(f"Profile '{name}' does not exist.")
|
|
72
|
+
|
|
73
|
+
# If we just deleted the active profile, fall back to another one.
|
|
74
|
+
if name == config.active_profile:
|
|
75
|
+
remaining = config.list_profile_names()
|
|
76
|
+
config.active_profile = remaining[0] if remaining else DEFAULT_PROFILE
|
|
77
|
+
|
|
78
|
+
save_config(config)
|
|
79
|
+
print_success(f"Profile '{name}' removed.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("use")
|
|
83
|
+
def use_profile(
|
|
84
|
+
profile: str = typer.Argument(help="Profile name to set as active"),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Set the active profile."""
|
|
87
|
+
config = load_config()
|
|
88
|
+
|
|
89
|
+
if profile not in config.profiles:
|
|
90
|
+
abort(f"Profile '{profile}' does not exist. Run: dm auth login --profile {profile}")
|
|
91
|
+
|
|
92
|
+
config.active_profile = profile
|
|
93
|
+
save_config(config)
|
|
94
|
+
print_success(f"Active profile set to '{profile}'.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("list")
|
|
98
|
+
def list_profiles() -> None:
|
|
99
|
+
"""List all configured profiles."""
|
|
100
|
+
config = load_config()
|
|
101
|
+
names = config.list_profile_names()
|
|
102
|
+
|
|
103
|
+
if not names:
|
|
104
|
+
print_info("No profiles configured. Run: dm auth login")
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
rows = []
|
|
108
|
+
for name in names:
|
|
109
|
+
p = config.profiles[name]
|
|
110
|
+
is_active = name == config.active_profile
|
|
111
|
+
rows.append(
|
|
112
|
+
[
|
|
113
|
+
"*" if is_active else "",
|
|
114
|
+
name,
|
|
115
|
+
p.url,
|
|
116
|
+
p.username,
|
|
117
|
+
]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
print_table(["", "Profile", "URL", "Username"], rows)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def status() -> None:
|
|
125
|
+
"""Show current authentication status and instance info."""
|
|
126
|
+
# Env vars take precedence over any saved profile in `get_client`,
|
|
127
|
+
# so report them here as the actual source rather than the stale profile.
|
|
128
|
+
env_profile = profile_from_env()
|
|
129
|
+
if env_profile is not None:
|
|
130
|
+
profile_label = "(env)"
|
|
131
|
+
profile = env_profile
|
|
132
|
+
else:
|
|
133
|
+
config = load_config()
|
|
134
|
+
profile = config.get_profile()
|
|
135
|
+
if not profile.is_configured:
|
|
136
|
+
abort(f"Profile '{config.active_profile}' is not configured. Run: dm auth login")
|
|
137
|
+
profile_label = config.active_profile
|
|
138
|
+
|
|
139
|
+
print_info(f"Profile: {profile_label}")
|
|
140
|
+
print_info(f"URL: {profile.url}")
|
|
141
|
+
print_info(f"Username: {profile.username}")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
client = get_client()
|
|
145
|
+
except SystemExit:
|
|
146
|
+
# `get_client` aborts on connection/auth failure,
|
|
147
|
+
# but here we want a softer warning since profile info was already printed.
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
license_info = client.get_current_license_info()
|
|
151
|
+
print_success("Authenticated.")
|
|
152
|
+
print_info(f"Licence: {license_info.uuid}")
|
|
153
|
+
expiry = license_info.expiry_date.isoformat() if license_info.expiry_date else "unknown"
|
|
154
|
+
print_info(f"Expiry: {expiry}")
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Connection management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from datamasque.client import DataMasqueClient
|
|
10
|
+
from datamasque.client.models.connection import (
|
|
11
|
+
AzureConnectionConfig,
|
|
12
|
+
ConnectionConfig,
|
|
13
|
+
DatabaseConnectionConfig,
|
|
14
|
+
DatabaseType,
|
|
15
|
+
DynamoConnectionConfig,
|
|
16
|
+
MountedShareConnectionConfig,
|
|
17
|
+
S3ConnectionConfig,
|
|
18
|
+
SnowflakeConnectionConfig,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from datamasque_cli.client import get_client
|
|
22
|
+
from datamasque_cli.output import abort, print_success, redact_sensitive_fields, render_output
|
|
23
|
+
|
|
24
|
+
_FILE_CONNECTION_TYPES = (MountedShareConnectionConfig, S3ConnectionConfig, AzureConnectionConfig)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _format_role(conn: ConnectionConfig) -> str:
|
|
28
|
+
"""Return `source`, `destination`, `source+destination`, or `—` for a connection.
|
|
29
|
+
|
|
30
|
+
Database-type connections always act as the source and mask in place,
|
|
31
|
+
so there's no source/destination split to display.
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(conn, _FILE_CONNECTION_TYPES):
|
|
34
|
+
return "source"
|
|
35
|
+
if conn.is_file_mask_source and conn.is_file_mask_destination:
|
|
36
|
+
return "source+destination"
|
|
37
|
+
if conn.is_file_mask_source:
|
|
38
|
+
return "source"
|
|
39
|
+
if conn.is_file_mask_destination:
|
|
40
|
+
return "destination"
|
|
41
|
+
return "—"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
app = typer.Typer(help="Manage database and file connections.")
|
|
45
|
+
|
|
46
|
+
# Maps the `type` field in JSON to the right config class.
|
|
47
|
+
_CONNECTION_CLASSES = {
|
|
48
|
+
"database": DatabaseConnectionConfig,
|
|
49
|
+
"s3": S3ConnectionConfig,
|
|
50
|
+
"azure": AzureConnectionConfig,
|
|
51
|
+
"mounted_share": MountedShareConnectionConfig,
|
|
52
|
+
"snowflake": SnowflakeConnectionConfig,
|
|
53
|
+
"dynamodb": DynamoConnectionConfig,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("list")
|
|
58
|
+
def list_connections(
|
|
59
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
60
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""List all configured connections."""
|
|
63
|
+
client = get_client(profile)
|
|
64
|
+
connections = client.list_connections()
|
|
65
|
+
|
|
66
|
+
data = []
|
|
67
|
+
for conn in connections:
|
|
68
|
+
class_name = type(conn).__name__
|
|
69
|
+
entry: dict[str, object] = {
|
|
70
|
+
"id": conn.id,
|
|
71
|
+
"name": conn.name,
|
|
72
|
+
"type": class_name.replace("ConnectionConfig", "").replace("Config", ""),
|
|
73
|
+
"role": _format_role(conn),
|
|
74
|
+
}
|
|
75
|
+
data.append(entry)
|
|
76
|
+
|
|
77
|
+
render_output(
|
|
78
|
+
data,
|
|
79
|
+
is_json=is_json,
|
|
80
|
+
columns=["id", "name", "type", "role"],
|
|
81
|
+
title="Connections",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("get")
|
|
86
|
+
def get_connection(
|
|
87
|
+
name: str = typer.Argument(help="Connection name"),
|
|
88
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
89
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Show details for a specific connection."""
|
|
92
|
+
client = get_client(profile)
|
|
93
|
+
connections = client.list_connections()
|
|
94
|
+
|
|
95
|
+
match = next((c for c in connections if c.name == name), None)
|
|
96
|
+
if match is None:
|
|
97
|
+
abort(f"Connection '{name}' not found.")
|
|
98
|
+
|
|
99
|
+
data = redact_sensitive_fields(match.model_dump())
|
|
100
|
+
render_output(data, is_json=is_json, title=f"Connection: {name}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("create")
|
|
104
|
+
def create_connection(
|
|
105
|
+
file: Path | None = typer.Option(None, "--file", "-f", help="JSON file defining the connection"),
|
|
106
|
+
name: str | None = typer.Option(None, help="Connection name"),
|
|
107
|
+
conn_type: str | None = typer.Option(None, "--type", "-t", help="database, s3, azure, mounted_share"),
|
|
108
|
+
host: str | None = typer.Option(None, help="Database host"),
|
|
109
|
+
port: str | None = typer.Option(None, help="Database port"),
|
|
110
|
+
database: str | None = typer.Option(None, help="Database name"),
|
|
111
|
+
user: str | None = typer.Option(None, help="Database user"),
|
|
112
|
+
password: str | None = typer.Option(None, help="Database password"),
|
|
113
|
+
db_type: str | None = typer.Option(None, "--db-type", help="postgres, mysql, oracle, mssql, mariadb, etc."),
|
|
114
|
+
schema: str | None = typer.Option(None, help="Schema name"),
|
|
115
|
+
base_directory: str | None = typer.Option(None, "--base-dir", help="Base directory (file connections)"),
|
|
116
|
+
is_source: bool = typer.Option(False, "--source", help="Is a file mask source"),
|
|
117
|
+
is_destination: bool = typer.Option(False, "--destination", help="Is a file mask destination"),
|
|
118
|
+
bucket: str | None = typer.Option(None, help="S3 bucket name"),
|
|
119
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Create or update a connection.
|
|
122
|
+
|
|
123
|
+
Use --file for full control (JSON), or flags for quick database/file connections.
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
|
|
127
|
+
# From JSON file (any connection type)
|
|
128
|
+
dm connections create --file connection.json
|
|
129
|
+
|
|
130
|
+
# Quick database connection
|
|
131
|
+
dm connections create --name mydb --type database --db-type postgres \\
|
|
132
|
+
--host db.example.com --port 5432 --database mydb --user admin --password secret
|
|
133
|
+
|
|
134
|
+
# Quick mounted share
|
|
135
|
+
dm connections create --name input --type mounted_share --base-dir my-data --source
|
|
136
|
+
"""
|
|
137
|
+
client = get_client(profile)
|
|
138
|
+
|
|
139
|
+
if file is not None:
|
|
140
|
+
_create_from_file(client, file)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if name is None or conn_type is None:
|
|
144
|
+
abort("Provide either --file or both --name and --type.")
|
|
145
|
+
|
|
146
|
+
config = _build_connection_config(
|
|
147
|
+
name=name,
|
|
148
|
+
conn_type=conn_type,
|
|
149
|
+
host=host,
|
|
150
|
+
port=port,
|
|
151
|
+
database=database,
|
|
152
|
+
user=user,
|
|
153
|
+
password=password,
|
|
154
|
+
db_type=db_type,
|
|
155
|
+
schema=schema,
|
|
156
|
+
base_directory=base_directory,
|
|
157
|
+
is_source=is_source,
|
|
158
|
+
is_destination=is_destination,
|
|
159
|
+
bucket=bucket,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
client.create_or_update_connection(config)
|
|
163
|
+
print_success(f"Connection '{name}' created/updated.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _create_from_file(client: DataMasqueClient, file: Path) -> None:
|
|
167
|
+
"""Create a connection from a JSON file."""
|
|
168
|
+
data = json.loads(file.read_text())
|
|
169
|
+
conn_type = data.pop("type", "database")
|
|
170
|
+
|
|
171
|
+
if conn_type not in _CONNECTION_CLASSES:
|
|
172
|
+
valid = ", ".join(_CONNECTION_CLASSES)
|
|
173
|
+
abort(f"Unknown connection type '{conn_type}'. Valid: {valid}")
|
|
174
|
+
|
|
175
|
+
# Convert db_type string to enum for database connections
|
|
176
|
+
if conn_type == "database" and "database_type" in data:
|
|
177
|
+
data["database_type"] = DatabaseType(data["database_type"])
|
|
178
|
+
|
|
179
|
+
klass = _CONNECTION_CLASSES[conn_type]
|
|
180
|
+
config = klass(**data)
|
|
181
|
+
client.create_or_update_connection(config)
|
|
182
|
+
print_success(f"Connection '{config.name}' created/updated.")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _build_connection_config(
|
|
186
|
+
*,
|
|
187
|
+
name: str,
|
|
188
|
+
conn_type: str,
|
|
189
|
+
host: str | None,
|
|
190
|
+
port: str | None,
|
|
191
|
+
database: str | None,
|
|
192
|
+
user: str | None,
|
|
193
|
+
password: str | None,
|
|
194
|
+
db_type: str | None,
|
|
195
|
+
schema: str | None,
|
|
196
|
+
base_directory: str | None,
|
|
197
|
+
is_source: bool,
|
|
198
|
+
is_destination: bool,
|
|
199
|
+
bucket: str | None,
|
|
200
|
+
) -> DatabaseConnectionConfig | S3ConnectionConfig | MountedShareConnectionConfig:
|
|
201
|
+
"""Build a connection config from CLI flags."""
|
|
202
|
+
if conn_type == "database":
|
|
203
|
+
if not all([host, port, database, user, password, db_type]):
|
|
204
|
+
abort("Database connections require: --host, --port, --database, --user, --password, --db-type")
|
|
205
|
+
return DatabaseConnectionConfig(
|
|
206
|
+
name=name,
|
|
207
|
+
host=host,
|
|
208
|
+
port=port,
|
|
209
|
+
database=database,
|
|
210
|
+
user=user,
|
|
211
|
+
password=password,
|
|
212
|
+
database_type=DatabaseType(db_type),
|
|
213
|
+
schema=schema,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if conn_type == "mounted_share":
|
|
217
|
+
if base_directory is None:
|
|
218
|
+
abort("Mounted share connections require: --base-dir")
|
|
219
|
+
return MountedShareConnectionConfig(
|
|
220
|
+
name=name,
|
|
221
|
+
base_directory=base_directory,
|
|
222
|
+
is_file_mask_source=is_source,
|
|
223
|
+
is_file_mask_destination=is_destination,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if conn_type == "s3":
|
|
227
|
+
if base_directory is None or bucket is None:
|
|
228
|
+
abort("S3 connections require: --base-dir, --bucket")
|
|
229
|
+
return S3ConnectionConfig(
|
|
230
|
+
name=name,
|
|
231
|
+
base_directory=base_directory,
|
|
232
|
+
bucket=bucket,
|
|
233
|
+
is_file_mask_source=is_source,
|
|
234
|
+
is_file_mask_destination=is_destination,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
abort(f"Use --file for '{conn_type}' connections (too many fields for CLI flags).")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command("test")
|
|
241
|
+
def test_connection(
|
|
242
|
+
name: str = typer.Argument(help="Connection name or ID to test"),
|
|
243
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Verify a connection can reach its target.
|
|
246
|
+
|
|
247
|
+
Posts the stored connection to the server's test endpoint, which attempts
|
|
248
|
+
an actual database handshake or filesystem/bucket open and reports
|
|
249
|
+
success, a warning, or a hard failure.
|
|
250
|
+
"""
|
|
251
|
+
client = get_client(profile)
|
|
252
|
+
|
|
253
|
+
match = next((c for c in client.list_connections() if c.name == name or str(c.id) == name), None)
|
|
254
|
+
if match is None:
|
|
255
|
+
abort(f"Connection '{name}' not found.")
|
|
256
|
+
|
|
257
|
+
response = client.make_request("POST", f"/api/connections/{match.id}/test/", data={})
|
|
258
|
+
body = response.json() if response.content else {}
|
|
259
|
+
warning = body.get("message") if isinstance(body, dict) else None
|
|
260
|
+
|
|
261
|
+
if warning:
|
|
262
|
+
print_success(f"Connection '{match.name}' reachable (warning: {warning}).")
|
|
263
|
+
else:
|
|
264
|
+
print_success(f"Connection '{match.name}' reachable.")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.command("update")
|
|
268
|
+
def update_connection(
|
|
269
|
+
name: str = typer.Argument(help="Connection name or ID to update"),
|
|
270
|
+
host: str | None = typer.Option(None, help="New database host"),
|
|
271
|
+
port: str | None = typer.Option(None, help="New database port"),
|
|
272
|
+
database: str | None = typer.Option(None, help="New database name"),
|
|
273
|
+
user: str | None = typer.Option(None, help="New database user"),
|
|
274
|
+
password: str | None = typer.Option(None, help="New database password"),
|
|
275
|
+
schema: str | None = typer.Option(None, help="New schema name"),
|
|
276
|
+
base_directory: str | None = typer.Option(None, "--base-dir", help="New base directory (file connections)"),
|
|
277
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Update selected fields on an existing connection without recreating it.
|
|
280
|
+
|
|
281
|
+
Preserves the connection's UUID, so any ruleset or run history that
|
|
282
|
+
references it stays intact. Pass only the fields that should change.
|
|
283
|
+
"""
|
|
284
|
+
client = get_client(profile)
|
|
285
|
+
|
|
286
|
+
match = next((c for c in client.list_connections() if c.name == name or str(c.id) == name), None)
|
|
287
|
+
if match is None:
|
|
288
|
+
abort(f"Connection '{name}' not found.")
|
|
289
|
+
|
|
290
|
+
updates: dict[str, object] = {
|
|
291
|
+
key: value
|
|
292
|
+
for key, value in {
|
|
293
|
+
"host": host,
|
|
294
|
+
"port": port,
|
|
295
|
+
"database": database,
|
|
296
|
+
"user": user,
|
|
297
|
+
"password": password,
|
|
298
|
+
"schema": schema,
|
|
299
|
+
"base_directory": base_directory,
|
|
300
|
+
}.items()
|
|
301
|
+
if value is not None
|
|
302
|
+
}
|
|
303
|
+
if not updates:
|
|
304
|
+
abort("Pass at least one field to update (e.g. --password, --host).")
|
|
305
|
+
|
|
306
|
+
client.make_request("PATCH", f"/api/connections/{match.id}/", data=updates)
|
|
307
|
+
print_success(f"Connection '{match.name}' updated: {', '.join(updates)}.")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.command("delete")
|
|
311
|
+
def delete_connection(
|
|
312
|
+
name: str = typer.Argument(help="Connection name to delete"),
|
|
313
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
314
|
+
is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Delete a connection by name."""
|
|
317
|
+
client = get_client(profile)
|
|
318
|
+
if not any(c.name == name for c in client.list_connections()):
|
|
319
|
+
abort(f"Connection '{name}' not found.")
|
|
320
|
+
|
|
321
|
+
if not is_confirmed:
|
|
322
|
+
typer.confirm(f"Delete connection '{name}'?", abort=True)
|
|
323
|
+
|
|
324
|
+
client.delete_connection_by_name_if_exists(name)
|
|
325
|
+
print_success(f"Connection '{name}' deleted.")
|