Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/guides/installing-loopx.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ loopx update plan # read-only command, validation, and rollback plan
loopx update apply # explicit local-environment mutation
```

For archive installs, `update apply` downloads the bootstrap installer to a
private temporary file before executing it. Downloading is limited to three
attempts, a 60-second total download budget (or the smaller command timeout),
and 20 seconds per transfer, with 1- and 2-second retry delays. Transient
403/408/429/500/502/503/504 responses and selected connection/transfer failures
are retried; 401/404 and certificate-verification failures stop immediately.
Installer execution is never retried, and partial downloads are never executed.
The command timeout covers downloading and installer execution together.

JSON `execution.installer_download` and the text execution report show the
stage and each attempt's HTTP status and curl exit code. HTTP `0` means no
usable HTTP status was received. Download diagnostics exclude URLs, response
bodies, headers, and raw curl errors so proxy credentials and signed parameters
cannot leak. These retries cover the bootstrap download, not subsequent
archive downloads or a failed installation. Pip/pipx and read-only plans keep
their existing behavior.

Bare `loopx update` remains a read-only plan. The older `--check`, `--dry-run`,
and `--execute` spellings remain compatibility aliases, but new instructions
should use the named actions.
Expand Down
61 changes: 18 additions & 43 deletions examples/loopx-update-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def test_module_plan() -> None:
assert payload["plan"]["backup"]["rollback_release_id"] == "20260621T170342Z", payload
assert "loopx update --rollback 20260621T170342Z" in payload["plan"]["backup"]["rollback_command"], payload
assert "ln -sfn" not in payload["plan"]["backup"]["rollback_command"], payload
assert "LOOPX_ARCHIVE_URL=https://example.invalid/loopx.tar.gz" in payload["plan"]["install_command"], payload
assert "--archive-url https://example.invalid/loopx.tar.gz" in payload["plan"]["install_command"], payload
rendered = render_update_plan_markdown(payload)
assert "**No update was applied.**" in rendered, rendered
assert "## Next Action" in rendered, rendered
Expand All @@ -199,8 +199,8 @@ def test_default_source_uses_stable_ref() -> None:
assert payload["source"]["channel"] == "github_archive_stable", payload
assert payload["source"]["ref_source"] == "default_stable", payload
assert "/tar.gz/stable" in payload["source"]["archive_url"], payload
assert "LOOPX_REF=stable" in payload["plan"]["install_command"], payload
assert "LOOPX_ARCHIVE_URL=" not in payload["plan"]["install_command"], payload
assert payload["plan"]["install_command"] == "loopx update apply", payload
assert "--archive-url" not in payload["plan"]["install_command"], payload
default_env = _installer_env_for_source(
payload["source"],
base_env={"LOOPX_ARCHIVE_URL": "https://stale.invalid/archive.tar.gz"},
Expand All @@ -216,47 +216,19 @@ def test_explicit_archive_url_reaches_installer() -> None:
doctor_payload=fake_doctor_payload(),
)
assert payload["source"]["channel"] == "github_archive_url_override", payload
assert "LOOPX_ARCHIVE_URL=https://example.invalid/loopx.tar.gz" in payload["plan"]["install_command"], payload
assert "set -o errexit -o pipefail" in payload["plan"]["install_command"], payload
assert "export LOOPX_REPO=example/loopx" in payload["plan"]["install_command"], payload
assert "export LOOPX_REF=fixture" in payload["plan"]["install_command"], payload
assert "--archive-url https://example.invalid/loopx.tar.gz" in payload["plan"]["install_command"], payload
assert "loopx update apply" in payload["plan"]["install_command"], payload
assert "--repo example/loopx" in payload["plan"]["install_command"], payload
assert "--ref fixture" in payload["plan"]["install_command"], payload
installer_env = _installer_env_for_source(payload["source"], base_env={})
assert installer_env["LOOPX_ARCHIVE_URL"] == "https://example.invalid/loopx.tar.gz", installer_env


def test_update_preview_stops_before_doctor_when_download_fails() -> None:
payload = build_update_plan(
repo="example/loopx",
ref="fixture",
archive_url="https://example.invalid/loopx.tar.gz",
execute=False,
doctor_payload=fake_doctor_payload(),
)
with TemporaryDirectory() as tmpdir:
bin_dir = Path(tmpdir)
marker_path = bin_dir / "doctor-ran"
curl_bin = bin_dir / "curl"
curl_bin.write_text("#!/usr/bin/env bash\nexit 22\n", encoding="utf-8")
curl_bin.chmod(0o755)
loopx_bin = bin_dir / "loopx"
loopx_bin.write_text(
"#!/usr/bin/env bash\n"
f"touch {json.dumps(str(marker_path))}\n"
"exit 0\n",
encoding="utf-8",
)
loopx_bin.chmod(0o755)
env = dict(os.environ)
env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
result = subprocess.run(
["bash", "-c", payload["plan"]["install_command"]],
text=True,
capture_output=True,
env=env,
)

assert result.returncode == 22, result
assert not marker_path.exists(), result
def test_update_preview_uses_managed_download_path() -> None:
payload = build_update_plan(doctor_payload=fake_doctor_payload())
command = payload["plan"]["install_command"]
assert command.startswith("loopx update apply"), command
assert "curl" not in command, command


def test_execute_update_propagates_installer_download_failure() -> None:
Expand Down Expand Up @@ -290,10 +262,13 @@ def test_execute_update_propagates_installer_download_failure() -> None:

assert result["ok"] is False, result
install_command = run.call_args_list[0].args[0]
assert install_command[:2] == ["bash", "-lc"], install_command
assert "set -o pipefail" in install_command[2], install_command
assert install_command[0] == "curl", install_command
assert "--output" in install_command, install_command
assert result["execution"]["install_returncode"] == 22, result
assert result["execution"]["doctor_returncode"] == 0, result
assert result["execution"]["installer_download"]["stage"] == "installer_download", result
assert result["changes_applied"] is False, result
assert "Download attempt 1: HTTP `0`, curl `22`" in render_update_plan_markdown(result)


def test_active_release_python_reaches_installer() -> None:
Expand Down Expand Up @@ -562,7 +537,7 @@ def main() -> int:
test_module_plan()
test_default_source_uses_stable_ref()
test_explicit_archive_url_reaches_installer()
test_update_preview_stops_before_doctor_when_download_fails()
test_update_preview_uses_managed_download_path()
test_execute_update_propagates_installer_download_failure()
test_active_release_python_reaches_installer()
test_active_release_python_marker_fails_closed()
Expand Down
37 changes: 14 additions & 23 deletions loopx/self_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .doctor import collect_doctor
from .install_contract import NO_CLONE_INSTALL_URL
from .self_update_download import run_archive_installer


UPDATE_PLAN_SCHEMA_VERSION = "loopx_update_plan_v0"
Expand Down Expand Up @@ -105,20 +106,7 @@ def _command_for_source(source: dict[str, Any]) -> str:
"Update a trusted LoopX checkout, then run its "
"`scripts/install-windows.ps1` with PowerShell 7."
)
exports = [
f"LOOPX_REPO={shlex.quote(str(source['repo']))}",
f"LOOPX_REF={shlex.quote(str(source['ref']))}",
]
archive_url = source.get("archive_url")
if source.get("channel") == "github_archive_url_override" and archive_url:
exports.append(f"LOOPX_ARCHIVE_URL={shlex.quote(str(archive_url))}")
return (
"set -o errexit -o pipefail\n"
+ "\n".join(f"export {value}" for value in exports)
+ f"\ncurl -fsSL {shlex.quote(str(source['installer_url']))} | bash\n"
'export PATH="$HOME/.local/bin:$PATH"\n'
"loopx doctor"
)
return _update_action_command(UpdateAction.APPLY, source)


def _installer_env_for_source(
Expand Down Expand Up @@ -1104,16 +1092,10 @@ def execute_update_plan(
current_release_root if isinstance(current_release_root, str) else None
),
)
install_result = subprocess.run(
[
"bash",
"-lc",
f"set -o pipefail; curl -fsSL {shlex.quote(installer_url)} | bash",
],
text=True,
capture_output=True,
install_result, download_observation = run_archive_installer(
installer_url,
env=env,
timeout=timeout_seconds,
timeout_seconds=timeout_seconds,
)
loopx_bin = Path.home() / ".local" / "bin" / "loopx"
doctor_result = subprocess.run(
Expand All @@ -1124,6 +1106,7 @@ def execute_update_plan(
timeout=timeout_seconds,
)
execution = {
"installer_download": download_observation,
"install_returncode": install_result.returncode,
"doctor_returncode": doctor_result.returncode,
"install_stdout_tail": install_result.stdout[-2000:],
Expand Down Expand Up @@ -1472,4 +1455,12 @@ def render_update_plan_markdown(payload: dict[str, Any]) -> str:
f"- Doctor return code: `{execution.get('doctor_returncode')}`",
]
)
download = execution.get("installer_download")
if isinstance(download, dict):
lines.append(f"- Installer stage: `{download.get('stage')}`")
for attempt in download.get("attempts", []):
lines.append(
f"- Download attempt {attempt['attempt']}: "
f"HTTP `{attempt['http_status']}`, curl `{attempt['curl_returncode']}`"
)
return "\n".join(lines) + "\n"
108 changes: 108 additions & 0 deletions loopx/self_update_download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Bounded archive-installer download; never execute a partial response."""

import subprocess
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any


def run_archive_installer(

Check failure on line 10 in loopx/self_update_download.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaB1Zb8OrTOB3X7gpB6F&open=AaB1Zb8OrTOB3X7gpB6F&pullRequest=3991
url: str, *, env: dict[str, str], timeout_seconds: int
) -> tuple[subprocess.CompletedProcess[str], dict[str, Any]]:
deadline = time.monotonic() + timeout_seconds
download_deadline = min(deadline, time.monotonic() + 60)
observation: dict[str, Any] = {"stage": "installer_download", "attempts": []}
# Deliberately omit URLs, headers, bodies and raw curl errors: any of these
# can carry proxy credentials or signed URL parameters.
with TemporaryDirectory(prefix="loopx-update-") as raw:
script = Path(raw) / "install.sh"
script.touch(mode=0o600)
code = 28
for attempt in range(1, 4):
remaining = download_deadline - time.monotonic()
if remaining <= 0:
break
# Remove bytes from a failed transfer before the next attempt.
script.write_bytes(b"")
args = [
"curl",
"--silent",
"--show-error",
"--fail",
"--location",
"--connect-timeout",
"10",
"--max-time",
str(min(20, remaining)),
"--output",
str(script),
"--write-out",
"%{http_code}",
url,
]
try:
result = subprocess.run(
args,
text=True,
capture_output=True,
env=env,
timeout=remaining,
check=False,
)
code = result.returncode
status = (
int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
)
except subprocess.TimeoutExpired:
code, status = 28, 0
except OSError:
code, status = 127, 0
observation["attempts"].append(
{"attempt": attempt, "curl_returncode": code, "http_status": status}
)
if code == 0 and 200 <= status < 300 and script.stat().st_size:
remaining = deadline - time.monotonic()
if remaining <= 0:
code = 28
break
observation["stage"] = "installer_execution"
try:
return subprocess.run(
["bash", str(script)],
check=False,
text=True,
capture_output=True,
env=env,
timeout=remaining,
), observation
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
[],
124,
"",
"Installer execution timed out; it was not retried.",
), observation
# 403 can be transient at an edge/proxy; retry only within this
# budget. Never change credentials, endpoint, or TLS verification.
retryable = status in {403, 408, 429, 500, 502, 503, 504} or code in {
5,
6,
7,
18,
28,
35,
52,
55,
56,
}
code = code or 22
if not retryable or attempt == 3:
break
remaining = download_deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(attempt, remaining))
return subprocess.CompletedProcess(
[], code, "", "Installer download failed; see installer_download diagnostics."
), observation
Loading
Loading