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
devlift_cli/ops/eks.py ADDED
@@ -0,0 +1,877 @@
1
+ """`devlift eks create`: register a service and park its first configuration.
2
+
3
+ Mirrors the web's "Create & Add" followed by the Settings tab's Save, which is
4
+ also what the MCP's create_service_and_save_draft does:
5
+
6
+ 1. POST /services/create-service the service (name, type, product, group)
7
+ 2. POST /service-configs a baseline row for ONE placement: cluster
8
+ context, ALB selection, namespace — none of
9
+ the user's settings, on purpose (see below)
10
+ 3. POST /transaction/service-settings/{code} the settings as a DRAFT queue row
11
+
12
+ Nothing is submitted, approved or deployed here; those are the review lane's
13
+ verbs. The settings deliberately do NOT go into step 2: the draft in step 3 is
14
+ diffed against the live row, and a live row that already held the values would
15
+ diff to nothing and queue no review.
16
+
17
+ Values: the user supplies what no template can know (repository, branches,
18
+ language, version); the rest starts from the platform's per-language template
19
+ (the same one the MCP applies) and `--set key=value` overrides any field.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import dataclass, field
26
+
27
+ from devlift_cli.api import catalog, infra_list, services as services_api
28
+ from devlift_cli.context import Invocation
29
+ from devlift_cli.errors import EXIT_CONFLICT, EXIT_NOT_FOUND, CliError, InputError
30
+ from devlift_cli.ops.placement import resolve_placement
31
+ from devlift_cli.render import output
32
+ from devlift_cli.render.output import kv_table, rows_table
33
+ from devlift_cli.resolve import allowlist
34
+ from devlift_cli.resolve.names import Resolver
35
+
36
+ SERVICE_TYPES = {"api": "API", "worker": "BACKGROUND_SERVICE", "background": "BACKGROUND_SERVICE"}
37
+
38
+ # ── the settings a service has (form field id → where it lives in `config`) ──
39
+ # The dashboard's Settings form, the chatbot form and the language template all
40
+ # name these the same way; only the autoscaling trio is nested in the stored
41
+ # config (config.hpa.enabled / min_replicas / max_replicas).
42
+ _STRING_FIELDS = (
43
+ "port", "health", "service_path", "build_path", "dockerfile_path", "go_config_path",
44
+ "xms", "xmx", "alb_schema", "compute", "auth_mode",
45
+ "cpu_requested", "cpu_limit", "memory_requested", "memory_limit",
46
+ "replica_count", "min_replicas", "max_replicas",
47
+ )
48
+ _BOOL_FIELDS = (
49
+ "generate_dockerfile", "go_use_aws_secrets", "hpa_enabled",
50
+ "create_ecr", "create_secrets", "create_ssm", "create_argo",
51
+ )
52
+ _LIST_FIELDS = ("other_paths", "custom_iam_policies")
53
+ _PAIR_FIELDS = ("build_args",)
54
+ SETTABLE_FIELDS = _STRING_FIELDS + _BOOL_FIELDS + _LIST_FIELDS + _PAIR_FIELDS
55
+
56
+ _CHOICES = {
57
+ "alb_schema": ("internal", "internet-facing"),
58
+ "compute": ("on-demand", "spot"),
59
+ "auth_mode": ("pod_identity", "irsa"),
60
+ }
61
+ _IAM_POLICIES = ("s3", "sqs", "dynamodb", "ses")
62
+ _JAVA = ("Java Gradle", "Java Maven")
63
+
64
+ # When a setting applies at all — the conditional rules of the platform's EKS
65
+ # form (eks_service_form.json), which the web's Settings tab mirrors: JVM heap
66
+ # only for Java, Go config only for Go, build path for compiled languages,
67
+ # Dockerfile path only when the Dockerfile is not generated, ALB schema and
68
+ # the two HTTP paths only for an API (a worker has no load balancer), min/max
69
+ # replicas only with autoscaling, a fixed replica count only without it.
70
+ # `required` = must have a value once it applies.
71
+ _APPLIES = {
72
+ "xms": {"language": _JAVA},
73
+ "xmx": {"language": _JAVA},
74
+ "go_config_path": {"language": ("Go",)},
75
+ "go_use_aws_secrets": {"language": ("Go",)},
76
+ "build_path": {"language": _JAVA + ("Go",)},
77
+ "dockerfile_path": {"generate_dockerfile": False},
78
+ "alb_schema": {"service_type": "API", "required": True},
79
+ "health": {"service_type": "API", "required": True},
80
+ "service_path": {"service_type": "API", "required": True},
81
+ "min_replicas": {"hpa_enabled": True, "required": True},
82
+ "max_replicas": {"hpa_enabled": True, "required": True},
83
+ "replica_count": {"hpa_enabled": False, "required": True},
84
+ }
85
+ _ALWAYS_REQUIRED = ("port", "cpu_requested", "cpu_limit", "memory_requested", "memory_limit", "compute", "generate_dockerfile", "hpa_enabled")
86
+
87
+
88
+ def applies(key: str, *, language: str, service_type: str, settings: dict) -> tuple[bool, str]:
89
+ """(does this setting apply, why not)."""
90
+ rule = _APPLIES.get(key)
91
+ if not rule:
92
+ return True, ""
93
+ if "language" in rule and language not in rule["language"]:
94
+ return False, f"only applies to {' / '.join(rule['language'])} services, and this one is {language}"
95
+ if "service_type" in rule and service_type != rule["service_type"]:
96
+ return False, "only applies to an API service; a worker has no load balancer or HTTP path"
97
+ if "generate_dockerfile" in rule and bool(settings.get("generate_dockerfile")) != rule["generate_dockerfile"]:
98
+ return False, "only applies when the Dockerfile is not generated (generate_dockerfile=false)"
99
+ if "hpa_enabled" in rule and bool(settings.get("hpa_enabled")) != rule["hpa_enabled"]:
100
+ return False, ("only applies with autoscaling on (hpa_enabled=true)" if rule["hpa_enabled"]
101
+ else "does not apply with autoscaling on; the HPA decides the count")
102
+ return True, ""
103
+
104
+
105
+ def _number(key: str, value, lo: float, hi: float) -> float:
106
+ try:
107
+ n = float(value)
108
+ except (TypeError, ValueError):
109
+ raise InputError(f"{LABELS.get(key, key)} must be a number, got '{value}'.")
110
+ if not lo <= n <= hi:
111
+ raise InputError(f"{LABELS.get(key, key)} must be between {lo:g} and {hi:g}.")
112
+ return n
113
+
114
+
115
+ def validate_settings(settings: dict) -> None:
116
+ """The form's own validation rules, applied before anything is written."""
117
+ s = settings
118
+ if "port" in s and not re.match(r"^[0-9]{1,5}$", str(s["port"])):
119
+ raise InputError("Port must be a number of up to five digits, e.g. 8080.")
120
+ cpu_req = _number("cpu_requested", s["cpu_requested"], 0, 4) if s.get("cpu_requested") else None
121
+ cpu_lim = _number("cpu_limit", s["cpu_limit"], 0, 4) if s.get("cpu_limit") else None
122
+ if cpu_req is not None and cpu_lim is not None and cpu_lim < cpu_req:
123
+ raise InputError("CPU limit cannot be less than CPU request.")
124
+ mem_req = _number("memory_requested", s["memory_requested"], 0, 8) if s.get("memory_requested") else None
125
+ mem_lim = _number("memory_limit", s["memory_limit"], 0, 8) if s.get("memory_limit") else None
126
+ if mem_req is not None and mem_lim is not None and mem_lim < mem_req:
127
+ raise InputError("Memory limit cannot be less than memory request.")
128
+ for key in ("min_replicas", "max_replicas", "replica_count"):
129
+ if s.get(key):
130
+ _number(key, s[key], 1, 100)
131
+ if s.get("min_replicas") and s.get("max_replicas") and float(s["max_replicas"]) <= float(s["min_replicas"]):
132
+ raise InputError("Max replicas must be greater than min replicas.")
133
+ for key in ("health", "service_path"):
134
+ if s.get(key) and not str(s[key]).startswith("/"):
135
+ raise InputError(f"{LABELS[key]} must start with '/'.")
136
+ for key in ("xms", "xmx"):
137
+ if s.get(key) and not re.match(r"^[0-9]+[mMgG]?$", str(s[key])):
138
+ raise InputError(f"{LABELS[key]} must be a heap size like 512m or 1g.")
139
+ bad = [p for p in s.get("custom_iam_policies") or [] if p not in _IAM_POLICIES]
140
+ if bad:
141
+ raise InputError(f"Unknown IAM policy {', '.join(bad)}.", hint="One of: " + ", ".join(_IAM_POLICIES))
142
+ for arg in s.get("build_args") or []:
143
+ if not re.match(r"^[A-Z][A-Z0-9_]*$", str(arg.get("name", ""))):
144
+ raise InputError(f"Build arg name '{arg.get('name')}' must be uppercase, e.g. APP_ENV.")
145
+ _HPA_KEYS = {"hpa_enabled": "enabled", "min_replicas": "min_replicas", "max_replicas": "max_replicas"}
146
+ # Template keys that are not settings at all (the manifests carry them, nobody
147
+ # is asked for them) — same exclusion the MCP applies.
148
+ _NOT_SETTINGS = {"namespace", "ebs_enabled", "ebs.volume", "ebs.size", "ebs.type",
149
+ "secrets_enabled", "secret_keys", "ci_provider"}
150
+ _TEMPLATE_ALIASES = {"hpa.enabled": "hpa_enabled", "hpa.min_replicas": "min_replicas", "hpa.max_replicas": "max_replicas"}
151
+
152
+ LABELS = {
153
+ "port": "Port", "health": "Health check path", "service_path": "Service path",
154
+ "build_path": "Build path", "dockerfile_path": "Dockerfile path", "generate_dockerfile": "Generate Dockerfile",
155
+ "go_config_path": "Go config path", "go_use_aws_secrets": "Go: AWS Secrets Manager",
156
+ "xms": "JVM initial heap (-Xms)", "xmx": "JVM max heap (-Xmx)", "build_args": "Build args",
157
+ "cpu_requested": "CPU request (cores)", "cpu_limit": "CPU limit (cores)",
158
+ "memory_requested": "Memory request (GiB)", "memory_limit": "Memory limit (GiB)",
159
+ "hpa_enabled": "Autoscaling", "min_replicas": "Min replicas", "max_replicas": "Max replicas",
160
+ "replica_count": "Replicas", "alb_schema": "ALB schema", "compute": "Compute",
161
+ "custom_iam_policies": "IAM policies", "create_ecr": "Create ECR repo", "create_secrets": "Create secret",
162
+ "create_ssm": "Create SSM parameters", "create_argo": "Create Argo CD app", "auth_mode": "IAM auth mode",
163
+ "other_paths": "Extra trigger paths",
164
+ }
165
+
166
+
167
+ def normalise_service_name(name: str) -> str:
168
+ """The name the platform deploys under: lowercase, hyphens, `-service` suffix.
169
+ Same rule as the manifest generator, so paths derived here match."""
170
+ n = name.strip().lower().replace(" ", "-").replace("_", "-")
171
+ return n if n.endswith("-service") else f"{n}-service"
172
+
173
+
174
+ def validate_service_name(raw: str) -> str | None:
175
+ name = raw.strip()
176
+ if not name:
177
+ return "required."
178
+ if len(name) > 255:
179
+ return "must be 255 characters or fewer."
180
+ if not re.match(r"^[a-z][a-z0-9-]{1,254}$", name):
181
+ return "use lowercase letters, numbers and hyphens, starting with a letter, e.g. user-service."
182
+ return None
183
+
184
+
185
+ # ── --set parsing ────────────────────────────────────────────────────────────
186
+
187
+ def _split_pairs(pairs: list[str]) -> list[str]:
188
+ """Several settings in one `--set`, separated by `;` or a newline.
189
+
190
+ Comma cannot be that separator: it already separates the values INSIDE
191
+ one setting (`custom_iam_policies=s3,sqs`), so `a=1,b=2` would have to
192
+ guess which it meant. A semicolon never appears in these values, so
193
+ `--set "cpu_limit=0.5;port=9090"` is unambiguous, and repeating `--set`
194
+ keeps working exactly as before.
195
+ """
196
+ out: list[str] = []
197
+ for raw in pairs:
198
+ out += [part.strip() for part in raw.replace("\n", ";").split(";") if part.strip()]
199
+ return out
200
+
201
+
202
+ def parse_set(pairs: list[str]) -> dict:
203
+ """`--set key=value` flags into typed values, validated against the field list."""
204
+ out: dict = {}
205
+ for raw in _split_pairs(pairs):
206
+ if "=" not in raw:
207
+ raise InputError(f"--set expects key=value, got '{raw}'.")
208
+ key, value = raw.split("=", 1)
209
+ key, value = key.strip(), value.strip()
210
+ if key not in SETTABLE_FIELDS:
211
+ raise InputError(f"Unknown setting '{key}'.", hint="Settable: " + ", ".join(SETTABLE_FIELDS))
212
+ if key in _BOOL_FIELDS:
213
+ if value.lower() not in ("true", "false", "yes", "no"):
214
+ raise InputError(f"--set {key} must be true or false.")
215
+ out[key] = value.lower() in ("true", "yes")
216
+ elif key in _LIST_FIELDS:
217
+ out[key] = [v.strip() for v in value.split(",") if v.strip()]
218
+ elif key in _PAIR_FIELDS:
219
+ items = []
220
+ for part in (p.strip() for p in value.split(",") if p.strip()):
221
+ if "=" not in part:
222
+ raise InputError(f"--set {key} takes NAME=VALUE pairs separated by commas, got '{part}'.")
223
+ n, v = part.split("=", 1)
224
+ items.append({"name": n.strip(), "value": v.strip()})
225
+ out[key] = items
226
+ else:
227
+ if key in _CHOICES and value not in _CHOICES[key]:
228
+ raise InputError(f"--set {key} must be one of {', '.join(_CHOICES[key])}.")
229
+ out[key] = value
230
+ return out
231
+
232
+
233
+ # ── template → settings ──────────────────────────────────────────────────────
234
+
235
+ def settings_from_template(block: dict | None, service_name: str) -> dict:
236
+ """Flat settings (form field ids) from a language template block.
237
+
238
+ `null` = no reliable default, leave unset so it gets asked. Empty string or
239
+ list = deliberately unset (the MCP's `template_skips`). Placeholders take
240
+ the normalised service name."""
241
+ settings: dict = {}
242
+ deployed = normalise_service_name(service_name)
243
+ for key, value in (block or {}).items():
244
+ if key in _NOT_SETTINGS or value is None:
245
+ continue
246
+ key = _TEMPLATE_ALIASES.get(key, key)
247
+ if value == "":
248
+ continue
249
+ if isinstance(value, bool):
250
+ settings[key] = value
251
+ elif isinstance(value, list):
252
+ settings[key] = value
253
+ elif isinstance(value, (int, float)):
254
+ settings[key] = str(value)
255
+ else:
256
+ text = str(value).replace("{service_name}", deployed).replace("{service_path}", f"/{deployed}")
257
+ settings[key] = text
258
+ return settings
259
+
260
+
261
+ def _missing(settings: dict, *, language: str, service_type: str) -> list[str]:
262
+ need = list(_ALWAYS_REQUIRED) + [
263
+ k for k, rule in _APPLIES.items()
264
+ if rule.get("required") and applies(k, language=language, service_type=service_type, settings=settings)[0]
265
+ ]
266
+ return [k for k in need if settings.get(k) in (None, "")]
267
+
268
+
269
+ def _to_config(settings: dict, *, service_name: str, service_type: str, language_ref_code: str,
270
+ repository: str, branches: list[str]) -> dict:
271
+ """The `config` block of the draft, in the shape the Settings tab sends."""
272
+ config: dict = {
273
+ "repository": repository,
274
+ "branches": branches,
275
+ "language_ref_code": language_ref_code,
276
+ "namespace": normalise_service_name(service_name),
277
+ "alb_selection": "no_alb" if service_type == "BACKGROUND_SERVICE" else "existing_alb",
278
+ }
279
+ hpa: dict = {"enabled": bool(settings.get("hpa_enabled"))}
280
+ for key, value in settings.items():
281
+ if key in _HPA_KEYS:
282
+ if key != "hpa_enabled" and hpa["enabled"]:
283
+ hpa[_HPA_KEYS[key]] = str(value)
284
+ continue
285
+ config[key] = value
286
+ config["hpa"] = hpa
287
+ if hpa["enabled"]:
288
+ config.pop("replica_count", None)
289
+ for key in _LIST_FIELDS + _PAIR_FIELDS:
290
+ config.setdefault(key, [])
291
+ return config
292
+
293
+
294
+ # ── cluster / language lookups ───────────────────────────────────────────────
295
+
296
+ def _cluster_context(locator: dict) -> dict:
297
+ """The cluster fields the web copies from the canvas node onto the baseline
298
+ row. Locators mix snake_case and camelCase; read both. vpc_id is left out
299
+ on purpose — the web's create dialog never carries it."""
300
+ def pick(*keys):
301
+ for k in keys:
302
+ v = locator.get(k)
303
+ if v not in (None, "", [], {}):
304
+ return v
305
+ return None
306
+ context = {
307
+ "cluster_name": pick("cluster_name", "clusterName"),
308
+ "cluster_arn": pick("cluster_arn", "clusterArn"),
309
+ "region": pick("cloudRegion", "region", "cloud_region"),
310
+ "cloud_region_id": pick("cloudRegionId", "cloud_region_id"),
311
+ "subnet_ids": pick("subnetIds", "subnet_ids"),
312
+ }
313
+ return {k: v for k, v in context.items() if v is not None}
314
+
315
+
316
+ def resolve_cluster(inv: Invocation, *, application_code: str, environment: str, geo_loc_mst_code: str, wanted: str | None) -> dict:
317
+ """The registered EKS cluster this service runs on. Product-scoped clusters
318
+ win; tenant-level ones (no product) are the fallback — the same rule the
319
+ dashboard picker and the MCP apply. The list route already hides
320
+ unregistered clusters."""
321
+ rows = infra_list.list_infrastructures(
322
+ inv.api, infra_type=services_api.EKS_INFRA_TYPE, environment=environment, geo_loc_mst_code=geo_loc_mst_code,
323
+ )
324
+ scoped = [r for r in rows if r.get("applications_mst_code") == application_code]
325
+ shared = [r for r in rows if not r.get("applications_mst_code")]
326
+ candidates = scoped or shared
327
+ if wanted:
328
+ w = wanted.strip().lower()
329
+ hit = [r for r in rows if w in (str(r.get("code", "")).lower(), str(r.get("name", "")).lower(), str(r.get("cluster_name", "")).lower())]
330
+ if not hit:
331
+ names = ", ".join(f"{r.get('cluster_name') or r.get('name')} ({r['code']})" for r in rows) or "none"
332
+ raise CliError(f"Cluster '{wanted}' is not available for {environment} / {geo_loc_mst_code}. Available: {names}.", EXIT_NOT_FOUND)
333
+ return hit[0]
334
+ if not candidates:
335
+ raise CliError(
336
+ f"No registered EKS cluster for this product in {environment} / {geo_loc_mst_code}.",
337
+ EXIT_NOT_FOUND, hint="Ask the DevOps team to register one, or pick another environment/region.",
338
+ )
339
+ if len(candidates) == 1:
340
+ return candidates[0]
341
+ labels = [f"{r.get('cluster_name') or r.get('name')} ({r['code']})" for r in candidates]
342
+ choice = inv.ask("Cluster", choices=labels, flag="--cluster")
343
+ return candidates[labels.index(choice)]
344
+
345
+
346
+ def resolve_language(inv: Invocation, language: str | None, version: str | None) -> tuple[str, str, str]:
347
+ """(language label, version, language_ref_code) from the grouped list."""
348
+ groups = services_api.language_versions_grouped(inv.api)
349
+ labels = [g["language_name"] for g in groups]
350
+ if language is None:
351
+ language = inv.ask("Language", choices=labels, flag="--language")
352
+ want = language.strip().lower()
353
+ group = next((g for g in groups if g["language_name"].lower() == want), None)
354
+ if group is None:
355
+ prefix = [g for g in groups if g["language_name"].lower().startswith(want)]
356
+ if len(prefix) == 1:
357
+ group = prefix[0]
358
+ else:
359
+ raise InputError(f"Unknown language '{language}'.", hint="One of: " + ", ".join(labels))
360
+ versions = group.get("versions") or []
361
+ if not versions:
362
+ raise CliError(f"No versions are configured for {group['language_name']}.", EXIT_NOT_FOUND)
363
+ if version is None:
364
+ version = versions[0]["version"] if len(versions) == 1 else inv.ask(
365
+ f"{group['language_name']} version", choices=[v["version"] for v in versions], flag="--version",
366
+ )
367
+ v = version.strip().lower()
368
+ row = next((x for x in versions if v in (str(x.get("version", "")).lower(), str(x.get("code", "")).lower())), None)
369
+ if row is None:
370
+ raise InputError(f"{group['language_name']} has no version '{version}'.", hint="One of: " + ", ".join(x["version"] for x in versions))
371
+ return group["language_name"], row["version"], row["code"]
372
+
373
+
374
+ def resolve_repository(inv: Invocation, wanted: str | None) -> tuple[str, str]:
375
+ """(owner/name, default branch) from the repositories the GitHub App can
376
+ see. A bare name matches when it is unique; anything else is asked or
377
+ refused with the list to run."""
378
+ repos = catalog.list_repositories(inv.api)
379
+ if not repos:
380
+ raise CliError("The DevLift GitHub App sees no repositories for this tenant.", EXIT_NOT_FOUND,
381
+ hint="Install or link the GitHub App from the dashboard first.")
382
+ names = [r["full_name"] for r in repos]
383
+ if wanted is None:
384
+ wanted = inv.ask("Repository", choices=names if len(names) <= 12 else None, flag="--repository")
385
+ w = wanted.strip().lower()
386
+ hit = [r for r in repos if r["full_name"].lower() == w] or [r for r in repos if str(r.get("name", "")).lower() == w]
387
+ if len(hit) != 1:
388
+ raise CliError(
389
+ f"Repository '{wanted}' is not one the DevLift GitHub App can see." if not hit
390
+ else f"Repository '{wanted}' matches several: " + ", ".join(r["full_name"] for r in hit) + ".",
391
+ EXIT_NOT_FOUND if not hit else 3,
392
+ hint="`devlift repositories list` shows the accepted owner/name values.",
393
+ )
394
+ return hit[0]["full_name"], hit[0].get("default_branch") or "main"
395
+
396
+
397
+ def resolve_branches(inv: Invocation, repository: str, wanted: list[str], default_branch: str) -> list[str]:
398
+ """The branches that trigger a build, each checked to exist on the
399
+ repository. The branch is the user's decision: asked on a terminal (the
400
+ repository's default branch offered as the default answer), required
401
+ under --no-input."""
402
+ existing = [b["name"] for b in catalog.list_branches(inv.api, repository)]
403
+ if not wanted:
404
+ if not inv.interactive:
405
+ raise InputError(
406
+ "Branch is required.",
407
+ hint=f"Pass --branch (repeatable). `devlift repositories branches {repository}` lists them; the default branch is {default_branch}.",
408
+ )
409
+ if existing and len(existing) <= 12:
410
+ output.err_console.print(f"[bold]Branches on {repository}[/bold]: " + ", ".join(existing))
411
+ raw = inv.ask("Branches that trigger a build (comma-separated)", default=default_branch, flag="--branch")
412
+ wanted = [b.strip() for b in raw.split(",") if b.strip()]
413
+ unknown = [b for b in wanted if existing and b not in existing]
414
+ if unknown:
415
+ shown = ", ".join(existing[:8]) + (" …" if len(existing) > 8 else "")
416
+ raise InputError(
417
+ f"Branch {', '.join(repr(b) for b in unknown)} does not exist on {repository}.",
418
+ hint=f"`devlift repositories branches {repository}` lists them (for example: {shown}).",
419
+ )
420
+ return wanted
421
+
422
+
423
+ # ── the sequence ─────────────────────────────────────────────────────────────
424
+
425
+ @dataclass
426
+ class EksCreateResult:
427
+ service_name: str
428
+ service_code: str
429
+ service_config_code: str
430
+ environment: str
431
+ region: str
432
+ cluster: str
433
+ queue_code: str | None
434
+ queue_status: str | None
435
+ config: dict = field(default_factory=dict)
436
+
437
+ def to_dict(self) -> dict:
438
+ return {
439
+ "service": self.service_name,
440
+ "service_code": self.service_code,
441
+ "service_config_code": self.service_config_code,
442
+ "environment": self.environment,
443
+ "region": self.region,
444
+ "cluster": self.cluster,
445
+ "queue_code": self.queue_code,
446
+ "queue_status": self.queue_status,
447
+ "config": self.config,
448
+ }
449
+
450
+
451
+ def create_eks_service(
452
+ inv: Invocation,
453
+ res: Resolver,
454
+ *,
455
+ name: str | None,
456
+ app: str | None,
457
+ env: str | None,
458
+ region: str | None,
459
+ resource_group: str | None,
460
+ service_type: str | None,
461
+ repository: str | None,
462
+ branches: list[str],
463
+ language: str | None,
464
+ version: str | None,
465
+ cluster: str | None,
466
+ overrides: dict,
467
+ public: bool = False,
468
+ ) -> EksCreateResult:
469
+ # 1. what it is, where it lives
470
+ if name is None:
471
+ name = inv.ask("Service name", flag="--name")
472
+ problem = validate_service_name(name)
473
+ if problem:
474
+ raise InputError(f"Service name {problem}")
475
+ name = name.strip()
476
+
477
+ if service_type is None:
478
+ service_type = inv.ask("Service type", choices=["api", "worker"], default="api", flag="--type")
479
+ stype = SERVICE_TYPES.get(service_type.strip().lower())
480
+ if not stype:
481
+ raise InputError("--type must be api or worker.")
482
+
483
+ placement = resolve_placement(inv, res, app, env, region, service=allowlist.SERVICE_EKS)
484
+
485
+ groups = [g for g in res.resource_groups() if g.get("applications_mst_code") == placement.application_code]
486
+ if resource_group is None:
487
+ if len(groups) == 1:
488
+ group = groups[0]
489
+ elif groups:
490
+ pick = inv.ask("Resource group", choices=[g["name"] for g in groups], flag="--resource-group")
491
+ group = next(g for g in groups if g["name"] == pick)
492
+ else:
493
+ raise CliError(f"'{placement.application_name}' has no resource groups; create one in the dashboard first.", EXIT_NOT_FOUND)
494
+ else:
495
+ group = res.resource_group(resource_group, placement.application_code)
496
+
497
+ existing = [s for s in catalog.list_services(inv.api, application_code=placement.application_code)
498
+ if str(s.get("service_name", "")).strip().lower() == name.lower()]
499
+ if existing:
500
+ raise CliError(
501
+ f"A service named '{name}' already exists in {placement.application_name} ({existing[0].get('service_code')}).",
502
+ EXIT_CONFLICT, hint=f"`devlift services describe {name}`. Adding a configuration for another environment is not available yet.",
503
+ )
504
+
505
+ chosen = resolve_cluster(inv, application_code=placement.application_code, environment=placement.environment,
506
+ geo_loc_mst_code=placement.geo_loc_mst_code, wanted=cluster)
507
+ cluster_locator = chosen.get("locator") or {}
508
+ cluster_label = cluster_locator.get("cluster_name") or chosen.get("cluster_name") or chosen.get("name") or chosen["code"]
509
+
510
+ # 2. what only the user knows — checked against the same lists the web's
511
+ # dropdowns show (`devlift repositories list` / `repositories branches`)
512
+ repository, default_branch = resolve_repository(inv, repository)
513
+ branches = resolve_branches(inv, repository, branches, default_branch)
514
+ lang_label, lang_version, language_ref_code = resolve_language(inv, language, version)
515
+
516
+ # 3. the rest: template, then overrides
517
+ templates = services_api.eks_language_templates(inv.api)
518
+ block = next((b for k, b in templates.items() if k.lower() == lang_label.lower()), None)
519
+ if block is None:
520
+ output.warn(f"No template for {lang_label}; every setting must come from --set.")
521
+ settings = settings_from_template(block, name)
522
+ settings.update(overrides)
523
+ # A setting the user named that cannot apply to THIS service is a mistake
524
+ # to report, not to drop silently: `--set xms=1g` on a Go service.
525
+ for key in overrides:
526
+ ok, why = applies(key, language=lang_label, service_type=stype, settings=settings)
527
+ if not ok:
528
+ raise InputError(f"{LABELS.get(key, key)} ({key}) {why}.")
529
+ # Template values that do not apply here just fall away — the template is
530
+ # per language, but the service type and the autoscaling choice are not.
531
+ settings = {k: v for k, v in settings.items()
532
+ if applies(k, language=lang_label, service_type=stype, settings=settings)[0]}
533
+ for key in _missing(settings, language=lang_label, service_type=stype):
534
+ if key in ("port", "health", "service_path") and inv.interactive:
535
+ settings[key] = inv.ask(LABELS.get(key, key), flag=f"--set {key}=…")
536
+ else:
537
+ raise InputError(f"{LABELS.get(key, key)} is not set and the {lang_label} template has no default for it.",
538
+ hint=f"Pass --set {key}=<value>.")
539
+ validate_settings(settings)
540
+
541
+ config = _to_config(settings, service_name=name, service_type=stype, language_ref_code=language_ref_code,
542
+ repository=repository, branches=branches)
543
+
544
+ # 4. show, confirm
545
+ if inv.show_summary:
546
+ output.err_console.print(kv_table([
547
+ ("Service", name),
548
+ ("Type", "API" if stype == "API" else "Worker (no load balancer)"),
549
+ ("Application", placement.application_name),
550
+ ("Resource group", group["name"]),
551
+ ("Environment", placement.environment),
552
+ ("Region", placement.region_name),
553
+ ("Cluster", cluster_label),
554
+ ("Repository", repository),
555
+ ("Branches", ", ".join(branches)),
556
+ ("Language", f"{lang_label} {lang_version}"),
557
+ ], title="Create service"))
558
+ shown = [(LABELS.get(k, k), _show(v), "you" if k in overrides else lang_label + " template")
559
+ for k, v in settings.items()]
560
+ output.err_console.print(rows_table(["Setting", "Value", "From"], shown, title="Configuration"))
561
+ inv.confirm(f"Create '{name}' and save this configuration as a draft for review?")
562
+
563
+ # 5. the three calls
564
+ created = services_api.create_service(
565
+ inv.api, application_code=placement.application_code, resource_group_code=group["code"],
566
+ service_name=name, service_type=stype, is_public_facing=public,
567
+ )
568
+ service_code = created.get("service_code")
569
+ if not service_code:
570
+ raise CliError("DevLift created the service but returned no code.")
571
+ output.info(f"Created service {name} ({service_code}).")
572
+
573
+ baseline = {
574
+ "alb_selection": config["alb_selection"],
575
+ "namespace": config["namespace"],
576
+ **_cluster_context(cluster_locator),
577
+ }
578
+ cfg = services_api.create_service_config(
579
+ inv.api, services_mst_code=service_code, environment=placement.environment,
580
+ geo_loc_mst_code=placement.geo_loc_mst_code, infrastructure_mst_code=chosen["code"], config=baseline,
581
+ )
582
+ service_config_code = cfg.get("code")
583
+ if not service_config_code:
584
+ raise CliError("DevLift created the configuration row but returned no code.")
585
+ output.info(f"Configuration row {service_config_code} created for {placement.environment} / {placement.region_name}.")
586
+
587
+ snapshot = {
588
+ "config": config,
589
+ "language_name": lang_label,
590
+ "language_version": lang_version,
591
+ "services_mst_code": service_code,
592
+ "service_name": name,
593
+ "service_type": stype,
594
+ "geo_loc_mst_code": placement.geo_loc_mst_code,
595
+ "environment": placement.environment,
596
+ "infrastructuretype_ref_code": services_api.EKS_INFRA_TYPE,
597
+ "infrastructure_mst_code": chosen["code"],
598
+ "product_name": placement.application_name,
599
+ "applications_mst_code": placement.application_code,
600
+ }
601
+ ingress = (cfg.get("config") or {}).get("ingress_group_order")
602
+ if ingress is not None:
603
+ snapshot["ingress_group_order"] = ingress
604
+ draft = services_api.save_settings_draft(inv.api, service_config_code=service_config_code, config_snapshot=snapshot)
605
+ approval = draft.get("approval") or {}
606
+ queue_code, queue_status = approval.get("code"), approval.get("status")
607
+ output.info(f"Configuration saved as draft {queue_code or ''} ({queue_status or 'draft'}). Nothing is deployed until it is submitted, approved and deployed.")
608
+
609
+ return EksCreateResult(
610
+ service_name=name, service_code=service_code, service_config_code=service_config_code,
611
+ environment=placement.environment, region=placement.region_name, cluster=cluster_label,
612
+ queue_code=queue_code, queue_status=queue_status, config=config,
613
+ )
614
+
615
+
616
+ # ── editing an existing service ──────────────────────────────────────────────
617
+ #
618
+ # The Settings tab's model, and the MCP's edit path: the form is hydrated from
619
+ # the LIVE row, then the caller's OPEN DRAFT (if any) is laid over it, the user
620
+ # changes fields, and Save parks the full result as a settings draft — reusing
621
+ # the open draft's queue code so a second edit updates the same request rather
622
+ # than tripping the one-live-request lane. A request under review (submitted
623
+ # or approved, whoever's) blocks editing: the values are being decided on.
624
+
625
+ def settings_from_config(config: dict) -> dict:
626
+ """A stored `config` block → the flat settings keys the form and --set use."""
627
+ cfg = config or {}
628
+ out: dict = {}
629
+ for key in _STRING_FIELDS:
630
+ if key in ("min_replicas", "max_replicas"):
631
+ continue
632
+ v = cfg.get(key)
633
+ if v not in (None, "", [], {}):
634
+ out[key] = str(v) if not isinstance(v, str) else v
635
+ for key in _BOOL_FIELDS:
636
+ if key == "hpa_enabled":
637
+ continue
638
+ v = cfg.get(key)
639
+ if v is not None:
640
+ out[key] = bool(v) if not isinstance(v, str) else v.lower() == "true"
641
+ for key in _LIST_FIELDS + _PAIR_FIELDS:
642
+ v = cfg.get(key)
643
+ if isinstance(v, list):
644
+ out[key] = v
645
+ hpa = cfg.get("hpa") if isinstance(cfg.get("hpa"), dict) else None
646
+ if hpa is not None:
647
+ out["hpa_enabled"] = bool(hpa.get("enabled"))
648
+ if out["hpa_enabled"]:
649
+ for k in ("min_replicas", "max_replicas"):
650
+ if hpa.get(k) not in (None, ""):
651
+ out[k] = str(hpa[k])
652
+ elif "replica_count" in out:
653
+ out["hpa_enabled"] = False
654
+ return out
655
+
656
+
657
+ def language_label(name: str | None, code: str | None) -> str | None:
658
+ """The label the grouped list uses: 'Java Maven' / 'Java Gradle' by code,
659
+ otherwise the first word of the name ('Python 3.12' → 'Python')."""
660
+ if code and code.upper().startswith("JAVA-MAVEN"):
661
+ return "Java Maven"
662
+ if code and code.upper().startswith("JAVA"):
663
+ return "Java Gradle"
664
+ return (name or "").split()[0] if name else None
665
+
666
+
667
+ @dataclass
668
+ class Effective:
669
+ """A service configuration as the Settings tab would show it."""
670
+ target: object # ops.kong.GatewayTarget
671
+ service: dict # the services list row
672
+ live: dict # GET /service-configs/by-code/{code}
673
+ config: dict # live config with the open draft laid over it
674
+ draft: dict | None # the caller's open settings draft, if any
675
+ pending_keys: set # keys where the draft differs from live
676
+ language_ref_code: str | None
677
+ language_label: str | None
678
+ language_version: str | None
679
+ settings: dict # flat form keys
680
+
681
+
682
+ def load_effective(inv: Invocation, res: Resolver, service: str, env: str | None, region: str | None, *, for_edit: bool) -> Effective:
683
+ from devlift_cli.api import approvals as approvals_api
684
+ from devlift_cli.ops.kong import resolve_target
685
+
686
+ target = resolve_target(inv, res, service, env, region)
687
+ service_row = res.service(service)
688
+ live = services_api.get_service_config(inv.api, target.config_code)
689
+ live_config = dict(live.get("config") or {})
690
+
691
+ rows = [r for r in approvals_api.list_requests(inv.api, resource_code=target.config_code) if r.get("case_ref_code") == "update_service"]
692
+ under_review = [r for r in rows if r.get("status") in ("submit", "approved")]
693
+ if for_edit and under_review:
694
+ r = under_review[0]
695
+ raise CliError(
696
+ f"{target.service_name} has a {r['status']} settings request ({r['code']}, by "
697
+ f"{r.get('requested_by_name') or r.get('requested_by')}) — its values are being decided on and cannot be edited now.",
698
+ EXIT_CONFLICT, hint="Wait for it to deploy, or withdraw / revoke it first.",
699
+ )
700
+ draft = next((r for r in rows if r.get("status") == "draft" and (r.get("you") or {}).get("mine")), None)
701
+ shown_draft = draft or next((r for r in under_review), None)
702
+ overlay = ((shown_draft or {}).get("config_snapshot") or {}).get("config") or {}
703
+ config = {**live_config, **overlay}
704
+ pending = {k for k, v in overlay.items() if live_config.get(k) != v}
705
+
706
+ language_ref_code = config.get("language_ref_code") or live.get("language_ref_code")
707
+ label = version = None
708
+ if language_ref_code:
709
+ try:
710
+ lv = services_api.language_version(inv.api, language_ref_code)
711
+ label, version = language_label(lv.get("name"), lv.get("code")), lv.get("version")
712
+ except CliError:
713
+ label = language_label(None, language_ref_code)
714
+ return Effective(target, service_row, live, config, draft if for_edit else shown_draft, pending,
715
+ language_ref_code, label, version, settings_from_config(config))
716
+
717
+
718
+ @dataclass
719
+ class EditResult:
720
+ service_name: str
721
+ config_code: str
722
+ environment: str | None
723
+ changed: bool
724
+ changes: list[tuple[str, object, object]]
725
+ queue_code: str | None = None
726
+ queue_status: str | None = None
727
+
728
+ def to_dict(self) -> dict:
729
+ return {
730
+ "service": self.service_name, "service_config_code": self.config_code, "environment": self.environment,
731
+ "changed": self.changed, "changes": {f: {"from": a, "to": b} for f, a, b in self.changes},
732
+ "queue_code": self.queue_code, "queue_status": self.queue_status,
733
+ }
734
+
735
+
736
+ def edit_eks_service(
737
+ inv: Invocation, res: Resolver, service: str, *, env: str | None, region: str | None,
738
+ overrides: dict, repository: str | None, branches: list[str], language: str | None, version: str | None,
739
+ ) -> EditResult:
740
+ eff = load_effective(inv, res, service, env, region, for_edit=True)
741
+ target = eff.target
742
+ stype = (eff.service.get("service_type") or "API").upper()
743
+ before = dict(eff.settings)
744
+
745
+ # what only the user knows, when they change it
746
+ repo_before = eff.config.get("repository")
747
+ branches_before = list(eff.config.get("branches") or eff.config.get("selected_branches") or [])
748
+ lang_code, lang_label, lang_version = eff.language_ref_code, eff.language_label, eff.language_version
749
+ repo_after, default_branch = (resolve_repository(inv, repository) if repository else (repo_before, None))
750
+ if branches or (repository and repo_after != repo_before):
751
+ if not repo_after:
752
+ raise InputError("This configuration has no repository yet; pass --repository too.")
753
+ branches_after = resolve_branches(inv, repo_after, branches or branches_before, default_branch or "main")
754
+ else:
755
+ branches_after = branches_before
756
+ if language or version:
757
+ lang_label, lang_version, lang_code = resolve_language(inv, language or lang_label, version)
758
+ if not lang_label:
759
+ raise InputError(f"{target.service_name} has no language set yet.", hint="Pass --language and --version.")
760
+
761
+ settings = dict(before)
762
+ settings.update(overrides)
763
+ for key in overrides:
764
+ ok, why = applies(key, language=lang_label, service_type=stype, settings=settings)
765
+ if not ok:
766
+ raise InputError(f"{LABELS.get(key, key)} ({key}) {why}.")
767
+ settings = {k: v for k, v in settings.items() if applies(k, language=lang_label, service_type=stype, settings=settings)[0]}
768
+ missing = _missing(settings, language=lang_label, service_type=stype)
769
+ if missing:
770
+ raise InputError(
771
+ "Not set: " + ", ".join(LABELS.get(k, k) for k in missing) + ".",
772
+ hint="Pass them with --set key=value: " + " ".join(f"--set {k}=…" for k in missing),
773
+ )
774
+ validate_settings(settings)
775
+
776
+ changes: list[tuple[str, object, object]] = []
777
+ for key in sorted(set(before) | set(settings)):
778
+ if _comparable(before.get(key)) != _comparable(settings.get(key)):
779
+ changes.append((key, before.get(key), settings.get(key)))
780
+ if repo_after != repo_before:
781
+ changes.append(("repository", repo_before, repo_after))
782
+ if branches_after != branches_before:
783
+ changes.append(("branches", branches_before, branches_after))
784
+ if lang_code != eff.language_ref_code:
785
+ changes.append(("language", f"{eff.language_label or '–'} {eff.language_version or ''}".strip(), f"{lang_label} {lang_version}"))
786
+ if not changes:
787
+ output.info(f"{target.service_name} already has those values. Nothing to change.")
788
+ return EditResult(target.service_name, target.config_code, target.environment, False, [])
789
+
790
+ if inv.show_summary:
791
+ output.err_console.print(rows_table(
792
+ ["Setting", "Current", "New"],
793
+ [(LABELS.get(f, f), _show(a), _show(b)) for f, a, b in changes],
794
+ title=f"Edit {target.service_name} ({target.environment} / {target.region_name})"
795
+ + (f" — updates your draft {eff.draft['code']}" if eff.draft else ""),
796
+ ))
797
+ inv.confirm("Save these changes as a draft for review?")
798
+
799
+ config = _to_config(settings, service_name=target.service_name, service_type=stype, language_ref_code=lang_code or "",
800
+ repository=repo_after or "", branches=branches_after)
801
+ live = eff.live
802
+ snapshot = {
803
+ "config": config,
804
+ "language_name": lang_label,
805
+ "language_version": lang_version,
806
+ "services_mst_code": live.get("services_mst_code") or eff.service.get("service_code"),
807
+ "service_name": target.service_name,
808
+ "service_type": stype,
809
+ "geo_loc_mst_code": live.get("geo_loc_mst_code"),
810
+ "environment": live.get("environment"),
811
+ "infrastructuretype_ref_code": live.get("infrastructuretype_ref_code") or services_api.EKS_INFRA_TYPE,
812
+ "infrastructure_mst_code": live.get("infrastructure_mst_code"),
813
+ "product_name": eff.service.get("application_name"),
814
+ "applications_mst_code": eff.service.get("application_code"),
815
+ }
816
+ ingress = (live.get("config") or {}).get("ingress_group_order")
817
+ if ingress is not None:
818
+ snapshot["ingress_group_order"] = ingress
819
+ draft = services_api.save_settings_draft(
820
+ inv.api, service_config_code=target.config_code, config_snapshot=snapshot,
821
+ queue_code=(eff.draft or {}).get("code"),
822
+ )
823
+ approval = draft.get("approval") or {}
824
+ output.info(f"Saved as draft {approval.get('code') or ''} ({approval.get('status') or 'draft'}). "
825
+ f"Submit it with `devlift request submit {target.service_name}`.")
826
+ return EditResult(target.service_name, target.config_code, target.environment, True, changes,
827
+ queue_code=approval.get("code"), queue_status=approval.get("status"))
828
+
829
+
830
+ def _comparable(value):
831
+ if isinstance(value, list):
832
+ return [str(v) for v in value]
833
+ if isinstance(value, bool) or value is None:
834
+ return value
835
+ return str(value)
836
+
837
+
838
+ def describe_settings() -> list[dict]:
839
+ """One row per --set key: what it is and when it applies (for `eks settings`)."""
840
+ rows = []
841
+ for key in SETTABLE_FIELDS:
842
+ rule = _APPLIES.get(key, {})
843
+ if "language" in rule:
844
+ when = "language " + " / ".join(rule["language"])
845
+ elif "service_type" in rule:
846
+ when = "API services only"
847
+ elif "generate_dockerfile" in rule:
848
+ when = "generate_dockerfile=false"
849
+ elif "hpa_enabled" in rule:
850
+ when = "hpa_enabled=true" if rule["hpa_enabled"] else "hpa_enabled=false"
851
+ else:
852
+ when = "always"
853
+ if key in _BOOL_FIELDS:
854
+ kind = "true | false"
855
+ elif key in _CHOICES:
856
+ kind = " | ".join(_CHOICES[key])
857
+ elif key == "custom_iam_policies":
858
+ kind = "comma-separated: " + ", ".join(_IAM_POLICIES)
859
+ elif key in _LIST_FIELDS:
860
+ kind = "comma-separated list"
861
+ elif key in _PAIR_FIELDS:
862
+ kind = "NAME=VALUE pairs, comma-separated"
863
+ else:
864
+ kind = "text"
865
+ rows.append({"key": key, "label": LABELS.get(key, key), "values": kind, "applies": when,
866
+ "required": key in _ALWAYS_REQUIRED or bool(rule.get("required"))})
867
+ return rows
868
+
869
+
870
+ def _show(value) -> str:
871
+ if isinstance(value, bool):
872
+ return "yes" if value else "no"
873
+ if isinstance(value, list):
874
+ if value and isinstance(value[0], dict):
875
+ return ", ".join(f"{d.get('name')}={d.get('value')}" for d in value)
876
+ return ", ".join(str(v) for v in value) if value else "–"
877
+ return "–" if value in (None, "") else str(value)