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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Rendering: table on a terminal, json when piped, yaml on request.
|
|
2
|
+
|
|
3
|
+
`emit(data, fmt, table=…)`: `data` is what -o json prints, byte for byte the
|
|
4
|
+
server's shape plus whatever the command adds. `table` is a callable that
|
|
5
|
+
turns the same data into a Rich renderable; it is only called for table
|
|
6
|
+
output, so json never depends on presentation code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Callable, Iterable
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
FORMATS = ("table", "json", "yaml")
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
err_console = Console(stderr=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_format(requested: str | None, profile_default: str) -> str:
|
|
27
|
+
if requested:
|
|
28
|
+
return requested
|
|
29
|
+
if not sys.stdout.isatty():
|
|
30
|
+
return "json"
|
|
31
|
+
return profile_default if profile_default in FORMATS else "table"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def emit(data: Any, fmt: str, table: Callable[[Any], Any] | None = None) -> None:
|
|
35
|
+
if fmt == "json":
|
|
36
|
+
print(json.dumps(data, indent=2, default=str))
|
|
37
|
+
elif fmt == "yaml":
|
|
38
|
+
print(yaml.safe_dump(data, sort_keys=False, default_flow_style=False), end="")
|
|
39
|
+
else:
|
|
40
|
+
if table is None:
|
|
41
|
+
print(json.dumps(data, indent=2, default=str))
|
|
42
|
+
else:
|
|
43
|
+
console.print(table(data))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def kv_table(rows: Iterable[tuple[str, Any]], title: str | None = None) -> Table:
|
|
47
|
+
t = Table(title=title, show_header=False, box=None, pad_edge=False)
|
|
48
|
+
t.add_column("key", style="bold")
|
|
49
|
+
t.add_column("value")
|
|
50
|
+
for key, value in rows:
|
|
51
|
+
# Values are data, never markup: a branch list rendered as "[main]"
|
|
52
|
+
# would otherwise be swallowed as a Rich style tag.
|
|
53
|
+
t.add_row(escape(str(key)), "" if value is None else escape(str(value)))
|
|
54
|
+
return t
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rows_table(columns: list[str], rows: Iterable[Iterable[Any]], title: str | None = None) -> Table:
|
|
58
|
+
t = Table(title=title, header_style="bold")
|
|
59
|
+
for col in columns:
|
|
60
|
+
t.add_column(col)
|
|
61
|
+
for row in rows:
|
|
62
|
+
t.add_row(*("" if v is None else escape(str(v)) for v in row))
|
|
63
|
+
return t
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def info(message: str) -> None:
|
|
67
|
+
err_console.print(message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def warn(message: str) -> None:
|
|
71
|
+
err_console.print(f"[yellow]{message}[/yellow]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def success(message: str) -> None:
|
|
75
|
+
err_console.print(f"[green]{message}[/green]")
|
|
File without changes
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Placement allowlist: what each product, environment and region really offers.
|
|
2
|
+
|
|
3
|
+
The backend returns every region for every application, which is wider than
|
|
4
|
+
what exists. `data/placement/{tenant}.json` records the truth, in the shape
|
|
5
|
+
|
|
6
|
+
{"<workspace>": {"<product>": {"<env>": {"<aws region>": ["s3", "sqs", ...]}}}}
|
|
7
|
+
|
|
8
|
+
and is the same file the chatbot carries under its own data directory;
|
|
9
|
+
keep the two in step. The dashboard carries its own, coarser copy.
|
|
10
|
+
|
|
11
|
+
A file is found by its name, `{tenant}.json`, or by listing the tenant in its
|
|
12
|
+
own `_tenants` key, which is how one customer known by several tenant codes
|
|
13
|
+
gets one file instead of a copy per code. Keys beginning with `_` are metadata
|
|
14
|
+
and are never read as workspaces.
|
|
15
|
+
|
|
16
|
+
Matching is on LABELS, never on codes: the config is written in human terms
|
|
17
|
+
(product `core`, environment `stage`, region `ap-south-1`) while the API
|
|
18
|
+
speaks opaque codes, so the config only ever decides which API rows survive.
|
|
19
|
+
|
|
20
|
+
Safety stance: this filter fails CLOSED. If matching goes wrong the user is
|
|
21
|
+
told there is no placement rather than handed one that does not exist — a
|
|
22
|
+
refusal is recoverable, a bucket in the wrong region is not. Filtering is
|
|
23
|
+
opt-in per tenant: a tenant with no config file is left unfiltered.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
_DIR = Path(__file__).resolve().parent.parent / "data" / "placement"
|
|
32
|
+
|
|
33
|
+
# The service key each command's resources use in the config's leaf lists.
|
|
34
|
+
SERVICE_S3 = "s3"
|
|
35
|
+
SERVICE_SQS = "sqs"
|
|
36
|
+
SERVICE_DYNAMODB = "dynamo"
|
|
37
|
+
SERVICE_EKS = "eks"
|
|
38
|
+
SERVICE_GATEWAY = "gateway"
|
|
39
|
+
|
|
40
|
+
# AWS region code -> the city name the API uses as a region label.
|
|
41
|
+
REGION_CITY = {
|
|
42
|
+
"ap-south-1": "mumbai", "ap-southeast-1": "singapore", "ap-northeast-1": "tokyo",
|
|
43
|
+
"eu-west-1": "ireland", "eu-west-2": "london", "eu-central-1": "frankfurt",
|
|
44
|
+
"us-east-1": "virginia", "us-east-2": "ohio", "us-west-2": "oregon",
|
|
45
|
+
"ca-central-1": "canada",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_cache: dict[str, dict | None] = {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _norm(text) -> str:
|
|
52
|
+
return str(text or "").strip().casefold()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read(path: Path) -> dict | None:
|
|
56
|
+
"""The raw JSON at `path`, or None if it is missing or unreadable."""
|
|
57
|
+
try:
|
|
58
|
+
raw = json.loads(path.read_text())
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
return None
|
|
61
|
+
return raw if isinstance(raw, dict) else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _serves(raw: dict, tenant: str) -> bool:
|
|
65
|
+
"""Does this file's `_tenants` list name the tenant?
|
|
66
|
+
|
|
67
|
+
One customer can appear under more than one tenant code. The codes are
|
|
68
|
+
listed in the file itself rather than mapped in here, so every name
|
|
69
|
+
belonging to a customer stays in that customer's own data file and none
|
|
70
|
+
is compiled into the CLI.
|
|
71
|
+
"""
|
|
72
|
+
declared = raw.get("_tenants")
|
|
73
|
+
return isinstance(declared, list) and any(_norm(t) == tenant for t in declared)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _locate(tenant: str) -> dict | None:
|
|
77
|
+
"""The raw config for this tenant: its own file, else one that claims it."""
|
|
78
|
+
raw = _read(_DIR / f"{tenant}.json")
|
|
79
|
+
if raw is not None:
|
|
80
|
+
return raw
|
|
81
|
+
try:
|
|
82
|
+
candidates = sorted(_DIR.glob("*.json"))
|
|
83
|
+
except OSError:
|
|
84
|
+
return None
|
|
85
|
+
for path in candidates:
|
|
86
|
+
other = _read(path)
|
|
87
|
+
if other is not None and _serves(other, tenant):
|
|
88
|
+
return other
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load(tenant_code: str) -> dict | None:
|
|
93
|
+
"""The tenant's config, or None when it has none (then nothing is filtered)."""
|
|
94
|
+
tenant = _norm(tenant_code)
|
|
95
|
+
if tenant not in _cache:
|
|
96
|
+
raw = _locate(tenant)
|
|
97
|
+
# `_`-prefixed keys are metadata (`_tenants`, notes), never workspaces.
|
|
98
|
+
_cache[tenant] = (
|
|
99
|
+
{k: v for k, v in raw.items() if not str(k).startswith("_")} if raw else None
|
|
100
|
+
) or None
|
|
101
|
+
return _cache[tenant]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _products(config: dict) -> dict:
|
|
105
|
+
"""Collapse the workspace envelope: the CLI has no workspace selector."""
|
|
106
|
+
products: dict = {}
|
|
107
|
+
for workspace in config.values():
|
|
108
|
+
if isinstance(workspace, dict):
|
|
109
|
+
products.update(workspace)
|
|
110
|
+
return products
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _matches_region(config_region: str, row: dict) -> bool:
|
|
114
|
+
"""Does an API placement row correspond to a config region code?"""
|
|
115
|
+
label, value = _norm(row.get("region")), _norm(row.get("geo_loc_mst_code"))
|
|
116
|
+
code = _norm(config_region)
|
|
117
|
+
city = REGION_CITY.get(code, "")
|
|
118
|
+
if code and (code == label or code == value):
|
|
119
|
+
return True
|
|
120
|
+
return bool(city) and (city == label or city in value)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def allowed_regions(config: dict, product: str, environment: str, service: str) -> list[str]:
|
|
124
|
+
"""The config's region codes offering `service` for this product and environment."""
|
|
125
|
+
products = _products(config)
|
|
126
|
+
key = next((k for k in products if _norm(k) == _norm(product)), None)
|
|
127
|
+
if key is None:
|
|
128
|
+
return []
|
|
129
|
+
envs = products[key] or {}
|
|
130
|
+
env_key = next((k for k in envs if _norm(k) == _norm(environment)), None)
|
|
131
|
+
if env_key is None:
|
|
132
|
+
return []
|
|
133
|
+
return [r for r, services in (envs[env_key] or {}).items()
|
|
134
|
+
if isinstance(services, list) and service in services]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def any_regions(config: dict, product: str, environment: str) -> list[str]:
|
|
138
|
+
"""Every region the config lists for this product and environment, whatever
|
|
139
|
+
the service. This is what "does this placement exist at all" means."""
|
|
140
|
+
products = _products(config)
|
|
141
|
+
key = next((k for k in products if _norm(k) == _norm(product)), None)
|
|
142
|
+
if key is None:
|
|
143
|
+
return []
|
|
144
|
+
envs = products[key] or {}
|
|
145
|
+
env_key = next((k for k in envs if _norm(k) == _norm(environment)), None)
|
|
146
|
+
if env_key is None:
|
|
147
|
+
return []
|
|
148
|
+
return [r for r, services in (envs[env_key] or {}).items() if services]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def filter_placements(rows: list[dict], tenant_code: str, service: str | None = None) -> list[dict]:
|
|
152
|
+
"""Keep only the placement rows this tenant really has.
|
|
153
|
+
|
|
154
|
+
`rows` are the flattened placement options (product, environment, region,
|
|
155
|
+
geo_loc_mst_code). With `service`, only placements offering that service
|
|
156
|
+
survive; without one, any placement the config lists at all. Returns the
|
|
157
|
+
rows unchanged when the tenant has no config.
|
|
158
|
+
"""
|
|
159
|
+
config = load(tenant_code)
|
|
160
|
+
if not config:
|
|
161
|
+
return rows
|
|
162
|
+
kept = []
|
|
163
|
+
for row in rows:
|
|
164
|
+
product, environment = row.get("product", ""), row.get("environment", "")
|
|
165
|
+
allowed = (allowed_regions(config, product, environment, service) if service
|
|
166
|
+
else any_regions(config, product, environment))
|
|
167
|
+
if any(_matches_region(r, row) for r in allowed):
|
|
168
|
+
kept.append(row)
|
|
169
|
+
return kept
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def filter_applications(apps: list[dict], rows: list[dict], tenant_code: str) -> list[dict]:
|
|
173
|
+
"""Keep only the applications that have at least one real placement."""
|
|
174
|
+
if not load(tenant_code):
|
|
175
|
+
return apps
|
|
176
|
+
codes = {r.get("application_code") for r in filter_placements(rows, tenant_code)}
|
|
177
|
+
return [a for a in apps if a.get("application_code") in codes]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def describe(tenant_code: str, service: str | None) -> str:
|
|
181
|
+
"""One line naming what the config does offer, for an error message."""
|
|
182
|
+
config = load(tenant_code) if service else None
|
|
183
|
+
if not config:
|
|
184
|
+
return ""
|
|
185
|
+
bits = []
|
|
186
|
+
for product, envs in _products(config).items():
|
|
187
|
+
for env, regions in (envs or {}).items():
|
|
188
|
+
names = [REGION_CITY.get(_norm(r), r).title() for r, s in (regions or {}).items()
|
|
189
|
+
if isinstance(s, list) and service in s]
|
|
190
|
+
if names:
|
|
191
|
+
bits.append(f"{product}/{env}: {', '.join(sorted(names))}")
|
|
192
|
+
return "; ".join(sorted(bits))
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Names in, codes out.
|
|
2
|
+
|
|
3
|
+
Users say `--app core --env stage --region mumbai --service demo-api`; the
|
|
4
|
+
API wants `application_code`, `EnvironmentEnum`, `geo_loc_mst_code`,
|
|
5
|
+
`service_code`. Matching is case-insensitive against name AND code, exact
|
|
6
|
+
first, then unique prefix. Ambiguity is an InputError that lists the
|
|
7
|
+
candidates; nothing is ever guessed.
|
|
8
|
+
|
|
9
|
+
Catalog responses are cached per profile+backend for a few minutes so a
|
|
10
|
+
command that resolves three names does not pay three round trips every time.
|
|
11
|
+
`--no-cache` (or DEVLIFT_NO_CACHE=1) bypasses it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from devlift_cli.api import catalog
|
|
25
|
+
from devlift_cli.api.client import ApiClient
|
|
26
|
+
from devlift_cli.config import CONFIG_DIR
|
|
27
|
+
from devlift_cli.errors import EXIT_NOT_FOUND, CliError, InputError
|
|
28
|
+
|
|
29
|
+
CACHE_TTL_SECONDS = 600
|
|
30
|
+
ENVIRONMENTS = ("dev", "stage", "qa", "prod")
|
|
31
|
+
_ENV_ALIASES = {"staging": "stage", "production": "prod", "development": "dev"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Placement:
|
|
36
|
+
application_code: str
|
|
37
|
+
application_name: str
|
|
38
|
+
environment: str
|
|
39
|
+
geo_loc_mst_code: str
|
|
40
|
+
region_name: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Cache:
|
|
44
|
+
def __init__(self, profile_name: str, base_url: str, enabled: bool = True):
|
|
45
|
+
key = hashlib.sha1(f"{profile_name}|{base_url}".encode()).hexdigest()[:12]
|
|
46
|
+
self.path = CONFIG_DIR / "cache" / f"{key}.json"
|
|
47
|
+
self.enabled = enabled and not os.environ.get("DEVLIFT_NO_CACHE")
|
|
48
|
+
self._data: dict | None = None
|
|
49
|
+
|
|
50
|
+
def _load(self) -> dict:
|
|
51
|
+
if self._data is None:
|
|
52
|
+
try:
|
|
53
|
+
self._data = json.loads(self.path.read_text())
|
|
54
|
+
except (FileNotFoundError, ValueError):
|
|
55
|
+
self._data = {}
|
|
56
|
+
return self._data
|
|
57
|
+
|
|
58
|
+
def get(self, name: str, fetch: Callable[[], list]) -> list:
|
|
59
|
+
data = self._load()
|
|
60
|
+
entry = data.get(name)
|
|
61
|
+
if self.enabled and entry and time.time() - entry.get("at", 0) < CACHE_TTL_SECONDS:
|
|
62
|
+
return entry["value"]
|
|
63
|
+
value = fetch()
|
|
64
|
+
if self.enabled:
|
|
65
|
+
data[name] = {"at": time.time(), "value": value}
|
|
66
|
+
try:
|
|
67
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
tmp = self.path.with_suffix(".tmp")
|
|
69
|
+
tmp.write_text(json.dumps(data))
|
|
70
|
+
tmp.chmod(0o600)
|
|
71
|
+
tmp.replace(self.path)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
def clear(self) -> None:
|
|
77
|
+
try:
|
|
78
|
+
self.path.unlink()
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
pass
|
|
81
|
+
self._data = {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _match(items: list[dict], wanted: str, *keys: str, what: str) -> dict:
|
|
85
|
+
needle = wanted.strip().lower()
|
|
86
|
+
exact = [i for i in items if any(str(i.get(k) or "").lower() == needle for k in keys)]
|
|
87
|
+
if len(exact) == 1:
|
|
88
|
+
return exact[0]
|
|
89
|
+
if len(exact) > 1:
|
|
90
|
+
raise InputError(f"'{wanted}' matches more than one {what}: " + ", ".join(_label(i, keys) for i in exact))
|
|
91
|
+
prefix = [i for i in items if any(str(i.get(k) or "").lower().startswith(needle) for k in keys)]
|
|
92
|
+
if len(prefix) == 1:
|
|
93
|
+
return prefix[0]
|
|
94
|
+
if len(prefix) > 1:
|
|
95
|
+
raise InputError(f"'{wanted}' is ambiguous for {what}: " + ", ".join(_label(i, keys) for i in prefix))
|
|
96
|
+
known = ", ".join(sorted({str(i.get(keys[0]) or "") for i in items})) or "none"
|
|
97
|
+
raise CliError(f"No {what} named '{wanted}'. Known: {known}", EXIT_NOT_FOUND)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _label(item: dict, keys: tuple[str, ...]) -> str:
|
|
101
|
+
return str(item.get(keys[0]) or item.get(keys[-1]) or "?")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Resolver:
|
|
105
|
+
def __init__(self, api: ApiClient, profile_name: str, use_cache: bool = True):
|
|
106
|
+
self.api = api
|
|
107
|
+
self.cache = Cache(profile_name, api.profile.base_url, enabled=use_cache)
|
|
108
|
+
|
|
109
|
+
# ── raw lists (cached) ───────────────────────────────────────────────
|
|
110
|
+
def applications(self) -> list[dict]:
|
|
111
|
+
return self.cache.get("applications", lambda: catalog.list_applications(self.api))
|
|
112
|
+
|
|
113
|
+
def services(self) -> list[dict]:
|
|
114
|
+
return self.cache.get("services", lambda: catalog.list_services(self.api))
|
|
115
|
+
|
|
116
|
+
def tenant_code(self) -> str:
|
|
117
|
+
"""The signed-in tenant, which keys the placement allowlist."""
|
|
118
|
+
from devlift_cli.api.context import get_context
|
|
119
|
+
|
|
120
|
+
return self.cache.get("tenant", lambda: [get_context(self.api).tenant_code])[0]
|
|
121
|
+
|
|
122
|
+
def placements(self) -> list[dict]:
|
|
123
|
+
return self.cache.get("placements", lambda: catalog.placement_rows(catalog.placement_options(self.api)))
|
|
124
|
+
|
|
125
|
+
def resource_groups(self) -> list[dict]:
|
|
126
|
+
return self.cache.get("resource_groups", lambda: catalog.list_resource_groups(self.api))
|
|
127
|
+
|
|
128
|
+
def infrastructure_types(self) -> list[dict]:
|
|
129
|
+
return self.cache.get("infrastructure_types", lambda: catalog.list_infrastructure_types(self.api))
|
|
130
|
+
|
|
131
|
+
# ── resolutions ──────────────────────────────────────────────────────
|
|
132
|
+
def application(self, name_or_code: str) -> dict:
|
|
133
|
+
return _match(self.applications(), name_or_code, "application_name", "application_code", what="application")
|
|
134
|
+
|
|
135
|
+
def environment(self, name: str) -> str:
|
|
136
|
+
value = _ENV_ALIASES.get(name.strip().lower(), name.strip().lower())
|
|
137
|
+
if value not in ENVIRONMENTS:
|
|
138
|
+
raise InputError(f"Unknown environment '{name}'. One of: {', '.join(ENVIRONMENTS)}")
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
def service(self, name_or_code: str) -> dict:
|
|
142
|
+
return _match(self.services(), name_or_code, "service_name", "service_code", what="service")
|
|
143
|
+
|
|
144
|
+
def resource_group(self, name_or_code: str, application_code: str | None = None) -> dict:
|
|
145
|
+
groups = self.resource_groups()
|
|
146
|
+
if application_code:
|
|
147
|
+
groups = [g for g in groups if g.get("applications_mst_code") == application_code] or groups
|
|
148
|
+
return _match(groups, name_or_code, "name", "code", what="resource group")
|
|
149
|
+
|
|
150
|
+
def infrastructure_type(self, name_or_code: str) -> dict:
|
|
151
|
+
return _match(self.infrastructure_types(), name_or_code, "name", "code", what="resource type")
|
|
152
|
+
|
|
153
|
+
def placement(self, app: str, env: str, region: str) -> Placement:
|
|
154
|
+
"""The one (product, environment, region) row the user means, or why not."""
|
|
155
|
+
application = self.application(app)
|
|
156
|
+
environment = self.environment(env)
|
|
157
|
+
rows = [r for r in self.placements() if r["application_code"] == application["application_code"]]
|
|
158
|
+
if not rows:
|
|
159
|
+
raise CliError(f"Application '{application['application_name']}' has no placements configured.", EXIT_NOT_FOUND)
|
|
160
|
+
in_env = [r for r in rows if r["environment"] == environment]
|
|
161
|
+
if not in_env:
|
|
162
|
+
envs = ", ".join(sorted({r["environment"] for r in rows}))
|
|
163
|
+
raise InputError(f"'{application['application_name']}' is not set up for {environment}. Environments: {envs}")
|
|
164
|
+
row = _match(in_env, region, "region", "geo_loc_mst_code", what=f"region for {application['application_name']}/{environment}")
|
|
165
|
+
return Placement(
|
|
166
|
+
application_code=application["application_code"],
|
|
167
|
+
application_name=application["application_name"],
|
|
168
|
+
environment=environment,
|
|
169
|
+
geo_loc_mst_code=row["geo_loc_mst_code"],
|
|
170
|
+
region_name=row["region"],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def regions_for(self, application_code: str | None = None, environment: str | None = None) -> list[dict]:
|
|
174
|
+
rows = self.placements()
|
|
175
|
+
if application_code:
|
|
176
|
+
rows = [r for r in rows if r["application_code"] == application_code]
|
|
177
|
+
if environment:
|
|
178
|
+
rows = [r for r in rows if r["environment"] == environment]
|
|
179
|
+
return rows
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devlift-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DevLift command-line tool: create, change, review and deploy DevLift resources from the terminal.
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Keywords: devlift,devops,deployment,infrastructure,cli
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: System Administrators
|
|
11
|
+
Classifier: License :: Other/Proprietary License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: typer>=0.15
|
|
23
|
+
Requires-Dist: rich>=13
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: keyring>=25
|
|
26
|
+
Requires-Dist: pydantic>=2
|
|
27
|
+
Requires-Dist: pyyaml>=6
|
|
28
|
+
|
|
29
|
+
# devlift-cli
|
|
30
|
+
|
|
31
|
+
DevLift from the terminal, in the style of the AWS CLI:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
devlift <group> <operation> [flags]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Every command is a fixed sequence of calls to the DevLift backend's REST API,
|
|
38
|
+
the same endpoints the web dashboard uses. Nothing here talks to an LLM.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
pip install devlift-cli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or, to keep it in its own environment and off your system Python:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pipx install devlift-cli
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Upgrade with `pip install -U devlift-cli` (or `pipx upgrade devlift-cli`).
|
|
53
|
+
Your profile and sign-in live in `~/.config/devlift` and survive upgrades.
|
|
54
|
+
|
|
55
|
+
Then:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
devlift configure --base-url <your DevLift backend> --default
|
|
59
|
+
devlift login
|
|
60
|
+
devlift whoami
|
|
61
|
+
devlift man
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requires Python 3.11 or newer. To remove it: `pip uninstall devlift-cli`
|
|
65
|
+
(or `pipx uninstall devlift-cli`). That leaves `~/.config/devlift`, so delete
|
|
66
|
+
that too if you want your profiles and sign-in gone as well.
|
|
67
|
+
|
|
68
|
+
Several backends? `devlift profile list`, `devlift profile use <name>` and
|
|
69
|
+
`devlift profile show` switch between them and say which one is active.
|
|
70
|
+
|
|
71
|
+
## Manual
|
|
72
|
+
|
|
73
|
+
The full command reference ships inside the package: `devlift man` pages
|
|
74
|
+
through it like `man`, and `devlift manual s3` prints one section.
|
|
75
|
+
|
|
76
|
+
## What it covers
|
|
77
|
+
|
|
78
|
+
- Resources: `s3`, `sqs`, `dynamodb` create / list / describe, `s3` and `sqs` update.
|
|
79
|
+
- Services: `eks create / edit / show / diff / deploy / status`, `kong route add / remove / rename`, `kong plugin add`.
|
|
80
|
+
- Review lane: `request submit / withdraw / discard / list / show`, `approval approve / request-changes / reject / revoke / list`.
|
|
81
|
+
- Deployments: `deployment list / status`, `queue list`.
|
|
82
|
+
- Discovery: applications, environments, regions, resource groups, services, repositories, branches, languages.
|
|
83
|
+
|
|
84
|
+
Variables and secrets stay in the dashboard by design.
|
|
85
|
+
|
|
86
|
+
## Scripting
|
|
87
|
+
|
|
88
|
+
- `-o json` prints only JSON on stdout (the default when stdout is not a terminal).
|
|
89
|
+
- `--yes` answers confirmations, `--no-input` fails instead of prompting.
|
|
90
|
+
- `DEVLIFT_TOKEN` skips the stored login. `DEVLIFT_PROFILE`, `DEVLIFT_BASE_URL` override the profile.
|
|
91
|
+
- Exit codes: 0 ok · 1 error · 2 not signed in · 3 bad input · 4 permission · 5 confirmation needed · 6 not found · 7 conflict.
|
|
92
|
+
|
|
93
|
+
## Claude skill
|
|
94
|
+
|
|
95
|
+
`skills/devlift/` teaches Claude (Claude Code or any Agent Skills host) to
|
|
96
|
+
drive this CLI non-interactively. Install with:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
ln -s "$(pwd)/skills/devlift" ~/.claude/skills/devlift # personal
|
|
100
|
+
# or copy it into a project's .claude/skills/devlift
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
Building, testing and cutting a release: see `CONTRIBUTING.md` in the source
|
|
106
|
+
repository. It is deliberately not part of the published package.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
devlift_cli/MANUAL.md,sha256=D3GEU-Kqll9ePcLnkUVdcr7bMvc39YPLov32VnSQthw,47823
|
|
2
|
+
devlift_cli/__init__.py,sha256=ZGNQ5xyfg1UJc1q5_m9CSn-S5WuOV2Bezi-hUmuvTgA,56
|
|
3
|
+
devlift_cli/__main__.py,sha256=Tmx64_6dPMuxUtDmEC3FOa3KQbW-agv1fwAUkUtHhaY,72
|
|
4
|
+
devlift_cli/app.py,sha256=KBvRt-_05-Q-r3HnZA9IH9KKepsWLXXqVZpQSadx-Ik,6962
|
|
5
|
+
devlift_cli/config.py,sha256=sfcJI24GvT-oq-WcKnaFhAbQcwoYreXUilIzOvx2rRA,2958
|
|
6
|
+
devlift_cli/context.py,sha256=xXICnB0sVOaT5W_hWs2DgW4SrrL0wvQk6P0aUTz92Js,3857
|
|
7
|
+
devlift_cli/errors.py,sha256=_wo50KhVsN7smvVc4n7MNNwnXtlxpKClWT4ld9k1NY0,1620
|
|
8
|
+
devlift_cli/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
devlift_cli/api/approvals.py,sha256=yuYBDCf62wrHq1o7cMWl5QcT90-PLJqYNhK8oX-ixvw,2413
|
|
10
|
+
devlift_cli/api/catalog.py,sha256=jcAKUFMPtZjqvaHMzXn_aFFOGwI9Tx-0iUBBL_5N9uk,3791
|
|
11
|
+
devlift_cli/api/client.py,sha256=FTVVzsU-BA3xXiVweil4FAFl1hxcGJzBIEnINqFFvkU,4777
|
|
12
|
+
devlift_cli/api/context.py,sha256=3m9DOz08H4DtUwHHqALfrnL5vtVLmT0XY6ox7tTtivE,542
|
|
13
|
+
devlift_cli/api/deployments.py,sha256=F7Lqh0767wv-oiMHpBBEPhSYjKjJ5MfC7dz1cA9nL_8,1442
|
|
14
|
+
devlift_cli/api/infra.py,sha256=FxPYWlH17NVHT868oKae2opb7gG4Xlub4gitehJAl0A,3553
|
|
15
|
+
devlift_cli/api/infra_list.py,sha256=-XAQKvHeh_Ux57MrAyd_eFHc9FJIJWFs23Jt153EA2A,1955
|
|
16
|
+
devlift_cli/api/kong.py,sha256=1m8XOiWdsASecP7ubrmzaBH4VAAhdwD3ZpqLjzVriYU,1358
|
|
17
|
+
devlift_cli/api/services.py,sha256=yiZaRHwKNkh1Axm-UQQGZFM1Mxt703vy2VXceG1bDo0,3593
|
|
18
|
+
devlift_cli/api/vpc.py,sha256=Cj4JLa1Cmcibckr9RP7qN6gLMLKgc67dG2vTGY_wIjs,1050
|
|
19
|
+
devlift_cli/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
devlift_cli/auth/oauth.py,sha256=xbyqpE0Yq3YS9Z1UTJXMqLJ3Dnhm-5pPqPX1AMF75Mc,10475
|
|
21
|
+
devlift_cli/auth/session.py,sha256=g25N6nVUOBBsLUmETIQPcfo4kmqD9dhXGH22__EDWHI,2342
|
|
22
|
+
devlift_cli/auth/storage.py,sha256=fKnWEdZ80dUQwmuQdYlgcyIH8xttYQilfvR9P_sVvdg,3594
|
|
23
|
+
devlift_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
devlift_cli/commands/approval.py,sha256=ZbwJ1XnYmZ4ZqSASlqCHDUTR4G0H-vm4Q5EkUAyarq4,2889
|
|
25
|
+
devlift_cli/commands/auth.py,sha256=RZfzC0rku3Gnun-j34eUzlUPVCjCxQeIaG6SG756VVI,7684
|
|
26
|
+
devlift_cli/commands/catalog.py,sha256=JaUDqER81fMhhWmOrKIVdLEevsrr4KdDgAS825W2bFY,10075
|
|
27
|
+
devlift_cli/commands/clusters.py,sha256=Z9NMeeR-M5LVz9qGZau_QsWUqHX5WRnmLaacN99FnW0,4944
|
|
28
|
+
devlift_cli/commands/deployment.py,sha256=-H-2NN_kRZZCYsTLNKKOaAUvL0y11dDJ3fNUkUMhOy4,3299
|
|
29
|
+
devlift_cli/commands/dynamodb.py,sha256=p2WJGCPR9Gwg52o3C1kmYWVcCCfauTHzsdpzVOckRao,5517
|
|
30
|
+
devlift_cli/commands/eks.py,sha256=hZkyv72iKDzdbTqzAHCwS4rmDFfO2o-yMG5PI2X-Fiw,18129
|
|
31
|
+
devlift_cli/commands/kong.py,sha256=aP1NbJu_Z5sO3-rdRdVM9G45sYlltR1li9jvbDAomD4,6671
|
|
32
|
+
devlift_cli/commands/languages.py,sha256=H5fBgeFOBJBDxmnj31K2bJ5q9KQM-cPPLcpb4_lpY3U,1507
|
|
33
|
+
devlift_cli/commands/manual.py,sha256=iy6vld-xMq86GCnr-Wmi__WWHuXFuxZg65zeltZgj1Q,3220
|
|
34
|
+
devlift_cli/commands/repositories.py,sha256=LuEDc8093y1A6FybexJi_9GaTL_iAjYOM_Wg25zYSNc,1862
|
|
35
|
+
devlift_cli/commands/request.py,sha256=ZSqv4oobqyPADFc62AgzYkuTSzZHm7-xVpWC5zv0JVA,4533
|
|
36
|
+
devlift_cli/commands/s3.py,sha256=h_i-jitna6bXd06XgoviSeMb3p5oPGUcKgqiTjxr_WM,9163
|
|
37
|
+
devlift_cli/commands/sqs.py,sha256=wAPxNM9mBYANywahHtMAE7CeLJxWOBYPMjYUTEjZt2Y,10944
|
|
38
|
+
devlift_cli/data/placement/vance.json,sha256=e9yIZkcTiJwa-oH_KZ9lfMD2Xum9cM6oKhighHEqsUg,330
|
|
39
|
+
devlift_cli/ops/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
40
|
+
devlift_cli/ops/approvals.py,sha256=XjjbjSor3MmSp8sUO4OsLvkOsYk2WQ2WdVIYMk9fzAc,17406
|
|
41
|
+
devlift_cli/ops/eks.py,sha256=tW8gq2f8c6WOnhvK6dDZyam05UnVbxWvGZNe1p9eIsI,43808
|
|
42
|
+
devlift_cli/ops/kong.py,sha256=DIcNB8-3BCkO5vog4aYOsTulqpKHMgtltW-9LD_ydHg,16652
|
|
43
|
+
devlift_cli/ops/placement.py,sha256=CWkinGOSNJ3opgwYqeBbvX-FYVKyqBX08VL3fn_5QlQ,5900
|
|
44
|
+
devlift_cli/ops/resources.py,sha256=Te_MGia6a9qEHxUTZbwoSjFe06bxclWhaYWhfvT6Ry8,18171
|
|
45
|
+
devlift_cli/ops/status.py,sha256=l1AahuXHw1k7TZUI9SI8ilHibAe0wMqwJIFnkqSfLb8,7211
|
|
46
|
+
devlift_cli/ops/wait.py,sha256=Q0c1XITipLUAWxxG306kIAuasNiAPr_QGNElMnVQmdo,3493
|
|
47
|
+
devlift_cli/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
48
|
+
devlift_cli/render/output.py,sha256=NlRFHfEvvV1gdktl6JbP-jDKFNqSxnpo1V4ze56jahc,2339
|
|
49
|
+
devlift_cli/resolve/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
50
|
+
devlift_cli/resolve/allowlist.py,sha256=wlnLOoY6g7G5UDC3ZRJVkpSzyXBAQ-G1XL-_7BV88uI,7534
|
|
51
|
+
devlift_cli/resolve/names.py,sha256=xHOiYHnDcLS22SLw0CtmRihqKc97IdsZFqwzta4ywpI,7849
|
|
52
|
+
devlift_cli-0.1.0.dist-info/METADATA,sha256=RbQDUkTXTii8Znqt--4EFNtWyEAgw2zHxCzPkQy-nLk,3696
|
|
53
|
+
devlift_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
54
|
+
devlift_cli-0.1.0.dist-info/entry_points.txt,sha256=QRD_aXcdHOAbbsh7j5Z6WYo0dnnw0XQyv68hJcq70IM,75
|
|
55
|
+
devlift_cli-0.1.0.dist-info/top_level.txt,sha256=uabaGJ5Bj1mSyc99HXldIoegI_rzU2S3-pNtwN7_iSk,12
|
|
56
|
+
devlift_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devlift_cli
|