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.
Files changed (42) hide show
  1. localargo/__about__.py +6 -0
  2. localargo/__init__.py +6 -0
  3. localargo/__main__.py +11 -0
  4. localargo/cli/__init__.py +49 -0
  5. localargo/cli/commands/__init__.py +5 -0
  6. localargo/cli/commands/app.py +150 -0
  7. localargo/cli/commands/cluster.py +312 -0
  8. localargo/cli/commands/debug.py +478 -0
  9. localargo/cli/commands/port_forward.py +311 -0
  10. localargo/cli/commands/secrets.py +300 -0
  11. localargo/cli/commands/sync.py +291 -0
  12. localargo/cli/commands/template.py +288 -0
  13. localargo/cli/commands/up.py +341 -0
  14. localargo/config/__init__.py +15 -0
  15. localargo/config/manifest.py +520 -0
  16. localargo/config/store.py +66 -0
  17. localargo/core/__init__.py +6 -0
  18. localargo/core/apps.py +330 -0
  19. localargo/core/argocd.py +509 -0
  20. localargo/core/catalog.py +284 -0
  21. localargo/core/cluster.py +149 -0
  22. localargo/core/k8s.py +140 -0
  23. localargo/eyecandy/__init__.py +15 -0
  24. localargo/eyecandy/progress_steps.py +283 -0
  25. localargo/eyecandy/table_renderer.py +154 -0
  26. localargo/eyecandy/tables.py +57 -0
  27. localargo/logging.py +99 -0
  28. localargo/manager.py +232 -0
  29. localargo/providers/__init__.py +6 -0
  30. localargo/providers/base.py +146 -0
  31. localargo/providers/k3s.py +206 -0
  32. localargo/providers/kind.py +326 -0
  33. localargo/providers/registry.py +52 -0
  34. localargo/utils/__init__.py +4 -0
  35. localargo/utils/cli.py +231 -0
  36. localargo/utils/proc.py +148 -0
  37. localargo/utils/retry.py +58 -0
  38. localargo-0.1.0.dist-info/METADATA +149 -0
  39. localargo-0.1.0.dist-info/RECORD +42 -0
  40. localargo-0.1.0.dist-info/WHEEL +4 -0
  41. localargo-0.1.0.dist-info/entry_points.txt +2 -0
  42. 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
+ )