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/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)
|