devlift-cli 0.1.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.
- devlift_cli/MANUAL.md +1066 -0
- devlift_cli/__init__.py +3 -0
- devlift_cli/__main__.py +4 -0
- devlift_cli/api/__init__.py +0 -0
- devlift_cli/api/approvals.py +53 -0
- devlift_cli/api/catalog.py +96 -0
- devlift_cli/api/client.py +125 -0
- devlift_cli/api/context.py +21 -0
- devlift_cli/api/deployments.py +37 -0
- devlift_cli/api/infra.py +94 -0
- devlift_cli/api/infra_list.py +61 -0
- devlift_cli/api/kong.py +29 -0
- devlift_cli/api/services.py +106 -0
- devlift_cli/api/vpc.py +24 -0
- devlift_cli/app.py +163 -0
- devlift_cli/auth/__init__.py +0 -0
- devlift_cli/auth/oauth.py +270 -0
- devlift_cli/auth/session.py +64 -0
- devlift_cli/auth/storage.py +135 -0
- devlift_cli/commands/__init__.py +0 -0
- devlift_cli/commands/approval.py +51 -0
- devlift_cli/commands/auth.py +180 -0
- devlift_cli/commands/catalog.py +187 -0
- devlift_cli/commands/clusters.py +108 -0
- devlift_cli/commands/deployment.py +77 -0
- devlift_cli/commands/dynamodb.py +121 -0
- devlift_cli/commands/eks.py +326 -0
- devlift_cli/commands/kong.py +145 -0
- devlift_cli/commands/languages.py +40 -0
- devlift_cli/commands/manual.py +82 -0
- devlift_cli/commands/repositories.py +49 -0
- devlift_cli/commands/request.py +89 -0
- devlift_cli/commands/s3.py +198 -0
- devlift_cli/commands/sqs.py +229 -0
- devlift_cli/config.py +94 -0
- devlift_cli/context.py +97 -0
- devlift_cli/data/placement/vance.json +16 -0
- devlift_cli/errors.py +52 -0
- devlift_cli/ops/__init__.py +0 -0
- devlift_cli/ops/approvals.py +343 -0
- devlift_cli/ops/eks.py +877 -0
- devlift_cli/ops/kong.py +343 -0
- devlift_cli/ops/placement.py +128 -0
- devlift_cli/ops/resources.py +418 -0
- devlift_cli/ops/status.py +152 -0
- devlift_cli/ops/wait.py +82 -0
- devlift_cli/render/__init__.py +0 -0
- devlift_cli/render/output.py +75 -0
- devlift_cli/resolve/__init__.py +0 -0
- devlift_cli/resolve/allowlist.py +192 -0
- devlift_cli/resolve/names.py +179 -0
- devlift_cli-0.1.0.dist-info/METADATA +106 -0
- devlift_cli-0.1.0.dist-info/RECORD +56 -0
- devlift_cli-0.1.0.dist-info/WHEEL +5 -0
- devlift_cli-0.1.0.dist-info/entry_points.txt +3 -0
- devlift_cli-0.1.0.dist-info/top_level.txt +1 -0
devlift_cli/app.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Command tree root and process entry point.
|
|
2
|
+
|
|
3
|
+
devlift [--profile P] [-o table|json|yaml] [--yes] [--no-input]
|
|
4
|
+
[--endpoint-url URL] [--debug] <group> <operation> [flags]
|
|
5
|
+
|
|
6
|
+
Every command receives an Invocation (profile, output format, confirmation
|
|
7
|
+
policy, API client) through ctx.obj, and fails by raising CliError, which
|
|
8
|
+
main() prints once and turns into the documented exit code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.markup import escape
|
|
17
|
+
|
|
18
|
+
from devlift_cli import __version__
|
|
19
|
+
from devlift_cli.commands import approval as approval_cmds
|
|
20
|
+
from devlift_cli.commands import auth as auth_cmds
|
|
21
|
+
from devlift_cli.commands import catalog as catalog_cmds
|
|
22
|
+
from devlift_cli.commands import clusters as clusters_cmds
|
|
23
|
+
from devlift_cli.commands import deployment as deployment_cmds
|
|
24
|
+
from devlift_cli.commands import dynamodb as dynamodb_cmds
|
|
25
|
+
from devlift_cli.commands import eks as eks_cmds
|
|
26
|
+
from devlift_cli.commands import kong as kong_cmds
|
|
27
|
+
from devlift_cli.commands import languages as languages_cmds
|
|
28
|
+
from devlift_cli.commands import manual as manual_cmds
|
|
29
|
+
from devlift_cli.commands import repositories as repositories_cmds
|
|
30
|
+
from devlift_cli.commands import request as request_cmds
|
|
31
|
+
from devlift_cli.commands import s3 as s3_cmds
|
|
32
|
+
from devlift_cli.commands import sqs as sqs_cmds
|
|
33
|
+
from devlift_cli.config import load_profile
|
|
34
|
+
from devlift_cli.context import Invocation
|
|
35
|
+
from devlift_cli.errors import CliError
|
|
36
|
+
from devlift_cli.render import output
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(
|
|
39
|
+
name="devlift",
|
|
40
|
+
help="DevLift from the terminal: create, change, review and deploy your services and resources.",
|
|
41
|
+
epilog="Full manual: devlift man (or devlift manual \\[topic]). Start with: devlift configure, devlift login, devlift whoami.",
|
|
42
|
+
no_args_is_help=True,
|
|
43
|
+
pretty_exceptions_enable=False,
|
|
44
|
+
rich_markup_mode="rich",
|
|
45
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _version(value: bool):
|
|
50
|
+
if value:
|
|
51
|
+
print(f"devlift {__version__}")
|
|
52
|
+
raise typer.Exit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.callback()
|
|
56
|
+
def root(
|
|
57
|
+
ctx: typer.Context,
|
|
58
|
+
profile: str | None = typer.Option(None, "--profile", envvar="DEVLIFT_PROFILE", help="Profile from `devlift configure`."),
|
|
59
|
+
out: str | None = typer.Option(None, "--output", "-o", help="table | json | yaml (json when piped)."),
|
|
60
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Answer yes to confirmations."),
|
|
61
|
+
no_input: bool = typer.Option(False, "--no-input", help="Never prompt; fail when input is missing."),
|
|
62
|
+
endpoint_url: str | None = typer.Option(None, "--endpoint-url", envvar="DEVLIFT_BASE_URL", help="Override the backend URL for this call."),
|
|
63
|
+
debug: bool = typer.Option(False, "--debug", help="Log every HTTP call to stderr."),
|
|
64
|
+
version: bool = typer.Option(False, "--version", "-V", callback=_version, is_eager=True, help="Print the version and exit."),
|
|
65
|
+
):
|
|
66
|
+
if out and out not in output.FORMATS:
|
|
67
|
+
raise typer.BadParameter(f"--output must be one of {', '.join(output.FORMATS)}")
|
|
68
|
+
prof = load_profile(profile, base_url_override=endpoint_url)
|
|
69
|
+
ctx.obj = Invocation(
|
|
70
|
+
profile=prof,
|
|
71
|
+
output=output.resolve_format(out, prof.output),
|
|
72
|
+
yes=yes,
|
|
73
|
+
no_input=no_input,
|
|
74
|
+
debug=debug,
|
|
75
|
+
)
|
|
76
|
+
ctx.call_on_close(ctx.obj.close)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── auth ─────────────────────────────────────────────────────────────────
|
|
80
|
+
app.command("login")(auth_cmds.login)
|
|
81
|
+
app.command("logout")(auth_cmds.logout)
|
|
82
|
+
app.command("whoami")(auth_cmds.whoami)
|
|
83
|
+
app.command("configure")(auth_cmds.configure)
|
|
84
|
+
app.add_typer(auth_cmds.profile_app, name="profile")
|
|
85
|
+
app.command("manual")(manual_cmds.manual)
|
|
86
|
+
# `devlift man` — the name every terminal user reaches for first. Hidden from
|
|
87
|
+
# the command list so the manual appears once there, under its full name.
|
|
88
|
+
app.command("man", hidden=True)(manual_cmds.manual)
|
|
89
|
+
|
|
90
|
+
# ── catalog ──────────────────────────────────────────────────────────────
|
|
91
|
+
app.add_typer(catalog_cmds.applications_app, name="applications")
|
|
92
|
+
app.add_typer(catalog_cmds.environments_app, name="environments")
|
|
93
|
+
app.add_typer(catalog_cmds.regions_app, name="regions")
|
|
94
|
+
app.add_typer(catalog_cmds.resource_types_app, name="resource-types")
|
|
95
|
+
app.add_typer(catalog_cmds.resource_groups_app, name="resource-groups")
|
|
96
|
+
app.add_typer(catalog_cmds.services_app, name="services")
|
|
97
|
+
app.add_typer(repositories_cmds.repositories_app, name="repositories")
|
|
98
|
+
app.add_typer(languages_cmds.languages_app, name="languages")
|
|
99
|
+
app.add_typer(clusters_cmds.clusters_app, name="clusters")
|
|
100
|
+
|
|
101
|
+
# ── resources ────────────────────────────────────────────────────────────
|
|
102
|
+
app.add_typer(s3_cmds.s3_app, name="s3")
|
|
103
|
+
app.add_typer(sqs_cmds.sqs_app, name="sqs")
|
|
104
|
+
app.add_typer(dynamodb_cmds.dynamodb_app, name="dynamodb")
|
|
105
|
+
app.add_typer(eks_cmds.eks_app, name="eks")
|
|
106
|
+
app.add_typer(kong_cmds.kong_app, name="kong")
|
|
107
|
+
app.add_typer(request_cmds.request_app, name="request")
|
|
108
|
+
app.add_typer(approval_cmds.approval_app, name="approval")
|
|
109
|
+
app.add_typer(deployment_cmds.deployment_app, name="deployment")
|
|
110
|
+
app.add_typer(deployment_cmds.queue_app, name="queue")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_GLOBAL_WITH_VALUE = {"--profile", "--output", "-o", "--endpoint-url"}
|
|
114
|
+
_GLOBAL_FLAGS = {"--yes", "-y", "--no-input", "--debug"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _hoist_global_options(argv: list[str]) -> list[str]:
|
|
118
|
+
"""Let global options sit anywhere, like `aws s3 ls -o json`.
|
|
119
|
+
|
|
120
|
+
Click only reads group options before the subcommand name, so anything
|
|
121
|
+
from the global set found later in argv is moved to the front. Stops at
|
|
122
|
+
`--` and leaves everything else in order.
|
|
123
|
+
"""
|
|
124
|
+
front: list[str] = []
|
|
125
|
+
rest: list[str] = []
|
|
126
|
+
i = 0
|
|
127
|
+
while i < len(argv):
|
|
128
|
+
arg = argv[i]
|
|
129
|
+
if arg == "--":
|
|
130
|
+
rest.extend(argv[i:])
|
|
131
|
+
break
|
|
132
|
+
name, eq, _ = arg.partition("=")
|
|
133
|
+
if name in _GLOBAL_WITH_VALUE:
|
|
134
|
+
if eq:
|
|
135
|
+
front.append(arg)
|
|
136
|
+
elif i + 1 < len(argv):
|
|
137
|
+
front.extend(argv[i : i + 2])
|
|
138
|
+
i += 1
|
|
139
|
+
else:
|
|
140
|
+
rest.append(arg)
|
|
141
|
+
elif name in _GLOBAL_FLAGS and not eq:
|
|
142
|
+
front.append(arg)
|
|
143
|
+
else:
|
|
144
|
+
rest.append(arg)
|
|
145
|
+
i += 1
|
|
146
|
+
return front + rest
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main() -> None:
|
|
150
|
+
try:
|
|
151
|
+
app(args=_hoist_global_options(sys.argv[1:]))
|
|
152
|
+
except CliError as exc:
|
|
153
|
+
output.err_console.print(f"[red]error:[/red] {escape(exc.message)}")
|
|
154
|
+
if exc.hint:
|
|
155
|
+
output.err_console.print(f"[dim]{escape(exc.hint)}[/dim]")
|
|
156
|
+
sys.exit(exc.code)
|
|
157
|
+
except KeyboardInterrupt:
|
|
158
|
+
output.err_console.print("\n[dim]Interrupted.[/dim]")
|
|
159
|
+
sys.exit(130)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Browser login against DevLift's OAuth 2.1 server.
|
|
2
|
+
|
|
3
|
+
The server publishes RFC 8414 metadata at
|
|
4
|
+
`{base_url}/.well-known/oauth-authorization-server/devlift-mcp`. The flow is
|
|
5
|
+
the standard native-app one: dynamic client registration (RFC 7591) with a
|
|
6
|
+
loopback redirect, PKCE S256, a short-lived local HTTP server that catches
|
|
7
|
+
the redirect, then the code exchange. Refresh tokens rotate on every use.
|
|
8
|
+
|
|
9
|
+
The access token is an opaque string that obs_tool's REST API accepts as a
|
|
10
|
+
bearer token, so this is the only login the CLI needs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import secrets
|
|
18
|
+
import socket
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import webbrowser
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
23
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from devlift_cli.auth.storage import Credentials
|
|
28
|
+
from devlift_cli.config import OAUTH_METADATA_PATH
|
|
29
|
+
from devlift_cli.errors import AuthError, CliError
|
|
30
|
+
|
|
31
|
+
CLIENT_NAME = "devlift-cli"
|
|
32
|
+
LOGIN_TIMEOUT_SECONDS = 300
|
|
33
|
+
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
|
34
|
+
|
|
35
|
+
_SUCCESS_PAGE = """<!doctype html><html><head><meta charset="utf-8"><title>DevLift CLI</title>
|
|
36
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
|
|
37
|
+
main{text-align:center}h1{font-weight:600}p{color:#94a3b8}</style></head>
|
|
38
|
+
<body><main><h1>Signed in to DevLift</h1><p>You can close this tab and return to the terminal.</p></main></body></html>"""
|
|
39
|
+
|
|
40
|
+
_FAILURE_PAGE = """<!doctype html><html><head><meta charset="utf-8"><title>DevLift CLI</title>
|
|
41
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
|
|
42
|
+
main{text-align:center}h1{font-weight:600;color:#f87171}p{color:#94a3b8}</style></head>
|
|
43
|
+
<body><main><h1>Sign-in failed</h1><p>%s</p></main></body></html>"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
47
|
+
verifier = secrets.token_urlsafe(64)
|
|
48
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
49
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
50
|
+
return verifier, challenge
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _free_port() -> int:
|
|
54
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
55
|
+
s.bind(("127.0.0.1", 0))
|
|
56
|
+
return s.getsockname()[1]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _port_is_free(port: int) -> bool:
|
|
60
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
61
|
+
try:
|
|
62
|
+
s.bind(("127.0.0.1", port))
|
|
63
|
+
return True
|
|
64
|
+
except OSError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _redirect_uri(port: int) -> str:
|
|
69
|
+
return f"http://127.0.0.1:{port}/callback"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def discover(base_url: str) -> dict:
|
|
73
|
+
url = base_url.rstrip("/") + OAUTH_METADATA_PATH
|
|
74
|
+
try:
|
|
75
|
+
resp = httpx.get(url, timeout=_TIMEOUT)
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
return resp.json()
|
|
78
|
+
except httpx.HTTPError as exc:
|
|
79
|
+
raise CliError(
|
|
80
|
+
f"Could not read the sign-in configuration from {url}: {exc}",
|
|
81
|
+
hint="Is the DevLift backend running at that address? Use --endpoint-url or `devlift configure`.",
|
|
82
|
+
) from exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def register_client(metadata: dict, port: int) -> tuple[str, str | None]:
|
|
86
|
+
endpoint = metadata.get("registration_endpoint")
|
|
87
|
+
if not endpoint:
|
|
88
|
+
raise CliError("The DevLift server does not offer client registration.")
|
|
89
|
+
body = {
|
|
90
|
+
"client_name": CLIENT_NAME,
|
|
91
|
+
"redirect_uris": [_redirect_uri(port)],
|
|
92
|
+
"grant_types": ["authorization_code", "refresh_token"],
|
|
93
|
+
"response_types": ["code"],
|
|
94
|
+
"token_endpoint_auth_method": "none",
|
|
95
|
+
}
|
|
96
|
+
resp = httpx.post(endpoint, json=body, timeout=_TIMEOUT)
|
|
97
|
+
if resp.status_code >= 300:
|
|
98
|
+
raise CliError(f"Client registration failed ({resp.status_code}): {resp.text[:300]}")
|
|
99
|
+
data = resp.json()
|
|
100
|
+
return data["client_id"], data.get("client_secret")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
104
|
+
"""Catches exactly one redirect and stores its query on the server object."""
|
|
105
|
+
|
|
106
|
+
def do_GET(self): # noqa: N802
|
|
107
|
+
parsed = urlparse(self.path)
|
|
108
|
+
if parsed.path != "/callback":
|
|
109
|
+
self.send_response(404)
|
|
110
|
+
self.end_headers()
|
|
111
|
+
return
|
|
112
|
+
query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
|
113
|
+
self.server.result = query # type: ignore[attr-defined]
|
|
114
|
+
if "error" in query:
|
|
115
|
+
body = _FAILURE_PAGE % (query.get("error_description") or query["error"])
|
|
116
|
+
else:
|
|
117
|
+
body = _SUCCESS_PAGE
|
|
118
|
+
payload = body.encode("utf-8")
|
|
119
|
+
self.send_response(200)
|
|
120
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
121
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
122
|
+
self.end_headers()
|
|
123
|
+
self.wfile.write(payload)
|
|
124
|
+
|
|
125
|
+
def log_message(self, *_args): # silence the default stderr access log
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _wait_for_callback(port: int, timeout: float) -> dict:
|
|
130
|
+
server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
|
|
131
|
+
server.result = None # type: ignore[attr-defined]
|
|
132
|
+
server.timeout = 1.0
|
|
133
|
+
deadline = time.time() + timeout
|
|
134
|
+
thread = threading.Thread(target=_serve_until, args=(server, deadline), daemon=True)
|
|
135
|
+
thread.start()
|
|
136
|
+
thread.join()
|
|
137
|
+
server.server_close()
|
|
138
|
+
result = server.result # type: ignore[attr-defined]
|
|
139
|
+
if result is None:
|
|
140
|
+
raise AuthError("Timed out waiting for the browser sign-in.", hint="Run `devlift login` again.")
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _serve_until(server: HTTPServer, deadline: float) -> None:
|
|
145
|
+
while server.result is None and time.time() < deadline: # type: ignore[attr-defined]
|
|
146
|
+
server.handle_request()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _exchange(metadata: dict, form: dict) -> dict:
|
|
150
|
+
resp = httpx.post(metadata["token_endpoint"], data=form, timeout=_TIMEOUT)
|
|
151
|
+
if resp.status_code >= 300:
|
|
152
|
+
try:
|
|
153
|
+
detail = resp.json()
|
|
154
|
+
message = detail.get("error_description") or detail.get("error") or resp.text
|
|
155
|
+
except ValueError:
|
|
156
|
+
message = resp.text
|
|
157
|
+
raise AuthError(f"Token request failed ({resp.status_code}): {str(message)[:300]}", hint="Run `devlift login`.")
|
|
158
|
+
return resp.json()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _to_credentials(base_url: str, metadata: dict, client_id: str, client_secret: str | None, port: int, token: dict) -> Credentials:
|
|
162
|
+
expires_in = float(token.get("expires_in") or 3600)
|
|
163
|
+
scope = token.get("scope") or ""
|
|
164
|
+
return Credentials(
|
|
165
|
+
issuer=metadata["issuer"],
|
|
166
|
+
client_id=client_id,
|
|
167
|
+
base_url=base_url.rstrip("/"),
|
|
168
|
+
client_secret=client_secret,
|
|
169
|
+
redirect_port=port,
|
|
170
|
+
access_token=token["access_token"],
|
|
171
|
+
refresh_token=token.get("refresh_token"),
|
|
172
|
+
expires_at=time.time() + expires_in,
|
|
173
|
+
scopes=scope.split() if scope else [],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def login(base_url: str, existing: Credentials | None = None, open_browser: bool = True, echo=print) -> Credentials:
|
|
178
|
+
"""Run the whole browser flow and return fresh credentials.
|
|
179
|
+
|
|
180
|
+
Reuses the stored client registration when its redirect port is still
|
|
181
|
+
free, so the server sees one client per machine rather than one per
|
|
182
|
+
login; otherwise registers a new one.
|
|
183
|
+
"""
|
|
184
|
+
metadata = discover(base_url)
|
|
185
|
+
|
|
186
|
+
client_id = client_secret = None
|
|
187
|
+
port = 0
|
|
188
|
+
if existing and existing.issuer == metadata.get("issuer") and existing.redirect_port and _port_is_free(existing.redirect_port):
|
|
189
|
+
client_id, client_secret, port = existing.client_id, existing.client_secret, existing.redirect_port
|
|
190
|
+
if not client_id:
|
|
191
|
+
port = _free_port()
|
|
192
|
+
client_id, client_secret = register_client(metadata, port)
|
|
193
|
+
|
|
194
|
+
verifier, challenge = _pkce_pair()
|
|
195
|
+
state = secrets.token_urlsafe(24)
|
|
196
|
+
params = {
|
|
197
|
+
"response_type": "code",
|
|
198
|
+
"client_id": client_id,
|
|
199
|
+
"redirect_uri": _redirect_uri(port),
|
|
200
|
+
"code_challenge": challenge,
|
|
201
|
+
"code_challenge_method": "S256",
|
|
202
|
+
"state": state,
|
|
203
|
+
}
|
|
204
|
+
url = metadata["authorization_endpoint"] + "?" + urlencode(params)
|
|
205
|
+
|
|
206
|
+
echo("Opening your browser to sign in to DevLift…")
|
|
207
|
+
echo(f"If it does not open, visit:\n {url}")
|
|
208
|
+
if open_browser:
|
|
209
|
+
try:
|
|
210
|
+
webbrowser.open(url, new=2)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
result = _wait_for_callback(port, LOGIN_TIMEOUT_SECONDS)
|
|
215
|
+
if "error" in result:
|
|
216
|
+
raise AuthError(f"Sign-in refused: {result.get('error_description') or result['error']}", hint=None)
|
|
217
|
+
if result.get("state") != state:
|
|
218
|
+
raise AuthError("Sign-in response did not match this login attempt (state mismatch).", hint="Run `devlift login` again.")
|
|
219
|
+
code = result.get("code")
|
|
220
|
+
if not code:
|
|
221
|
+
raise AuthError("Sign-in response carried no authorization code.", hint="Run `devlift login` again.")
|
|
222
|
+
|
|
223
|
+
form = {
|
|
224
|
+
"grant_type": "authorization_code",
|
|
225
|
+
"code": code,
|
|
226
|
+
"redirect_uri": _redirect_uri(port),
|
|
227
|
+
"client_id": client_id,
|
|
228
|
+
"code_verifier": verifier,
|
|
229
|
+
}
|
|
230
|
+
if client_secret:
|
|
231
|
+
form["client_secret"] = client_secret
|
|
232
|
+
token = _exchange(metadata, form)
|
|
233
|
+
return _to_credentials(base_url, metadata, client_id, client_secret, port, token)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def refresh(base_url: str, creds: Credentials) -> Credentials:
|
|
237
|
+
"""Rotate the token pair. Raises AuthError when the refresh token is gone."""
|
|
238
|
+
if not creds.refresh_token:
|
|
239
|
+
raise AuthError("Your session has expired.")
|
|
240
|
+
metadata = discover(base_url)
|
|
241
|
+
form = {
|
|
242
|
+
"grant_type": "refresh_token",
|
|
243
|
+
"refresh_token": creds.refresh_token,
|
|
244
|
+
"client_id": creds.client_id,
|
|
245
|
+
}
|
|
246
|
+
if creds.client_secret:
|
|
247
|
+
form["client_secret"] = creds.client_secret
|
|
248
|
+
token = _exchange(metadata, form)
|
|
249
|
+
fresh = _to_credentials(base_url, metadata, creds.client_id, creds.client_secret, creds.redirect_port, token)
|
|
250
|
+
if not fresh.refresh_token:
|
|
251
|
+
fresh.refresh_token = creds.refresh_token
|
|
252
|
+
return fresh
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def revoke(base_url: str, creds: Credentials) -> None:
|
|
256
|
+
"""Best effort: tell the server to forget the tokens. Never raises."""
|
|
257
|
+
try:
|
|
258
|
+
metadata = discover(base_url)
|
|
259
|
+
endpoint = metadata.get("revocation_endpoint")
|
|
260
|
+
if not endpoint:
|
|
261
|
+
return
|
|
262
|
+
for token in (creds.refresh_token, creds.access_token):
|
|
263
|
+
if not token:
|
|
264
|
+
continue
|
|
265
|
+
form = {"token": token, "client_id": creds.client_id}
|
|
266
|
+
if creds.client_secret:
|
|
267
|
+
form["client_secret"] = creds.client_secret
|
|
268
|
+
httpx.post(endpoint, data=form, timeout=_TIMEOUT)
|
|
269
|
+
except Exception:
|
|
270
|
+
return
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The token a command actually sends.
|
|
2
|
+
|
|
3
|
+
Precedence: DEVLIFT_TOKEN (scripts, CI) → stored credentials for the profile,
|
|
4
|
+
refreshed transparently when expired. A refresh that fails clears nothing;
|
|
5
|
+
the user is told to run `devlift login`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from devlift_cli.auth import oauth, storage
|
|
13
|
+
from devlift_cli.config import Profile
|
|
14
|
+
from devlift_cli.errors import AuthError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TokenSource:
|
|
18
|
+
def __init__(self, profile: Profile):
|
|
19
|
+
self.profile = profile
|
|
20
|
+
self._creds: storage.Credentials | None = None
|
|
21
|
+
self._env_token = os.environ.get("DEVLIFT_TOKEN") or None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def from_environment(self) -> bool:
|
|
25
|
+
return self._env_token is not None
|
|
26
|
+
|
|
27
|
+
def current(self) -> str:
|
|
28
|
+
if self._env_token:
|
|
29
|
+
return self._env_token
|
|
30
|
+
creds = self._load()
|
|
31
|
+
if creds.expired:
|
|
32
|
+
creds = self._refresh(creds)
|
|
33
|
+
return creds.access_token
|
|
34
|
+
|
|
35
|
+
def force_refresh(self) -> str | None:
|
|
36
|
+
"""After a 401: rotate once. None means there is nothing to rotate with."""
|
|
37
|
+
if self._env_token:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
return self._refresh(self._load()).access_token
|
|
41
|
+
except AuthError:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def _load(self) -> storage.Credentials:
|
|
45
|
+
if self._creds is None:
|
|
46
|
+
self._creds = storage.load(self.profile.name)
|
|
47
|
+
if self._creds is None:
|
|
48
|
+
raise AuthError(f"Not signed in (profile '{self.profile.name}').")
|
|
49
|
+
# A token is bound to the backend that issued it. --endpoint-url or
|
|
50
|
+
# DEVLIFT_BASE_URL can point the same profile elsewhere; never send
|
|
51
|
+
# one backend's token to another.
|
|
52
|
+
if self._creds.base_url and self._creds.base_url != self.profile.base_url:
|
|
53
|
+
raise AuthError(
|
|
54
|
+
f"Profile '{self.profile.name}' is signed in to {self._creds.base_url}, "
|
|
55
|
+
f"but this command targets {self.profile.base_url}.",
|
|
56
|
+
hint="Run `devlift login` for this backend, or drop --endpoint-url / DEVLIFT_BASE_URL.",
|
|
57
|
+
)
|
|
58
|
+
return self._creds
|
|
59
|
+
|
|
60
|
+
def _refresh(self, creds: storage.Credentials) -> storage.Credentials:
|
|
61
|
+
fresh = oauth.refresh(self.profile.base_url, creds)
|
|
62
|
+
storage.save(self.profile.name, fresh)
|
|
63
|
+
self._creds = fresh
|
|
64
|
+
return fresh
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Where the credentials live.
|
|
2
|
+
|
|
3
|
+
Primary: the OS keyring (service "devlift-cli", one entry per profile).
|
|
4
|
+
Fallback: ~/.config/devlift/credentials.json with mode 0600, for machines
|
|
5
|
+
without a keyring backend (headless Linux, CI). Which one is in use is
|
|
6
|
+
reported by `whoami --debug`; the data shape is the same in both.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from devlift_cli.config import CONFIG_DIR
|
|
17
|
+
|
|
18
|
+
KEYRING_SERVICE = "devlift-cli"
|
|
19
|
+
FALLBACK_FILE = CONFIG_DIR / "credentials.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Credentials:
|
|
24
|
+
issuer: str
|
|
25
|
+
client_id: str
|
|
26
|
+
base_url: str = "" # the backend this login was made against
|
|
27
|
+
client_secret: str | None = None
|
|
28
|
+
redirect_port: int = 0
|
|
29
|
+
access_token: str = ""
|
|
30
|
+
refresh_token: str | None = None
|
|
31
|
+
expires_at: float = 0.0
|
|
32
|
+
scopes: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def expired(self) -> bool:
|
|
36
|
+
# A minute of slack so a token does not die mid-command.
|
|
37
|
+
return bool(self.expires_at) and time.time() > self.expires_at - 60
|
|
38
|
+
|
|
39
|
+
def to_json(self) -> str:
|
|
40
|
+
return json.dumps(asdict(self))
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_json(cls, raw: str) -> "Credentials":
|
|
44
|
+
data = json.loads(raw)
|
|
45
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _keyring():
|
|
49
|
+
try:
|
|
50
|
+
import keyring
|
|
51
|
+
from keyring.errors import NoKeyringError # noqa: F401
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
backend = keyring.get_keyring()
|
|
56
|
+
# The "fail" backend raises on every call; treat it as absent.
|
|
57
|
+
if backend.__class__.__module__.startswith("keyring.backends.fail"):
|
|
58
|
+
return None
|
|
59
|
+
return keyring
|
|
60
|
+
except Exception:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_fallback() -> dict:
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(FALLBACK_FILE.read_text())
|
|
67
|
+
except (FileNotFoundError, ValueError):
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_fallback(data: dict) -> None:
|
|
72
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
tmp = FALLBACK_FILE.with_suffix(".tmp")
|
|
74
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n")
|
|
75
|
+
tmp.chmod(0o600)
|
|
76
|
+
tmp.replace(FALLBACK_FILE)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def backend_name() -> str:
|
|
80
|
+
kr = _keyring()
|
|
81
|
+
return kr.get_keyring().__class__.__name__ if kr else f"file ({FALLBACK_FILE})"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load(profile: str) -> Credentials | None:
|
|
85
|
+
kr = _keyring()
|
|
86
|
+
raw = None
|
|
87
|
+
if kr:
|
|
88
|
+
try:
|
|
89
|
+
raw = kr.get_password(KEYRING_SERVICE, profile)
|
|
90
|
+
except Exception:
|
|
91
|
+
raw = None
|
|
92
|
+
if raw is None:
|
|
93
|
+
raw = _read_fallback().get(profile)
|
|
94
|
+
if isinstance(raw, dict):
|
|
95
|
+
raw = json.dumps(raw)
|
|
96
|
+
if not raw:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
return Credentials.from_json(raw)
|
|
100
|
+
except (ValueError, TypeError):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save(profile: str, creds: Credentials) -> None:
|
|
105
|
+
kr = _keyring()
|
|
106
|
+
if kr:
|
|
107
|
+
try:
|
|
108
|
+
kr.set_password(KEYRING_SERVICE, profile, creds.to_json())
|
|
109
|
+
return
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
data = _read_fallback()
|
|
113
|
+
data[profile] = json.loads(creds.to_json())
|
|
114
|
+
_write_fallback(data)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def clear(profile: str) -> bool:
|
|
118
|
+
removed = False
|
|
119
|
+
kr = _keyring()
|
|
120
|
+
if kr:
|
|
121
|
+
try:
|
|
122
|
+
kr.delete_password(KEYRING_SERVICE, profile)
|
|
123
|
+
removed = True
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
data = _read_fallback()
|
|
127
|
+
if profile in data:
|
|
128
|
+
del data[profile]
|
|
129
|
+
_write_fallback(data)
|
|
130
|
+
removed = True
|
|
131
|
+
return removed
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def fallback_path() -> Path:
|
|
135
|
+
return FALLBACK_FILE
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""devlift approval … — the reviewer's side of the review lane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from devlift_cli.commands.manual import section_command
|
|
8
|
+
from devlift_cli.api import approvals as approvals_api
|
|
9
|
+
from devlift_cli.context import Invocation
|
|
10
|
+
from devlift_cli.ops.approvals import run_verb
|
|
11
|
+
from devlift_cli.render.output import rows_table
|
|
12
|
+
from devlift_cli.resolve.names import Resolver
|
|
13
|
+
|
|
14
|
+
approval_app = typer.Typer(help="Review change requests: inbox, approve, reject, request changes, revoke.", no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
_REF = typer.Argument(..., help="Queue code (queue-…) or service name.")
|
|
17
|
+
_ENV = typer.Option(None, "--env", help="Environment, when the service is configured in several.")
|
|
18
|
+
_REGION = typer.Option(None, "--region", help="Region, when the service runs in several.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@approval_app.command("list")
|
|
22
|
+
def approval_list(ctx: typer.Context):
|
|
23
|
+
"""Services with requests waiting for you (submitted or approved)."""
|
|
24
|
+
inv: Invocation = ctx.obj
|
|
25
|
+
rows = approvals_api.services_awaiting(inv.api)
|
|
26
|
+
inv.emit(rows, lambda d: rows_table(
|
|
27
|
+
["Service", "Environment", "Pending", "Approved", "Latest request", "Configuration"],
|
|
28
|
+
[(r.get("service_name"), r.get("environment"), r.get("pending_count"), r.get("approved_count"),
|
|
29
|
+
(r.get("latest_requested_at") or "")[:16].replace("T", " "), r.get("resource_code")) for r in d],
|
|
30
|
+
))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _verb_command(verb: str, doc: str, comment_required: bool):
|
|
34
|
+
comment_opt = typer.Option(..., "--comment", help="Why — recorded for the author.") if comment_required else \
|
|
35
|
+
typer.Option("", "--comment", help="Optional note recorded on the request.")
|
|
36
|
+
|
|
37
|
+
def command(ctx: typer.Context, ref: str = _REF, env: str | None = _ENV, region: str | None = _REGION, comment: str = comment_opt):
|
|
38
|
+
inv: Invocation = ctx.obj
|
|
39
|
+
result = run_verb(inv, Resolver(inv.api, inv.profile.name), verb, ref, env=env, region=region, comment=comment)
|
|
40
|
+
inv.emit(result.to_dict(), lambda d: rows_table(["Request", "Kind", "Status"], [(r["code"], r["kind"], r["status"]) for r in d["requests"]], title=f"{d['service']}: {verb}"))
|
|
41
|
+
command.__doc__ = doc
|
|
42
|
+
return command
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
approval_app.command("approve")(_verb_command("approve", "Approve a submitted request (settings and routes together). It becomes deployable.", False))
|
|
46
|
+
approval_app.command("request-changes")(_verb_command("request-changes", "Send a submitted request back to its author as a draft, with a comment.", True))
|
|
47
|
+
approval_app.command("reject")(_verb_command("reject", "Reject a submitted request. Terminal — use request-changes unless it should never happen.", True))
|
|
48
|
+
approval_app.command("revoke")(_verb_command("revoke", "Take an approval back; the request waits for a decision again. Not possible once a deploy has started.", False))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
approval_app.command("manual")(section_command("request, approval"))
|