kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.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.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
"""Doctor and init commands for secrets subsystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from subprocess import run
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from kstlib.cli.common import (
|
|
15
|
+
CommandResult,
|
|
16
|
+
CommandStatus,
|
|
17
|
+
console,
|
|
18
|
+
exit_error,
|
|
19
|
+
render_result,
|
|
20
|
+
)
|
|
21
|
+
from kstlib.config.exceptions import ConfigNotLoadedError
|
|
22
|
+
from kstlib.config.loader import get_config
|
|
23
|
+
|
|
24
|
+
from .common import INIT_FORCE_OPTION, INIT_LOCAL_OPTION, CheckEntry, resolve_sops_binary
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Doctor Checks
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_sops_binary() -> CheckEntry:
|
|
36
|
+
"""Return the SOPS binary availability check result."""
|
|
37
|
+
binary = resolve_sops_binary()
|
|
38
|
+
binary_path = shutil.which(binary)
|
|
39
|
+
if binary_path:
|
|
40
|
+
return {"component": "sops", "status": "available", "details": binary_path}
|
|
41
|
+
return {"component": "sops", "status": "missing", "details": f"Executable '{binary}' not found."}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _find_effective_sops_config() -> tuple[Path | None, str]:
|
|
45
|
+
"""Find the SOPS config file exactly as SOPS does.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tuple of (config_path, source) where source is one of:
|
|
49
|
+
- "env" if from SOPS_CONFIG environment variable
|
|
50
|
+
- "local" if found by walking up from cwd (but not in HOME)
|
|
51
|
+
- "home" if from ~/.sops.yaml (whether found by walking or fallback)
|
|
52
|
+
- "none" if not found
|
|
53
|
+
"""
|
|
54
|
+
home_dir = Path.home()
|
|
55
|
+
home_config = home_dir / ".sops.yaml"
|
|
56
|
+
|
|
57
|
+
# 1. Check SOPS_CONFIG environment variable (highest priority)
|
|
58
|
+
sops_config_env = os.getenv("SOPS_CONFIG")
|
|
59
|
+
if sops_config_env:
|
|
60
|
+
config_path = Path(sops_config_env)
|
|
61
|
+
if config_path.exists():
|
|
62
|
+
return config_path, "env"
|
|
63
|
+
return None, "none"
|
|
64
|
+
|
|
65
|
+
# 2. Walk up from cwd to find .sops.yaml (like SOPS does)
|
|
66
|
+
current = Path.cwd()
|
|
67
|
+
while current != current.parent:
|
|
68
|
+
candidate = current / ".sops.yaml"
|
|
69
|
+
if candidate.exists():
|
|
70
|
+
# Distinguish: if found in HOME, label as "home" not "local"
|
|
71
|
+
if candidate.resolve() == home_config.resolve():
|
|
72
|
+
return candidate, "home"
|
|
73
|
+
return candidate, "local"
|
|
74
|
+
current = current.parent
|
|
75
|
+
|
|
76
|
+
# 3. Fallback: check ~/.sops.yaml directly
|
|
77
|
+
if home_config.exists():
|
|
78
|
+
return home_config, "home"
|
|
79
|
+
|
|
80
|
+
return None, "none"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _find_sops_config_path() -> Path | None:
|
|
84
|
+
"""Find the SOPS config file path (returns None if not found)."""
|
|
85
|
+
config_path, _ = _find_effective_sops_config()
|
|
86
|
+
return config_path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_age_recipients_from_config(config_path: Path) -> list[str]:
|
|
90
|
+
"""Extract age recipients from a .sops.yaml config file."""
|
|
91
|
+
import re
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
content = config_path.read_text(encoding="utf-8")
|
|
95
|
+
except OSError:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
# Simple regex to find age: keys (handles multi-line with >-)
|
|
99
|
+
recipients: list[str] = []
|
|
100
|
+
# Match "age: age1..." or "age: >-\n age1...,\n age1..."
|
|
101
|
+
age_pattern = re.compile(r"\bage:\s*([^\n]+(?:\n\s+[^\n]+)*)", re.MULTILINE)
|
|
102
|
+
for match in age_pattern.finditer(content):
|
|
103
|
+
value = match.group(1).strip()
|
|
104
|
+
# Handle >- multi-line format
|
|
105
|
+
if value.startswith(">"):
|
|
106
|
+
value = value[1:].strip("-").strip()
|
|
107
|
+
# Extract individual age keys
|
|
108
|
+
for key in re.findall(r"(age1[a-z0-9]+)", value):
|
|
109
|
+
if key not in recipients:
|
|
110
|
+
recipients.append(key)
|
|
111
|
+
return recipients
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _detect_sops_backends(config_path: Path) -> list[str]:
|
|
115
|
+
"""Detect which encryption backends are configured in .sops.yaml.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of backend names: "age", "gpg", "kms", "gcp_kms", "azure_kv"
|
|
119
|
+
"""
|
|
120
|
+
import re
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
content = config_path.read_text(encoding="utf-8")
|
|
124
|
+
except OSError:
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
backends: list[str] = []
|
|
128
|
+
|
|
129
|
+
# Check for age (age: age1...)
|
|
130
|
+
if re.search(r"\bage:\s*age1", content):
|
|
131
|
+
backends.append("age")
|
|
132
|
+
|
|
133
|
+
# Check for GPG/PGP (pgp: or gpg:)
|
|
134
|
+
if re.search(r"\b(pgp|gpg):\s*\S", content):
|
|
135
|
+
backends.append("gpg")
|
|
136
|
+
|
|
137
|
+
# Check for AWS KMS (kms: arn:aws:kms:...)
|
|
138
|
+
if re.search(r"\bkms:\s*arn:aws:kms:", content):
|
|
139
|
+
backends.append("kms")
|
|
140
|
+
|
|
141
|
+
# Check for GCP KMS (gcp_kms: projects/...)
|
|
142
|
+
if re.search(r"\bgcp_kms:\s*projects/", content):
|
|
143
|
+
backends.append("gcp_kms")
|
|
144
|
+
|
|
145
|
+
# Check for Azure Key Vault (azure_keyvault: https://...)
|
|
146
|
+
if re.search(r"\bazure_keyvault:\s*https://", content):
|
|
147
|
+
backends.append("azure_kv")
|
|
148
|
+
|
|
149
|
+
return backends
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _format_config_source(source: str) -> str:
|
|
153
|
+
"""Format the config source for display."""
|
|
154
|
+
source_labels = {
|
|
155
|
+
"env": "SOPS_CONFIG env var",
|
|
156
|
+
"local": "local directory (walking up from cwd)",
|
|
157
|
+
"home": "home directory (~/.sops.yaml)",
|
|
158
|
+
"none": "not found",
|
|
159
|
+
}
|
|
160
|
+
return source_labels.get(source, source)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_sops_config() -> CheckEntry:
|
|
164
|
+
"""Return the SOPS configuration availability check result.
|
|
165
|
+
|
|
166
|
+
Shows exactly which config SOPS will use and where it comes from.
|
|
167
|
+
"""
|
|
168
|
+
# Check if SOPS_CONFIG points to missing file
|
|
169
|
+
sops_config_env = os.getenv("SOPS_CONFIG")
|
|
170
|
+
if sops_config_env:
|
|
171
|
+
env_config_path = Path(sops_config_env)
|
|
172
|
+
if not env_config_path.exists():
|
|
173
|
+
return {
|
|
174
|
+
"component": "sops_config",
|
|
175
|
+
"status": "warning",
|
|
176
|
+
"details": f"SOPS_CONFIG points to missing file: {env_config_path}",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Find the effective config (exactly as SOPS does)
|
|
180
|
+
effective_config, source = _find_effective_sops_config()
|
|
181
|
+
|
|
182
|
+
if effective_config is None:
|
|
183
|
+
return {
|
|
184
|
+
"component": "sops_config",
|
|
185
|
+
"status": "missing",
|
|
186
|
+
"details": (
|
|
187
|
+
"No .sops.yaml found. Run 'kstlib secrets init' or create one manually. "
|
|
188
|
+
"See: https://github.com/getsops/sops#usage"
|
|
189
|
+
),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# Extract age recipients to show which keys will be used
|
|
193
|
+
recipients = _extract_age_recipients_from_config(effective_config)
|
|
194
|
+
source_label = _format_config_source(source)
|
|
195
|
+
|
|
196
|
+
if recipients:
|
|
197
|
+
# Show full public key(s) - this is the key SOPS will use for encryption
|
|
198
|
+
key_lines = "\n ".join(recipients)
|
|
199
|
+
extra = "" if len(recipients) <= 3 else f"\n (+{len(recipients) - 3} more)"
|
|
200
|
+
details = f"[{source_label}] {effective_config}\n Public key(s) for encryption:\n {key_lines}{extra}"
|
|
201
|
+
else:
|
|
202
|
+
details = f"[{source_label}] {effective_config} (no age recipients found)"
|
|
203
|
+
|
|
204
|
+
return {"component": "sops_config", "status": "available", "details": details}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_age_keygen() -> CheckEntry:
|
|
208
|
+
"""Return the age-keygen binary availability check result."""
|
|
209
|
+
age_binary_path = shutil.which("age-keygen")
|
|
210
|
+
if age_binary_path:
|
|
211
|
+
return {"component": "age-keygen", "status": "available", "details": age_binary_path}
|
|
212
|
+
return {
|
|
213
|
+
"component": "age-keygen",
|
|
214
|
+
"status": "warning",
|
|
215
|
+
"details": "Executable 'age-keygen' not found; age recipients will be unavailable.",
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _find_age_key_path() -> Path | None:
|
|
220
|
+
"""Find the age key file path (returns None if not found)."""
|
|
221
|
+
age_key_env = os.getenv("SOPS_AGE_KEY_FILE")
|
|
222
|
+
if age_key_env:
|
|
223
|
+
age_key_path = Path(age_key_env)
|
|
224
|
+
if age_key_path.exists():
|
|
225
|
+
return age_key_path
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
# Check platform-specific default locations
|
|
229
|
+
default_paths = [Path.home() / ".config" / "sops" / "age" / "keys.txt"]
|
|
230
|
+
if os.name == "nt": # Windows
|
|
231
|
+
appdata = os.getenv("APPDATA")
|
|
232
|
+
if appdata:
|
|
233
|
+
default_paths.insert(0, Path(appdata) / "sops" / "age" / "keys.txt")
|
|
234
|
+
|
|
235
|
+
for key_path in default_paths:
|
|
236
|
+
if key_path.exists():
|
|
237
|
+
return key_path
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_age_public_key(key_path: Path) -> str | None:
|
|
242
|
+
"""Read the public key from an age key file."""
|
|
243
|
+
try:
|
|
244
|
+
content = key_path.read_text(encoding="utf-8")
|
|
245
|
+
except OSError:
|
|
246
|
+
return None
|
|
247
|
+
for line in content.splitlines():
|
|
248
|
+
if line.startswith("# public key:"):
|
|
249
|
+
return line.split(":", 1)[1].strip()
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_age_key() -> CheckEntry:
|
|
254
|
+
"""Return the age key file availability check result."""
|
|
255
|
+
age_key_env = os.getenv("SOPS_AGE_KEY_FILE")
|
|
256
|
+
if age_key_env:
|
|
257
|
+
age_key_path = Path(age_key_env)
|
|
258
|
+
if not age_key_path.exists():
|
|
259
|
+
return {
|
|
260
|
+
"component": "age_key",
|
|
261
|
+
"status": "warning",
|
|
262
|
+
"details": f"SOPS_AGE_KEY_FILE points to missing file: {age_key_path}",
|
|
263
|
+
}
|
|
264
|
+
# Read and display public key
|
|
265
|
+
public_key = _read_age_public_key(age_key_path)
|
|
266
|
+
if public_key:
|
|
267
|
+
return {
|
|
268
|
+
"component": "age_key",
|
|
269
|
+
"status": "available",
|
|
270
|
+
"details": f"{age_key_path} (public: {public_key})",
|
|
271
|
+
}
|
|
272
|
+
return {"component": "age_key", "status": "available", "details": str(age_key_path)}
|
|
273
|
+
|
|
274
|
+
# Check platform-specific default locations
|
|
275
|
+
default_paths = [Path.home() / ".config" / "sops" / "age" / "keys.txt"]
|
|
276
|
+
if os.name == "nt": # Windows
|
|
277
|
+
appdata = os.getenv("APPDATA")
|
|
278
|
+
if appdata:
|
|
279
|
+
default_paths.insert(0, Path(appdata) / "sops" / "age" / "keys.txt")
|
|
280
|
+
|
|
281
|
+
for key_path in default_paths:
|
|
282
|
+
if key_path.exists():
|
|
283
|
+
# Read and display public key
|
|
284
|
+
public_key = _read_age_public_key(key_path)
|
|
285
|
+
if public_key:
|
|
286
|
+
return {
|
|
287
|
+
"component": "age_key",
|
|
288
|
+
"status": "available",
|
|
289
|
+
"details": f"{key_path} (public: {public_key})",
|
|
290
|
+
}
|
|
291
|
+
return {"component": "age_key", "status": "available", "details": str(key_path)}
|
|
292
|
+
|
|
293
|
+
hint = "%APPDATA%\\sops\\age\\keys.txt" if os.name == "nt" else "~/.config/sops/age/keys.txt"
|
|
294
|
+
return {
|
|
295
|
+
"component": "age_key",
|
|
296
|
+
"status": "warning",
|
|
297
|
+
"details": f"No age key detected (set SOPS_AGE_KEY_FILE or create {hint}).",
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _check_age_key_consistency() -> CheckEntry | None:
|
|
302
|
+
"""Check if age key in keys.txt matches .sops.yaml recipients."""
|
|
303
|
+
# Find age key file and extract public key
|
|
304
|
+
key_path = _find_age_key_path()
|
|
305
|
+
if not key_path:
|
|
306
|
+
return None # Will be reported by _check_age_key
|
|
307
|
+
|
|
308
|
+
public_key = _read_age_public_key(key_path)
|
|
309
|
+
config_path = _find_sops_config_path()
|
|
310
|
+
if not public_key or not config_path:
|
|
311
|
+
return None # Missing components reported elsewhere
|
|
312
|
+
|
|
313
|
+
recipients = _extract_age_recipients_from_config(config_path)
|
|
314
|
+
if not recipients:
|
|
315
|
+
return {
|
|
316
|
+
"component": "age_consistency",
|
|
317
|
+
"status": "warning",
|
|
318
|
+
"details": f"No age recipients found in {config_path}",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Check if current key is in recipients
|
|
322
|
+
if public_key in recipients:
|
|
323
|
+
detail = (
|
|
324
|
+
"Key matches .sops.yaml recipient"
|
|
325
|
+
if len(recipients) == 1
|
|
326
|
+
else f"Key matches .sops.yaml (1 of {len(recipients)} recipients)"
|
|
327
|
+
)
|
|
328
|
+
return {"component": "age_consistency", "status": "available", "details": detail}
|
|
329
|
+
|
|
330
|
+
# Key mismatch!
|
|
331
|
+
short_current = f"{public_key[:12]}...{public_key[-6:]}"
|
|
332
|
+
short_configured = ", ".join(f"{r[:12]}...{r[-6:]}" for r in recipients[:2])
|
|
333
|
+
if len(recipients) > 2:
|
|
334
|
+
short_configured += f" (+{len(recipients) - 2} more)"
|
|
335
|
+
return {
|
|
336
|
+
"component": "age_consistency",
|
|
337
|
+
"status": "warning",
|
|
338
|
+
"details": f"KEY MISMATCH! Your key ({short_current}) not in .sops.yaml ({short_configured})",
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _check_keyring() -> CheckEntry:
|
|
343
|
+
"""Return the keyring backend availability check result."""
|
|
344
|
+
try:
|
|
345
|
+
keyring_module = importlib.import_module("keyring")
|
|
346
|
+
except ImportError:
|
|
347
|
+
return {
|
|
348
|
+
"component": "keyring",
|
|
349
|
+
"status": "missing",
|
|
350
|
+
"details": "Python 'keyring' package not installed.",
|
|
351
|
+
}
|
|
352
|
+
backend = keyring_module.get_keyring().__class__.__name__
|
|
353
|
+
return {"component": "keyring", "status": "available", "details": backend}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _check_gpg_binary() -> CheckEntry:
|
|
357
|
+
"""Return the GPG binary availability check result."""
|
|
358
|
+
# Try common GPG binary names
|
|
359
|
+
gpg_names = ["gpg", "gpg2"]
|
|
360
|
+
for gpg_name in gpg_names:
|
|
361
|
+
gpg_path = shutil.which(gpg_name)
|
|
362
|
+
if gpg_path:
|
|
363
|
+
# Try to get version
|
|
364
|
+
try:
|
|
365
|
+
result = run([gpg_path, "--version"], capture_output=True, text=True, check=False)
|
|
366
|
+
if result.returncode == 0:
|
|
367
|
+
# Extract first line (e.g., "gpg (GnuPG) 2.4.0")
|
|
368
|
+
version_line = result.stdout.split("\n")[0] if result.stdout else gpg_name
|
|
369
|
+
return {"component": "gpg", "status": "available", "details": f"{gpg_path} ({version_line})"}
|
|
370
|
+
except OSError:
|
|
371
|
+
pass
|
|
372
|
+
return {"component": "gpg", "status": "available", "details": gpg_path}
|
|
373
|
+
return {
|
|
374
|
+
"component": "gpg",
|
|
375
|
+
"status": "warning",
|
|
376
|
+
"details": "GPG not found; GPG-encrypted SOPS files will be unavailable.",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _check_gpg_keys() -> CheckEntry:
|
|
381
|
+
"""Return the GPG secret keys availability check result."""
|
|
382
|
+
gpg_path = shutil.which("gpg") or shutil.which("gpg2")
|
|
383
|
+
if not gpg_path:
|
|
384
|
+
return {
|
|
385
|
+
"component": "gpg_keys",
|
|
386
|
+
"status": "warning",
|
|
387
|
+
"details": "GPG not installed; cannot check for secret keys.",
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
result = run(
|
|
392
|
+
[gpg_path, "--list-secret-keys", "--keyid-format=long"],
|
|
393
|
+
capture_output=True,
|
|
394
|
+
text=True,
|
|
395
|
+
check=False,
|
|
396
|
+
)
|
|
397
|
+
except OSError:
|
|
398
|
+
return {
|
|
399
|
+
"component": "gpg_keys",
|
|
400
|
+
"status": "warning",
|
|
401
|
+
"details": "Failed to execute GPG.",
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if result.returncode != 0:
|
|
405
|
+
return {
|
|
406
|
+
"component": "gpg_keys",
|
|
407
|
+
"status": "warning",
|
|
408
|
+
"details": "Failed to list GPG secret keys.",
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
# Check if there are any secret keys
|
|
412
|
+
output = result.stdout.strip()
|
|
413
|
+
if not output or "sec" not in output:
|
|
414
|
+
return {
|
|
415
|
+
"component": "gpg_keys",
|
|
416
|
+
"status": "warning",
|
|
417
|
+
"details": "No GPG secret keys found; generate one with 'gpg --gen-key'.",
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
# Count keys (lines starting with "sec")
|
|
421
|
+
key_count = sum(1 for line in output.split("\n") if line.strip().startswith("sec"))
|
|
422
|
+
return {
|
|
423
|
+
"component": "gpg_keys",
|
|
424
|
+
"status": "available",
|
|
425
|
+
"details": f"{key_count} secret key(s) available",
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _check_boto3() -> CheckEntry:
|
|
430
|
+
"""Return the boto3 availability check result (for KMS)."""
|
|
431
|
+
try:
|
|
432
|
+
boto3_module = importlib.import_module("boto3")
|
|
433
|
+
except ImportError:
|
|
434
|
+
return {
|
|
435
|
+
"component": "boto3",
|
|
436
|
+
"status": "warning",
|
|
437
|
+
"details": "boto3 not installed; KMS provider will be unavailable.",
|
|
438
|
+
}
|
|
439
|
+
version = getattr(boto3_module, "__version__", "unknown")
|
|
440
|
+
return {"component": "boto3", "status": "available", "details": f"v{version}"}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _check_aws_credentials() -> CheckEntry:
|
|
444
|
+
"""Return basic AWS credentials check result (for KMS)."""
|
|
445
|
+
# Check for common credential sources
|
|
446
|
+
cred_sources: list[str] = []
|
|
447
|
+
|
|
448
|
+
if os.getenv("AWS_ACCESS_KEY_ID") and os.getenv("AWS_SECRET_ACCESS_KEY"):
|
|
449
|
+
cred_sources.append("environment variables")
|
|
450
|
+
|
|
451
|
+
if os.getenv("AWS_PROFILE"):
|
|
452
|
+
cred_sources.append(f"profile '{os.getenv('AWS_PROFILE')}'")
|
|
453
|
+
|
|
454
|
+
# Check for credentials file
|
|
455
|
+
aws_creds_file = Path.home() / ".aws" / "credentials"
|
|
456
|
+
if aws_creds_file.exists():
|
|
457
|
+
cred_sources.append("~/.aws/credentials")
|
|
458
|
+
|
|
459
|
+
# Check for config file
|
|
460
|
+
aws_config_file = Path.home() / ".aws" / "config"
|
|
461
|
+
if aws_config_file.exists():
|
|
462
|
+
cred_sources.append("~/.aws/config")
|
|
463
|
+
|
|
464
|
+
if cred_sources:
|
|
465
|
+
return {
|
|
466
|
+
"component": "aws_credentials",
|
|
467
|
+
"status": "available",
|
|
468
|
+
"details": ", ".join(cred_sources),
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
"component": "aws_credentials",
|
|
473
|
+
"status": "warning",
|
|
474
|
+
"details": "No AWS credentials detected; KMS will require explicit configuration.",
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _evaluate_config_state() -> tuple[list[CheckEntry], dict[str, Any] | None]:
|
|
479
|
+
"""Return configuration diagnostics entries and resolver snapshot."""
|
|
480
|
+
try:
|
|
481
|
+
config = get_config()
|
|
482
|
+
except ConfigNotLoadedError:
|
|
483
|
+
warning = {
|
|
484
|
+
"component": "config",
|
|
485
|
+
"status": "warning",
|
|
486
|
+
"details": "Global configuration not loaded; resolver will use defaults.",
|
|
487
|
+
}
|
|
488
|
+
return ([warning], None)
|
|
489
|
+
|
|
490
|
+
secrets_config = getattr(config, "secrets", None)
|
|
491
|
+
resolver_config = secrets_config.to_dict() if secrets_config is not None else None
|
|
492
|
+
return ([], resolver_config)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _derive_doctor_status(checks: Sequence[CheckEntry]) -> CommandStatus:
|
|
496
|
+
"""Compute the overall doctor status from individual checks."""
|
|
497
|
+
if any(entry.get("status") == "missing" for entry in checks):
|
|
498
|
+
return CommandStatus.ERROR
|
|
499
|
+
if any(entry.get("status") == "warning" for entry in checks):
|
|
500
|
+
return CommandStatus.WARNING
|
|
501
|
+
return CommandStatus.OK
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _build_doctor_message(
|
|
505
|
+
checks: Sequence[CheckEntry],
|
|
506
|
+
status: CommandStatus,
|
|
507
|
+
backends: list[str] | None = None,
|
|
508
|
+
) -> str:
|
|
509
|
+
"""Build a descriptive message listing issues by severity."""
|
|
510
|
+
if status is CommandStatus.OK:
|
|
511
|
+
if backends:
|
|
512
|
+
backend_str = ", ".join(backends)
|
|
513
|
+
return f"Secrets subsystem ready (backend: {backend_str})."
|
|
514
|
+
return "Secrets subsystem ready."
|
|
515
|
+
|
|
516
|
+
# Collect issues by severity
|
|
517
|
+
missing = [e["component"] for e in checks if e.get("status") == "missing"]
|
|
518
|
+
warnings = [e["component"] for e in checks if e.get("status") == "warning"]
|
|
519
|
+
|
|
520
|
+
parts: list[str] = []
|
|
521
|
+
if missing:
|
|
522
|
+
parts.append(f"[red]Missing ({len(missing)})[/red]: {', '.join(missing)}")
|
|
523
|
+
if warnings:
|
|
524
|
+
parts.append(f"[yellow]Warnings ({len(warnings)})[/yellow]: {', '.join(warnings)}")
|
|
525
|
+
|
|
526
|
+
return "Secrets subsystem issues:\n" + "\n".join(parts)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def doctor() -> None:
|
|
530
|
+
"""Run diagnostics for the secrets subsystem.
|
|
531
|
+
|
|
532
|
+
Only checks components relevant to the configured backend(s) in .sops.yaml.
|
|
533
|
+
If no .sops.yaml is found, reports a critical error.
|
|
534
|
+
"""
|
|
535
|
+
checks: list[CheckEntry] = [
|
|
536
|
+
# Core SOPS - always required
|
|
537
|
+
_check_sops_binary(),
|
|
538
|
+
_check_sops_config(),
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
# Detect backends from config to conditionally run checks
|
|
542
|
+
config_path = _find_sops_config_path()
|
|
543
|
+
backends = _detect_sops_backends(config_path) if config_path else []
|
|
544
|
+
|
|
545
|
+
# If no specific backend detected, assume age (most common for kstlib users)
|
|
546
|
+
if not backends and config_path:
|
|
547
|
+
backends = ["age"]
|
|
548
|
+
|
|
549
|
+
# Age checks (if age backend configured or default)
|
|
550
|
+
if "age" in backends:
|
|
551
|
+
checks.extend([_check_age_keygen(), _check_age_key()])
|
|
552
|
+
consistency_check = _check_age_key_consistency()
|
|
553
|
+
if consistency_check:
|
|
554
|
+
checks.append(consistency_check)
|
|
555
|
+
|
|
556
|
+
# GPG checks (only if GPG backend configured)
|
|
557
|
+
if "gpg" in backends:
|
|
558
|
+
checks.extend([_check_gpg_binary(), _check_gpg_keys()])
|
|
559
|
+
|
|
560
|
+
# AWS KMS checks (only if KMS backend configured)
|
|
561
|
+
if "kms" in backends:
|
|
562
|
+
checks.extend([_check_boto3(), _check_aws_credentials()])
|
|
563
|
+
|
|
564
|
+
# Keyring is always checked (used for token caching regardless of backend)
|
|
565
|
+
checks.append(_check_keyring())
|
|
566
|
+
|
|
567
|
+
config_checks, resolver_config = _evaluate_config_state()
|
|
568
|
+
checks.extend(config_checks)
|
|
569
|
+
|
|
570
|
+
status = _derive_doctor_status(checks)
|
|
571
|
+
message = _build_doctor_message(checks, status, backends)
|
|
572
|
+
payload: dict[str, Any] = {"checks": checks, "backends": backends}
|
|
573
|
+
if resolver_config is not None:
|
|
574
|
+
payload["resolver"] = resolver_config
|
|
575
|
+
|
|
576
|
+
result = CommandResult(status=status, message=message, payload=payload)
|
|
577
|
+
render_result(result)
|
|
578
|
+
if result.status is CommandStatus.ERROR:
|
|
579
|
+
raise typer.Exit(code=1)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# =============================================================================
|
|
583
|
+
# Init Command Helpers
|
|
584
|
+
# =============================================================================
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _get_default_sops_paths(local: bool) -> tuple[Path, Path]:
|
|
588
|
+
"""Return (age_key_path, sops_config_path) based on platform and local flag.
|
|
589
|
+
|
|
590
|
+
Note: SOPS always looks for .sops.yaml in $HOME (not APPDATA on Windows),
|
|
591
|
+
but age keys are stored in platform-specific locations.
|
|
592
|
+
"""
|
|
593
|
+
if local:
|
|
594
|
+
return (Path(".age-key.txt"), Path(".sops.yaml"))
|
|
595
|
+
|
|
596
|
+
home = Path.home()
|
|
597
|
+
# SOPS config is ALWAYS in $HOME/.sops.yaml (SOPS does not check APPDATA)
|
|
598
|
+
config_path = home / ".sops.yaml"
|
|
599
|
+
|
|
600
|
+
if os.name == "nt": # Windows
|
|
601
|
+
# Age keys are stored in %APPDATA%\sops\age\keys.txt
|
|
602
|
+
appdata = os.getenv("APPDATA")
|
|
603
|
+
if appdata:
|
|
604
|
+
key_path = Path(appdata) / "sops" / "age" / "keys.txt"
|
|
605
|
+
return (key_path, config_path)
|
|
606
|
+
|
|
607
|
+
# Linux/macOS: keys in ~/.config/sops/age/keys.txt
|
|
608
|
+
return (home / ".config" / "sops" / "age" / "keys.txt", config_path)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _generate_age_key(key_path: Path) -> str | None:
|
|
612
|
+
"""Generate an age key and return the public key, or None on failure."""
|
|
613
|
+
age_keygen = shutil.which("age-keygen")
|
|
614
|
+
if not age_keygen:
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
key_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
618
|
+
|
|
619
|
+
result = run(
|
|
620
|
+
[age_keygen, "-o", str(key_path)],
|
|
621
|
+
capture_output=True,
|
|
622
|
+
text=True,
|
|
623
|
+
check=False,
|
|
624
|
+
)
|
|
625
|
+
if result.returncode != 0:
|
|
626
|
+
return None
|
|
627
|
+
|
|
628
|
+
# Extract public key from stderr (age-keygen outputs it there)
|
|
629
|
+
for line in result.stderr.splitlines():
|
|
630
|
+
if line.startswith("Public key:"):
|
|
631
|
+
return line.split(":", 1)[1].strip()
|
|
632
|
+
# Fallback: read from the file
|
|
633
|
+
if key_path.exists():
|
|
634
|
+
content = key_path.read_text(encoding="utf-8")
|
|
635
|
+
for line in content.splitlines():
|
|
636
|
+
if line.startswith("# public key:"):
|
|
637
|
+
return line.split(":", 1)[1].strip()
|
|
638
|
+
return None
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _create_sops_config(config_path: Path, public_key: str) -> bool:
|
|
642
|
+
"""Create a .sops.yaml config file with the given public key."""
|
|
643
|
+
# pylint: disable=no-else-return # TRY300 requires else block
|
|
644
|
+
config_content = f"""\
|
|
645
|
+
# SOPS configuration - generated by kstlib secrets init
|
|
646
|
+
creation_rules:
|
|
647
|
+
- path_regex: .*\\.(yml|yaml)$
|
|
648
|
+
encrypted_regex: .*(?:sops|key|password|secret|token|credentials?).*
|
|
649
|
+
age: {public_key}
|
|
650
|
+
"""
|
|
651
|
+
try:
|
|
652
|
+
config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
|
|
653
|
+
config_path.write_text(config_content, encoding="utf-8")
|
|
654
|
+
except OSError:
|
|
655
|
+
return False
|
|
656
|
+
else:
|
|
657
|
+
return True
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _read_existing_public_key(key_path: Path) -> str | None:
|
|
661
|
+
"""Read public key from an existing age key file."""
|
|
662
|
+
try:
|
|
663
|
+
content = key_path.read_text(encoding="utf-8")
|
|
664
|
+
except OSError:
|
|
665
|
+
return None
|
|
666
|
+
for line in content.splitlines():
|
|
667
|
+
if line.startswith("# public key:"):
|
|
668
|
+
return line.split(":", 1)[1].strip()
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _ensure_age_key(key_path: Path, *, force: bool) -> tuple[str, bool]:
|
|
673
|
+
"""Ensure age key exists, return (public_key, was_created)."""
|
|
674
|
+
if key_path.exists() and not force:
|
|
675
|
+
public_key = _read_existing_public_key(key_path)
|
|
676
|
+
if not public_key:
|
|
677
|
+
exit_error(f"Age key exists at {key_path} but could not read public key.")
|
|
678
|
+
console.print(f"[dim]Age key already exists:[/dim] {key_path}")
|
|
679
|
+
return public_key, False
|
|
680
|
+
|
|
681
|
+
# Delete existing file if force (age-keygen won't overwrite)
|
|
682
|
+
if key_path.exists():
|
|
683
|
+
key_path.unlink()
|
|
684
|
+
public_key = _generate_age_key(key_path)
|
|
685
|
+
if not public_key:
|
|
686
|
+
exit_error(f"Failed to generate age key at {key_path}.")
|
|
687
|
+
return public_key, True
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _ensure_sops_config(config_path: Path, public_key: str, *, force: bool) -> bool:
|
|
691
|
+
"""Ensure SOPS config exists, return True if created."""
|
|
692
|
+
if config_path.exists() and not force:
|
|
693
|
+
console.print(f"[dim]SOPS config already exists:[/dim] {config_path}")
|
|
694
|
+
return False
|
|
695
|
+
|
|
696
|
+
if not _create_sops_config(config_path, public_key):
|
|
697
|
+
exit_error(f"Failed to create SOPS config at {config_path}.")
|
|
698
|
+
return True
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def init(
|
|
702
|
+
*,
|
|
703
|
+
local: bool = INIT_LOCAL_OPTION,
|
|
704
|
+
force: bool = INIT_FORCE_OPTION,
|
|
705
|
+
) -> None:
|
|
706
|
+
"""Quick setup: generate age key and create .sops.yaml config.
|
|
707
|
+
|
|
708
|
+
By default, creates files in the user's home directory (cross-platform).
|
|
709
|
+
Use --local to create them in the current project directory instead.
|
|
710
|
+
|
|
711
|
+
For advanced options (KMS, GPG, multi-recipients), use age-keygen and sops directly.
|
|
712
|
+
"""
|
|
713
|
+
if not shutil.which("age-keygen"):
|
|
714
|
+
exit_error("age-keygen not found. Install age first (see: kstlib secrets doctor).")
|
|
715
|
+
|
|
716
|
+
key_path, config_path = _get_default_sops_paths(local)
|
|
717
|
+
created_files: list[str] = []
|
|
718
|
+
|
|
719
|
+
public_key, key_created = _ensure_age_key(key_path, force=force)
|
|
720
|
+
if key_created:
|
|
721
|
+
created_files.append(str(key_path))
|
|
722
|
+
|
|
723
|
+
if _ensure_sops_config(config_path, public_key, force=force):
|
|
724
|
+
created_files.append(str(config_path))
|
|
725
|
+
|
|
726
|
+
# Build summary
|
|
727
|
+
if created_files:
|
|
728
|
+
summary = "Created:\n" + "\n".join(f" - {f}" for f in created_files)
|
|
729
|
+
summary += f"\n\nPublic key: {public_key}"
|
|
730
|
+
if local:
|
|
731
|
+
summary += "\n\n[dim]Add .age-key.txt to .gitignore![/dim]"
|
|
732
|
+
render_result(CommandResult(status=CommandStatus.OK, message=summary))
|
|
733
|
+
console.print("\n[dim]For advanced options (KMS, GPG), see age-keygen --help and sops docs.[/dim]")
|
|
734
|
+
else:
|
|
735
|
+
render_result(
|
|
736
|
+
CommandResult(
|
|
737
|
+
status=CommandStatus.WARNING,
|
|
738
|
+
message="All files already exist. Use --force to overwrite.",
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
__all__ = ["doctor", "init"]
|