gemini-computer-use 0.2.4 → 0.4.1
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.
- package/README.md +733 -16
- package/bin/cli.js +112 -17
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/agent.cpython-313.pyc +0 -0
- package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
- package/core/__pycache__/schemas.cpython-313.pyc +0 -0
- package/core/__pycache__/server.cpython-313.pyc +0 -0
- package/core/__pycache__/vitals.cpython-313.pyc +0 -0
- package/core/__pycache__/web_share.cpython-313.pyc +0 -0
- package/core/agent.py +503 -18
- package/core/agent_harness.py +9 -2
- package/core/code_cache.py +15 -3
- package/core/device.py +2400 -0
- package/core/domain.py +303 -0
- package/core/mcp_tools.py +1840 -112
- package/core/policies.py +35 -5
- package/core/server.py +78 -32
- package/core/termux.py +916 -0
- package/core/updater.py +1713 -0
- package/core/version.py +31 -0
- package/core/vitals.py +66 -2
- package/core/web_share.py +814 -0
- package/gateway.py +1469 -98
- package/install-gui.cmd +40 -0
- package/install-gui.ps1 +1882 -0
- package/install-gui.strings.json +211 -0
- package/install.ps1 +960 -92
- package/install.sh +1214 -81
- package/ops/doctor.ps1 +735 -0
- package/ops/mesh-domain.sh +86 -0
- package/ops/nginx/README.md +77 -0
- package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
- package/ops/nginx/mesh-domain.vhost.template +58 -0
- package/ops/nginx/render-domain.sh +123 -0
- package/ops/update.ps1 +373 -0
- package/ops/windows/README.md +298 -0
- package/ops/windows/agent-watchdog.ps1 +575 -0
- package/ops/windows/run-hidden.vbs +64 -0
- package/package.json +6 -2
- package/skills/orchestrator.md +12 -0
package/core/updater.py
ADDED
|
@@ -0,0 +1,1713 @@
|
|
|
1
|
+
"""core/updater.py - self-update from the project's GitHub releases.
|
|
2
|
+
|
|
3
|
+
The node installs itself from a release and then keeps itself current the same
|
|
4
|
+
way: it asks the GitHub Releases API what the newest published version is,
|
|
5
|
+
compares it with :mod:`core.version`, downloads the matching release asset,
|
|
6
|
+
verifies its SHA-256, swaps ``core/``/``skills/``/``ops/`` in place with a
|
|
7
|
+
backup it can roll back to, and restarts the agent through whatever supervises
|
|
8
|
+
it (a Windows Scheduled Task, a systemd user unit, a launchd agent, or a plain
|
|
9
|
+
detached ``python -m core.agent``).
|
|
10
|
+
|
|
11
|
+
Design contract
|
|
12
|
+
---------------
|
|
13
|
+
|
|
14
|
+
* **Standard library only.** The node has exactly one third-party dependency
|
|
15
|
+
(``websockets``) and this module must not add a second one: it runs on a
|
|
16
|
+
Windows box with nothing but python.org's Python, on a VPS, and on macOS.
|
|
17
|
+
* **Fail closed.** An update is applied only when the payload could be
|
|
18
|
+
downloaded, its structure verified, and (unless the operator deliberately
|
|
19
|
+
opted out) its SHA-256 matched a published checksum. A half-verified payload
|
|
20
|
+
never touches an installed node.
|
|
21
|
+
* **Never leave a broken node.** Every swap is preceded by a backup and a
|
|
22
|
+
journal entry. A process killed mid-swap rolls back on the next start
|
|
23
|
+
(:func:`recover_pending`), so the worst case is "still on the old version".
|
|
24
|
+
* **One code path for check and apply.** The background thread in
|
|
25
|
+
:mod:`core.agent`, the MCP tool ``mesh_update``, ``ops/update.ps1`` and
|
|
26
|
+
``bin/cli.js update`` all call the functions here, so they cannot disagree
|
|
27
|
+
about what "up to date" means.
|
|
28
|
+
|
|
29
|
+
Configuration (``MESH_*`` keys are read from the environment, and therefore also
|
|
30
|
+
from ``agent.env``, which :mod:`core.agent` exports at startup)::
|
|
31
|
+
|
|
32
|
+
MESH_UPDATE_CHECK 1/0, default 1: check at all
|
|
33
|
+
MESH_UPDATE_AUTO 1/0, default 1: apply a found update by itself
|
|
34
|
+
MESH_UPDATE_CHECK_INTERVAL seconds between checks, default 21600 (6 h), min 300
|
|
35
|
+
MESH_UPDATE_CHANNEL "stable" (default) or "prerelease"
|
|
36
|
+
MESH_UPDATE_TOKEN GitHub token, for the 60 -> 5000 requests/hour limit
|
|
37
|
+
(GITHUB_TOKEN / GH_TOKEN are honoured too)
|
|
38
|
+
MESH_UPDATE_REPO owner/name override, default core.version.REPO_SLUG
|
|
39
|
+
MESH_UPDATE_DIR install directory override (default: the directory
|
|
40
|
+
holding this package)
|
|
41
|
+
MESH_UPDATE_RESTART 1/0, default 1: restart the agent after applying
|
|
42
|
+
MESH_UPDATE_ALLOW_UNVERIFIED
|
|
43
|
+
1/0, default 0: accept a payload with no published
|
|
44
|
+
checksum (source tarball fallback)
|
|
45
|
+
MESH_UPDATE_KEEP_BACKUPS how many old versions to keep, default 3
|
|
46
|
+
MESH_UPDATE_STATE path of the state file
|
|
47
|
+
MESH_UPDATE_STATE_FILE alias of MESH_UPDATE_STATE
|
|
48
|
+
MESH_UPDATE_DRY_RUN 1/0, default 0: stage and verify, change nothing
|
|
49
|
+
|
|
50
|
+
Command line (used by ``ops/update.ps1`` and the CLI)::
|
|
51
|
+
|
|
52
|
+
python -m core.updater --status [--json] report local state, no network
|
|
53
|
+
python -m core.updater --check [--json] ask GitHub; exit 2 when an update exists
|
|
54
|
+
python -m core.updater --apply [--json] check, download, verify, swap, restart
|
|
55
|
+
python -m core.updater --restart-helper ... internal: wait for a pid, then restart
|
|
56
|
+
|
|
57
|
+
Exit codes::
|
|
58
|
+
|
|
59
|
+
0 up to date, or the requested action completed
|
|
60
|
+
2 an update is available and was not applied (--check)
|
|
61
|
+
3 the action failed (network, checksum mismatch, unusable payload)
|
|
62
|
+
4 the update was applied and the node is restarting (nothing left to do)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import argparse
|
|
68
|
+
import hashlib
|
|
69
|
+
import json
|
|
70
|
+
import logging
|
|
71
|
+
import os
|
|
72
|
+
import re
|
|
73
|
+
import shutil
|
|
74
|
+
import signal
|
|
75
|
+
import ssl
|
|
76
|
+
import subprocess
|
|
77
|
+
import sys
|
|
78
|
+
import tarfile
|
|
79
|
+
import tempfile
|
|
80
|
+
import time
|
|
81
|
+
import urllib.error
|
|
82
|
+
import urllib.parse
|
|
83
|
+
import urllib.request
|
|
84
|
+
import zipfile
|
|
85
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
86
|
+
|
|
87
|
+
from core import version as version_module
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger("agy-updater")
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Identity
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
#: Repository that publishes the releases this node updates itself from.
|
|
96
|
+
REPO = version_module.REPO_SLUG
|
|
97
|
+
|
|
98
|
+
API_ROOT = "https://api.github.com"
|
|
99
|
+
USER_AGENT = "AntigravityMesh-Updater/%s (+https://github.com/%s)" % (
|
|
100
|
+
version_module.__version__, REPO)
|
|
101
|
+
|
|
102
|
+
#: What an update replaces inside the install directory. Everything else in that
|
|
103
|
+
#: directory (install-gui.strings.json, a checkout's docs/, templates/) is left
|
|
104
|
+
#: exactly as it is: the updater owns the node payload, not the whole working tree.
|
|
105
|
+
PAYLOAD_DIRS: Tuple[str, ...] = ("core", "skills", "ops")
|
|
106
|
+
PAYLOAD_FILES: Tuple[str, ...] = ("package.json",)
|
|
107
|
+
|
|
108
|
+
#: A payload without these is not a node, whatever its zip says.
|
|
109
|
+
REQUIRED_PATHS: Tuple[str, ...] = (
|
|
110
|
+
"core/agent.py",
|
|
111
|
+
"core/mcp_tools.py",
|
|
112
|
+
"core/updater.py",
|
|
113
|
+
"core/version.py",
|
|
114
|
+
"core/__init__.py",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
#: Names the release is expected to carry, newest format first.
|
|
118
|
+
PAYLOAD_ASSET = "mesh-payload-%s.zip"
|
|
119
|
+
SETUP_ASSET = "AntigravityMesh-Setup-%s.exe"
|
|
120
|
+
|
|
121
|
+
HTTP_TIMEOUT = 20.0
|
|
122
|
+
DOWNLOAD_TIMEOUT = 120.0
|
|
123
|
+
#: Sanity ceiling for a payload download (a release asset is a few hundred KB).
|
|
124
|
+
MAX_DOWNLOAD_BYTES = 256 * 1024 * 1024
|
|
125
|
+
|
|
126
|
+
DEFAULT_CHECK_INTERVAL = 6 * 60 * 60
|
|
127
|
+
MIN_CHECK_INTERVAL = 300
|
|
128
|
+
#: After a failed apply, do not hammer the same release again immediately.
|
|
129
|
+
APPLY_RETRY_BACKOFF = 60 * 60
|
|
130
|
+
|
|
131
|
+
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "antigravity-mesh")
|
|
132
|
+
STATE_FILE = os.path.join(CONFIG_DIR, "update.state.json")
|
|
133
|
+
JOURNAL_FILE = os.path.join(CONFIG_DIR, "update.journal.json")
|
|
134
|
+
LOG_FILE = os.path.join(CONFIG_DIR, "update.log")
|
|
135
|
+
BACKUP_ROOT = os.path.join(CONFIG_DIR, "backups")
|
|
136
|
+
HEARTBEAT_FILE = os.path.join(CONFIG_DIR, "agent.heartbeat")
|
|
137
|
+
WATCHDOG = os.path.join("ops", "windows", "agent-watchdog.ps1")
|
|
138
|
+
SYSTEMD_UNIT = "agy-agent.service"
|
|
139
|
+
LAUNCHD_LABEL = "com.antigravity.mesh.agent"
|
|
140
|
+
|
|
141
|
+
_VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+]([0-9A-Za-z.\-]+))?$")
|
|
142
|
+
_VERSION_LITERAL_RE = re.compile(r"^__version__\s*=\s*[\"']([^\"']+)[\"']", re.M)
|
|
143
|
+
_SHA256_RE = re.compile(r"\b([0-9a-fA-F]{64})\b")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class UpdateError(RuntimeError):
|
|
147
|
+
"""A failed check, download, verification or swap, with a human reason."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RateLimited(UpdateError):
|
|
151
|
+
"""GitHub refused the request because the rate limit was reached."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, message: str, retry_after: float = 0.0, reset_at: float = 0.0):
|
|
154
|
+
super().__init__(message)
|
|
155
|
+
self.retry_after = max(0.0, float(retry_after or 0.0))
|
|
156
|
+
self.reset_at = max(0.0, float(reset_at or 0.0))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Small utilities
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def now_iso() -> str:
|
|
164
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _env_flag(name: str, default: bool = False, env: Optional[Dict[str, str]] = None) -> bool:
|
|
168
|
+
source = os.environ if env is None else env
|
|
169
|
+
raw = source.get(name)
|
|
170
|
+
if raw is None or str(raw).strip() == "":
|
|
171
|
+
return default
|
|
172
|
+
return str(raw).strip().lower() in ("1", "true", "yes", "on", "y")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _env_int(name: str, default: int, env: Optional[Dict[str, str]] = None) -> int:
|
|
176
|
+
source = os.environ if env is None else env
|
|
177
|
+
try:
|
|
178
|
+
return int(str(source.get(name, "")).strip())
|
|
179
|
+
except Exception:
|
|
180
|
+
return default
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _env_str(name: str, default: str = "", env: Optional[Dict[str, str]] = None) -> str:
|
|
184
|
+
source = os.environ if env is None else env
|
|
185
|
+
value = source.get(name)
|
|
186
|
+
if value is None:
|
|
187
|
+
return default
|
|
188
|
+
value = str(value).strip()
|
|
189
|
+
return value or default
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _read_json(path: str) -> Optional[Dict[str, Any]]:
|
|
193
|
+
try:
|
|
194
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
195
|
+
data = json.load(handle)
|
|
196
|
+
except Exception:
|
|
197
|
+
return None
|
|
198
|
+
return data if isinstance(data, dict) else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
|
|
202
|
+
directory = os.path.dirname(path)
|
|
203
|
+
if directory:
|
|
204
|
+
os.makedirs(directory, exist_ok=True)
|
|
205
|
+
temporary = path + ".tmp"
|
|
206
|
+
with open(temporary, "w", encoding="utf-8") as handle:
|
|
207
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
208
|
+
handle.write("\n")
|
|
209
|
+
os.replace(temporary, path)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def file_log(message: str, path: Optional[str] = None) -> None:
|
|
213
|
+
"""Append one line to the updater journal file (never raises)."""
|
|
214
|
+
target = path or LOG_FILE
|
|
215
|
+
try:
|
|
216
|
+
directory = os.path.dirname(target)
|
|
217
|
+
if directory:
|
|
218
|
+
os.makedirs(directory, exist_ok=True)
|
|
219
|
+
stamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
|
|
220
|
+
with open(target, "a", encoding="utf-8") as handle:
|
|
221
|
+
handle.write("[%s] %s\n" % (stamp, message.replace("\n", " ")))
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def sha256_file(path: str, chunk: int = 1024 * 256) -> str:
|
|
227
|
+
digest = hashlib.sha256()
|
|
228
|
+
with open(path, "rb") as handle:
|
|
229
|
+
while True:
|
|
230
|
+
block = handle.read(chunk)
|
|
231
|
+
if not block:
|
|
232
|
+
break
|
|
233
|
+
digest.update(block)
|
|
234
|
+
return digest.hexdigest()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# Versions
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def parse_version(text: Any) -> Optional[Tuple[int, int, int, int, Tuple]]:
|
|
242
|
+
"""Parse ``MAJOR.MINOR.PATCH[-pre]`` (a leading ``v`` is accepted).
|
|
243
|
+
|
|
244
|
+
Returns a tuple that sorts the way semver requires: the release of a version
|
|
245
|
+
is newer than its pre-releases (``0.3.0 > 0.3.0-rc.1``), and pre-release
|
|
246
|
+
identifiers compare segment by segment, numbers before words.
|
|
247
|
+
"""
|
|
248
|
+
if text is None:
|
|
249
|
+
return None
|
|
250
|
+
match = _VERSION_RE.match(str(text).strip())
|
|
251
|
+
if not match:
|
|
252
|
+
return None
|
|
253
|
+
major, minor, patch = (int(match.group(index)) for index in (1, 2, 3))
|
|
254
|
+
pre = match.group(4) or ""
|
|
255
|
+
if not pre:
|
|
256
|
+
return (major, minor, patch, 1, ())
|
|
257
|
+
segments: List[Tuple[int, int, str]] = []
|
|
258
|
+
for chunk in re.split(r"[.\-]", pre):
|
|
259
|
+
if chunk.isdigit():
|
|
260
|
+
segments.append((0, int(chunk), ""))
|
|
261
|
+
else:
|
|
262
|
+
segments.append((1, 0, chunk))
|
|
263
|
+
return (major, minor, patch, 0, tuple(segments))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_newer(candidate: Any, current: Any) -> bool:
|
|
267
|
+
"""True when ``candidate`` is a strictly newer, parseable version.
|
|
268
|
+
|
|
269
|
+
An unparseable candidate is never newer: installing something the node
|
|
270
|
+
cannot compare would risk a restart loop on a malformed tag.
|
|
271
|
+
"""
|
|
272
|
+
left = parse_version(candidate)
|
|
273
|
+
right = parse_version(current)
|
|
274
|
+
if left is None:
|
|
275
|
+
return False
|
|
276
|
+
if right is None:
|
|
277
|
+
return False
|
|
278
|
+
return left > right
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def local_version(install_dir: Optional[str] = None) -> str:
|
|
282
|
+
"""The version installed in ``install_dir`` (the running one by default).
|
|
283
|
+
|
|
284
|
+
Read from ``core/version.py`` on disk rather than from the imported module,
|
|
285
|
+
so a CLI or a watchdog that did not import the node still reports what is
|
|
286
|
+
actually installed.
|
|
287
|
+
"""
|
|
288
|
+
override = os.environ.get("MESH_UPDATE_LOCAL_VERSION")
|
|
289
|
+
if override:
|
|
290
|
+
return override.strip()
|
|
291
|
+
root = install_dir or default_install_dir()
|
|
292
|
+
path = os.path.join(root, "core", "version.py")
|
|
293
|
+
try:
|
|
294
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
295
|
+
match = _VERSION_LITERAL_RE.search(handle.read())
|
|
296
|
+
if match:
|
|
297
|
+
return match.group(1).strip()
|
|
298
|
+
except OSError:
|
|
299
|
+
pass
|
|
300
|
+
marker = os.path.join(root, "VERSION")
|
|
301
|
+
try:
|
|
302
|
+
with open(marker, "r", encoding="utf-8", errors="replace") as handle:
|
|
303
|
+
text = handle.read().strip()
|
|
304
|
+
if text:
|
|
305
|
+
return text.split()[0]
|
|
306
|
+
except OSError:
|
|
307
|
+
pass
|
|
308
|
+
return version_module.__version__
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Configuration
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def default_install_dir() -> str:
|
|
316
|
+
"""The directory holding the ``core`` package this module was loaded from."""
|
|
317
|
+
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def config_from_env(env: Optional[Dict[str, str]] = None,
|
|
321
|
+
install_dir: Optional[str] = None) -> Dict[str, Any]:
|
|
322
|
+
"""Every updater knob in one dict, from the environment (and thus agent.env)."""
|
|
323
|
+
source = os.environ if env is None else env
|
|
324
|
+
channel = _env_str("MESH_UPDATE_CHANNEL", "stable", source).lower()
|
|
325
|
+
prerelease = channel in ("prerelease", "pre", "beta", "nightly", "insiders")
|
|
326
|
+
interval = _env_int("MESH_UPDATE_CHECK_INTERVAL", DEFAULT_CHECK_INTERVAL, source)
|
|
327
|
+
if interval < MIN_CHECK_INTERVAL:
|
|
328
|
+
interval = MIN_CHECK_INTERVAL
|
|
329
|
+
token = (_env_str("MESH_UPDATE_TOKEN", "", source)
|
|
330
|
+
or _env_str("GITHUB_TOKEN", "", source)
|
|
331
|
+
or _env_str("GH_TOKEN", "", source))
|
|
332
|
+
repo = _env_str("MESH_UPDATE_REPO", REPO, source)
|
|
333
|
+
state = (_env_str("MESH_UPDATE_STATE", "", source)
|
|
334
|
+
or _env_str("MESH_UPDATE_STATE_FILE", "", source)
|
|
335
|
+
or STATE_FILE)
|
|
336
|
+
root = (install_dir
|
|
337
|
+
or _env_str("MESH_UPDATE_DIR", "", source)
|
|
338
|
+
or _env_str("MESH_INSTALL_DIR", "", source)
|
|
339
|
+
or default_install_dir())
|
|
340
|
+
return {
|
|
341
|
+
"enabled": _env_flag("MESH_UPDATE_CHECK", True, source),
|
|
342
|
+
"auto": _env_flag("MESH_UPDATE_AUTO", True, source),
|
|
343
|
+
"restart": _env_flag("MESH_UPDATE_RESTART", True, source),
|
|
344
|
+
"interval": interval,
|
|
345
|
+
"prerelease": prerelease,
|
|
346
|
+
"channel": "prerelease" if prerelease else "stable",
|
|
347
|
+
"token": token,
|
|
348
|
+
"repo": repo,
|
|
349
|
+
"install_dir": os.path.abspath(root),
|
|
350
|
+
"allow_unverified": _env_flag("MESH_UPDATE_ALLOW_UNVERIFIED", False, source),
|
|
351
|
+
"keep_backups": max(0, _env_int("MESH_UPDATE_KEEP_BACKUPS", 3, source)),
|
|
352
|
+
"dry_run": _env_flag("MESH_UPDATE_DRY_RUN", False, source),
|
|
353
|
+
"state_file": state,
|
|
354
|
+
"journal_file": JOURNAL_FILE,
|
|
355
|
+
"log_file": LOG_FILE,
|
|
356
|
+
"backup_root": BACKUP_ROOT,
|
|
357
|
+
"heartbeat_file": HEARTBEAT_FILE,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# HTTP (injectable, so the whole updater is testable without a network)
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
366
|
+
return ssl.create_default_context()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def http_get(url: str, *, headers: Optional[Dict[str, str]] = None,
|
|
370
|
+
timeout: float = HTTP_TIMEOUT,
|
|
371
|
+
max_bytes: Optional[int] = None) -> Tuple[int, Dict[str, str], bytes]:
|
|
372
|
+
"""One GET with a real User-Agent; returns ``(status, headers, body)``.
|
|
373
|
+
|
|
374
|
+
HTTP error statuses come back as data (a 404 from the releases API is an
|
|
375
|
+
answer, not an exception); transport failures raise :class:`UpdateError`.
|
|
376
|
+
"""
|
|
377
|
+
request = urllib.request.Request(url, headers=headers or {}, method="GET")
|
|
378
|
+
if not request.get_header("User-agent"):
|
|
379
|
+
request.add_header("User-Agent", USER_AGENT)
|
|
380
|
+
try:
|
|
381
|
+
with urllib.request.urlopen(request, timeout=timeout, context=_ssl_context()) as response:
|
|
382
|
+
body = response.read() if max_bytes is None else response.read(max_bytes)
|
|
383
|
+
return (int(getattr(response, "status", 200) or 200),
|
|
384
|
+
{str(k).lower(): str(v) for k, v in response.headers.items()},
|
|
385
|
+
body)
|
|
386
|
+
except urllib.error.HTTPError as exc:
|
|
387
|
+
raw = b""
|
|
388
|
+
try:
|
|
389
|
+
raw = exc.read() or b""
|
|
390
|
+
except Exception:
|
|
391
|
+
raw = b""
|
|
392
|
+
return (int(exc.code),
|
|
393
|
+
{str(k).lower(): str(v) for k, v in (exc.headers or {}).items()},
|
|
394
|
+
raw)
|
|
395
|
+
except urllib.error.URLError as exc:
|
|
396
|
+
raise UpdateError("network error reaching %s: %s" % (_short(url), exc.reason))
|
|
397
|
+
except Exception as exc: # socket.timeout, ssl.SSLError, ...
|
|
398
|
+
raise UpdateError("network error reaching %s: %s" % (_short(url), exc))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _short(url: str) -> str:
|
|
402
|
+
parsed = urllib.parse.urlsplit(url)
|
|
403
|
+
return parsed.netloc or url
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _api_headers(token: str = "") -> Dict[str, str]:
|
|
407
|
+
headers = {
|
|
408
|
+
"User-Agent": USER_AGENT,
|
|
409
|
+
"Accept": "application/vnd.github+json",
|
|
410
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
411
|
+
}
|
|
412
|
+
if token:
|
|
413
|
+
headers["Authorization"] = "Bearer %s" % token
|
|
414
|
+
return headers
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _raise_rate_limited(status: int, headers: Dict[str, str], body: bytes) -> None:
|
|
418
|
+
retry_after = 0.0
|
|
419
|
+
try:
|
|
420
|
+
retry_after = float(headers.get("retry-after", "") or 0.0)
|
|
421
|
+
except Exception:
|
|
422
|
+
retry_after = 0.0
|
|
423
|
+
reset_at = 0.0
|
|
424
|
+
try:
|
|
425
|
+
reset_at = float(headers.get("x-ratelimit-reset", "") or 0.0)
|
|
426
|
+
except Exception:
|
|
427
|
+
reset_at = 0.0
|
|
428
|
+
text = body.decode("utf-8", "replace")[:200] if body else ""
|
|
429
|
+
raise RateLimited(
|
|
430
|
+
"GitHub rate limit reached (HTTP %d%s). Set MESH_UPDATE_TOKEN to raise the "
|
|
431
|
+
"limit from 60 to 5000 requests per hour." % (status, (": " + text) if text else ""),
|
|
432
|
+
retry_after=retry_after, reset_at=reset_at)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# GitHub releases
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def version_from_tag(tag: Any) -> Optional[str]:
|
|
440
|
+
"""``v0.3.0-rc.1`` -> ``0.3.0-rc.1``; None when the tag is not a version."""
|
|
441
|
+
match = _VERSION_RE.match(str(tag or "").strip())
|
|
442
|
+
if not match:
|
|
443
|
+
return None
|
|
444
|
+
core = "%d.%d.%d" % (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
445
|
+
return core + (("-" + match.group(4)) if match.group(4) else "")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _release_from_payload(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
449
|
+
tag = str(payload.get("tag_name") or "").strip()
|
|
450
|
+
canonical = version_from_tag(tag)
|
|
451
|
+
if canonical is None:
|
|
452
|
+
# A tag that is not a version (a date, a codename) cannot be compared, and
|
|
453
|
+
# guessing is worse than skipping: another release in the list may parse.
|
|
454
|
+
return None
|
|
455
|
+
if payload.get("draft"):
|
|
456
|
+
return None
|
|
457
|
+
assets = []
|
|
458
|
+
for asset in payload.get("assets") or []:
|
|
459
|
+
if not isinstance(asset, dict) or not asset.get("name"):
|
|
460
|
+
continue
|
|
461
|
+
assets.append({
|
|
462
|
+
"name": str(asset.get("name")),
|
|
463
|
+
"url": str(asset.get("browser_download_url") or ""),
|
|
464
|
+
"size": int(asset.get("size") or 0),
|
|
465
|
+
"digest": str(asset.get("digest") or ""),
|
|
466
|
+
"content_type": str(asset.get("content_type") or ""),
|
|
467
|
+
})
|
|
468
|
+
return {
|
|
469
|
+
"tag": tag,
|
|
470
|
+
"version": canonical,
|
|
471
|
+
"parsed": parse_version(canonical),
|
|
472
|
+
"name": str(payload.get("name") or tag),
|
|
473
|
+
# Notes travel into the state file and the MCP answer, so they are
|
|
474
|
+
# bounded: a long changelog must not bloat either.
|
|
475
|
+
"notes": str(payload.get("body") or "")[:4000],
|
|
476
|
+
"published_at": str(payload.get("published_at") or ""),
|
|
477
|
+
"html_url": str(payload.get("html_url") or ""),
|
|
478
|
+
"prerelease": bool(payload.get("prerelease")),
|
|
479
|
+
"assets": assets,
|
|
480
|
+
"tarball_url": str(payload.get("tarball_url") or ""),
|
|
481
|
+
"zipball_url": str(payload.get("zipball_url") or ""),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _public_release(release: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
486
|
+
"""The release without its internal sort key, for JSON results."""
|
|
487
|
+
if not release:
|
|
488
|
+
return None
|
|
489
|
+
return {key: value for key, value in release.items() if key != "parsed"}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def fetch_latest_release(*, repo: str = REPO, token: str = "",
|
|
493
|
+
prerelease: bool = False, timeout: float = HTTP_TIMEOUT,
|
|
494
|
+
etag: str = "") -> Optional[Dict[str, Any]]:
|
|
495
|
+
"""The newest published release, or ``None`` when the request came back 304.
|
|
496
|
+
|
|
497
|
+
``prerelease`` asks the list endpoint for the newest release of any kind and
|
|
498
|
+
picks the first one that carries a parseable version; the stable channel asks
|
|
499
|
+
``/releases/latest``, which already excludes drafts and pre-releases.
|
|
500
|
+
"""
|
|
501
|
+
if prerelease:
|
|
502
|
+
url = "%s/repos/%s/releases?per_page=20" % (API_ROOT, repo)
|
|
503
|
+
else:
|
|
504
|
+
url = "%s/repos/%s/releases/latest" % (API_ROOT, repo)
|
|
505
|
+
headers = _api_headers(token)
|
|
506
|
+
if etag:
|
|
507
|
+
headers["If-None-Match"] = etag
|
|
508
|
+
status, response_headers, body = http_get(url, headers=headers, timeout=timeout)
|
|
509
|
+
if status == 304:
|
|
510
|
+
return None
|
|
511
|
+
if status in (403, 429):
|
|
512
|
+
if response_headers.get("x-ratelimit-remaining") == "0" or status == 429:
|
|
513
|
+
_raise_rate_limited(status, response_headers, body)
|
|
514
|
+
if status == 404:
|
|
515
|
+
raise UpdateError("no releases found in %s (HTTP 404)" % repo)
|
|
516
|
+
if status != 200:
|
|
517
|
+
raise UpdateError("GitHub answered HTTP %d for %s" % (status, _short(url)))
|
|
518
|
+
try:
|
|
519
|
+
payload = json.loads(body.decode("utf-8", "replace"))
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
raise UpdateError("GitHub answered with invalid JSON: %s" % exc)
|
|
522
|
+
if prerelease:
|
|
523
|
+
if not isinstance(payload, list):
|
|
524
|
+
raise UpdateError("the releases list was not a JSON array")
|
|
525
|
+
for item in payload:
|
|
526
|
+
if not isinstance(item, dict):
|
|
527
|
+
continue
|
|
528
|
+
release = _release_from_payload(item)
|
|
529
|
+
if release is not None:
|
|
530
|
+
return release
|
|
531
|
+
raise UpdateError("no release in %s carries a version tag" % repo)
|
|
532
|
+
if not isinstance(payload, dict):
|
|
533
|
+
raise UpdateError("the latest-release answer was not a JSON object")
|
|
534
|
+
release = _release_from_payload(payload)
|
|
535
|
+
if release is None:
|
|
536
|
+
raise UpdateError("release %s carries no parseable version tag"
|
|
537
|
+
% payload.get("tag_name"))
|
|
538
|
+
return release
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# Assets
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def checksum_asset_name(name: str) -> str:
|
|
546
|
+
return name + ".sha256"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def find_asset(release: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
|
|
550
|
+
for asset in release.get("assets") or []:
|
|
551
|
+
if asset.get("name") == name:
|
|
552
|
+
return asset
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def choose_asset(release: Dict[str, Any], *, platform: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
557
|
+
"""The best asset for this platform, in preference order.
|
|
558
|
+
|
|
559
|
+
1. ``mesh-payload-<version>.zip`` - the node payload, built by the release
|
|
560
|
+
workflow, verified with its published ``.sha256``;
|
|
561
|
+
2. ``AntigravityMesh-Setup-<version>.exe`` - the Windows setup, unpacked with
|
|
562
|
+
``--unpack`` and applied from there (Windows only);
|
|
563
|
+
3. the release source archive - only when the operator allowed unverified
|
|
564
|
+
payloads, because GitHub publishes no checksum for it.
|
|
565
|
+
"""
|
|
566
|
+
version = str(release.get("version") or "")
|
|
567
|
+
payload = find_asset(release, PAYLOAD_ASSET % version)
|
|
568
|
+
if payload and payload.get("size", 0) > 0:
|
|
569
|
+
return {"kind": "payload", "asset": payload, "name": payload["name"],
|
|
570
|
+
"url": payload["url"], "size": payload["size"], "verified": True}
|
|
571
|
+
name = platform or ("windows" if os.name == "nt" else sys.platform)
|
|
572
|
+
if name.startswith("win"):
|
|
573
|
+
setup = find_asset(release, SETUP_ASSET % version)
|
|
574
|
+
if setup and setup.get("url"):
|
|
575
|
+
return {"kind": "setup", "asset": setup, "name": setup["name"],
|
|
576
|
+
"url": setup["url"], "size": setup["size"], "verified": True}
|
|
577
|
+
tarball = str(release.get("tarball_url") or "")
|
|
578
|
+
if tarball:
|
|
579
|
+
return {"kind": "tarball", "asset": None, "name": "%s.tar.gz" % str(release.get("tag") or version),
|
|
580
|
+
"url": tarball, "size": 0, "verified": False}
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _checksum_for(release: Dict[str, Any], asset_name: str,
|
|
585
|
+
*, timeout: float = HTTP_TIMEOUT) -> str:
|
|
586
|
+
"""The published SHA-256 of ``asset_name``, or "" when the release has none."""
|
|
587
|
+
asset = find_asset(release, checksum_asset_name(asset_name))
|
|
588
|
+
if asset and asset.get("url"):
|
|
589
|
+
status, _headers, body = http_get(asset["url"], timeout=timeout, max_bytes=4096)
|
|
590
|
+
if status == 200:
|
|
591
|
+
match = _SHA256_RE.search(body.decode("utf-8", "replace"))
|
|
592
|
+
if match:
|
|
593
|
+
return match.group(1).lower()
|
|
594
|
+
digest = ""
|
|
595
|
+
for candidate in release.get("assets") or []:
|
|
596
|
+
if candidate.get("name") == asset_name:
|
|
597
|
+
digest = str(candidate.get("digest") or "")
|
|
598
|
+
break
|
|
599
|
+
if digest.lower().startswith("sha256:"):
|
|
600
|
+
return digest.split(":", 1)[1].strip().lower()
|
|
601
|
+
return ""
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ---------------------------------------------------------------------------
|
|
605
|
+
# Download
|
|
606
|
+
# ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
def download_to_file(url: str, destination: str, *, expected_sha256: str = "",
|
|
609
|
+
timeout: float = DOWNLOAD_TIMEOUT, token: str = "",
|
|
610
|
+
max_bytes: int = MAX_DOWNLOAD_BYTES) -> Dict[str, Any]:
|
|
611
|
+
"""Stream ``url`` into ``destination``, hashing as it goes.
|
|
612
|
+
|
|
613
|
+
The file is written to ``destination + ".part"`` and only renamed into place
|
|
614
|
+
once it is complete and verified, so an interrupted download can never be
|
|
615
|
+
mistaken for a payload.
|
|
616
|
+
"""
|
|
617
|
+
directory = os.path.dirname(destination)
|
|
618
|
+
if directory:
|
|
619
|
+
os.makedirs(directory, exist_ok=True)
|
|
620
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/octet-stream"}
|
|
621
|
+
if token and "api.github.com" in url:
|
|
622
|
+
headers["Authorization"] = "Bearer %s" % token
|
|
623
|
+
request = urllib.request.Request(url, headers=headers, method="GET")
|
|
624
|
+
digest = hashlib.sha256()
|
|
625
|
+
written = 0
|
|
626
|
+
partial = destination + ".part"
|
|
627
|
+
try:
|
|
628
|
+
with urllib.request.urlopen(request, timeout=timeout, context=_ssl_context()) as response:
|
|
629
|
+
status = int(getattr(response, "status", 200) or 200)
|
|
630
|
+
if status != 200:
|
|
631
|
+
raise UpdateError("download failed: HTTP %d for %s" % (status, _short(url)))
|
|
632
|
+
with open(partial, "wb") as handle:
|
|
633
|
+
while True:
|
|
634
|
+
block = response.read(1024 * 256)
|
|
635
|
+
if not block:
|
|
636
|
+
break
|
|
637
|
+
written += len(block)
|
|
638
|
+
if written > max_bytes:
|
|
639
|
+
raise UpdateError("download exceeded %d bytes; refusing" % max_bytes)
|
|
640
|
+
digest.update(block)
|
|
641
|
+
handle.write(block)
|
|
642
|
+
except urllib.error.HTTPError as exc:
|
|
643
|
+
_remove(partial)
|
|
644
|
+
raise UpdateError("download failed: HTTP %d for %s" % (exc.code, _short(url)))
|
|
645
|
+
except UpdateError:
|
|
646
|
+
_remove(partial)
|
|
647
|
+
raise
|
|
648
|
+
except Exception as exc:
|
|
649
|
+
_remove(partial)
|
|
650
|
+
raise UpdateError("download failed: %s" % exc)
|
|
651
|
+
actual = digest.hexdigest().lower()
|
|
652
|
+
if expected_sha256 and actual != expected_sha256.lower():
|
|
653
|
+
_remove(partial)
|
|
654
|
+
raise UpdateError("SHA-256 mismatch: published %s, downloaded %s"
|
|
655
|
+
% (expected_sha256[:16], actual[:16]))
|
|
656
|
+
os.replace(partial, destination)
|
|
657
|
+
return {"path": destination, "size": written, "sha256": actual,
|
|
658
|
+
"verified": bool(expected_sha256)}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _remove(path: str) -> None:
|
|
662
|
+
try:
|
|
663
|
+
if os.path.isdir(path):
|
|
664
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
665
|
+
elif os.path.exists(path):
|
|
666
|
+
os.remove(path)
|
|
667
|
+
except OSError:
|
|
668
|
+
pass
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
# Staging a payload
|
|
673
|
+
# ---------------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
def verify_payload(root: str) -> None:
|
|
676
|
+
"""Raise unless ``root`` holds a complete node payload."""
|
|
677
|
+
missing = [relative for relative in REQUIRED_PATHS
|
|
678
|
+
if not os.path.isfile(os.path.join(root, *relative.split("/")))]
|
|
679
|
+
if missing:
|
|
680
|
+
raise UpdateError("the payload is incomplete: missing %s" % ", ".join(missing))
|
|
681
|
+
if not any(os.path.isdir(os.path.join(root, name)) for name in PAYLOAD_DIRS):
|
|
682
|
+
raise UpdateError("the payload holds none of %s" % ", ".join(PAYLOAD_DIRS))
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _safe_extract_zip(archive: str, destination: str) -> None:
|
|
686
|
+
"""Extract ``archive``, refusing absolute paths and ``..`` escapes.
|
|
687
|
+
|
|
688
|
+
Members are written one by one rather than through ``extractall``: archive
|
|
689
|
+
entry names are normalised from Windows separators first. ``zipfile`` treats a
|
|
690
|
+
backslash as an ordinary character on POSIX, so an archive built by
|
|
691
|
+
``Compress-Archive`` would otherwise unpack into files literally named
|
|
692
|
+
``core\\agent.py`` and the node would never find its own code.
|
|
693
|
+
"""
|
|
694
|
+
with zipfile.ZipFile(archive) as bundle:
|
|
695
|
+
for member in bundle.infolist():
|
|
696
|
+
name = member.filename.replace("\\", "/")
|
|
697
|
+
if name.startswith("/") or ".." in name.split("/") or re.match(r"^[A-Za-z]:", name):
|
|
698
|
+
raise UpdateError("the payload archive contains an unsafe path: %s" % member.filename)
|
|
699
|
+
target = os.path.join(destination, *[part for part in name.split("/") if part])
|
|
700
|
+
if member.is_dir() or not name.strip("/"):
|
|
701
|
+
os.makedirs(target, exist_ok=True)
|
|
702
|
+
continue
|
|
703
|
+
os.makedirs(os.path.dirname(target) or destination, exist_ok=True)
|
|
704
|
+
with bundle.open(member) as source, open(target, "wb") as handle:
|
|
705
|
+
shutil.copyfileobj(source, handle, 1024 * 256)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _safe_extract_tar(archive: str, destination: str) -> None:
|
|
709
|
+
with tarfile.open(archive, "r:*") as bundle:
|
|
710
|
+
for member in bundle.getmembers():
|
|
711
|
+
name = member.name.replace("\\", "/")
|
|
712
|
+
if name.startswith("/") or ".." in name.split("/") or member.islnk() or member.issym():
|
|
713
|
+
raise UpdateError("the payload archive contains an unsafe entry: %s" % member.name)
|
|
714
|
+
try: # Python 3.12+: refuse device files and the like
|
|
715
|
+
bundle.extractall(destination, filter="data")
|
|
716
|
+
except TypeError:
|
|
717
|
+
bundle.extractall(destination)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _strip_single_root(directory: str) -> str:
|
|
721
|
+
"""Return the payload root inside ``directory``.
|
|
722
|
+
|
|
723
|
+
GitHub's source archives wrap everything in one ``<repo>-<tag>/`` directory,
|
|
724
|
+
while our own ``mesh-payload`` zip has ``core/`` at the top level. Both are
|
|
725
|
+
accepted by looking for the level that actually holds ``core``.
|
|
726
|
+
"""
|
|
727
|
+
if os.path.isdir(os.path.join(directory, "core")):
|
|
728
|
+
return directory
|
|
729
|
+
entries = [name for name in os.listdir(directory) if not name.startswith(".")]
|
|
730
|
+
for name in entries:
|
|
731
|
+
inner = os.path.join(directory, name)
|
|
732
|
+
if os.path.isdir(inner) and os.path.isdir(os.path.join(inner, "core")):
|
|
733
|
+
return inner
|
|
734
|
+
return directory
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _no_window_flags() -> int:
|
|
738
|
+
"""CREATE_NO_WINDOW for a console child on Windows, 0 elsewhere.
|
|
739
|
+
|
|
740
|
+
The agent itself runs in a hidden console (the Windows launcher starts it with
|
|
741
|
+
``WshShell.Run ..., 0``), but a child that does not inherit that console gets a
|
|
742
|
+
NEW one - and a new console is a window on the user's desktop. The updater
|
|
743
|
+
starts powershell.exe, taskkill.exe and schtasks.exe, so without this flag an
|
|
744
|
+
update paints windows nobody asked for.
|
|
745
|
+
"""
|
|
746
|
+
return 0x08000000 if os.name == "nt" else 0
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _run_capture(argv: Sequence[str], *, cwd: Optional[str] = None,
|
|
750
|
+
timeout: float = 180.0,
|
|
751
|
+
env: Optional[Dict[str, str]] = None) -> Tuple[int, str]:
|
|
752
|
+
try:
|
|
753
|
+
completed = subprocess.run(list(argv), cwd=cwd, capture_output=True,
|
|
754
|
+
timeout=timeout, check=False, env=env,
|
|
755
|
+
creationflags=_no_window_flags())
|
|
756
|
+
except FileNotFoundError as exc:
|
|
757
|
+
return (127, str(exc))
|
|
758
|
+
except subprocess.TimeoutExpired:
|
|
759
|
+
return (124, "timed out after %.0fs" % timeout)
|
|
760
|
+
output = b"\n".join(part for part in (completed.stdout, completed.stderr) if part)
|
|
761
|
+
return (completed.returncode, output.decode("utf-8", "replace"))
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
_UNPACK_PATH_RE = re.compile(r"^unpacked\s+\S+\s+to\s+(.+?)\s*$", re.M)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _unpack_setup(exe_path: str, workdir: str, version: str = "") -> str:
|
|
768
|
+
"""Unpack a downloaded setup executable and return its payload root.
|
|
769
|
+
|
|
770
|
+
``AntigravityMesh-Setup-<version>.exe --unpack`` prints
|
|
771
|
+
``unpacked <version> to <path>`` and stops, which is the authoritative answer;
|
|
772
|
+
the paths it defaults to are only used when that line cannot be parsed.
|
|
773
|
+
"""
|
|
774
|
+
environment = dict(os.environ)
|
|
775
|
+
setup_root = os.path.join(workdir, "setup")
|
|
776
|
+
environment["MESH_SETUP_DIR"] = setup_root
|
|
777
|
+
logger.info("Unpacking %s", os.path.basename(exe_path))
|
|
778
|
+
code, output = _run_capture([exe_path, "--unpack"], timeout=300.0, env=environment)
|
|
779
|
+
if code != 0:
|
|
780
|
+
raise UpdateError("the setup executable could not unpack itself (exit %d): %s"
|
|
781
|
+
% (code, output.strip()[:300]))
|
|
782
|
+
candidates: List[str] = []
|
|
783
|
+
match = _UNPACK_PATH_RE.search(output)
|
|
784
|
+
if match:
|
|
785
|
+
candidates.append(match.group(1).strip().strip('"'))
|
|
786
|
+
if version:
|
|
787
|
+
candidates.append(os.path.join(setup_root, version))
|
|
788
|
+
candidates.append(setup_root)
|
|
789
|
+
for candidate in candidates:
|
|
790
|
+
if os.path.isdir(os.path.join(candidate, "core")):
|
|
791
|
+
return candidate
|
|
792
|
+
if os.path.isdir(setup_root):
|
|
793
|
+
for entry in sorted(os.listdir(setup_root), reverse=True):
|
|
794
|
+
candidate = os.path.join(setup_root, entry)
|
|
795
|
+
if os.path.isdir(os.path.join(candidate, "core")):
|
|
796
|
+
return candidate
|
|
797
|
+
raise UpdateError("the setup executable unpacked no payload: %s" % output.strip()[:300])
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def stage_payload(release: Dict[str, Any], *, config: Dict[str, Any],
|
|
801
|
+
workdir: Optional[str] = None) -> Dict[str, Any]:
|
|
802
|
+
"""Download and unpack the release payload; returns the staging description.
|
|
803
|
+
|
|
804
|
+
The returned ``root`` is a directory that :func:`verify_payload` accepted.
|
|
805
|
+
"""
|
|
806
|
+
asset = choose_asset(release)
|
|
807
|
+
if asset is None:
|
|
808
|
+
raise UpdateError("release %s carries no usable asset" % release.get("tag"))
|
|
809
|
+
if not asset["verified"] and not config.get("allow_unverified"):
|
|
810
|
+
raise UpdateError(
|
|
811
|
+
"release %s has no mesh-payload asset and no checksum; refusing the "
|
|
812
|
+
"source-archive fallback (set MESH_UPDATE_ALLOW_UNVERIFIED=1 to accept it)"
|
|
813
|
+
% release.get("tag"))
|
|
814
|
+
workdir = workdir or tempfile.mkdtemp(prefix="mesh-update-")
|
|
815
|
+
os.makedirs(workdir, exist_ok=True)
|
|
816
|
+
expected = _checksum_for(release, asset["name"]) if asset["asset"] else ""
|
|
817
|
+
if asset["verified"] and not expected and not config.get("allow_unverified"):
|
|
818
|
+
raise UpdateError("no published SHA-256 for %s; refusing to install an "
|
|
819
|
+
"unverifiable payload (set MESH_UPDATE_ALLOW_UNVERIFIED=1 "
|
|
820
|
+
"to accept it)" % asset["name"])
|
|
821
|
+
target = os.path.join(workdir, os.path.basename(asset["name"]))
|
|
822
|
+
download = download_to_file(asset["url"], target, expected_sha256=expected,
|
|
823
|
+
token=str(config.get("token") or ""))
|
|
824
|
+
root = workdir
|
|
825
|
+
if asset["kind"] == "payload":
|
|
826
|
+
_safe_extract_zip(target, workdir)
|
|
827
|
+
root = _strip_single_root(workdir)
|
|
828
|
+
elif asset["kind"] == "setup":
|
|
829
|
+
root = _unpack_setup(target, workdir, str(release.get("version") or ""))
|
|
830
|
+
else:
|
|
831
|
+
_safe_extract_tar(target, workdir)
|
|
832
|
+
root = _strip_single_root(workdir)
|
|
833
|
+
verify_payload(root)
|
|
834
|
+
return {"root": root, "workdir": workdir, "asset": asset["name"],
|
|
835
|
+
"kind": asset["kind"], "size": download["size"],
|
|
836
|
+
"sha256": download["sha256"], "verified": download["verified"],
|
|
837
|
+
"archive": target}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# ---------------------------------------------------------------------------
|
|
841
|
+
# Applying a payload
|
|
842
|
+
# ---------------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
def _move_with_retry(source: str, destination: str, attempts: int = 5) -> None:
|
|
845
|
+
"""Move ``source`` onto ``destination``, retrying Windows handle contention.
|
|
846
|
+
|
|
847
|
+
A running Python may still hold ``__pycache__`` files open for a moment, and
|
|
848
|
+
an antivirus scanner can hold a fresh ``.py`` for longer; both make a
|
|
849
|
+
directory rename fail with a sharing violation that clears by itself.
|
|
850
|
+
"""
|
|
851
|
+
last: Optional[BaseException] = None
|
|
852
|
+
for attempt in range(attempts):
|
|
853
|
+
try:
|
|
854
|
+
shutil.move(source, destination)
|
|
855
|
+
return
|
|
856
|
+
except OSError as exc:
|
|
857
|
+
last = exc
|
|
858
|
+
time.sleep(0.4 * (attempt + 1))
|
|
859
|
+
raise UpdateError("could not move %s into place: %s" % (source, last))
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _prune_pycache(root: str) -> int:
|
|
863
|
+
"""Delete stale bytecode below ``root``; returns how many caches were removed.
|
|
864
|
+
|
|
865
|
+
A payload built from a working tree can carry ``__pycache__`` directories
|
|
866
|
+
whose timestamps match the sources they were compiled from, so Python would
|
|
867
|
+
happily run the OLD bytecode under the NEW file names. Removing them makes
|
|
868
|
+
the next import compile the sources that were actually installed.
|
|
869
|
+
"""
|
|
870
|
+
removed = 0
|
|
871
|
+
for current, directories, _files in os.walk(root):
|
|
872
|
+
if "__pycache__" in directories:
|
|
873
|
+
target = os.path.join(current, "__pycache__")
|
|
874
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
875
|
+
directories.remove("__pycache__")
|
|
876
|
+
removed += 1
|
|
877
|
+
return removed
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _backup_destination(config: Dict[str, Any], from_version: str) -> str:
|
|
881
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
882
|
+
safe = re.sub(r"[^0-9A-Za-z.\-_]", "_", from_version or "unknown")
|
|
883
|
+
return os.path.join(str(config["backup_root"]), "%s-%s" % (safe, stamp))
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def prune_backups(backup_root: str, keep: int) -> int:
|
|
887
|
+
"""Keep the newest ``keep`` backups; returns how many were deleted."""
|
|
888
|
+
try:
|
|
889
|
+
entries = sorted((os.path.join(backup_root, name) for name in os.listdir(backup_root)
|
|
890
|
+
if os.path.isdir(os.path.join(backup_root, name))),
|
|
891
|
+
key=lambda path: os.path.getmtime(path), reverse=True)
|
|
892
|
+
except OSError:
|
|
893
|
+
return 0
|
|
894
|
+
deleted = 0
|
|
895
|
+
for path in entries[max(0, keep):]:
|
|
896
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
897
|
+
deleted += 1
|
|
898
|
+
return deleted
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def apply_payload(payload_root: str, install_dir: str, *, config: Dict[str, Any],
|
|
902
|
+
from_version: str = "", to_version: str = "") -> Dict[str, Any]:
|
|
903
|
+
"""Replace the node payload in ``install_dir`` with the staged one.
|
|
904
|
+
|
|
905
|
+
Only :data:`PAYLOAD_DIRS` and :data:`PAYLOAD_FILES` are touched. Every
|
|
906
|
+
replaced name is first moved into a timestamped backup and the operation is
|
|
907
|
+
journalled before it starts, so :func:`recover_pending` can undo it after a
|
|
908
|
+
crash or a power cut.
|
|
909
|
+
"""
|
|
910
|
+
verify_payload(payload_root)
|
|
911
|
+
install_dir = os.path.abspath(install_dir)
|
|
912
|
+
if not os.path.isdir(install_dir):
|
|
913
|
+
raise UpdateError("the install directory does not exist: %s" % install_dir)
|
|
914
|
+
if os.path.abspath(payload_root) == install_dir:
|
|
915
|
+
raise UpdateError("refusing to update a directory onto itself")
|
|
916
|
+
names = [name for name in PAYLOAD_DIRS if os.path.isdir(os.path.join(payload_root, name))]
|
|
917
|
+
files = [name for name in PAYLOAD_FILES if os.path.isfile(os.path.join(payload_root, name))]
|
|
918
|
+
if not names:
|
|
919
|
+
raise UpdateError("the payload holds no %s directory" % "/".join(PAYLOAD_DIRS))
|
|
920
|
+
backup = _backup_destination(config, from_version)
|
|
921
|
+
os.makedirs(backup, exist_ok=True)
|
|
922
|
+
journal = {
|
|
923
|
+
"state": "applying",
|
|
924
|
+
"from": from_version,
|
|
925
|
+
"to": to_version,
|
|
926
|
+
"install_dir": install_dir,
|
|
927
|
+
"backup": backup,
|
|
928
|
+
"names": names + files,
|
|
929
|
+
"started_at": now_iso(),
|
|
930
|
+
"pid": os.getpid(),
|
|
931
|
+
}
|
|
932
|
+
_write_json_atomic(str(config["journal_file"]), journal)
|
|
933
|
+
file_log("applying %s -> %s (backup %s)" % (from_version or "?", to_version or "?",
|
|
934
|
+
backup), str(config.get("log_file")))
|
|
935
|
+
|
|
936
|
+
moved: List[Tuple[str, str]] = [] # (name, backup path)
|
|
937
|
+
placed: List[str] = []
|
|
938
|
+
try:
|
|
939
|
+
for name in names + files:
|
|
940
|
+
source = os.path.join(payload_root, name)
|
|
941
|
+
target = os.path.join(install_dir, name)
|
|
942
|
+
if os.path.exists(target):
|
|
943
|
+
saved = os.path.join(backup, name)
|
|
944
|
+
_move_with_retry(target, saved)
|
|
945
|
+
moved.append((name, saved))
|
|
946
|
+
_move_with_retry(source, target)
|
|
947
|
+
placed.append(name)
|
|
948
|
+
except BaseException as exc:
|
|
949
|
+
# Put back what was moved, newest first, so a half-applied update does not
|
|
950
|
+
# survive this call. Failures here are reported, not swallowed: the
|
|
951
|
+
# journal stays "applying" and recover_pending retries on the next start.
|
|
952
|
+
for name in reversed(placed):
|
|
953
|
+
_remove(os.path.join(install_dir, name))
|
|
954
|
+
for name, saved in reversed(moved):
|
|
955
|
+
try:
|
|
956
|
+
_move_with_retry(saved, os.path.join(install_dir, name))
|
|
957
|
+
except UpdateError as restore_exc:
|
|
958
|
+
file_log("ROLLBACK FAILED for %s: %s" % (name, restore_exc),
|
|
959
|
+
str(config.get("log_file")))
|
|
960
|
+
raise UpdateError("the update could not be applied (%s); the previous version "
|
|
961
|
+
"was restored" % exc)
|
|
962
|
+
|
|
963
|
+
journal["state"] = "swapped"
|
|
964
|
+
journal["swapped_at"] = now_iso()
|
|
965
|
+
_write_json_atomic(str(config["journal_file"]), journal)
|
|
966
|
+
|
|
967
|
+
removed_pycache = sum(_prune_pycache(os.path.join(install_dir, name)) for name in names)
|
|
968
|
+
marker = os.path.join(install_dir, "VERSION")
|
|
969
|
+
try:
|
|
970
|
+
with open(marker, "w", encoding="utf-8") as handle:
|
|
971
|
+
handle.write("%s\n" % (to_version or local_version(payload_root)))
|
|
972
|
+
except OSError as exc:
|
|
973
|
+
logger.debug("could not write %s: %s", marker, exc)
|
|
974
|
+
|
|
975
|
+
journal["state"] = "applied"
|
|
976
|
+
journal["applied_at"] = now_iso()
|
|
977
|
+
_write_json_atomic(str(config["journal_file"]), journal)
|
|
978
|
+
kept = max(0, int(config.get("keep_backups", 0)))
|
|
979
|
+
prune_backups(str(config["backup_root"]), kept)
|
|
980
|
+
file_log("applied %s -> %s (%d caches cleared)"
|
|
981
|
+
% (from_version or "?", to_version or "?", removed_pycache),
|
|
982
|
+
str(config.get("log_file")))
|
|
983
|
+
return {"applied": True, "from": from_version, "to": to_version,
|
|
984
|
+
"backup": backup, "replaced": names + files,
|
|
985
|
+
"pycache_removed": removed_pycache}
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def recover_pending(*, config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
989
|
+
"""Finish or undo an update that was interrupted, at agent start.
|
|
990
|
+
|
|
991
|
+
Returns a description when something was done, ``None`` when the journal was
|
|
992
|
+
clean. A journal left at ``applying`` means the swap did not finish: the
|
|
993
|
+
backup is restored, which is the only safe direction - a node on the old
|
|
994
|
+
version always works, a node with half of two versions may not.
|
|
995
|
+
"""
|
|
996
|
+
settings = config or config_from_env()
|
|
997
|
+
journal = _read_json(str(settings["journal_file"]))
|
|
998
|
+
if not journal:
|
|
999
|
+
return None
|
|
1000
|
+
state = str(journal.get("state") or "")
|
|
1001
|
+
install_dir = str(journal.get("install_dir") or settings["install_dir"])
|
|
1002
|
+
result: Dict[str, Any] = {"state": state, "to": journal.get("to"),
|
|
1003
|
+
"from": journal.get("from"), "install_dir": install_dir}
|
|
1004
|
+
if state == "applying":
|
|
1005
|
+
backup = str(journal.get("backup") or "")
|
|
1006
|
+
restored: List[str] = []
|
|
1007
|
+
for name in journal.get("names") or []:
|
|
1008
|
+
saved = os.path.join(backup, str(name))
|
|
1009
|
+
if not os.path.exists(saved):
|
|
1010
|
+
continue
|
|
1011
|
+
target = os.path.join(install_dir, str(name))
|
|
1012
|
+
_remove(target)
|
|
1013
|
+
try:
|
|
1014
|
+
_move_with_retry(saved, target)
|
|
1015
|
+
restored.append(str(name))
|
|
1016
|
+
except UpdateError as exc:
|
|
1017
|
+
result["error"] = "could not restore %s: %s" % (name, exc)
|
|
1018
|
+
file_log("RECOVERY FAILED: %s" % result["error"], str(settings.get("log_file")))
|
|
1019
|
+
return result
|
|
1020
|
+
result["action"] = "rolled back"
|
|
1021
|
+
result["restored"] = restored
|
|
1022
|
+
file_log("recovered an interrupted update: rolled back %s to %s"
|
|
1023
|
+
% (journal.get("to"), journal.get("from") or local_version(install_dir)),
|
|
1024
|
+
str(settings.get("log_file")))
|
|
1025
|
+
elif state == "swapped":
|
|
1026
|
+
# The tree is consistent; only the post-swap bookkeeping is missing.
|
|
1027
|
+
result["action"] = "completed"
|
|
1028
|
+
try:
|
|
1029
|
+
with open(os.path.join(install_dir, "VERSION"), "w", encoding="utf-8") as handle:
|
|
1030
|
+
handle.write("%s\n" % (journal.get("to") or local_version(install_dir)))
|
|
1031
|
+
except OSError:
|
|
1032
|
+
pass
|
|
1033
|
+
file_log("completed an interrupted update to %s" % journal.get("to"),
|
|
1034
|
+
str(settings.get("log_file")))
|
|
1035
|
+
else:
|
|
1036
|
+
result["action"] = "cleared"
|
|
1037
|
+
_remove(str(settings["journal_file"]))
|
|
1038
|
+
return result
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
# ---------------------------------------------------------------------------
|
|
1042
|
+
# Restarting the node
|
|
1043
|
+
# ---------------------------------------------------------------------------
|
|
1044
|
+
|
|
1045
|
+
def _pid_alive(pid: int) -> bool:
|
|
1046
|
+
if pid <= 0:
|
|
1047
|
+
return False
|
|
1048
|
+
if os.name == "nt":
|
|
1049
|
+
import ctypes
|
|
1050
|
+
from ctypes import wintypes
|
|
1051
|
+
process_query_limited_information = 0x1000
|
|
1052
|
+
still_active = 259
|
|
1053
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
1054
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
1055
|
+
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
1056
|
+
kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
1057
|
+
handle = kernel32.OpenProcess(process_query_limited_information, False, int(pid))
|
|
1058
|
+
if not handle:
|
|
1059
|
+
return False # gone, or access denied: both mean "not ours anymore"
|
|
1060
|
+
try:
|
|
1061
|
+
code = wintypes.DWORD()
|
|
1062
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
1063
|
+
return False
|
|
1064
|
+
return code.value == still_active
|
|
1065
|
+
finally:
|
|
1066
|
+
kernel32.CloseHandle(handle)
|
|
1067
|
+
try:
|
|
1068
|
+
os.kill(int(pid), 0)
|
|
1069
|
+
except ProcessLookupError:
|
|
1070
|
+
return False
|
|
1071
|
+
except PermissionError:
|
|
1072
|
+
return True
|
|
1073
|
+
except OSError:
|
|
1074
|
+
return False
|
|
1075
|
+
return True
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _heartbeat_state(path: Optional[str] = None) -> Dict[str, Any]:
|
|
1079
|
+
return _read_json(path or HEARTBEAT_FILE) or {}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def running_agent_pid(config: Optional[Dict[str, Any]] = None) -> int:
|
|
1083
|
+
"""The pid of the agent that currently owns this node, or 0.
|
|
1084
|
+
|
|
1085
|
+
A fresh heartbeat naming a live process is the only accepted answer: the pid
|
|
1086
|
+
must not be reused by something unrelated, and a node that is not running
|
|
1087
|
+
needs no stop at all.
|
|
1088
|
+
"""
|
|
1089
|
+
settings = config or config_from_env()
|
|
1090
|
+
state = _heartbeat_state(settings.get("heartbeat_file"))
|
|
1091
|
+
try:
|
|
1092
|
+
pid = int(state.get("pid") or 0)
|
|
1093
|
+
stamp = float(state.get("ts") or 0.0)
|
|
1094
|
+
except Exception:
|
|
1095
|
+
return 0
|
|
1096
|
+
if not pid or pid == os.getpid():
|
|
1097
|
+
return 0
|
|
1098
|
+
if (time.time() - stamp) > 300.0:
|
|
1099
|
+
return 0
|
|
1100
|
+
return pid if _pid_alive(pid) else 0
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def stop_agent(pid: int, *, config: Optional[Dict[str, Any]] = None,
|
|
1104
|
+
grace: float = 15.0) -> Dict[str, Any]:
|
|
1105
|
+
"""Ask a running agent to exit, then insist.
|
|
1106
|
+
|
|
1107
|
+
A node whose old agent keeps running is a node that never actually updates:
|
|
1108
|
+
the new process waits for the instance lock the old one holds and the tunnel
|
|
1109
|
+
keeps saying the old version is current. A graceful signal comes first (the
|
|
1110
|
+
agent writes its clean-stop marker and releases everything); a process that
|
|
1111
|
+
ignores it for ``grace`` seconds is terminated.
|
|
1112
|
+
"""
|
|
1113
|
+
if pid <= 0 or pid == os.getpid() or not _pid_alive(pid):
|
|
1114
|
+
return {"stopped": False, "reason": "nothing to stop", "pid": pid}
|
|
1115
|
+
if os.name == "nt":
|
|
1116
|
+
code, _output = _run_capture(["taskkill", "/PID", str(pid)], timeout=30.0)
|
|
1117
|
+
if code == 0:
|
|
1118
|
+
waited = _wait_for_exit(pid, 10.0)
|
|
1119
|
+
if waited:
|
|
1120
|
+
return {"stopped": True, "pid": pid, "method": "taskkill"}
|
|
1121
|
+
code, output = _run_capture(["taskkill", "/T", "/F", "/PID", str(pid)], timeout=60.0)
|
|
1122
|
+
alive = _pid_alive(pid)
|
|
1123
|
+
return {"stopped": not alive, "pid": pid, "method": "taskkill /T /F",
|
|
1124
|
+
"exit_code": code, "detail": output.strip()[-200:]}
|
|
1125
|
+
number = getattr(signal, "SIGTERM", 15)
|
|
1126
|
+
try:
|
|
1127
|
+
os.kill(pid, number)
|
|
1128
|
+
except OSError as exc:
|
|
1129
|
+
return {"stopped": False, "pid": pid, "method": "SIGTERM", "error": str(exc)}
|
|
1130
|
+
if _wait_for_exit(pid, grace):
|
|
1131
|
+
return {"stopped": True, "pid": pid, "method": "SIGTERM"}
|
|
1132
|
+
try:
|
|
1133
|
+
os.kill(pid, getattr(signal, "SIGKILL", 9))
|
|
1134
|
+
except OSError as exc:
|
|
1135
|
+
return {"stopped": False, "pid": pid, "method": "SIGKILL", "error": str(exc)}
|
|
1136
|
+
return {"stopped": _wait_for_exit(pid, 10.0), "pid": pid, "method": "SIGKILL"}
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _wait_for_exit(pid: int, timeout: float) -> bool:
|
|
1140
|
+
deadline = time.time() + max(0.0, timeout)
|
|
1141
|
+
while time.time() < deadline:
|
|
1142
|
+
if not _pid_alive(pid):
|
|
1143
|
+
return True
|
|
1144
|
+
time.sleep(0.5)
|
|
1145
|
+
return not _pid_alive(pid)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def wait_for_node(heartbeat_file: str, *, previous_pid: int = 0,
|
|
1149
|
+
after: float = 0.0, timeout: float = 45.0) -> bool:
|
|
1150
|
+
"""True once a heartbeat newer than ``after`` comes from a different process."""
|
|
1151
|
+
deadline = time.time() + max(1.0, timeout)
|
|
1152
|
+
while time.time() < deadline:
|
|
1153
|
+
state = _heartbeat_state(heartbeat_file)
|
|
1154
|
+
try:
|
|
1155
|
+
stamp = float(state.get("ts") or 0.0)
|
|
1156
|
+
except Exception:
|
|
1157
|
+
stamp = 0.0
|
|
1158
|
+
pid = int(state.get("pid") or 0)
|
|
1159
|
+
if stamp > after and pid and pid != previous_pid:
|
|
1160
|
+
return True
|
|
1161
|
+
time.sleep(2.0)
|
|
1162
|
+
return False
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _detached_flags() -> Dict[str, Any]:
|
|
1166
|
+
if os.name == "nt":
|
|
1167
|
+
detached = 0x00000008 # DETACHED_PROCESS
|
|
1168
|
+
new_group = 0x00000200 # CREATE_NEW_PROCESS_GROUP
|
|
1169
|
+
no_window = 0x08000000 # CREATE_NO_WINDOW
|
|
1170
|
+
return {"creationflags": detached | new_group | no_window}
|
|
1171
|
+
return {"start_new_session": True}
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _agent_log_handle(config: Dict[str, Any]):
|
|
1175
|
+
path = os.path.join(os.path.dirname(str(config.get("log_file") or LOG_FILE)), "agent.log")
|
|
1176
|
+
try:
|
|
1177
|
+
return open(path, "a", encoding="utf-8")
|
|
1178
|
+
except OSError:
|
|
1179
|
+
return subprocess.DEVNULL
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _start_agent_directly(install_dir: str, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1183
|
+
"""Start ``python -m core.agent`` detached, the way a bare install would."""
|
|
1184
|
+
handle = _agent_log_handle(config)
|
|
1185
|
+
argv = [sys.executable or "python", "-u", "-m", "core.agent"]
|
|
1186
|
+
kwargs: Dict[str, Any] = {"cwd": install_dir, "stdout": handle,
|
|
1187
|
+
"stderr": subprocess.STDOUT}
|
|
1188
|
+
kwargs.update(_detached_flags())
|
|
1189
|
+
try:
|
|
1190
|
+
process = subprocess.Popen(argv, **kwargs)
|
|
1191
|
+
except Exception as exc:
|
|
1192
|
+
return {"ok": False, "method": "direct", "error": str(exc)}
|
|
1193
|
+
finally:
|
|
1194
|
+
if handle is not subprocess.DEVNULL:
|
|
1195
|
+
try:
|
|
1196
|
+
handle.close()
|
|
1197
|
+
except Exception:
|
|
1198
|
+
pass
|
|
1199
|
+
return {"ok": True, "method": "direct", "pid": process.pid}
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _restart_windows(install_dir: str, config: Dict[str, Any],
|
|
1203
|
+
previous_pid: int) -> Dict[str, Any]:
|
|
1204
|
+
watchdog = os.path.join(install_dir, *WATCHDOG.split("/"))
|
|
1205
|
+
if not os.path.isfile(watchdog):
|
|
1206
|
+
return _start_agent_directly(install_dir, config)
|
|
1207
|
+
heartbeat = str(config.get("heartbeat_file") or HEARTBEAT_FILE)
|
|
1208
|
+
started = time.time()
|
|
1209
|
+
attempts = [5, 0]
|
|
1210
|
+
last: Dict[str, Any] = {}
|
|
1211
|
+
for grace in attempts:
|
|
1212
|
+
argv = ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
|
1213
|
+
"-WindowStyle", "Hidden", "-File", watchdog,
|
|
1214
|
+
"-GraceSeconds", str(grace), "-Quiet"]
|
|
1215
|
+
code, output = _run_capture(argv, cwd=install_dir, timeout=180.0)
|
|
1216
|
+
last = {"ok": code == 0, "method": "watchdog", "grace": grace,
|
|
1217
|
+
"exit_code": code, "output": output.strip()[-300:]}
|
|
1218
|
+
file_log("watchdog (grace %ss) exited %d" % (grace, code),
|
|
1219
|
+
str(config.get("log_file")))
|
|
1220
|
+
if wait_for_node(heartbeat, previous_pid=previous_pid, after=started, timeout=30.0):
|
|
1221
|
+
last["ok"] = True
|
|
1222
|
+
last["verified"] = True
|
|
1223
|
+
return last
|
|
1224
|
+
# The watchdog can answer "online" from the gateway before the closed tunnel
|
|
1225
|
+
# is noticed; starting the agent directly is the deterministic last resort.
|
|
1226
|
+
direct = _start_agent_directly(install_dir, config)
|
|
1227
|
+
direct["watchdog"] = last
|
|
1228
|
+
direct["verified"] = wait_for_node(heartbeat, previous_pid=previous_pid,
|
|
1229
|
+
after=started, timeout=30.0)
|
|
1230
|
+
return direct
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _restart_posix(install_dir: str, config: Dict[str, Any],
|
|
1234
|
+
previous_pid: int) -> Dict[str, Any]:
|
|
1235
|
+
heartbeat = str(config.get("heartbeat_file") or HEARTBEAT_FILE)
|
|
1236
|
+
started = time.time()
|
|
1237
|
+
home = os.path.expanduser("~")
|
|
1238
|
+
if sys.platform == "darwin":
|
|
1239
|
+
plist = os.path.join(home, "Library", "LaunchAgents", LAUNCHD_LABEL + ".plist")
|
|
1240
|
+
if os.path.isfile(plist) and shutil.which("launchctl"):
|
|
1241
|
+
target = "gui/%d/%s" % (os.getuid(), LAUNCHD_LABEL)
|
|
1242
|
+
code, output = _run_capture(["launchctl", "kickstart", "-k", target], timeout=60.0)
|
|
1243
|
+
if code == 0:
|
|
1244
|
+
verified = wait_for_node(heartbeat, previous_pid=previous_pid,
|
|
1245
|
+
after=started, timeout=45.0)
|
|
1246
|
+
if verified:
|
|
1247
|
+
return {"ok": True, "method": "launchctl", "verified": True}
|
|
1248
|
+
return _start_agent_directly(install_dir, config)
|
|
1249
|
+
return _start_agent_directly(install_dir, config)
|
|
1250
|
+
unit = os.path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT)
|
|
1251
|
+
if os.path.isfile(unit) and shutil.which("systemctl"):
|
|
1252
|
+
code, output = _run_capture(["systemctl", "--user", "restart", SYSTEMD_UNIT], timeout=60.0)
|
|
1253
|
+
if code == 0:
|
|
1254
|
+
verified = wait_for_node(heartbeat, previous_pid=previous_pid,
|
|
1255
|
+
after=started, timeout=45.0)
|
|
1256
|
+
if verified:
|
|
1257
|
+
return {"ok": True, "method": "systemctl", "verified": True}
|
|
1258
|
+
else:
|
|
1259
|
+
file_log("systemctl --user restart %s exited %d: %s"
|
|
1260
|
+
% (SYSTEMD_UNIT, code, output.strip()[-200:]),
|
|
1261
|
+
str(config.get("log_file")))
|
|
1262
|
+
return _start_agent_directly(install_dir, config)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def restart_node(install_dir: str, *, config: Optional[Dict[str, Any]] = None,
|
|
1266
|
+
previous_pid: int = 0) -> Dict[str, Any]:
|
|
1267
|
+
"""Bring the agent back through whatever supervises it."""
|
|
1268
|
+
settings = config or config_from_env()
|
|
1269
|
+
if os.name == "nt":
|
|
1270
|
+
return _restart_windows(install_dir, settings, previous_pid)
|
|
1271
|
+
return _restart_posix(install_dir, settings, previous_pid)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
#: Set by :mod:`core.agent` at startup. The updater calls it after a successful
|
|
1275
|
+
#: apply so the process that must be replaced stops itself; a caller that manages
|
|
1276
|
+
#: the node externally (a CLI, a test) leaves it unset.
|
|
1277
|
+
_RESTART_HOOK: Optional[Any] = None
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def set_restart_hook(hook: Optional[Any]) -> None:
|
|
1281
|
+
"""Register the callable that stops the running agent after an update."""
|
|
1282
|
+
global _RESTART_HOOK
|
|
1283
|
+
_RESTART_HOOK = hook
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _notify_restart() -> bool:
|
|
1287
|
+
hook = _RESTART_HOOK
|
|
1288
|
+
if hook is None:
|
|
1289
|
+
return False
|
|
1290
|
+
try:
|
|
1291
|
+
hook()
|
|
1292
|
+
return True
|
|
1293
|
+
except Exception as exc: # a broken hook must not undo an update
|
|
1294
|
+
logger.warning("the restart hook failed: %s", exc)
|
|
1295
|
+
return False
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def spawn_restart_helper(install_dir: str, *, config: Dict[str, Any],
|
|
1299
|
+
pid: Optional[int] = None, wait_seconds: Optional[float] = None) -> Dict[str, Any]:
|
|
1300
|
+
"""Start a detached helper that brings the node back on the new version.
|
|
1301
|
+
|
|
1302
|
+
The helper is a separate process on purpose. It must outlive the process it
|
|
1303
|
+
replaces, wait for the instance lock and the tunnel to be released, and - when
|
|
1304
|
+
the update was applied by a CLI or by the Windows Scheduled Task while the old
|
|
1305
|
+
agent kept running - stop that agent, which would otherwise hold the lock
|
|
1306
|
+
forever and leave the node on the old code.
|
|
1307
|
+
|
|
1308
|
+
``pid`` defaults to the agent that currently owns the node, or to this process
|
|
1309
|
+
when the node is not running (an in-process apply).
|
|
1310
|
+
"""
|
|
1311
|
+
self_pid = os.getpid()
|
|
1312
|
+
target = int(pid) if pid is not None else (running_agent_pid(config) or self_pid)
|
|
1313
|
+
foreign = target not in (0, self_pid)
|
|
1314
|
+
if wait_seconds is None:
|
|
1315
|
+
# Our own process stops itself within seconds once asked; a foreign agent
|
|
1316
|
+
# has to be asked by the helper, so a long wait would only delay the node.
|
|
1317
|
+
wait_seconds = 25.0 if foreign else 120.0
|
|
1318
|
+
argv = [sys.executable or "python", "-m", "core.updater",
|
|
1319
|
+
"--restart-helper", "--pid", str(target),
|
|
1320
|
+
"--dir", install_dir, "--wait", str(float(wait_seconds))]
|
|
1321
|
+
handle = _agent_log_handle(config)
|
|
1322
|
+
kwargs: Dict[str, Any] = {"cwd": install_dir, "stdout": handle,
|
|
1323
|
+
"stderr": subprocess.STDOUT}
|
|
1324
|
+
kwargs.update(_detached_flags())
|
|
1325
|
+
try:
|
|
1326
|
+
process = subprocess.Popen(argv, **kwargs)
|
|
1327
|
+
except Exception as exc:
|
|
1328
|
+
return {"ok": False, "error": str(exc), "target_pid": target}
|
|
1329
|
+
finally:
|
|
1330
|
+
if handle is not subprocess.DEVNULL:
|
|
1331
|
+
try:
|
|
1332
|
+
handle.close()
|
|
1333
|
+
except Exception:
|
|
1334
|
+
pass
|
|
1335
|
+
file_log("restart helper started (pid %s) for node pid %s (foreign=%s)"
|
|
1336
|
+
% (process.pid, target, foreign), str(config.get("log_file")))
|
|
1337
|
+
return {"ok": True, "pid": process.pid, "target_pid": target, "foreign": foreign}
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def restart_helper_main(pid: int, install_dir: str, wait_seconds: float = 120.0) -> int:
|
|
1341
|
+
"""The ``--restart-helper`` entry point: wait for ``pid``, then restart."""
|
|
1342
|
+
config = config_from_env(install_dir=install_dir)
|
|
1343
|
+
deadline = time.time() + max(5.0, wait_seconds)
|
|
1344
|
+
while time.time() < deadline and _pid_alive(pid):
|
|
1345
|
+
time.sleep(1.0)
|
|
1346
|
+
if pid > 0 and pid != os.getpid() and _pid_alive(pid):
|
|
1347
|
+
stopped = stop_agent(pid, config=config)
|
|
1348
|
+
file_log("restart helper stopped the old agent: %s" % json.dumps(stopped, sort_keys=True),
|
|
1349
|
+
str(config.get("log_file")))
|
|
1350
|
+
# A short grace period lets the OS release the instance lock and the gateway
|
|
1351
|
+
# notice the closed tunnel before the watchdog is asked whether the node is up.
|
|
1352
|
+
time.sleep(3.0)
|
|
1353
|
+
result = restart_node(install_dir, config=config, previous_pid=pid)
|
|
1354
|
+
file_log("restart helper result: %s" % json.dumps(result, sort_keys=True),
|
|
1355
|
+
str(config.get("log_file")))
|
|
1356
|
+
return 0 if result.get("ok") else 3
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
# ---------------------------------------------------------------------------
|
|
1360
|
+
# The public operations: status, check, apply
|
|
1361
|
+
# ---------------------------------------------------------------------------
|
|
1362
|
+
|
|
1363
|
+
def status(*, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1364
|
+
"""Local state only: no network, no side effects."""
|
|
1365
|
+
settings = config or config_from_env()
|
|
1366
|
+
install_dir = str(settings["install_dir"])
|
|
1367
|
+
state = _read_json(str(settings["state_file"])) or {}
|
|
1368
|
+
journal = _read_json(str(settings["journal_file"])) or {}
|
|
1369
|
+
latest = state.get("latest") if isinstance(state.get("latest"), dict) else None
|
|
1370
|
+
return {
|
|
1371
|
+
"ok": True,
|
|
1372
|
+
"current": local_version(install_dir),
|
|
1373
|
+
"install_dir": install_dir,
|
|
1374
|
+
"channel": settings["channel"],
|
|
1375
|
+
"check_enabled": bool(settings["enabled"]),
|
|
1376
|
+
"auto": bool(settings["auto"]),
|
|
1377
|
+
"interval": int(settings["interval"]),
|
|
1378
|
+
"last_check": state.get("last_check") or "",
|
|
1379
|
+
"last_result": state.get("last_result") or "",
|
|
1380
|
+
"last_applied": state.get("last_applied") or "",
|
|
1381
|
+
"latest": (latest or {}).get("version") if latest else "",
|
|
1382
|
+
"latest_url": (latest or {}).get("html_url") if latest else "",
|
|
1383
|
+
"update_available": state.get("update_available") if isinstance(
|
|
1384
|
+
state.get("update_available"), bool) else None,
|
|
1385
|
+
"pending_journal": journal.get("state") or "",
|
|
1386
|
+
"log_file": str(settings["log_file"]),
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def check_for_update(*, config: Optional[Dict[str, Any]] = None, force: bool = False,
|
|
1391
|
+
offline: bool = False) -> Dict[str, Any]:
|
|
1392
|
+
"""Ask GitHub for the newest release and compare it with the installed version.
|
|
1393
|
+
|
|
1394
|
+
The answer is cached in the state file for ``MESH_UPDATE_CHECK_INTERVAL``:
|
|
1395
|
+
an unauthenticated GitHub client is allowed 60 requests per hour, and a fleet
|
|
1396
|
+
of nodes polling every minute would spend that on nothing.
|
|
1397
|
+
"""
|
|
1398
|
+
settings = config or config_from_env()
|
|
1399
|
+
install_dir = str(settings["install_dir"])
|
|
1400
|
+
current = local_version(install_dir)
|
|
1401
|
+
state = _read_json(str(settings["state_file"])) or {}
|
|
1402
|
+
now = time.time()
|
|
1403
|
+
|
|
1404
|
+
def cached_result(reason: str, *, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1405
|
+
latest = state.get("latest") if isinstance(state.get("latest"), dict) else None
|
|
1406
|
+
result = {
|
|
1407
|
+
"ok": True,
|
|
1408
|
+
"current": current,
|
|
1409
|
+
"latest": (latest or {}).get("version") or current,
|
|
1410
|
+
"update_available": bool(latest and is_newer(latest.get("version"), current)),
|
|
1411
|
+
"reason": reason,
|
|
1412
|
+
"cached": True,
|
|
1413
|
+
"checked_at": state.get("last_check") or "",
|
|
1414
|
+
"release": latest,
|
|
1415
|
+
"asset": None,
|
|
1416
|
+
"channel": settings["channel"],
|
|
1417
|
+
"auto": bool(settings["auto"]),
|
|
1418
|
+
"install_dir": install_dir,
|
|
1419
|
+
}
|
|
1420
|
+
if latest:
|
|
1421
|
+
asset = choose_asset(latest)
|
|
1422
|
+
result["asset"] = None if asset is None else {
|
|
1423
|
+
"name": asset["name"], "kind": asset["kind"], "size": asset["size"],
|
|
1424
|
+
"verified": asset["verified"]}
|
|
1425
|
+
result["release"] = _public_release(latest)
|
|
1426
|
+
if extra:
|
|
1427
|
+
result.update(extra)
|
|
1428
|
+
return result
|
|
1429
|
+
|
|
1430
|
+
if not settings["enabled"]:
|
|
1431
|
+
return cached_result("checking is disabled (MESH_UPDATE_CHECK=0)")
|
|
1432
|
+
if offline:
|
|
1433
|
+
return cached_result("offline: cached state only")
|
|
1434
|
+
last_check = float(state.get("last_check_epoch") or 0.0)
|
|
1435
|
+
if not force and last_check and (now - last_check) < int(settings["interval"]):
|
|
1436
|
+
return cached_result("checked %d s ago; next check in %d s"
|
|
1437
|
+
% (now - last_check, int(settings["interval"] - (now - last_check))))
|
|
1438
|
+
|
|
1439
|
+
try:
|
|
1440
|
+
release = fetch_latest_release(repo=str(settings["repo"]),
|
|
1441
|
+
token=str(settings["token"] or ""),
|
|
1442
|
+
prerelease=bool(settings["prerelease"]))
|
|
1443
|
+
except RateLimited as exc:
|
|
1444
|
+
state.update({"last_check": now_iso(), "last_check_epoch": now,
|
|
1445
|
+
"last_result": "rate limited"})
|
|
1446
|
+
_write_json_atomic(str(settings["state_file"]), state)
|
|
1447
|
+
return cached_result("GitHub rate limit: %s" % exc)
|
|
1448
|
+
except UpdateError as exc:
|
|
1449
|
+
state.update({"last_check_epoch": now, "last_result": "check failed: %s" % exc})
|
|
1450
|
+
_write_json_atomic(str(settings["state_file"]), state)
|
|
1451
|
+
file_log("check failed: %s" % exc, str(settings.get("log_file")))
|
|
1452
|
+
return {"ok": False, "current": current, "latest": "",
|
|
1453
|
+
"update_available": False, "reason": "check failed: %s" % exc,
|
|
1454
|
+
"cached": False, "checked_at": now_iso(), "release": None,
|
|
1455
|
+
"asset": None, "channel": settings["channel"],
|
|
1456
|
+
"auto": bool(settings["auto"]), "install_dir": install_dir}
|
|
1457
|
+
|
|
1458
|
+
latest_version = str(release["version"])
|
|
1459
|
+
available = is_newer(latest_version, current)
|
|
1460
|
+
asset = choose_asset(release)
|
|
1461
|
+
state.update({
|
|
1462
|
+
"last_check": now_iso(),
|
|
1463
|
+
"last_check_epoch": now,
|
|
1464
|
+
"latest": _public_release(release),
|
|
1465
|
+
"update_available": available,
|
|
1466
|
+
"last_result": ("update available: %s" % latest_version) if available
|
|
1467
|
+
else ("up to date (%s)" % current),
|
|
1468
|
+
})
|
|
1469
|
+
_write_json_atomic(str(settings["state_file"]), state)
|
|
1470
|
+
if available:
|
|
1471
|
+
file_log("update available: %s -> %s" % (current, latest_version),
|
|
1472
|
+
str(settings.get("log_file")))
|
|
1473
|
+
return {
|
|
1474
|
+
"ok": True,
|
|
1475
|
+
"current": current,
|
|
1476
|
+
"latest": latest_version,
|
|
1477
|
+
"update_available": available,
|
|
1478
|
+
"reason": ("release %s is newer than %s" % (latest_version, current)) if available
|
|
1479
|
+
else "up to date",
|
|
1480
|
+
"cached": False,
|
|
1481
|
+
"checked_at": now_iso(),
|
|
1482
|
+
"release": _public_release(release),
|
|
1483
|
+
"asset": None if asset is None else {"name": asset["name"], "kind": asset["kind"],
|
|
1484
|
+
"size": asset["size"], "verified": asset["verified"]},
|
|
1485
|
+
"channel": settings["channel"],
|
|
1486
|
+
"auto": bool(settings["auto"]),
|
|
1487
|
+
"install_dir": install_dir,
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def apply_update(*, config: Optional[Dict[str, Any]] = None,
|
|
1492
|
+
check: Optional[Dict[str, Any]] = None, force: bool = False,
|
|
1493
|
+
restart: Optional[bool] = None, workdir: Optional[str] = None) -> Dict[str, Any]:
|
|
1494
|
+
"""Check, download, verify and swap in the newest release.
|
|
1495
|
+
|
|
1496
|
+
``restart`` defaults to ``MESH_UPDATE_RESTART``. Applying without a restart
|
|
1497
|
+
is for tests and for an operator who supervises the node themselves.
|
|
1498
|
+
"""
|
|
1499
|
+
settings = config or config_from_env()
|
|
1500
|
+
install_dir = str(settings["install_dir"])
|
|
1501
|
+
result = check if check is not None else check_for_update(config=settings, force=force)
|
|
1502
|
+
answer: Dict[str, Any] = {
|
|
1503
|
+
"ok": True,
|
|
1504
|
+
"applied": False,
|
|
1505
|
+
"current": result.get("current") or local_version(install_dir),
|
|
1506
|
+
"latest": result.get("latest") or "",
|
|
1507
|
+
"restart_scheduled": False,
|
|
1508
|
+
"reason": "",
|
|
1509
|
+
"release": result.get("release"),
|
|
1510
|
+
}
|
|
1511
|
+
if not result.get("ok"):
|
|
1512
|
+
answer.update({"ok": False, "reason": result.get("reason") or "check failed"})
|
|
1513
|
+
return answer
|
|
1514
|
+
if not result.get("update_available"):
|
|
1515
|
+
answer["reason"] = "already up to date (%s)" % answer["current"]
|
|
1516
|
+
return answer
|
|
1517
|
+
|
|
1518
|
+
state = _read_json(str(settings["state_file"])) or {}
|
|
1519
|
+
last_failed = float(state.get("last_apply_failed_epoch") or 0.0)
|
|
1520
|
+
if last_failed and (time.time() - last_failed) < APPLY_RETRY_BACKOFF and not force:
|
|
1521
|
+
answer["reason"] = ("the previous attempt failed %d s ago; retrying in %d s"
|
|
1522
|
+
% (time.time() - last_failed,
|
|
1523
|
+
APPLY_RETRY_BACKOFF - (time.time() - last_failed)))
|
|
1524
|
+
return answer
|
|
1525
|
+
|
|
1526
|
+
try:
|
|
1527
|
+
staged = stage_payload(result["release"], config=settings, workdir=workdir)
|
|
1528
|
+
except UpdateError as exc:
|
|
1529
|
+
state.update({"last_apply_failed_epoch": time.time(),
|
|
1530
|
+
"last_result": "download failed: %s" % exc})
|
|
1531
|
+
_write_json_atomic(str(settings["state_file"]), state)
|
|
1532
|
+
file_log("download failed: %s" % exc, str(settings.get("log_file")))
|
|
1533
|
+
answer.update({"ok": False, "reason": str(exc)})
|
|
1534
|
+
return answer
|
|
1535
|
+
|
|
1536
|
+
answer["asset"] = staged["asset"]
|
|
1537
|
+
answer["sha256"] = staged["sha256"]
|
|
1538
|
+
answer["verified"] = staged["verified"]
|
|
1539
|
+
if settings.get("dry_run"):
|
|
1540
|
+
answer.update({"reason": "dry run: payload %s staged and verified, nothing replaced"
|
|
1541
|
+
% staged["asset"], "staged": staged["root"]})
|
|
1542
|
+
return answer
|
|
1543
|
+
|
|
1544
|
+
do_restart = bool(settings["restart"]) if restart is None else bool(restart)
|
|
1545
|
+
helper: Dict[str, Any] = {}
|
|
1546
|
+
if do_restart:
|
|
1547
|
+
# Spawned BEFORE the swap: if this process dies during the swap, the node
|
|
1548
|
+
# is still brought back - and recover_pending() then rolls the tree back.
|
|
1549
|
+
helper = spawn_restart_helper(install_dir, config=settings)
|
|
1550
|
+
answer["restart_scheduled"] = bool(helper.get("ok"))
|
|
1551
|
+
answer["restart_target_pid"] = helper.get("target_pid")
|
|
1552
|
+
try:
|
|
1553
|
+
applied = apply_payload(staged["root"], install_dir, config=settings,
|
|
1554
|
+
from_version=str(answer["current"]),
|
|
1555
|
+
to_version=str(answer["latest"]))
|
|
1556
|
+
except UpdateError as exc:
|
|
1557
|
+
state = _read_json(str(settings["state_file"])) or {}
|
|
1558
|
+
state.update({"last_apply_failed_epoch": time.time(),
|
|
1559
|
+
"last_result": "apply failed: %s" % exc})
|
|
1560
|
+
_write_json_atomic(str(settings["state_file"]), state)
|
|
1561
|
+
file_log("apply failed: %s" % exc, str(settings.get("log_file")))
|
|
1562
|
+
answer.update({"ok": False, "reason": str(exc)})
|
|
1563
|
+
return answer
|
|
1564
|
+
|
|
1565
|
+
state = _read_json(str(settings["state_file"])) or {}
|
|
1566
|
+
state.update({"last_applied": answer["latest"],
|
|
1567
|
+
"last_applied_epoch": time.time(),
|
|
1568
|
+
"last_result": "applied %s -> %s" % (answer["current"], answer["latest"]),
|
|
1569
|
+
"update_available": False,
|
|
1570
|
+
"last_apply_failed_epoch": 0})
|
|
1571
|
+
_write_json_atomic(str(settings["state_file"]), state)
|
|
1572
|
+
answer.update({"applied": True, "backup": applied["backup"],
|
|
1573
|
+
"replaced": applied["replaced"],
|
|
1574
|
+
"reason": "applied %s -> %s" % (answer["current"], answer["latest"]),
|
|
1575
|
+
"staged": staged["root"]})
|
|
1576
|
+
if do_restart and answer["restart_scheduled"]:
|
|
1577
|
+
# In-process callers (the agent's background thread, the mesh_update tool)
|
|
1578
|
+
# stop themselves here; the helper is already waiting for exactly that.
|
|
1579
|
+
answer["restart_requested"] = _notify_restart()
|
|
1580
|
+
if not do_restart:
|
|
1581
|
+
answer["reason"] += " (restart not requested)"
|
|
1582
|
+
return answer
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def background_tick(*, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1586
|
+
"""One pass of the agent's background updater.
|
|
1587
|
+
|
|
1588
|
+
Returns the check/apply result plus ``restart``: True when the caller must
|
|
1589
|
+
stop the agent so the helper can start the new version.
|
|
1590
|
+
"""
|
|
1591
|
+
settings = config or config_from_env()
|
|
1592
|
+
result = check_for_update(config=settings)
|
|
1593
|
+
if not result.get("ok") or not result.get("update_available"):
|
|
1594
|
+
return {"restart": False, "check": result}
|
|
1595
|
+
if not settings["auto"]:
|
|
1596
|
+
return {"restart": False, "check": result,
|
|
1597
|
+
"reason": "an update is available but MESH_UPDATE_AUTO=0"}
|
|
1598
|
+
applied = apply_update(config=settings, check=result)
|
|
1599
|
+
restart = bool(applied.get("applied") and settings["restart"]
|
|
1600
|
+
and applied.get("restart_scheduled"))
|
|
1601
|
+
return {"restart": restart, "check": result, "apply": applied}
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
# ---------------------------------------------------------------------------
|
|
1605
|
+
# Command line
|
|
1606
|
+
# ---------------------------------------------------------------------------
|
|
1607
|
+
|
|
1608
|
+
def _print_result(result: Dict[str, Any], *, as_json: bool, stream=None) -> None:
|
|
1609
|
+
stream = stream or sys.stdout
|
|
1610
|
+
if as_json:
|
|
1611
|
+
# ensure_ascii keeps the machine-readable answer 7-bit clean: release
|
|
1612
|
+
# notes carry emoji, and a Windows console whose code page cannot encode
|
|
1613
|
+
# them used to turn a successful check into a UnicodeEncodeError.
|
|
1614
|
+
json.dump(result, stream, ensure_ascii=True, indent=2, sort_keys=True)
|
|
1615
|
+
stream.write("\n")
|
|
1616
|
+
return
|
|
1617
|
+
if "current" in result:
|
|
1618
|
+
stream.write("current : %s\n" % result.get("current"))
|
|
1619
|
+
if result.get("latest"):
|
|
1620
|
+
stream.write("latest : %s\n" % result.get("latest"))
|
|
1621
|
+
if "update_available" in result:
|
|
1622
|
+
stream.write("update : %s\n" % ("available" if result.get("update_available")
|
|
1623
|
+
else "none"))
|
|
1624
|
+
for key in ("reason", "action", "asset", "sha256", "verified", "backup",
|
|
1625
|
+
"restart_scheduled", "install_dir", "log_file"):
|
|
1626
|
+
if result.get(key) not in (None, "", {}):
|
|
1627
|
+
stream.write("%-13s: %s\n" % (key, result.get(key)))
|
|
1628
|
+
release = result.get("release") or {}
|
|
1629
|
+
if release.get("html_url"):
|
|
1630
|
+
stream.write("release : %s\n" % release["html_url"])
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def _make_output_safe() -> None:
|
|
1634
|
+
"""Never let an unprintable character turn a result into a crash.
|
|
1635
|
+
|
|
1636
|
+
Release notes are written by humans and carry emoji and non-Latin text; a
|
|
1637
|
+
Windows console is often a cp1251/cp866 code page. Replacing what the console
|
|
1638
|
+
cannot encode is right for a diagnostic tool - the exit code and the JSON
|
|
1639
|
+
answer are what scripts read.
|
|
1640
|
+
"""
|
|
1641
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1642
|
+
try:
|
|
1643
|
+
stream.reconfigure(errors="replace")
|
|
1644
|
+
except Exception:
|
|
1645
|
+
pass
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
1649
|
+
parser = argparse.ArgumentParser(
|
|
1650
|
+
prog="python -m core.updater",
|
|
1651
|
+
description="Check for, download and apply Antigravity Mesh updates.")
|
|
1652
|
+
actions = parser.add_mutually_exclusive_group()
|
|
1653
|
+
actions.add_argument("--status", action="store_true",
|
|
1654
|
+
help="report local state; no network, no changes")
|
|
1655
|
+
actions.add_argument("--check", action="store_true",
|
|
1656
|
+
help="ask GitHub whether a newer release exists")
|
|
1657
|
+
actions.add_argument("--apply", action="store_true",
|
|
1658
|
+
help="check, download, verify, swap and restart")
|
|
1659
|
+
parser.add_argument("--restart-helper", action="store_true",
|
|
1660
|
+
help=argparse.SUPPRESS)
|
|
1661
|
+
parser.add_argument("--pid", type=int, default=0, help=argparse.SUPPRESS)
|
|
1662
|
+
parser.add_argument("--dir", default="", help="install directory override")
|
|
1663
|
+
parser.add_argument("--wait", type=float, default=120.0, help=argparse.SUPPRESS)
|
|
1664
|
+
parser.add_argument("--force", action="store_true",
|
|
1665
|
+
help="ignore the check interval and the retry backoff")
|
|
1666
|
+
parser.add_argument("--offline", action="store_true",
|
|
1667
|
+
help="report the cached state without touching the network")
|
|
1668
|
+
parser.add_argument("--no-restart", action="store_true",
|
|
1669
|
+
help="apply the payload but do not restart the agent")
|
|
1670
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
1671
|
+
help="download and verify, change nothing")
|
|
1672
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1673
|
+
parser.add_argument("--quiet", action="store_true", help="print nothing on success")
|
|
1674
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
1675
|
+
_make_output_safe()
|
|
1676
|
+
|
|
1677
|
+
if args.restart_helper:
|
|
1678
|
+
return restart_helper_main(args.pid, args.dir or default_install_dir(), args.wait)
|
|
1679
|
+
|
|
1680
|
+
config = config_from_env(install_dir=args.dir or None)
|
|
1681
|
+
if args.dry_run:
|
|
1682
|
+
config["dry_run"] = True
|
|
1683
|
+
|
|
1684
|
+
if args.status:
|
|
1685
|
+
result = status(config=config)
|
|
1686
|
+
if not args.quiet:
|
|
1687
|
+
_print_result(result, as_json=args.json)
|
|
1688
|
+
return 0
|
|
1689
|
+
|
|
1690
|
+
if args.apply:
|
|
1691
|
+
result = apply_update(config=config, force=args.force,
|
|
1692
|
+
restart=False if args.no_restart else None)
|
|
1693
|
+
if not args.quiet:
|
|
1694
|
+
_print_result(result, as_json=args.json)
|
|
1695
|
+
if not result.get("ok"):
|
|
1696
|
+
return 3
|
|
1697
|
+
if result.get("applied") and result.get("restart_scheduled"):
|
|
1698
|
+
return 4
|
|
1699
|
+
return 0
|
|
1700
|
+
|
|
1701
|
+
result = check_for_update(config=config, force=args.force, offline=args.offline)
|
|
1702
|
+
if not args.quiet:
|
|
1703
|
+
_print_result(result, as_json=args.json)
|
|
1704
|
+
if not result.get("ok"):
|
|
1705
|
+
return 3
|
|
1706
|
+
return 2 if result.get("update_available") else 0
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
if __name__ == "__main__":
|
|
1710
|
+
try:
|
|
1711
|
+
sys.exit(main())
|
|
1712
|
+
except KeyboardInterrupt:
|
|
1713
|
+
sys.exit(130)
|