kstlib 0.0.1a0__py3-none-any.whl → 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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,425 @@
1
+ """Shared utilities for secrets CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from subprocess import CompletedProcess, run
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import typer
12
+
13
+ from kstlib.config.exceptions import ConfigNotLoadedError
14
+ from kstlib.config.loader import get_config
15
+ from kstlib.utils.secure_delete import (
16
+ DEFAULT_CHUNK_SIZE,
17
+ SecureDeleteMethod,
18
+ SecureDeleteReport,
19
+ secure_delete,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from pathlib import Path
24
+ else: # pragma: no cover - runtime alias for Typer conversions
25
+ import pathlib
26
+
27
+ Path = pathlib.Path
28
+
29
+ CheckEntry = dict[str, Any]
30
+
31
+ # =============================================================================
32
+ # Typer Options and Arguments
33
+ # =============================================================================
34
+
35
+ CONFIG_OPTION = typer.Option(
36
+ None,
37
+ "--config",
38
+ help="Path to a SOPS configuration file.",
39
+ exists=True,
40
+ file_okay=True,
41
+ dir_okay=False,
42
+ readable=True,
43
+ )
44
+ FORCE_OPTION = typer.Option(False, "--force", "-f", help="Overwrite the output file if it already exists.")
45
+ FORMATS_OPTION = typer.Option(
46
+ ("auto", "auto"),
47
+ "--format",
48
+ "-F",
49
+ help="Input and output format (auto|json|yaml|text). Provide two values: input then output.",
50
+ metavar="INPUT OUTPUT",
51
+ show_default=True,
52
+ )
53
+ ENCRYPT_SOURCE_ARG = typer.Argument(
54
+ ...,
55
+ exists=True,
56
+ dir_okay=False,
57
+ help="Path to the cleartext secrets file.",
58
+ )
59
+ DECRYPT_SOURCE_ARG = typer.Argument(
60
+ ...,
61
+ exists=True,
62
+ dir_okay=False,
63
+ help="Path to the encrypted SOPS file.",
64
+ )
65
+ OUT_OPTION = typer.Option(None, "--out", "-o", help="Target path for the resulting file.")
66
+ QUIET_OPTION = typer.Option(
67
+ False,
68
+ "--quiet",
69
+ help="Suppress Rich output; rely on the exit code only.",
70
+ )
71
+ SHRED_OPTION = typer.Option(
72
+ False,
73
+ "--shred",
74
+ help="Remove the cleartext source file after a successful run.",
75
+ )
76
+ SHRED_METHOD_OPTION = typer.Option(
77
+ None,
78
+ "--shred-method",
79
+ help="Secure delete method when shredding (auto|command|overwrite).",
80
+ )
81
+ SHRED_PASSES_OPTION = typer.Option(
82
+ None,
83
+ "--shred-passes",
84
+ help="Number of overwrite passes when shredding.",
85
+ )
86
+ SHRED_ZERO_LAST_OPTION = typer.Option(
87
+ None,
88
+ "--shred-zero-last-pass/--shred-no-zero-last-pass",
89
+ help="Control whether the last overwrite pass writes zeros.",
90
+ )
91
+ SHRED_CHUNK_SIZE_OPTION = typer.Option(
92
+ None,
93
+ "--shred-chunk-size",
94
+ help="Chunk size in bytes used for overwrite operations.",
95
+ )
96
+ AGE_RECIPIENT_OPTION = typer.Option(
97
+ None,
98
+ "--age-recipient",
99
+ help="Add an age public key recipient (option can be repeated).",
100
+ )
101
+ KMS_KEY_OPTION = typer.Option(
102
+ None,
103
+ "--kms-key",
104
+ help="Add an AWS KMS key ARN (option can be repeated).",
105
+ )
106
+ DATA_KEY_OPTION = typer.Option(
107
+ None,
108
+ "--key",
109
+ help="Provide a raw data key or provider-specific key flag understood by sops (option can be repeated).",
110
+ )
111
+ SHRED_TARGET_ARG = typer.Argument(
112
+ ...,
113
+ exists=True,
114
+ dir_okay=False,
115
+ help="Path to the secrets file that must be removed.",
116
+ )
117
+ SHRED_FORCE_OPTION = typer.Option(
118
+ False,
119
+ "--force",
120
+ "-f",
121
+ help="Skip the confirmation prompt when shredding secrets.",
122
+ )
123
+ SHRED_CMD_METHOD_OPTION = typer.Option(
124
+ None,
125
+ "--method",
126
+ help="Secure delete method (auto|command|overwrite). Overrides configuration.",
127
+ )
128
+ SHRED_CMD_PASSES_OPTION = typer.Option(
129
+ None,
130
+ "--passes",
131
+ help="Number of overwrite passes to perform.",
132
+ )
133
+ SHRED_CMD_ZERO_LAST_OPTION = typer.Option(
134
+ None,
135
+ "--zero-last-pass/--no-zero-last-pass",
136
+ help="Control whether the last overwrite pass writes zeros.",
137
+ )
138
+ SHRED_CMD_CHUNK_SIZE_OPTION = typer.Option(
139
+ None,
140
+ "--chunk-size",
141
+ help="Chunk size in bytes used for overwrite operations.",
142
+ )
143
+ SHRED_CMD_QUIET_OPTION = typer.Option(
144
+ False,
145
+ "--quiet",
146
+ help="Suppress Rich output when shredding directly.",
147
+ )
148
+ INIT_LOCAL_OPTION = typer.Option(
149
+ False,
150
+ "--local",
151
+ "-l",
152
+ help="Create config in current directory instead of user home.",
153
+ )
154
+ INIT_FORCE_OPTION = typer.Option(
155
+ False,
156
+ "--force",
157
+ "-f",
158
+ help="Overwrite existing files.",
159
+ )
160
+
161
+ # =============================================================================
162
+ # Dataclasses
163
+ # =============================================================================
164
+
165
+
166
+ @dataclass(slots=True)
167
+ class SecureDeleteCLIOptions:
168
+ """Secure delete options supplied through the CLI."""
169
+
170
+ enabled: bool
171
+ method: str | None
172
+ passes: int | None
173
+ zero_last_pass: bool | None
174
+ chunk_size: int | None
175
+
176
+
177
+ @dataclass(slots=True)
178
+ class EncryptCommandOptions: # pylint: disable=too-many-instance-attributes
179
+ """Container for encrypt command options."""
180
+
181
+ out: Path | None
182
+ binary: str
183
+ config: Path | None
184
+ formats: tuple[str, str]
185
+ force: bool
186
+ quiet: bool
187
+ shred: SecureDeleteCLIOptions
188
+ age_recipients: tuple[str, ...]
189
+ kms_keys: tuple[str, ...]
190
+ data_keys: tuple[str, ...]
191
+
192
+
193
+ @dataclass(slots=True)
194
+ class ShredCommandOptions:
195
+ """Container for shred command options."""
196
+
197
+ force: bool
198
+ method: str | None
199
+ passes: int | None
200
+ zero_last_pass: bool | None
201
+ chunk_size: int | None
202
+ quiet: bool
203
+
204
+
205
+ # =============================================================================
206
+ # SOPS Command Execution
207
+ # =============================================================================
208
+
209
+
210
+ def run_sops_command(binary: str, arguments: list[str]) -> CompletedProcess[str]:
211
+ """Execute the sops binary with the provided arguments."""
212
+ binary_path = shutil.which(binary)
213
+ if binary_path is None:
214
+ raise FileNotFoundError(binary)
215
+ command = [binary_path, *arguments]
216
+ return run(
217
+ command,
218
+ capture_output=True,
219
+ text=True,
220
+ check=False,
221
+ shell=False,
222
+ )
223
+
224
+
225
+ def resolve_sops_binary() -> str:
226
+ """Return the configured sops binary name if set, otherwise the default."""
227
+ default_binary = "sops"
228
+ try:
229
+ config = get_config()
230
+ except ConfigNotLoadedError:
231
+ return default_binary
232
+
233
+ secrets_config = getattr(config, "secrets", None)
234
+ if secrets_config is None or not hasattr(secrets_config, "to_dict"):
235
+ return default_binary
236
+
237
+ data = secrets_config.to_dict()
238
+ if not isinstance(data, Mapping):
239
+ return default_binary
240
+
241
+ sops_config = data.get("sops")
242
+ if isinstance(sops_config, Mapping):
243
+ binary = sops_config.get("binary")
244
+ if isinstance(binary, str) and binary:
245
+ return binary
246
+
247
+ settings = sops_config.get("settings")
248
+ if isinstance(settings, Mapping):
249
+ settings_binary = settings.get("binary")
250
+ if isinstance(settings_binary, str) and settings_binary:
251
+ return settings_binary
252
+
253
+ return default_binary
254
+
255
+
256
+ def format_arguments(input_format: str, output_format: str) -> list[str]:
257
+ """Build format arguments for sops command."""
258
+ arguments: list[str] = []
259
+ if input_format.lower() != "auto":
260
+ arguments.extend(["--input-type", input_format])
261
+ if output_format.lower() != "auto":
262
+ arguments.extend(["--output-type", output_format])
263
+ return arguments
264
+
265
+
266
+ # =============================================================================
267
+ # Secure Delete Helpers
268
+ # =============================================================================
269
+
270
+
271
+ def shred_file(
272
+ target: Path,
273
+ *,
274
+ method: str | None = None,
275
+ passes: int | None = None,
276
+ zero_last_pass: bool | None = None,
277
+ chunk_size: int | None = None,
278
+ ) -> SecureDeleteReport:
279
+ """Remove a file from disk using secure deletion semantics."""
280
+ try:
281
+ settings = _resolve_secure_delete_settings(
282
+ method=method,
283
+ passes=passes,
284
+ zero_last_pass=zero_last_pass,
285
+ chunk_size=chunk_size,
286
+ )
287
+ except ValueError as error:
288
+ return SecureDeleteReport(
289
+ success=False,
290
+ method=SecureDeleteMethod.AUTO,
291
+ passes=passes or 0,
292
+ message=str(error),
293
+ )
294
+
295
+ try:
296
+ return secure_delete(
297
+ target,
298
+ method=settings["method"],
299
+ passes=settings["passes"],
300
+ zero_last_pass=settings["zero_last_pass"],
301
+ chunk_size=settings["chunk_size"],
302
+ )
303
+ except ValueError as error:
304
+ return SecureDeleteReport(
305
+ success=False,
306
+ method=settings["method"],
307
+ passes=settings["passes"],
308
+ message=str(error),
309
+ )
310
+
311
+
312
+ def _resolve_secure_delete_settings(
313
+ *,
314
+ method: str | SecureDeleteMethod | None,
315
+ passes: int | None,
316
+ zero_last_pass: bool | None,
317
+ chunk_size: int | None,
318
+ ) -> dict[str, Any]:
319
+ """Resolve secure deletion settings from configuration and overrides."""
320
+ config_settings = _get_secure_delete_settings()
321
+
322
+ method_value = method if method is not None else config_settings.get("method")
323
+ resolved_method = _normalize_method(method_value)
324
+
325
+ resolved_passes = passes if passes is not None else int(config_settings.get("passes", 3))
326
+ if resolved_passes < 1:
327
+ raise ValueError("Secure delete passes must be >= 1.")
328
+
329
+ resolved_chunk_size = (
330
+ chunk_size if chunk_size is not None else int(config_settings.get("chunk_size", DEFAULT_CHUNK_SIZE))
331
+ )
332
+ if resolved_chunk_size < 1:
333
+ raise ValueError("Secure delete chunk size must be >= 1.")
334
+
335
+ resolved_zero_last = (
336
+ zero_last_pass if zero_last_pass is not None else bool(config_settings.get("zero_last_pass", True))
337
+ )
338
+
339
+ return {
340
+ "method": resolved_method,
341
+ "passes": resolved_passes,
342
+ "zero_last_pass": resolved_zero_last,
343
+ "chunk_size": resolved_chunk_size,
344
+ }
345
+
346
+
347
+ def _normalize_method(value: str | SecureDeleteMethod | None) -> SecureDeleteMethod:
348
+ """Normalise user-provided secure delete method values."""
349
+ if value is None:
350
+ return SecureDeleteMethod.AUTO
351
+ if isinstance(value, SecureDeleteMethod):
352
+ return value
353
+ try:
354
+ return SecureDeleteMethod(str(value).lower())
355
+ except ValueError as error:
356
+ raise ValueError(f"Unsupported secure delete method '{value}'.") from error
357
+
358
+
359
+ def _get_secure_delete_settings() -> dict[str, Any]:
360
+ """Return secure delete settings from the loaded configuration."""
361
+ try:
362
+ config = get_config()
363
+ except ConfigNotLoadedError:
364
+ return {}
365
+
366
+ result: dict[str, Any] = {}
367
+ candidates: list[Any] = []
368
+
369
+ utilities = getattr(config, "utilities", None)
370
+ if utilities is not None:
371
+ candidates.append(getattr(utilities, "secure_delete", None))
372
+
373
+ secrets_config = getattr(config, "secrets", None)
374
+ if secrets_config is not None:
375
+ candidates.append(getattr(secrets_config, "secure_delete", None))
376
+
377
+ for node in candidates:
378
+ if node is None:
379
+ continue
380
+ if hasattr(node, "to_dict"):
381
+ data = node.to_dict()
382
+ elif isinstance(node, dict):
383
+ data = node
384
+ else:
385
+ continue
386
+ result.update({k: v for k, v in data.items() if v is not None})
387
+
388
+ return result
389
+
390
+
391
+ __all__ = [
392
+ "AGE_RECIPIENT_OPTION",
393
+ "CONFIG_OPTION",
394
+ "DATA_KEY_OPTION",
395
+ "DECRYPT_SOURCE_ARG",
396
+ "ENCRYPT_SOURCE_ARG",
397
+ "FORCE_OPTION",
398
+ "FORMATS_OPTION",
399
+ "INIT_FORCE_OPTION",
400
+ "INIT_LOCAL_OPTION",
401
+ "KMS_KEY_OPTION",
402
+ "OUT_OPTION",
403
+ "QUIET_OPTION",
404
+ "SHRED_CHUNK_SIZE_OPTION",
405
+ "SHRED_CMD_CHUNK_SIZE_OPTION",
406
+ "SHRED_CMD_METHOD_OPTION",
407
+ "SHRED_CMD_PASSES_OPTION",
408
+ "SHRED_CMD_QUIET_OPTION",
409
+ "SHRED_CMD_ZERO_LAST_OPTION",
410
+ "SHRED_FORCE_OPTION",
411
+ "SHRED_METHOD_OPTION",
412
+ "SHRED_OPTION",
413
+ "SHRED_PASSES_OPTION",
414
+ "SHRED_TARGET_ARG",
415
+ "SHRED_ZERO_LAST_OPTION",
416
+ "CheckEntry",
417
+ "EncryptCommandOptions",
418
+ "Path",
419
+ "SecureDeleteCLIOptions",
420
+ "ShredCommandOptions",
421
+ "format_arguments",
422
+ "resolve_sops_binary",
423
+ "run_sops_command",
424
+ "shred_file",
425
+ ]
@@ -0,0 +1,88 @@
1
+ """Decrypt command for secrets subsystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import typer
8
+
9
+ from kstlib.cli.common import (
10
+ CommandResult,
11
+ CommandStatus,
12
+ console,
13
+ exit_with_result,
14
+ render_result,
15
+ )
16
+
17
+ from .common import (
18
+ DECRYPT_SOURCE_ARG,
19
+ FORCE_OPTION,
20
+ OUT_OPTION,
21
+ QUIET_OPTION,
22
+ resolve_sops_binary,
23
+ run_sops_command,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from pathlib import Path
28
+ else: # pragma: no cover - runtime alias for Typer conversions
29
+ import pathlib
30
+
31
+ Path = pathlib.Path
32
+
33
+
34
+ def _build_decrypt_args(
35
+ source: Path,
36
+ out: Path | None,
37
+ ) -> list[str]:
38
+ """Build arguments for ``sops --decrypt``."""
39
+ arguments = ["--decrypt"]
40
+ if out is not None:
41
+ arguments.extend(["--output", str(out)])
42
+ arguments.append(str(source))
43
+ return arguments
44
+
45
+
46
+ def decrypt(
47
+ source: Path = DECRYPT_SOURCE_ARG,
48
+ out: Path | None = OUT_OPTION,
49
+ force: bool = FORCE_OPTION,
50
+ quiet: bool = QUIET_OPTION,
51
+ ) -> None:
52
+ """Decrypt a SOPS file."""
53
+ binary = resolve_sops_binary()
54
+ if out is not None and out.exists() and not force:
55
+ result = CommandResult(
56
+ status=CommandStatus.ERROR,
57
+ message=f"Refuse to overwrite existing file: {out} (use --force).",
58
+ )
59
+ exit_with_result(result, quiet, exit_code=1)
60
+
61
+ try:
62
+ completed = run_sops_command(binary, _build_decrypt_args(source, out))
63
+ except FileNotFoundError as exc:
64
+ result = CommandResult(
65
+ status=CommandStatus.ERROR,
66
+ message=f"SOPS binary '{binary}' not found. Install it or set secrets.sops.binary in the config.",
67
+ )
68
+ exit_with_result(result, quiet, exit_code=1, cause=exc)
69
+
70
+ if completed.returncode != 0:
71
+ message = completed.stderr.strip() or completed.stdout.strip() or "sops command failed"
72
+ result = CommandResult(status=CommandStatus.ERROR, message=message)
73
+ exit_with_result(result, quiet, exit_code=1)
74
+
75
+ if out is None and completed.stdout and not quiet:
76
+ console.print(completed.stdout.rstrip("\n"))
77
+
78
+ target_info = str(out) if out else "stdout"
79
+ result = CommandResult(
80
+ status=CommandStatus.OK,
81
+ message=f"Decrypted secrets written to {target_info}.",
82
+ )
83
+ if quiet:
84
+ raise typer.Exit(code=0)
85
+ render_result(result)
86
+
87
+
88
+ __all__ = ["decrypt"]