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
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse connect <provider>`: open the provider's own approval.
|
|
4
|
+
|
|
5
|
+
lighthouse connect # what is connected, and what can be
|
|
6
|
+
lighthouse connect github
|
|
7
|
+
lighthouse connect aws --no-browser
|
|
8
|
+
lighthouse connect gitlab --reuse-local # offers `glab`'s sign-in; asks first
|
|
9
|
+
security find-generic-password -s gh-ci -w | \
|
|
10
|
+
lighthouse connect github --credential-stdin --access read_write \
|
|
11
|
+
--target organisation=example-air # an existing token, as you
|
|
12
|
+
... | lighthouse connect github --credential-stdin \
|
|
13
|
+
--deployment-key "$(terraform output -raw deployment_public_key)"
|
|
14
|
+
|
|
15
|
+
**What this command does and does not do.** It opens the portal's Connect page
|
|
16
|
+
for this tenant, which is where the provider's own approval is started:
|
|
17
|
+
GitHub's app installation, GitLab's OAuth approval, Datadog's integration
|
|
18
|
+
approval, the cloud roles' stack. It does not write
|
|
19
|
+
the connection itself, and that is deliberate rather than unfinished: the CLI
|
|
20
|
+
holds a *delegated* token, and a delegated token may never administer
|
|
21
|
+
(`docs/adr/0012-delegated-credentials.md`).
|
|
22
|
+
|
|
23
|
+
It opens `/connect` rather than `/connect/<provider>`, because the portal has
|
|
24
|
+
one Connect page and not one per connector. A deep link that 404s is worse
|
|
25
|
+
than a page with the right card on it, so the provider is named in this
|
|
26
|
+
command's own output instead.
|
|
27
|
+
Connecting a provider gives LightHouse standing access to a company's estate,
|
|
28
|
+
so it is approved by a person in the portal, under their own second factor,
|
|
29
|
+
and audited there — never by a token sitting in a shell history or a CI
|
|
30
|
+
variable.
|
|
31
|
+
|
|
32
|
+
So what the CLI saves a developer is the part that is actually tedious: finding
|
|
33
|
+
the right screen, with the right scopes, for the right tenant. One command
|
|
34
|
+
instead of four clicks.
|
|
35
|
+
|
|
36
|
+
**What is offered comes from the service.** The listing reads
|
|
37
|
+
`GET /api/v1/connectors`, the same catalogue the portal's Connect page shows:
|
|
38
|
+
each connector's `state` is `available` (connect it now), `engineer` (your
|
|
39
|
+
engineer configures it in your deployment; it is read today) or `coming`. The
|
|
40
|
+
CLI keeps no list of its own except as a fallback for a role that may not read
|
|
41
|
+
the catalogue, and says so when it uses it.
|
|
42
|
+
|
|
43
|
+
**Reusing a local sign-in.** Where `gh`, `glab` or a Datadog key is already set
|
|
44
|
+
up on this machine, `--reuse-local` offers to use it instead of a fresh
|
|
45
|
+
approval. It is offered **only on an explicit yes**: never on `--yes`, never
|
|
46
|
+
under `--no-input`, and never without naming exactly which local credential
|
|
47
|
+
would be read and where it would go. A tool that quietly harvested the
|
|
48
|
+
credentials it found on a developer's laptop would be indistinguishable from
|
|
49
|
+
one that should not be trusted. On that yes, and only then, the credential is
|
|
50
|
+
asked of the tool that owns it (`gh auth token`, `glab config get token`, or
|
|
51
|
+
`DD_API_KEY` and `DD_APP_KEY` from this shell, with `DD_SITE`), and handed
|
|
52
|
+
straight to the same path as `--credential-stdin` below: sealed on this
|
|
53
|
+
machine to your deployment where it can keep it, as you, never printed.
|
|
54
|
+
Refused, and said why, under `--yes`, under `--no-input`, when standard input
|
|
55
|
+
is not a terminal, on any answer but `yes`, and when the tool will not give
|
|
56
|
+
one.
|
|
57
|
+
|
|
58
|
+
**Feeding an existing token (`--credential-stdin`).** A token that already
|
|
59
|
+
exists (in a laptop's Keychain, in `gh auth token`, in a password manager) is
|
|
60
|
+
connected exactly as if the client's administrator had pasted it on the
|
|
61
|
+
Connect page: the same `POST /api/v1/connectors/<id>/token`, the same check at
|
|
62
|
+
the provider, the same sealing, the same audit entry naming them. Three rules
|
|
63
|
+
make that safe to script:
|
|
64
|
+
|
|
65
|
+
- **It is the person, not the CLI's token.** Connecting is administration, and
|
|
66
|
+
a delegated token administers nothing (ADR 0012), so this request carries
|
|
67
|
+
the person's own Cloudflare Access session for the tenant host instead,
|
|
68
|
+
from `cloudflared access token` (they sign in once with `cloudflared access
|
|
69
|
+
login https://<host>`, second factor included). The service sees a client
|
|
70
|
+
administrator at their own keyboard, which is what they are; the delegated
|
|
71
|
+
token is not sent at all. AAL staff and machine keys are refused by the
|
|
72
|
+
service as they are on the page.
|
|
73
|
+
- **The credential is read from standard input, never from an argument.** So
|
|
74
|
+
it is in no shell history, no process listing and no log. It is never
|
|
75
|
+
printed: the answer is the connection's card, which holds a fingerprint and
|
|
76
|
+
no credential.
|
|
77
|
+
- **Read is the default.** `--access read_write` is the administrator's
|
|
78
|
+
explicit choice, and the service refuses a token whose scopes disagree with
|
|
79
|
+
it (`credential_can_write`, `credential_cannot_write`), as the page does.
|
|
80
|
+
- **Finance, aviation, ERP and HR read only (contracts 1.27, 1.28).**
|
|
81
|
+
`--access read_write` for one of them is refused here, before standard input is read, with the
|
|
82
|
+
card's own reason (`read_only_connector`), and the service and the
|
|
83
|
+
deployment refuse it too.
|
|
84
|
+
|
|
85
|
+
**Sealed on this machine (contract 1.18).** Where the tenant's deployment
|
|
86
|
+
advertises a sealing key and both sides switched `secret.put` on
|
|
87
|
+
(`GET /api/v1/connectors/sealing`), the token is sealed here, to the
|
|
88
|
+
deployment's own key (`lighthouse/sealing.py`), and only the sealed blob, its
|
|
89
|
+
keyed fingerprint and the connection id it was sealed for are sent: the
|
|
90
|
+
control plane never sees the token, and the deployment writes it to the
|
|
91
|
+
client's own secrets store and checks it at the provider. With
|
|
92
|
+
`--deployment-key` (or `LIGHTHOUSE_DEPLOYMENT_KEY`), the deployment's public
|
|
93
|
+
key as the client's own infrastructure printed it, the advertisement must be
|
|
94
|
+
signed by exactly that key: a substituted sealing key is refused before
|
|
95
|
+
anything is sealed, and a deployment that cannot seal is refused rather than
|
|
96
|
+
sent the token in the clear. Without a pin, the advertisement is checked
|
|
97
|
+
against the keys the control plane says the deployment registered (the
|
|
98
|
+
Connect page's trust), and where nothing can seal, the token is sent as
|
|
99
|
+
before and sealed under the tenant's own key, which the answer says.
|
|
100
|
+
"""
|
|
101
|
+
from __future__ import annotations
|
|
102
|
+
|
|
103
|
+
import argparse
|
|
104
|
+
import base64
|
|
105
|
+
import getpass
|
|
106
|
+
import re
|
|
107
|
+
import shutil
|
|
108
|
+
import subprocess # noqa: S404 # nosec B404 - asks `gh`/`glab` if they are signed in
|
|
109
|
+
from dataclasses import dataclass
|
|
110
|
+
from typing import Any
|
|
111
|
+
|
|
112
|
+
from lighthouse import sealing
|
|
113
|
+
from lighthouse.device import open_browser
|
|
114
|
+
from lighthouse.exits import FAILED, USAGE, CliError
|
|
115
|
+
from lighthouse.main import Command, Context
|
|
116
|
+
from lighthouse.out import Out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Provider:
|
|
121
|
+
name: str
|
|
122
|
+
label: str
|
|
123
|
+
family: str # the service's family (1.26), or cloud for a deployment target
|
|
124
|
+
state: str # available | engineer | upgrade | coming | onboarding
|
|
125
|
+
scopes: str
|
|
126
|
+
local: str | None = None # the local tool whose sign-in could be reused
|
|
127
|
+
why: str = ""
|
|
128
|
+
kind: str = "" # what it holds (1.26): Mail, CRM, Service desk ...
|
|
129
|
+
read_only_why: str = "" # 1.27: why it connects read-only (finance, aviation), or ""
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def built(self) -> bool:
|
|
133
|
+
"""Whether LightHouse reads it today: connectable in the portal, or
|
|
134
|
+
configured by the client's engineer."""
|
|
135
|
+
return self.state in ("available", "engineer")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# The local tool whose sign-in `--reuse-local` could offer, by connector id.
|
|
139
|
+
LOCAL = {"github": "gh", "gitlab": "glab", "datadog": "datadog"}
|
|
140
|
+
FAMILY = {"github": "code", "gitlab": "code", "datadog": "observability"}
|
|
141
|
+
|
|
142
|
+
# What is known without asking the service: the three connectors LightHouse
|
|
143
|
+
# connects today, with the read-only scopes the control plane asks for, and the
|
|
144
|
+
# four places the engine can be deployed. **The service's catalogue
|
|
145
|
+
# (`GET /api/v1/connectors`) is the authority**: `connect` reads it and uses this
|
|
146
|
+
# table only when it cannot (a role that may not read it, say), and for the
|
|
147
|
+
# deployment targets, which are not connectors.
|
|
148
|
+
FALLBACK: tuple[Provider, ...] = (
|
|
149
|
+
Provider("aws", "Amazon Web Services", "cloud", "available",
|
|
150
|
+
"a scoped role created by the one-click stack; read-only outside the engine"),
|
|
151
|
+
Provider("azure", "Microsoft Azure", "cloud", "available",
|
|
152
|
+
"a scoped app registration created by the Bicep stack"),
|
|
153
|
+
Provider("gcp", "Google Cloud", "cloud", "available",
|
|
154
|
+
"a scoped service account created by the Terraform stack"),
|
|
155
|
+
Provider("docker", "Your own servers (Docker or Podman)", "cloud", "available",
|
|
156
|
+
"no cloud credential: Compose runs the engine in your own estate"),
|
|
157
|
+
Provider("github", "GitHub", "code", "available",
|
|
158
|
+
"contents:read, metadata:read, issues:read, pull_requests:read", local="gh"),
|
|
159
|
+
Provider("gitlab", "GitLab", "code", "available",
|
|
160
|
+
"read_api", local="glab"),
|
|
161
|
+
Provider("datadog", "Datadog", "observability", "available",
|
|
162
|
+
"monitors_read, incident_read, notebooks_read", local="datadog"),
|
|
163
|
+
# The engine reads these; a client's own engineer configures them in the
|
|
164
|
+
# deployment's catalogue, so they are not connectable in the portal yet.
|
|
165
|
+
Provider("gdrive", "Google Drive", "data", "engineer", "drive.readonly"),
|
|
166
|
+
Provider("confluence", "Confluence", "data", "engineer", "read:content:confluence"),
|
|
167
|
+
Provider("jira", "Jira", "data", "engineer", "read:jira-work"),
|
|
168
|
+
Provider("upload", "File upload", "data", "engineer", "none: you upload the files"),
|
|
169
|
+
# The engine does not read these yet.
|
|
170
|
+
Provider("sharepoint", "SharePoint", "data", "coming", "Sites.Selected (read)"),
|
|
171
|
+
Provider("onedrive", "OneDrive", "data", "coming", "Files.Read.All"),
|
|
172
|
+
Provider("s3", "Amazon S3", "data", "coming",
|
|
173
|
+
"s3:GetObject, s3:ListBucket on the named prefix"),
|
|
174
|
+
Provider("azure-blob", "Azure Blob Storage", "data", "coming", "Storage Blob Data Reader"),
|
|
175
|
+
Provider("salesforce", "Salesforce", "data", "coming",
|
|
176
|
+
"api, refresh_token (read-only profile)"),
|
|
177
|
+
Provider("servicenow", "ServiceNow", "data", "coming", "snc_read_only"),
|
|
178
|
+
Provider("slack", "Slack", "data", "coming", "channels:history, files:read"),
|
|
179
|
+
Provider("teams", "Microsoft Teams", "data", "coming", "ChannelMessage.Read.All"),
|
|
180
|
+
Provider("postgres", "PostgreSQL", "data", "coming", "a read-only role you create"),
|
|
181
|
+
Provider("snowflake", "Snowflake", "data", "coming", "a read-only role you create"),
|
|
182
|
+
)
|
|
183
|
+
BY_NAME = {p.name: p for p in FALLBACK}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def from_catalogue(card: dict[str, Any]) -> Provider:
|
|
187
|
+
"""A provider as the service describes it: its `state` is the service's own
|
|
188
|
+
(`available` to connect now, `engineer` where the client's engineer
|
|
189
|
+
configures it, `coming` where the engine does not read it yet)."""
|
|
190
|
+
name = str(card.get("connector"))
|
|
191
|
+
return Provider(name, str(card.get("name") or name),
|
|
192
|
+
str(card.get("family") or FAMILY.get(name, "data")),
|
|
193
|
+
str(card.get("state") or ("available" if card.get("available") else "coming")),
|
|
194
|
+
", ".join(card.get("scopes") or []) or str(card.get("brings") or ""),
|
|
195
|
+
local=LOCAL.get(name), why=str(card.get("why") or ""),
|
|
196
|
+
kind=str(card.get("kind") or ""),
|
|
197
|
+
read_only_why=str(card.get("read_only_why") or ""))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def connect_url(host: str) -> str:
|
|
201
|
+
"""Where a person approves a connection: the portal's one Connect page.
|
|
202
|
+
|
|
203
|
+
Not `/connect/<provider>`: the portal has a single page listing every
|
|
204
|
+
connector, so a per-provider path would be a 404 — and a link that 404s
|
|
205
|
+
teaches a developer to stop following this command's links.
|
|
206
|
+
"""
|
|
207
|
+
return f"https://{host}/connect"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
211
|
+
ap.add_argument("provider", nargs="?",
|
|
212
|
+
help="the provider to connect; `list` (or nothing) to list them")
|
|
213
|
+
ap.add_argument("--family", default=None,
|
|
214
|
+
help="with list: one family (google, microsoft, oracle, crm, ...)")
|
|
215
|
+
ap.add_argument("--kind", default=None,
|
|
216
|
+
help="with list: what it holds (crm, mail, service-desk, ...)")
|
|
217
|
+
ap.add_argument("--search", default=None,
|
|
218
|
+
help="with list: words to find in the name, family, kind or description "
|
|
219
|
+
"(\"email\" finds Mail, \"tickets\" finds Service desk)")
|
|
220
|
+
ap.add_argument("--state", default=None,
|
|
221
|
+
choices=("available", "engineer", "upgrade", "coming", "onboarding"),
|
|
222
|
+
help="with list: only connectors in this state")
|
|
223
|
+
ap.add_argument("--no-browser", action="store_true", dest="no_browser",
|
|
224
|
+
help="print the approval URL instead of opening it")
|
|
225
|
+
ap.add_argument("--reuse-local", action="store_true", dest="reuse_local",
|
|
226
|
+
help="offer to reuse a sign-in already on this machine (asks first)")
|
|
227
|
+
ap.add_argument("--all", action="store_true", dest="every",
|
|
228
|
+
help="with no provider: list the coming ones too")
|
|
229
|
+
ap.add_argument("--credential-stdin", action="store_true", dest="credential_stdin",
|
|
230
|
+
help="connect an existing token, read from standard input, as yourself "
|
|
231
|
+
"(needs `cloudflared access login https://<host>`)")
|
|
232
|
+
ap.add_argument("--access", choices=("read", "read_write"), default="read",
|
|
233
|
+
help="with --credential-stdin: read (the default) or read_write")
|
|
234
|
+
ap.add_argument("--target", action="append", default=[], metavar="KEY=VALUE",
|
|
235
|
+
help="with --credential-stdin: what the connection points at "
|
|
236
|
+
"(organisation=..., instance_url=..., site=...); repeatable")
|
|
237
|
+
ap.add_argument("--deployment", default=None,
|
|
238
|
+
help="with --credential-stdin: the deployment that keeps the credential, "
|
|
239
|
+
"where the tenant has more than one")
|
|
240
|
+
ap.add_argument("--deployment-key", default=None, dest="deployment_key", metavar="KEY",
|
|
241
|
+
help="with --credential-stdin: pin the deployment's public key (base64 or "
|
|
242
|
+
"PEM, as your infrastructure printed it); the token is sealed only to "
|
|
243
|
+
"a sealing key that key signed (or LIGHTHOUSE_DEPLOYMENT_KEY)")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# -- an existing token, as the person --------------------------------------------
|
|
247
|
+
|
|
248
|
+
_TARGET_KEY = re.compile(r"^[a-z_]{1,40}$")
|
|
249
|
+
_HOST = re.compile(r"^[a-z0-9][a-z0-9.-]{0,252}[a-z0-9]$")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def person_session(host: str) -> str:
|
|
253
|
+
"""The person's own Cloudflare Access session for `host`, from
|
|
254
|
+
`cloudflared access token`. Its output is the token and is never printed;
|
|
255
|
+
a failure says how to sign in rather than what cloudflared said."""
|
|
256
|
+
hint = f"sign in first: cloudflared access login https://{host}"
|
|
257
|
+
tool = shutil.which("cloudflared")
|
|
258
|
+
if not _HOST.match(host) or tool is None:
|
|
259
|
+
raise CliError("connecting a token needs your own portal session, from cloudflared",
|
|
260
|
+
code=USAGE, error="no_session", hint=hint)
|
|
261
|
+
try:
|
|
262
|
+
# A fixed argument vector, the tool's resolved path and a checked
|
|
263
|
+
# hostname: never a shell.
|
|
264
|
+
done = subprocess.run( # noqa: S603 # nosec B603
|
|
265
|
+
[tool, "access", "token", f"-app=https://{host}"],
|
|
266
|
+
capture_output=True, timeout=30, check=False, shell=False, text=True)
|
|
267
|
+
except (OSError, subprocess.SubprocessError):
|
|
268
|
+
raise CliError("cloudflared did not answer", code=USAGE, error="no_session",
|
|
269
|
+
hint=hint) from None
|
|
270
|
+
token = (done.stdout or "").strip()
|
|
271
|
+
if done.returncode != 0 or not token or any(c.isspace() for c in token):
|
|
272
|
+
raise CliError("there is no portal session for this host on this machine",
|
|
273
|
+
code=USAGE, error="no_session", hint=hint)
|
|
274
|
+
return token
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _targets(pairs: list[str]) -> dict[str, str]:
|
|
278
|
+
out: dict[str, str] = {}
|
|
279
|
+
for pair in pairs:
|
|
280
|
+
key, sep, value = pair.partition("=")
|
|
281
|
+
if not sep or not _TARGET_KEY.match(key) or not value.strip():
|
|
282
|
+
raise CliError(f"--target is KEY=VALUE, not {key!r}", code=USAGE,
|
|
283
|
+
error="bad_target")
|
|
284
|
+
out[key] = value.strip()
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _read_credential(ctx: Context) -> str:
|
|
289
|
+
"""The credential, from standard input. Piped, it is read whole (a Datadog
|
|
290
|
+
key pair and an Atlassian credential are two lines; a Google key is a
|
|
291
|
+
file); at a terminal, it is asked for without echo."""
|
|
292
|
+
import sys
|
|
293
|
+
stream = ctx.stdin or sys.stdin
|
|
294
|
+
if stream.isatty():
|
|
295
|
+
if getattr(ctx.args, "no_input", False):
|
|
296
|
+
raise CliError("pipe the credential in: --no-input is set", code=USAGE,
|
|
297
|
+
error="no_credential")
|
|
298
|
+
got = getpass.getpass("Paste the credential (not shown): ")
|
|
299
|
+
else:
|
|
300
|
+
got = stream.read()
|
|
301
|
+
got = got.rstrip("\r\n")
|
|
302
|
+
if len(got.strip()) < 8:
|
|
303
|
+
raise CliError("no credential on standard input", code=USAGE, error="no_credential",
|
|
304
|
+
hint="pipe it in: security find-generic-password -s <name> -w | "
|
|
305
|
+
"lighthouse connect <provider> --credential-stdin")
|
|
306
|
+
return got
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _pin(ctx: Context) -> bytes | None:
|
|
310
|
+
text = getattr(ctx.args, "deployment_key", None) or ctx.env.get("LIGHTHOUSE_DEPLOYMENT_KEY")
|
|
311
|
+
if not text:
|
|
312
|
+
return None
|
|
313
|
+
try:
|
|
314
|
+
return sealing.load_deployment_key(text)
|
|
315
|
+
except sealing.SealingError as e:
|
|
316
|
+
raise CliError(str(e), code=USAGE, error="bad_deployment_key") from None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _sealer(ctx: Context, client: Any, provider: Provider) -> dict[str, Any] | None:
|
|
320
|
+
"""Where this credential can be sealed to, or None to send it as before.
|
|
321
|
+
|
|
322
|
+
A pinned deployment key makes sealing a requirement: nothing is sent in
|
|
323
|
+
the clear, and an advertisement that key did not sign stops everything."""
|
|
324
|
+
pinned = _pin(ctx)
|
|
325
|
+
try:
|
|
326
|
+
info = client.get("/api/v1/connectors/sealing") or {}
|
|
327
|
+
except CliError as e:
|
|
328
|
+
# A control plane before 1.18 has no such route.
|
|
329
|
+
if pinned is None and e.extra.get("status") in (404, 405):
|
|
330
|
+
return None
|
|
331
|
+
raise
|
|
332
|
+
wanted = getattr(ctx.args, "deployment", None)
|
|
333
|
+
ready = [d for d in info.get("deployments") or [] if d.get("ready") and d.get("sealing")
|
|
334
|
+
and (wanted is None or d.get("deployment") == wanted)]
|
|
335
|
+
if provider.name not in (info.get("connectors") or []) or not ready:
|
|
336
|
+
if pinned is not None:
|
|
337
|
+
why = next((d.get("why") for d in info.get("deployments") or [] if d.get("why")), "")
|
|
338
|
+
raise CliError("your deployment cannot keep this credential in your own store, so "
|
|
339
|
+
"it was not sent", code=FAILED, error="sealing_unavailable",
|
|
340
|
+
hint=why or None)
|
|
341
|
+
return None
|
|
342
|
+
if len(ready) > 1:
|
|
343
|
+
raise CliError("more than one deployment can keep it: name one with --deployment",
|
|
344
|
+
code=USAGE, error="deployment_required",
|
|
345
|
+
hint=", ".join(sorted(str(d["deployment"]) for d in ready)))
|
|
346
|
+
dep = ready[0]
|
|
347
|
+
full = f"{info.get('tenant')}/{dep['deployment']}"
|
|
348
|
+
keys = [pinned] if pinned is not None else [
|
|
349
|
+
base64.b64decode(k) for k in dep.get("deployment_keys") or []]
|
|
350
|
+
if not sealing.verify_advert(dep["sealing"], full, keys):
|
|
351
|
+
raise CliError("the sealing key LightHouse relayed is not signed by "
|
|
352
|
+
+ ("the deployment key you pinned" if pinned else
|
|
353
|
+
"your deployment's registered key")
|
|
354
|
+
+ "; nothing was sent", code=FAILED, error="sealing_key_unverified")
|
|
355
|
+
return {"deployment": dep["deployment"], "full": full, "public": dep["sealing"]["public"],
|
|
356
|
+
"fingerprint_key": base64.b64decode(info.get("fingerprint_key") or ""),
|
|
357
|
+
"pinned": pinned is not None}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _sealed_body(where: dict[str, Any], provider: Provider, credential: str,
|
|
361
|
+
target: dict[str, str]) -> dict[str, Any]:
|
|
362
|
+
try:
|
|
363
|
+
fields = sealing.credential_fields(provider.name, credential, target)
|
|
364
|
+
except sealing.SealingError as e:
|
|
365
|
+
raise CliError(str(e), code=USAGE, error="bad_credential") from None
|
|
366
|
+
cid = sealing.connection_id()
|
|
367
|
+
body = {"sealed": sealing.seal(where["public"], sealing.canonical(fields),
|
|
368
|
+
deployment=where["full"], connector=provider.name, name=cid),
|
|
369
|
+
"fingerprint": sealing.fingerprint(fields, where["fingerprint_key"]),
|
|
370
|
+
"connection": cid, "deployment": where["deployment"], "target": target,
|
|
371
|
+
"sealed_by": "cli"}
|
|
372
|
+
fields.clear()
|
|
373
|
+
return body
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _connect_token(ctx: Context, provider: Provider, credential: str | None = None,
|
|
377
|
+
target: dict[str, str] | None = None) -> dict[str, Any]:
|
|
378
|
+
"""`POST /api/v1/connectors/<id>/token` as the person: the credential
|
|
379
|
+
sealed here to the deployment where it can keep it (1.18), otherwise in
|
|
380
|
+
the request body and nowhere else. Dropped once sent either way. Given
|
|
381
|
+
`credential` (a reused local sign-in), standard input is not read."""
|
|
382
|
+
if provider.state != "available":
|
|
383
|
+
raise CliError(f"{provider.label} cannot be connected with a token here "
|
|
384
|
+
f"({provider.state})", code=USAGE, error="not_connectable")
|
|
385
|
+
target = {**_targets(getattr(ctx.args, "target", []) or []), **(target or {})}
|
|
386
|
+
access = getattr(ctx.args, "access", "read") or "read"
|
|
387
|
+
if access != "read" and (provider.name in sealing.WAVE_046
|
|
388
|
+
or provider.name in sealing.WAVE_047):
|
|
389
|
+
# 1.27: finance and aviation read only; refused here, before anything
|
|
390
|
+
# is read from standard input or sealed.
|
|
391
|
+
raise CliError(provider.read_only_why or f"{provider.label} connects read-only; "
|
|
392
|
+
"connect it with --access read", code=USAGE, error="read_only_connector")
|
|
393
|
+
credential = credential if credential is not None else _read_credential(ctx)
|
|
394
|
+
host = ctx.host
|
|
395
|
+
client = ctx.new_client(host, None, session=person_session(host))
|
|
396
|
+
path = f"/api/v1/connectors/{provider.name}/token"
|
|
397
|
+
where = None
|
|
398
|
+
try:
|
|
399
|
+
where = _sealer(ctx, client, provider)
|
|
400
|
+
if where is None and (provider.name in sealing.ENTERPRISE
|
|
401
|
+
or provider.name in sealing.WAVE_046
|
|
402
|
+
or provider.name in sealing.WAVE_047):
|
|
403
|
+
# Contracts 1.26 to 1.28: these are sealed here or not sent at all.
|
|
404
|
+
engine = ("0.47.1" if provider.name in sealing.WAVE_047
|
|
405
|
+
else "0.46.0" if provider.name in sealing.WAVE_046 else "0.45.0")
|
|
406
|
+
raise CliError(f"{provider.label}'s credential is only ever sealed to your "
|
|
407
|
+
f"deployment, which cannot keep it yet (engine {engine} with its own "
|
|
408
|
+
"store); nothing was sent", code=FAILED, error="sealing_required")
|
|
409
|
+
if where is None:
|
|
410
|
+
body: dict[str, Any] = {"credential": credential, "target": target}
|
|
411
|
+
else:
|
|
412
|
+
body = _sealed_body(where, provider, credential, target)
|
|
413
|
+
if access != "read":
|
|
414
|
+
body["access"] = access
|
|
415
|
+
try:
|
|
416
|
+
got = client.post(path, body) or {}
|
|
417
|
+
except CliError as e:
|
|
418
|
+
# The deployment rotated its sealing key since it was read: seal
|
|
419
|
+
# again to the new one, once.
|
|
420
|
+
if where is None or e.error not in ("sealing_key_changed", "conflict"):
|
|
421
|
+
raise
|
|
422
|
+
where = _sealer(ctx, client, provider)
|
|
423
|
+
if where is None:
|
|
424
|
+
raise
|
|
425
|
+
body = {**_sealed_body(where, provider, credential, target),
|
|
426
|
+
**({"access": access} if access != "read" else {})}
|
|
427
|
+
got = client.post(path, body) or {}
|
|
428
|
+
finally:
|
|
429
|
+
credential = ""
|
|
430
|
+
client.close()
|
|
431
|
+
pending = bool(got.get("pending"))
|
|
432
|
+
return {"ok": True, "provider": provider.name, "label": provider.label,
|
|
433
|
+
"connected": not pending, "pending": pending, "connection": got.get("id"),
|
|
434
|
+
"access": got.get("access"),
|
|
435
|
+
"can_write": got.get("can_write"), "write_scopes": got.get("write_scopes") or [],
|
|
436
|
+
"access_note": got.get("access_note"), "state": got.get("state"),
|
|
437
|
+
"fingerprint": got.get("fingerprint"), "replaced": got.get("replaced") or [],
|
|
438
|
+
"card": got.get("label"),
|
|
439
|
+
"sealed_by": got.get("sealed_by") or ("cli" if where else None),
|
|
440
|
+
"pinned": bool(where and where["pinned"]),
|
|
441
|
+
"deployment": got.get("deployment"), "custody": got.get("custody"),
|
|
442
|
+
"reason": got.get("reason") if pending else None,
|
|
443
|
+
"command": (got.get("command") or {}).get("command_id") if pending else None}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# -- detecting a local sign-in ---------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def _ran_ok(argv: list[str]) -> bool:
|
|
449
|
+
"""Whether a local tool reports itself signed in. Its output is never read
|
|
450
|
+
and never printed: a token could be in it."""
|
|
451
|
+
if shutil.which(argv[0]) is None:
|
|
452
|
+
return False
|
|
453
|
+
try:
|
|
454
|
+
# A fixed argument vector from the table above: never a shell, and never
|
|
455
|
+
# anything a person typed.
|
|
456
|
+
done = subprocess.run(argv, capture_output=True, timeout=10, # noqa: S603 # nosec B603
|
|
457
|
+
check=False, shell=False)
|
|
458
|
+
except (OSError, subprocess.SubprocessError):
|
|
459
|
+
return False
|
|
460
|
+
return done.returncode == 0
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def local_signin(provider: Provider, env: dict[str, str]) -> dict[str, Any] | None:
|
|
464
|
+
"""What is already signed in on this machine for `provider`, or None.
|
|
465
|
+
|
|
466
|
+
Only the *fact* of a sign-in is established — never the credential. The
|
|
467
|
+
credential is read only after a person says yes, and then by the tool that
|
|
468
|
+
owns it, not by reading its files.
|
|
469
|
+
"""
|
|
470
|
+
if provider.local == "gh" and _ran_ok(["gh", "auth", "status"]):
|
|
471
|
+
return {"tool": "gh", "how": "`gh auth token` would be asked for the token"}
|
|
472
|
+
if provider.local == "glab" and _ran_ok(["glab", "auth", "status"]):
|
|
473
|
+
return {"tool": "glab", "how": "`glab config get token` would be asked for the token"}
|
|
474
|
+
if provider.local == "datadog" and env.get("DD_API_KEY") and env.get("DD_APP_KEY"):
|
|
475
|
+
return {"tool": "environment",
|
|
476
|
+
"how": "DD_API_KEY and DD_APP_KEY are set in this shell"}
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def catalogue(ctx: Context) -> tuple[dict[str, Provider], list[dict[str, Any]], str]:
|
|
481
|
+
"""The connectors and the connections, from `GET /api/v1/connectors`.
|
|
482
|
+
|
|
483
|
+
Returns `(providers by name, connections, source)`. Where the service will
|
|
484
|
+
not say (exit 3 or 4: a role that may not read it), the built-in table
|
|
485
|
+
stands in, `source` says `built-in`, and the command warns, so a label is
|
|
486
|
+
never presented as the service's when it is the CLI's.
|
|
487
|
+
"""
|
|
488
|
+
query = _filters(ctx)
|
|
489
|
+
try:
|
|
490
|
+
body = ctx.client().get("/api/v1/connectors", **query) or {}
|
|
491
|
+
except CliError as e:
|
|
492
|
+
if e.code not in (3, 4):
|
|
493
|
+
raise
|
|
494
|
+
ctx.out.warn(f"could not read the connector catalogue ({e.error}); "
|
|
495
|
+
f"showing what this CLI knows")
|
|
496
|
+
return dict(BY_NAME), [], "built-in"
|
|
497
|
+
found = {str(c.get("connector")): from_catalogue(c) for c in body.get("connectors") or []}
|
|
498
|
+
# Deployment targets are not connectors, so the catalogue does not list
|
|
499
|
+
# them; a filtered listing is the catalogue's alone.
|
|
500
|
+
for p in FALLBACK:
|
|
501
|
+
if p.family == "cloud" and not query:
|
|
502
|
+
found.setdefault(p.name, p)
|
|
503
|
+
live = [c for c in body.get("connections") or [] if c.get("state") != "disconnected"]
|
|
504
|
+
return found, live, "service"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _filters(ctx: Context) -> dict[str, str]:
|
|
508
|
+
"""`--family`, `--kind`, `--search` and `--state`, as the service's query
|
|
509
|
+
parameters (contract 1.26). The service matches them: synonyms, case and
|
|
510
|
+
partial words, the same rules as the portal and the docs."""
|
|
511
|
+
got = {"family": getattr(ctx.args, "family", None), "kind": getattr(ctx.args, "kind", None),
|
|
512
|
+
"q": getattr(ctx.args, "search", None), "state": getattr(ctx.args, "state", None)}
|
|
513
|
+
return {k: str(v) for k, v in got.items() if v}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _local_match(p: Provider, query: dict[str, str]) -> bool:
|
|
517
|
+
"""The built-in table's stand-in for the service's filters: exact family,
|
|
518
|
+
kind and state, and every search word somewhere in the label or family."""
|
|
519
|
+
def norm(v: str) -> str:
|
|
520
|
+
return re.sub(r"[\s_\-]+", " ", v.strip().lower())
|
|
521
|
+
hay = norm(f"{p.name} {p.label} {p.family} {p.kind} {p.scopes}")
|
|
522
|
+
return ((not query.get("family") or norm(p.family) == norm(query["family"]))
|
|
523
|
+
and (not query.get("kind") or norm(p.kind) == norm(query["kind"]))
|
|
524
|
+
and (not query.get("state") or p.state == query["state"])
|
|
525
|
+
and all(w in hay for w in norm(query.get("q", "")).split()))
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _listing(ctx: Context) -> dict[str, Any]:
|
|
529
|
+
known, connections, source = catalogue(ctx)
|
|
530
|
+
live = {str(c.get("connector")) for c in connections}
|
|
531
|
+
query = _filters(ctx)
|
|
532
|
+
every = getattr(ctx.args, "every", False) or bool(query)
|
|
533
|
+
rows = [p for p in known.values() if (p.built or every)
|
|
534
|
+
and (source == "service" or _local_match(p, query))]
|
|
535
|
+
return {"ok": True, "source": source, "filters": query, "providers": [
|
|
536
|
+
{"provider": p.name, "label": p.label, "family": p.family, "kind": p.kind,
|
|
537
|
+
"status": "connected" if p.name in live else p.state,
|
|
538
|
+
"scopes": p.scopes,
|
|
539
|
+
"connect": connect_url(ctx.host)} for p in rows],
|
|
540
|
+
"connected": len(live)}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _show_listing(out: Out, body: dict[str, Any]) -> None:
|
|
544
|
+
out.table(["provider", "what", "family", "kind", "status", "read-only scopes"],
|
|
545
|
+
[[p["provider"], p["label"], p["family"], p.get("kind", ""), p["status"],
|
|
546
|
+
p["scopes"]] for p in body["providers"]])
|
|
547
|
+
if not body["providers"]:
|
|
548
|
+
out.note("no connector matches; ask for one from Help, or tell your Atlantic contact")
|
|
549
|
+
out.note("`lighthouse connect <provider>` opens the approval; `--all` lists the coming "
|
|
550
|
+
"ones too; `connect list --family F --kind K --search WORDS` narrows the list")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def connect(ctx: Context) -> dict[str, Any]:
|
|
554
|
+
name = getattr(ctx.args, "provider", None)
|
|
555
|
+
if not name or name == "list":
|
|
556
|
+
return _listing(ctx)
|
|
557
|
+
known, _connections, _source = catalogue(ctx)
|
|
558
|
+
provider = known.get(name.lower())
|
|
559
|
+
if provider is None:
|
|
560
|
+
raise CliError(f"{name!r} is not a provider LightHouse knows", code=USAGE,
|
|
561
|
+
error="unknown_provider",
|
|
562
|
+
hint="run `lighthouse connect --all` to see every one")
|
|
563
|
+
if getattr(ctx.args, "credential_stdin", False):
|
|
564
|
+
return _connect_token(ctx, provider)
|
|
565
|
+
url = connect_url(ctx.host)
|
|
566
|
+
out: dict[str, Any] = {"ok": True, "provider": provider.name, "label": provider.label,
|
|
567
|
+
"status": provider.state,
|
|
568
|
+
"approval_url": url, "scopes": provider.scopes,
|
|
569
|
+
"opened": False, "reuse": None}
|
|
570
|
+
if provider.state == "coming":
|
|
571
|
+
out["message"] = (f"connecting {provider.label} is coming; the page lists it and says so")
|
|
572
|
+
elif provider.state == "engineer":
|
|
573
|
+
out["message"] = provider.why or (f"{provider.label} is configured by your engineer in "
|
|
574
|
+
f"your deployment, not connected from the portal")
|
|
575
|
+
if getattr(ctx.args, "reuse_local", False):
|
|
576
|
+
found = local_signin(provider, ctx.env)
|
|
577
|
+
if found is None:
|
|
578
|
+
out["reuse"] = {"offered": False,
|
|
579
|
+
"reason": f"no local sign-in for {provider.label} on this machine"}
|
|
580
|
+
else:
|
|
581
|
+
out["reuse"] = _offer_reuse(ctx, provider, found)
|
|
582
|
+
if out["reuse"].get("sent"):
|
|
583
|
+
return {**out["reuse"]["connection"], "reuse": {
|
|
584
|
+
k: v for k, v in out["reuse"].items() if k != "connection"}}
|
|
585
|
+
if not ctx.args.no_browser:
|
|
586
|
+
out["opened"] = open_browser(url, opener=ctx.opener)
|
|
587
|
+
return out
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
DD_SITES = {v: k for k, v in sealing.DATADOG_ENGINE_SITES.items()}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _tool_output(argv: list[str]) -> str | None:
|
|
594
|
+
"""The first line a local tool prints, or None. Never printed or logged:
|
|
595
|
+
it is a credential."""
|
|
596
|
+
tool = shutil.which(argv[0])
|
|
597
|
+
if tool is None:
|
|
598
|
+
return None
|
|
599
|
+
try:
|
|
600
|
+
# A fixed argument vector from this module, the tool's resolved path:
|
|
601
|
+
# never a shell, never anything typed.
|
|
602
|
+
done = subprocess.run([tool, *argv[1:]], capture_output=True, # noqa: S603 # nosec B603
|
|
603
|
+
timeout=15, check=False, shell=False, text=True)
|
|
604
|
+
except (OSError, subprocess.SubprocessError):
|
|
605
|
+
return None
|
|
606
|
+
got = (done.stdout or "").strip().splitlines()
|
|
607
|
+
if done.returncode != 0 or not got or any(c.isspace() for c in got[0].strip()):
|
|
608
|
+
return None
|
|
609
|
+
return got[0].strip()
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def read_local(provider: Provider, env: dict[str, str]) -> tuple[str, dict[str, str]] | None:
|
|
613
|
+
"""The credential, from the tool that owns it, and what it points at.
|
|
614
|
+
Called only after a person typed yes."""
|
|
615
|
+
if provider.local == "gh":
|
|
616
|
+
token = _tool_output(["gh", "auth", "token"])
|
|
617
|
+
return (token, {}) if token else None
|
|
618
|
+
if provider.local == "glab":
|
|
619
|
+
host = (env.get("GITLAB_HOST") or "gitlab.com").strip()
|
|
620
|
+
if not re.match(r"^[a-z0-9][a-z0-9.-]{0,252}$", host.lower()):
|
|
621
|
+
return None
|
|
622
|
+
token = _tool_output(["glab", "config", "get", "token", "--host", host])
|
|
623
|
+
target = {} if host == "gitlab.com" else {"instance_url": f"https://{host}"}
|
|
624
|
+
return (token, target) if token else None
|
|
625
|
+
if provider.local == "datadog":
|
|
626
|
+
api, app = env.get("DD_API_KEY", "").strip(), env.get("DD_APP_KEY", "").strip()
|
|
627
|
+
if not api or not app:
|
|
628
|
+
return None
|
|
629
|
+
site = DD_SITES.get(env.get("DD_SITE", "").strip().lower(), "")
|
|
630
|
+
return f"{api}\n{app}", ({"site": site} if site else {})
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _offer_reuse(ctx: Context, provider: Provider, found: dict[str, Any]) -> dict[str, Any]:
|
|
635
|
+
"""Ask, in as many words, before touching a credential this machine holds.
|
|
636
|
+
|
|
637
|
+
`--yes` does not answer this one, and neither does a pipe. A blanket yes on
|
|
638
|
+
the command line is consent to the command's own work, not consent to hand
|
|
639
|
+
a developer's personal GitHub token to a service, and the two must not be
|
|
640
|
+
the same keystroke; a `yes` piped into standard input is not a person.
|
|
641
|
+
"""
|
|
642
|
+
import sys
|
|
643
|
+
question = (f"Reuse the {found['tool']} sign-in on this machine for {provider.label}? "
|
|
644
|
+
f"{found['how']}. It would be sealed on this machine and sent to {ctx.host} "
|
|
645
|
+
f"as you, to be kept in your own deployment where it can, or under your "
|
|
646
|
+
f"tenant's own key")
|
|
647
|
+
refusal = ("no_input" if getattr(ctx.args, "no_input", False) else
|
|
648
|
+
"yes_flag" if getattr(ctx.args, "yes", False) else
|
|
649
|
+
"not_interactive" if not (ctx.stdin or sys.stdin).isatty() else
|
|
650
|
+
"not_connectable" if provider.state != "available" else None)
|
|
651
|
+
if refusal is not None:
|
|
652
|
+
why = {"no_input": "--no-input is set", "yes_flag": "--yes is not consent to hand "
|
|
653
|
+
"over a credential", "not_interactive": "standard input is not a terminal",
|
|
654
|
+
"not_connectable": f"{provider.label} is not connected with a token"}[refusal]
|
|
655
|
+
ctx.out.note(f"{question}: not asked, because {why}")
|
|
656
|
+
return {"offered": True, "accepted": False, "reason": refusal}
|
|
657
|
+
ctx.out.say(question + ".")
|
|
658
|
+
answer = (ctx.reader or input)("Type yes to use it, anything else to approve in the "
|
|
659
|
+
"browser instead: ").strip().lower()
|
|
660
|
+
if answer != "yes":
|
|
661
|
+
return {"offered": True, "accepted": False, "reason": "declined"}
|
|
662
|
+
got = read_local(provider, ctx.env)
|
|
663
|
+
if got is None:
|
|
664
|
+
return {"offered": True, "accepted": True, "sent": False, "tool": found["tool"],
|
|
665
|
+
"reason": "local_read_failed",
|
|
666
|
+
"message": f"{found['tool']} did not give a credential; nothing was sent"}
|
|
667
|
+
credential, target = got
|
|
668
|
+
try:
|
|
669
|
+
connected = _connect_token(ctx, provider, credential=credential, target=target)
|
|
670
|
+
finally:
|
|
671
|
+
credential = ""
|
|
672
|
+
return {"offered": True, "accepted": True, "sent": True, "tool": found["tool"],
|
|
673
|
+
"connection": connected}
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
677
|
+
if "providers" in body:
|
|
678
|
+
_show_listing(out, body)
|
|
679
|
+
return
|
|
680
|
+
if body.get("pending"):
|
|
681
|
+
out.pairs([("sent to your own store", body.get("card") or body["label"]),
|
|
682
|
+
("deployment", body.get("deployment") or ""),
|
|
683
|
+
("access", "can write" if body.get("can_write") else "read only"),
|
|
684
|
+
("connection", body.get("connection") or "")])
|
|
685
|
+
out.note(body.get("reason") or "waiting for your deployment to keep it")
|
|
686
|
+
if body.get("custody"):
|
|
687
|
+
out.note(body["custody"])
|
|
688
|
+
if body.get("sealed_by") == "cli" and not body.get("pinned"):
|
|
689
|
+
out.note("pin --deployment-key to check the sealing key against your own "
|
|
690
|
+
"infrastructure's record of the deployment")
|
|
691
|
+
return
|
|
692
|
+
if body.get("connected"):
|
|
693
|
+
out.pairs([("connected", body.get("card") or body["label"]),
|
|
694
|
+
("access", "can write" if body.get("can_write") else "read only"),
|
|
695
|
+
("state", body.get("state") or ""),
|
|
696
|
+
("connection", body.get("connection") or "")])
|
|
697
|
+
if body.get("access_note"):
|
|
698
|
+
out.note(body["access_note"])
|
|
699
|
+
if body.get("custody"):
|
|
700
|
+
out.note(body["custody"])
|
|
701
|
+
return
|
|
702
|
+
out.pairs([("provider", body["label"]), ("status", body["status"]),
|
|
703
|
+
("read-only scopes", body["scopes"]), ("approve at", body["approval_url"])])
|
|
704
|
+
out.note(f"the Connect page lists every connector; use the {body['label']} card")
|
|
705
|
+
if body.get("message"):
|
|
706
|
+
out.warn(body["message"])
|
|
707
|
+
reuse = body.get("reuse")
|
|
708
|
+
if reuse and reuse.get("message"):
|
|
709
|
+
out.warn(reuse["message"])
|
|
710
|
+
elif reuse and reuse.get("offered") and not reuse.get("accepted"):
|
|
711
|
+
out.note("not reusing a local sign-in")
|
|
712
|
+
if body["opened"]:
|
|
713
|
+
out.note("opened in your browser")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
COMMANDS = (Command("connect", "open a provider's own approval for this tenant",
|
|
717
|
+
_configure, connect, _show),)
|