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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Schema discovery and sensitive data discovery 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, RunId
|
|
10
|
+
from datamasque.client.models.connection import ConnectionId
|
|
11
|
+
from datamasque.client.models.discovery import SchemaDiscoveryRequest
|
|
12
|
+
|
|
13
|
+
from datamasque_cli.client import get_client
|
|
14
|
+
from datamasque_cli.output import abort, print_json, print_success, render_output
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Data discovery operations.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _write_or_echo(content: str, output: Path | None, success_label: str) -> None:
|
|
20
|
+
"""Write `content` to `output` when given, otherwise echo to stdout."""
|
|
21
|
+
if output is None:
|
|
22
|
+
typer.echo(content)
|
|
23
|
+
return
|
|
24
|
+
output.write_text(content)
|
|
25
|
+
print_success(f"{success_label} written to {output}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_connection_id(client: DataMasqueClient, name_or_id: str) -> str:
|
|
29
|
+
"""Resolve a connection name or ID to its UUID string."""
|
|
30
|
+
match = next((c for c in client.list_connections() if c.name == name_or_id or str(c.id) == name_or_id), None)
|
|
31
|
+
if match is None:
|
|
32
|
+
abort(f"Connection '{name_or_id}' not found.")
|
|
33
|
+
return str(match.id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("schema")
|
|
37
|
+
def schema_discovery(
|
|
38
|
+
connection: str = typer.Argument(help="Connection name or ID"),
|
|
39
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Start a schema-discovery run on a connection.
|
|
42
|
+
|
|
43
|
+
Results are scoped to a run ID, not a connection, so use
|
|
44
|
+
`dm discover schema-results <run-id>` once this run reaches a terminal state
|
|
45
|
+
(poll with `dm run status <run-id>`).
|
|
46
|
+
"""
|
|
47
|
+
client = get_client(profile)
|
|
48
|
+
conn_id = _resolve_connection_id(client, connection)
|
|
49
|
+
|
|
50
|
+
request = SchemaDiscoveryRequest(connection=ConnectionId(conn_id))
|
|
51
|
+
run_id = client.start_schema_discovery_run(request)
|
|
52
|
+
print_success(
|
|
53
|
+
f"Schema discovery run {run_id} started for connection '{connection}'. "
|
|
54
|
+
f"Once finished, list results with: dm discover schema-results {run_id}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("schema-results")
|
|
59
|
+
def schema_results(
|
|
60
|
+
run_id: int = typer.Argument(help="Schema discovery run ID"),
|
|
61
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
62
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""List schema-discovery results for a completed run (paginates server-side).
|
|
65
|
+
|
|
66
|
+
Surfaces the detected `data_type`, the comma-joined classifier `matches`,
|
|
67
|
+
and the column `constraint` (PK/UK/empty) from each result so the table
|
|
68
|
+
output reflects what discovery actually found.
|
|
69
|
+
"""
|
|
70
|
+
client = get_client(profile)
|
|
71
|
+
results = client.list_schema_discovery_results(RunId(run_id))
|
|
72
|
+
|
|
73
|
+
data = [
|
|
74
|
+
{
|
|
75
|
+
"id": r.id,
|
|
76
|
+
"schema": r.schema_name or "",
|
|
77
|
+
"table": r.table,
|
|
78
|
+
"column": r.column,
|
|
79
|
+
"data_type": r.data.data_type or "",
|
|
80
|
+
"matches": ", ".join(m.label for m in r.data.discovery_matches) or "-",
|
|
81
|
+
"constraint": r.data.constraint or "",
|
|
82
|
+
}
|
|
83
|
+
for r in results
|
|
84
|
+
]
|
|
85
|
+
render_output(
|
|
86
|
+
data,
|
|
87
|
+
is_json=is_json,
|
|
88
|
+
columns=["id", "schema", "table", "column", "data_type", "matches", "constraint"],
|
|
89
|
+
title=f"Schema Discovery: Run {run_id}",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("sdd-report")
|
|
94
|
+
def sdd_report(
|
|
95
|
+
run_id: int = typer.Argument(help="Run ID"),
|
|
96
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write CSV to this path"),
|
|
97
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Download sensitive data discovery report for a run."""
|
|
100
|
+
client = get_client(profile)
|
|
101
|
+
report = client.get_sdd_report(RunId(run_id))
|
|
102
|
+
_write_or_echo(report, output, "SDD report")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("db-report")
|
|
106
|
+
def db_discovery_report(
|
|
107
|
+
run_id: int = typer.Argument(help="Run ID"),
|
|
108
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write CSV to this path"),
|
|
109
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Download database discovery report (CSV) for a run."""
|
|
112
|
+
client = get_client(profile)
|
|
113
|
+
report = client.get_db_discovery_result_report(RunId(run_id))
|
|
114
|
+
_write_or_echo(report, output, "Database discovery report")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command("file-report")
|
|
118
|
+
def file_discovery_report(
|
|
119
|
+
run_id: int = typer.Argument(help="Run ID"),
|
|
120
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write JSON to this path"),
|
|
121
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
122
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Download file discovery report for a run."""
|
|
125
|
+
client = get_client(profile)
|
|
126
|
+
report = client.get_file_data_discovery_report(RunId(run_id))
|
|
127
|
+
|
|
128
|
+
if output is not None:
|
|
129
|
+
output.write_text(json.dumps(report, indent=2, default=str))
|
|
130
|
+
print_success(f"File discovery report written to {output}")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
if is_json:
|
|
134
|
+
print_json(report)
|
|
135
|
+
else:
|
|
136
|
+
render_output(report, is_json=False, title=f"File Discovery: Run {run_id}")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""File management commands (Oracle wallets, Snowflake keys, etc.)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from datamasque.client.models.files import DataMasqueFile, SnowflakeKeyFile
|
|
9
|
+
|
|
10
|
+
from datamasque_cli.client import get_client
|
|
11
|
+
from datamasque_cli.output import abort, print_success, render_output
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Manage uploaded files (Oracle wallets, Snowflake keys).")
|
|
14
|
+
|
|
15
|
+
# Map user-friendly type names to their classes.
|
|
16
|
+
_FILE_TYPES: dict[str, type[DataMasqueFile]] = {
|
|
17
|
+
"snowflake-key": SnowflakeKeyFile,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_file_type(type_name: str) -> type[DataMasqueFile]:
|
|
22
|
+
if type_name not in _FILE_TYPES:
|
|
23
|
+
valid = ", ".join(_FILE_TYPES)
|
|
24
|
+
msg = f"Unknown file type '{type_name}'. Valid types: {valid}"
|
|
25
|
+
raise typer.BadParameter(msg)
|
|
26
|
+
return _FILE_TYPES[type_name]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("list")
|
|
30
|
+
def list_files(
|
|
31
|
+
file_type: str = typer.Argument(help="File type: snowflake-key"),
|
|
32
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
33
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""List uploaded files of a given type."""
|
|
36
|
+
klass = _resolve_file_type(file_type)
|
|
37
|
+
client = get_client(profile)
|
|
38
|
+
files = client.list_files_of_type(klass)
|
|
39
|
+
|
|
40
|
+
data = [{"id": f.id, "name": f.name} for f in files]
|
|
41
|
+
render_output(data, is_json=is_json, columns=["id", "name"], title=f"Files ({file_type})")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("delete")
|
|
45
|
+
def delete_file(
|
|
46
|
+
file_type: str = typer.Argument(help="File type: snowflake-key"),
|
|
47
|
+
name: str = typer.Argument(help="File name"),
|
|
48
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
49
|
+
is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Delete a previously uploaded file by name."""
|
|
52
|
+
klass = _resolve_file_type(file_type)
|
|
53
|
+
client = get_client(profile)
|
|
54
|
+
|
|
55
|
+
match = client.get_file_of_type_by_name(klass, name)
|
|
56
|
+
if match is None:
|
|
57
|
+
abort(f"File '{name}' ({file_type}) not found.")
|
|
58
|
+
|
|
59
|
+
if not is_confirmed:
|
|
60
|
+
typer.confirm(f"Delete file '{name}' ({file_type})?", abort=True)
|
|
61
|
+
|
|
62
|
+
client.delete_file_if_exists(match)
|
|
63
|
+
print_success(f"File '{name}' deleted.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command("upload")
|
|
67
|
+
def upload_file(
|
|
68
|
+
file_type: str = typer.Argument(help="File type: snowflake-key"),
|
|
69
|
+
file: Path = typer.Option(..., "--file", "-f", help="Path to file", exists=True, readable=True),
|
|
70
|
+
name: str = typer.Option(..., help="Name for the uploaded file"),
|
|
71
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Upload a file."""
|
|
74
|
+
klass = _resolve_file_type(file_type)
|
|
75
|
+
client = get_client(profile)
|
|
76
|
+
client.upload_file(klass, name, file)
|
|
77
|
+
print_success(f"File '{name}' uploaded as {file_type}.")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Ruleset library management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from datamasque.client.models.ruleset_library import RulesetLibrary
|
|
9
|
+
|
|
10
|
+
from datamasque_cli.client import get_client
|
|
11
|
+
from datamasque_cli.output import abort, print_success, render_output
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Manage ruleset libraries.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("list")
|
|
17
|
+
def list_libraries(
|
|
18
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
19
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""List all ruleset libraries."""
|
|
22
|
+
client = get_client(profile)
|
|
23
|
+
libraries = client.list_ruleset_libraries()
|
|
24
|
+
|
|
25
|
+
data = [
|
|
26
|
+
{
|
|
27
|
+
"id": lib.id,
|
|
28
|
+
"namespace": lib.namespace or "",
|
|
29
|
+
"name": lib.name,
|
|
30
|
+
"valid": lib.is_valid.value if lib.is_valid else "unknown",
|
|
31
|
+
}
|
|
32
|
+
for lib in libraries
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
render_output(data, is_json=is_json, columns=["id", "namespace", "name", "valid"], title="Ruleset Libraries")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("get")
|
|
39
|
+
def get_library(
|
|
40
|
+
name: str = typer.Argument(help="Library name"),
|
|
41
|
+
namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
|
|
42
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
43
|
+
is_yaml: bool = typer.Option(False, "--yaml", help="Output raw YAML content only"),
|
|
44
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Show a library's details or YAML content."""
|
|
47
|
+
client = get_client(profile)
|
|
48
|
+
lib = client.get_ruleset_library_by_name(name, namespace)
|
|
49
|
+
|
|
50
|
+
if lib is None:
|
|
51
|
+
label = f"{namespace}/{name}" if namespace else name
|
|
52
|
+
abort(f"Library '{label}' not found.")
|
|
53
|
+
|
|
54
|
+
if is_yaml:
|
|
55
|
+
typer.echo(lib.yaml)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
data: dict[str, object] = {
|
|
59
|
+
"id": lib.id,
|
|
60
|
+
"namespace": lib.namespace,
|
|
61
|
+
"name": lib.name,
|
|
62
|
+
"valid": lib.is_valid.value if lib.is_valid else "unknown",
|
|
63
|
+
"created": lib.created,
|
|
64
|
+
"modified": lib.modified,
|
|
65
|
+
}
|
|
66
|
+
render_output(data, is_json=is_json, title=f"Library: {lib.name}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("create")
|
|
70
|
+
def create_library(
|
|
71
|
+
name: str = typer.Option(..., help="Library name"),
|
|
72
|
+
file: Path = typer.Option(..., "--file", "-f", help="Path to YAML library file", exists=True, readable=True),
|
|
73
|
+
namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
|
|
74
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Create or update a ruleset library from a YAML file."""
|
|
77
|
+
yaml_content = file.read_text()
|
|
78
|
+
client = get_client(profile)
|
|
79
|
+
|
|
80
|
+
library = RulesetLibrary(name=name, namespace=namespace, yaml=yaml_content)
|
|
81
|
+
client.create_or_update_ruleset_library(library)
|
|
82
|
+
print_success(f"Library '{name}' created/updated.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("delete")
|
|
86
|
+
def delete_library(
|
|
87
|
+
name: str = typer.Argument(help="Library name to delete"),
|
|
88
|
+
namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
|
|
89
|
+
force: bool = typer.Option(False, "--force", help="Force delete even if imported by rulesets"),
|
|
90
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
91
|
+
is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Delete a ruleset library by name."""
|
|
94
|
+
label = f"{namespace}/{name}" if namespace else name
|
|
95
|
+
|
|
96
|
+
client = get_client(profile)
|
|
97
|
+
if client.get_ruleset_library_by_name(name, namespace) is None:
|
|
98
|
+
abort(f"Library '{label}' not found.")
|
|
99
|
+
|
|
100
|
+
if not is_confirmed:
|
|
101
|
+
typer.confirm(f"Delete library '{label}'?", abort=True)
|
|
102
|
+
|
|
103
|
+
client.delete_ruleset_library_by_name_if_exists(name, namespace, force=force)
|
|
104
|
+
print_success(f"Library '{label}' deleted.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("validate")
|
|
108
|
+
def validate_library(
|
|
109
|
+
name: str = typer.Argument(help="Library name"),
|
|
110
|
+
namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
|
|
111
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Re-validate a ruleset library against the current server schema.
|
|
114
|
+
|
|
115
|
+
Triggers a server-side validation pass on an existing library and reports the result.
|
|
116
|
+
"""
|
|
117
|
+
client = get_client(profile)
|
|
118
|
+
lib = client.get_ruleset_library_by_name(name, namespace)
|
|
119
|
+
|
|
120
|
+
if lib is None:
|
|
121
|
+
label = f"{namespace}/{name}" if namespace else name
|
|
122
|
+
abort(f"Library '{label}' not found.")
|
|
123
|
+
|
|
124
|
+
validated = client.validate_ruleset_library(lib.id)
|
|
125
|
+
status = validated.is_valid.value if validated.is_valid else "unknown"
|
|
126
|
+
label = f"{namespace}/{name}" if namespace else name
|
|
127
|
+
print_success(f"Library '{label}' validation status: {status}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command("usage")
|
|
131
|
+
def library_usage(
|
|
132
|
+
name: str = typer.Argument(help="Library name"),
|
|
133
|
+
namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
|
|
134
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
135
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Show which rulesets import a given library."""
|
|
138
|
+
client = get_client(profile)
|
|
139
|
+
lib = client.get_ruleset_library_by_name(name, namespace)
|
|
140
|
+
|
|
141
|
+
if lib is None:
|
|
142
|
+
label = f"{namespace}/{name}" if namespace else name
|
|
143
|
+
abort(f"Library '{label}' not found.")
|
|
144
|
+
|
|
145
|
+
rulesets = client.list_rulesets_using_library(lib.id)
|
|
146
|
+
|
|
147
|
+
data = [
|
|
148
|
+
{
|
|
149
|
+
"id": rs.id,
|
|
150
|
+
"name": rs.name,
|
|
151
|
+
"type": rs.ruleset_type.value,
|
|
152
|
+
}
|
|
153
|
+
for rs in rulesets
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
render_output(data, is_json=is_json, columns=["id", "name", "type"], title=f"Rulesets using '{name}'")
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Ruleset management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from datamasque.client import DataMasqueClient
|
|
11
|
+
from datamasque.client.base import UploadFile
|
|
12
|
+
from datamasque.client.exceptions import DataMasqueApiError
|
|
13
|
+
from datamasque.client.models.ruleset import Ruleset, RulesetType
|
|
14
|
+
|
|
15
|
+
from datamasque_cli.client import get_client
|
|
16
|
+
from datamasque_cli.output import abort, print_error, print_info, print_success, print_warning, render_output
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Manage masking rulesets.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _find_by_name(
|
|
22
|
+
client: DataMasqueClient,
|
|
23
|
+
name: str,
|
|
24
|
+
mask_type: RulesetType | None = None,
|
|
25
|
+
) -> list[Ruleset]:
|
|
26
|
+
"""Return all rulesets matching `name`, optionally narrowed by `mask_type`."""
|
|
27
|
+
matches = [rs for rs in client.list_rulesets() if rs.name == name]
|
|
28
|
+
if mask_type is not None:
|
|
29
|
+
matches = [rs for rs in matches if rs.ruleset_type == mask_type]
|
|
30
|
+
return matches
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pick_single(matches: list[Ruleset], name: str) -> Ruleset:
|
|
34
|
+
"""Return the sole match or abort with a disambiguation message."""
|
|
35
|
+
if not matches:
|
|
36
|
+
abort(f"Ruleset '{name}' not found.")
|
|
37
|
+
if len(matches) > 1:
|
|
38
|
+
options = "\n ".join(f"id={rs.id} type={rs.ruleset_type.value}" for rs in matches)
|
|
39
|
+
abort(f"Multiple rulesets named '{name}':\n {options}\nPass --type file|database to disambiguate.")
|
|
40
|
+
return matches[0]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("list")
|
|
44
|
+
def list_rulesets(
|
|
45
|
+
ruleset_type: str | None = typer.Option(None, "--type", "-t", help="Filter by type: database or file"),
|
|
46
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
47
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""List all rulesets."""
|
|
50
|
+
client = get_client(profile)
|
|
51
|
+
rulesets = client.list_rulesets()
|
|
52
|
+
|
|
53
|
+
if ruleset_type is not None:
|
|
54
|
+
wanted = RulesetType(ruleset_type)
|
|
55
|
+
rulesets = [rs for rs in rulesets if rs.ruleset_type == wanted]
|
|
56
|
+
|
|
57
|
+
data = [
|
|
58
|
+
{
|
|
59
|
+
"id": rs.id,
|
|
60
|
+
"name": rs.name,
|
|
61
|
+
"type": rs.ruleset_type.value,
|
|
62
|
+
}
|
|
63
|
+
for rs in rulesets
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
render_output(data, is_json=is_json, columns=["id", "name", "type"], title="Rulesets")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("get")
|
|
70
|
+
def get_ruleset(
|
|
71
|
+
name: str = typer.Argument(help="Ruleset name"),
|
|
72
|
+
ruleset_type: str | None = typer.Option(None, "--type", "-t", help="Required when two rulesets share a name"),
|
|
73
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
74
|
+
is_yaml: bool = typer.Option(False, "--yaml", help="Output raw YAML content only"),
|
|
75
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Show a ruleset's details or YAML content."""
|
|
78
|
+
client = get_client(profile)
|
|
79
|
+
wanted = RulesetType(ruleset_type) if ruleset_type is not None else None
|
|
80
|
+
match = _pick_single(_find_by_name(client, name, wanted), name)
|
|
81
|
+
|
|
82
|
+
# `list_rulesets` omits the YAML body for performance; fetch the single ruleset
|
|
83
|
+
# to populate `yaml` via the Ruleset pydantic model's `config_yaml` alias.
|
|
84
|
+
response = client.make_request("GET", f"/api/rulesets/{match.id}/")
|
|
85
|
+
full = Ruleset.model_validate(response.json())
|
|
86
|
+
|
|
87
|
+
if is_yaml:
|
|
88
|
+
typer.echo(full.yaml)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
data: dict[str, object] = {
|
|
92
|
+
"id": full.id,
|
|
93
|
+
"name": full.name,
|
|
94
|
+
"type": full.ruleset_type.value,
|
|
95
|
+
"yaml": full.yaml,
|
|
96
|
+
}
|
|
97
|
+
render_output(data, is_json=is_json, title=f"Ruleset: {name}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("create")
|
|
101
|
+
def create_ruleset(
|
|
102
|
+
name: str = typer.Option(..., help="Ruleset name"),
|
|
103
|
+
file: Path = typer.Option(..., "--file", "-f", help="Path to YAML ruleset file", exists=True, readable=True),
|
|
104
|
+
ruleset_type: str | None = typer.Option(
|
|
105
|
+
None,
|
|
106
|
+
"--type",
|
|
107
|
+
"-t",
|
|
108
|
+
help=(
|
|
109
|
+
"Ruleset type: database or file. "
|
|
110
|
+
"Required when the ruleset does not yet exist; defaults to the existing type on updates."
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Create or update a ruleset from a YAML file.
|
|
116
|
+
|
|
117
|
+
Read the server first to decide which namespace the upload belongs in. A
|
|
118
|
+
brand-new ruleset needs --type because there is no stored row to copy the
|
|
119
|
+
type from; an update defaults to whatever the existing row is stored as.
|
|
120
|
+
"""
|
|
121
|
+
client = get_client(profile)
|
|
122
|
+
existing = _find_by_name(client, name)
|
|
123
|
+
explicit = RulesetType(ruleset_type) if ruleset_type is not None else None
|
|
124
|
+
|
|
125
|
+
if explicit is not None:
|
|
126
|
+
rs_type = explicit
|
|
127
|
+
elif len(existing) == 1:
|
|
128
|
+
rs_type = existing[0].ruleset_type
|
|
129
|
+
print_info(f"Updating existing {rs_type.value}-type ruleset '{name}'.")
|
|
130
|
+
elif not existing:
|
|
131
|
+
abort(f"No ruleset named '{name}' exists. Pass --type file|database to create a new one.")
|
|
132
|
+
else:
|
|
133
|
+
options = ", ".join(r.ruleset_type.value for r in existing)
|
|
134
|
+
abort(f"Multiple rulesets named '{name}' ({options}). Pass --type file|database to pick which one to update.")
|
|
135
|
+
|
|
136
|
+
yaml_content = file.read_text()
|
|
137
|
+
ruleset = Ruleset(name=name, yaml=yaml_content, ruleset_type=rs_type)
|
|
138
|
+
client.create_or_update_ruleset(ruleset)
|
|
139
|
+
print_success(f"Ruleset '{name}' ({rs_type.value}) created/updated.")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("delete")
|
|
143
|
+
def delete_ruleset(
|
|
144
|
+
name: str = typer.Argument(help="Ruleset name to delete"),
|
|
145
|
+
ruleset_type: str | None = typer.Option(None, "--type", "-t", help="Required when two rulesets share a name"),
|
|
146
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
147
|
+
is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Delete a ruleset by name."""
|
|
150
|
+
client = get_client(profile)
|
|
151
|
+
wanted = RulesetType(ruleset_type) if ruleset_type is not None else None
|
|
152
|
+
match = _pick_single(_find_by_name(client, name, wanted), name)
|
|
153
|
+
|
|
154
|
+
if not is_confirmed:
|
|
155
|
+
typer.confirm(f"Delete ruleset '{name}' ({match.ruleset_type.value})?", abort=True)
|
|
156
|
+
|
|
157
|
+
assert match.id is not None # Populated by list_rulesets
|
|
158
|
+
client.delete_ruleset_by_id_if_exists(match.id)
|
|
159
|
+
print_success(f"Ruleset '{name}' ({match.ruleset_type.value}) deleted.")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command("validate")
|
|
163
|
+
def validate_ruleset(
|
|
164
|
+
file: Path = typer.Option(..., "--file", "-f", help="Path to YAML ruleset file", exists=True, readable=True),
|
|
165
|
+
ruleset_type: str = typer.Option(
|
|
166
|
+
...,
|
|
167
|
+
"--type",
|
|
168
|
+
"-t",
|
|
169
|
+
help="Ruleset type: database or file",
|
|
170
|
+
),
|
|
171
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Validate a ruleset YAML file against the DataMasque server.
|
|
174
|
+
|
|
175
|
+
Creates a temporary ruleset to trigger server-side validation,
|
|
176
|
+
then deletes it. Reports any validation errors.
|
|
177
|
+
"""
|
|
178
|
+
yaml_content = file.read_text()
|
|
179
|
+
rs_type = RulesetType(ruleset_type)
|
|
180
|
+
# `uuid` guards against collisions between concurrent `validate` runs.
|
|
181
|
+
temp_name = f"__dm_cli_validate_{uuid.uuid4().hex}"
|
|
182
|
+
|
|
183
|
+
client = get_client(profile)
|
|
184
|
+
ruleset = Ruleset(name=temp_name, yaml=yaml_content, ruleset_type=rs_type)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
created = client.create_or_update_ruleset(ruleset)
|
|
188
|
+
except DataMasqueApiError as exc:
|
|
189
|
+
print_error(f"Validation failed: {exc}")
|
|
190
|
+
raise SystemExit(1) from None
|
|
191
|
+
|
|
192
|
+
# `try/finally` so a Ctrl-C or unexpected exception between create and
|
|
193
|
+
# delete still cleans up the temp ruleset on the server.
|
|
194
|
+
try:
|
|
195
|
+
print_success(f"Ruleset '{file.name}' ({rs_type.value}) is valid.")
|
|
196
|
+
finally:
|
|
197
|
+
if created.id is not None:
|
|
198
|
+
try:
|
|
199
|
+
client.delete_ruleset_by_id_if_exists(created.id)
|
|
200
|
+
except DataMasqueApiError as exc:
|
|
201
|
+
print_warning(f"Validation ruleset '{temp_name}' left on server; delete manually. Reason: {exc}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command("export-bundle")
|
|
205
|
+
def export_bundle(
|
|
206
|
+
output_path: Path = typer.Option("datamasque-bundle.zip", "--output", "-o", help="Where to save the ZIP bundle"),
|
|
207
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Export rulesets, ruleset libraries, and any referenced seed files as a ZIP bundle.
|
|
210
|
+
|
|
211
|
+
Bundles only rulesets + libraries + optional seed files — not connections,
|
|
212
|
+
users, licences, or instance settings.
|
|
213
|
+
"""
|
|
214
|
+
client = get_client(profile)
|
|
215
|
+
# `export_configuration` is not yet wrapped in datamasque-python; hit the endpoint directly.
|
|
216
|
+
response = client.make_request("GET", "/api/export/v1/")
|
|
217
|
+
output_path.write_bytes(response.content)
|
|
218
|
+
print_success(f"Bundle exported to {output_path}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command("import-bundle")
|
|
222
|
+
def import_bundle(
|
|
223
|
+
file: Path = typer.Option(..., "--file", "-f", help="Path to bundle ZIP", exists=True, readable=True),
|
|
224
|
+
overwrite_rulesets: bool = typer.Option(
|
|
225
|
+
False, "--overwrite-rulesets", help="Overwrite rulesets that already exist with the same name"
|
|
226
|
+
),
|
|
227
|
+
overwrite_libraries: bool = typer.Option(
|
|
228
|
+
False, "--overwrite-libraries", help="Overwrite ruleset libraries that already exist with the same name"
|
|
229
|
+
),
|
|
230
|
+
overwrite_seeds: bool = typer.Option(
|
|
231
|
+
False, "--overwrite-seeds", help="Overwrite seed files that already exist with the same name"
|
|
232
|
+
),
|
|
233
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
234
|
+
is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Import rulesets, ruleset libraries, and seed files from a ZIP bundle.
|
|
237
|
+
|
|
238
|
+
Bundle must have been produced by `dm rulesets export-bundle` or the
|
|
239
|
+
server's `/api/export/v1/` endpoint. By default, duplicates are skipped;
|
|
240
|
+
pass `--overwrite-*` flags to replace existing entries.
|
|
241
|
+
"""
|
|
242
|
+
if not is_confirmed:
|
|
243
|
+
typer.confirm("This will modify rulesets, libraries, and seed files. Continue?", abort=True)
|
|
244
|
+
|
|
245
|
+
client = get_client(profile)
|
|
246
|
+
# `/api/import/v1/` expects a multipart `zip_archive` upload plus three
|
|
247
|
+
# optional boolean overwrite flags; `import_configuration` is not yet
|
|
248
|
+
# wrapped in datamasque-python, so we call `make_request` directly.
|
|
249
|
+
with file.open("rb") as zip_content:
|
|
250
|
+
response = client.make_request(
|
|
251
|
+
"POST",
|
|
252
|
+
"/api/import/v1/",
|
|
253
|
+
data={
|
|
254
|
+
"overwrite_rulesets": str(overwrite_rulesets).lower(),
|
|
255
|
+
"overwrite_libraries": str(overwrite_libraries).lower(),
|
|
256
|
+
"overwrite_seed_files": str(overwrite_seeds).lower(),
|
|
257
|
+
},
|
|
258
|
+
files=[
|
|
259
|
+
UploadFile(
|
|
260
|
+
field_name="zip_archive",
|
|
261
|
+
filename=file.name,
|
|
262
|
+
content=zip_content,
|
|
263
|
+
content_type="application/zip",
|
|
264
|
+
),
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
print_success("Bundle imported successfully.")
|
|
269
|
+
if response.content:
|
|
270
|
+
try:
|
|
271
|
+
summary = response.json()
|
|
272
|
+
except ValueError:
|
|
273
|
+
return
|
|
274
|
+
if isinstance(summary, dict):
|
|
275
|
+
render_output(summary, is_json=False, title="Import summary")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command("generate")
|
|
279
|
+
def generate_ruleset(
|
|
280
|
+
request_file: Path = typer.Option(
|
|
281
|
+
..., "--file", "-f", help="Path to JSON generation request", exists=True, readable=True
|
|
282
|
+
),
|
|
283
|
+
is_file_ruleset: bool = typer.Option(False, "--file-ruleset", help="Generate a file masking ruleset"),
|
|
284
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write generated YAML to file"),
|
|
285
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Generate a ruleset from a generation request (JSON).
|
|
288
|
+
|
|
289
|
+
The request JSON format matches the DataMasque API's /api/generate-ruleset/v2/ endpoint.
|
|
290
|
+
"""
|
|
291
|
+
client = get_client(profile)
|
|
292
|
+
generation_request = json.loads(request_file.read_text())
|
|
293
|
+
|
|
294
|
+
if is_file_ruleset:
|
|
295
|
+
yaml_content = client.generate_file_ruleset(generation_request)
|
|
296
|
+
else:
|
|
297
|
+
yaml_content = client.generate_ruleset(generation_request)
|
|
298
|
+
|
|
299
|
+
if output is not None:
|
|
300
|
+
output.write_text(yaml_content)
|
|
301
|
+
print_success(f"Generated ruleset written to {output}")
|
|
302
|
+
else:
|
|
303
|
+
typer.echo(yaml_content)
|