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/config.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Profiles and settings.
|
|
2
|
+
|
|
3
|
+
~/.config/devlift/cli.json:
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"default_profile": "default",
|
|
7
|
+
"profiles": {
|
|
8
|
+
"default": {"base_url": "http://localhost:8000", "output": "table"},
|
|
9
|
+
"stage": {"base_url": "https://devlift.example.com"}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
Environment overrides (highest precedence): DEVLIFT_PROFILE, DEVLIFT_BASE_URL,
|
|
14
|
+
DEVLIFT_TOKEN (skips the stored credentials entirely), DEVLIFT_OUTPUT.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
25
|
+
API_PREFIX = "/api/v1"
|
|
26
|
+
OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server/devlift-mcp"
|
|
27
|
+
|
|
28
|
+
CONFIG_DIR = Path(os.environ.get("DEVLIFT_CONFIG_DIR", Path.home() / ".config" / "devlift"))
|
|
29
|
+
CONFIG_FILE = CONFIG_DIR / "cli.json"
|
|
30
|
+
PROJECT_FILE_NAME = "project.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Profile:
|
|
35
|
+
name: str
|
|
36
|
+
base_url: str = DEFAULT_BASE_URL
|
|
37
|
+
output: str = "table"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def api_url(self) -> str:
|
|
41
|
+
return self.base_url.rstrip("/") + API_PREFIX
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_file() -> dict:
|
|
45
|
+
try:
|
|
46
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
47
|
+
except (FileNotFoundError, ValueError):
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _write_file(data: dict) -> None:
|
|
52
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
tmp = CONFIG_FILE.with_suffix(".tmp")
|
|
54
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n")
|
|
55
|
+
tmp.chmod(0o600)
|
|
56
|
+
tmp.replace(CONFIG_FILE)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_profiles() -> dict[str, dict]:
|
|
60
|
+
return dict(_read_file().get("profiles") or {})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def default_profile_name() -> str:
|
|
64
|
+
return os.environ.get("DEVLIFT_PROFILE") or _read_file().get("default_profile") or "default"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_profile(name: str | None = None, base_url_override: str | None = None) -> Profile:
|
|
68
|
+
name = name or default_profile_name()
|
|
69
|
+
raw = list_profiles().get(name) or {}
|
|
70
|
+
base_url = base_url_override or os.environ.get("DEVLIFT_BASE_URL") or raw.get("base_url") or DEFAULT_BASE_URL
|
|
71
|
+
output = os.environ.get("DEVLIFT_OUTPUT") or raw.get("output") or "table"
|
|
72
|
+
return Profile(name=name, base_url=base_url.rstrip("/"), output=output)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_profile(profile: Profile, make_default: bool = False) -> None:
|
|
76
|
+
data = _read_file()
|
|
77
|
+
profiles = data.setdefault("profiles", {})
|
|
78
|
+
profiles[profile.name] = {"base_url": profile.base_url, "output": profile.output}
|
|
79
|
+
if make_default or "default_profile" not in data:
|
|
80
|
+
data["default_profile"] = profile.name
|
|
81
|
+
_write_file(data)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_project_id(start: Path | None = None) -> str | None:
|
|
85
|
+
"""`.devlift/project.json` in the cwd or any parent, like `.git`."""
|
|
86
|
+
here = (start or Path.cwd()).resolve()
|
|
87
|
+
for directory in (here, *here.parents):
|
|
88
|
+
candidate = directory / ".devlift" / PROJECT_FILE_NAME
|
|
89
|
+
if candidate.is_file():
|
|
90
|
+
try:
|
|
91
|
+
return json.loads(candidate.read_text()).get("project_id")
|
|
92
|
+
except ValueError:
|
|
93
|
+
return None
|
|
94
|
+
return None
|
devlift_cli/context.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Per-invocation state shared by every command: profile, output format,
|
|
2
|
+
confirmation policy, and a lazily built API client."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from devlift_cli.api.client import ApiClient
|
|
9
|
+
from devlift_cli.auth.session import TokenSource
|
|
10
|
+
from devlift_cli.config import Profile
|
|
11
|
+
from devlift_cli.errors import ConfirmationRequired, InputError
|
|
12
|
+
from devlift_cli.render import output
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Invocation:
|
|
17
|
+
profile: Profile
|
|
18
|
+
output: str
|
|
19
|
+
yes: bool = False
|
|
20
|
+
no_input: bool = False
|
|
21
|
+
debug: bool = False
|
|
22
|
+
_api: ApiClient | None = field(default=None, repr=False)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def api(self) -> ApiClient:
|
|
26
|
+
if self._api is None:
|
|
27
|
+
self._api = ApiClient(self.profile, TokenSource(self.profile), debug=self.debug)
|
|
28
|
+
return self._api
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def interactive(self) -> bool:
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
return not self.no_input and sys.stdin.isatty()
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def show_summary(self) -> bool:
|
|
38
|
+
"""Print the pre-confirmation summary (tables on stderr)?
|
|
39
|
+
|
|
40
|
+
On a terminal or in table mode, always. In json mode it is also
|
|
41
|
+
printed whenever the run is a DRY RUN (no --yes): a script or an agent
|
|
42
|
+
that runs a write without -y is asking "what would this do?", and the
|
|
43
|
+
answer has to be visible, not only the exit-5 line. Only a scripted
|
|
44
|
+
`-y -o json` run stays quiet."""
|
|
45
|
+
return self.output == "table" or self.interactive or not self.yes
|
|
46
|
+
|
|
47
|
+
def confirm(self, question: str) -> None:
|
|
48
|
+
"""Proceed, or raise. --yes answers; --no-input without --yes refuses."""
|
|
49
|
+
if self.yes:
|
|
50
|
+
return
|
|
51
|
+
if not self.interactive:
|
|
52
|
+
raise ConfirmationRequired(f"{question} (needs confirmation)")
|
|
53
|
+
from rich.prompt import Confirm
|
|
54
|
+
|
|
55
|
+
if not Confirm.ask(question, default=False, console=output.err_console):
|
|
56
|
+
raise ConfirmationRequired("Cancelled.")
|
|
57
|
+
|
|
58
|
+
def ask(self, label: str, *, choices: list[str] | None = None, default: str | None = None, flag: str | None = None) -> str:
|
|
59
|
+
"""Prompt for a missing value, or refuse under --no-input naming the flag."""
|
|
60
|
+
if not self.interactive:
|
|
61
|
+
raise InputError(f"{label} is required.", hint=f"Pass {flag}." if flag else None)
|
|
62
|
+
from rich.prompt import Prompt
|
|
63
|
+
|
|
64
|
+
if choices and len(choices) <= 12:
|
|
65
|
+
output.err_console.print(f"[bold]{label}[/bold]")
|
|
66
|
+
for i, choice in enumerate(choices, 1):
|
|
67
|
+
output.err_console.print(f" {i}. {choice}")
|
|
68
|
+
while True:
|
|
69
|
+
raw = Prompt.ask("Choose (number or name)", default=default, console=output.err_console)
|
|
70
|
+
if raw is None:
|
|
71
|
+
continue
|
|
72
|
+
raw = raw.strip()
|
|
73
|
+
if raw.isdigit() and 1 <= int(raw) <= len(choices):
|
|
74
|
+
return choices[int(raw) - 1]
|
|
75
|
+
match = [c for c in choices if c.lower() == raw.lower()]
|
|
76
|
+
if match:
|
|
77
|
+
return match[0]
|
|
78
|
+
output.warn(f"Pick one of 1-{len(choices)}.")
|
|
79
|
+
while True:
|
|
80
|
+
raw = Prompt.ask(label, default=default, console=output.err_console)
|
|
81
|
+
if raw and raw.strip():
|
|
82
|
+
return raw.strip()
|
|
83
|
+
output.warn(f"{label} is required.")
|
|
84
|
+
|
|
85
|
+
def ask_bool(self, question: str, *, default: bool = False, flag: str | None = None) -> bool:
|
|
86
|
+
if not self.interactive:
|
|
87
|
+
raise InputError(f"{question} needs an answer.", hint=f"Pass {flag}." if flag else None)
|
|
88
|
+
from rich.prompt import Confirm
|
|
89
|
+
|
|
90
|
+
return Confirm.ask(question, default=default, console=output.err_console)
|
|
91
|
+
|
|
92
|
+
def emit(self, data, table=None) -> None:
|
|
93
|
+
output.emit(data, self.output, table)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
if self._api is not None:
|
|
97
|
+
self._api.close()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_tenants": ["vance", "aspora"],
|
|
3
|
+
|
|
4
|
+
"Default_workspace": {
|
|
5
|
+
"core": {
|
|
6
|
+
"stage": {
|
|
7
|
+
"ap-south-1": ["database", "dynamo", "gateway", "s3", "sqs", "eks"]
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
"prod": {
|
|
11
|
+
"ap-south-1": ["database", "gateway", "s3", "sqs"],
|
|
12
|
+
"eu-west-2": ["database", "dynamo", "gateway", "s3", "sqs", "eks"]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
devlift_cli/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""One error type, one exit-code table.
|
|
2
|
+
|
|
3
|
+
Every command failure is raised as a CliError and printed once, in main().
|
|
4
|
+
The codes are part of the CLI's contract for scripts:
|
|
5
|
+
|
|
6
|
+
0 ok
|
|
7
|
+
1 error (server 5xx, unexpected)
|
|
8
|
+
2 not authenticated — run `devlift login`
|
|
9
|
+
3 missing / invalid input, ambiguous name
|
|
10
|
+
4 permission refused (403)
|
|
11
|
+
5 confirmation required (no --yes under --no-input)
|
|
12
|
+
6 not found (404)
|
|
13
|
+
7 conflict / lane busy / wrong state (409)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
EXIT_OK = 0
|
|
17
|
+
EXIT_ERROR = 1
|
|
18
|
+
EXIT_AUTH = 2
|
|
19
|
+
EXIT_INPUT = 3
|
|
20
|
+
EXIT_PERMISSION = 4
|
|
21
|
+
EXIT_CONFIRM = 5
|
|
22
|
+
EXIT_NOT_FOUND = 6
|
|
23
|
+
EXIT_CONFLICT = 7
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CliError(Exception):
|
|
27
|
+
def __init__(self, message: str, code: int = EXIT_ERROR, hint: str | None = None, http_status: int | None = None):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.message = message
|
|
30
|
+
self.code = code
|
|
31
|
+
self.hint = hint
|
|
32
|
+
self.http_status = http_status
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def transient(self) -> bool:
|
|
36
|
+
"""A server-side hiccup worth retrying (bad gateway, unavailable, timeout)."""
|
|
37
|
+
return self.http_status in (502, 503, 504)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthError(CliError):
|
|
41
|
+
def __init__(self, message: str = "Not signed in.", hint: str | None = "Run `devlift login`."):
|
|
42
|
+
super().__init__(message, EXIT_AUTH, hint)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InputError(CliError):
|
|
46
|
+
def __init__(self, message: str, hint: str | None = None):
|
|
47
|
+
super().__init__(message, EXIT_INPUT, hint)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConfirmationRequired(CliError):
|
|
51
|
+
def __init__(self, message: str = "Confirmation required."):
|
|
52
|
+
super().__init__(message, EXIT_CONFIRM, "Pass --yes to confirm non-interactively.")
|
|
File without changes
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""`devlift request …`, `devlift approval …`, `devlift eks deploy`: the review lane.
|
|
2
|
+
|
|
3
|
+
A service's change is a CHANGE SET: its settings row, its gateway-routes row
|
|
4
|
+
and its variables row in the transaction queue, all for one service
|
|
5
|
+
configuration and one author. The backend moves the whole set on every verb
|
|
6
|
+
(submit, approve, ...), so here a request is named either by its queue code
|
|
7
|
+
or by the service, and the output lists every row that moved.
|
|
8
|
+
|
|
9
|
+
draft ──submit──▶ submit ──approve──▶ approved ──deploy──▶ in flight
|
|
10
|
+
▲ │ ▲ │
|
|
11
|
+
└───withdraw──────┘ └─────revoke────────┘
|
|
12
|
+
└──request-changes──┘ reject ──▶ rejected (terminal)
|
|
13
|
+
discard: a draft is thrown away
|
|
14
|
+
|
|
15
|
+
Deploy is `POST /deployments/multiple-deploy {service_config_code}`: obs_tool
|
|
16
|
+
resolves the approved rows itself, runs the gate (status, can_deploy, seal)
|
|
17
|
+
and starts one Temporal batch — the same single call the dashboard makes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from devlift_cli.api import approvals as approvals_api
|
|
25
|
+
from devlift_cli.api import kong as kong_api
|
|
26
|
+
from devlift_cli.context import Invocation
|
|
27
|
+
from devlift_cli.errors import EXIT_CONFLICT, EXIT_INPUT, EXIT_NOT_FOUND, CliError, InputError
|
|
28
|
+
from devlift_cli.ops import wait
|
|
29
|
+
from devlift_cli.render import output
|
|
30
|
+
from devlift_cli.render.output import kv_table, rows_table
|
|
31
|
+
from devlift_cli.resolve.names import Resolver
|
|
32
|
+
|
|
33
|
+
KIND = {"update_service": "Settings", "add_route": "Gateway routes", "update_variables": "Variables", "delete_service": "Delete service"}
|
|
34
|
+
|
|
35
|
+
# Which statuses each verb starts from, and whether only the author may call it.
|
|
36
|
+
VERB_FROM = {
|
|
37
|
+
"submit": ("draft",), "discard": ("draft",), "withdraw": ("submit",),
|
|
38
|
+
"approve": ("submit",), "request-changes": ("submit",), "reject": ("submit",), "revoke": ("approved",),
|
|
39
|
+
}
|
|
40
|
+
AUTHOR_VERBS = ("submit", "discard", "withdraw")
|
|
41
|
+
VERB_DONE = {
|
|
42
|
+
"submit": "submitted for review", "discard": "discarded", "withdraw": "withdrawn, back with you as a draft",
|
|
43
|
+
"approve": "approved — it can be deployed", "request-changes": "sent back to the author as a draft",
|
|
44
|
+
"reject": "rejected", "revoke": "approval taken back — waiting for a decision again",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def kind_of(row: dict) -> str:
|
|
49
|
+
return KIND.get(row.get("case_ref_code") or "", row.get("case_ref_code") or row.get("resource_type") or "?")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── finding the request ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Target:
|
|
56
|
+
config_code: str
|
|
57
|
+
service_name: str
|
|
58
|
+
environment: str | None
|
|
59
|
+
region_name: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _target_from_row(inv: Invocation, row: dict) -> Target:
|
|
63
|
+
"""A queue row names its configuration; the environment is read from the
|
|
64
|
+
service's configurations, since the row itself does not carry it."""
|
|
65
|
+
code = row.get("resource_code") or ""
|
|
66
|
+
name = (row.get("config_snapshot") or {}).get("service_name") or row.get("display_name") or code
|
|
67
|
+
env = region = None
|
|
68
|
+
services_mst_code = (row.get("config_snapshot") or {}).get("services_mst_code")
|
|
69
|
+
if services_mst_code:
|
|
70
|
+
for o in kong_api.env_geo_options(inv.api, services_mst_code):
|
|
71
|
+
if o.get("config_code") == code:
|
|
72
|
+
env, region = o.get("environment"), o.get("geo_loc_name") or o.get("geo_loc_code")
|
|
73
|
+
name = name if name != code else o.get("service_name") or name
|
|
74
|
+
return Target(code, name, env, region)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_target(inv: Invocation, res: Resolver, service: str, env: str | None, region: str | None) -> Target:
|
|
78
|
+
from devlift_cli.ops.kong import resolve_target as _rt # same lookup the kong commands use
|
|
79
|
+
t = _rt(inv, res, service, env, region)
|
|
80
|
+
return Target(t.config_code, t.service_name, t.environment, t.region_name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_requests(inv: Invocation, res: Resolver, ref: str, *, env: str | None, region: str | None,
|
|
84
|
+
statuses: tuple[str, ...] | None, mine_only: bool) -> tuple[Target, list[dict]]:
|
|
85
|
+
"""The change set the user means: by queue code (that row's set) or by
|
|
86
|
+
service (every row on its configuration in the wanted statuses)."""
|
|
87
|
+
if ref.startswith("queue-"):
|
|
88
|
+
row = approvals_api.get_request(inv.api, ref)
|
|
89
|
+
target = _target_from_row(inv, row)
|
|
90
|
+
rows = approvals_api.list_requests(inv.api, resource_code=target.config_code)
|
|
91
|
+
same = [r for r in rows if r.get("status") == row.get("status") and r.get("requested_by") == row.get("requested_by")]
|
|
92
|
+
if all(r["code"] != row["code"] for r in same):
|
|
93
|
+
same.append(row)
|
|
94
|
+
return target, sorted(same, key=lambda r: r["code"] != row["code"])
|
|
95
|
+
target = resolve_target(inv, res, ref, env, region)
|
|
96
|
+
rows = approvals_api.list_requests(inv.api, resource_code=target.config_code)
|
|
97
|
+
if statuses:
|
|
98
|
+
rows = [r for r in rows if r.get("status") in statuses]
|
|
99
|
+
if mine_only:
|
|
100
|
+
rows = [r for r in rows if (r.get("you") or {}).get("mine")]
|
|
101
|
+
authors = {r.get("requested_by") for r in rows}
|
|
102
|
+
if len(authors) > 1:
|
|
103
|
+
raise CliError(
|
|
104
|
+
f"{target.service_name} has requests from several people: " + ", ".join(
|
|
105
|
+
f"{r['code']} ({r.get('requested_by_name') or r.get('requested_by')}, {r['status']})" for r in rows),
|
|
106
|
+
EXIT_INPUT, hint="Name the queue code instead of the service.",
|
|
107
|
+
)
|
|
108
|
+
return target, rows
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── showing it ───────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def diff_rows(row: dict) -> list[tuple[str, str, str]]:
|
|
114
|
+
"""The frozen diff as (field, from, to). Settings rows carry a field map;
|
|
115
|
+
a gateway row carries the groups with their path actions."""
|
|
116
|
+
changes = row.get("changes") or {}
|
|
117
|
+
out: list[tuple[str, str, str]] = []
|
|
118
|
+
if "groups" in changes:
|
|
119
|
+
for g in changes.get("groups") or []:
|
|
120
|
+
label = f"{g.get('route_group_key')} · {g.get('http_method')}"
|
|
121
|
+
for p in g.get("paths") or []:
|
|
122
|
+
act = (p.get("action") or "").lower()
|
|
123
|
+
if act == "add":
|
|
124
|
+
out.append((label, "–", p.get("route_path") or ""))
|
|
125
|
+
elif act == "delete":
|
|
126
|
+
out.append((label, p.get("route_path") or "", "– (removed)"))
|
|
127
|
+
elif act == "edit":
|
|
128
|
+
out.append((label, p.get("old_path") or "", p.get("route_path") or ""))
|
|
129
|
+
pb, pa = g.get("plugins_before") or [], g.get("plugins_after") or []
|
|
130
|
+
if sorted(pb) != sorted(pa):
|
|
131
|
+
out.append((f"{label} plugins", ", ".join(pb) or "–", ", ".join(pa) or "–"))
|
|
132
|
+
rb, ra = g.get("regex_priority_before") or 0, g.get("regex_priority_after") or 0
|
|
133
|
+
if rb != ra:
|
|
134
|
+
out.append((f"{label} priority", str(rb), str(ra)))
|
|
135
|
+
return out
|
|
136
|
+
for field, move in changes.items():
|
|
137
|
+
if isinstance(move, dict) and ("from" in move or "to" in move):
|
|
138
|
+
out.append((field, _v(move.get("from")), _v(move.get("to"))))
|
|
139
|
+
else:
|
|
140
|
+
out.append((field, "", _v(move)))
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _v(value) -> str:
|
|
145
|
+
if value is None or value == "":
|
|
146
|
+
return "–"
|
|
147
|
+
if isinstance(value, list):
|
|
148
|
+
return ", ".join(str(x) for x in value)
|
|
149
|
+
return str(value)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def print_request(row: dict, *, with_diff: bool = True) -> None:
|
|
153
|
+
you = row.get("you") or {}
|
|
154
|
+
rows = [
|
|
155
|
+
("Request", f"{row.get('code')} ({kind_of(row)})"),
|
|
156
|
+
("Service", row.get("display_name")),
|
|
157
|
+
("Status", row.get("status")),
|
|
158
|
+
("Requested by", f"{row.get('requested_by_name') or row.get('requested_by')}" + (" (you)" if you.get("mine") else "")),
|
|
159
|
+
("Requested at", (row.get("requested_at") or "")[:19].replace("T", " ")),
|
|
160
|
+
]
|
|
161
|
+
if row.get("decided_by"):
|
|
162
|
+
rows.append(("Decided by", f"{row.get('decided_by_name') or row.get('decided_by')} at {(row.get('decided_at') or '')[:19].replace('T', ' ')}"))
|
|
163
|
+
if row.get("decision_comment"):
|
|
164
|
+
rows.append(("Comment", row["decision_comment"]))
|
|
165
|
+
rows.append(("You may", ", ".join(k[4:].replace("_", " ") for k in ("can_approve", "can_deploy") if you.get(k)) or "–"))
|
|
166
|
+
output.err_console.print(kv_table(rows))
|
|
167
|
+
if with_diff:
|
|
168
|
+
diff = diff_rows(row)
|
|
169
|
+
if diff:
|
|
170
|
+
output.err_console.print(rows_table(["Field", "From", "To"], diff, title="Changes"))
|
|
171
|
+
elif row.get("status") == "draft":
|
|
172
|
+
output.info("The diff is frozen at submit; a draft shows it in the dashboard's Preview tab.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── acting ───────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class VerbResult:
|
|
179
|
+
verb: str
|
|
180
|
+
service_name: str
|
|
181
|
+
config_code: str
|
|
182
|
+
rows: list[dict]
|
|
183
|
+
|
|
184
|
+
def to_dict(self) -> dict:
|
|
185
|
+
return {
|
|
186
|
+
"verb": self.verb, "service": self.service_name, "service_config_code": self.config_code,
|
|
187
|
+
"requests": [{"code": r.get("code"), "kind": kind_of(r), "status": r.get("status")} for r in self.rows],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_verb(inv: Invocation, res: Resolver, verb: str, ref: str, *, env: str | None, region: str | None, comment: str) -> VerbResult:
|
|
192
|
+
from_statuses = VERB_FROM[verb]
|
|
193
|
+
target, rows = find_requests(inv, res, ref, env=env, region=region, statuses=from_statuses, mine_only=verb in AUTHOR_VERBS)
|
|
194
|
+
if not rows:
|
|
195
|
+
raise CliError(
|
|
196
|
+
f"No {' or '.join(from_statuses)} request of yours on {target.service_name}" if verb in AUTHOR_VERBS
|
|
197
|
+
else f"No {' or '.join(from_statuses)} request on {target.service_name}",
|
|
198
|
+
EXIT_NOT_FOUND, hint=f"`devlift request list --service {target.service_name}` shows what exists.",
|
|
199
|
+
)
|
|
200
|
+
wrong = [r for r in rows if r.get("status") not in from_statuses]
|
|
201
|
+
if wrong:
|
|
202
|
+
raise CliError(
|
|
203
|
+
f"{wrong[0]['code']} is {wrong[0]['status']} — only a {' or '.join(from_statuses)} request can be {verb}ed.".replace("eed", "ed"),
|
|
204
|
+
EXIT_CONFLICT,
|
|
205
|
+
)
|
|
206
|
+
if verb in ("request-changes", "reject") and not comment.strip():
|
|
207
|
+
raise InputError(f"{verb} needs a comment telling the author why.", hint="Pass --comment.")
|
|
208
|
+
|
|
209
|
+
if inv.show_summary:
|
|
210
|
+
for r in rows:
|
|
211
|
+
full = approvals_api.get_request(inv.api, r["code"])
|
|
212
|
+
print_request(full, with_diff=verb != "discard")
|
|
213
|
+
inv.confirm(f"{verb.capitalize().replace('-', ' ')} this change on {target.service_name} ({len(rows)} request{'s' if len(rows) > 1 else ''})?")
|
|
214
|
+
|
|
215
|
+
approvals_api.act(inv.api, rows[0]["code"], verb, comment)
|
|
216
|
+
after: list[dict] = []
|
|
217
|
+
for r in rows:
|
|
218
|
+
try:
|
|
219
|
+
after.append(approvals_api.get_request(inv.api, r["code"]))
|
|
220
|
+
except CliError:
|
|
221
|
+
after.append({**r, "status": "discarded"}) # gone from every read
|
|
222
|
+
for r in after:
|
|
223
|
+
output.info(f"{r['code']} ({kind_of(r)}): {VERB_DONE[verb]}.")
|
|
224
|
+
return VerbResult(verb, target.service_name, target.config_code, after)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── deploy ───────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class DeployResult:
|
|
231
|
+
service_name: str
|
|
232
|
+
config_code: str
|
|
233
|
+
environment: str | None
|
|
234
|
+
rows: list[dict]
|
|
235
|
+
workflow_id: str | None
|
|
236
|
+
status: str | None
|
|
237
|
+
state: dict | None = None
|
|
238
|
+
|
|
239
|
+
def to_dict(self) -> dict:
|
|
240
|
+
return {
|
|
241
|
+
"service": self.service_name, "service_config_code": self.config_code, "environment": self.environment,
|
|
242
|
+
"requests": [{"code": r.get("code"), "kind": kind_of(r)} for r in self.rows],
|
|
243
|
+
"workflow_id": self.workflow_id, "status": self.status, "state": self.state,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def deploy(inv: Invocation, res: Resolver, service: str, *, env: str | None, region: str | None,
|
|
248
|
+
confirm_name: str | None, do_wait: bool) -> DeployResult:
|
|
249
|
+
target = resolve_target(inv, res, service, env, region)
|
|
250
|
+
rows = [r for r in approvals_api.list_requests(inv.api, resource_code=target.config_code) if r.get("status") == "approved"]
|
|
251
|
+
if not rows:
|
|
252
|
+
raise CliError(f"Nothing approved to deploy on {target.service_name} ({target.environment}).", EXIT_NOT_FOUND,
|
|
253
|
+
hint=f"`devlift request list --service {target.service_name}` shows where it stands.")
|
|
254
|
+
if any((r.get("you") or {}).get("deploy_in_flight") for r in rows):
|
|
255
|
+
raise CliError(f"A deployment of {target.service_name} is already in progress.", EXIT_CONFLICT)
|
|
256
|
+
if not all((r.get("you") or {}).get("can_deploy", True) for r in rows):
|
|
257
|
+
raise CliError(f"You are not allowed to deploy {target.service_name} in {target.environment}.", 4)
|
|
258
|
+
|
|
259
|
+
if inv.show_summary:
|
|
260
|
+
output.err_console.print(kv_table([
|
|
261
|
+
("Service", f"{target.service_name} ({target.environment} / {target.region_name})"),
|
|
262
|
+
("Configuration", target.config_code),
|
|
263
|
+
] + [(f"Approved {kind_of(r).lower()}", f"{r['code']} by {r.get('requested_by_name') or r.get('requested_by')}") for r in rows], title="Deploy"))
|
|
264
|
+
# What is actually about to ship, not just which rows. This is the last
|
|
265
|
+
# point where the values can still be looked at, and the approved
|
|
266
|
+
# snapshot is exactly what the pipeline renders — so show the same
|
|
267
|
+
# Deployed → Requested table the reviewer decided on.
|
|
268
|
+
for row in rows:
|
|
269
|
+
try:
|
|
270
|
+
full = approvals_api.get_request(inv.api, row["code"])
|
|
271
|
+
except CliError:
|
|
272
|
+
continue
|
|
273
|
+
output.err_console.print(rows_table(
|
|
274
|
+
["Field", "Deployed", "Requested"],
|
|
275
|
+
diff_rows(full) or [("–", "–", "no field-level changes recorded")],
|
|
276
|
+
title=f"{kind_of(full)} · {full.get('code')}",
|
|
277
|
+
))
|
|
278
|
+
if (target.environment or "").lower() == "prod":
|
|
279
|
+
# Production: the service name has to be typed, as the dashboard and the
|
|
280
|
+
# assistant require. --yes alone is not enough here.
|
|
281
|
+
typed = confirm_name
|
|
282
|
+
if typed is None:
|
|
283
|
+
if not inv.interactive:
|
|
284
|
+
raise CliError("Deploying to production needs the service name typed back.", 5,
|
|
285
|
+
hint=f"Pass --confirm-name {target.service_name}.")
|
|
286
|
+
typed = inv.ask(f"Type the service name to deploy to PRODUCTION ({target.service_name})", flag="--confirm-name")
|
|
287
|
+
if typed.strip() != target.service_name:
|
|
288
|
+
raise InputError(f"'{typed}' does not match {target.service_name}. Nothing was deployed.")
|
|
289
|
+
else:
|
|
290
|
+
inv.confirm(f"Deploy {target.service_name} to {target.environment}?")
|
|
291
|
+
|
|
292
|
+
started = approvals_api.multiple_deploy(inv.api, target.config_code) or {}
|
|
293
|
+
workflow_id = started.get("workflow_id")
|
|
294
|
+
output.info(f"Deployment of {target.service_name} to {target.environment} started" + (f" (workflow {workflow_id})." if workflow_id else "."))
|
|
295
|
+
result = DeployResult(target.service_name, target.config_code, target.environment, rows, workflow_id, started.get("status"))
|
|
296
|
+
if do_wait and workflow_id:
|
|
297
|
+
result.state = wait_for_multiple_deploy(inv, workflow_id, label=f"deploying {target.service_name}")
|
|
298
|
+
result.status = str((result.state or {}).get("step") or result.status)
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
_ORCH_STEPS = {
|
|
303
|
+
"preparing": "preparing", "acquiring_locks": "acquiring locks", "deploying_variables": "deploying variables",
|
|
304
|
+
"deploying_infra": "deploying the service", "completed": "done", "failed": "failed",
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def wait_for_multiple_deploy(inv: Invocation, workflow_id: str, label: str) -> dict:
|
|
309
|
+
"""Follow the orchestrator: its own steps, then the infra child workflow's
|
|
310
|
+
granular steps once it exists."""
|
|
311
|
+
last: dict = {}
|
|
312
|
+
hiccups = 0
|
|
313
|
+
|
|
314
|
+
def describe():
|
|
315
|
+
nonlocal last, hiccups
|
|
316
|
+
try:
|
|
317
|
+
last = approvals_api.multiple_deploy_status(inv.api, workflow_id) or {}
|
|
318
|
+
hiccups = 0
|
|
319
|
+
except CliError as exc:
|
|
320
|
+
if not getattr(exc, "transient", False) or hiccups >= 12:
|
|
321
|
+
raise
|
|
322
|
+
hiccups += 1
|
|
323
|
+
return f"waiting for status (status service busy, retry {hiccups})", False, False
|
|
324
|
+
step = str(last.get("step") or "").lower()
|
|
325
|
+
text = _ORCH_STEPS.get(step, step or "starting")
|
|
326
|
+
child = last.get("infra_workflow_id") or last.get("infra_child_workflow_id")
|
|
327
|
+
if child and step == "deploying_infra":
|
|
328
|
+
try:
|
|
329
|
+
from devlift_cli.api import infra
|
|
330
|
+
sub = infra.deploy_status(inv.api, child) or {}
|
|
331
|
+
sub_step = str(sub.get("step") or "").lower()
|
|
332
|
+
text += ": " + wait._STEP_TEXT.get(sub_step, sub_step or "starting")
|
|
333
|
+
if sub.get("pr_number") and sub.get("repo_full_name"):
|
|
334
|
+
text += f" (PR #{sub['pr_number']} on {sub['repo_full_name']})"
|
|
335
|
+
except CliError:
|
|
336
|
+
pass
|
|
337
|
+
failed = step == "failed"
|
|
338
|
+
if failed and last.get("error"):
|
|
339
|
+
text += f": {last['error']}"
|
|
340
|
+
return text, failed or step == "completed", failed
|
|
341
|
+
|
|
342
|
+
wait.poll(describe, label=label)
|
|
343
|
+
return last
|