devlift-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devlift_cli/MANUAL.md +1066 -0
- devlift_cli/__init__.py +3 -0
- devlift_cli/__main__.py +4 -0
- devlift_cli/api/__init__.py +0 -0
- devlift_cli/api/approvals.py +53 -0
- devlift_cli/api/catalog.py +96 -0
- devlift_cli/api/client.py +125 -0
- devlift_cli/api/context.py +21 -0
- devlift_cli/api/deployments.py +37 -0
- devlift_cli/api/infra.py +94 -0
- devlift_cli/api/infra_list.py +61 -0
- devlift_cli/api/kong.py +29 -0
- devlift_cli/api/services.py +106 -0
- devlift_cli/api/vpc.py +24 -0
- devlift_cli/app.py +163 -0
- devlift_cli/auth/__init__.py +0 -0
- devlift_cli/auth/oauth.py +270 -0
- devlift_cli/auth/session.py +64 -0
- devlift_cli/auth/storage.py +135 -0
- devlift_cli/commands/__init__.py +0 -0
- devlift_cli/commands/approval.py +51 -0
- devlift_cli/commands/auth.py +180 -0
- devlift_cli/commands/catalog.py +187 -0
- devlift_cli/commands/clusters.py +108 -0
- devlift_cli/commands/deployment.py +77 -0
- devlift_cli/commands/dynamodb.py +121 -0
- devlift_cli/commands/eks.py +326 -0
- devlift_cli/commands/kong.py +145 -0
- devlift_cli/commands/languages.py +40 -0
- devlift_cli/commands/manual.py +82 -0
- devlift_cli/commands/repositories.py +49 -0
- devlift_cli/commands/request.py +89 -0
- devlift_cli/commands/s3.py +198 -0
- devlift_cli/commands/sqs.py +229 -0
- devlift_cli/config.py +94 -0
- devlift_cli/context.py +97 -0
- devlift_cli/data/placement/vance.json +16 -0
- devlift_cli/errors.py +52 -0
- devlift_cli/ops/__init__.py +0 -0
- devlift_cli/ops/approvals.py +343 -0
- devlift_cli/ops/eks.py +877 -0
- devlift_cli/ops/kong.py +343 -0
- devlift_cli/ops/placement.py +128 -0
- devlift_cli/ops/resources.py +418 -0
- devlift_cli/ops/status.py +152 -0
- devlift_cli/ops/wait.py +82 -0
- devlift_cli/render/__init__.py +0 -0
- devlift_cli/render/output.py +75 -0
- devlift_cli/resolve/__init__.py +0 -0
- devlift_cli/resolve/allowlist.py +192 -0
- devlift_cli/resolve/names.py +179 -0
- devlift_cli-0.1.0.dist-info/METADATA +106 -0
- devlift_cli-0.1.0.dist-info/RECORD +56 -0
- devlift_cli-0.1.0.dist-info/WHEEL +5 -0
- devlift_cli-0.1.0.dist-info/entry_points.txt +3 -0
- devlift_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Create and update an S3 / SQS / DynamoDB resource.
|
|
2
|
+
|
|
3
|
+
The create sequence, identical for every region-scoped resource:
|
|
4
|
+
|
|
5
|
+
1. resolve placement (app, env, region → codes)
|
|
6
|
+
2. validate the name locally, then with the server's duplicate check
|
|
7
|
+
3. show what will happen; confirm (or --yes)
|
|
8
|
+
4. POST /infrastructures → resource code
|
|
9
|
+
5. POST /transaction-queue/add-to-queue → queue item
|
|
10
|
+
6. POST /transaction-queue/bulk-approve → approved
|
|
11
|
+
7. POST /transaction-queue/deploy → workflow (Temporal) or PR
|
|
12
|
+
8. --wait: poll the workflow to its end
|
|
13
|
+
|
|
14
|
+
An update reads the row first (GET list → POST get-detail), shows the diff,
|
|
15
|
+
then runs steps 4-8 with `code` set on step 4 — the same route upserts when
|
|
16
|
+
it is given a code. Steps 4-7 are what the dashboard's create-and-deploy and
|
|
17
|
+
the MCP's edit path do; nothing here is CLI-specific.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
from devlift_cli.api import infra, infra_list
|
|
26
|
+
from devlift_cli.context import Invocation
|
|
27
|
+
from devlift_cli.errors import EXIT_CONFLICT, EXIT_NOT_FOUND, CliError, InputError
|
|
28
|
+
from devlift_cli.ops import wait
|
|
29
|
+
from devlift_cli.ops.placement import canvas_placement, resolve_placement
|
|
30
|
+
from devlift_cli.resolve import allowlist
|
|
31
|
+
from devlift_cli.render import output
|
|
32
|
+
from devlift_cli.render.output import kv_table, rows_table
|
|
33
|
+
from devlift_cli.resolve.names import Placement, Resolver # noqa: F401 (Placement used for the backfill lookup)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ResourceKind:
|
|
38
|
+
noun: str # "bucket"
|
|
39
|
+
infra_type: str # "s3_infrastructuretype_ref"
|
|
40
|
+
case_ref_code: str # "create_bucket"
|
|
41
|
+
service: str = "" # placement-allowlist key: "s3", "sqs", "dynamo"
|
|
42
|
+
name_key: str = "identifier" # key of the name inside type_specific_config
|
|
43
|
+
validate_name: Callable[[str], str | None] = lambda _: None
|
|
44
|
+
labels: dict[str, str] = field(default_factory=dict) # attribute key → label for the summary
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class CreateResult:
|
|
49
|
+
resource_code: str
|
|
50
|
+
queue_id: int
|
|
51
|
+
queue_code: str
|
|
52
|
+
deploy: dict
|
|
53
|
+
workflow: dict | None = None
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict:
|
|
56
|
+
data = {
|
|
57
|
+
"resource_code": self.resource_code,
|
|
58
|
+
"queue_id": self.queue_id,
|
|
59
|
+
"queue_code": self.queue_code,
|
|
60
|
+
"status": self.deploy.get("status"),
|
|
61
|
+
"workflow_id": self.deploy.get("workflow_id"),
|
|
62
|
+
"pr_url": self.deploy.get("pr_url"),
|
|
63
|
+
"pr_number": self.deploy.get("pr_number"),
|
|
64
|
+
}
|
|
65
|
+
if self.workflow:
|
|
66
|
+
data["workflow"] = self.workflow
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_duplicate(check: dict | None) -> str | None:
|
|
71
|
+
"""Read the server's duplicate answer in whichever spelling it uses."""
|
|
72
|
+
if not check:
|
|
73
|
+
return None
|
|
74
|
+
message = check.get("message") or check.get("description") or "already exists"
|
|
75
|
+
for key in ("is_duplicate", "duplicate", "exists", "is_exists"):
|
|
76
|
+
if check.get(key) is True:
|
|
77
|
+
return str(message)
|
|
78
|
+
if check.get("is_valid") is False or check.get("valid") is False:
|
|
79
|
+
return str(message)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def create_resource(
|
|
84
|
+
inv: Invocation,
|
|
85
|
+
res: Resolver,
|
|
86
|
+
kind: ResourceKind,
|
|
87
|
+
*,
|
|
88
|
+
name: str,
|
|
89
|
+
app: str | None,
|
|
90
|
+
env: str | None,
|
|
91
|
+
region: str | None,
|
|
92
|
+
attributes: dict,
|
|
93
|
+
do_wait: bool = False,
|
|
94
|
+
) -> CreateResult:
|
|
95
|
+
name = name.strip()
|
|
96
|
+
problem = kind.validate_name(name)
|
|
97
|
+
if problem:
|
|
98
|
+
raise InputError(f"{kind.noun.capitalize()} name: {problem}")
|
|
99
|
+
|
|
100
|
+
placement: Placement = resolve_placement(inv, res, app, env, region, service=kind.service or None)
|
|
101
|
+
|
|
102
|
+
duplicate = _is_duplicate(
|
|
103
|
+
infra.validate_duplicate(inv.api, kind.infra_type, placement.application_code, placement.environment, placement.geo_loc_mst_code, name)
|
|
104
|
+
)
|
|
105
|
+
if duplicate:
|
|
106
|
+
where = f"{placement.application_name}/{placement.environment}/{placement.region_name}"
|
|
107
|
+
text = duplicate if duplicate != "already exists" else f"A {kind.noun} named '{name}' already exists in {where}."
|
|
108
|
+
raise CliError(text, EXIT_CONFLICT, hint=f"Pick another name, or `devlift {kind.infra_type.split('_')[0]} describe {name} --env {placement.environment}`.")
|
|
109
|
+
|
|
110
|
+
# The canvas placement (account + cloud region). Stored in the locator, and
|
|
111
|
+
# the dashboard drops any resource that lacks it — so it is resolved here,
|
|
112
|
+
# shown in the summary, and its absence is said out loud before the
|
|
113
|
+
# confirmation rather than discovered later on an empty canvas.
|
|
114
|
+
canvas = canvas_placement(inv, placement)
|
|
115
|
+
config = {kind.name_key: name, **{k: v for k, v in attributes.items() if v is not None}, **canvas}
|
|
116
|
+
|
|
117
|
+
rows = [
|
|
118
|
+
(kind.noun.capitalize(), name),
|
|
119
|
+
("Application", placement.application_name),
|
|
120
|
+
("Environment", placement.environment),
|
|
121
|
+
("Region", placement.region_name),
|
|
122
|
+
]
|
|
123
|
+
if canvas:
|
|
124
|
+
rows += [("AWS account", canvas["accountId"]), ("Cloud region", canvas["cloudRegion"])]
|
|
125
|
+
rows += [(kind.labels.get(k, k), _show(v)) for k, v in config.items()
|
|
126
|
+
if k != kind.name_key and k not in canvas]
|
|
127
|
+
if inv.show_summary:
|
|
128
|
+
output.err_console.print(kv_table(rows, title=f"Create {kind.noun}"))
|
|
129
|
+
if not canvas:
|
|
130
|
+
output.warn(
|
|
131
|
+
f"No AWS account and cloud region could be resolved for {placement.region_name}. "
|
|
132
|
+
f"The {kind.noun} will deploy normally but will NOT appear on the dashboard canvas."
|
|
133
|
+
)
|
|
134
|
+
inv.confirm(f"Create this {kind.noun} and start its deployment?")
|
|
135
|
+
|
|
136
|
+
created = infra.create_infrastructure(
|
|
137
|
+
inv.api,
|
|
138
|
+
infra_type=kind.infra_type,
|
|
139
|
+
application_code=placement.application_code,
|
|
140
|
+
environment=placement.environment,
|
|
141
|
+
geo_loc_mst_code=placement.geo_loc_mst_code,
|
|
142
|
+
type_specific_config=config,
|
|
143
|
+
)
|
|
144
|
+
resource_code = created["code"]
|
|
145
|
+
output.info(f"Registered {kind.noun} {name} ({resource_code}).")
|
|
146
|
+
|
|
147
|
+
return _queue_and_deploy(
|
|
148
|
+
inv, kind,
|
|
149
|
+
resource_code=resource_code, name=name, config=config,
|
|
150
|
+
application_code=placement.application_code,
|
|
151
|
+
environment=placement.environment,
|
|
152
|
+
geo_loc_mst_code=placement.geo_loc_mst_code,
|
|
153
|
+
do_wait=do_wait,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _queue_and_deploy(
|
|
158
|
+
inv: Invocation,
|
|
159
|
+
kind: ResourceKind,
|
|
160
|
+
*,
|
|
161
|
+
resource_code: str,
|
|
162
|
+
name: str,
|
|
163
|
+
config: dict,
|
|
164
|
+
application_code: str,
|
|
165
|
+
environment: str,
|
|
166
|
+
geo_loc_mst_code: str,
|
|
167
|
+
do_wait: bool,
|
|
168
|
+
) -> CreateResult:
|
|
169
|
+
"""Steps 5-8: queue the row's current config, approve, deploy, optionally wait.
|
|
170
|
+
|
|
171
|
+
Shared by create and update — an update is queued under the same case ref
|
|
172
|
+
as a create (`create_bucket`, `create_queue`), because the generator
|
|
173
|
+
rebuilds the resource's terragrunt file from the full config either way;
|
|
174
|
+
that is also how the dashboard and the MCP's edit path do it.
|
|
175
|
+
"""
|
|
176
|
+
snapshot = {
|
|
177
|
+
**config,
|
|
178
|
+
"applications_mst_code": application_code,
|
|
179
|
+
"environment": environment,
|
|
180
|
+
"geo_loc_mst_code": geo_loc_mst_code,
|
|
181
|
+
"case_ref_code": kind.case_ref_code,
|
|
182
|
+
"infrastructuretype_ref_code": kind.infra_type,
|
|
183
|
+
"infrastructure_mst_code": resource_code,
|
|
184
|
+
}
|
|
185
|
+
item = infra.add_to_queue(
|
|
186
|
+
inv.api,
|
|
187
|
+
transaction_code=resource_code,
|
|
188
|
+
table_name=infra.TABLE_INFRASTRUCTURE,
|
|
189
|
+
config_snapshot=snapshot,
|
|
190
|
+
case_ref_code=kind.case_ref_code,
|
|
191
|
+
)
|
|
192
|
+
queue_id, queue_code = int(item["id"]), item["code"]
|
|
193
|
+
|
|
194
|
+
approved = infra.bulk_approve(inv.api, [queue_id])
|
|
195
|
+
if not approved or int(approved.get("updated_count") or 0) < 1:
|
|
196
|
+
raise CliError(f"Queue item {queue_code} could not be approved: {approved}")
|
|
197
|
+
|
|
198
|
+
deploy = infra.deploy_queue_items(inv.api, [queue_id]) or {}
|
|
199
|
+
result = CreateResult(resource_code=resource_code, queue_id=queue_id, queue_code=queue_code, deploy=deploy)
|
|
200
|
+
|
|
201
|
+
if deploy.get("workflow_id"):
|
|
202
|
+
output.info(f"Deployment queued (workflow {deploy['workflow_id']}).")
|
|
203
|
+
elif deploy.get("pr_url"):
|
|
204
|
+
output.info(f"Pull request opened: {deploy['pr_url']}")
|
|
205
|
+
else:
|
|
206
|
+
output.info(f"Deployment status: {deploy.get('status')}")
|
|
207
|
+
|
|
208
|
+
if do_wait and deploy.get("workflow_id"):
|
|
209
|
+
result.workflow = wait.wait_for_workflow(inv.api, deploy["workflow_id"], label=f"deploying {name}")
|
|
210
|
+
elif do_wait:
|
|
211
|
+
output.warn("Nothing to wait for: the server did not return a workflow id.")
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── update ───────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class UpdateResult:
|
|
219
|
+
resource_code: str
|
|
220
|
+
name: str
|
|
221
|
+
changed: bool
|
|
222
|
+
diff: list[tuple[str, object, object]] # (field, current, new)
|
|
223
|
+
result: CreateResult | None = None # None when nothing changed
|
|
224
|
+
|
|
225
|
+
def to_dict(self) -> dict:
|
|
226
|
+
data = {
|
|
227
|
+
"resource_code": self.resource_code,
|
|
228
|
+
"changed": self.changed,
|
|
229
|
+
"changes": {f: {"from": a, "to": b} for f, a, b in self.diff},
|
|
230
|
+
}
|
|
231
|
+
if self.result:
|
|
232
|
+
data.update(self.result.to_dict())
|
|
233
|
+
else:
|
|
234
|
+
data["status"] = "unchanged"
|
|
235
|
+
return data
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _comparable(value):
|
|
239
|
+
"""Normalise before comparing a flag value with the stored one.
|
|
240
|
+
|
|
241
|
+
The stored locator holds booleans as real booleans when the dashboard's
|
|
242
|
+
canvas wrote them and as "true"/"false" strings when a form did, and
|
|
243
|
+
numbers arrive either way. Comparing raw would report a change on a value
|
|
244
|
+
nobody touched — the same rule the MCP's edit path applies."""
|
|
245
|
+
if isinstance(value, bool) or value is None:
|
|
246
|
+
return value
|
|
247
|
+
if isinstance(value, (int, float)):
|
|
248
|
+
return str(value)
|
|
249
|
+
text = str(value).strip()
|
|
250
|
+
if text.lower() in ("true", "false"):
|
|
251
|
+
return text.lower() == "true"
|
|
252
|
+
return text
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def update_resource(
|
|
256
|
+
inv: Invocation,
|
|
257
|
+
res: Resolver,
|
|
258
|
+
kind: ResourceKind,
|
|
259
|
+
*,
|
|
260
|
+
name: str,
|
|
261
|
+
env: str | None,
|
|
262
|
+
changes: dict,
|
|
263
|
+
app: str | None = None,
|
|
264
|
+
check: Callable[[dict], str | None] | None = None,
|
|
265
|
+
do_wait: bool = False,
|
|
266
|
+
) -> UpdateResult:
|
|
267
|
+
"""Change settings on an existing resource: read the row, diff, confirm,
|
|
268
|
+
upsert the FULL config with the edits laid over it, queue, deploy.
|
|
269
|
+
|
|
270
|
+
The full stored set goes back, not just the changed keys: the generator
|
|
271
|
+
rebuilds the resolved AWS name from whatever it is handed and defaults
|
|
272
|
+
anything missing — an absent fifo flag reads as False and points the row
|
|
273
|
+
at a queue that does not exist. Name and identity fields are never among
|
|
274
|
+
`changes`; a rename is a new resource. `check` sees the merged config and
|
|
275
|
+
may veto it (replication without a target account, say).
|
|
276
|
+
"""
|
|
277
|
+
row = find_resource(inv, res, kind, name, env=env)
|
|
278
|
+
detail = infra_list.get_infrastructure(inv.api, row["code"]) or {}
|
|
279
|
+
stored = dict(detail.get("locator") or row.get("locator") or {})
|
|
280
|
+
resource_code = detail.get("code") or row["code"]
|
|
281
|
+
shown = infra_list.display_name(detail if detail.get("locator") else row)
|
|
282
|
+
|
|
283
|
+
def first(*keys):
|
|
284
|
+
for source in (detail, row):
|
|
285
|
+
for key in keys:
|
|
286
|
+
if source.get(key):
|
|
287
|
+
return source[key]
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
environment = first("environment")
|
|
291
|
+
geo_loc_mst_code = first("geo_loc_mst_code")
|
|
292
|
+
if not (environment and geo_loc_mst_code):
|
|
293
|
+
raise CliError(
|
|
294
|
+
f"Cannot read where {kind.noun} '{shown}' lives (environment / region), so it cannot be updated from here.",
|
|
295
|
+
hint="Change it in the DevLift dashboard.",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# The application. Older backends do not report it on an infrastructure
|
|
299
|
+
# row, and the update route requires it. Rather than demand --app, infer
|
|
300
|
+
# it: only one application can offer this kind of resource in this exact
|
|
301
|
+
# placement, so when the allowlist leaves exactly one, that is the answer.
|
|
302
|
+
application_code = res.application(app)["application_code"] if app else first(
|
|
303
|
+
"applications_mst_code", "application_code", "application_mst_code"
|
|
304
|
+
)
|
|
305
|
+
if not application_code:
|
|
306
|
+
here = [
|
|
307
|
+
r for r in allowlist.filter_placements(res.placements(), res.tenant_code(), kind.service or None)
|
|
308
|
+
if r["environment"] == environment and r["geo_loc_mst_code"] == geo_loc_mst_code
|
|
309
|
+
]
|
|
310
|
+
candidates = {r["application_code"]: r["product"] for r in here}
|
|
311
|
+
if len(candidates) == 1:
|
|
312
|
+
application_code, product = next(iter(candidates.items()))
|
|
313
|
+
output.info(f"Application not reported by this backend; {product} is the only one with {kind.noun}s in {environment}.")
|
|
314
|
+
else:
|
|
315
|
+
raise InputError(
|
|
316
|
+
f"This DevLift backend does not say which application {kind.noun} '{shown}' belongs to.",
|
|
317
|
+
hint="Pass --app NAME (`devlift applications list`)." if candidates
|
|
318
|
+
else "Pass --app NAME; no placement matches this resource's environment and region.",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Repair a row written without the canvas placement (an older CLI create):
|
|
322
|
+
# the resource is invisible on the dashboard until its locator names an
|
|
323
|
+
# account and a cloud region, and an update is the natural moment to add
|
|
324
|
+
# them, since it rewrites the whole locator anyway.
|
|
325
|
+
backfill: dict = {}
|
|
326
|
+
if not (stored.get("cloudRegionId") and stored.get("accountId")):
|
|
327
|
+
backfill = canvas_placement(inv, Placement(
|
|
328
|
+
application_code=application_code, application_name=application_code,
|
|
329
|
+
environment=environment, geo_loc_mst_code=geo_loc_mst_code, region_name=geo_loc_mst_code,
|
|
330
|
+
))
|
|
331
|
+
|
|
332
|
+
diff = [(f, stored.get(f), v) for f, v in changes.items() if _comparable(stored.get(f)) != _comparable(v)]
|
|
333
|
+
if not diff and not backfill:
|
|
334
|
+
output.info(f"{kind.noun.capitalize()} '{shown}' already has those values. Nothing to change.")
|
|
335
|
+
return UpdateResult(resource_code=resource_code, name=shown, changed=False, diff=[])
|
|
336
|
+
if backfill:
|
|
337
|
+
output.info(
|
|
338
|
+
f"Adding the missing dashboard placement (account {backfill['accountId']}, "
|
|
339
|
+
f"{backfill['cloudRegion']}) so '{shown}' appears on the canvas."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
config = {**stored, **changes, **backfill}
|
|
343
|
+
problem = check(config) if check else None
|
|
344
|
+
if problem:
|
|
345
|
+
raise InputError(problem)
|
|
346
|
+
|
|
347
|
+
if inv.show_summary:
|
|
348
|
+
output.err_console.print(rows_table(
|
|
349
|
+
["Field", "Current", "New"],
|
|
350
|
+
[(kind.labels.get(f, f), _show(a) if a not in (None, "", []) else "–", _show(b) if b not in (None, "", []) else "–") for f, a, b in diff],
|
|
351
|
+
title=f"Update {kind.noun} {shown} ({environment})",
|
|
352
|
+
))
|
|
353
|
+
inv.confirm(f"Apply these changes to {kind.noun} '{shown}' and start its deployment?")
|
|
354
|
+
|
|
355
|
+
updated = infra.create_infrastructure(
|
|
356
|
+
inv.api,
|
|
357
|
+
infra_type=kind.infra_type,
|
|
358
|
+
application_code=application_code,
|
|
359
|
+
environment=environment,
|
|
360
|
+
geo_loc_mst_code=geo_loc_mst_code,
|
|
361
|
+
type_specific_config=config,
|
|
362
|
+
code=resource_code, # upsert: the same route updates when the code is given
|
|
363
|
+
)
|
|
364
|
+
resource_code = (updated or {}).get("code") or resource_code
|
|
365
|
+
output.info(f"Updated {kind.noun} {shown} ({resource_code}).")
|
|
366
|
+
|
|
367
|
+
result = _queue_and_deploy(
|
|
368
|
+
inv, kind,
|
|
369
|
+
resource_code=resource_code, name=shown, config=config,
|
|
370
|
+
application_code=application_code, environment=environment, geo_loc_mst_code=geo_loc_mst_code,
|
|
371
|
+
do_wait=do_wait,
|
|
372
|
+
)
|
|
373
|
+
return UpdateResult(resource_code=resource_code, name=shown, changed=True, diff=diff, result=result)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def list_resources(inv: Invocation, res: Resolver, kind: ResourceKind, *, app: str | None, env: str | None, region: str | None) -> list[dict]:
|
|
377
|
+
application_code = res.application(app)["application_code"] if app else None
|
|
378
|
+
environment = res.environment(env) if env else None
|
|
379
|
+
geo = None
|
|
380
|
+
if region:
|
|
381
|
+
rows = res.regions_for(application_code, environment)
|
|
382
|
+
match = [r for r in rows if region.lower() in (r["region"].lower(), r["geo_loc_mst_code"].lower())]
|
|
383
|
+
if not match:
|
|
384
|
+
raise InputError(f"Unknown region '{region}'. See `devlift regions list`.")
|
|
385
|
+
geo = match[0]["geo_loc_mst_code"]
|
|
386
|
+
return infra_list.list_infrastructures(inv.api, infra_type=kind.infra_type, environment=environment, geo_loc_mst_code=geo, application_code=application_code)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def find_resource(inv: Invocation, res: Resolver, kind: ResourceKind, name: str, *, env: str | None = None) -> dict:
|
|
390
|
+
"""One row by user-typed name (or code); env narrows when the name repeats."""
|
|
391
|
+
environment = res.environment(env) if env else None
|
|
392
|
+
rows = infra_list.list_infrastructures(inv.api, infra_type=kind.infra_type, environment=environment)
|
|
393
|
+
needle = name.strip().lower()
|
|
394
|
+
hits = [r for r in rows if needle in (infra_list.display_name(r).lower(), str(r.get("code") or "").lower(), str(r.get("name") or "").lower())]
|
|
395
|
+
if not hits:
|
|
396
|
+
raise CliError(f"No {kind.noun} named '{name}'" + (f" in {environment}" if environment else "") + ".", EXIT_NOT_FOUND)
|
|
397
|
+
if len(hits) > 1:
|
|
398
|
+
where = ", ".join(f"{infra_list.display_name(r)} ({r.get('environment')}/{r.get('geo_loc_mst_code')})" for r in hits)
|
|
399
|
+
raise InputError(f"'{name}' matches several {kind.noun}s: {where}", hint="Add --env (and check the region).")
|
|
400
|
+
return hits[0]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def describe_resource(inv: Invocation, res: Resolver, kind: ResourceKind, name: str, *, env: str | None = None) -> dict:
|
|
404
|
+
row = find_resource(inv, res, kind, name, env=env)
|
|
405
|
+
detail = infra_list.get_infrastructure(inv.api, row["code"])
|
|
406
|
+
detail.setdefault("status", row.get("status"))
|
|
407
|
+
detail.setdefault("derived_variables", row.get("derived_variables"))
|
|
408
|
+
return detail
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _show(value) -> str:
|
|
412
|
+
if isinstance(value, bool):
|
|
413
|
+
return "yes" if value else "no"
|
|
414
|
+
if isinstance(value, str) and value.lower() in ("true", "false"):
|
|
415
|
+
return "yes" if value.lower() == "true" else "no"
|
|
416
|
+
if isinstance(value, list):
|
|
417
|
+
return ", ".join(str(v) for v in value)
|
|
418
|
+
return str(value)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Deployment and application status: the two different questions.
|
|
2
|
+
|
|
3
|
+
`deployment` — did DevLift's own pipeline finish? Read from the deployment
|
|
4
|
+
history the dashboard's tracker shows: one workflow, its stages, its PR.
|
|
5
|
+
|
|
6
|
+
`application` — is the service actually serving? DevLift's pipeline turns
|
|
7
|
+
green when the manifests are merged, before ArgoCD has rolled a pod. That is
|
|
8
|
+
asked of ArgoCD, through the same implementation the assistant's
|
|
9
|
+
get_application_status tool uses, and compared against the deploy's finish
|
|
10
|
+
time so a stale "Healthy" from the previous revision is not mistaken for the
|
|
11
|
+
new one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from devlift_cli.api import deployments as deployments_api
|
|
17
|
+
from devlift_cli.api import infra
|
|
18
|
+
from devlift_cli.context import Invocation
|
|
19
|
+
from devlift_cli.errors import EXIT_NOT_FOUND, CliError
|
|
20
|
+
from devlift_cli.ops import wait
|
|
21
|
+
from devlift_cli.render import output
|
|
22
|
+
from devlift_cli.render.output import kv_table, rows_table
|
|
23
|
+
|
|
24
|
+
_STATUS_TEXT = {"RUNNING": "running", "COMPLETED": "completed", "FAILED": "failed", "TIMEOUT": "timed out", "TIMEDOUT": "timed out", "CANCELLED": "cancelled"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ts(value) -> str:
|
|
28
|
+
return str(value or "")[:19].replace("T", " ")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _duration(row: dict) -> str:
|
|
32
|
+
seconds = row.get("duration_seconds")
|
|
33
|
+
if seconds is None:
|
|
34
|
+
return ""
|
|
35
|
+
seconds = int(seconds)
|
|
36
|
+
return f"{seconds // 60}m {seconds % 60:02d}s" if seconds >= 60 else f"{seconds}s"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stage_line(detail: dict) -> str:
|
|
40
|
+
"""One line for a spinner: the newest stage and the workflow status."""
|
|
41
|
+
status = str(detail.get("status") or "").upper()
|
|
42
|
+
stages = detail.get("stages") or []
|
|
43
|
+
text = _STATUS_TEXT.get(status, status.lower() or "starting")
|
|
44
|
+
if stages:
|
|
45
|
+
last = stages[-1]
|
|
46
|
+
text += f" — {last.get('name')}: {last.get('status')}"
|
|
47
|
+
if last.get("error"):
|
|
48
|
+
text += f" ({last['error']})"
|
|
49
|
+
if detail.get("pr_url"):
|
|
50
|
+
text += f" (PR #{detail.get('pr_number')})"
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def deployment_detail(inv: Invocation, workflow_id: str, *, do_wait: bool) -> dict:
|
|
55
|
+
"""The history row with stages; polled to a terminal status with --wait.
|
|
56
|
+
Falls back to the transaction-queue step query for a workflow the history
|
|
57
|
+
does not know (an older id)."""
|
|
58
|
+
try:
|
|
59
|
+
detail = deployments_api.history_detail(inv.api, workflow_id)
|
|
60
|
+
except CliError as exc:
|
|
61
|
+
if exc.code != EXIT_NOT_FOUND:
|
|
62
|
+
raise
|
|
63
|
+
state = wait.wait_for_workflow(inv.api, workflow_id, label="deployment") if do_wait else (infra.deploy_status(inv.api, workflow_id) or {})
|
|
64
|
+
return {"workflow_id": workflow_id, "status": str(state.get("step") or "").upper(), "stages": [], "legacy": state}
|
|
65
|
+
if not do_wait or str(detail.get("status") or "").upper() in deployments_api.TERMINAL:
|
|
66
|
+
return detail
|
|
67
|
+
|
|
68
|
+
last = {"detail": detail}
|
|
69
|
+
|
|
70
|
+
def describe():
|
|
71
|
+
last["detail"] = deployments_api.history_detail(inv.api, workflow_id)
|
|
72
|
+
status = str(last["detail"].get("status") or "").upper()
|
|
73
|
+
done = status in deployments_api.TERMINAL
|
|
74
|
+
return stage_line(last["detail"]), done, done and status != "COMPLETED"
|
|
75
|
+
|
|
76
|
+
wait.poll(describe, label="deployment")
|
|
77
|
+
return last["detail"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def history_table(rows: list[dict]):
|
|
81
|
+
return rows_table(
|
|
82
|
+
["Workflow", "Status", "Started", "Duration", "Application", "Env", "Resources", "PR", "By"],
|
|
83
|
+
[(r.get("workflow_id"), _STATUS_TEXT.get(str(r.get("status") or "").upper(), r.get("status")), _ts(r.get("started_at")), _duration(r),
|
|
84
|
+
r.get("application_name"), r.get("environment"),
|
|
85
|
+
"\n".join(x.get("display_name") or x.get("transaction_code") or "" for x in r.get("resources") or []),
|
|
86
|
+
f"#{r['pr_number']}" if r.get("pr_number") else "–", r.get("user_name")) for r in rows],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def print_deployment(detail: dict) -> None:
|
|
91
|
+
output.err_console.print(kv_table([
|
|
92
|
+
("Workflow", detail.get("workflow_id")),
|
|
93
|
+
("Status", _STATUS_TEXT.get(str(detail.get("status") or "").upper(), detail.get("status"))),
|
|
94
|
+
("Started", _ts(detail.get("started_at"))),
|
|
95
|
+
("Finished", _ts(detail.get("completed_at")) or "–"),
|
|
96
|
+
("Duration", _duration(detail) or "–"),
|
|
97
|
+
("Application", f"{detail.get('application_name')} / {detail.get('environment')} / {detail.get('geo_name')}"),
|
|
98
|
+
("Pull request", detail.get("pr_url") or "–"),
|
|
99
|
+
("Started by", detail.get("user_name")),
|
|
100
|
+
] + [("Resource", f"{x.get('display_name')} [{x.get('queue_status')}]") for x in detail.get("resources") or []], title="Deployment"))
|
|
101
|
+
stages = detail.get("stages") or []
|
|
102
|
+
if stages:
|
|
103
|
+
output.err_console.print(rows_table(
|
|
104
|
+
["Stage", "Status", "Started", "Ended", "Error"],
|
|
105
|
+
[(s.get("name"), s.get("status"), _ts(s.get("started_at")), _ts(s.get("ended_at")), s.get("error") or "") for s in stages],
|
|
106
|
+
title="Stages",
|
|
107
|
+
))
|
|
108
|
+
elif detail.get("legacy"):
|
|
109
|
+
output.err_console.print(kv_table([("Step", detail["legacy"].get("step")), ("Result", detail["legacy"].get("result"))]))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── application (ArgoCD) ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def latest_deployment_for(inv: Invocation, *, application_code: str, environment: str, config_code: str) -> dict | None:
|
|
115
|
+
rows, _ = deployments_api.history(inv.api, application_code=application_code, environment=environment, limit=100)
|
|
116
|
+
for r in rows: # newest first
|
|
117
|
+
if any(x.get("transaction_code") == config_code for x in r.get("resources") or []):
|
|
118
|
+
return r
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def application_status(inv: Invocation, config_code: str, workflow_id: str | None, *, do_wait: bool) -> dict:
|
|
123
|
+
"""ArgoCD's view of the service; with --wait, polled until it settles."""
|
|
124
|
+
result = deployments_api.application_status(inv.api, config_code, workflow_id)
|
|
125
|
+
if not do_wait or result.get("settled", True):
|
|
126
|
+
return result
|
|
127
|
+
last = {"r": result}
|
|
128
|
+
|
|
129
|
+
def describe():
|
|
130
|
+
last["r"] = deployments_api.application_status(inv.api, config_code, workflow_id)
|
|
131
|
+
r = last["r"]
|
|
132
|
+
text = f"{r.get('state')}" + (f" (health {r.get('health_status')}, sync {r.get('sync_status')})" if r.get("health_status") else "")
|
|
133
|
+
settled = bool(r.get("settled", True))
|
|
134
|
+
return text, settled, settled and r.get("state") in ("unhealthy", "not_picked_up", "missing", "unavailable")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
wait.poll(describe, interval=10.0, timeout=600.0, label="application")
|
|
138
|
+
except CliError:
|
|
139
|
+
pass # the state itself is the answer; printed below
|
|
140
|
+
return last["r"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def print_application(result: dict) -> None:
|
|
144
|
+
output.err_console.print(kv_table([
|
|
145
|
+
("State", result.get("state")),
|
|
146
|
+
("", result.get("message")),
|
|
147
|
+
("Health", result.get("health_status") or "–"),
|
|
148
|
+
("Sync", result.get("sync_status") or "–"),
|
|
149
|
+
("Running image", result.get("running_image") or "–"),
|
|
150
|
+
("ArgoCD", result.get("argocd_url") or "–"),
|
|
151
|
+
("Health URL", result.get("health_url") or "–"),
|
|
152
|
+
], title="Application (ArgoCD)"))
|
devlift_cli/ops/wait.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Polling with a spinner, for --wait."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.spinner import Spinner
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from devlift_cli.api import infra
|
|
13
|
+
from devlift_cli.api.client import ApiClient
|
|
14
|
+
from devlift_cli.errors import EXIT_ERROR, CliError
|
|
15
|
+
from devlift_cli.render import output
|
|
16
|
+
|
|
17
|
+
# DeploymentWorkflow steps, in the order they normally happen.
|
|
18
|
+
_STEP_TEXT = {
|
|
19
|
+
"acquiring_locks": "acquiring locks",
|
|
20
|
+
"creating_pr": "creating pull request",
|
|
21
|
+
"waiting_for_plan": "waiting for terraform plan",
|
|
22
|
+
"verifying_plan": "verifying plan",
|
|
23
|
+
"waiting_for_approval": "waiting for PR approval",
|
|
24
|
+
"waiting_for_apply": "applying",
|
|
25
|
+
"posting_apply_comment": "applying",
|
|
26
|
+
"waiting_for_merge": "waiting for merge",
|
|
27
|
+
"active": "done",
|
|
28
|
+
"completed": "done",
|
|
29
|
+
"failed": "failed",
|
|
30
|
+
}
|
|
31
|
+
_TERMINAL_OK = {"active", "completed", "done", "succeeded", "success"}
|
|
32
|
+
_TERMINAL_FAIL = {"failed", "error", "cancelled", "canceled"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def poll(describe: Callable[[], tuple[str, bool, bool]], interval: float = 5.0, timeout: float = 3600.0, label: str = "deploying") -> None:
|
|
36
|
+
"""Call `describe()` until it reports done. It returns (status_line, done, failed)."""
|
|
37
|
+
deadline = time.time() + timeout
|
|
38
|
+
spinner = Spinner("dots", text=Text(label))
|
|
39
|
+
with Live(spinner, console=output.err_console, refresh_per_second=8, transient=True):
|
|
40
|
+
while True:
|
|
41
|
+
line, done, failed = describe()
|
|
42
|
+
spinner.text = Text(f"{label}: {line}")
|
|
43
|
+
if done:
|
|
44
|
+
break
|
|
45
|
+
if time.time() > deadline:
|
|
46
|
+
raise CliError(f"Gave up waiting after {int(timeout)}s (last status: {line}).", EXIT_ERROR)
|
|
47
|
+
time.sleep(interval)
|
|
48
|
+
if failed:
|
|
49
|
+
raise CliError(f"Deployment failed: {line}", EXIT_ERROR)
|
|
50
|
+
output.success(f"{label}: {line}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def wait_for_workflow(api: ApiClient, workflow_id: str, label: str = "deploying") -> dict:
|
|
54
|
+
"""Follow a transaction-queue DeploymentWorkflow to its end; return the last state."""
|
|
55
|
+
last: dict = {}
|
|
56
|
+
hiccups = 0
|
|
57
|
+
|
|
58
|
+
def describe() -> tuple[str, bool, bool]:
|
|
59
|
+
nonlocal last, hiccups
|
|
60
|
+
try:
|
|
61
|
+
last = infra.deploy_status(api, workflow_id) or {}
|
|
62
|
+
hiccups = 0
|
|
63
|
+
except CliError as exc:
|
|
64
|
+
# The status route answers 503 while the workflow service is busy;
|
|
65
|
+
# the deployment itself is unaffected. Keep polling for a while.
|
|
66
|
+
if not exc.transient or hiccups >= 12:
|
|
67
|
+
raise
|
|
68
|
+
hiccups += 1
|
|
69
|
+
return f"{_STEP_TEXT.get(str(last.get('step') or '').lower(), 'waiting for status')} (status service busy, retry {hiccups})", False, False
|
|
70
|
+
step = str(last.get("step") or "").lower()
|
|
71
|
+
result = last.get("result") or {}
|
|
72
|
+
text = _STEP_TEXT.get(step, step or "starting")
|
|
73
|
+
if last.get("pr_number") and last.get("repo_full_name"):
|
|
74
|
+
text += f" (PR #{last['pr_number']} on {last['repo_full_name']})"
|
|
75
|
+
failed = step in _TERMINAL_FAIL or (isinstance(result, dict) and str(result.get("status", "")).lower() in _TERMINAL_FAIL)
|
|
76
|
+
done = failed or step in _TERMINAL_OK or (isinstance(result, dict) and bool(result) and str(result.get("status", "")).lower() in _TERMINAL_OK)
|
|
77
|
+
if failed and isinstance(result, dict) and result.get("error"):
|
|
78
|
+
text += f": {result['error']}"
|
|
79
|
+
return text, done, failed
|
|
80
|
+
|
|
81
|
+
poll(describe, label=label)
|
|
82
|
+
return last
|
|
File without changes
|