ground-control-cli 0.5.0__tar.gz → 0.5.2__tar.gz
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.
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/PKG-INFO +1 -1
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/__init__.py +1 -3
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/pyproject.toml +1 -1
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/service.py +0 -38
- ground_control_cli-0.5.0/probes/deploy.py +0 -261
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/.gitignore +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/__init__.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/actions.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/claude.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/cli.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/connect.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/hooks.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/machine.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/code/__init__.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/code/bash.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/code/javascript.js +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/code/javascript.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/code/python.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/errors.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/gh.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/git.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/health.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/queue.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/request.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/settings.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/tests.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/probes/workspace.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/queue.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/repos.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/system/__init__.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/system/macos.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/tools.py +0 -0
- {ground_control_cli-0.5.0 → ground_control_cli-0.5.2}/update.py +0 -0
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from collections.abc import Callable
|
|
15
15
|
|
|
16
|
-
from . import code,
|
|
16
|
+
from . import code, gh, git, health, queue, request, tests, workspace
|
|
17
17
|
|
|
18
18
|
# Every probe the client runs: its name -> the function of (params, root) that runs it.
|
|
19
19
|
RUNS: dict[str, Callable[[dict, object], dict]] = {
|
|
@@ -41,8 +41,6 @@ RUNS: dict[str, Callable[[dict, object], dict]] = {
|
|
|
41
41
|
"git.remote_branch": git.remote_branch,
|
|
42
42
|
"request.read": request.read,
|
|
43
43
|
"workspace.changes": workspace.changes,
|
|
44
|
-
"deploy.commit": deploy.commit,
|
|
45
|
-
"deploy.discover": deploy.discover,
|
|
46
44
|
"queue.waiting": queue.waiting,
|
|
47
45
|
}
|
|
48
46
|
|
|
@@ -343,44 +343,6 @@ class Service:
|
|
|
343
343
|
"""
|
|
344
344
|
return self.call("GET", "/api/plugin/settings?" + urlencode({"repo": repo}))
|
|
345
345
|
|
|
346
|
-
def run_checks(
|
|
347
|
-
self, repo: str, feature: str, step: str, moment: str, root, entering=None
|
|
348
|
-
) -> list[dict]:
|
|
349
|
-
"""Have a step's checks run on the service, running here the probes it asks for.
|
|
350
|
-
|
|
351
|
-
Args:
|
|
352
|
-
repo: The repo, as its host writes it, such as owner/repo.
|
|
353
|
-
feature: The feature, `<lane>/<name>`.
|
|
354
|
-
step: The step entered or exited.
|
|
355
|
-
moment: entry or exit.
|
|
356
|
-
root: The checkout the probes run in.
|
|
357
|
-
entering: The fields of the step_enter row being entered, for the entry checks.
|
|
358
|
-
|
|
359
|
-
Returns:
|
|
360
|
-
Every check of the step, as the service decided it, in run order.
|
|
361
|
-
|
|
362
|
-
Raises:
|
|
363
|
-
ServiceError: If the service refuses, cannot be reached, or asks again for a probe
|
|
364
|
-
it was given.
|
|
365
|
-
|
|
366
|
-
Company: the one at the address.
|
|
367
|
-
"""
|
|
368
|
-
body = {"repo": repo, "feature": feature, "step": step, "moment": moment}
|
|
369
|
-
body["entering"] = dict(entering or {})
|
|
370
|
-
found: list[dict] = []
|
|
371
|
-
ran: set[str] = set()
|
|
372
|
-
while True:
|
|
373
|
-
answer = self.call("POST", "/api/plugin/check", body | {"facts": found})
|
|
374
|
-
if not answer["need"]:
|
|
375
|
-
return answer["checks"]
|
|
376
|
-
for probe in answer["need"]:
|
|
377
|
-
key = json.dumps(probe, sort_keys=True)
|
|
378
|
-
if key in ran:
|
|
379
|
-
raise ServiceError(None, f"the service asked again for {probe['name']}")
|
|
380
|
-
ran.add(key)
|
|
381
|
-
fact = probes.run(probe["name"], probe["params"], root)
|
|
382
|
-
found.append(probe | {"fact": fact})
|
|
383
|
-
|
|
384
346
|
def create_plan(self, repo: str, feature: str, role: str, issue: str = "") -> int:
|
|
385
347
|
"""Have the service make a feature's plan, as its first version.
|
|
386
348
|
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
"""The deploy probes: which commit production runs, and where production says it.
|
|
2
|
-
|
|
3
|
-
Production is asked from the developer's machine, never from the service. deploy.commit asks the
|
|
4
|
-
health address once, or every poll until production runs the commit waited for or a descendant
|
|
5
|
-
of it; deploy.discover finds the health address in the repo. A fact is a commit, an address, a
|
|
6
|
-
status, a field's name; never what an answer said beyond the commit.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import re
|
|
13
|
-
import subprocess
|
|
14
|
-
import time
|
|
15
|
-
import urllib.error
|
|
16
|
-
import urllib.request
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
from urllib.parse import urlsplit
|
|
19
|
-
|
|
20
|
-
from . import errors
|
|
21
|
-
|
|
22
|
-
MIN_PREFIX = 7
|
|
23
|
-
HEALTH_PATHS = ("/healthz", "/health", "/healthcheck", "/api/health", "/status", "/ready", "/livez")
|
|
24
|
-
ENV_EXAMPLES = (".env.example", ".env.sample", ".env.template", "example.env", "sample.env")
|
|
25
|
-
PRODUCTION_KEY_RE = re.compile(
|
|
26
|
-
r"""^\s*(?:export\s+)?([A-Z0-9_]*PROD[A-Z0-9_]*_(?:URL|DOMAIN|HOST))\s*=\s*["']?([^"'\s#]+)"""
|
|
27
|
-
)
|
|
28
|
-
TOML_SECTION_RE = re.compile(r"^\s*\[([^\]]+)\]\s*$")
|
|
29
|
-
TOML_HEALTH_RE = re.compile(r"""^\s*healthcheckPath\s*=\s*["']([^"']+)["']""")
|
|
30
|
-
HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
|
|
31
|
-
# The extensions a code reader reads, and the shebang of a script it reads: where discovery looks
|
|
32
|
-
# for a health path as a string literal.
|
|
33
|
-
SOURCE = (".py", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".html", ".sh")
|
|
34
|
-
SHEBANG_RE = re.compile(r"#!\s*(?:\S*/)?(?:env\s+(?:-\S+\s+)*)?(?:\S*/)?(?:bash|sh)(?:\s|$)")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _git(root, *args) -> subprocess.CompletedProcess:
|
|
38
|
-
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _resolve(root, ref: str) -> str:
|
|
42
|
-
out = _git(root, "rev-parse", "--verify", "-q", f"{ref}^{{commit}}")
|
|
43
|
-
return out.stdout.strip() if out.returncode == 0 else ""
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _commit_of(root, prefix: str) -> str:
|
|
47
|
-
"""The full commit a prefix names, fetching origin once when it is not known yet; or ""."""
|
|
48
|
-
if not (len(prefix) >= MIN_PREFIX and HEX_RE.match(prefix)):
|
|
49
|
-
return ""
|
|
50
|
-
found = _resolve(root, prefix)
|
|
51
|
-
if not found:
|
|
52
|
-
_git(root, "fetch", "-q", "origin")
|
|
53
|
-
found = _resolve(root, prefix)
|
|
54
|
-
return found
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _covers(root, returned: str, release: str) -> bool:
|
|
58
|
-
return bool(returned and release) and (
|
|
59
|
-
_git(root, "merge-base", "--is-ancestor", release, returned).returncode == 0
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _fetch(url: str, limit: float) -> tuple:
|
|
64
|
-
"""(the reply's JSON, None, status) or (None, why, status)."""
|
|
65
|
-
try:
|
|
66
|
-
with urllib.request.urlopen(url, timeout=limit) as reply:
|
|
67
|
-
body, status = reply.read(), reply.status
|
|
68
|
-
except urllib.error.HTTPError as e:
|
|
69
|
-
return None, "not_2xx", e.code
|
|
70
|
-
except (urllib.error.URLError, OSError, ValueError):
|
|
71
|
-
return None, "no_reply", None
|
|
72
|
-
try:
|
|
73
|
-
return json.loads(body.decode("utf-8", errors="replace")), None, status
|
|
74
|
-
except json.JSONDecodeError:
|
|
75
|
-
return None, "not_json", status
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def fields(reply) -> dict:
|
|
79
|
-
"""The top-level fields of a JSON object, and those one level down as `outer.inner`."""
|
|
80
|
-
out = {}
|
|
81
|
-
if not isinstance(reply, dict):
|
|
82
|
-
return out
|
|
83
|
-
for key, value in reply.items():
|
|
84
|
-
out[key] = value
|
|
85
|
-
if isinstance(value, dict):
|
|
86
|
-
for inner, deeper in value.items():
|
|
87
|
-
out[f"{key}.{inner}"] = deeper
|
|
88
|
-
return out
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def commit(params: dict, root) -> dict:
|
|
92
|
-
"""deploy.commit: the commit production runs, waiting for one when asked to.
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
params: {"url", "commit_field", "until", "timeout", "poll", "limit"}.
|
|
96
|
-
root: The repo, where commits are resolved.
|
|
97
|
-
|
|
98
|
-
Returns:
|
|
99
|
-
The DeployFacts fields.
|
|
100
|
-
|
|
101
|
-
Company: assumes none.
|
|
102
|
-
"""
|
|
103
|
-
until, end = params.get("until") or "", time.monotonic() + float(params.get("timeout") or 0)
|
|
104
|
-
seen = ""
|
|
105
|
-
while True:
|
|
106
|
-
reply, _, _ = _fetch(params["url"], float(params.get("limit") or 5))
|
|
107
|
-
value = fields(reply).get(params["commit_field"]) if reply is not None else None
|
|
108
|
-
seen = _commit_of(root, value.lower()) if isinstance(value, str) else ""
|
|
109
|
-
if not until or _covers(root, seen, until):
|
|
110
|
-
break
|
|
111
|
-
left = end - time.monotonic()
|
|
112
|
-
if left <= 0:
|
|
113
|
-
break
|
|
114
|
-
time.sleep(min(float(params.get("poll") or 30), left))
|
|
115
|
-
if not seen:
|
|
116
|
-
return {"error": errors.NOT_FOUND}
|
|
117
|
-
return {"commit": seen, "covers": bool(until) and _covers(root, seen, until)}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# --- discover ----------------------------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _read(root, name: str) -> str:
|
|
124
|
-
try:
|
|
125
|
-
return (Path(root) / name).read_text(encoding="utf-8", errors="replace")
|
|
126
|
-
except OSError:
|
|
127
|
-
return ""
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _is_source(root, name: str) -> bool:
|
|
131
|
-
if Path(name).suffix.lower() in SOURCE:
|
|
132
|
-
return True
|
|
133
|
-
first = _read(root, name).split("\n", 1)[0]
|
|
134
|
-
return bool(SHEBANG_RE.match(first.strip()))
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _base(value: str) -> str:
|
|
138
|
-
parts = urlsplit(value if "://" in value else f"https://{value}")
|
|
139
|
-
return f"{parts.scheme}://{parts.netloc}" if parts.netloc else ""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _domain(root, files: list) -> str:
|
|
143
|
-
for name in files:
|
|
144
|
-
if Path(name).name == "vercel.json":
|
|
145
|
-
try:
|
|
146
|
-
alias = json.loads(_read(root, name)).get("alias")
|
|
147
|
-
except (json.JSONDecodeError, AttributeError):
|
|
148
|
-
alias = None
|
|
149
|
-
alias = alias[0] if isinstance(alias, list) and alias else alias
|
|
150
|
-
if isinstance(alias, str) and _base(alias.strip()):
|
|
151
|
-
return _base(alias.strip())
|
|
152
|
-
for name in files:
|
|
153
|
-
if Path(name).name == "CNAME":
|
|
154
|
-
host = _read(root, name).strip().splitlines()
|
|
155
|
-
if host and _base(host[0].strip()):
|
|
156
|
-
return _base(host[0].strip())
|
|
157
|
-
for name in files:
|
|
158
|
-
if Path(name).name in ENV_EXAMPLES or Path(name).name.endswith(".env.example"):
|
|
159
|
-
for line in _read(root, name).splitlines():
|
|
160
|
-
m = PRODUCTION_KEY_RE.match(line)
|
|
161
|
-
if m and _base(m.group(2)):
|
|
162
|
-
return _base(m.group(2))
|
|
163
|
-
return ""
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _host_path(root, files: list) -> str:
|
|
167
|
-
for name in files:
|
|
168
|
-
base = Path(name).name
|
|
169
|
-
if base == "railway.json":
|
|
170
|
-
try:
|
|
171
|
-
deploy = json.loads(_read(root, name)).get("deploy") or {}
|
|
172
|
-
path = deploy.get("healthcheckPath", "")
|
|
173
|
-
except (json.JSONDecodeError, AttributeError):
|
|
174
|
-
path = ""
|
|
175
|
-
if isinstance(path, str) and path.startswith("/"):
|
|
176
|
-
return path
|
|
177
|
-
elif base == "railway.toml":
|
|
178
|
-
section = ""
|
|
179
|
-
for line in _read(root, name).splitlines():
|
|
180
|
-
m = TOML_SECTION_RE.match(line)
|
|
181
|
-
if m:
|
|
182
|
-
section = m.group(1).strip()
|
|
183
|
-
continue
|
|
184
|
-
m = TOML_HEALTH_RE.match(line)
|
|
185
|
-
if m and section == "deploy" and m.group(1).startswith("/"):
|
|
186
|
-
return m.group(1)
|
|
187
|
-
return ""
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _paths(root, files: list) -> list:
|
|
191
|
-
"""The host settings' health path, then each usual one written as a literal in the source."""
|
|
192
|
-
out = [p for p in (_host_path(root, files),) if p]
|
|
193
|
-
texts = [_read(root, n) for n in files if (Path(root) / n).is_file() and _is_source(root, n)]
|
|
194
|
-
for path in HEALTH_PATHS:
|
|
195
|
-
literal = re.compile(r"""(["'`])""" + re.escape(path) + r"\1")
|
|
196
|
-
if path not in out and any(literal.search(t) for t in texts):
|
|
197
|
-
out.append(path)
|
|
198
|
-
return out
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def discover(params: dict, root) -> dict:
|
|
202
|
-
"""deploy.discover: the first health address whose JSON reply names a commit on origin/main.
|
|
203
|
-
|
|
204
|
-
Args:
|
|
205
|
-
params: {"domain", "commit_field"}.
|
|
206
|
-
root: The repo.
|
|
207
|
-
|
|
208
|
-
Returns:
|
|
209
|
-
The DiscoverFacts fields.
|
|
210
|
-
|
|
211
|
-
Company: assumes none.
|
|
212
|
-
"""
|
|
213
|
-
out = _git(root, "ls-files", "-z")
|
|
214
|
-
if out.returncode != 0:
|
|
215
|
-
return {"error": errors.failed(out.stderr)}
|
|
216
|
-
files = [f for f in out.stdout.split("\0") if f]
|
|
217
|
-
base = _base(params["domain"]) if params.get("domain") else _domain(root, files)
|
|
218
|
-
if not base:
|
|
219
|
-
return {"no_domain": True}
|
|
220
|
-
paths = _paths(root, files)
|
|
221
|
-
if not paths:
|
|
222
|
-
return {"no_path": True}
|
|
223
|
-
_git(root, "fetch", "-q", "origin", "main")
|
|
224
|
-
listed = _git(root, "rev-list", "origin/main")
|
|
225
|
-
main = listed.stdout.split() if listed.returncode == 0 else []
|
|
226
|
-
wanted, tried = params.get("commit_field") or "", []
|
|
227
|
-
for path in paths:
|
|
228
|
-
url = base + path
|
|
229
|
-
reply, why, status = _fetch(url, 5)
|
|
230
|
-
if reply is None:
|
|
231
|
-
tried.append({"url": url, "reason": why, "status": status})
|
|
232
|
-
continue
|
|
233
|
-
found = {
|
|
234
|
-
name: value
|
|
235
|
-
for name, value in fields(reply).items()
|
|
236
|
-
if isinstance(value, str)
|
|
237
|
-
and len(value) >= MIN_PREFIX
|
|
238
|
-
and HEX_RE.match(value)
|
|
239
|
-
and any(c.startswith(value.lower()) for c in main)
|
|
240
|
-
}
|
|
241
|
-
if not found:
|
|
242
|
-
names = sorted(fields(reply))
|
|
243
|
-
tried.append(
|
|
244
|
-
{"url": url, "reason": "no_commit_field", "status": status, "fields": names}
|
|
245
|
-
)
|
|
246
|
-
continue
|
|
247
|
-
if wanted:
|
|
248
|
-
if wanted in found:
|
|
249
|
-
return {"url": url, "commit_field": wanted, "value": found[wanted], "tried": tried}
|
|
250
|
-
tried.append(
|
|
251
|
-
{"url": url, "reason": "not_that_field", "status": status, "fields": sorted(found)}
|
|
252
|
-
)
|
|
253
|
-
continue
|
|
254
|
-
if len(found) > 1:
|
|
255
|
-
tried.append(
|
|
256
|
-
{"url": url, "reason": "several_fields", "status": status, "fields": sorted(found)}
|
|
257
|
-
)
|
|
258
|
-
return {"tried": tried}
|
|
259
|
-
[(name, value)] = found.items()
|
|
260
|
-
return {"url": url, "commit_field": name, "value": value, "tried": tried}
|
|
261
|
-
return {"tried": tried}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|