localargo 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.
- localargo/__about__.py +6 -0
- localargo/__init__.py +6 -0
- localargo/__main__.py +11 -0
- localargo/cli/__init__.py +49 -0
- localargo/cli/commands/__init__.py +5 -0
- localargo/cli/commands/app.py +150 -0
- localargo/cli/commands/cluster.py +312 -0
- localargo/cli/commands/debug.py +478 -0
- localargo/cli/commands/port_forward.py +311 -0
- localargo/cli/commands/secrets.py +300 -0
- localargo/cli/commands/sync.py +291 -0
- localargo/cli/commands/template.py +288 -0
- localargo/cli/commands/up.py +341 -0
- localargo/config/__init__.py +15 -0
- localargo/config/manifest.py +520 -0
- localargo/config/store.py +66 -0
- localargo/core/__init__.py +6 -0
- localargo/core/apps.py +330 -0
- localargo/core/argocd.py +509 -0
- localargo/core/catalog.py +284 -0
- localargo/core/cluster.py +149 -0
- localargo/core/k8s.py +140 -0
- localargo/eyecandy/__init__.py +15 -0
- localargo/eyecandy/progress_steps.py +283 -0
- localargo/eyecandy/table_renderer.py +154 -0
- localargo/eyecandy/tables.py +57 -0
- localargo/logging.py +99 -0
- localargo/manager.py +232 -0
- localargo/providers/__init__.py +6 -0
- localargo/providers/base.py +146 -0
- localargo/providers/k3s.py +206 -0
- localargo/providers/kind.py +326 -0
- localargo/providers/registry.py +52 -0
- localargo/utils/__init__.py +4 -0
- localargo/utils/cli.py +231 -0
- localargo/utils/proc.py +148 -0
- localargo/utils/retry.py +58 -0
- localargo-0.1.0.dist-info/METADATA +149 -0
- localargo-0.1.0.dist-info/RECORD +42 -0
- localargo-0.1.0.dist-info/WHEEL +4 -0
- localargo-0.1.0.dist-info/entry_points.txt +2 -0
- localargo-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,311 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
"""Port forwarding management for ArgoCD applications.
|
5
|
+
|
6
|
+
This module provides commands for managing port forwarding to services
|
7
|
+
in ArgoCD applications and Kubernetes clusters.
|
8
|
+
"""
|
9
|
+
|
10
|
+
from __future__ import annotations
|
11
|
+
|
12
|
+
import shutil
|
13
|
+
import subprocess
|
14
|
+
|
15
|
+
import click
|
16
|
+
|
17
|
+
from localargo.logging import logger
|
18
|
+
from localargo.utils.cli import (
|
19
|
+
check_cli_availability,
|
20
|
+
log_subprocess_error,
|
21
|
+
run_subprocess,
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
@click.group()
|
26
|
+
def port_forward() -> None:
|
27
|
+
"""Manage port forwarding for ArgoCD applications."""
|
28
|
+
|
29
|
+
|
30
|
+
@port_forward.command()
|
31
|
+
@click.argument("service")
|
32
|
+
@click.option("--namespace", "-n", help="Service namespace")
|
33
|
+
@click.option(
|
34
|
+
"--local-port", "-l", type=int, help="Local port (auto-assigned if not specified)"
|
35
|
+
)
|
36
|
+
@click.option(
|
37
|
+
"--remote-port", "-r", type=int, help="Remote port (auto-detected if not specified)"
|
38
|
+
)
|
39
|
+
@click.option("--argocd-namespace", default="argocd", help="ArgoCD namespace")
|
40
|
+
def start(
|
41
|
+
service: str,
|
42
|
+
namespace: str | None,
|
43
|
+
local_port: int | None,
|
44
|
+
remote_port: int | None,
|
45
|
+
argocd_namespace: str,
|
46
|
+
) -> None:
|
47
|
+
"""Start port forwarding for a service."""
|
48
|
+
try:
|
49
|
+
port_config = _resolve_port_forwarding_config(
|
50
|
+
service, namespace, local_port, remote_port, argocd_namespace
|
51
|
+
)
|
52
|
+
|
53
|
+
_execute_port_forwarding(port_config)
|
54
|
+
|
55
|
+
except subprocess.CalledProcessError as e:
|
56
|
+
logger.error("❌ Starting port forward failed with return code %s", e.returncode)
|
57
|
+
if e.stderr:
|
58
|
+
logger.error("Error details: %s", e.stderr.strip())
|
59
|
+
raise
|
60
|
+
except (OSError, ValueError) as e:
|
61
|
+
logger.error("❌ Error starting port forward: %s", e)
|
62
|
+
raise
|
63
|
+
|
64
|
+
|
65
|
+
def _resolve_port_forwarding_config(
|
66
|
+
service: str,
|
67
|
+
namespace: str | None,
|
68
|
+
local_port: int | None,
|
69
|
+
remote_port: int | None,
|
70
|
+
argocd_namespace: str,
|
71
|
+
) -> dict[str, str | int]:
|
72
|
+
"""Resolve and validate port forwarding configuration."""
|
73
|
+
# Auto-detect namespace if not provided
|
74
|
+
resolved_namespace = namespace or _detect_app_namespace(service, argocd_namespace)
|
75
|
+
|
76
|
+
# Auto-detect remote port if not provided
|
77
|
+
resolved_remote_port = remote_port or _detect_service_port(service, resolved_namespace)
|
78
|
+
|
79
|
+
# Set local port to remote port if not provided
|
80
|
+
resolved_local_port = local_port or resolved_remote_port
|
81
|
+
|
82
|
+
logger.info(
|
83
|
+
"Starting port forward: %s:%s/%s:%s",
|
84
|
+
resolved_local_port,
|
85
|
+
resolved_namespace,
|
86
|
+
service,
|
87
|
+
resolved_remote_port,
|
88
|
+
)
|
89
|
+
|
90
|
+
return {
|
91
|
+
"service": service,
|
92
|
+
"namespace": resolved_namespace,
|
93
|
+
"local_port": resolved_local_port,
|
94
|
+
"remote_port": resolved_remote_port,
|
95
|
+
}
|
96
|
+
|
97
|
+
|
98
|
+
def _execute_port_forwarding(config: dict[str, str | int]) -> None:
|
99
|
+
"""Execute the port forwarding command."""
|
100
|
+
cmd = _build_port_forward_command(config)
|
101
|
+
|
102
|
+
logger.info("🔗 Port forward active: http://localhost:%s", config["local_port"])
|
103
|
+
logger.info("Press Ctrl+C to stop...")
|
104
|
+
|
105
|
+
try:
|
106
|
+
subprocess.run(cmd, check=True)
|
107
|
+
except KeyboardInterrupt:
|
108
|
+
logger.info("\n✅ Port forward stopped")
|
109
|
+
|
110
|
+
|
111
|
+
def _build_port_forward_command(config: dict[str, str | int]) -> list[str]:
|
112
|
+
"""Build the kubectl port-forward command."""
|
113
|
+
return [
|
114
|
+
"kubectl",
|
115
|
+
"port-forward",
|
116
|
+
"-n",
|
117
|
+
str(config["namespace"]),
|
118
|
+
f"svc/{config['service']}",
|
119
|
+
f"{config['local_port']}:{config['remote_port']}",
|
120
|
+
]
|
121
|
+
|
122
|
+
|
123
|
+
@port_forward.command()
|
124
|
+
@click.argument("app_name")
|
125
|
+
def app(app_name: str) -> None:
|
126
|
+
"""Port forward all services in an ArgoCD application."""
|
127
|
+
try:
|
128
|
+
# Get application details
|
129
|
+
# Note: JSON parsing for service auto-detection is not yet implemented
|
130
|
+
logger.info("Port forwarding services for app '%s'...", app_name)
|
131
|
+
logger.info("⚠️ Auto-detection of app services not yet implemented")
|
132
|
+
logger.info("Use 'localargo port-forward start <service>' for individual services")
|
133
|
+
|
134
|
+
except FileNotFoundError:
|
135
|
+
logger.error("❌ argocd CLI not found")
|
136
|
+
except subprocess.CalledProcessError as e:
|
137
|
+
log_subprocess_error(e)
|
138
|
+
|
139
|
+
|
140
|
+
@port_forward.command()
|
141
|
+
def list_forwards() -> None:
|
142
|
+
"""List active port forwards."""
|
143
|
+
try:
|
144
|
+
pids = _find_port_forward_processes()
|
145
|
+
if pids:
|
146
|
+
logger.info("Active port forwards (%s):", len(pids))
|
147
|
+
_display_port_forward_details(pids)
|
148
|
+
else:
|
149
|
+
logger.info("No active port forwards found")
|
150
|
+
|
151
|
+
except FileNotFoundError:
|
152
|
+
logger.error("❌ pgrep not available")
|
153
|
+
|
154
|
+
|
155
|
+
def _find_port_forward_processes() -> list[str]:
|
156
|
+
"""Find PIDs of kubectl port-forward processes."""
|
157
|
+
pgrep_path = shutil.which("pgrep")
|
158
|
+
if pgrep_path is None:
|
159
|
+
msg = "pgrep not found in PATH. Please ensure pgrep is installed and available."
|
160
|
+
raise RuntimeError(msg)
|
161
|
+
|
162
|
+
result = subprocess.run(
|
163
|
+
[pgrep_path, "-f", "kubectl port-forward"],
|
164
|
+
capture_output=True,
|
165
|
+
text=True,
|
166
|
+
check=False,
|
167
|
+
)
|
168
|
+
|
169
|
+
if result.returncode == 0 and result.stdout.strip():
|
170
|
+
return result.stdout.strip().split("\n")
|
171
|
+
return []
|
172
|
+
|
173
|
+
|
174
|
+
def _display_port_forward_details(pids: list[str]) -> None:
|
175
|
+
"""Display detailed information about port-forward processes."""
|
176
|
+
for pid in pids:
|
177
|
+
try:
|
178
|
+
process_details = _get_process_details(pid)
|
179
|
+
if process_details:
|
180
|
+
logger.info(process_details)
|
181
|
+
except subprocess.CalledProcessError:
|
182
|
+
pass
|
183
|
+
|
184
|
+
|
185
|
+
def _get_process_details(pid: str) -> str | None:
|
186
|
+
"""Get detailed information about a specific process."""
|
187
|
+
ps_path = shutil.which("ps")
|
188
|
+
if ps_path is None:
|
189
|
+
msg = "ps not found in PATH. Please ensure ps is installed and available."
|
190
|
+
raise RuntimeError(msg)
|
191
|
+
|
192
|
+
ps_result = subprocess.run(
|
193
|
+
[ps_path, "-p", pid, "-o", "pid,ppid,cmd"],
|
194
|
+
capture_output=True,
|
195
|
+
text=True,
|
196
|
+
check=True,
|
197
|
+
)
|
198
|
+
return ps_result.stdout.strip()
|
199
|
+
|
200
|
+
|
201
|
+
@port_forward.command()
|
202
|
+
@click.option("--all-forwards", "-a", is_flag=True, help="Stop all port forwards")
|
203
|
+
def stop(*, all_forwards: bool) -> None:
|
204
|
+
"""Stop port forwarding processes."""
|
205
|
+
try:
|
206
|
+
if all_forwards:
|
207
|
+
# Kill all kubectl port-forward processes
|
208
|
+
pkill_path = shutil.which("pkill")
|
209
|
+
if pkill_path is None:
|
210
|
+
msg = (
|
211
|
+
"pkill not found in PATH. Please ensure pkill is installed and available."
|
212
|
+
)
|
213
|
+
raise RuntimeError(msg)
|
214
|
+
result = subprocess.run(
|
215
|
+
[pkill_path, "-f", "kubectl port-forward"],
|
216
|
+
capture_output=True,
|
217
|
+
text=True,
|
218
|
+
check=False,
|
219
|
+
)
|
220
|
+
if result.returncode == 0:
|
221
|
+
logger.info("✅ All port forwards stopped")
|
222
|
+
else:
|
223
|
+
logger.info("No active port forwards to stop")
|
224
|
+
else:
|
225
|
+
logger.info("Use --all-forwards to stop all port forwards")
|
226
|
+
|
227
|
+
except FileNotFoundError:
|
228
|
+
logger.error("❌ pkill not available")
|
229
|
+
|
230
|
+
|
231
|
+
def _detect_app_namespace(service_name: str, _argocd_namespace: str) -> str:
|
232
|
+
"""Try to detect the namespace for a service based on ArgoCD apps."""
|
233
|
+
try:
|
234
|
+
# Get all applications
|
235
|
+
result = run_subprocess(["argocd", "app", "list", "-o", "name"])
|
236
|
+
except FileNotFoundError:
|
237
|
+
return "default"
|
238
|
+
|
239
|
+
apps = result.stdout.strip().split("\n")
|
240
|
+
|
241
|
+
# For each app, check if it contains the service
|
242
|
+
for app_name in apps:
|
243
|
+
if not app_name.strip():
|
244
|
+
continue
|
245
|
+
|
246
|
+
app_namespace = _extract_namespace_from_app(app_name, service_name)
|
247
|
+
if app_namespace:
|
248
|
+
return app_namespace
|
249
|
+
|
250
|
+
# Default fallback
|
251
|
+
return "default"
|
252
|
+
|
253
|
+
|
254
|
+
def _extract_namespace_from_app(app_name: str, service_name: str) -> str | None:
|
255
|
+
"""Extract namespace from an ArgoCD app if it contains the service."""
|
256
|
+
try:
|
257
|
+
# Get app details
|
258
|
+
app_result = run_subprocess(["argocd", "app", "get", app_name, "--hard-refresh=false"])
|
259
|
+
except subprocess.CalledProcessError:
|
260
|
+
return None
|
261
|
+
|
262
|
+
# Look for the service in the output (simplified)
|
263
|
+
if service_name not in app_result.stdout:
|
264
|
+
return None
|
265
|
+
|
266
|
+
# Extract destination namespace from app
|
267
|
+
lines = app_result.stdout.split("\n")
|
268
|
+
for line in lines:
|
269
|
+
if "Destination:" in line and "namespace:" in line:
|
270
|
+
# Parse namespace from line like:
|
271
|
+
# "Destination: https://kubernetes.default.svc (namespace: myapp)"
|
272
|
+
return line.split("namespace:")[-1].strip().split(")")[0].strip()
|
273
|
+
|
274
|
+
return None
|
275
|
+
|
276
|
+
|
277
|
+
def _detect_service_port(service_name: str, namespace: str) -> int:
|
278
|
+
"""Detect the port for a service."""
|
279
|
+
try:
|
280
|
+
# Get service details
|
281
|
+
kubectl_path = check_cli_availability("kubectl")
|
282
|
+
if kubectl_path is None:
|
283
|
+
msg = (
|
284
|
+
"kubectl not found in PATH. Please ensure kubectl is installed and available."
|
285
|
+
)
|
286
|
+
raise RuntimeError(msg)
|
287
|
+
result = subprocess.run(
|
288
|
+
[
|
289
|
+
kubectl_path,
|
290
|
+
"get",
|
291
|
+
"svc",
|
292
|
+
service_name,
|
293
|
+
"-n",
|
294
|
+
namespace,
|
295
|
+
"-o",
|
296
|
+
"jsonpath={.spec.ports[0].port}",
|
297
|
+
],
|
298
|
+
capture_output=True,
|
299
|
+
text=True,
|
300
|
+
check=True,
|
301
|
+
)
|
302
|
+
|
303
|
+
port = result.stdout.strip()
|
304
|
+
if port:
|
305
|
+
return int(port)
|
306
|
+
except (subprocess.CalledProcessError, ValueError):
|
307
|
+
# Fallback to common ports
|
308
|
+
return 80
|
309
|
+
|
310
|
+
# Fallback to common ports
|
311
|
+
return 80
|
@@ -0,0 +1,300 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
"""Secrets management for ArgoCD applications.
|
5
|
+
|
6
|
+
This module provides commands for managing Kubernetes secrets used by ArgoCD applications.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from __future__ import annotations
|
10
|
+
|
11
|
+
import base64
|
12
|
+
import json
|
13
|
+
import os
|
14
|
+
import subprocess
|
15
|
+
import tempfile
|
16
|
+
from pathlib import Path
|
17
|
+
|
18
|
+
import click
|
19
|
+
import yaml
|
20
|
+
|
21
|
+
from localargo.logging import logger
|
22
|
+
from localargo.utils.cli import run_subprocess
|
23
|
+
|
24
|
+
# Constants
|
25
|
+
MAX_SECRET_DISPLAY_LENGTH = 50
|
26
|
+
|
27
|
+
|
28
|
+
@click.group()
|
29
|
+
def secrets() -> None:
|
30
|
+
"""Manage secrets for local ArgoCD development."""
|
31
|
+
|
32
|
+
|
33
|
+
@secrets.command()
|
34
|
+
@click.argument("name")
|
35
|
+
@click.option("--namespace", "-n", default="default", help="Namespace")
|
36
|
+
@click.option("--from-literal", "-l", multiple=True, help="Key=value pairs")
|
37
|
+
@click.option("--from-file", "-f", multiple=True, help="Key=file pairs")
|
38
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be created")
|
39
|
+
def create(
|
40
|
+
name: str,
|
41
|
+
namespace: str,
|
42
|
+
from_literal: tuple[str, ...],
|
43
|
+
from_file: tuple[str, ...],
|
44
|
+
*,
|
45
|
+
dry_run: bool,
|
46
|
+
) -> None:
|
47
|
+
"""Create a secret from literals or files."""
|
48
|
+
secret_data = _build_secret_data(from_literal, from_file)
|
49
|
+
if not secret_data:
|
50
|
+
return
|
51
|
+
|
52
|
+
secret_yaml = _generate_secret_yaml(name, namespace, secret_data)
|
53
|
+
|
54
|
+
if dry_run:
|
55
|
+
logger.info("--- DRY RUN ---")
|
56
|
+
logger.info(secret_yaml)
|
57
|
+
return
|
58
|
+
|
59
|
+
# Apply the secret
|
60
|
+
_apply_secret_yaml(secret_yaml, name, namespace)
|
61
|
+
|
62
|
+
|
63
|
+
def _build_secret_data(
|
64
|
+
from_literal: tuple[str, ...], from_file: tuple[str, ...]
|
65
|
+
) -> dict[str, str]:
|
66
|
+
"""Build secret data from literals and files."""
|
67
|
+
data: dict[str, str] = {}
|
68
|
+
|
69
|
+
# Process literal values
|
70
|
+
if not _accumulate_literal_values(from_literal, data):
|
71
|
+
return {}
|
72
|
+
|
73
|
+
# Process file values
|
74
|
+
if not _accumulate_file_values(from_file, data):
|
75
|
+
return {}
|
76
|
+
|
77
|
+
if not data:
|
78
|
+
logger.error("❌ No data provided. Use --from-literal or --from-file")
|
79
|
+
|
80
|
+
return data
|
81
|
+
|
82
|
+
|
83
|
+
def _accumulate_literal_values(literals: tuple[str, ...], data: dict[str, str]) -> bool:
|
84
|
+
"""Parse and add literal key=value pairs into data. Return False on error."""
|
85
|
+
for literal in literals:
|
86
|
+
if "=" not in literal:
|
87
|
+
logger.error("❌ Invalid literal format: %s (expected key=value)", literal)
|
88
|
+
return False
|
89
|
+
key, val = literal.split("=", 1)
|
90
|
+
data[key] = base64.b64encode(val.encode()).decode()
|
91
|
+
return True
|
92
|
+
|
93
|
+
|
94
|
+
def _accumulate_file_values(file_specs: tuple[str, ...], data: dict[str, str]) -> bool:
|
95
|
+
"""Parse and add file-based key=file pairs into data. Return False on error."""
|
96
|
+
for file_spec in file_specs:
|
97
|
+
if "=" not in file_spec:
|
98
|
+
logger.error("❌ Invalid file format: %s (expected key=file)", file_spec)
|
99
|
+
return False
|
100
|
+
key, file_path = file_spec.split("=", 1)
|
101
|
+
|
102
|
+
file_path_obj = Path(file_path)
|
103
|
+
if not file_path_obj.exists():
|
104
|
+
logger.error("❌ File not found: %s", file_path)
|
105
|
+
return False
|
106
|
+
|
107
|
+
with open(file_path_obj, "rb") as file_handle:
|
108
|
+
data[key] = base64.b64encode(file_handle.read()).decode()
|
109
|
+
return True
|
110
|
+
|
111
|
+
|
112
|
+
def _generate_secret_yaml(name: str, namespace: str, data: dict[str, str]) -> str:
|
113
|
+
"""Generate YAML for the secret."""
|
114
|
+
yaml_lines = [
|
115
|
+
f"""apiVersion: v1
|
116
|
+
kind: Secret
|
117
|
+
metadata:
|
118
|
+
name: {name}
|
119
|
+
namespace: {namespace}
|
120
|
+
type: Opaque
|
121
|
+
data:
|
122
|
+
"""
|
123
|
+
]
|
124
|
+
|
125
|
+
for key, val in data.items():
|
126
|
+
yaml_lines.append(f" {key}: {val}\n")
|
127
|
+
|
128
|
+
return "".join(yaml_lines)
|
129
|
+
|
130
|
+
|
131
|
+
def _apply_secret_yaml(secret_yaml: str, name: str, namespace: str) -> None:
|
132
|
+
"""Apply the secret YAML to the cluster."""
|
133
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp_file:
|
134
|
+
tmp_file.write(secret_yaml)
|
135
|
+
tmp_path = tmp_file.name
|
136
|
+
|
137
|
+
temp_file_path = Path(tmp_path)
|
138
|
+
|
139
|
+
try:
|
140
|
+
run_subprocess(["kubectl", "apply", "-f", str(temp_file_path)])
|
141
|
+
logger.info("✅ Secret '%s' created in namespace '%s'", name, namespace)
|
142
|
+
except subprocess.CalledProcessError as err:
|
143
|
+
logger.info("❌ Error creating secret: %s", err)
|
144
|
+
finally:
|
145
|
+
temp_file_path.unlink(missing_ok=True)
|
146
|
+
|
147
|
+
|
148
|
+
@secrets.command()
|
149
|
+
@click.argument("name")
|
150
|
+
@click.option("--namespace", "-n", default="default", help="Namespace")
|
151
|
+
def get(name: str, namespace: str) -> None:
|
152
|
+
"""Get and decode secret values."""
|
153
|
+
try:
|
154
|
+
# Get secret data
|
155
|
+
result = run_subprocess(
|
156
|
+
["kubectl", "get", "secret", name, "-n", namespace, "-o", "jsonpath={.data}"]
|
157
|
+
)
|
158
|
+
|
159
|
+
if not result.stdout.strip():
|
160
|
+
logger.info("❌ Secret '%s' not found or has no data", name)
|
161
|
+
return
|
162
|
+
|
163
|
+
# Parse and decode the data
|
164
|
+
data = json.loads(result.stdout)
|
165
|
+
|
166
|
+
logger.info("Secret: %s (namespace: %s)", name, namespace)
|
167
|
+
logger.info("-" * 40)
|
168
|
+
|
169
|
+
for key, encoded_value in data.items():
|
170
|
+
try:
|
171
|
+
decoded = base64.b64decode(encoded_value).decode("utf-8")
|
172
|
+
# Mask sensitive data
|
173
|
+
if len(decoded) > MAX_SECRET_DISPLAY_LENGTH:
|
174
|
+
decoded = decoded[:MAX_SECRET_DISPLAY_LENGTH] + "..."
|
175
|
+
logger.info("%s: %s", key, decoded)
|
176
|
+
except (ValueError, UnicodeDecodeError):
|
177
|
+
logger.info("%s: <binary data or decode error>", key)
|
178
|
+
|
179
|
+
except subprocess.CalledProcessError as e:
|
180
|
+
logger.info(
|
181
|
+
"❌ Error getting secret: %s",
|
182
|
+
e,
|
183
|
+
)
|
184
|
+
|
185
|
+
|
186
|
+
@secrets.command()
|
187
|
+
@click.argument("name")
|
188
|
+
@click.option("--namespace", "-n", default="default", help="Namespace")
|
189
|
+
@click.option("--key", "-k", required=True, help="Secret key to update")
|
190
|
+
@click.option("--value", "-v", help="New value")
|
191
|
+
@click.option("--from-file", help="File containing new value")
|
192
|
+
def update(
|
193
|
+
name: str, namespace: str, key: str, value: str | None, from_file: str | None
|
194
|
+
) -> None:
|
195
|
+
"""Update a secret key."""
|
196
|
+
if not _validate_update_inputs(value, from_file):
|
197
|
+
return
|
198
|
+
|
199
|
+
try:
|
200
|
+
secret = _read_current_secret(name, namespace)
|
201
|
+
encoded_value = _get_encoded_value(value, from_file)
|
202
|
+
|
203
|
+
_update_secret_data(secret, key, encoded_value)
|
204
|
+
_apply_updated_secret(secret, name, key)
|
205
|
+
|
206
|
+
except subprocess.CalledProcessError as e:
|
207
|
+
logger.error("❌ Updating secret failed with return code %s", e.returncode)
|
208
|
+
if e.stderr:
|
209
|
+
logger.error("Error details: %s", e.stderr.strip())
|
210
|
+
raise
|
211
|
+
except (OSError, ValueError) as e:
|
212
|
+
logger.error("❌ Error updating secret: %s", e)
|
213
|
+
raise
|
214
|
+
|
215
|
+
|
216
|
+
def _validate_update_inputs(value: str | None, from_file: str | None) -> bool:
|
217
|
+
"""Validate update command inputs."""
|
218
|
+
if not value and not from_file:
|
219
|
+
logger.error("❌ Must provide --value or --from-file")
|
220
|
+
return False
|
221
|
+
|
222
|
+
if value and from_file:
|
223
|
+
logger.error("❌ Cannot specify both --value and --from-file")
|
224
|
+
return False
|
225
|
+
|
226
|
+
return True
|
227
|
+
|
228
|
+
|
229
|
+
def _read_current_secret(name: str, namespace: str) -> dict[str, str | dict[str, str]]:
|
230
|
+
"""Read the current secret from the cluster."""
|
231
|
+
result = run_subprocess(["kubectl", "get", "secret", name, "-n", namespace, "-o", "yaml"])
|
232
|
+
secret_data = yaml.safe_load(result.stdout)
|
233
|
+
if not isinstance(secret_data, dict):
|
234
|
+
msg = f"Expected dict from kubectl output, got {type(secret_data)}"
|
235
|
+
raise TypeError(msg)
|
236
|
+
return secret_data
|
237
|
+
|
238
|
+
|
239
|
+
def _get_encoded_value(value: str | None, from_file: str | None) -> str:
|
240
|
+
"""Get the base64 encoded value from either direct value or file."""
|
241
|
+
if from_file:
|
242
|
+
if not Path(from_file).exists():
|
243
|
+
logger.error("❌ File not found: %s", from_file)
|
244
|
+
msg = f"File not found: {from_file}"
|
245
|
+
raise FileNotFoundError(msg)
|
246
|
+
with open(from_file, "rb") as f:
|
247
|
+
return base64.b64encode(f.read()).decode()
|
248
|
+
else:
|
249
|
+
if value is None:
|
250
|
+
msg = "Value cannot be None when not reading from file"
|
251
|
+
raise ValueError(msg)
|
252
|
+
return base64.b64encode(value.encode()).decode()
|
253
|
+
|
254
|
+
|
255
|
+
def _update_secret_data(
|
256
|
+
secret: dict[str, str | dict[str, str]], key: str, encoded_value: str
|
257
|
+
) -> None:
|
258
|
+
"""Update the secret data with the new key-value pair."""
|
259
|
+
if "data" not in secret:
|
260
|
+
secret["data"] = {}
|
261
|
+
data_section = secret["data"]
|
262
|
+
if isinstance(data_section, dict):
|
263
|
+
data_section[key] = encoded_value
|
264
|
+
|
265
|
+
|
266
|
+
def _apply_updated_secret(
|
267
|
+
secret: dict[str, str | dict[str, str]], name: str, key: str
|
268
|
+
) -> None:
|
269
|
+
"""Apply the updated secret to the cluster."""
|
270
|
+
temp_file = _write_secret_to_temp_file(secret)
|
271
|
+
try:
|
272
|
+
run_subprocess(["kubectl", "apply", "-f", str(temp_file)])
|
273
|
+
logger.info("✅ Secret '%s' updated (key: %s)", name, key)
|
274
|
+
finally:
|
275
|
+
temp_file.unlink(missing_ok=True)
|
276
|
+
|
277
|
+
|
278
|
+
def _write_secret_to_temp_file(secret: dict[str, str | dict[str, str]]) -> Path:
|
279
|
+
"""Write secret to a temporary file and return the path."""
|
280
|
+
temp_fd, temp_path = tempfile.mkstemp(suffix=".yaml")
|
281
|
+
os.close(temp_fd) # Close the unused file descriptor
|
282
|
+
temp_file = Path(temp_path)
|
283
|
+
temp_file.write_text(yaml.dump(secret), encoding="utf-8")
|
284
|
+
return temp_file
|
285
|
+
|
286
|
+
|
287
|
+
@secrets.command()
|
288
|
+
@click.argument("name")
|
289
|
+
@click.option("--namespace", "-n", default="default", help="Namespace")
|
290
|
+
def delete(name: str, namespace: str) -> None:
|
291
|
+
"""Delete a secret."""
|
292
|
+
if click.confirm(f"Delete secret '{name}' from namespace '{namespace}'?"):
|
293
|
+
try:
|
294
|
+
run_subprocess(["kubectl", "delete", "secret", name, "-n", namespace])
|
295
|
+
logger.info("✅ Secret '%s' deleted", name)
|
296
|
+
except subprocess.CalledProcessError as e:
|
297
|
+
logger.info(
|
298
|
+
"❌ Error deleting secret: %s",
|
299
|
+
e,
|
300
|
+
)
|