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/ops/kong.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""`devlift kong …`: read a service's gateway and park a route change as a draft.
|
|
2
|
+
|
|
3
|
+
A gateway change is one *route group* (a tag for one HTTP method) with its
|
|
4
|
+
desired end state plus a `delta` naming what moved. That is what the web's
|
|
5
|
+
Gateway tab sends on Save and what the MCP's edit path builds; this module
|
|
6
|
+
does the same arithmetic against the current state, then:
|
|
7
|
+
|
|
8
|
+
GET /kong-route-configs/gateway/by-config/{config} the groups as they stand
|
|
9
|
+
POST /transaction/kong-gateway/{config} one GatewayGroupSave → DRAFT queue row
|
|
10
|
+
|
|
11
|
+
Nothing is submitted, approved or deployed here. The draft shares the
|
|
12
|
+
service's change set with any settings draft, and the review lane takes it
|
|
13
|
+
from there.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from devlift_cli.api import kong as kong_api
|
|
22
|
+
from devlift_cli.context import Invocation
|
|
23
|
+
from devlift_cli.errors import EXIT_CONFLICT, EXIT_NOT_FOUND, CliError, InputError
|
|
24
|
+
from devlift_cli.render import output
|
|
25
|
+
from devlift_cli.render.output import kv_table, rows_table
|
|
26
|
+
from devlift_cli.resolve.names import Resolver
|
|
27
|
+
|
|
28
|
+
METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
|
29
|
+
JWT_PLUGIN = "JWT"
|
|
30
|
+
PLUGINS = ("User ID Injection",) # the Gateway tab's extra-plugin choices
|
|
31
|
+
AUTH = {"jwt": True, "auth": True, "secured": True, "public": False, "none": False, "open": False}
|
|
32
|
+
|
|
33
|
+
# The platform form's own rules (kong_route_form.json).
|
|
34
|
+
_PATH_RE = re.compile(r"^~/(?!.*(?:/:[A-Za-z_]|\{[A-Za-z_][A-Za-z0-9_]*\}|/\*))\S*\$$")
|
|
35
|
+
_TAG_RE = re.compile(r"^[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$")
|
|
36
|
+
_PRIORITY_RANGE = (0, 10000)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_path(path: str) -> str:
|
|
40
|
+
p = path.strip()
|
|
41
|
+
if not _PATH_RE.match(p):
|
|
42
|
+
raise InputError(
|
|
43
|
+
f"'{path}' is not a Kong regex path.",
|
|
44
|
+
hint="A path starts with '~/' and ends with '$', no spaces, e.g. '~/api/v1/orders$'. "
|
|
45
|
+
"Parameters are regex groups — '~/api/v1/users/(?<id>[^/]+)$', not '/:id' or '{id}'.",
|
|
46
|
+
)
|
|
47
|
+
return p
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_tag(tag: str) -> str:
|
|
51
|
+
t = tag.strip()
|
|
52
|
+
if not _TAG_RE.match(t):
|
|
53
|
+
raise InputError(f"Tag '{tag}' is invalid.", hint="Letters, numbers, underscores and single hyphens, e.g. orders-open.")
|
|
54
|
+
return t
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalise_plugin(name: str) -> str:
|
|
58
|
+
wanted = name.strip().lower().replace("-", " ").replace("_", " ")
|
|
59
|
+
for p in PLUGINS:
|
|
60
|
+
if p.lower() == wanted:
|
|
61
|
+
return p
|
|
62
|
+
raise InputError(f"Unknown plugin '{name}'.", hint="One of: " + ", ".join(PLUGINS) + ". JWT is set by --auth, not --plugin.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def default_route_group_key(service_name: str, secured: bool, existing_keys: list[str]) -> str:
|
|
66
|
+
"""The Gateway tab's rule: the plain service name for the service's first
|
|
67
|
+
group, then the plain name for secured cards and '<service>-open' for
|
|
68
|
+
public ones."""
|
|
69
|
+
if service_name not in existing_keys:
|
|
70
|
+
return service_name
|
|
71
|
+
return service_name if secured else f"{service_name}-open"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── where the gateway lives ──────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class GatewayTarget:
|
|
78
|
+
service_code: str
|
|
79
|
+
service_name: str
|
|
80
|
+
config_code: str
|
|
81
|
+
environment: str
|
|
82
|
+
region_name: str
|
|
83
|
+
geo_loc_mst_code: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_target(inv: Invocation, res: Resolver, service: str, env: str | None, region: str | None) -> GatewayTarget:
|
|
87
|
+
"""The service configuration (one environment + region) whose gateway is meant."""
|
|
88
|
+
row = res.service(service)
|
|
89
|
+
options = kong_api.env_geo_options(inv.api, row["service_code"])
|
|
90
|
+
if not options:
|
|
91
|
+
raise CliError(f"Service '{row['service_name']}' has no configuration in any environment yet.", EXIT_NOT_FOUND,
|
|
92
|
+
hint="Create one with `devlift eks create` or from the dashboard.")
|
|
93
|
+
if env is None:
|
|
94
|
+
envs = sorted({o["environment"] for o in options})
|
|
95
|
+
env = envs[0] if len(envs) == 1 else inv.ask("Environment", choices=envs, flag="--env")
|
|
96
|
+
environment = res.environment(env)
|
|
97
|
+
in_env = [o for o in options if o["environment"] == environment]
|
|
98
|
+
if not in_env:
|
|
99
|
+
raise CliError(f"'{row['service_name']}' is not configured in {environment}. Environments: "
|
|
100
|
+
+ ", ".join(sorted({o['environment'] for o in options})) + ".", EXIT_NOT_FOUND)
|
|
101
|
+
if region:
|
|
102
|
+
w = region.strip().lower()
|
|
103
|
+
in_env = [o for o in in_env if w in (str(o.get("geo_loc_name", "")).lower(), str(o.get("geo_loc_code", "")).lower())]
|
|
104
|
+
if not in_env:
|
|
105
|
+
raise CliError(f"'{row['service_name']}' has no {environment} configuration in region '{region}'.", EXIT_NOT_FOUND)
|
|
106
|
+
if len(in_env) > 1:
|
|
107
|
+
names = [o.get("geo_loc_name") or o["geo_loc_code"] for o in in_env]
|
|
108
|
+
pick = inv.ask("Region", choices=names, flag="--region")
|
|
109
|
+
in_env = [in_env[names.index(pick)]]
|
|
110
|
+
opt = in_env[0]
|
|
111
|
+
return GatewayTarget(
|
|
112
|
+
service_code=row["service_code"], service_name=row["service_name"], config_code=opt["config_code"],
|
|
113
|
+
environment=environment, region_name=opt.get("geo_loc_name") or opt["geo_loc_code"], geo_loc_mst_code=opt["geo_loc_code"],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── reading ──────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def route_cards(state: dict) -> list[dict]:
|
|
120
|
+
"""The gateway as the tab shows it: one row per group, ordered by method."""
|
|
121
|
+
order = {m: i for i, m in enumerate(METHODS)}
|
|
122
|
+
cards = []
|
|
123
|
+
for g in state.get("groups") or []:
|
|
124
|
+
plugins = list(g.get("plugins") or [])
|
|
125
|
+
pending = []
|
|
126
|
+
for p in g.get("pending") or []:
|
|
127
|
+
delta = p.get("delta") or {}
|
|
128
|
+
pending.append({
|
|
129
|
+
"queue_code": p.get("queue_code"), "status": p.get("status"), "mine": bool(p.get("is_mine")),
|
|
130
|
+
"added": [a.get("route_path") for a in (delta.get("paths") or []) if a.get("action") == "add"],
|
|
131
|
+
"removed": [a.get("route_path") for a in (delta.get("paths") or []) if a.get("action") == "delete"],
|
|
132
|
+
"renamed": [(a.get("old_path"), a.get("route_path")) for a in (delta.get("paths") or []) if a.get("action") == "edit"],
|
|
133
|
+
})
|
|
134
|
+
cards.append({
|
|
135
|
+
"method": (g.get("http_method") or "").upper(),
|
|
136
|
+
"auth": "jwt" if JWT_PLUGIN in plugins else "public",
|
|
137
|
+
"tag": g.get("route_group_key"),
|
|
138
|
+
"plugins": [p for p in plugins if p != JWT_PLUGIN],
|
|
139
|
+
"priority": g.get("regex_priority") or 0,
|
|
140
|
+
"paths": [{"path": p.get("route_path"), "deployed": bool(p.get("code"))} for p in g.get("paths") or []],
|
|
141
|
+
"pending": pending,
|
|
142
|
+
})
|
|
143
|
+
cards.sort(key=lambda c: (order.get(c["method"], 9), c["auth"] != "jwt", c["tag"] or ""))
|
|
144
|
+
return cards
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── the change ───────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class RouteChange:
|
|
151
|
+
"""What the user asked for on one route group."""
|
|
152
|
+
method: str
|
|
153
|
+
secured: bool | None = None # None = keep the group's auth (remove / rename / plugin)
|
|
154
|
+
tag: str | None = None
|
|
155
|
+
add: list[str] = field(default_factory=list)
|
|
156
|
+
remove: list[str] = field(default_factory=list)
|
|
157
|
+
rename: list[tuple[str, str]] = field(default_factory=list)
|
|
158
|
+
plugins: list[str] = field(default_factory=list)
|
|
159
|
+
priority: int | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _find_group(state: dict, tag: str, method: str) -> dict | None:
|
|
163
|
+
for g in state.get("groups") or []:
|
|
164
|
+
if (g.get("route_group_key") or "") == tag and (g.get("http_method") or "").upper() == method:
|
|
165
|
+
return g
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_group_save(change: RouteChange, state: dict) -> tuple[dict | None, dict]:
|
|
170
|
+
"""One GatewayGroupSave for the transaction route, plus a summary.
|
|
171
|
+
(None, summary) when nothing would move. Same arithmetic as the web's
|
|
172
|
+
buildPayload and the MCP's build_gateway_group_save."""
|
|
173
|
+
method = change.method
|
|
174
|
+
service_name = state.get("service_name") or ""
|
|
175
|
+
existing_keys = [g.get("route_group_key") or "" for g in state.get("groups") or []]
|
|
176
|
+
|
|
177
|
+
if change.tag:
|
|
178
|
+
tag = change.tag
|
|
179
|
+
existing = _find_group(state, tag, method)
|
|
180
|
+
else:
|
|
181
|
+
if change.secured is None:
|
|
182
|
+
raise InputError("--tag is required for this change.", hint="`devlift kong routes list` shows the tags.")
|
|
183
|
+
tag = default_route_group_key(service_name, change.secured, existing_keys)
|
|
184
|
+
existing = _find_group(state, tag, method)
|
|
185
|
+
existing_plugins = list((existing or {}).get("plugins") or [])
|
|
186
|
+
existing_secured = JWT_PLUGIN in existing_plugins
|
|
187
|
+
|
|
188
|
+
if existing is None and change.secured is None:
|
|
189
|
+
raise CliError(f"There is no route group '{tag}' for {method} on {service_name}.", EXIT_NOT_FOUND,
|
|
190
|
+
hint=f"`devlift kong routes list {service_name}` shows the groups.")
|
|
191
|
+
if existing is not None and change.secured is not None and existing_secured != change.secured:
|
|
192
|
+
kind = "an Auth (JWT)" if existing_secured else "a No Auth (public)"
|
|
193
|
+
raise CliError(
|
|
194
|
+
f"Tag '{tag}' is already {kind} group for {method} on {service_name}. A tag is one group per method: "
|
|
195
|
+
f"pick another --tag or the matching --auth.", EXIT_CONFLICT,
|
|
196
|
+
)
|
|
197
|
+
secured = change.secured if change.secured is not None else existing_secured
|
|
198
|
+
|
|
199
|
+
plugins_after = [p for p in existing_plugins if p != JWT_PLUGIN]
|
|
200
|
+
for p in change.plugins:
|
|
201
|
+
if p not in plugins_after:
|
|
202
|
+
plugins_after.append(p)
|
|
203
|
+
if secured:
|
|
204
|
+
plugins_after.insert(0, JWT_PLUGIN)
|
|
205
|
+
priority_before = int((existing or {}).get("regex_priority") or 0)
|
|
206
|
+
priority_after = change.priority if change.priority is not None else priority_before
|
|
207
|
+
|
|
208
|
+
paths = [{"code": p.get("code"), "route_path": p.get("route_path")} for p in (existing or {}).get("paths") or [] if p.get("route_path")]
|
|
209
|
+
by_path = {p["route_path"]: p for p in paths}
|
|
210
|
+
|
|
211
|
+
edited, missing = [], []
|
|
212
|
+
for old, new in change.rename:
|
|
213
|
+
row = by_path.get(old)
|
|
214
|
+
if row is None:
|
|
215
|
+
missing.append(old)
|
|
216
|
+
continue
|
|
217
|
+
row["route_path"] = new # same code — the row keeps its identity
|
|
218
|
+
by_path.pop(old); by_path[new] = row
|
|
219
|
+
edited.append({"code": row.get("code"), "old_path": old, "route_path": new})
|
|
220
|
+
removed = []
|
|
221
|
+
for path in change.remove:
|
|
222
|
+
row = by_path.get(path)
|
|
223
|
+
if row is None:
|
|
224
|
+
missing.append(path)
|
|
225
|
+
continue
|
|
226
|
+
paths = [p for p in paths if p is not row]
|
|
227
|
+
by_path.pop(path)
|
|
228
|
+
removed.append({"code": row.get("code"), "route_path": path})
|
|
229
|
+
if missing:
|
|
230
|
+
raise CliError(
|
|
231
|
+
f"Not on route group '{tag} · {method}': {', '.join(missing)}. Its paths are: "
|
|
232
|
+
+ (", ".join(sorted(by_path)) or "(none)") + ".", EXIT_NOT_FOUND,
|
|
233
|
+
)
|
|
234
|
+
added, already = [], []
|
|
235
|
+
for path in change.add:
|
|
236
|
+
if path in by_path or path in added:
|
|
237
|
+
already.append(path)
|
|
238
|
+
else:
|
|
239
|
+
added.append(path)
|
|
240
|
+
|
|
241
|
+
summary = {
|
|
242
|
+
"tag": tag, "method": method, "auth": "jwt" if secured else "public", "new_group": existing is None,
|
|
243
|
+
"added": added, "removed": [r["route_path"] for r in removed],
|
|
244
|
+
"renamed": [(e["old_path"], e["route_path"]) for e in edited], "already_present": already,
|
|
245
|
+
"plugins": {"from": existing_plugins, "to": plugins_after},
|
|
246
|
+
"priority": {"from": priority_before, "to": priority_after},
|
|
247
|
+
}
|
|
248
|
+
nothing_moved = (
|
|
249
|
+
not added and not removed and not edited and existing is not None
|
|
250
|
+
and sorted(existing_plugins) == sorted(plugins_after) and priority_before == priority_after
|
|
251
|
+
)
|
|
252
|
+
if nothing_moved:
|
|
253
|
+
return None, summary
|
|
254
|
+
|
|
255
|
+
save = {
|
|
256
|
+
"code": (existing or {}).get("code"),
|
|
257
|
+
"route_group_key": tag,
|
|
258
|
+
"http_method": method,
|
|
259
|
+
"updated_at": (existing or {}).get("updated_at"), # the concurrency token the server locks on
|
|
260
|
+
"plugins": plugins_after,
|
|
261
|
+
"regex_priority": priority_after,
|
|
262
|
+
"paths": paths + [{"code": None, "route_path": p} for p in added],
|
|
263
|
+
"delta": {
|
|
264
|
+
"paths": [
|
|
265
|
+
*({"action": "edit", "code": e["code"], "route_path": e["route_path"], "old_path": e["old_path"]} for e in edited),
|
|
266
|
+
*({"action": "delete", "code": r["code"], "route_path": r["route_path"]} for r in removed),
|
|
267
|
+
*({"action": "add", "code": None, "route_path": p} for p in added),
|
|
268
|
+
],
|
|
269
|
+
"plugins_before": existing_plugins,
|
|
270
|
+
"plugins_after": plugins_after,
|
|
271
|
+
"regex_priority_before": priority_before,
|
|
272
|
+
"regex_priority_after": priority_after,
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
return save, summary
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass
|
|
279
|
+
class GatewayResult:
|
|
280
|
+
service_name: str
|
|
281
|
+
config_code: str
|
|
282
|
+
environment: str
|
|
283
|
+
region: str
|
|
284
|
+
changed: bool
|
|
285
|
+
summary: dict
|
|
286
|
+
queue_code: str | None = None
|
|
287
|
+
queue_status: str | None = None
|
|
288
|
+
|
|
289
|
+
def to_dict(self) -> dict:
|
|
290
|
+
return {
|
|
291
|
+
"service": self.service_name, "service_config_code": self.config_code,
|
|
292
|
+
"environment": self.environment, "region": self.region, "changed": self.changed,
|
|
293
|
+
"change": self.summary, "queue_code": self.queue_code, "queue_status": self.queue_status,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def apply_change(inv: Invocation, target: GatewayTarget, change: RouteChange) -> GatewayResult:
|
|
298
|
+
"""Read the gateway, compute the group save, show it, confirm, park it as a draft."""
|
|
299
|
+
state = kong_api.gateway_state(inv.api, target.config_code)
|
|
300
|
+
save, summary = build_group_save(change, state)
|
|
301
|
+
if save is None:
|
|
302
|
+
note = f" ({', '.join(summary['already_present'])} already on it)" if summary["already_present"] else ""
|
|
303
|
+
output.info(f"Nothing to change on '{summary['tag']} · {summary['method']}'{note}.")
|
|
304
|
+
return GatewayResult(target.service_name, target.config_code, target.environment, target.region_name, False, summary)
|
|
305
|
+
|
|
306
|
+
if inv.show_summary:
|
|
307
|
+
rows = [
|
|
308
|
+
("Service", f"{target.service_name} ({target.environment} / {target.region_name})"),
|
|
309
|
+
("Route group", f"{summary['tag']} · {summary['method']}" + (" (new group)" if summary["new_group"] else "")),
|
|
310
|
+
("Auth", "JWT required" if summary["auth"] == "jwt" else "public"),
|
|
311
|
+
]
|
|
312
|
+
rows += [("Add path", p) for p in summary["added"]]
|
|
313
|
+
rows += [("Remove path", p) for p in summary["removed"]]
|
|
314
|
+
rows += [("Rename path", f"{a} → {b}") for a, b in summary["renamed"]]
|
|
315
|
+
if summary["already_present"]:
|
|
316
|
+
rows.append(("Already present", ", ".join(summary["already_present"])))
|
|
317
|
+
if sorted(summary["plugins"]["from"]) != sorted(summary["plugins"]["to"]):
|
|
318
|
+
rows.append(("Plugins", f"{', '.join(summary['plugins']['from']) or '–'} → {', '.join(summary['plugins']['to']) or '–'}"))
|
|
319
|
+
if summary["priority"]["from"] != summary["priority"]["to"]:
|
|
320
|
+
rows.append(("Priority", f"{summary['priority']['from']} → {summary['priority']['to']}"))
|
|
321
|
+
output.err_console.print(kv_table(rows, title="Gateway change"))
|
|
322
|
+
inv.confirm("Save this route change as a draft for review?")
|
|
323
|
+
|
|
324
|
+
draft = kong_api.save_gateway_draft(inv.api, target.config_code, [save])
|
|
325
|
+
approval = draft.get("approval") or {}
|
|
326
|
+
queue_code, queue_status = approval.get("code"), approval.get("status")
|
|
327
|
+
output.info(f"Route change saved as draft {queue_code or ''} ({queue_status or 'draft'}). "
|
|
328
|
+
f"Nothing reaches Kong until it is submitted, approved and deployed.")
|
|
329
|
+
return GatewayResult(target.service_name, target.config_code, target.environment, target.region_name, True, summary,
|
|
330
|
+
queue_code=queue_code, queue_status=queue_status)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def routes_table(cards: list[dict], title: str):
|
|
334
|
+
rows = []
|
|
335
|
+
for c in cards:
|
|
336
|
+
pend = "; ".join(
|
|
337
|
+
f"{p['status']}: " + ", ".join([f"+{a}" for a in p["added"]] + [f"-{r}" for r in p["removed"]] + [f"{a}→{b}" for a, b in p["renamed"]])
|
|
338
|
+
for p in c["pending"]
|
|
339
|
+
)
|
|
340
|
+
rows.append((c["method"], "JWT" if c["auth"] == "jwt" else "public", c["tag"],
|
|
341
|
+
"\n".join(p["path"] + ("" if p["deployed"] else " (not deployed)") for p in c["paths"]) or "–",
|
|
342
|
+
c["priority"], ", ".join(c["plugins"]) or "–", pend or "–"))
|
|
343
|
+
return rows_table(["Method", "Auth", "Tag", "Paths", "Priority", "Plugins", "Pending"], rows, title=title)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Turn --app / --env / --region flags (any of them missing) into a Placement,
|
|
2
|
+
prompting on a terminal and failing cleanly under --no-input.
|
|
3
|
+
|
|
4
|
+
Plus the second half of placement, the one the dashboard cares about: which
|
|
5
|
+
AWS account and cloud region the resource sits in on the canvas.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from devlift_cli.api import vpc
|
|
11
|
+
from devlift_cli.context import Invocation
|
|
12
|
+
from devlift_cli.errors import CliError, InputError
|
|
13
|
+
from devlift_cli.render import output
|
|
14
|
+
from devlift_cli.resolve import allowlist
|
|
15
|
+
from devlift_cli.resolve.names import ENVIRONMENTS, Placement, Resolver
|
|
16
|
+
|
|
17
|
+
# Only a tiebreaker: used when one account owns several cloud regions, which
|
|
18
|
+
# the per-geo account layout normally prevents. Mirrors the backend's
|
|
19
|
+
# `_GEO_TO_AWS_REGION` (devlift_mcp/dispatcher.py) and the web's
|
|
20
|
+
# `resolveRegionFromGeoCode`.
|
|
21
|
+
_GEO_TO_AWS_REGION = {
|
|
22
|
+
"mumbai": "ap-south-1", "london": "eu-west-2", "uk": "eu-west-2", "us": "us-east-1",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _region_for_geo(geo_loc_mst_code: str) -> str | None:
|
|
27
|
+
"""`region-<tenant>-mumbai` → `ap-south-1`, by the shared naming convention."""
|
|
28
|
+
parts = geo_loc_mst_code.lower().split("-")
|
|
29
|
+
suffix = "-".join(parts[2:]) if len(parts) >= 3 and parts[0] == "region" else parts[-1]
|
|
30
|
+
return _GEO_TO_AWS_REGION.get(suffix) or _GEO_TO_AWS_REGION.get(parts[-1])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canvas_placement(inv: Invocation, placement: Placement, vendor: str = "aws") -> dict:
|
|
34
|
+
"""{accountId, cloudRegion, cloudRegionId} for this placement, or {}.
|
|
35
|
+
|
|
36
|
+
Stored in the resource's `type_specific_config` so the dashboard canvas
|
|
37
|
+
can place the node. A resource without it deploys perfectly well and is
|
|
38
|
+
invisible on the canvas, which is the confusing part, so a miss is
|
|
39
|
+
reported to the caller rather than swallowed.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
context = vpc.placement_context(inv.api, placement.application_code, placement.environment)
|
|
43
|
+
except CliError as exc:
|
|
44
|
+
output.warn(f"Could not read the canvas placement context: {exc}")
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
want = placement.geo_loc_mst_code.lower()
|
|
48
|
+
geo = next((g for g in context.get("geoLocations") or [] if (g.get("geoLocCode") or "").lower() == want), None)
|
|
49
|
+
if geo is None:
|
|
50
|
+
return {}
|
|
51
|
+
account = next(
|
|
52
|
+
(a for a in context.get("accounts") or []
|
|
53
|
+
if a.get("geoLocationId") == geo.get("id") and (a.get("vendor") or "").lower() == vendor.lower()),
|
|
54
|
+
None,
|
|
55
|
+
)
|
|
56
|
+
if account is None:
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
regions = [c for c in context.get("cloudRegions") or [] if c.get("accountId") == account.get("id")]
|
|
60
|
+
if len(regions) == 1:
|
|
61
|
+
region = regions[0]
|
|
62
|
+
else:
|
|
63
|
+
wanted = _region_for_geo(placement.geo_loc_mst_code)
|
|
64
|
+
region = next((c for c in regions if c.get("name") == wanted), None)
|
|
65
|
+
if region is None:
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"accountId": account.get("accountId"),
|
|
70
|
+
"cloudRegion": region.get("name"),
|
|
71
|
+
"cloudRegionId": region.get("id"),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_placement(
|
|
76
|
+
inv: Invocation, res: Resolver, app: str | None, env: str | None, region: str | None,
|
|
77
|
+
service: str | None = None,
|
|
78
|
+
) -> Placement:
|
|
79
|
+
"""The product, environment and region to place a resource in.
|
|
80
|
+
|
|
81
|
+
`service` is the placement-allowlist key for what is being created (`s3`,
|
|
82
|
+
`sqs`, `dynamo`, `eks`, `gateway`). When given, only the combinations the
|
|
83
|
+
tenant's allowlist really offers are proposed or accepted — the backend
|
|
84
|
+
lists every region for every product, which is wider than what exists.
|
|
85
|
+
"""
|
|
86
|
+
rows_all = allowlist.filter_placements(res.placements(), res.tenant_code(), service)
|
|
87
|
+
offered = allowlist.describe(res.tenant_code(), service)
|
|
88
|
+
if not rows_all:
|
|
89
|
+
raise InputError(
|
|
90
|
+
f"Nothing in this tenant offers {service}." if service else "No placements are configured.",
|
|
91
|
+
hint="Check the placement allowlist shipped with this CLI.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if app is None:
|
|
95
|
+
names = sorted({r["product"] for r in rows_all})
|
|
96
|
+
app = names[0] if len(names) == 1 else inv.ask("Application", choices=names, flag="--app")
|
|
97
|
+
application = res.application(app)
|
|
98
|
+
rows = [r for r in rows_all if r["application_code"] == application["application_code"]]
|
|
99
|
+
if not rows:
|
|
100
|
+
raise InputError(
|
|
101
|
+
f"'{application['application_name']}' does not offer {service}." if service
|
|
102
|
+
else f"Application '{application['application_name']}' has no placements configured.",
|
|
103
|
+
hint=f"Available: {offered}." if offered else None,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if env is None:
|
|
107
|
+
envs = sorted({r["environment"] for r in rows}, key=lambda e: ENVIRONMENTS.index(e) if e in ENVIRONMENTS else 99)
|
|
108
|
+
env = envs[0] if len(envs) == 1 else inv.ask("Environment", choices=envs, flag="--env")
|
|
109
|
+
environment = res.environment(env)
|
|
110
|
+
in_env = [r for r in rows if r["environment"] == environment]
|
|
111
|
+
if not in_env:
|
|
112
|
+
raise InputError(
|
|
113
|
+
f"'{application['application_name']}' does not offer {service} in {environment}." if service
|
|
114
|
+
else f"'{application['application_name']}' is not set up for {environment}.",
|
|
115
|
+
hint=f"Available: {offered}." if offered else "Environments: " + ", ".join(sorted({r['environment'] for r in rows})),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if region is None:
|
|
119
|
+
regions = sorted({r["region"] for r in in_env})
|
|
120
|
+
region = regions[0] if len(regions) == 1 else inv.ask("Region", choices=regions, flag="--region")
|
|
121
|
+
wanted = region.strip().casefold()
|
|
122
|
+
if not any(wanted in (r["region"].casefold(), str(r["geo_loc_mst_code"]).casefold()) for r in in_env):
|
|
123
|
+
raise InputError(
|
|
124
|
+
f"{service or 'That resource'} is not offered in '{region}' for "
|
|
125
|
+
f"{application['application_name']}/{environment}.",
|
|
126
|
+
hint="Available there: " + ", ".join(sorted({r["region"] for r in in_env})) + ".",
|
|
127
|
+
)
|
|
128
|
+
return res.placement(application["application_name"], environment, region)
|