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.
Files changed (56) hide show
  1. devlift_cli/MANUAL.md +1066 -0
  2. devlift_cli/__init__.py +3 -0
  3. devlift_cli/__main__.py +4 -0
  4. devlift_cli/api/__init__.py +0 -0
  5. devlift_cli/api/approvals.py +53 -0
  6. devlift_cli/api/catalog.py +96 -0
  7. devlift_cli/api/client.py +125 -0
  8. devlift_cli/api/context.py +21 -0
  9. devlift_cli/api/deployments.py +37 -0
  10. devlift_cli/api/infra.py +94 -0
  11. devlift_cli/api/infra_list.py +61 -0
  12. devlift_cli/api/kong.py +29 -0
  13. devlift_cli/api/services.py +106 -0
  14. devlift_cli/api/vpc.py +24 -0
  15. devlift_cli/app.py +163 -0
  16. devlift_cli/auth/__init__.py +0 -0
  17. devlift_cli/auth/oauth.py +270 -0
  18. devlift_cli/auth/session.py +64 -0
  19. devlift_cli/auth/storage.py +135 -0
  20. devlift_cli/commands/__init__.py +0 -0
  21. devlift_cli/commands/approval.py +51 -0
  22. devlift_cli/commands/auth.py +180 -0
  23. devlift_cli/commands/catalog.py +187 -0
  24. devlift_cli/commands/clusters.py +108 -0
  25. devlift_cli/commands/deployment.py +77 -0
  26. devlift_cli/commands/dynamodb.py +121 -0
  27. devlift_cli/commands/eks.py +326 -0
  28. devlift_cli/commands/kong.py +145 -0
  29. devlift_cli/commands/languages.py +40 -0
  30. devlift_cli/commands/manual.py +82 -0
  31. devlift_cli/commands/repositories.py +49 -0
  32. devlift_cli/commands/request.py +89 -0
  33. devlift_cli/commands/s3.py +198 -0
  34. devlift_cli/commands/sqs.py +229 -0
  35. devlift_cli/config.py +94 -0
  36. devlift_cli/context.py +97 -0
  37. devlift_cli/data/placement/vance.json +16 -0
  38. devlift_cli/errors.py +52 -0
  39. devlift_cli/ops/__init__.py +0 -0
  40. devlift_cli/ops/approvals.py +343 -0
  41. devlift_cli/ops/eks.py +877 -0
  42. devlift_cli/ops/kong.py +343 -0
  43. devlift_cli/ops/placement.py +128 -0
  44. devlift_cli/ops/resources.py +418 -0
  45. devlift_cli/ops/status.py +152 -0
  46. devlift_cli/ops/wait.py +82 -0
  47. devlift_cli/render/__init__.py +0 -0
  48. devlift_cli/render/output.py +75 -0
  49. devlift_cli/resolve/__init__.py +0 -0
  50. devlift_cli/resolve/allowlist.py +192 -0
  51. devlift_cli/resolve/names.py +179 -0
  52. devlift_cli-0.1.0.dist-info/METADATA +106 -0
  53. devlift_cli-0.1.0.dist-info/RECORD +56 -0
  54. devlift_cli-0.1.0.dist-info/WHEEL +5 -0
  55. devlift_cli-0.1.0.dist-info/entry_points.txt +3 -0
  56. devlift_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -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)