mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-20 08:03:42 +08:00
fix(cloud): print top-up instructions on 402 and guide oversize or archive --source (#1242)
- Every payment-required error now ends with a "Next step" line: the platform hint when one is sent, else the topup command and the billing URL for the configured platform. JSON output gets the same text as next_step. The platform hint is no longer repeated inside the error. - An archive file passed to --source is rejected with guidance to pass the directory instead, which packs and excludes deps/build output. - An oversize archive names its largest files and points to --exclude and --dry-run --show-files. - uploads request help points to scans start --source for local code.
This commit is contained in:
@@ -34,13 +34,30 @@ EXIT_AUTH = 4
|
|||||||
EXIT_PAYMENT = 5
|
EXIT_PAYMENT = 5
|
||||||
|
|
||||||
|
|
||||||
class CloudError(Exception):
|
TOPUP_COMMAND = "strix cloud billing topup --credits <count>"
|
||||||
"""A failed cloud command. Carries the process exit code."""
|
BALANCE_COMMAND = "strix cloud billing credits"
|
||||||
|
|
||||||
def __init__(self, message: str, *, exit_code: int = EXIT_ERROR, payload: Any = None) -> None:
|
|
||||||
|
class CloudError(Exception):
|
||||||
|
"""A failed cloud command. Carries the process exit code.
|
||||||
|
|
||||||
|
`next_step` is a short recovery instruction that the runner prints on its
|
||||||
|
own line after the error, so a person or an agent can act without reading
|
||||||
|
the docs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
exit_code: int = EXIT_ERROR,
|
||||||
|
payload: Any = None,
|
||||||
|
next_step: str | None = None,
|
||||||
|
) -> None:
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.exit_code = exit_code
|
self.exit_code = exit_code
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
|
self.next_step = next_step
|
||||||
|
|
||||||
|
|
||||||
class CloudTransportError(CloudError):
|
class CloudTransportError(CloudError):
|
||||||
@@ -349,13 +366,43 @@ def check(response: requests.Response) -> Any:
|
|||||||
error_code = error_code or str(nested.get("code") or "")
|
error_code = error_code or str(nested.get("code") or "")
|
||||||
detail = str(nested.get("message") or detail)
|
detail = str(nested.get("message") or detail)
|
||||||
message = detail or f"HTTP {response.status_code}"
|
message = detail or f"HTTP {response.status_code}"
|
||||||
if error_code == "scan_credit_limit_reached":
|
if error_code == "scan_credit_limit_reached" or response.status_code == 402:
|
||||||
raise CloudError(message, exit_code=EXIT_PAYMENT, payload=data)
|
raise payment_required_error(data, detail=detail)
|
||||||
if response.status_code in (401, 403):
|
if response.status_code in (401, 403):
|
||||||
raise CloudError(message, exit_code=EXIT_AUTH, payload=data)
|
raise CloudError(message, exit_code=EXIT_AUTH, payload=data)
|
||||||
if response.status_code == 402:
|
|
||||||
hint = detail or (
|
|
||||||
"not enough credits. Run `strix cloud billing topup --credits N` to buy credits."
|
|
||||||
)
|
|
||||||
raise CloudError(hint, exit_code=EXIT_PAYMENT, payload=data)
|
|
||||||
raise CloudError(message, exit_code=EXIT_ERROR, payload=data)
|
raise CloudError(message, exit_code=EXIT_ERROR, payload=data)
|
||||||
|
|
||||||
|
|
||||||
|
def topup_url() -> str:
|
||||||
|
return f"{app_url()}/settings/billing"
|
||||||
|
|
||||||
|
|
||||||
|
def topup_next_step(url: str | None = None) -> str:
|
||||||
|
return (
|
||||||
|
f"Buy credits with `{TOPUP_COMMAND}` or at {url or topup_url()}. "
|
||||||
|
f"Run `{BALANCE_COMMAND}` to see the balance. Then retry this command."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def payment_required_error(data: Any, *, detail: str = "") -> CloudError:
|
||||||
|
"""Build the error for an exhausted credit balance.
|
||||||
|
|
||||||
|
The platform sends the recovery instruction in `hint` and repeats it inside
|
||||||
|
`detail`. The CLI shows the instruction once, on its own line, and adds its
|
||||||
|
own instruction when the platform sends none.
|
||||||
|
"""
|
||||||
|
server_hint = ""
|
||||||
|
server_url: str | None = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
raw = cast("dict[str, Any]", data)
|
||||||
|
server_hint = str(raw.get("hint") or "").strip()
|
||||||
|
raw_url = raw.get("topup_url")
|
||||||
|
if isinstance(raw_url, str) and raw_url.startswith("https://"):
|
||||||
|
server_url = raw_url
|
||||||
|
message = detail.strip()
|
||||||
|
if server_hint and message.endswith(server_hint):
|
||||||
|
message = message[: -len(server_hint)].strip()
|
||||||
|
if not message:
|
||||||
|
message = "Not enough credits to run this command."
|
||||||
|
next_step = server_hint or topup_next_step(server_url)
|
||||||
|
return CloudError(message, exit_code=EXIT_PAYMENT, payload=data, next_step=next_step)
|
||||||
|
|||||||
@@ -1035,10 +1035,14 @@ def _emit_error(
|
|||||||
payload = {"error": str(exc)}
|
payload = {"error": str(exc)}
|
||||||
if exc.payload is not None:
|
if exc.payload is not None:
|
||||||
payload["detail"] = exc.payload
|
payload["detail"] = exc.payload
|
||||||
|
if exc.next_step:
|
||||||
|
payload["next_step"] = exc.next_step
|
||||||
sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n")
|
sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n")
|
||||||
return
|
return
|
||||||
target = Console(stderr=True) if to_stderr else console
|
target = Console(stderr=True) if to_stderr else console
|
||||||
target.print(f"[red]Error:[/] {escape(sanitize_terminal_text(exc))}")
|
target.print(f"[red]Error:[/] {escape(sanitize_terminal_text(exc))}")
|
||||||
|
if exc.next_step:
|
||||||
|
target.print(f"[yellow]Next step:[/] {escape(sanitize_terminal_text(exc.next_step))}")
|
||||||
|
|
||||||
|
|
||||||
def _emit_interrupted(console: Console, *, as_json: bool, to_stderr: bool) -> None:
|
def _emit_interrupted(console: Console, *, as_json: bool, to_stderr: bool) -> None:
|
||||||
|
|||||||
@@ -203,6 +203,17 @@ def prepare_source(
|
|||||||
"""Select safe source files and build a bounded temporary ZIP archive."""
|
"""Select safe source files and build a bounded temporary ZIP archive."""
|
||||||
source = Path(value).expanduser().resolve()
|
source = Path(value).expanduser().resolve()
|
||||||
if not source.is_dir():
|
if not source.is_dir():
|
||||||
|
if source.is_file() and (
|
||||||
|
source.name.lower().endswith(_ARCHIVE_SUFFIXES) or _has_archive_magic(source)
|
||||||
|
):
|
||||||
|
raise http.CloudError(
|
||||||
|
f"--source must be a directory, not an archive: {source}",
|
||||||
|
next_step=(
|
||||||
|
"Extract the archive and pass the directory to --source. Strix packs the "
|
||||||
|
"directory and excludes dependencies, build output, and secret-like files. "
|
||||||
|
"Add --dry-run --show-files to review the selection first."
|
||||||
|
),
|
||||||
|
)
|
||||||
raise http.CloudError(f"--source must be a directory: {source}")
|
raise http.CloudError(f"--source must be a directory: {source}")
|
||||||
manifest = select_source(
|
manifest = select_source(
|
||||||
source,
|
source,
|
||||||
@@ -224,14 +235,34 @@ def prepare_source(
|
|||||||
archive_bytes = archive_path.stat().st_size
|
archive_bytes = archive_path.stat().st_size
|
||||||
if archive_bytes > MAX_ARCHIVE_BYTES:
|
if archive_bytes > MAX_ARCHIVE_BYTES:
|
||||||
archive_path.unlink(missing_ok=True)
|
archive_path.unlink(missing_ok=True)
|
||||||
raise http.CloudError(
|
raise _archive_too_large_error(manifest, archive_bytes)
|
||||||
"source archive is larger than the 50 MB upload limit; narrow --source or "
|
|
||||||
"add --exclude patterns."
|
|
||||||
)
|
|
||||||
digest = _sha256(archive_path)
|
digest = _sha256(archive_path)
|
||||||
return SourceBundle(manifest, archive_path, archive_bytes, digest)
|
return SourceBundle(manifest, archive_path, archive_bytes, digest)
|
||||||
|
|
||||||
|
|
||||||
|
_LARGEST_FILES_SHOWN = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _format_mib(size: int) -> str:
|
||||||
|
return f"{size / (1024 * 1024):.1f} MiB"
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_too_large_error(manifest: SourceManifest, archive_bytes: int) -> http.CloudError:
|
||||||
|
"""Name the largest selected files so the user knows what to exclude."""
|
||||||
|
largest = sorted(manifest.files, key=lambda item: item.size, reverse=True)
|
||||||
|
listed = ", ".join(
|
||||||
|
f"{item.archive_name} ({_format_mib(item.size)})" for item in largest[:_LARGEST_FILES_SHOWN]
|
||||||
|
)
|
||||||
|
return http.CloudError(
|
||||||
|
f"the source archive is {_format_mib(archive_bytes)}, larger than the "
|
||||||
|
f"{_format_mib(MAX_ARCHIVE_BYTES)} upload limit. Largest files: {listed}.",
|
||||||
|
next_step=(
|
||||||
|
"Add --exclude patterns for large files or directories, or point --source at a "
|
||||||
|
"smaller directory. Run with --dry-run --show-files to review the selection."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def select_source(
|
def select_source(
|
||||||
source: Path,
|
source: Path,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1066,7 +1066,8 @@ SPEC: dict[str, dict[str, Cmd]] = {
|
|||||||
"request": Cmd(
|
"request": Cmd(
|
||||||
"POST",
|
"POST",
|
||||||
"/uploads/request",
|
"/uploads/request",
|
||||||
"Request an upload URL.",
|
"Request an upload URL. To scan local source, prefer `strix cloud scans start "
|
||||||
|
"--source DIR`, which packs, uploads, and starts the scan in one step.",
|
||||||
body=(
|
body=(
|
||||||
P("file_name", required=True, help="File name."),
|
P("file_name", required=True, help="File name."),
|
||||||
P("file_size", "int", required=True, help="File size in bytes."),
|
P("file_size", "int", required=True, help="File size in bytes."),
|
||||||
|
|||||||
@@ -534,6 +534,69 @@ def test_insufficient_credits_exits_with_payment_code(monkeypatch: pytest.Monkey
|
|||||||
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT
|
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_credits_always_prints_topup_instruction(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
http,
|
||||||
|
"request",
|
||||||
|
lambda *_a, **_k: FakeResponse(
|
||||||
|
status_code=402,
|
||||||
|
payload={"detail": "Out of credits.", "code": "scan_credit_limit_reached"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||||
|
argv = ["scans", "start", "--domain-ids", "d1", "--app-url", "https://app.strix.ai"]
|
||||||
|
assert cloud.run_cloud(argv) == http.EXIT_PAYMENT
|
||||||
|
output = " ".join(capsys.readouterr().out.split())
|
||||||
|
assert "Error: Out of credits." in output
|
||||||
|
assert "Next step:" in output
|
||||||
|
assert "strix cloud billing topup --credits <count>" in output
|
||||||
|
assert "https://app.strix.ai/settings/billing" in output
|
||||||
|
assert "strix cloud billing credits" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_credits_shows_platform_hint_once(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||||
|
) -> None:
|
||||||
|
hint = "Buy credits at https://app.strix.ai/settings/billing. Then retry this request."
|
||||||
|
payload = {
|
||||||
|
"detail": f"Out of credits. {hint}",
|
||||||
|
"code": "scan_credit_limit_reached",
|
||||||
|
"hint": hint,
|
||||||
|
"topup_url": "https://app.strix.ai/settings/billing",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=payload)
|
||||||
|
)
|
||||||
|
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--json"]) == http.EXIT_PAYMENT
|
||||||
|
result = json.loads(capsys.readouterr().out)
|
||||||
|
assert result["error"] == "Out of credits."
|
||||||
|
assert result["next_step"] == hint
|
||||||
|
assert result["topup_url"] == "https://app.strix.ai/settings/billing"
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||||
|
assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT
|
||||||
|
output = " ".join(capsys.readouterr().out.split())
|
||||||
|
assert output.count(hint) == 1
|
||||||
|
assert "Error: Out of credits." in output
|
||||||
|
assert f"Next step: {hint}" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_payment_required_without_body_names_the_topup_command(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload={})
|
||||||
|
)
|
||||||
|
argv = ["scans", "start", "--domain-ids", "d1", "--json", "--app-url", "https://app.strix.ai"]
|
||||||
|
assert cloud.run_cloud(argv) == http.EXIT_PAYMENT
|
||||||
|
result = json.loads(capsys.readouterr().out)
|
||||||
|
assert result["error"] == "Not enough credits to run this command."
|
||||||
|
assert "strix cloud billing topup --credits <count>" in result["next_step"]
|
||||||
|
assert "https://app.strix.ai/settings/billing" in result["next_step"]
|
||||||
|
|
||||||
|
|
||||||
def test_data_rejects_non_object() -> None:
|
def test_data_rejects_non_object() -> None:
|
||||||
assert cloud.run_cloud(["scans", "start", "--data", "[1,2]"]) == http.EXIT_USAGE
|
assert cloud.run_cloud(["scans", "start", "--data", "[1,2]"]) == http.EXIT_USAGE
|
||||||
assert cloud.run_cloud(["scans", "start", "--data", "not json"]) == http.EXIT_USAGE
|
assert cloud.run_cloud(["scans", "start", "--data", "not json"]) == http.EXIT_USAGE
|
||||||
|
|||||||
@@ -655,3 +655,44 @@ def test_incomplete_upload_credentials_delete_the_reserved_upload(
|
|||||||
monkeypatch.setattr(http, "request", fake_request)
|
monkeypatch.setattr(http, "request", fake_request)
|
||||||
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 1
|
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes", "--json"]) == 1
|
||||||
assert ("DELETE", "/uploads/upload-incomplete") in paths
|
assert ("DELETE", "/uploads/upload-incomplete") in paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_source_is_rejected_with_directory_guidance(tmp_path: Path) -> None:
|
||||||
|
archive = tmp_path / "backend.zip"
|
||||||
|
with zipfile.ZipFile(archive, "w") as bundle:
|
||||||
|
bundle.writestr("app.py", "print('safe')\n")
|
||||||
|
|
||||||
|
with pytest.raises(http.CloudError, match="not an archive") as raised:
|
||||||
|
source_upload.prepare_source(
|
||||||
|
str(archive),
|
||||||
|
include_hidden=False,
|
||||||
|
include_sensitive=False,
|
||||||
|
include_archives=False,
|
||||||
|
exclude=[],
|
||||||
|
)
|
||||||
|
assert raised.value.next_step is not None
|
||||||
|
assert "--source" in raised.value.next_step
|
||||||
|
assert "--dry-run --show-files" in raised.value.next_step
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversize_archive_names_largest_files_and_exclude_guidance(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||||
|
(tmp_path / "big.bin").write_bytes(os.urandom(4096))
|
||||||
|
monkeypatch.setattr(source_upload, "MAX_ARCHIVE_BYTES", 1024)
|
||||||
|
|
||||||
|
with pytest.raises(http.CloudError, match=r"larger than the 0\.0 MiB upload limit") as raised:
|
||||||
|
source_upload.prepare_source(
|
||||||
|
str(tmp_path),
|
||||||
|
include_hidden=False,
|
||||||
|
include_sensitive=False,
|
||||||
|
include_archives=False,
|
||||||
|
exclude=[],
|
||||||
|
)
|
||||||
|
message = str(raised.value)
|
||||||
|
assert message.index("big.bin") < message.index("app.py")
|
||||||
|
assert raised.value.next_step is not None
|
||||||
|
assert "--exclude" in raised.value.next_step
|
||||||
|
assert "--dry-run --show-files" in raised.value.next_step
|
||||||
|
assert not list(tmp_path.glob("strix-source-*.zip"))
|
||||||
|
|||||||
Reference in New Issue
Block a user