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.
@@ -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