lighthouse-cli 0.16.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.
- lighthouse/__init__.py +6 -0
- lighthouse/__main__.py +9 -0
- lighthouse/commands/__init__.py +0 -0
- lighthouse/commands/agents.py +310 -0
- lighthouse/commands/builds.py +85 -0
- lighthouse/commands/collectors.py +147 -0
- lighthouse/commands/connect.py +717 -0
- lighthouse/commands/connections.py +201 -0
- lighthouse/commands/evals.py +198 -0
- lighthouse/commands/kb.py +224 -0
- lighthouse/commands/language.py +56 -0
- lighthouse/commands/limits.py +61 -0
- lighthouse/commands/login.py +207 -0
- lighthouse/commands/models.py +61 -0
- lighthouse/commands/runs.py +157 -0
- lighthouse/commands/spend.py +84 -0
- lighthouse/commands/stack.py +103 -0
- lighthouse/commands/terms.py +50 -0
- lighthouse/commands/whoami.py +80 -0
- lighthouse/config.py +141 -0
- lighthouse/credentials.py +233 -0
- lighthouse/device.py +124 -0
- lighthouse/exits.py +78 -0
- lighthouse/flags.py +61 -0
- lighthouse/http.py +355 -0
- lighthouse/i18n.py +250 -0
- lighthouse/locales/ar.json +343 -0
- lighthouse/locales/de.json +343 -0
- lighthouse/locales/en.json +343 -0
- lighthouse/locales/es.json +343 -0
- lighthouse/locales/fr.json +343 -0
- lighthouse/locales/ga.json +343 -0
- lighthouse/locales/it.json +343 -0
- lighthouse/locales/ja.json +343 -0
- lighthouse/locales/nl.json +343 -0
- lighthouse/locales/pt.json +343 -0
- lighthouse/locales/zh-Hans.json +343 -0
- lighthouse/locales/zh-Hant.json +343 -0
- lighthouse/main.py +229 -0
- lighthouse/out.py +169 -0
- lighthouse/py.typed +0 -0
- lighthouse/sealing.py +448 -0
- lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
- lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
- lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
- lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
- lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
- lighthouse_cli-0.16.0.dist-info/top_level.txt +1 -0
lighthouse/device.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Signing in through the browser with a device code, as `stripe login` does.
|
|
4
|
+
|
|
5
|
+
$ lighthouse login --host t7m2kqx9vbn.atlanticagenticlabs.com
|
|
6
|
+
|
|
7
|
+
Your pairing code is: WXKM-7T4P
|
|
8
|
+
Press Enter to open the browser, or visit
|
|
9
|
+
https://t7m2kqx9vbn.atlanticagenticlabs.com/device
|
|
10
|
+
Waiting for approval…
|
|
11
|
+
Signed in as ops@example.test (viewer). Scopes: telemetry:read agents:read
|
|
12
|
+
|
|
13
|
+
The person approves in their own portal, behind their own identity provider and
|
|
14
|
+
their own second factor. Nothing is typed into this terminal: no password, no
|
|
15
|
+
token, no copy and paste. The CLI then holds a short-lived delegated token in
|
|
16
|
+
the OS keychain (credentials.py).
|
|
17
|
+
|
|
18
|
+
**The code is shown before the browser opens**, and the browser is opened only
|
|
19
|
+
on a keypress unless `--yes` is given. A command that silently launches a
|
|
20
|
+
browser is a command nobody can run over SSH, and a code the person has already
|
|
21
|
+
seen is a code they can compare with the one on the page — which is the whole
|
|
22
|
+
protection against being walked through approving somebody else's sign-in.
|
|
23
|
+
|
|
24
|
+
`--no-browser` prints the URL and nothing else, for a remote shell.
|
|
25
|
+
|
|
26
|
+
Polling obeys the interval the service asks for, and backs off further when it
|
|
27
|
+
answers `slow_down` (RFC 8628 §3.5). It stops at `expires_in` with a message
|
|
28
|
+
that says to run the command again, rather than looping.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import time
|
|
33
|
+
import webbrowser
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from lighthouse.exits import NEEDS_A_PERSON, CliError
|
|
38
|
+
from lighthouse.http import Client, Refused
|
|
39
|
+
from lighthouse.i18n import t
|
|
40
|
+
from lighthouse.out import Out
|
|
41
|
+
|
|
42
|
+
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
43
|
+
PENDING = ("authorization_pending", "slow_down")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Started:
|
|
48
|
+
user_code: str
|
|
49
|
+
verification_uri: str
|
|
50
|
+
verification_uri_complete: str
|
|
51
|
+
device_code: str
|
|
52
|
+
interval: int
|
|
53
|
+
expires_in: int
|
|
54
|
+
scope: str
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def of(cls, body: dict[str, Any]) -> Started:
|
|
58
|
+
return cls(str(body["user_code"]), str(body["verification_uri"]),
|
|
59
|
+
str(body.get("verification_uri_complete") or body["verification_uri"]),
|
|
60
|
+
str(body["device_code"]), int(body.get("interval") or 5),
|
|
61
|
+
int(body.get("expires_in") or 600), str(body.get("scope") or ""))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def start(client: Client, *, scope: str | None, client_name: str) -> Started:
|
|
65
|
+
body = client.post("/api/v1/oauth/device/code",
|
|
66
|
+
{"scope": scope, "client_name": client_name})
|
|
67
|
+
return Started.of(body)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def open_browser(url: str, *, opener: Any = None) -> bool:
|
|
71
|
+
"""True if a browser was opened. Never raises: a machine with no browser is
|
|
72
|
+
a normal thing, and the URL has already been printed."""
|
|
73
|
+
try:
|
|
74
|
+
return bool((opener or webbrowser.open)(url))
|
|
75
|
+
except Exception: # pragma: no cover - platform dependent
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def poll(client: Client, started: Started, *, out: Out, sleep: Any = None,
|
|
80
|
+
now: Any = None) -> dict[str, Any]:
|
|
81
|
+
"""Wait for the person, then return the token the service issued."""
|
|
82
|
+
rest = sleep or time.sleep
|
|
83
|
+
clock = now or time.monotonic
|
|
84
|
+
deadline = clock() + started.expires_in
|
|
85
|
+
interval = float(started.interval)
|
|
86
|
+
while True:
|
|
87
|
+
if clock() >= deadline:
|
|
88
|
+
raise CliError(t("err.pairing_expired"), error="expired", hint=t("hint.login_again"))
|
|
89
|
+
rest(interval)
|
|
90
|
+
try:
|
|
91
|
+
return dict(client.post("/api/v1/oauth/token", {
|
|
92
|
+
"grant_type": DEVICE_GRANT, "device_code": started.device_code}))
|
|
93
|
+
except Refused as e:
|
|
94
|
+
if e.error == "slow_down":
|
|
95
|
+
interval += 5
|
|
96
|
+
continue
|
|
97
|
+
if e.error == "authorization_pending":
|
|
98
|
+
continue
|
|
99
|
+
if e.error == "access_denied":
|
|
100
|
+
raise CliError(t("err.sign_in_declined"), error="declined") from None
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sign_in(client: Client, *, out: Out, scope: str | None, client_name: str,
|
|
105
|
+
no_browser: bool = False, assume_yes: bool = False, no_input: bool = False,
|
|
106
|
+
opener: Any = None, sleep: Any = None, reader: Any = None) -> dict[str, Any]:
|
|
107
|
+
"""The whole flow. Returns the token document; the caller stores it."""
|
|
108
|
+
started = start(client, scope=scope, client_name=client_name)
|
|
109
|
+
out.say(t("login.pairing_code", code=out.paint(started.user_code, "bold")))
|
|
110
|
+
out.say(t("login.approve_at", url=started.verification_uri))
|
|
111
|
+
if started.scope:
|
|
112
|
+
out.note(t("login.asking_for", scope=started.scope))
|
|
113
|
+
if not no_browser:
|
|
114
|
+
if assume_yes or no_input:
|
|
115
|
+
open_browser(started.verification_uri_complete, opener=opener)
|
|
116
|
+
else:
|
|
117
|
+
try:
|
|
118
|
+
(reader or input)(t("prompt.open_browser") + " ")
|
|
119
|
+
except EOFError:
|
|
120
|
+
raise CliError(t("err.no_terminal"), code=NEEDS_A_PERSON,
|
|
121
|
+
error="needs_a_person", hint=t("hint.no_terminal")) from None
|
|
122
|
+
open_browser(started.verification_uri_complete, opener=opener)
|
|
123
|
+
out.note(t("note.waiting_for_approval"))
|
|
124
|
+
return poll(client, started, out=out, sleep=sleep)
|
lighthouse/exits.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Exit codes, and the one error type that carries them.
|
|
4
|
+
|
|
5
|
+
A script in someone's CI has to be able to tell the four cases apart without
|
|
6
|
+
reading English, so the codes are few, fixed and documented:
|
|
7
|
+
|
|
8
|
+
| Code | Meaning | What a pipeline should do |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| 0 | it worked | carry on |
|
|
11
|
+
| 1 | the command failed | stop; the message says why |
|
|
12
|
+
| 2 | the command line was wrong | stop; a human must fix the invocation |
|
|
13
|
+
| 3 | not signed in, or the credential has expired | run `lighthouse login` |
|
|
14
|
+
| 4 | signed in, but the role or scope does not reach this | ask an administrator |
|
|
15
|
+
| 5 | the service refused for now: rate limit, or a retryable 5xx | retry later |
|
|
16
|
+
| 7 | the command needed a person and none is there (`--no-input`, or no tty) | stop |
|
|
17
|
+
|
|
18
|
+
Nothing exits on a signal with a code of its own: SIGINT leaves 130 as the
|
|
19
|
+
shell expects.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from lighthouse.i18n import t
|
|
26
|
+
|
|
27
|
+
OK = 0
|
|
28
|
+
FAILED = 1
|
|
29
|
+
USAGE = 2
|
|
30
|
+
UNAUTHENTICATED = 3
|
|
31
|
+
FORBIDDEN = 4
|
|
32
|
+
RETRY = 5
|
|
33
|
+
NEEDS_A_PERSON = 7
|
|
34
|
+
|
|
35
|
+
CODES = {
|
|
36
|
+
OK: "ok",
|
|
37
|
+
FAILED: "failed",
|
|
38
|
+
USAGE: "usage",
|
|
39
|
+
UNAUTHENTICATED: "unauthenticated",
|
|
40
|
+
FORBIDDEN: "forbidden",
|
|
41
|
+
RETRY: "retry",
|
|
42
|
+
NEEDS_A_PERSON: "needs_a_person",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CliError(Exception):
|
|
47
|
+
"""A failure to report and exit with. `error` is a stable short code, so
|
|
48
|
+
`--json` output can be matched on and the English can be improved without
|
|
49
|
+
breaking anyone's script."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, *, code: int = FAILED, error: str = "failed",
|
|
52
|
+
hint: str | None = None, **extra: Any) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.message = message
|
|
55
|
+
self.code = code
|
|
56
|
+
self.error = error
|
|
57
|
+
self.hint = hint
|
|
58
|
+
self.extra = extra
|
|
59
|
+
|
|
60
|
+
def body(self) -> dict[str, Any]:
|
|
61
|
+
out: dict[str, Any] = {"ok": False, "error": self.error, "message": self.message}
|
|
62
|
+
if self.hint:
|
|
63
|
+
out["hint"] = self.hint
|
|
64
|
+
out.update(self.extra)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NotSignedIn(CliError):
|
|
69
|
+
def __init__(self, host: str | None = None) -> None:
|
|
70
|
+
message = t("err.not_signed_in_for", host=host) if host else t("err.not_signed_in")
|
|
71
|
+
super().__init__(message, code=UNAUTHENTICATED, error="unauthenticated",
|
|
72
|
+
hint=t("hint.login"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NeedsAPerson(CliError):
|
|
76
|
+
def __init__(self, what: str) -> None:
|
|
77
|
+
super().__init__(t("err.needs_a_person", what=what), code=NEEDS_A_PERSON,
|
|
78
|
+
error="needs_a_person", hint=t("hint.needs_a_person"))
|
lighthouse/flags.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The global options, declared once and attached everywhere.
|
|
4
|
+
|
|
5
|
+
`--host`, `--json`, `--quiet`, `--no-input`, `--yes` and `--timeout` must work
|
|
6
|
+
at every depth: `lighthouse --json runs tail`, `lighthouse runs --json tail` and
|
|
7
|
+
`lighthouse runs tail --json` all mean the same thing, because people type all
|
|
8
|
+
three and a tool that accepts only one of them is a tool people swear at.
|
|
9
|
+
|
|
10
|
+
That is done by declaring them on the root parser with real defaults, and on a
|
|
11
|
+
shared parent — `inherited()` — with `default=SUPPRESS` for every subparser at
|
|
12
|
+
every level. Without `SUPPRESS`, parsing a subcommand would write the flag's
|
|
13
|
+
default over whatever an outer parser had already read, which is the single
|
|
14
|
+
commonest bug in nested `argparse` command lines.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
HELP = {
|
|
22
|
+
"host": "the tenant's hostname or id",
|
|
23
|
+
"json": "one JSON document on standard output, nothing else",
|
|
24
|
+
"quiet": "no prose",
|
|
25
|
+
"no_input": "never ask a person anything; exit 7 instead",
|
|
26
|
+
"yes": "answer yes to every confirmation",
|
|
27
|
+
"timeout": "seconds for one request",
|
|
28
|
+
"lang": "the language to speak: en, fr, de, es, it, pt, nl, ga, ja, zh-Hans, zh-Hant or ar (default: your setting, then LANG)",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add(target: argparse.ArgumentParser, *, suppress: bool) -> None:
|
|
33
|
+
"""Attach the global options. `suppress` for anything but the root."""
|
|
34
|
+
def opts(default: Any) -> dict[str, Any]:
|
|
35
|
+
return {"default": argparse.SUPPRESS} if suppress else {"default": default}
|
|
36
|
+
|
|
37
|
+
hide = argparse.SUPPRESS
|
|
38
|
+
target.add_argument("--host", help=hide if suppress else HELP["host"], **opts(None))
|
|
39
|
+
target.add_argument("--json", action="store_true", dest="as_json",
|
|
40
|
+
help=hide if suppress else HELP["json"], **opts(False))
|
|
41
|
+
target.add_argument("--quiet", action="store_true",
|
|
42
|
+
help=hide if suppress else HELP["quiet"], **opts(False))
|
|
43
|
+
target.add_argument("--no-input", action="store_true", dest="no_input",
|
|
44
|
+
help=hide if suppress else HELP["no_input"], **opts(False))
|
|
45
|
+
target.add_argument("--yes", action="store_true",
|
|
46
|
+
help=hide if suppress else HELP["yes"], **opts(False))
|
|
47
|
+
target.add_argument("--timeout", type=float,
|
|
48
|
+
help=hide if suppress else HELP["timeout"], **opts(None))
|
|
49
|
+
target.add_argument("--lang", metavar="TAG",
|
|
50
|
+
help=hide if suppress else HELP["lang"], **opts(None))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def inherited() -> argparse.ArgumentParser:
|
|
54
|
+
"""A parent parser carrying the global options, for `parents=[...]`.
|
|
55
|
+
|
|
56
|
+
A fresh one each time: `argparse` refuses to share a parent between two
|
|
57
|
+
parsers that both add the same option, and a sub-subcommand needs its own.
|
|
58
|
+
"""
|
|
59
|
+
parent = argparse.ArgumentParser(add_help=False)
|
|
60
|
+
add(parent, suppress=True)
|
|
61
|
+
return parent
|
lighthouse/http.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Talking to the control plane: retries, pagination, idempotency, refusals.
|
|
4
|
+
|
|
5
|
+
One class, `Client`, used by every command. It holds four behaviours that would
|
|
6
|
+
otherwise be written slightly differently in each of them:
|
|
7
|
+
|
|
8
|
+
**Retries.** A 429 or a 5xx is retried with exponential back-off and full
|
|
9
|
+
jitter, honouring `Retry-After` when the service sends one. A 4xx that is not
|
|
10
|
+
429 is never retried: the request was wrong and sending it again will not make
|
|
11
|
+
it right. Nor is a write retried without an idempotency key, because a POST
|
|
12
|
+
that timed out may well have been applied.
|
|
13
|
+
|
|
14
|
+
**Idempotency.** Every write carries `Idempotency-Key`, a fresh UUID per
|
|
15
|
+
logical operation (not per attempt), so a retried deploy is the same deploy and
|
|
16
|
+
not a second one.
|
|
17
|
+
|
|
18
|
+
**Pagination.** The read API answers `{<rows>: [...], "next": "<cursor>"}` and
|
|
19
|
+
takes that cursor back as `?cursor=`. `each()` follows it and yields rows, and
|
|
20
|
+
`get_all()` does the same but returns one document with every row in it, so no
|
|
21
|
+
command has a `while True` of its own.
|
|
22
|
+
|
|
23
|
+
**Cloudflare Access.** Every tenant host is behind Cloudflare Access except the
|
|
24
|
+
OAuth endpoints. When a credential carries the tenant's Access service token,
|
|
25
|
+
`CF-Access-Client-Id` and `CF-Access-Client-Secret` go on every request (it is
|
|
26
|
+
harmless on the bypassed paths). If Access answers instead of the service, with
|
|
27
|
+
a redirect to `*.cloudflareaccess.com` or a login page, the CLI says the service
|
|
28
|
+
token is missing or wrong and names the two variables.
|
|
29
|
+
|
|
30
|
+
**Refusals.** The control plane answers `{error, message}` with a fixed code
|
|
31
|
+
(`docs/CONTRACT.md`). Those codes are mapped onto the CLI's exit codes
|
|
32
|
+
(exits.py) once, here: `role_required` becomes 4, an expired credential becomes
|
|
33
|
+
3 after one attempt to refresh it, a rate limit becomes 5.
|
|
34
|
+
|
|
35
|
+
**Refresh.** A delegated token is short-lived (eight hours). The client
|
|
36
|
+
refreshes it before it lapses and once on a 401, then stores the new one in the
|
|
37
|
+
keychain. A credential that came from `LIGHTHOUSE_TOKEN` is never refreshed: in
|
|
38
|
+
a pipeline there is nowhere to put the new one, and silently holding one in
|
|
39
|
+
memory would hide that the variable needs rotating. A machine key (`aalcp-`)
|
|
40
|
+
does not expire by time, so it is the credential for a pipeline.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import random
|
|
46
|
+
import time
|
|
47
|
+
import uuid
|
|
48
|
+
from collections.abc import Iterator
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
import httpx
|
|
52
|
+
|
|
53
|
+
from lighthouse import __version__
|
|
54
|
+
from lighthouse.credentials import ENV_ACCESS_ID, ENV_ACCESS_SECRET, ENV_TOKEN, Credential, Store
|
|
55
|
+
from lighthouse.exits import FORBIDDEN, RETRY, UNAUTHENTICATED, CliError, NotSignedIn
|
|
56
|
+
from lighthouse.i18n import t
|
|
57
|
+
|
|
58
|
+
log = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=60.0, pool=10.0)
|
|
61
|
+
# Pages walked at most, so a service that never stops sending a cursor cannot
|
|
62
|
+
# keep a pipeline busy for ever.
|
|
63
|
+
MAX_PAGES = 500
|
|
64
|
+
ATTEMPTS = 4
|
|
65
|
+
BACKOFF = 0.5
|
|
66
|
+
MAX_BACKOFF = 8.0
|
|
67
|
+
WRITES = ("POST", "PUT", "PATCH", "DELETE")
|
|
68
|
+
|
|
69
|
+
# A refusal code (docs/CONTRACT.md) -> the exit code it becomes. Anything not
|
|
70
|
+
# here is an ordinary failure, exit 1.
|
|
71
|
+
EXIT_FOR = {
|
|
72
|
+
"unauthenticated": UNAUTHENTICATED,
|
|
73
|
+
"invalid_token": UNAUTHENTICATED,
|
|
74
|
+
"mfa_required": UNAUTHENTICATED,
|
|
75
|
+
"role_required": FORBIDDEN,
|
|
76
|
+
"not_authorised": FORBIDDEN,
|
|
77
|
+
"scope_required": FORBIDDEN,
|
|
78
|
+
"rate_limited": RETRY,
|
|
79
|
+
"tier_b_unavailable": RETRY,
|
|
80
|
+
"tenant_key_missing": RETRY,
|
|
81
|
+
"records_deferred": RETRY,
|
|
82
|
+
"identity_unavailable": RETRY,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Refused(CliError):
|
|
87
|
+
"""The service refused. Carries the service's own code, so a script can
|
|
88
|
+
match on `error` and a person reads `message`."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, status: int, body: dict[str, Any]) -> None:
|
|
91
|
+
code = str(body.get("error") or f"http_{status}")
|
|
92
|
+
hint = None
|
|
93
|
+
needed = body.get("required_scope")
|
|
94
|
+
if code == "scope_required" and isinstance(needed, str) and needed:
|
|
95
|
+
# The token was approved without the scope this command needs:
|
|
96
|
+
# say which, and how to get it, rather than leave a bare 403.
|
|
97
|
+
hint = t("hint.scope_required", scope=needed)
|
|
98
|
+
super().__init__(str(body.get("message") or t("err.service_answered", status=status)),
|
|
99
|
+
code=EXIT_FOR.get(code, 1), error=code, hint=hint, status=status)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def access_wall(reply: httpx.Response) -> bool:
|
|
103
|
+
"""Whether Cloudflare Access, not the service, answered.
|
|
104
|
+
|
|
105
|
+
Access sends an unauthenticated API call to its login: a 302/303 to
|
|
106
|
+
`<team>.cloudflareaccess.com`, or a login page as HTML. The service answers
|
|
107
|
+
JSON, always, so an HTML 200, 401 or 403 is Access (or something that is not
|
|
108
|
+
LightHouse), never the control plane. A 5xx page from a proxy is left
|
|
109
|
+
alone: that is an outage, not a missing token.
|
|
110
|
+
"""
|
|
111
|
+
if reply.status_code in (301, 302, 303, 307, 308):
|
|
112
|
+
return "cloudflareaccess.com" in reply.headers.get("Location", "").lower()
|
|
113
|
+
kind = reply.headers.get("Content-Type", "").lower()
|
|
114
|
+
if "json" in kind:
|
|
115
|
+
return False
|
|
116
|
+
if "html" in kind and reply.status_code in (200, 401, 403):
|
|
117
|
+
return True
|
|
118
|
+
head = reply.content[:2000].lower()
|
|
119
|
+
return b"cloudflareaccess.com" in head or b"cloudflare access" in head
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TokenRefused(NotSignedIn):
|
|
123
|
+
"""A 401 on a credential from `LIGHTHOUSE_TOKEN`, said in its own terms."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, message: str, hint: str) -> None:
|
|
126
|
+
CliError.__init__(self, message, code=UNAUTHENTICATED, error="unauthenticated",
|
|
127
|
+
hint=hint)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AccessRefused(CliError):
|
|
131
|
+
def __init__(self, host: str, *, configured: bool) -> None:
|
|
132
|
+
state = t("err.access_state_wrong") if configured else t("err.access_state_missing")
|
|
133
|
+
super().__init__(
|
|
134
|
+
t("err.access_stopped", state=state),
|
|
135
|
+
code=UNAUTHENTICATED, error="access_service_token", host=host,
|
|
136
|
+
hint=t("hint.access_stopped", id=ENV_ACCESS_ID, secret=ENV_ACCESS_SECRET))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def sleep_for(attempt: int, retry_after: str | None) -> float:
|
|
140
|
+
"""How long before the next attempt. `Retry-After` wins where the service
|
|
141
|
+
sent one; otherwise exponential with full jitter, so a fleet of runners
|
|
142
|
+
does not retry in step."""
|
|
143
|
+
if retry_after:
|
|
144
|
+
try:
|
|
145
|
+
return max(0.0, min(float(retry_after), 60.0))
|
|
146
|
+
except ValueError:
|
|
147
|
+
pass
|
|
148
|
+
# Full jitter, so a fleet of runners does not retry in step. Not a
|
|
149
|
+
# cryptographic choice: nothing here is a secret or a credential.
|
|
150
|
+
return random.uniform(0, min(MAX_BACKOFF, BACKOFF * (2 ** attempt))) # noqa: S311 # nosec B311
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def retry_hint(status: int, headers: Any) -> str | None:
|
|
154
|
+
"""The service's own word on when to send again: `Retry-After`, or on a
|
|
155
|
+
429 that carries none, `RateLimit-Reset` (the seconds until this minute's
|
|
156
|
+
allowance is renewed)."""
|
|
157
|
+
hint = headers.get("Retry-After")
|
|
158
|
+
if not hint and status == 429:
|
|
159
|
+
hint = headers.get("RateLimit-Reset")
|
|
160
|
+
return str(hint) if hint else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Client:
|
|
164
|
+
def __init__(self, host: str, credential: Credential | None, *, store: Store | None = None,
|
|
165
|
+
transport: Any = None, sleep: Any = None, attempts: int = ATTEMPTS,
|
|
166
|
+
user_agent: str | None = None, timeout: float | None = None,
|
|
167
|
+
access: tuple[str, str] | None = None, session: str | None = None) -> None:
|
|
168
|
+
self.host = host
|
|
169
|
+
self.credential = credential
|
|
170
|
+
# A person's own Cloudflare Access session for this host, from
|
|
171
|
+
# `cloudflared access token` (`connect --credential-stdin`). Sent as
|
|
172
|
+
# `cf-access-token`, the header Access itself documents for a CLI, and
|
|
173
|
+
# never beside a delegated token: the request is the person's.
|
|
174
|
+
self._session = session
|
|
175
|
+
self.store = store
|
|
176
|
+
self.attempts = attempts
|
|
177
|
+
self._access = access
|
|
178
|
+
self._sleep = sleep or time.sleep
|
|
179
|
+
# `--timeout N` is seconds for one request: connect, read, write and
|
|
180
|
+
# pool alike. Unset, the defaults above stand.
|
|
181
|
+
self.timeout = httpx.Timeout(timeout) if timeout and timeout > 0 else TIMEOUT
|
|
182
|
+
self._http = httpx.Client(
|
|
183
|
+
base_url=f"https://{host}", timeout=self.timeout, transport=transport,
|
|
184
|
+
follow_redirects=False,
|
|
185
|
+
headers={"User-Agent": user_agent or f"lighthouse/{__version__}",
|
|
186
|
+
"Accept": "application/json"})
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
self._http.close()
|
|
190
|
+
|
|
191
|
+
def __enter__(self) -> Client:
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def __exit__(self, *_: Any) -> None:
|
|
195
|
+
self.close()
|
|
196
|
+
|
|
197
|
+
# -- credentials ----------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def _access_pair(self) -> tuple[str, str] | None:
|
|
200
|
+
if self._access:
|
|
201
|
+
return self._access
|
|
202
|
+
return self.credential.access() if self.credential else None
|
|
203
|
+
|
|
204
|
+
def _headers(self, idempotency: str | None) -> dict[str, str]:
|
|
205
|
+
out = {}
|
|
206
|
+
pair = self._access_pair()
|
|
207
|
+
if pair:
|
|
208
|
+
out["CF-Access-Client-Id"], out["CF-Access-Client-Secret"] = pair
|
|
209
|
+
if self._session:
|
|
210
|
+
out["cf-access-token"] = self._session
|
|
211
|
+
elif self.credential and self.credential.access_token:
|
|
212
|
+
out["Authorization"] = f"Bearer {self.credential.access_token}"
|
|
213
|
+
if idempotency:
|
|
214
|
+
out["Idempotency-Key"] = idempotency
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
def refresh(self) -> bool:
|
|
218
|
+
"""Exchange the refresh token for a new pair. False if there is nothing
|
|
219
|
+
to refresh with, which is the normal case in CI."""
|
|
220
|
+
cred = self.credential
|
|
221
|
+
if cred is None or not cred.refresh_token or cred.source != "keychain":
|
|
222
|
+
return False
|
|
223
|
+
reply = self._http.post("/api/v1/oauth/token", headers=self._headers(None), json={
|
|
224
|
+
"grant_type": "refresh_token", "refresh_token": cred.refresh_token})
|
|
225
|
+
if reply.status_code != 200:
|
|
226
|
+
return False
|
|
227
|
+
token = reply.json()
|
|
228
|
+
self.credential = self.store.save(
|
|
229
|
+
self.host, token, email=cred.email, access_client_id=cred.access_client_id,
|
|
230
|
+
access_client_secret=cred.access_client_secret) \
|
|
231
|
+
if self.store is not None else cred
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
# -- requests ---------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def request(self, method: str, path: str, *, params: dict[str, Any] | None = None,
|
|
237
|
+
json_body: Any = None, idempotency: str | None = None) -> Any:
|
|
238
|
+
"""One logical request, retried where retrying is safe."""
|
|
239
|
+
method = method.upper()
|
|
240
|
+
if idempotency is None and method in WRITES:
|
|
241
|
+
idempotency = str(uuid.uuid4())
|
|
242
|
+
if self.credential is not None and self.credential.stale():
|
|
243
|
+
self.refresh()
|
|
244
|
+
refreshed = False
|
|
245
|
+
last: CliError | None = None
|
|
246
|
+
for attempt in range(self.attempts):
|
|
247
|
+
try:
|
|
248
|
+
reply = self._http.request(
|
|
249
|
+
method, path, params={k: v for k, v in (params or {}).items() if v is not None},
|
|
250
|
+
json=json_body, headers=self._headers(idempotency))
|
|
251
|
+
except httpx.TimeoutException as e:
|
|
252
|
+
last = CliError(t("err.timeout", method=method, path=path), code=RETRY, error="timeout")
|
|
253
|
+
log.debug("timeout: %s", e)
|
|
254
|
+
self._sleep(sleep_for(attempt, None))
|
|
255
|
+
continue
|
|
256
|
+
except httpx.TransportError as e:
|
|
257
|
+
raise CliError(t("err.unreachable", host=self.host, error=e), code=RETRY,
|
|
258
|
+
error="unreachable", hint=t("hint.unreachable")) from None
|
|
259
|
+
if access_wall(reply):
|
|
260
|
+
raise AccessRefused(self.host, configured=self._access_pair() is not None)
|
|
261
|
+
if reply.status_code == 401 and not refreshed and self.refresh():
|
|
262
|
+
refreshed = True
|
|
263
|
+
continue
|
|
264
|
+
if 200 <= reply.status_code < 300:
|
|
265
|
+
if not reply.content:
|
|
266
|
+
return {}
|
|
267
|
+
try:
|
|
268
|
+
return reply.json()
|
|
269
|
+
except ValueError:
|
|
270
|
+
raise CliError(t("err.not_json", method=method, path=path),
|
|
271
|
+
error="bad_response") from None
|
|
272
|
+
body = self._body(reply)
|
|
273
|
+
if reply.status_code == 429 or reply.status_code >= 500:
|
|
274
|
+
last = Refused(reply.status_code, body)
|
|
275
|
+
if attempt + 1 < self.attempts:
|
|
276
|
+
self._sleep(sleep_for(attempt, retry_hint(reply.status_code, reply.headers)))
|
|
277
|
+
continue
|
|
278
|
+
if reply.status_code == 401:
|
|
279
|
+
raise self._unauthenticated()
|
|
280
|
+
raise Refused(reply.status_code, body)
|
|
281
|
+
raise last or CliError(t("err.exhausted", method=method, path=path), code=RETRY,
|
|
282
|
+
error="exhausted")
|
|
283
|
+
|
|
284
|
+
def _unauthenticated(self) -> CliError:
|
|
285
|
+
"""A 401, said in the terms of where the credential came from."""
|
|
286
|
+
cred = self.credential
|
|
287
|
+
if cred is None or cred.source != "environment":
|
|
288
|
+
return NotSignedIn(self.host)
|
|
289
|
+
if cred.kind == "machine":
|
|
290
|
+
return TokenRefused(t("err.machine_key_refused", env=ENV_TOKEN),
|
|
291
|
+
t("hint.machine_key_refused"))
|
|
292
|
+
return TokenRefused(t("err.delegated_token_refused", env=ENV_TOKEN),
|
|
293
|
+
t("hint.delegated_token_refused", env=ENV_TOKEN))
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _body(reply: httpx.Response) -> dict[str, Any]:
|
|
297
|
+
try:
|
|
298
|
+
out = reply.json()
|
|
299
|
+
return out if isinstance(out, dict) else {"message": str(out)[:200]}
|
|
300
|
+
except ValueError:
|
|
301
|
+
return {"message": t("err.service_answered", status=reply.status_code)}
|
|
302
|
+
|
|
303
|
+
def get(self, path: str, **params: Any) -> Any:
|
|
304
|
+
return self.request("GET", path, params=params)
|
|
305
|
+
|
|
306
|
+
def post(self, path: str, body: Any = None, **params: Any) -> Any:
|
|
307
|
+
return self.request("POST", path, params=params, json_body=body)
|
|
308
|
+
|
|
309
|
+
def delete(self, path: str, **params: Any) -> Any:
|
|
310
|
+
return self.request("DELETE", path, params=params)
|
|
311
|
+
|
|
312
|
+
# -- pagination ---------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def each(self, path: str, key: str, *, limit: int | None = None,
|
|
315
|
+
**params: Any) -> Iterator[dict[str, Any]]:
|
|
316
|
+
"""Every row of a cursor-paginated read, newest page first.
|
|
317
|
+
|
|
318
|
+
The read API answers `{<key>: [...], "next": "..."}` and takes that
|
|
319
|
+
value back as `?cursor=`; a page whose `next` is null is the last one.
|
|
320
|
+
`limit` caps the rows yielded, so `runs tail --limit 10` does not walk
|
|
321
|
+
a year of history to show ten lines.
|
|
322
|
+
"""
|
|
323
|
+
seen = 0
|
|
324
|
+
cursor = None
|
|
325
|
+
visited: set[str] = set()
|
|
326
|
+
for _ in range(MAX_PAGES):
|
|
327
|
+
page = self.get(path, cursor=cursor, **params)
|
|
328
|
+
rows = page.get(key) or []
|
|
329
|
+
for row in rows:
|
|
330
|
+
yield row
|
|
331
|
+
seen += 1
|
|
332
|
+
if limit is not None and seen >= limit:
|
|
333
|
+
return
|
|
334
|
+
cursor = page.get("next")
|
|
335
|
+
if not cursor or not rows or cursor in visited:
|
|
336
|
+
return
|
|
337
|
+
visited.add(str(cursor))
|
|
338
|
+
|
|
339
|
+
def get_all(self, path: str, key: str, **params: Any) -> dict[str, Any]:
|
|
340
|
+
"""The whole of a paginated read as one document: the first page, with
|
|
341
|
+
`key` holding every page's rows and `next` null. A release gate that
|
|
342
|
+
read only page one would pass on what it never saw."""
|
|
343
|
+
first = self.get(path, **params)
|
|
344
|
+
rows = list(first.get(key) or [])
|
|
345
|
+
cursor = first.get("next")
|
|
346
|
+
visited: set[str] = set()
|
|
347
|
+
for _ in range(MAX_PAGES):
|
|
348
|
+
if not cursor or cursor in visited:
|
|
349
|
+
break
|
|
350
|
+
visited.add(str(cursor))
|
|
351
|
+
page = self.get(path, cursor=cursor, **params)
|
|
352
|
+
more = page.get(key) or []
|
|
353
|
+
rows.extend(more)
|
|
354
|
+
cursor = page.get("next") if more else None
|
|
355
|
+
return {**first, key: rows, "next": None}
|