clue2app-cli 0.2.7__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.
- clue2app_cli/__init__.py +10 -0
- clue2app_cli/agents/__init__.py +9 -0
- clue2app_cli/agents/ecr_healer.py +475 -0
- clue2app_cli/auth.py +343 -0
- clue2app_cli/config.py +316 -0
- clue2app_cli/core_client.py +46 -0
- clue2app_cli/creds_module.py +253 -0
- clue2app_cli/curses_wizard.py +839 -0
- clue2app_cli/entrypoint_check.py +215 -0
- clue2app_cli/main.py +6711 -0
- clue2app_cli/sdk.py +2993 -0
- clue2app_cli/tui/__init__.py +33 -0
- clue2app_cli/tui/curses_app.py +733 -0
- clue2app_cli/tui/data.py +316 -0
- clue2app_cli/tui/screens/__init__.py +1 -0
- clue2app_cli/tui/textual_app.py +1021 -0
- clue2app_cli/tui/widgets/__init__.py +1 -0
- clue2app_cli/tui_wizard.py +987 -0
- clue2app_cli/version.py +3 -0
- clue2app_cli/wizard.py +1079 -0
- clue2app_cli-0.2.7.dist-info/METADATA +202 -0
- clue2app_cli-0.2.7.dist-info/RECORD +26 -0
- clue2app_cli-0.2.7.dist-info/WHEEL +5 -0
- clue2app_cli-0.2.7.dist-info/entry_points.txt +3 -0
- clue2app_cli-0.2.7.dist-info/licenses/LICENSE +21 -0
- clue2app_cli-0.2.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Git credential management for kpack-backed apps.
|
|
2
|
+
|
|
3
|
+
Background: every kpack `Image` resource references a `kubernetes.io/basic-auth`
|
|
4
|
+
secret with `username` + `password` (a GitHub PAT or `gho_*` OAuth token).
|
|
5
|
+
When those tokens expire — and `gho_*` ones in particular do silently — the
|
|
6
|
+
next git poll fails with no clear surface in the platform UI. The app stays
|
|
7
|
+
on its last successful build for weeks or months. The user only finds out
|
|
8
|
+
when they push to main and notice their app didn't update.
|
|
9
|
+
|
|
10
|
+
This module provides:
|
|
11
|
+
|
|
12
|
+
- `c2a creds rotate <secret> [--namespace NS]` — interactive (or `--token`
|
|
13
|
+
flag, or `--from-gh` to grab from the local `gh` CLI's authed token).
|
|
14
|
+
Updates ONLY the `password` field of the named secret.
|
|
15
|
+
- `c2a creds check [--all-namespaces]` — read-only scan that finds all
|
|
16
|
+
kpack `Image` resources, reads each one's referenced git secret, probes
|
|
17
|
+
GitHub with the embedded PAT, and reports which ones are dead.
|
|
18
|
+
|
|
19
|
+
These are the "supportable" surface that lets a user (or a future agent)
|
|
20
|
+
recover from a stuck-build state without dropping to kubectl.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import json
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Low-level helpers (no Click dependency — keeps these unit-testable)
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _kubectl(args: list[str], *, capture: bool = True) -> subprocess.CompletedProcess:
|
|
38
|
+
"""Shell out to kubectl. Returns the CompletedProcess so callers can
|
|
39
|
+
decide how to surface stderr."""
|
|
40
|
+
return subprocess.run(
|
|
41
|
+
["kubectl", *args],
|
|
42
|
+
capture_output=capture,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=60,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_gh_token() -> Optional[str]:
|
|
49
|
+
"""Return the local `gh` CLI's active token, or None if gh is not
|
|
50
|
+
installed/authed. Surface but don't crash on failure."""
|
|
51
|
+
if shutil.which("gh") is None:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
r = subprocess.run(
|
|
55
|
+
["gh", "auth", "token"],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
timeout=10,
|
|
59
|
+
)
|
|
60
|
+
if r.returncode != 0:
|
|
61
|
+
return None
|
|
62
|
+
token = r.stdout.strip()
|
|
63
|
+
return token or None
|
|
64
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def rotate_password_secret(secret_name: str, namespace: str, new_password: str) -> tuple[bool, str]:
|
|
69
|
+
"""Update the `password` field of a kubernetes.io/basic-auth secret.
|
|
70
|
+
|
|
71
|
+
Returns `(ok, message)`. Does NOT touch the `username` field — most
|
|
72
|
+
kpack basic-auth secrets have `username=git` or a real GitHub username
|
|
73
|
+
that doesn't change when the PAT rotates.
|
|
74
|
+
"""
|
|
75
|
+
if not new_password:
|
|
76
|
+
return False, "refused to write an empty password"
|
|
77
|
+
b64 = base64.b64encode(new_password.encode("utf-8")).decode("ascii")
|
|
78
|
+
patch = json.dumps({"data": {"password": b64}})
|
|
79
|
+
r = _kubectl(
|
|
80
|
+
["patch", "secret", secret_name, "-n", namespace, "--type=merge", "-p", patch],
|
|
81
|
+
)
|
|
82
|
+
if r.returncode != 0:
|
|
83
|
+
return False, (r.stderr or r.stdout).strip()
|
|
84
|
+
return True, (r.stdout or r.stderr).strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def trigger_kpack_rebuild(image_name: str, namespace: str) -> tuple[bool, str]:
|
|
88
|
+
"""Annotate a kpack Image with `kpack.io/rebuild=<RFC3339-now>` to
|
|
89
|
+
force a fresh build. Idempotent — kpack picks up the new annotation
|
|
90
|
+
timestamp and runs build N+1 on the next reconcile loop."""
|
|
91
|
+
import datetime
|
|
92
|
+
|
|
93
|
+
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
94
|
+
r = _kubectl([
|
|
95
|
+
"annotate", "image.kpack.io", image_name, "-n", namespace,
|
|
96
|
+
f"kpack.io/rebuild={ts}", "--overwrite",
|
|
97
|
+
])
|
|
98
|
+
if r.returncode != 0:
|
|
99
|
+
return False, (r.stderr or r.stdout).strip()
|
|
100
|
+
return True, ts
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Cred liveness probe (read-only — no writes)
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class CredStatus:
|
|
110
|
+
"""Result of probing one git secret's PAT against GitHub.
|
|
111
|
+
|
|
112
|
+
`alive`: bool — token authenticated successfully against GitHub.
|
|
113
|
+
`reason`: short tag for grouping (e.g. "ok", "401-unauthorized",
|
|
114
|
+
"saml-required", "no-password", "unreachable").
|
|
115
|
+
`detail`: human-readable extra info (which scope was missing, etc.).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
alive: bool
|
|
119
|
+
reason: str
|
|
120
|
+
detail: str = ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def probe_github_pat(token: str, *, timeout_s: float = 10.0) -> CredStatus:
|
|
124
|
+
"""Hit `GET https://api.github.com/user` with the token. 200 means the
|
|
125
|
+
token is valid AND has at least basic identity scope. 401 means
|
|
126
|
+
expired/revoked. 403 with `X-GitHub-SSO` header means the org needs
|
|
127
|
+
SAML SSO authorization for this token (the most common 'silent break'
|
|
128
|
+
in clue2solve's setup)."""
|
|
129
|
+
import urllib.error
|
|
130
|
+
import urllib.request
|
|
131
|
+
|
|
132
|
+
if not token:
|
|
133
|
+
return CredStatus(False, "no-password", "secret has no password value")
|
|
134
|
+
|
|
135
|
+
req = urllib.request.Request(
|
|
136
|
+
"https://api.github.com/user",
|
|
137
|
+
headers={
|
|
138
|
+
"Authorization": f"Bearer {token}",
|
|
139
|
+
"Accept": "application/vnd.github+json",
|
|
140
|
+
"User-Agent": "c2a-creds-check/1.0",
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
with urllib.request.urlopen(req, timeout=timeout_s) as r:
|
|
145
|
+
if r.status == 200:
|
|
146
|
+
return CredStatus(True, "ok")
|
|
147
|
+
return CredStatus(False, f"unexpected-{r.status}", "")
|
|
148
|
+
except urllib.error.HTTPError as e:
|
|
149
|
+
if e.code == 401:
|
|
150
|
+
return CredStatus(False, "401-unauthorized", "token expired or revoked")
|
|
151
|
+
if e.code == 403:
|
|
152
|
+
# GitHub uses 403 + the X-GitHub-SSO header to indicate
|
|
153
|
+
# SAML-SSO authorization is missing for the org.
|
|
154
|
+
sso = e.headers.get("X-GitHub-SSO") if e.headers else None
|
|
155
|
+
if sso:
|
|
156
|
+
return CredStatus(False, "saml-required", f"X-GitHub-SSO: {sso}")
|
|
157
|
+
return CredStatus(False, "403-forbidden", "")
|
|
158
|
+
return CredStatus(False, f"http-{e.code}", "")
|
|
159
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
160
|
+
return CredStatus(False, "unreachable", str(e))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Cluster discovery — find all kpack Image -> git secret references
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class KpackImageGitRef:
|
|
170
|
+
namespace: str
|
|
171
|
+
image_name: str # the kpack Image resource name
|
|
172
|
+
git_url: str
|
|
173
|
+
git_revision: str
|
|
174
|
+
service_account: str
|
|
175
|
+
git_secret_name: str # discovered via the SA's secrets list
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def list_kpack_image_git_refs(*, all_namespaces: bool = True, namespace: Optional[str] = None) -> list[KpackImageGitRef]:
|
|
179
|
+
"""Walk every kpack `Image` in scope, follow its serviceAccountName to
|
|
180
|
+
find the attached `kubernetes.io/basic-auth` secret. Returns one ref per
|
|
181
|
+
Image; secrets referenced by multiple Images appear once per Image."""
|
|
182
|
+
args = ["get", "image.kpack.io", "-o", "json"]
|
|
183
|
+
if all_namespaces:
|
|
184
|
+
args.insert(0 + 1, "-A") # `get image.kpack.io -A -o json`
|
|
185
|
+
elif namespace:
|
|
186
|
+
args += ["-n", namespace]
|
|
187
|
+
r = _kubectl(args)
|
|
188
|
+
if r.returncode != 0:
|
|
189
|
+
raise RuntimeError(f"kubectl get image.kpack.io failed: {r.stderr or r.stdout}")
|
|
190
|
+
try:
|
|
191
|
+
doc = json.loads(r.stdout)
|
|
192
|
+
except json.JSONDecodeError as e:
|
|
193
|
+
raise RuntimeError(f"non-JSON kubectl output: {e}") from e
|
|
194
|
+
|
|
195
|
+
refs: list[KpackImageGitRef] = []
|
|
196
|
+
for item in doc.get("items", []):
|
|
197
|
+
md = item.get("metadata", {})
|
|
198
|
+
ns = md.get("namespace", "")
|
|
199
|
+
image_name = md.get("name", "")
|
|
200
|
+
sa = item.get("spec", {}).get("serviceAccountName", "")
|
|
201
|
+
git = item.get("spec", {}).get("source", {}).get("git", {})
|
|
202
|
+
url = git.get("url", "")
|
|
203
|
+
rev = git.get("revision", "")
|
|
204
|
+
|
|
205
|
+
secret_name = _resolve_basic_auth_secret(ns, sa) if sa else ""
|
|
206
|
+
refs.append(KpackImageGitRef(
|
|
207
|
+
namespace=ns,
|
|
208
|
+
image_name=image_name,
|
|
209
|
+
git_url=url,
|
|
210
|
+
git_revision=rev,
|
|
211
|
+
service_account=sa,
|
|
212
|
+
git_secret_name=secret_name,
|
|
213
|
+
))
|
|
214
|
+
return refs
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _resolve_basic_auth_secret(namespace: str, service_account: str) -> str:
|
|
218
|
+
"""Inspect a ServiceAccount and return the name of the first
|
|
219
|
+
`kubernetes.io/basic-auth` secret it references, or "" if none."""
|
|
220
|
+
r = _kubectl([
|
|
221
|
+
"get", "sa", service_account, "-n", namespace, "-o", "json",
|
|
222
|
+
])
|
|
223
|
+
if r.returncode != 0:
|
|
224
|
+
return ""
|
|
225
|
+
try:
|
|
226
|
+
doc = json.loads(r.stdout)
|
|
227
|
+
except json.JSONDecodeError:
|
|
228
|
+
return ""
|
|
229
|
+
for sec_ref in doc.get("secrets", []):
|
|
230
|
+
sec_name = sec_ref.get("name", "")
|
|
231
|
+
if not sec_name:
|
|
232
|
+
continue
|
|
233
|
+
# Check the secret's type — only basic-auth ones hold git PATs.
|
|
234
|
+
r2 = _kubectl(["get", "secret", sec_name, "-n", namespace,
|
|
235
|
+
"-o", "jsonpath={.type}"])
|
|
236
|
+
if r2.returncode == 0 and r2.stdout.strip() == "kubernetes.io/basic-auth":
|
|
237
|
+
return sec_name
|
|
238
|
+
return ""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def read_secret_password(secret_name: str, namespace: str) -> Optional[str]:
|
|
242
|
+
"""Decode the `password` field of a secret. Returns None if the secret
|
|
243
|
+
or field is missing."""
|
|
244
|
+
r = _kubectl([
|
|
245
|
+
"get", "secret", secret_name, "-n", namespace,
|
|
246
|
+
"-o", "jsonpath={.data.password}",
|
|
247
|
+
])
|
|
248
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
return base64.b64decode(r.stdout.strip()).decode("utf-8", errors="replace")
|
|
252
|
+
except (ValueError, base64.binascii.Error):
|
|
253
|
+
return None
|