devlift-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. devlift_cli/MANUAL.md +1066 -0
  2. devlift_cli/__init__.py +3 -0
  3. devlift_cli/__main__.py +4 -0
  4. devlift_cli/api/__init__.py +0 -0
  5. devlift_cli/api/approvals.py +53 -0
  6. devlift_cli/api/catalog.py +96 -0
  7. devlift_cli/api/client.py +125 -0
  8. devlift_cli/api/context.py +21 -0
  9. devlift_cli/api/deployments.py +37 -0
  10. devlift_cli/api/infra.py +94 -0
  11. devlift_cli/api/infra_list.py +61 -0
  12. devlift_cli/api/kong.py +29 -0
  13. devlift_cli/api/services.py +106 -0
  14. devlift_cli/api/vpc.py +24 -0
  15. devlift_cli/app.py +163 -0
  16. devlift_cli/auth/__init__.py +0 -0
  17. devlift_cli/auth/oauth.py +270 -0
  18. devlift_cli/auth/session.py +64 -0
  19. devlift_cli/auth/storage.py +135 -0
  20. devlift_cli/commands/__init__.py +0 -0
  21. devlift_cli/commands/approval.py +51 -0
  22. devlift_cli/commands/auth.py +180 -0
  23. devlift_cli/commands/catalog.py +187 -0
  24. devlift_cli/commands/clusters.py +108 -0
  25. devlift_cli/commands/deployment.py +77 -0
  26. devlift_cli/commands/dynamodb.py +121 -0
  27. devlift_cli/commands/eks.py +326 -0
  28. devlift_cli/commands/kong.py +145 -0
  29. devlift_cli/commands/languages.py +40 -0
  30. devlift_cli/commands/manual.py +82 -0
  31. devlift_cli/commands/repositories.py +49 -0
  32. devlift_cli/commands/request.py +89 -0
  33. devlift_cli/commands/s3.py +198 -0
  34. devlift_cli/commands/sqs.py +229 -0
  35. devlift_cli/config.py +94 -0
  36. devlift_cli/context.py +97 -0
  37. devlift_cli/data/placement/vance.json +16 -0
  38. devlift_cli/errors.py +52 -0
  39. devlift_cli/ops/__init__.py +0 -0
  40. devlift_cli/ops/approvals.py +343 -0
  41. devlift_cli/ops/eks.py +877 -0
  42. devlift_cli/ops/kong.py +343 -0
  43. devlift_cli/ops/placement.py +128 -0
  44. devlift_cli/ops/resources.py +418 -0
  45. devlift_cli/ops/status.py +152 -0
  46. devlift_cli/ops/wait.py +82 -0
  47. devlift_cli/render/__init__.py +0 -0
  48. devlift_cli/render/output.py +75 -0
  49. devlift_cli/resolve/__init__.py +0 -0
  50. devlift_cli/resolve/allowlist.py +192 -0
  51. devlift_cli/resolve/names.py +179 -0
  52. devlift_cli-0.1.0.dist-info/METADATA +106 -0
  53. devlift_cli-0.1.0.dist-info/RECORD +56 -0
  54. devlift_cli-0.1.0.dist-info/WHEEL +5 -0
  55. devlift_cli-0.1.0.dist-info/entry_points.txt +3 -0
  56. devlift_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)"))
@@ -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