netizen-cli 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Copied outside the installation before use; intentionally standard-library only.
|
|
2
|
+
|
|
3
|
+
This file is an update protocol participant, not a second package installer. It
|
|
4
|
+
waits for the selected package manager, validates with a new interpreter, and
|
|
5
|
+
restores only the recorded running services using the newly installed CLI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import fcntl
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PROTOCOL_VERSION = 1
|
|
25
|
+
_interrupted_signal: int | None = None
|
|
26
|
+
_CHILD_STOP_GRACE = 5.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UpdateInterrupted(KeyboardInterrupt):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _request_interruption(signum: int, _frame: Any) -> None:
|
|
34
|
+
# Raising from a handler could abandon Popen during construction or release
|
|
35
|
+
# locks before the package tool exits. Repeated signals remain deferred too.
|
|
36
|
+
global _interrupted_signal
|
|
37
|
+
if _interrupted_signal is None:
|
|
38
|
+
_interrupted_signal = signum
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check_interrupted() -> None:
|
|
42
|
+
if _interrupted_signal is not None:
|
|
43
|
+
raise UpdateInterrupted(signal.Signals(_interrupted_signal).name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _stop_owned_child(child: subprocess.Popen) -> None:
|
|
47
|
+
"""Stop only the group whose unreaped leader still belongs to this worker."""
|
|
48
|
+
if child.returncode is not None:
|
|
49
|
+
return
|
|
50
|
+
for signum, grace in ((signal.SIGTERM, _CHILD_STOP_GRACE), (signal.SIGKILL, 0)):
|
|
51
|
+
try:
|
|
52
|
+
os.killpg(child.pid, signum)
|
|
53
|
+
except ProcessLookupError:
|
|
54
|
+
break
|
|
55
|
+
except PermissionError:
|
|
56
|
+
# macOS excludes zombies from group signal recipients and can return
|
|
57
|
+
# EPERM when only our exited leader remains. Reap it, then accept
|
|
58
|
+
# only a vanished group; never send another signal to its old ID.
|
|
59
|
+
if child.poll() is None:
|
|
60
|
+
raise
|
|
61
|
+
try:
|
|
62
|
+
os.killpg(child.pid, 0)
|
|
63
|
+
except ProcessLookupError:
|
|
64
|
+
break
|
|
65
|
+
raise
|
|
66
|
+
if grace:
|
|
67
|
+
# Do not poll/reap during grace: the leader's exit does not prove
|
|
68
|
+
# its descendants stopped. Its reserved PID anchors the final signal.
|
|
69
|
+
time.sleep(grace)
|
|
70
|
+
# Reap our direct child after the last group signal, while holding the locks.
|
|
71
|
+
# Orphan zombies are not waitable here and cannot execute or retain locks.
|
|
72
|
+
child.communicate()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_owned(command: list[str], *, timeout: float | None = None,
|
|
76
|
+
capture_output: bool = False, check: bool = False, **kwargs: Any) -> Any:
|
|
77
|
+
_check_interrupted()
|
|
78
|
+
if capture_output:
|
|
79
|
+
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
80
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
81
|
+
with subprocess.Popen(command, start_new_session=True, **kwargs) as child:
|
|
82
|
+
try:
|
|
83
|
+
while True:
|
|
84
|
+
_check_interrupted()
|
|
85
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
86
|
+
if remaining is not None and remaining <= 0:
|
|
87
|
+
raise subprocess.TimeoutExpired(command, timeout)
|
|
88
|
+
try:
|
|
89
|
+
stdout, stderr = child.communicate(timeout=min(0.2, remaining)
|
|
90
|
+
if remaining is not None else 0.2)
|
|
91
|
+
break
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
continue
|
|
94
|
+
except BaseException:
|
|
95
|
+
_stop_owned_child(child)
|
|
96
|
+
raise
|
|
97
|
+
# Successful communicate ends ownership of this command. A late signal
|
|
98
|
+
# still aborts the update, but must never target its already-reaped PID.
|
|
99
|
+
_check_interrupted()
|
|
100
|
+
if check and child.returncode:
|
|
101
|
+
raise subprocess.CalledProcessError(child.returncode, command, stdout, stderr)
|
|
102
|
+
return subprocess.CompletedProcess(command, child.returncode, stdout, stderr)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def persist_report(path: Path, report: dict[str, Any]) -> None:
|
|
106
|
+
"""An interrupted update leaves evidence, never an instruction to roll back."""
|
|
107
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".report-", dir=path.parent)
|
|
108
|
+
try:
|
|
109
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
110
|
+
json.dump(report, stream, ensure_ascii=False, indent=2)
|
|
111
|
+
stream.write("\n")
|
|
112
|
+
stream.flush()
|
|
113
|
+
os.fsync(stream.fileno())
|
|
114
|
+
os.replace(temporary, path)
|
|
115
|
+
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
116
|
+
try:
|
|
117
|
+
os.fsync(directory)
|
|
118
|
+
finally:
|
|
119
|
+
os.close(directory)
|
|
120
|
+
finally:
|
|
121
|
+
if os.path.exists(temporary):
|
|
122
|
+
os.unlink(temporary)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def print_report(report: dict[str, Any], *, json_output: bool = False) -> None:
|
|
126
|
+
if json_output:
|
|
127
|
+
print(json.dumps(report, ensure_ascii=False))
|
|
128
|
+
return
|
|
129
|
+
print(f"Update {report['status']}; phase: {report['phase']}")
|
|
130
|
+
if report.get("environment"):
|
|
131
|
+
print(f"Environment: {report['environment']} (backend: {report.get('backend') or 'unconfirmed'})")
|
|
132
|
+
package = report["package"]
|
|
133
|
+
print(f"Package: {package['state']} ({package.get('before_version', '?')}"
|
|
134
|
+
f" -> {package.get('after_version') or '?'})")
|
|
135
|
+
progress = report["progress"]
|
|
136
|
+
print(f"Stopped {progress['stopped']}/{progress['stop_total']}; "
|
|
137
|
+
f"ready {progress['ready']}/{progress['start_total']}")
|
|
138
|
+
for item in report["instances"]:
|
|
139
|
+
print(f" {item['root']} [{item['service']}]: {item['state']}")
|
|
140
|
+
if item.get("reason"):
|
|
141
|
+
print(f" {item['reason']}")
|
|
142
|
+
if report.get("reason"):
|
|
143
|
+
print(report["reason"])
|
|
144
|
+
if report.get("recommendation"):
|
|
145
|
+
print(f"Next: {report['recommendation']}")
|
|
146
|
+
if report.get("unexecuted"):
|
|
147
|
+
print("Not executed: " + ", ".join(report["unexecuted"]))
|
|
148
|
+
if report.get("report_path"):
|
|
149
|
+
print(f"Report: {report['report_path']}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _error(report: dict[str, Any], reason: str, recommendation: str) -> dict[str, Any]:
|
|
153
|
+
report.update(status="failed", reason=reason, recommendation=recommendation)
|
|
154
|
+
return report
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validation_error(result: Any, message: str) -> ValueError:
|
|
158
|
+
# Only our own inline Python probe emits this error object; package-manager
|
|
159
|
+
# diagnostics are never interpreted as a decision protocol.
|
|
160
|
+
try:
|
|
161
|
+
response = json.loads(result.stdout)
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
response = None
|
|
164
|
+
if isinstance(response, dict) and isinstance(response.get("error"), str):
|
|
165
|
+
message += ": " + response["error"][:4096]
|
|
166
|
+
return ValueError(message)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _validate_root_locks(plan: dict[str, Any]) -> None:
|
|
170
|
+
"""Keep parent-acquired root locks through replacement without importing it."""
|
|
171
|
+
locks = plan.get("root_locks", {})
|
|
172
|
+
roots = {item["root"] for item in plan["report"]["instances"]}
|
|
173
|
+
if set(locks) != roots:
|
|
174
|
+
raise ValueError("The updater did not retain every instance maintenance lock")
|
|
175
|
+
for root, descriptor in locks.items():
|
|
176
|
+
if type(descriptor) is not int or descriptor < 3:
|
|
177
|
+
raise ValueError("Invalid inherited instance maintenance lock")
|
|
178
|
+
path = Path(root) / "state/.install.lock"
|
|
179
|
+
held, expected = os.fstat(descriptor), path.lstat()
|
|
180
|
+
for metadata in (held, expected):
|
|
181
|
+
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid()
|
|
182
|
+
or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1):
|
|
183
|
+
raise ValueError(f"Unsafe inherited maintenance lock: {root}")
|
|
184
|
+
if (held.st_dev, held.st_ino) != (expected.st_dev, expected.st_ino):
|
|
185
|
+
raise ValueError(f"Inherited maintenance lock does not match: {root}")
|
|
186
|
+
probe = os.open(path, os.O_RDWR | os.O_NOFOLLOW)
|
|
187
|
+
try:
|
|
188
|
+
try:
|
|
189
|
+
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
190
|
+
except BlockingIOError:
|
|
191
|
+
pass
|
|
192
|
+
else:
|
|
193
|
+
raise ValueError(f"Instance maintenance lock is not held: {root}")
|
|
194
|
+
finally:
|
|
195
|
+
os.close(probe)
|
|
196
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
197
|
+
# Only an explicit pass_fds to the matching fresh restore child may pass
|
|
198
|
+
# this further; package tools and service processes cannot inherit it.
|
|
199
|
+
os.set_inheritable(descriptor, False)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def execute(plan: dict[str, Any], *, runner: Any = _run_owned) -> dict[str, Any]:
|
|
203
|
+
"""Run the irreversible half. All instances were stopped by the caller."""
|
|
204
|
+
report = plan["report"]
|
|
205
|
+
report_path = Path(report["report_path"])
|
|
206
|
+
package = plan["package_plan"]
|
|
207
|
+
environment = dict(os.environ)
|
|
208
|
+
environment.update(package.get("command_env", {}))
|
|
209
|
+
report["phase"] = "updating"
|
|
210
|
+
persist_report(report_path, report)
|
|
211
|
+
try:
|
|
212
|
+
_validate_root_locks(plan)
|
|
213
|
+
for name, expected in package.get("revalidation_files", {}).items():
|
|
214
|
+
actual = hashlib.sha256(Path(name).read_bytes()).hexdigest()
|
|
215
|
+
if actual != expected:
|
|
216
|
+
raise ValueError(f"Installation identity changed during preflight: {name}")
|
|
217
|
+
for name, expected in package.get("revalidation_paths", {}).items():
|
|
218
|
+
metadata = Path(name).stat()
|
|
219
|
+
if [metadata.st_dev, metadata.st_ino] != list(expected):
|
|
220
|
+
raise ValueError(f"Installation target changed during preflight: {name}")
|
|
221
|
+
if package.get("revalidation_command"):
|
|
222
|
+
checked = runner(
|
|
223
|
+
package["revalidation_command"], cwd=package["command_cwd"], env=environment,
|
|
224
|
+
stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
|
225
|
+
close_fds=True, check=False, timeout=60,
|
|
226
|
+
)
|
|
227
|
+
if checked.returncode != 0:
|
|
228
|
+
raise _validation_error(checked, "Fresh-process installation identity revalidation failed")
|
|
229
|
+
report["package"].update(state="mutation_started", replacement_started=True)
|
|
230
|
+
report["unexecuted"] = ["validation", "restore"]
|
|
231
|
+
persist_report(report_path, report)
|
|
232
|
+
# Package output is diagnostic only, never parsed to decide success or no-op.
|
|
233
|
+
result = runner(
|
|
234
|
+
package["command"], cwd=package["command_cwd"],
|
|
235
|
+
env=environment, stdin=subprocess.DEVNULL,
|
|
236
|
+
stdout=sys.stderr, stderr=sys.stderr, close_fds=True, check=False,
|
|
237
|
+
)
|
|
238
|
+
if result.returncode != 0:
|
|
239
|
+
report["package"]["state"] = "unknown"
|
|
240
|
+
return _error(
|
|
241
|
+
report, f"Package manager failed (exit {result.returncode}); no instance was restarted.",
|
|
242
|
+
"Repair this Python installation with its package manager, then explicitly start "
|
|
243
|
+
"the stopped instances. No automatic rollback or backend fallback was attempted.",
|
|
244
|
+
)
|
|
245
|
+
report["phase"] = "validating"
|
|
246
|
+
report["package"]["state"] = "unverified"
|
|
247
|
+
persist_report(report_path, report)
|
|
248
|
+
validation = runner(
|
|
249
|
+
package["validation_command"], cwd=package["command_cwd"],
|
|
250
|
+
env=environment, stdin=subprocess.DEVNULL,
|
|
251
|
+
capture_output=True, text=True, close_fds=True, check=False, timeout=60,
|
|
252
|
+
)
|
|
253
|
+
if validation.returncode != 0:
|
|
254
|
+
raise _validation_error(validation, "Fresh-process installation validation failed")
|
|
255
|
+
installed = json.loads(validation.stdout)
|
|
256
|
+
if not isinstance(installed, dict) or not isinstance(installed.get("version"), str):
|
|
257
|
+
raise ValueError("Fresh-process installation validation returned an invalid result")
|
|
258
|
+
report["package"].update(state="verified", after_version=installed["version"])
|
|
259
|
+
report["phase"] = "restoring"
|
|
260
|
+
report["unexecuted"] = [f"start:{item['root']}" for item in report["instances"] if item["was_running"]]
|
|
261
|
+
persist_report(report_path, report)
|
|
262
|
+
for item in report["instances"]:
|
|
263
|
+
if not item["was_running"]:
|
|
264
|
+
continue
|
|
265
|
+
item.update(state="starting", action="start")
|
|
266
|
+
report["unexecuted"].remove(f"start:{item['root']}")
|
|
267
|
+
persist_report(report_path, report)
|
|
268
|
+
try:
|
|
269
|
+
restored = runner(
|
|
270
|
+
[package["environment_python"], "-I", "-m", "netizen_cli.cli_update_restore",
|
|
271
|
+
"--lock-fd", str(plan["root_locks"][item["root"]]),
|
|
272
|
+
"--expected-prefix", item["prefix"], "--expected-python", item["python"],
|
|
273
|
+
"--root", item["root"]],
|
|
274
|
+
cwd=package["command_cwd"], env=environment,
|
|
275
|
+
stdin=subprocess.DEVNULL, stdout=sys.stderr, stderr=sys.stderr,
|
|
276
|
+
close_fds=True, pass_fds=(plan["root_locks"][item["root"]],),
|
|
277
|
+
check=False, timeout=180,
|
|
278
|
+
)
|
|
279
|
+
if restored.returncode == 0:
|
|
280
|
+
item["state"] = "ready"
|
|
281
|
+
report["progress"]["ready"] += 1
|
|
282
|
+
else:
|
|
283
|
+
item.update(state="unknown", reason=f"Start failed (exit {restored.returncode}); "
|
|
284
|
+
"the service may have started but not reached readiness.")
|
|
285
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
286
|
+
item.update(state="unknown", reason=f"Start result could not be confirmed: {type(error).__name__}")
|
|
287
|
+
persist_report(report_path, report)
|
|
288
|
+
if report["progress"]["ready"] != report["progress"]["start_total"]:
|
|
289
|
+
return _error(
|
|
290
|
+
report, "Program update was verified, but some instances did not confirm readiness.",
|
|
291
|
+
"Inspect each failed instance with status/logs and repair its startup failure. "
|
|
292
|
+
"Successful instances remain running; committed migrations are not rolled back.",
|
|
293
|
+
)
|
|
294
|
+
_check_interrupted()
|
|
295
|
+
report.update(phase="complete", status="succeeded", reason=None, recommendation=None)
|
|
296
|
+
report["unexecuted"] = []
|
|
297
|
+
return report
|
|
298
|
+
except (OSError, ValueError, subprocess.TimeoutExpired, KeyboardInterrupt) as error:
|
|
299
|
+
for item in report["instances"]:
|
|
300
|
+
if item["state"] == "starting":
|
|
301
|
+
item.update(state="unknown", reason="Start observation was interrupted; readiness is unknown.")
|
|
302
|
+
if report["package"]["replacement_started"] and report["package"]["state"] != "verified":
|
|
303
|
+
report["package"]["state"] = "unknown"
|
|
304
|
+
return _error(
|
|
305
|
+
report, f"Update interrupted or could not be verified: {type(error).__name__}: {error}",
|
|
306
|
+
"Inspect the recorded package and instance states. If package replacement began, repair "
|
|
307
|
+
"and verify the installation before explicitly restarting stopped instances. "
|
|
308
|
+
"No rollback or automatic compensation was performed.",
|
|
309
|
+
)
|
|
310
|
+
finally:
|
|
311
|
+
persist_report(report_path, report)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main() -> int:
|
|
315
|
+
if len(sys.argv) != 2:
|
|
316
|
+
print("This is a private, one-shot Netizen update helper.", file=sys.stderr)
|
|
317
|
+
return 2
|
|
318
|
+
plan_path = Path(sys.argv[1])
|
|
319
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
320
|
+
if plan.get("protocol") != PROTOCOL_VERSION:
|
|
321
|
+
print("Unsupported Netizen update helper protocol.", file=sys.stderr)
|
|
322
|
+
return 2
|
|
323
|
+
# Do not retain the inherited environment (which may include credentials) on disk.
|
|
324
|
+
plan_path.unlink()
|
|
325
|
+
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
326
|
+
signal.signal(signum, _request_interruption)
|
|
327
|
+
report = execute(plan)
|
|
328
|
+
print_report(report, json_output=plan.get("json_output", False))
|
|
329
|
+
return 0 if report["status"] == "succeeded" else 1
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
raise SystemExit(main())
|