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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {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"]
|