snowglobe-cli 0.1.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.
- snowglobe/__init__.py +6 -0
- snowglobe/__main__.py +3 -0
- snowglobe/cli/__init__.py +0 -0
- snowglobe/cli/access.py +197 -0
- snowglobe/cli/app.py +148 -0
- snowglobe/cli/context.py +48 -0
- snowglobe/cli/cost.py +291 -0
- snowglobe/cli/debug.py +265 -0
- snowglobe/cli/diff.py +34 -0
- snowglobe/cli/optimizer.py +91 -0
- snowglobe/cli/prompts.py +161 -0
- snowglobe/cli/report.py +91 -0
- snowglobe/cli/shell.py +1437 -0
- snowglobe/cli/shell_completer.py +128 -0
- snowglobe/collectors/access.py +882 -0
- snowglobe/collectors/query_history.py +46 -0
- snowglobe/collectors/query_profile.py +101 -0
- snowglobe/config/loader.py +42 -0
- snowglobe/core/access_service.py +721 -0
- snowglobe/core/cost_service.py +929 -0
- snowglobe/core/optimizer.py +92 -0
- snowglobe/core/query_service.py +48 -0
- snowglobe/core/report_service.py +110 -0
- snowglobe/core/risk_service.py +358 -0
- snowglobe/engines/access/__init__.py +0 -0
- snowglobe/engines/access/explainer.py +113 -0
- snowglobe/engines/access/resolver.py +199 -0
- snowglobe/engines/ai/cortex_optimizer.py +69 -0
- snowglobe/engines/optimizer/query_optimizer.py +326 -0
- snowglobe/graphs/__init__.py +0 -0
- snowglobe/graphs/role_graph.py +140 -0
- snowglobe/graphs/user_graph.py +64 -0
- snowglobe/models/__init__.py +0 -0
- snowglobe/models/access.py +65 -0
- snowglobe/models/access_path.py +15 -0
- snowglobe/models/object_ref.py +11 -0
- snowglobe/models/object_type.py +50 -0
- snowglobe/models/optimizer.py +15 -0
- snowglobe/models/privilege.py +78 -0
- snowglobe/models/query.py +59 -0
- snowglobe/output/__init__.py +0 -0
- snowglobe/output/cli.py +413 -0
- snowglobe/queries/__init__.py +0 -0
- snowglobe/queries/query_history.py +37 -0
- snowglobe/snowflake/connection.py +75 -0
- snowglobe/state/db.py +559 -0
- snowglobe/state/state.py +60 -0
- snowglobe/templates/report.md.j2 +55 -0
- snowglobe/tests/access_tests.py +5 -0
- snowglobe/tui/__init__.py +1 -0
- snowglobe/tui/__main__.py +3 -0
- snowglobe/tui/app.py +299 -0
- snowglobe/tui/screens/__init__.py +0 -0
- snowglobe/tui/screens/access.py +627 -0
- snowglobe/tui/screens/cost.py +831 -0
- snowglobe/tui/screens/home.py +222 -0
- snowglobe/tui/screens/refresh.py +222 -0
- snowglobe/tui/screens/reports.py +252 -0
- snowglobe/tui/screens/risk.py +417 -0
- snowglobe/tui/screens/tune.py +254 -0
- snowglobe/tui/widgets/__init__.py +0 -0
- snowglobe/tui/widgets/access_paths.py +63 -0
- snowglobe/tui/widgets/cache_badge.py +28 -0
- snowglobe/tui/widgets/header.py +21 -0
- snowglobe/tui/widgets/nav.py +32 -0
- snowglobe_cli-0.1.0.dist-info/METADATA +368 -0
- snowglobe_cli-0.1.0.dist-info/RECORD +71 -0
- snowglobe_cli-0.1.0.dist-info/WHEEL +5 -0
- snowglobe_cli-0.1.0.dist-info/entry_points.txt +2 -0
- snowglobe_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- snowglobe_cli-0.1.0.dist-info/top_level.txt +1 -0
snowglobe/cli/shell.py
ADDED
|
@@ -0,0 +1,1437 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from prompt_toolkit import PromptSession
|
|
4
|
+
from prompt_toolkit.completion import FuzzyCompleter
|
|
5
|
+
from snowglobe.cli.shell_completer import SnowglobeCompleter
|
|
6
|
+
from snowglobe.cli.context import SnowglobeContext
|
|
7
|
+
from snowglobe.core.access_service import AccessService
|
|
8
|
+
from snowglobe.core.optimizer import QueryOptimizerService
|
|
9
|
+
from snowglobe.output import cli
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def start_shell(ctx: SnowglobeContext):
|
|
13
|
+
"""Start the interactive Snowglobe shell."""
|
|
14
|
+
|
|
15
|
+
# Preload access graphs for completions and stateful queries
|
|
16
|
+
access_service = AccessService(ctx)
|
|
17
|
+
ctx.user_graph, ctx.role_graph, ctx.object_index = access_service.get_graphs()
|
|
18
|
+
|
|
19
|
+
session = PromptSession(
|
|
20
|
+
completer=FuzzyCompleter(SnowglobeCompleter(ctx))
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
typer.echo("Snowglobe Interactive Shell")
|
|
24
|
+
typer.echo("Type 'check' to get started, or 'help' for all commands.\n")
|
|
25
|
+
|
|
26
|
+
while True:
|
|
27
|
+
try:
|
|
28
|
+
active = ctx.target_role or ctx.username or ""
|
|
29
|
+
prompt_label = f"snowglobe[{active}]> " if active else "snowglobe> "
|
|
30
|
+
text = session.prompt(prompt_label).strip()
|
|
31
|
+
|
|
32
|
+
if not text:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
if text in {"exit", "quit"}:
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
_dispatch(text, ctx)
|
|
39
|
+
|
|
40
|
+
except KeyboardInterrupt:
|
|
41
|
+
continue
|
|
42
|
+
except EOFError:
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dispatch(text: str, ctx: SnowglobeContext):
|
|
47
|
+
"""Route shell input to the appropriate handler."""
|
|
48
|
+
parts = text.split()
|
|
49
|
+
cmd = parts[0]
|
|
50
|
+
args = parts[1:]
|
|
51
|
+
|
|
52
|
+
handlers = {
|
|
53
|
+
"check": _cmd_check,
|
|
54
|
+
"roles": _cmd_roles,
|
|
55
|
+
"members": _cmd_members,
|
|
56
|
+
"path": _cmd_path,
|
|
57
|
+
"escalation": _cmd_escalation,
|
|
58
|
+
"scan": _cmd_scan,
|
|
59
|
+
"use": _cmd_use,
|
|
60
|
+
"set": _cmd_set,
|
|
61
|
+
"access": _cmd_access,
|
|
62
|
+
"whoaccess": _cmd_whoaccess,
|
|
63
|
+
"create": _cmd_create,
|
|
64
|
+
"cost": _cmd_cost,
|
|
65
|
+
"optimize": _cmd_optimize,
|
|
66
|
+
"drift": _cmd_drift,
|
|
67
|
+
"unused": _cmd_unused,
|
|
68
|
+
"report": _cmd_report,
|
|
69
|
+
"refresh": _cmd_refresh,
|
|
70
|
+
"status": _cmd_status,
|
|
71
|
+
"debug": _cmd_debug,
|
|
72
|
+
"help": _cmd_help,
|
|
73
|
+
"?": _cmd_help,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
handler = handlers.get(cmd)
|
|
77
|
+
if handler:
|
|
78
|
+
handler(ctx, args)
|
|
79
|
+
else:
|
|
80
|
+
typer.secho(f"Unknown command: {cmd}. Type 'help' for available commands.", fg=typer.colors.YELLOW)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- Shell commands ---
|
|
84
|
+
|
|
85
|
+
def _cmd_check(ctx: SnowglobeContext, args: list):
|
|
86
|
+
"""Guided wizard for access and privilege checks."""
|
|
87
|
+
from prompt_toolkit import PromptSession
|
|
88
|
+
from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
|
|
89
|
+
|
|
90
|
+
typer.echo("")
|
|
91
|
+
typer.secho("What would you like to check?", fg=typer.colors.CYAN, bold=True)
|
|
92
|
+
typer.echo(" [1] Can a user/role access an object?")
|
|
93
|
+
typer.echo(" [2] Who can access an object?")
|
|
94
|
+
typer.echo(" [3] Where can a role create objects?")
|
|
95
|
+
typer.echo(" [4] What roles does a user have?")
|
|
96
|
+
typer.echo(" [5] Who has a specific role?")
|
|
97
|
+
typer.echo(" [6] Does one role inherit from another?")
|
|
98
|
+
typer.echo(" [7] Can a role escalate to admin privileges?")
|
|
99
|
+
typer.echo("")
|
|
100
|
+
|
|
101
|
+
session = PromptSession()
|
|
102
|
+
choice = session.prompt(
|
|
103
|
+
"Choice (1-7): ",
|
|
104
|
+
completer=WordCompleter(["1", "2", "3", "4", "5", "6", "7"]),
|
|
105
|
+
).strip()
|
|
106
|
+
|
|
107
|
+
if choice == "1":
|
|
108
|
+
# Clear object state so user gets prompted fresh
|
|
109
|
+
ctx.object_type = None
|
|
110
|
+
ctx.object_name = None
|
|
111
|
+
ctx.privilege = None
|
|
112
|
+
_cmd_access(ctx, args)
|
|
113
|
+
elif choice == "2":
|
|
114
|
+
_cmd_whoaccess(ctx, args)
|
|
115
|
+
elif choice == "3":
|
|
116
|
+
_cmd_create(ctx, args)
|
|
117
|
+
elif choice == "4":
|
|
118
|
+
_cmd_roles(ctx, args)
|
|
119
|
+
elif choice == "5":
|
|
120
|
+
_cmd_members(ctx, args)
|
|
121
|
+
elif choice == "6":
|
|
122
|
+
_cmd_path(ctx, args)
|
|
123
|
+
elif choice == "7":
|
|
124
|
+
_cmd_escalation(ctx, args)
|
|
125
|
+
else:
|
|
126
|
+
typer.secho("Invalid choice. Please enter 1-7.", fg=typer.colors.YELLOW)
|
|
127
|
+
|
|
128
|
+
def _cmd_use(ctx: SnowglobeContext, args: list):
|
|
129
|
+
"""Set the active user or role for subsequent queries."""
|
|
130
|
+
if len(args) < 2:
|
|
131
|
+
typer.echo("Usage: use role <name> | use user <name>")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
kind, name = args[0], args[1]
|
|
135
|
+
|
|
136
|
+
if kind == "role":
|
|
137
|
+
ctx.target_role = name
|
|
138
|
+
ctx.username = None
|
|
139
|
+
typer.secho(f"Using role: {name}", fg=typer.colors.GREEN)
|
|
140
|
+
elif kind == "user":
|
|
141
|
+
ctx.username = name
|
|
142
|
+
ctx.target_role = None
|
|
143
|
+
typer.secho(f"Using user: {name}", fg=typer.colors.GREEN)
|
|
144
|
+
else:
|
|
145
|
+
typer.echo("Usage: use role <name> | use user <name>")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _cmd_set(ctx: SnowglobeContext, args: list):
|
|
149
|
+
"""Set a working state field."""
|
|
150
|
+
if len(args) < 2:
|
|
151
|
+
typer.echo("Usage: set <field> <value>")
|
|
152
|
+
typer.echo("Fields: object_type, object_name, privilege")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
field, value = args[0], " ".join(args[1:])
|
|
156
|
+
valid_fields = {"object_type", "object_name", "privilege"}
|
|
157
|
+
|
|
158
|
+
if field not in valid_fields:
|
|
159
|
+
typer.secho(f"Unknown field: {field}. Valid: {', '.join(valid_fields)}", fg=typer.colors.YELLOW)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
setattr(ctx, field, value)
|
|
163
|
+
typer.secho(f"{field} = {value}", fg=typer.colors.GREEN)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cmd_access(ctx: SnowglobeContext, args: list):
|
|
167
|
+
"""Run access check using current shell state. Prompts for missing fields."""
|
|
168
|
+
from snowglobe.cli.prompts import resolve_access_inputs
|
|
169
|
+
|
|
170
|
+
# Resolve missing inputs with interactive prompts + fuzzy completion
|
|
171
|
+
resolved = resolve_access_inputs(
|
|
172
|
+
username=ctx.username,
|
|
173
|
+
role=ctx.target_role,
|
|
174
|
+
object_type=ctx.object_type,
|
|
175
|
+
object_name=ctx.object_name,
|
|
176
|
+
privilege=ctx.privilege,
|
|
177
|
+
user_graph=ctx.user_graph,
|
|
178
|
+
role_graph=ctx.role_graph,
|
|
179
|
+
grants=[],
|
|
180
|
+
object_index=ctx.object_index,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Update context state with resolved values for next time
|
|
184
|
+
ctx.username = resolved.get("username")
|
|
185
|
+
ctx.target_role = resolved.get("role")
|
|
186
|
+
ctx.object_type = resolved.get("object_type")
|
|
187
|
+
ctx.object_name = resolved.get("object_name")
|
|
188
|
+
ctx.privilege = resolved.get("privilege")
|
|
189
|
+
|
|
190
|
+
access_service = AccessService(ctx)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
result = access_service.inspect_access(
|
|
194
|
+
username=resolved["username"],
|
|
195
|
+
role=resolved["role"],
|
|
196
|
+
object_type=resolved["object_type"],
|
|
197
|
+
object_name=resolved["object_name"],
|
|
198
|
+
privilege=resolved["privilege"],
|
|
199
|
+
ignore_excluded_roles=False,
|
|
200
|
+
refresh_state=False,
|
|
201
|
+
)
|
|
202
|
+
typer.echo(cli.format_access_text(result))
|
|
203
|
+
except SystemExit:
|
|
204
|
+
pass
|
|
205
|
+
except Exception as e:
|
|
206
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cmd_whoaccess(ctx: SnowglobeContext, args: list):
|
|
210
|
+
"""Reverse lookup: who can access this object? Prompts for object details."""
|
|
211
|
+
from prompt_toolkit import PromptSession
|
|
212
|
+
from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
|
|
213
|
+
from snowglobe.models.object_type import ObjectType
|
|
214
|
+
|
|
215
|
+
session = PromptSession()
|
|
216
|
+
|
|
217
|
+
# Parse args: whoaccess [--privilege PRIV]
|
|
218
|
+
privilege = None
|
|
219
|
+
for i, arg in enumerate(args):
|
|
220
|
+
if arg == "--privilege" and i + 1 < len(args):
|
|
221
|
+
privilege = args[i + 1].upper()
|
|
222
|
+
|
|
223
|
+
# Prompt for object type
|
|
224
|
+
object_type = ctx.object_type
|
|
225
|
+
if not object_type:
|
|
226
|
+
items = [ot.value for ot in ObjectType]
|
|
227
|
+
word = WordCompleter(items, ignore_case=True)
|
|
228
|
+
fuzzy = FuzzyCompleter(word)
|
|
229
|
+
object_type = session.prompt(
|
|
230
|
+
"Object type: ",
|
|
231
|
+
completer=fuzzy,
|
|
232
|
+
complete_while_typing=True,
|
|
233
|
+
).strip().upper()
|
|
234
|
+
|
|
235
|
+
if not object_type:
|
|
236
|
+
typer.secho("Object type required.", fg=typer.colors.RED)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
# Prompt for object name with FQN completions
|
|
240
|
+
object_name = ctx.object_name
|
|
241
|
+
if not object_name:
|
|
242
|
+
obj_items = []
|
|
243
|
+
if ctx.object_index and object_type in ctx.object_index:
|
|
244
|
+
obj_items = ctx.object_index[object_type]
|
|
245
|
+
word = WordCompleter(obj_items, ignore_case=True, sentence=True)
|
|
246
|
+
fuzzy = FuzzyCompleter(word)
|
|
247
|
+
object_name = session.prompt(
|
|
248
|
+
"Object name (FQN): ",
|
|
249
|
+
completer=fuzzy,
|
|
250
|
+
complete_while_typing=True,
|
|
251
|
+
).strip()
|
|
252
|
+
|
|
253
|
+
if not object_name:
|
|
254
|
+
typer.secho("Object name required.", fg=typer.colors.RED)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Run the reverse lookup
|
|
258
|
+
access_service = AccessService(ctx)
|
|
259
|
+
try:
|
|
260
|
+
result = access_service.inspect_reverse(
|
|
261
|
+
object_type=object_type,
|
|
262
|
+
object_name=object_name,
|
|
263
|
+
privilege=privilege,
|
|
264
|
+
)
|
|
265
|
+
typer.echo(cli.format_reverse_text(result))
|
|
266
|
+
|
|
267
|
+
# Drill-down: select a role to see its full grants
|
|
268
|
+
privileges_result = result.get("privileges", {})
|
|
269
|
+
if privileges_result:
|
|
270
|
+
all_roles = set()
|
|
271
|
+
for priv_info in privileges_result.values():
|
|
272
|
+
all_roles.update(priv_info.get("direct_roles", []))
|
|
273
|
+
all_roles = sorted(all_roles)
|
|
274
|
+
if all_roles:
|
|
275
|
+
selected = _drill_down_prompt(all_roles, "Inspect role's grants")
|
|
276
|
+
if selected:
|
|
277
|
+
typer.echo(f"\n Fetching grants for {selected}...")
|
|
278
|
+
grant_rows = access_service.db.query_grants_by_grantees({selected})
|
|
279
|
+
if grant_rows:
|
|
280
|
+
import pandas as pd_local
|
|
281
|
+
grant_df = pd_local.DataFrame(grant_rows)[["privilege", "granted_on", "fqn"]]
|
|
282
|
+
grant_df.columns = ["PRIVILEGE", "OBJECT_TYPE", "OBJECT"]
|
|
283
|
+
cli.print_table(grant_df, title=f"Grants for {selected}")
|
|
284
|
+
else:
|
|
285
|
+
typer.echo(f" No grants found for {selected}.")
|
|
286
|
+
except Exception as e:
|
|
287
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _cmd_create(ctx: SnowglobeContext, args: list):
|
|
291
|
+
"""Check CREATE privileges for the active role/user."""
|
|
292
|
+
from prompt_toolkit import PromptSession
|
|
293
|
+
from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
|
|
294
|
+
|
|
295
|
+
CREATE_PRIVILEGES = [
|
|
296
|
+
"CREATE TABLE", "CREATE VIEW", "CREATE SCHEMA", "CREATE DATABASE",
|
|
297
|
+
"CREATE DYNAMIC TABLE", "CREATE STREAMLIT", "CREATE NOTEBOOK",
|
|
298
|
+
"CREATE STAGE", "CREATE STREAM", "CREATE PIPE", "CREATE TASK",
|
|
299
|
+
"CREATE FUNCTION", "CREATE PROCEDURE", "CREATE ALERT",
|
|
300
|
+
"CREATE FILE FORMAT", "CREATE SEQUENCE", "CREATE TAG",
|
|
301
|
+
"CREATE SECRET", "CREATE WAREHOUSE", "CREATE ROLE",
|
|
302
|
+
"CREATE MATERIALIZED VIEW", "CREATE EXTERNAL TABLE",
|
|
303
|
+
"CREATE ICEBERG TABLE", "CREATE MODEL", "CREATE AGENT",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
session = PromptSession()
|
|
307
|
+
|
|
308
|
+
# Resolve role/user — use current context or prompt
|
|
309
|
+
role = ctx.target_role
|
|
310
|
+
username = ctx.username
|
|
311
|
+
|
|
312
|
+
if not role and not username:
|
|
313
|
+
# Prompt for inspect type
|
|
314
|
+
items = list(ctx.role_graph.roles.keys()) if ctx.role_graph else []
|
|
315
|
+
word = WordCompleter(items, ignore_case=True)
|
|
316
|
+
fuzzy = FuzzyCompleter(word)
|
|
317
|
+
role = session.prompt(
|
|
318
|
+
"Role: ",
|
|
319
|
+
completer=fuzzy,
|
|
320
|
+
complete_while_typing=True,
|
|
321
|
+
).strip()
|
|
322
|
+
if not role:
|
|
323
|
+
typer.secho("Role required.", fg=typer.colors.RED)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
# Prompt for CREATE privilege
|
|
327
|
+
privilege = None
|
|
328
|
+
if args:
|
|
329
|
+
privilege = " ".join(args).upper()
|
|
330
|
+
|
|
331
|
+
if not privilege:
|
|
332
|
+
word = WordCompleter(CREATE_PRIVILEGES, ignore_case=True)
|
|
333
|
+
fuzzy = FuzzyCompleter(word)
|
|
334
|
+
privilege = session.prompt(
|
|
335
|
+
"CREATE privilege: ",
|
|
336
|
+
completer=fuzzy,
|
|
337
|
+
complete_while_typing=True,
|
|
338
|
+
).strip().upper()
|
|
339
|
+
|
|
340
|
+
if not privilege:
|
|
341
|
+
typer.secho("Privilege required.", fg=typer.colors.RED)
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# Optional scope — offer databases and schemas from the object index
|
|
345
|
+
scope_items = []
|
|
346
|
+
if ctx.object_index:
|
|
347
|
+
scope_items.extend(ctx.object_index.get("DATABASE", []))
|
|
348
|
+
scope_items.extend(ctx.object_index.get("SCHEMA", []))
|
|
349
|
+
scope_word = WordCompleter(sorted(set(scope_items)), ignore_case=True)
|
|
350
|
+
scope_fuzzy = FuzzyCompleter(scope_word)
|
|
351
|
+
scope = session.prompt(
|
|
352
|
+
"Scope (DB or DB.SCHEMA, blank for all): ",
|
|
353
|
+
completer=scope_fuzzy,
|
|
354
|
+
complete_while_typing=True,
|
|
355
|
+
).strip() or None
|
|
356
|
+
|
|
357
|
+
# Run the check
|
|
358
|
+
access_service = AccessService(ctx)
|
|
359
|
+
try:
|
|
360
|
+
result = access_service.inspect_create(
|
|
361
|
+
username=username,
|
|
362
|
+
role=role,
|
|
363
|
+
privilege=privilege,
|
|
364
|
+
scope=scope,
|
|
365
|
+
)
|
|
366
|
+
typer.echo(cli.format_create_text(result))
|
|
367
|
+
except SystemExit:
|
|
368
|
+
pass
|
|
369
|
+
except Exception as e:
|
|
370
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _cmd_roles(ctx: SnowglobeContext, args: list):
|
|
374
|
+
"""Show all roles a user has (direct + inherited)."""
|
|
375
|
+
from prompt_toolkit import PromptSession
|
|
376
|
+
from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
|
|
377
|
+
|
|
378
|
+
session = PromptSession()
|
|
379
|
+
|
|
380
|
+
# Prompt for username
|
|
381
|
+
username = args[0] if args else None
|
|
382
|
+
if not username:
|
|
383
|
+
items = list(ctx.user_graph.assigned_roles.keys())
|
|
384
|
+
word = WordCompleter(items, ignore_case=True)
|
|
385
|
+
fuzzy = FuzzyCompleter(word)
|
|
386
|
+
username = session.prompt(
|
|
387
|
+
"User: ",
|
|
388
|
+
completer=fuzzy,
|
|
389
|
+
complete_while_typing=True,
|
|
390
|
+
).strip()
|
|
391
|
+
|
|
392
|
+
if not username:
|
|
393
|
+
typer.secho("Username required.", fg=typer.colors.RED)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
if username not in ctx.user_graph.assigned_roles:
|
|
397
|
+
typer.secho(f"User '{username}' not found.", fg=typer.colors.RED)
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
# Direct roles
|
|
401
|
+
direct_roles, excluded = ctx.user_graph.roles_of(username)
|
|
402
|
+
effective = ctx.user_graph.effective_roles(username, ctx.role_graph)
|
|
403
|
+
|
|
404
|
+
typer.echo("")
|
|
405
|
+
typer.secho(f"Roles for user: {username}", fg=typer.colors.CYAN, bold=True)
|
|
406
|
+
typer.echo("")
|
|
407
|
+
|
|
408
|
+
typer.secho(f" Direct roles ({len(direct_roles)}):", fg=typer.colors.GREEN)
|
|
409
|
+
for r in sorted(direct_roles):
|
|
410
|
+
typer.echo(f" {r}")
|
|
411
|
+
|
|
412
|
+
if excluded:
|
|
413
|
+
typer.secho(f"\n Excluded roles ({len(excluded)}):", fg=typer.colors.YELLOW)
|
|
414
|
+
for r in sorted(excluded):
|
|
415
|
+
typer.echo(f" {r}")
|
|
416
|
+
|
|
417
|
+
inherited = effective - set(direct_roles) - set(excluded)
|
|
418
|
+
if inherited:
|
|
419
|
+
typer.secho(f"\n Inherited roles ({len(inherited)}):", fg=typer.colors.GREEN)
|
|
420
|
+
for r in sorted(inherited)[:30]:
|
|
421
|
+
typer.echo(f" {r}")
|
|
422
|
+
if len(inherited) > 30:
|
|
423
|
+
typer.echo(f" ... and {len(inherited) - 30} more")
|
|
424
|
+
|
|
425
|
+
typer.echo(f"\n Total effective: {len(effective)} roles")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _cmd_members(ctx: SnowglobeContext, args: list):
|
|
429
|
+
"""Show all users who have a specific role (direct assignment)."""
|
|
430
|
+
from prompt_toolkit import PromptSession
|
|
431
|
+
from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
|
|
432
|
+
|
|
433
|
+
session = PromptSession()
|
|
434
|
+
|
|
435
|
+
# Prompt for role
|
|
436
|
+
role = args[0] if args else None
|
|
437
|
+
if not role:
|
|
438
|
+
items = list(ctx.role_graph.roles.keys())
|
|
439
|
+
word = WordCompleter(items, ignore_case=True)
|
|
440
|
+
fuzzy = FuzzyCompleter(word)
|
|
441
|
+
role = session.prompt(
|
|
442
|
+
"Role: ",
|
|
443
|
+
completer=fuzzy,
|
|
444
|
+
complete_while_typing=True,
|
|
445
|
+
).strip()
|
|
446
|
+
|
|
447
|
+
if not role:
|
|
448
|
+
typer.secho("Role required.", fg=typer.colors.RED)
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
# Find users with this role (directly assigned)
|
|
452
|
+
direct_users = []
|
|
453
|
+
for user, assigned in ctx.user_graph.assigned_roles.items():
|
|
454
|
+
if role in assigned:
|
|
455
|
+
direct_users.append(user)
|
|
456
|
+
|
|
457
|
+
# Find users who inherit this role
|
|
458
|
+
inherited_users = []
|
|
459
|
+
for user, assigned in ctx.user_graph.assigned_roles.items():
|
|
460
|
+
if user in direct_users:
|
|
461
|
+
continue
|
|
462
|
+
effective = set(assigned)
|
|
463
|
+
for r in assigned:
|
|
464
|
+
effective |= ctx.role_graph.all_ancestors(r)
|
|
465
|
+
if role in effective:
|
|
466
|
+
inherited_users.append(user)
|
|
467
|
+
|
|
468
|
+
typer.echo("")
|
|
469
|
+
typer.secho(f"Users with role: {role}", fg=typer.colors.CYAN, bold=True)
|
|
470
|
+
typer.echo("")
|
|
471
|
+
|
|
472
|
+
if direct_users:
|
|
473
|
+
typer.secho(f" Directly assigned ({len(direct_users)}):", fg=typer.colors.GREEN)
|
|
474
|
+
for u in sorted(direct_users):
|
|
475
|
+
typer.echo(f" {u}")
|
|
476
|
+
else:
|
|
477
|
+
typer.echo(" No users directly assigned.")
|
|
478
|
+
|
|
479
|
+
if inherited_users:
|
|
480
|
+
typer.secho(f"\n Inherited ({len(inherited_users)}):", fg=typer.colors.GREEN)
|
|
481
|
+
for u in sorted(inherited_users)[:30]:
|
|
482
|
+
typer.echo(f" {u}")
|
|
483
|
+
if len(inherited_users) > 30:
|
|
484
|
+
typer.echo(f" ... and {len(inherited_users) - 30} more")
|
|
485
|
+
|
|
486
|
+
total = len(direct_users) + len(inherited_users)
|
|
487
|
+
typer.echo(f"\n Total: {total} users")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _cmd_path(ctx: SnowglobeContext, args: list):
|
|
491
|
+
"""Check if one role inherits from another and show the path."""
|
|
492
|
+
from prompt_toolkit import PromptSession
|
|
493
|
+
from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
|
|
494
|
+
|
|
495
|
+
session = PromptSession()
|
|
496
|
+
items = list(ctx.role_graph.roles.keys())
|
|
497
|
+
word = WordCompleter(items, ignore_case=True)
|
|
498
|
+
fuzzy = FuzzyCompleter(word)
|
|
499
|
+
|
|
500
|
+
# Prompt for source role
|
|
501
|
+
from_role = args[0] if len(args) > 0 else None
|
|
502
|
+
if not from_role:
|
|
503
|
+
from_role = session.prompt(
|
|
504
|
+
"From role: ",
|
|
505
|
+
completer=fuzzy,
|
|
506
|
+
complete_while_typing=True,
|
|
507
|
+
).strip()
|
|
508
|
+
|
|
509
|
+
if not from_role:
|
|
510
|
+
typer.secho("From role required.", fg=typer.colors.RED)
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
# Prompt for target role
|
|
514
|
+
to_role = args[1] if len(args) > 1 else None
|
|
515
|
+
if not to_role:
|
|
516
|
+
to_role = session.prompt(
|
|
517
|
+
"To role: ",
|
|
518
|
+
completer=fuzzy,
|
|
519
|
+
complete_while_typing=True,
|
|
520
|
+
).strip()
|
|
521
|
+
|
|
522
|
+
if not to_role:
|
|
523
|
+
typer.secho("To role required.", fg=typer.colors.RED)
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
# Check if to_role is in from_role's ancestors
|
|
527
|
+
ancestors = ctx.role_graph.all_ancestors(from_role)
|
|
528
|
+
if to_role not in ancestors:
|
|
529
|
+
typer.echo("")
|
|
530
|
+
typer.secho(f" {from_role} does NOT inherit from {to_role}", fg=typer.colors.RED)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
# Find all paths
|
|
534
|
+
paths = ctx.role_graph.all_paths(from_role, to_role)
|
|
535
|
+
|
|
536
|
+
typer.echo("")
|
|
537
|
+
typer.secho(f" {from_role} DOES inherit from {to_role}", fg=typer.colors.GREEN, bold=True)
|
|
538
|
+
typer.echo("")
|
|
539
|
+
|
|
540
|
+
if paths:
|
|
541
|
+
typer.secho(f" Inheritance paths ({len(paths)}):", fg=typer.colors.CYAN)
|
|
542
|
+
for i, path in enumerate(paths[:10], 1):
|
|
543
|
+
typer.echo(f" Path {i}: {' -> '.join(path)}")
|
|
544
|
+
if len(paths) > 10:
|
|
545
|
+
typer.echo(f" ... and {len(paths) - 10} more paths")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _cmd_escalation(ctx: SnowglobeContext, args: list):
|
|
549
|
+
"""Check if a role can reach admin privileges via inheritance."""
|
|
550
|
+
from prompt_toolkit import PromptSession
|
|
551
|
+
from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
|
|
552
|
+
from snowglobe.core.risk_service import RiskService
|
|
553
|
+
|
|
554
|
+
session = PromptSession()
|
|
555
|
+
|
|
556
|
+
role = args[0] if args else None
|
|
557
|
+
if not role:
|
|
558
|
+
items = list(ctx.role_graph.roles.keys())
|
|
559
|
+
word = WordCompleter(items, ignore_case=True)
|
|
560
|
+
fuzzy = FuzzyCompleter(word)
|
|
561
|
+
role = session.prompt(
|
|
562
|
+
"Role to check: ",
|
|
563
|
+
completer=fuzzy,
|
|
564
|
+
complete_while_typing=True,
|
|
565
|
+
).strip()
|
|
566
|
+
|
|
567
|
+
if not role:
|
|
568
|
+
typer.secho("Role required.", fg=typer.colors.RED)
|
|
569
|
+
return
|
|
570
|
+
|
|
571
|
+
result = RiskService(ctx).check_escalation(role, ctx.role_graph, ctx.user_graph)
|
|
572
|
+
|
|
573
|
+
typer.echo("")
|
|
574
|
+
|
|
575
|
+
if result["is_privileged"]:
|
|
576
|
+
typer.secho(f" {role} IS a privileged role.", fg=typer.colors.YELLOW, bold=True)
|
|
577
|
+
return
|
|
578
|
+
|
|
579
|
+
reachable = result["reachable_targets"]
|
|
580
|
+
if not reachable:
|
|
581
|
+
typer.secho(f" {role} has NO escalation path to admin roles.", fg=typer.colors.GREEN, bold=True)
|
|
582
|
+
typer.echo(" This role cannot reach ACCOUNTADMIN, SYSADMIN, SECURITYADMIN, or any role with MANAGE GRANTS.")
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
typer.secho(f" {role} can reach {len(reachable)} privileged role(s):", fg=typer.colors.RED, bold=True)
|
|
586
|
+
typer.echo("")
|
|
587
|
+
|
|
588
|
+
for entry in reachable:
|
|
589
|
+
hops = entry["hops"]
|
|
590
|
+
color = typer.colors.RED if hops <= 3 else typer.colors.YELLOW
|
|
591
|
+
typer.secho(f" → {entry['target']} ({hops} hops)", fg=color)
|
|
592
|
+
typer.echo(f" {' → '.join(entry['path'])}")
|
|
593
|
+
typer.echo("")
|
|
594
|
+
|
|
595
|
+
direct_users = result["affected_users"]["direct"]
|
|
596
|
+
inherited_users = result["affected_users"]["inherited"]
|
|
597
|
+
total_users = len(direct_users) + len(inherited_users)
|
|
598
|
+
if total_users > 0:
|
|
599
|
+
typer.secho(f" Users who can escalate via this role ({total_users}):", fg=typer.colors.CYAN)
|
|
600
|
+
for u in direct_users:
|
|
601
|
+
typer.echo(f" {u} (directly assigned)")
|
|
602
|
+
for u in inherited_users[:20]:
|
|
603
|
+
typer.echo(f" {u} (inherited)")
|
|
604
|
+
if len(inherited_users) > 20:
|
|
605
|
+
typer.echo(f" ... and {len(inherited_users) - 20} more")
|
|
606
|
+
else:
|
|
607
|
+
typer.echo(" No users currently hold this role.")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _cmd_scan(ctx: SnowglobeContext, args: list):
|
|
611
|
+
"""Scan all roles for privilege escalation paths to admin roles."""
|
|
612
|
+
from prompt_toolkit import PromptSession
|
|
613
|
+
from prompt_toolkit.completion import WordCompleter
|
|
614
|
+
from snowglobe.core.risk_service import RiskService
|
|
615
|
+
|
|
616
|
+
# Parse flags
|
|
617
|
+
csv_path = None
|
|
618
|
+
json_path = None
|
|
619
|
+
for i, a in enumerate(args):
|
|
620
|
+
if a == "--csv" and i + 1 < len(args):
|
|
621
|
+
csv_path = args[i + 1]
|
|
622
|
+
elif a == "--json" and i + 1 < len(args):
|
|
623
|
+
json_path = args[i + 1]
|
|
624
|
+
|
|
625
|
+
risk_service = RiskService(ctx)
|
|
626
|
+
|
|
627
|
+
n_roles = len(ctx.role_graph.all_roles())
|
|
628
|
+
typer.echo("")
|
|
629
|
+
typer.secho("Privilege Escalation Scan", fg=typer.colors.CYAN, bold=True)
|
|
630
|
+
typer.echo("─" * 50)
|
|
631
|
+
typer.echo(f"Scanning {n_roles} roles...")
|
|
632
|
+
typer.echo("")
|
|
633
|
+
|
|
634
|
+
result = risk_service.run_scan(ctx.role_graph, ctx.user_graph)
|
|
635
|
+
|
|
636
|
+
# --- Diff with previous scan ---
|
|
637
|
+
diff = result["diff"]
|
|
638
|
+
if diff is not None:
|
|
639
|
+
if diff["new"] or diff["resolved"]:
|
|
640
|
+
parts = []
|
|
641
|
+
if diff["new"]:
|
|
642
|
+
parts.append(f"+{len(diff['new'])} new")
|
|
643
|
+
if diff["resolved"]:
|
|
644
|
+
parts.append(f"-{len(diff['resolved'])} resolved")
|
|
645
|
+
typer.secho(f" Changes since last scan: {', '.join(parts)}", fg=typer.colors.MAGENTA, bold=True)
|
|
646
|
+
if diff.get("new_details"):
|
|
647
|
+
typer.echo("")
|
|
648
|
+
typer.secho(" NEW risks:", fg=typer.colors.RED)
|
|
649
|
+
for e in diff["new_details"][:5]:
|
|
650
|
+
typer.echo(f" {e['role']} → {e['target']} (score={e['risk_score']})")
|
|
651
|
+
typer.echo("")
|
|
652
|
+
else:
|
|
653
|
+
typer.secho(" No changes since last scan.", fg=typer.colors.GREEN)
|
|
654
|
+
typer.echo("")
|
|
655
|
+
|
|
656
|
+
# --- Direct privilege risks ---
|
|
657
|
+
dangerous_grants = result["dangerous_grants"]
|
|
658
|
+
if dangerous_grants:
|
|
659
|
+
typer.secho(f"Direct Privilege Risks ({len(dangerous_grants)} grants):", fg=typer.colors.RED, bold=True)
|
|
660
|
+
typer.echo("")
|
|
661
|
+
for g in dangerous_grants[:15]:
|
|
662
|
+
typer.echo(f" {g['ROLE']:<40} {g['PRIVILEGE']:<20} {g['OBJECT_TYPE']:<12} {g['OBJECT']}")
|
|
663
|
+
if len(dangerous_grants) > 15:
|
|
664
|
+
typer.echo(f" ... and {len(dangerous_grants) - 15} more")
|
|
665
|
+
typer.echo("")
|
|
666
|
+
|
|
667
|
+
# --- Flagged roles by risk score ---
|
|
668
|
+
high_risk = result["high_risk"]
|
|
669
|
+
medium_risk = result["medium_risk"]
|
|
670
|
+
|
|
671
|
+
if high_risk:
|
|
672
|
+
typer.secho(f"HIGH RISK — {len(high_risk)} roles (score ≥ 10):", fg=typer.colors.RED, bold=True)
|
|
673
|
+
typer.echo("")
|
|
674
|
+
for i, entry in enumerate(high_risk, 1):
|
|
675
|
+
typer.secho(
|
|
676
|
+
f" [{i}] {entry['role']} → {entry['target']} "
|
|
677
|
+
f"(score={entry['risk_score']}, {entry['hops']} hops, {entry['user_count']} users)",
|
|
678
|
+
fg=typer.colors.RED,
|
|
679
|
+
)
|
|
680
|
+
typer.echo(f" {' → '.join(entry['path'])}")
|
|
681
|
+
typer.echo("")
|
|
682
|
+
|
|
683
|
+
if medium_risk:
|
|
684
|
+
offset = len(high_risk)
|
|
685
|
+
typer.secho(f"MEDIUM — {len(medium_risk)} roles (score 5-10):", fg=typer.colors.YELLOW)
|
|
686
|
+
typer.echo("")
|
|
687
|
+
for i, entry in enumerate(medium_risk, offset + 1):
|
|
688
|
+
typer.echo(
|
|
689
|
+
f" [{i}] {entry['role']} → {entry['target']} "
|
|
690
|
+
f"(score={entry['risk_score']}, {entry['hops']} hops, {entry['user_count']} users)"
|
|
691
|
+
)
|
|
692
|
+
typer.echo("")
|
|
693
|
+
|
|
694
|
+
# --- Dormant users ---
|
|
695
|
+
dormant_users = result["dormant_users"]
|
|
696
|
+
if dormant_users:
|
|
697
|
+
seen = set()
|
|
698
|
+
unique_dormant = []
|
|
699
|
+
for d in sorted(dormant_users, key=lambda x: x["risk_score"], reverse=True):
|
|
700
|
+
if d["user"] not in seen:
|
|
701
|
+
seen.add(d["user"])
|
|
702
|
+
unique_dormant.append(d)
|
|
703
|
+
|
|
704
|
+
typer.secho(f"Dormant Escalation Risks ({len(unique_dormant)} users inactive >90 days):", fg=typer.colors.MAGENTA, bold=True)
|
|
705
|
+
typer.echo("")
|
|
706
|
+
for d in unique_dormant[:10]:
|
|
707
|
+
typer.echo(f" {d['user']:<40} via {d['role']} (score={d['risk_score']})")
|
|
708
|
+
if len(unique_dormant) > 10:
|
|
709
|
+
typer.echo(f" ... and {len(unique_dormant) - 10} more")
|
|
710
|
+
typer.echo("")
|
|
711
|
+
|
|
712
|
+
# --- Summary ---
|
|
713
|
+
s = result["summary"]
|
|
714
|
+
typer.secho("Summary:", fg=typer.colors.CYAN)
|
|
715
|
+
typer.echo(f" Privileged roles (admin): {s['admin_roles']}")
|
|
716
|
+
typer.echo(f" High risk (score ≥ 10): {s['high_risk']}")
|
|
717
|
+
typer.echo(f" Medium risk (score 5-10): {s['medium_risk']}")
|
|
718
|
+
typer.echo(f" Low risk (score < 5): {s['low_risk']}")
|
|
719
|
+
typer.echo(f" No escalation path: {s['no_path']}")
|
|
720
|
+
typer.echo(f" Direct privilege risks: {s['direct_privilege_risks']}")
|
|
721
|
+
if s["dormant_with_risk"]:
|
|
722
|
+
typer.echo(f" Dormant users with risk: {s['dormant_with_risk']}")
|
|
723
|
+
typer.echo(f" Total roles scanned: {s['total_scanned']}")
|
|
724
|
+
|
|
725
|
+
# --- Export ---
|
|
726
|
+
if csv_path:
|
|
727
|
+
risk_service.export_scan_csv(result["flagged"], csv_path)
|
|
728
|
+
typer.secho(f"\n Exported CSV: {csv_path}", fg=typer.colors.GREEN)
|
|
729
|
+
|
|
730
|
+
if json_path:
|
|
731
|
+
risk_service.export_scan_json(result, json_path)
|
|
732
|
+
typer.secho(f" Exported JSON: {json_path}", fg=typer.colors.GREEN)
|
|
733
|
+
|
|
734
|
+
# --- Drill-down ---
|
|
735
|
+
if high_risk or medium_risk:
|
|
736
|
+
typer.echo("")
|
|
737
|
+
session = PromptSession()
|
|
738
|
+
choices = [str(i) for i in range(1, len(high_risk) + len(medium_risk) + 1)]
|
|
739
|
+
choice = session.prompt(
|
|
740
|
+
"Drill into a role (number) or press Enter to skip: ",
|
|
741
|
+
completer=WordCompleter(choices),
|
|
742
|
+
).strip()
|
|
743
|
+
|
|
744
|
+
if choice and choice.isdigit():
|
|
745
|
+
idx = int(choice) - 1
|
|
746
|
+
all_displayed = high_risk + medium_risk
|
|
747
|
+
if 0 <= idx < len(all_displayed):
|
|
748
|
+
selected_role = all_displayed[idx]["role"]
|
|
749
|
+
typer.echo("")
|
|
750
|
+
_cmd_escalation(ctx, [selected_role])
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _cmd_cost(ctx: SnowglobeContext, args: list):
|
|
754
|
+
"""Cost analysis wizard — or use subcommands: cost summary|warehouses|users|ai|ai-users|services|queries"""
|
|
755
|
+
from prompt_toolkit import PromptSession
|
|
756
|
+
from prompt_toolkit.completion import WordCompleter
|
|
757
|
+
from snowglobe.core.cost_service import CostService
|
|
758
|
+
|
|
759
|
+
cost_service = CostService(ctx)
|
|
760
|
+
|
|
761
|
+
# Parse common flags from args
|
|
762
|
+
days = 30
|
|
763
|
+
csv_path = None
|
|
764
|
+
refresh = False
|
|
765
|
+
sub_args = args[1:] if args else []
|
|
766
|
+
for i, a in enumerate(sub_args):
|
|
767
|
+
if a == "--days" and i + 1 < len(sub_args):
|
|
768
|
+
days = int(sub_args[i + 1])
|
|
769
|
+
elif a == "--csv" and i + 1 < len(sub_args):
|
|
770
|
+
csv_path = sub_args[i + 1]
|
|
771
|
+
elif a == "--refresh":
|
|
772
|
+
refresh = True
|
|
773
|
+
|
|
774
|
+
# If a subcommand is given directly, route to it
|
|
775
|
+
if args:
|
|
776
|
+
sub = args[0].lower()
|
|
777
|
+
|
|
778
|
+
if sub == "summary":
|
|
779
|
+
_cost_summary(cost_service, days, csv_path, refresh)
|
|
780
|
+
elif sub == "warehouses":
|
|
781
|
+
_cost_warehouses(cost_service, days, csv_path, refresh)
|
|
782
|
+
elif sub == "users":
|
|
783
|
+
_cost_users(cost_service, min(days, 7), csv_path, refresh)
|
|
784
|
+
elif sub == "ai":
|
|
785
|
+
_cost_ai(cost_service, days, csv_path, refresh)
|
|
786
|
+
elif sub == "ai-users":
|
|
787
|
+
_cost_ai_users(cost_service, days, csv_path, refresh)
|
|
788
|
+
elif sub == "services":
|
|
789
|
+
_cost_services(cost_service, days, csv_path, refresh)
|
|
790
|
+
elif sub == "queries":
|
|
791
|
+
_cost_queries(cost_service, min(days, 7), csv_path, refresh)
|
|
792
|
+
elif sub == "trend":
|
|
793
|
+
_cost_trend(cost_service, days, csv_path, refresh)
|
|
794
|
+
elif sub == "storage":
|
|
795
|
+
_cost_storage(cost_service, days, csv_path, refresh)
|
|
796
|
+
elif sub == "budget":
|
|
797
|
+
_cost_budget(cost_service, csv_path)
|
|
798
|
+
elif sub == "replication":
|
|
799
|
+
_cost_replication(cost_service, days, csv_path, refresh)
|
|
800
|
+
elif sub in ("materialized-views", "mv"):
|
|
801
|
+
_cost_materialized_views(cost_service, days, csv_path, refresh)
|
|
802
|
+
else:
|
|
803
|
+
typer.secho(f"Unknown subcommand: {sub}. Use: summary, warehouses, users, ai, ai-users, services, queries, trend, storage, budget, replication, mv", fg=typer.colors.YELLOW)
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
# Interactive wizard
|
|
807
|
+
typer.echo("")
|
|
808
|
+
typer.secho("Cost Analysis", fg=typer.colors.CYAN, bold=True)
|
|
809
|
+
typer.echo(" [1] Account summary — total spend by service type")
|
|
810
|
+
typer.echo(" [2] Warehouse breakdown — cost per warehouse")
|
|
811
|
+
typer.echo(" [3] User breakdown — all costs per user (warehouse + AI)")
|
|
812
|
+
typer.echo(" [4] AI services — token costs by service type")
|
|
813
|
+
typer.echo(" [5] AI by user — token costs per user per service")
|
|
814
|
+
typer.echo(" [6] Services breakdown — pipes, tasks, SPCS, clustering")
|
|
815
|
+
typer.echo(" [7] Top expensive queries")
|
|
816
|
+
typer.echo(" [8] Daily trend — day-over-day spend with rolling average")
|
|
817
|
+
typer.echo(" [9] Storage — per-database storage breakdown")
|
|
818
|
+
typer.echo(" [10] Budget — Snowflake budget status & projected spend")
|
|
819
|
+
typer.echo(" [11] Replication — cross-region replication costs")
|
|
820
|
+
typer.echo(" [12] Materialized views — MV refresh costs")
|
|
821
|
+
typer.echo("")
|
|
822
|
+
|
|
823
|
+
session = PromptSession()
|
|
824
|
+
choice = session.prompt(
|
|
825
|
+
"Choice (1-12): ",
|
|
826
|
+
completer=WordCompleter(["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]),
|
|
827
|
+
).strip()
|
|
828
|
+
|
|
829
|
+
if choice == "1":
|
|
830
|
+
_cost_summary(cost_service, 30, None, False)
|
|
831
|
+
elif choice == "2":
|
|
832
|
+
_cost_warehouses(cost_service, 30, None, False)
|
|
833
|
+
elif choice == "3":
|
|
834
|
+
_cost_users(cost_service, 7, None, False)
|
|
835
|
+
elif choice == "4":
|
|
836
|
+
_cost_ai(cost_service, 30, None, False)
|
|
837
|
+
elif choice == "5":
|
|
838
|
+
_cost_ai_users(cost_service, 30, None, False)
|
|
839
|
+
elif choice == "6":
|
|
840
|
+
_cost_services(cost_service, 30, None, False)
|
|
841
|
+
elif choice == "7":
|
|
842
|
+
_cost_queries(cost_service, 7, None, False)
|
|
843
|
+
elif choice == "8":
|
|
844
|
+
_cost_trend(cost_service, 30, None, False)
|
|
845
|
+
elif choice == "9":
|
|
846
|
+
_cost_storage(cost_service, 30, None, False)
|
|
847
|
+
elif choice == "10":
|
|
848
|
+
_cost_budget(cost_service, None)
|
|
849
|
+
elif choice == "11":
|
|
850
|
+
_cost_replication(cost_service, 30, None, False)
|
|
851
|
+
elif choice == "12":
|
|
852
|
+
_cost_materialized_views(cost_service, 30, None, False)
|
|
853
|
+
else:
|
|
854
|
+
typer.secho("Invalid choice.", fg=typer.colors.YELLOW)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _export_csv(df, csv_path: str | None):
|
|
858
|
+
"""Export DataFrame to CSV if path is given. Returns True if exported."""
|
|
859
|
+
if csv_path:
|
|
860
|
+
df.to_csv(csv_path, index=False)
|
|
861
|
+
typer.secho(f" Exported to: {csv_path}", fg=typer.colors.GREEN)
|
|
862
|
+
return True
|
|
863
|
+
return False
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _cache_indicator(cache_age: int | None) -> str:
|
|
867
|
+
"""Return a cache status string for display."""
|
|
868
|
+
if cache_age is None:
|
|
869
|
+
return ""
|
|
870
|
+
return f" (cached {cache_age} min ago)"
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _drill_down_prompt(items: list[str], label: str = "Drill down") -> str | None:
|
|
874
|
+
"""
|
|
875
|
+
Show a numbered selection prompt after displaying results.
|
|
876
|
+
Returns the selected item string, or None if user skips.
|
|
877
|
+
"""
|
|
878
|
+
import sys
|
|
879
|
+
if not sys.stdin.isatty() or not items:
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
from prompt_toolkit import PromptSession
|
|
883
|
+
from prompt_toolkit.completion import WordCompleter
|
|
884
|
+
|
|
885
|
+
typer.echo("")
|
|
886
|
+
choices = [str(i + 1) for i in range(len(items))] + ["q"]
|
|
887
|
+
session = PromptSession()
|
|
888
|
+
selection = session.prompt(
|
|
889
|
+
f" {label} (1-{len(items)}, q to skip): ",
|
|
890
|
+
completer=WordCompleter(choices),
|
|
891
|
+
).strip()
|
|
892
|
+
|
|
893
|
+
if not selection or selection.lower() == "q":
|
|
894
|
+
return None
|
|
895
|
+
try:
|
|
896
|
+
idx = int(selection) - 1
|
|
897
|
+
if 0 <= idx < len(items):
|
|
898
|
+
return items[idx]
|
|
899
|
+
except ValueError:
|
|
900
|
+
pass
|
|
901
|
+
return None
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _display_daily_trend(df, title: str):
|
|
905
|
+
"""Render a daily trend DataFrame as a sparkline table."""
|
|
906
|
+
if df.empty:
|
|
907
|
+
typer.echo(" No data for this selection.")
|
|
908
|
+
return
|
|
909
|
+
typer.echo("")
|
|
910
|
+
typer.secho(f" {title}", fg=typer.colors.CYAN, bold=True)
|
|
911
|
+
typer.echo(f" {'DATE':<12} {'CREDITS':>10} {'TREND'}")
|
|
912
|
+
typer.echo(f" {'─' * 12} {'─' * 10} {'─' * 20}")
|
|
913
|
+
max_credits = df["CREDITS"].max() if not df.empty else 1
|
|
914
|
+
for _, row in df.iterrows():
|
|
915
|
+
bar_len = int((row["CREDITS"] / max_credits) * 20) if max_credits > 0 else 0
|
|
916
|
+
bar = "▓" * bar_len
|
|
917
|
+
typer.echo(f" {str(row['DATE']):<12} {row['CREDITS']:>10,.2f} {bar}")
|
|
918
|
+
typer.echo("")
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _cost_summary(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
922
|
+
"""Display account cost summary by service type."""
|
|
923
|
+
typer.echo(f"\nFetching account summary (last {days} days)...")
|
|
924
|
+
df, cache_age = cost_service.get_account_summary(days, refresh=refresh)
|
|
925
|
+
if df.empty:
|
|
926
|
+
typer.echo(" No cost data found.")
|
|
927
|
+
return
|
|
928
|
+
|
|
929
|
+
if _export_csv(df, csv_path):
|
|
930
|
+
return
|
|
931
|
+
|
|
932
|
+
typer.echo("")
|
|
933
|
+
total = df["CREDITS"].sum()
|
|
934
|
+
typer.secho(f" Total: {total:,.2f} credits ({days} days){_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
|
|
935
|
+
typer.echo("")
|
|
936
|
+
for i, (_, row) in enumerate(df.iterrows(), 1):
|
|
937
|
+
bar_len = int(row["PCT"] / 2)
|
|
938
|
+
bar = "█" * bar_len
|
|
939
|
+
typer.echo(f" [{i:>2}] {row['SERVICE_TYPE']:<38} {row['CREDITS']:>10,.2f} {row['PCT']:>5.1f}% {bar}")
|
|
940
|
+
typer.echo("")
|
|
941
|
+
|
|
942
|
+
# Drill-down
|
|
943
|
+
service_types = df["SERVICE_TYPE"].tolist()
|
|
944
|
+
selected = _drill_down_prompt(service_types, "View daily trend for service")
|
|
945
|
+
if selected:
|
|
946
|
+
typer.echo(f"\n Fetching daily trend for {selected}...")
|
|
947
|
+
detail_df = cost_service.get_service_daily_trend(selected, days)
|
|
948
|
+
_display_daily_trend(detail_df, f"Daily Trend: {selected} ({days} days)")
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _cost_warehouses(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
952
|
+
"""Display warehouse cost breakdown."""
|
|
953
|
+
typer.echo(f"\nFetching warehouse costs (last {days} days)...")
|
|
954
|
+
df, cache_age = cost_service.get_warehouse_breakdown(days, refresh=refresh)
|
|
955
|
+
if df.empty:
|
|
956
|
+
typer.echo(" No warehouse data found.")
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
if _export_csv(df, csv_path):
|
|
960
|
+
return
|
|
961
|
+
|
|
962
|
+
typer.echo("")
|
|
963
|
+
if cache_age is not None:
|
|
964
|
+
typer.secho(f" {_cache_indicator(cache_age).strip()}", fg=typer.colors.BRIGHT_BLACK)
|
|
965
|
+
cli.print_table(df, title=f"Warehouse Costs ({days} days)")
|
|
966
|
+
|
|
967
|
+
# Drill-down
|
|
968
|
+
if not df.empty and "WAREHOUSE_NAME" in df.columns:
|
|
969
|
+
warehouses = df["WAREHOUSE_NAME"].tolist()
|
|
970
|
+
selected = _drill_down_prompt(warehouses, "View daily trend for warehouse")
|
|
971
|
+
if selected:
|
|
972
|
+
typer.echo(f"\n Fetching daily trend for {selected}...")
|
|
973
|
+
detail_df = cost_service.get_warehouse_daily_trend(selected, days)
|
|
974
|
+
_display_daily_trend(detail_df, f"Daily Trend: {selected} ({days} days)")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _cost_users(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
978
|
+
"""Display complete cost per user — warehouse + all AI services."""
|
|
979
|
+
typer.echo(f"\nFetching user costs (last {days} days)...")
|
|
980
|
+
df, cache_age, note = cost_service.get_user_breakdown(days, refresh=refresh)
|
|
981
|
+
if df.empty:
|
|
982
|
+
typer.echo(" No user data found.")
|
|
983
|
+
return
|
|
984
|
+
|
|
985
|
+
if _export_csv(df, csv_path):
|
|
986
|
+
return
|
|
987
|
+
|
|
988
|
+
typer.echo("")
|
|
989
|
+
if note:
|
|
990
|
+
typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
|
|
991
|
+
if cache_age is not None:
|
|
992
|
+
typer.secho(f" {_cache_indicator(cache_age).strip()}", fg=typer.colors.BRIGHT_BLACK)
|
|
993
|
+
cli.print_table(df, title=f"User Cost Attribution ({days} days)")
|
|
994
|
+
|
|
995
|
+
# Drill-down
|
|
996
|
+
if not df.empty and "USER_NAME" in df.columns:
|
|
997
|
+
users = df["USER_NAME"].tolist()
|
|
998
|
+
selected = _drill_down_prompt(users, "View warehouse breakdown for user")
|
|
999
|
+
if selected:
|
|
1000
|
+
typer.echo(f"\n Fetching detail for {selected}...")
|
|
1001
|
+
detail_df, detail_note = cost_service.get_user_detail(selected, days)
|
|
1002
|
+
if detail_note:
|
|
1003
|
+
typer.secho(f" ⚠ {detail_note}", fg=typer.colors.YELLOW)
|
|
1004
|
+
if detail_df.empty:
|
|
1005
|
+
typer.echo(" No query attribution data for this user.")
|
|
1006
|
+
else:
|
|
1007
|
+
cli.print_table(detail_df, title=f"Warehouse Breakdown: {selected} ({days} days)")
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _cost_ai(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
1011
|
+
"""Display AI/ML token costs by service type."""
|
|
1012
|
+
typer.echo(f"\nFetching AI costs (last {days} days)...")
|
|
1013
|
+
df, cache_age, note = cost_service.get_ai_costs(days, refresh=refresh)
|
|
1014
|
+
if df.empty:
|
|
1015
|
+
typer.echo(" No AI usage found.")
|
|
1016
|
+
return
|
|
1017
|
+
|
|
1018
|
+
if _export_csv(df, csv_path):
|
|
1019
|
+
return
|
|
1020
|
+
|
|
1021
|
+
typer.echo("")
|
|
1022
|
+
if note:
|
|
1023
|
+
typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
|
|
1024
|
+
total = df["TOTAL_CREDITS"].astype(float).sum()
|
|
1025
|
+
typer.secho(f" Total AI credits: {total:,.2f} ({days} days){_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
|
|
1026
|
+
typer.echo("")
|
|
1027
|
+
for _, row in df.iterrows():
|
|
1028
|
+
bar_len = int(row["PCT"] / 2)
|
|
1029
|
+
bar = "█" * bar_len
|
|
1030
|
+
typer.echo(f" {row['SERVICE']:<30} {float(row['TOTAL_CREDITS']):>10,.2f} {row['PCT']:>5.1f}% {bar}")
|
|
1031
|
+
typer.echo("")
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _cost_ai_users(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
1035
|
+
"""Display AI/ML token costs per user with service breakdown."""
|
|
1036
|
+
typer.echo(f"\nFetching AI costs by user (last {days} days)...")
|
|
1037
|
+
df, cache_age, note = cost_service.get_ai_costs_by_user(days, refresh=refresh)
|
|
1038
|
+
if df.empty:
|
|
1039
|
+
typer.echo(" No AI usage found.")
|
|
1040
|
+
return
|
|
1041
|
+
|
|
1042
|
+
if _export_csv(df, csv_path):
|
|
1043
|
+
return
|
|
1044
|
+
|
|
1045
|
+
typer.echo("")
|
|
1046
|
+
if note:
|
|
1047
|
+
typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
|
|
1048
|
+
if cache_age is not None:
|
|
1049
|
+
typer.secho(f" {_cache_indicator(cache_age).strip()}", fg=typer.colors.BRIGHT_BLACK)
|
|
1050
|
+
cli.print_table(df, title=f"AI Token Costs by User ({days} days)")
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _cost_services(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
1054
|
+
"""Display non-warehouse service costs (pipes, tasks, SPCS, clustering)."""
|
|
1055
|
+
typer.echo(f"\nFetching service costs (last {days} days)...")
|
|
1056
|
+
df, cache_age = cost_service.get_service_breakdown(days, refresh=refresh)
|
|
1057
|
+
if df.empty:
|
|
1058
|
+
typer.echo(" No service cost data found.")
|
|
1059
|
+
return
|
|
1060
|
+
|
|
1061
|
+
if _export_csv(df, csv_path):
|
|
1062
|
+
return
|
|
1063
|
+
|
|
1064
|
+
typer.echo("")
|
|
1065
|
+
# Group totals by service type
|
|
1066
|
+
totals = df.groupby("SERVICE")["CREDITS"].sum().sort_values(ascending=False)
|
|
1067
|
+
typer.secho(f" Service totals:{_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
|
|
1068
|
+
for svc, credits in totals.items():
|
|
1069
|
+
typer.echo(f" {svc:<25} {credits:>10,.2f} credits")
|
|
1070
|
+
typer.echo("")
|
|
1071
|
+
cli.print_table(df, title=f"Service Resource Costs ({days} days)")
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _cost_queries(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
1075
|
+
"""Display top expensive queries."""
|
|
1076
|
+
typer.echo(f"\nFetching top queries (last {days} days)...")
|
|
1077
|
+
df, cache_age, note = cost_service.get_top_queries(days, refresh=refresh)
|
|
1078
|
+
if df.empty:
|
|
1079
|
+
typer.echo(" No query data found.")
|
|
1080
|
+
return
|
|
1081
|
+
|
|
1082
|
+
if _export_csv(df, csv_path):
|
|
1083
|
+
return
|
|
1084
|
+
|
|
1085
|
+
typer.echo("")
|
|
1086
|
+
if note:
|
|
1087
|
+
typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
|
|
1088
|
+
cli.print_table(df, title=f"Top Expensive Queries ({days} days)")
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _cost_trend(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
1092
|
+
"""Display daily cost trend with day-over-day change and rolling 7-day average."""
|
|
1093
|
+
typer.echo(f"\nFetching daily trend (last {days} days)...")
|
|
1094
|
+
df, cache_age = cost_service.get_daily_trend(days, refresh=refresh)
|
|
1095
|
+
if df.empty:
|
|
1096
|
+
typer.echo(" No trend data found.")
|
|
1097
|
+
return
|
|
1098
|
+
|
|
1099
|
+
if _export_csv(df, csv_path):
|
|
1100
|
+
return
|
|
1101
|
+
|
|
1102
|
+
typer.echo("")
|
|
1103
|
+
total = df["CREDITS"].sum()
|
|
1104
|
+
avg_daily = df["CREDITS"].mean()
|
|
1105
|
+
typer.secho(f" Total: {total:,.2f} credits | Avg daily: {avg_daily:,.2f}{_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
|
|
1106
|
+
typer.echo("")
|
|
1107
|
+
|
|
1108
|
+
# Sparkline-style table showing date, credits, delta, and rolling avg
|
|
1109
|
+
typer.echo(f" {'DATE':<12} {'CREDITS':>10} {'DELTA %':>9} {'7D AVG':>10} {'TREND'}")
|
|
1110
|
+
typer.echo(f" {'─' * 12} {'─' * 10} {'─' * 9} {'─' * 10} {'─' * 20}")
|
|
1111
|
+
max_credits = df["CREDITS"].max() if not df.empty else 1
|
|
1112
|
+
for _, row in df.iterrows():
|
|
1113
|
+
bar_len = int((row["CREDITS"] / max_credits) * 20) if max_credits > 0 else 0
|
|
1114
|
+
bar = "▓" * bar_len
|
|
1115
|
+
delta_str = f"{row['DELTA_PCT']:+.1f}%" if pd.notna(row["DELTA_PCT"]) else " —"
|
|
1116
|
+
avg_str = f"{row['ROLLING_7D_AVG']:,.2f}" if pd.notna(row["ROLLING_7D_AVG"]) else "—"
|
|
1117
|
+
typer.echo(f" {str(row['DATE']):<12} {row['CREDITS']:>10,.2f} {delta_str:>9} {avg_str:>10} {bar}")
|
|
1118
|
+
typer.echo("")
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _cost_storage(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
1122
|
+
"""Display per-database storage breakdown with estimated monthly cost."""
|
|
1123
|
+
typer.echo(f"\nFetching storage usage (avg over last {days} days)...")
|
|
1124
|
+
df, cache_age = cost_service.get_storage_usage(days, refresh=refresh)
|
|
1125
|
+
if df.empty:
|
|
1126
|
+
typer.echo(" No storage data found.")
|
|
1127
|
+
return
|
|
1128
|
+
|
|
1129
|
+
if _export_csv(df, csv_path):
|
|
1130
|
+
return
|
|
1131
|
+
|
|
1132
|
+
typer.echo("")
|
|
1133
|
+
total_tb = df["TOTAL_TB"].sum()
|
|
1134
|
+
total_cost = df["EST_MONTHLY_COST"].sum()
|
|
1135
|
+
rate = cost_service.get_storage_rate()
|
|
1136
|
+
typer.secho(f" Total storage: {total_tb:,.4f} TB | Est. monthly: ${total_cost:,.2f}{_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
|
|
1137
|
+
rate_source = "contracted rate" if rate != 23.0 else "on-demand default"
|
|
1138
|
+
typer.echo(f" (Estimated at ${rate:.2f}/TB/month — {rate_source})")
|
|
1139
|
+
typer.echo("")
|
|
1140
|
+
|
|
1141
|
+
# Display table with human-readable sizes
|
|
1142
|
+
display_df = df[["DATABASE_NAME", "TOTAL_TB", "EST_MONTHLY_COST"]].copy()
|
|
1143
|
+
display_df = display_df[display_df["TOTAL_TB"] > 0]
|
|
1144
|
+
if not display_df.empty:
|
|
1145
|
+
cli.print_table(display_df, title=f"Storage by Database ({days}-day avg)")
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _cost_budget(cost_service, csv_path: str | None):
|
|
1149
|
+
"""Display Snowflake native budget status."""
|
|
1150
|
+
typer.echo("\nFetching budget status...")
|
|
1151
|
+
df, error = cost_service.get_budget_status()
|
|
1152
|
+
if error:
|
|
1153
|
+
typer.secho(f" {error}", fg=typer.colors.YELLOW)
|
|
1154
|
+
return
|
|
1155
|
+
|
|
1156
|
+
if df.empty:
|
|
1157
|
+
typer.echo(" No budget spending history found.")
|
|
1158
|
+
return
|
|
1159
|
+
|
|
1160
|
+
if _export_csv(df, csv_path):
|
|
1161
|
+
return
|
|
1162
|
+
|
|
1163
|
+
typer.echo("")
|
|
1164
|
+
typer.secho(" Snowflake Budget — Spending History", fg=typer.colors.CYAN, bold=True)
|
|
1165
|
+
typer.echo("")
|
|
1166
|
+
cli.print_table(df, title="Budget Spending History")
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _cost_replication(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
1170
|
+
"""Display replication costs by group or daily."""
|
|
1171
|
+
typer.echo(f"\nFetching replication costs (last {days} days)...")
|
|
1172
|
+
df, cache_age = cost_service.get_replication_costs(days, refresh=refresh)
|
|
1173
|
+
if df.empty:
|
|
1174
|
+
typer.echo(" No replication cost data found.")
|
|
1175
|
+
return
|
|
1176
|
+
|
|
1177
|
+
if _export_csv(df, csv_path):
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
typer.echo("")
|
|
1181
|
+
if cache_age is not None:
|
|
1182
|
+
typer.secho(f" {_cache_indicator(cache_age).strip()}", fg=typer.colors.BRIGHT_BLACK)
|
|
1183
|
+
cli.print_table(df, title=f"Replication Costs ({days} days)")
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _cost_materialized_views(cost_service, days: int, csv_path: str | None, refresh: bool):
|
|
1187
|
+
"""Display materialized view refresh costs."""
|
|
1188
|
+
typer.echo(f"\nFetching materialized view costs (last {days} days)...")
|
|
1189
|
+
df, cache_age = cost_service.get_materialized_view_costs(days, refresh=refresh)
|
|
1190
|
+
if df.empty:
|
|
1191
|
+
typer.echo(" No materialized view cost data found.")
|
|
1192
|
+
return
|
|
1193
|
+
|
|
1194
|
+
if _export_csv(df, csv_path):
|
|
1195
|
+
return
|
|
1196
|
+
|
|
1197
|
+
typer.echo("")
|
|
1198
|
+
total = df["CREDITS"].sum() if "CREDITS" in df.columns else 0
|
|
1199
|
+
typer.secho(f" Total MV refresh credits: {total:,.2f}{_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
|
|
1200
|
+
typer.echo("")
|
|
1201
|
+
cli.print_table(df, title=f"Materialized View Costs ({days} days)")
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _cmd_drift(ctx: SnowglobeContext, args: list):
|
|
1205
|
+
"""Show access changes since last refresh or --days N."""
|
|
1206
|
+
days = None
|
|
1207
|
+
for i, a in enumerate(args):
|
|
1208
|
+
if a == "--days" and i + 1 < len(args):
|
|
1209
|
+
days = int(args[i + 1])
|
|
1210
|
+
|
|
1211
|
+
access_service = AccessService(ctx)
|
|
1212
|
+
result = access_service.detect_drift(days=days)
|
|
1213
|
+
typer.echo(cli.format_drift_text(result))
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _cmd_unused(ctx: SnowglobeContext, args: list):
|
|
1217
|
+
"""Find roles with granted privileges but no query activity."""
|
|
1218
|
+
days = 90
|
|
1219
|
+
for i, a in enumerate(args):
|
|
1220
|
+
if a == "--days" and i + 1 < len(args):
|
|
1221
|
+
days = int(args[i + 1])
|
|
1222
|
+
|
|
1223
|
+
typer.echo(f"\nChecking for unused privileges (inactive >{days} days)...")
|
|
1224
|
+
access_service = AccessService(ctx)
|
|
1225
|
+
df, error = access_service.detect_unused_privileges(days=days)
|
|
1226
|
+
if error:
|
|
1227
|
+
typer.secho(f" {error}", fg=typer.colors.YELLOW)
|
|
1228
|
+
return
|
|
1229
|
+
if df.empty:
|
|
1230
|
+
typer.secho(" All roles with data grants are active.", fg=typer.colors.GREEN)
|
|
1231
|
+
return
|
|
1232
|
+
typer.echo("")
|
|
1233
|
+
cli.print_table(df, title=f"Roles with Unused Privileges (>{days} days inactive)")
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _cmd_report(ctx: SnowglobeContext, args: list):
|
|
1237
|
+
"""Generate reports. Usage: report <username> | report full | report cost"""
|
|
1238
|
+
if not args:
|
|
1239
|
+
typer.echo("Usage:")
|
|
1240
|
+
typer.echo(" report <username> Full access report for a user")
|
|
1241
|
+
typer.echo(" report full Cost + AI + storage + queries report (saves .md)")
|
|
1242
|
+
typer.echo(" report cost Cost-only report (saves .md)")
|
|
1243
|
+
return
|
|
1244
|
+
|
|
1245
|
+
sub = args[0].lower()
|
|
1246
|
+
|
|
1247
|
+
if sub == "full":
|
|
1248
|
+
from snowglobe.core.report_service import ReportService
|
|
1249
|
+
days = 30
|
|
1250
|
+
for i, a in enumerate(args[1:]):
|
|
1251
|
+
if a == "--days" and i + 2 <= len(args[1:]):
|
|
1252
|
+
days = int(args[i + 2])
|
|
1253
|
+
|
|
1254
|
+
typer.echo(f"\nGenerating full report ({days} days)...")
|
|
1255
|
+
service = ReportService(ctx)
|
|
1256
|
+
output_path = f"snowglobe_report_{__import__('datetime').date.today().isoformat()}.md"
|
|
1257
|
+
_, data = service.generate_and_save(output_path, days=days)
|
|
1258
|
+
typer.echo(service.terminal_summary(data))
|
|
1259
|
+
typer.secho(f" Report saved: {output_path}", fg=typer.colors.GREEN, bold=True)
|
|
1260
|
+
typer.echo("")
|
|
1261
|
+
|
|
1262
|
+
elif sub == "cost":
|
|
1263
|
+
from snowglobe.core.report_service import ReportService
|
|
1264
|
+
days = 30
|
|
1265
|
+
for i, a in enumerate(args[1:]):
|
|
1266
|
+
if a == "--days" and i + 2 <= len(args[1:]):
|
|
1267
|
+
days = int(args[i + 2])
|
|
1268
|
+
|
|
1269
|
+
typer.echo(f"\nGenerating cost report ({days} days)...")
|
|
1270
|
+
service = ReportService(ctx)
|
|
1271
|
+
data = service.generate_full_report(days=days, top_n=0)
|
|
1272
|
+
data["top_queries"] = []
|
|
1273
|
+
markdown = service.render_markdown(data)
|
|
1274
|
+
output_path = f"snowglobe_cost_{__import__('datetime').date.today().isoformat()}.md"
|
|
1275
|
+
from pathlib import Path
|
|
1276
|
+
Path(output_path).write_text(markdown)
|
|
1277
|
+
typer.echo(service.terminal_summary(data))
|
|
1278
|
+
typer.secho(f" Report saved: {output_path}", fg=typer.colors.GREEN, bold=True)
|
|
1279
|
+
typer.echo("")
|
|
1280
|
+
|
|
1281
|
+
else:
|
|
1282
|
+
# Treat as username for access report
|
|
1283
|
+
username = args[0].upper()
|
|
1284
|
+
typer.echo(f"\nGenerating access report for {username}...")
|
|
1285
|
+
access_service = AccessService(ctx)
|
|
1286
|
+
try:
|
|
1287
|
+
result = access_service.inspect_user_report(username)
|
|
1288
|
+
typer.echo(cli.format_user_report(result))
|
|
1289
|
+
except Exception as e:
|
|
1290
|
+
typer.secho(f" Error: {e}", fg=typer.colors.RED)
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _cmd_optimize(ctx: SnowglobeContext, args: list):
|
|
1294
|
+
"""Analyze a query by ID."""
|
|
1295
|
+
if not args:
|
|
1296
|
+
typer.echo("Usage: optimize <query_id>")
|
|
1297
|
+
return
|
|
1298
|
+
|
|
1299
|
+
query_id = args[0]
|
|
1300
|
+
optimizer_service = QueryOptimizerService(ctx)
|
|
1301
|
+
optimizer_service.collect_query_profile(query_id)
|
|
1302
|
+
optimizer_service.analyze_query()
|
|
1303
|
+
|
|
1304
|
+
# Snowflake-native insights first
|
|
1305
|
+
insights = optimizer_service.collect_insights()
|
|
1306
|
+
if insights:
|
|
1307
|
+
typer.echo(cli.format_query_insights(query_id, insights))
|
|
1308
|
+
|
|
1309
|
+
# Local rule-based suggestions
|
|
1310
|
+
opt_suggestions = optimizer_service.suggestions()
|
|
1311
|
+
typer.echo(cli.format_optimizer_suggestions(query_id, opt_suggestions.suggestions))
|
|
1312
|
+
|
|
1313
|
+
tree = optimizer_service.build_operator_tree()
|
|
1314
|
+
scores = optimizer_service.score()
|
|
1315
|
+
cli.print_operator_tree(tree, scores)
|
|
1316
|
+
|
|
1317
|
+
opt_cost_attribution = optimizer_service.cost_attribution()
|
|
1318
|
+
typer.echo(cli.format_cost_attribution(opt_cost_attribution))
|
|
1319
|
+
|
|
1320
|
+
opt_exp = optimizer_service.expensive_operators()
|
|
1321
|
+
typer.echo(cli.format_expensive_operators(opt_exp))
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _cmd_status(ctx: SnowglobeContext, args: list):
|
|
1325
|
+
"""Show current shell working state."""
|
|
1326
|
+
from snowglobe.state.db import StateDB
|
|
1327
|
+
from datetime import datetime, timezone
|
|
1328
|
+
|
|
1329
|
+
typer.echo("Current state:")
|
|
1330
|
+
typer.echo(f" user: {ctx.username or '(not set)'}")
|
|
1331
|
+
typer.echo(f" role: {ctx.target_role or '(not set)'}")
|
|
1332
|
+
typer.echo(f" object_type: {ctx.object_type or '(not set)'}")
|
|
1333
|
+
typer.echo(f" object_name: {ctx.object_name or '(not set)'}")
|
|
1334
|
+
typer.echo(f" privilege: {ctx.privilege or '(not set)'}")
|
|
1335
|
+
|
|
1336
|
+
# Show cache age
|
|
1337
|
+
db = StateDB()
|
|
1338
|
+
refreshed_at = db.get_refreshed_at()
|
|
1339
|
+
if refreshed_at:
|
|
1340
|
+
try:
|
|
1341
|
+
refreshed = datetime.fromisoformat(refreshed_at)
|
|
1342
|
+
age = datetime.now(timezone.utc) - refreshed
|
|
1343
|
+
hours = age.total_seconds() / 3600
|
|
1344
|
+
if hours < 1:
|
|
1345
|
+
age_str = f"{int(age.total_seconds() / 60)} minutes ago"
|
|
1346
|
+
elif hours < 24:
|
|
1347
|
+
age_str = f"{int(hours)} hours ago"
|
|
1348
|
+
else:
|
|
1349
|
+
age_str = f"{int(hours // 24)} days ago"
|
|
1350
|
+
typer.echo(f" cache: refreshed {age_str}")
|
|
1351
|
+
except (ValueError, TypeError):
|
|
1352
|
+
typer.echo(" cache: unknown age")
|
|
1353
|
+
else:
|
|
1354
|
+
typer.echo(" cache: not populated")
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _cmd_debug(ctx: SnowglobeContext, args: list):
|
|
1358
|
+
"""Run connection diagnostics."""
|
|
1359
|
+
from snowglobe.cli.debug import run_diagnostics
|
|
1360
|
+
run_diagnostics(profile_name=ctx.profile_name, verbose=ctx.verbose)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _cmd_refresh(ctx: SnowglobeContext, args: list):
|
|
1364
|
+
"""Refresh cached state from Snowflake. Use 'refresh --full' for complete reload."""
|
|
1365
|
+
full = "--full" in args
|
|
1366
|
+
|
|
1367
|
+
access_service = AccessService(ctx)
|
|
1368
|
+
access_service.setup_state()
|
|
1369
|
+
|
|
1370
|
+
access_service.refresh_state(full=full)
|
|
1371
|
+
|
|
1372
|
+
# Update context with fresh data
|
|
1373
|
+
ctx.user_graph = access_service.user_graph
|
|
1374
|
+
ctx.role_graph = access_service.role_graph
|
|
1375
|
+
ctx.object_index = access_service.object_index
|
|
1376
|
+
|
|
1377
|
+
typer.secho(f" Users: {len(ctx.user_graph.assigned_roles)}", fg=typer.colors.GREEN)
|
|
1378
|
+
typer.secho(f" Roles: {len(ctx.role_graph.parents)}", fg=typer.colors.GREEN)
|
|
1379
|
+
total_objects = sum(len(v) for v in ctx.object_index.values())
|
|
1380
|
+
typer.secho(f" Object index: {total_objects} FQNs", fg=typer.colors.GREEN)
|
|
1381
|
+
typer.secho("Done.", fg=typer.colors.GREEN, bold=True)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _cmd_help(ctx: SnowglobeContext, args: list):
|
|
1385
|
+
"""Show available shell commands."""
|
|
1386
|
+
typer.echo("")
|
|
1387
|
+
typer.secho("Commands:", fg=typer.colors.CYAN, bold=True)
|
|
1388
|
+
typer.echo(" check Guided access & privilege checks (start here)")
|
|
1389
|
+
typer.echo(" roles <user> What roles does a user have?")
|
|
1390
|
+
typer.echo(" members <role> Who has this role?")
|
|
1391
|
+
typer.echo(" path <from> <to> Does one role inherit from another?")
|
|
1392
|
+
typer.echo(" escalation <role> Can this role reach admin privileges?")
|
|
1393
|
+
typer.echo(" scan Escalation scan with risk scoring (--csv, --json)")
|
|
1394
|
+
typer.echo(" cost Cost analysis wizard")
|
|
1395
|
+
typer.echo(" cost summary Account spend by service type")
|
|
1396
|
+
typer.echo(" cost warehouses Cost per warehouse")
|
|
1397
|
+
typer.echo(" cost users Cost per user")
|
|
1398
|
+
typer.echo(" cost ai AI token costs by service")
|
|
1399
|
+
typer.echo(" cost queries Top expensive queries")
|
|
1400
|
+
typer.echo(" cost trend Daily spend trend with rolling avg")
|
|
1401
|
+
typer.echo(" cost storage Per-database storage breakdown")
|
|
1402
|
+
typer.echo(" cost budget Snowflake budget status")
|
|
1403
|
+
typer.echo(" cost replication Replication costs by group")
|
|
1404
|
+
typer.echo(" cost mv Materialized view refresh costs")
|
|
1405
|
+
typer.echo(" optimize <id> Analyze a specific query")
|
|
1406
|
+
typer.echo(" drift Show access changes since last refresh")
|
|
1407
|
+
typer.echo(" unused Find roles with unused privileges")
|
|
1408
|
+
typer.echo(" report <user> Full access report for a user")
|
|
1409
|
+
typer.echo(" report full Cost/AI/storage/queries report (saves .md)")
|
|
1410
|
+
typer.echo(" report cost Cost-only report (saves .md)")
|
|
1411
|
+
typer.echo(" refresh Refresh cached state from Snowflake")
|
|
1412
|
+
typer.echo(" status Show current working state")
|
|
1413
|
+
typer.echo(" debug Run connection diagnostics")
|
|
1414
|
+
typer.echo(" help / ? Show this help")
|
|
1415
|
+
typer.echo(" exit Exit the shell")
|
|
1416
|
+
typer.echo("")
|
|
1417
|
+
typer.secho("Shortcuts:", fg=typer.colors.CYAN)
|
|
1418
|
+
typer.echo(" use role <name> Set active role")
|
|
1419
|
+
typer.echo(" use user <name> Set active user")
|
|
1420
|
+
typer.echo(" access Direct: can user/role access object?")
|
|
1421
|
+
typer.echo(" whoaccess Direct: who can access object?")
|
|
1422
|
+
typer.echo(" create Direct: where can role create objects?")
|
|
1423
|
+
typer.echo("")
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
# --- Typer registration ---
|
|
1427
|
+
|
|
1428
|
+
shell_app = typer.Typer(
|
|
1429
|
+
help="Interactive Snowglobe shell",
|
|
1430
|
+
no_args_is_help=True,
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
@shell_app.command()
|
|
1435
|
+
def shell(ctx: typer.Context):
|
|
1436
|
+
"""Launch the interactive Snowglobe shell."""
|
|
1437
|
+
start_shell(ctx.obj)
|