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,82 @@
1
+ """devlift manual [topic] (also `devlift man`) — the bundled manual, paged on a
2
+ terminal, or one section of it printed straight through."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import re
8
+ import sys
9
+ from importlib import resources
10
+
11
+ import typer
12
+ from rich.markdown import Markdown
13
+
14
+ from devlift_cli.context import Invocation
15
+ from devlift_cli.errors import InputError
16
+ from devlift_cli.render import output
17
+
18
+
19
+ def manual_text() -> str:
20
+ return resources.files("devlift_cli").joinpath("MANUAL.md").read_text(encoding="utf-8")
21
+
22
+
23
+ def _sections(text: str) -> list[tuple[str, str]]:
24
+ """(heading, body) for every `##`/`###` heading, in order."""
25
+ parts = re.split(r"^(#{2,3} .+)$", text, flags=re.MULTILINE)
26
+ out = []
27
+ for i in range(1, len(parts), 2):
28
+ heading = parts[i].lstrip("#").strip()
29
+ out.append((heading, parts[i] + parts[i + 1]))
30
+ return out
31
+
32
+
33
+ def section_command(topic: str):
34
+ """A `manual` subcommand for one command group: `devlift sqs manual`.
35
+
36
+ The same section `devlift manual sqs` prints, reachable from inside the
37
+ group so it can be read without leaving what you were typing.
38
+ """
39
+ def command(
40
+ ctx: typer.Context,
41
+ raw: bool = typer.Option(False, "--raw", help="Plain Markdown instead of terminal formatting."),
42
+ ):
43
+ inv: Invocation = ctx.obj
44
+ text = "\n".join(body for h, body in _sections(manual_text()) if topic.lower() in h.lower())
45
+ if raw or inv.output != "table":
46
+ print(text)
47
+ return
48
+ output.console.print(Markdown(text))
49
+
50
+ command.__doc__ = f"Show the manual section for {topic}."
51
+ return command
52
+
53
+
54
+ def manual(
55
+ ctx: typer.Context,
56
+ topic: str | None = typer.Argument(None, help="A section: s3, sqs, dynamodb, services, exit codes, scripting, …"),
57
+ raw: bool = typer.Option(False, "--raw", help="Plain Markdown instead of terminal formatting."),
58
+ no_pager: bool = typer.Option(False, "--no-pager", help="Print the whole manual straight through instead of paging it."),
59
+ ):
60
+ """Show the full user manual (also `devlift man`), or one section of it (`devlift manual s3`)."""
61
+ inv: Invocation = ctx.obj
62
+ text = manual_text()
63
+ if topic:
64
+ needle = topic.strip().lower()
65
+ matches = [(h, body) for h, body in _sections(text) if needle in h.lower()]
66
+ if not matches:
67
+ names = ", ".join(h for h, _ in _sections(text))
68
+ raise InputError(f"No manual section matches '{topic}'.", hint=f"Sections: {names}")
69
+ text = "\n".join(body for _, body in matches)
70
+ if raw or inv.output != "table":
71
+ print(text)
72
+ return
73
+ # The whole manual is a few screens long. On a terminal, page it the way
74
+ # `man` does — scroll, search with /, q to leave — instead of dumping it
75
+ # past the top of the window. A single section, a pipe, --raw or
76
+ # --no-pager print straight through.
77
+ if topic or no_pager or not sys.stdout.isatty() or not inv.interactive:
78
+ output.console.print(Markdown(text))
79
+ return
80
+ os.environ.setdefault("LESS", "-R") # keep the formatting when less is the pager
81
+ with output.console.pager(styles=True):
82
+ output.console.print(Markdown(text))
@@ -0,0 +1,49 @@
1
+ """devlift repositories … — the repositories and branches `eks create` picks from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from devlift_cli.commands.manual import section_command
8
+ from devlift_cli.api import catalog
9
+ from devlift_cli.context import Invocation
10
+ from devlift_cli.render.output import rows_table
11
+
12
+ repositories_app = typer.Typer(help="Git repositories the DevLift GitHub App can see.", no_args_is_help=True)
13
+
14
+
15
+ @repositories_app.command("list")
16
+ def repositories_list(
17
+ ctx: typer.Context,
18
+ search: str | None = typer.Option(None, "--search", help="Only repositories whose name contains this text."),
19
+ ):
20
+ """List repositories (the values --repository accepts)."""
21
+ inv: Invocation = ctx.obj
22
+ repos = catalog.list_repositories(inv.api)
23
+ if search:
24
+ s = search.lower()
25
+ repos = [r for r in repos if s in str(r.get("full_name", "")).lower()]
26
+ inv.emit(repos, lambda d: rows_table(
27
+ ["Repository", "Default branch", "Private", "Updated"],
28
+ [(r.get("full_name"), r.get("default_branch"), "yes" if r.get("private") else "no", (r.get("updated_at") or "")[:10]) for r in d],
29
+ ))
30
+
31
+
32
+ @repositories_app.command("branches")
33
+ def repositories_branches(
34
+ ctx: typer.Context,
35
+ repository: str = typer.Argument(..., help="Repository as owner/name."),
36
+ ):
37
+ """List a repository's branches (the values --branch accepts)."""
38
+ inv: Invocation = ctx.obj
39
+ if "/" not in repository:
40
+ raise typer.BadParameter("Give the repository as owner/name, e.g. example-org/orders.")
41
+ branches = catalog.list_branches(inv.api, repository)
42
+ inv.emit(branches, lambda d: rows_table(
43
+ ["Branch", "Protected"],
44
+ [(b.get("name"), "yes" if b.get("protected") else "no") for b in d],
45
+ title=repository,
46
+ ))
47
+
48
+
49
+ repositories_app.command("manual")(section_command("repositories"))
@@ -0,0 +1,89 @@
1
+ """devlift request … — the author's side of the review lane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from devlift_cli.commands.manual import section_command
8
+ from devlift_cli.api import approvals as approvals_api
9
+ from devlift_cli.context import Invocation
10
+ from devlift_cli.ops.approvals import find_requests, kind_of, print_request, resolve_target, run_verb
11
+ from devlift_cli.render.output import kv_table, rows_table
12
+ from devlift_cli.resolve.names import Resolver
13
+
14
+ request_app = typer.Typer(help="Your change requests: list, show, submit, withdraw, discard.", no_args_is_help=True)
15
+
16
+ _REF = typer.Argument(..., help="Queue code (queue-…) or service name.")
17
+ _ENV = typer.Option(None, "--env", help="Environment, when the service is configured in several.")
18
+ _REGION = typer.Option(None, "--region", help="Region, when the service runs in several.")
19
+ _COMMENT = typer.Option("", "--comment", help="Optional note recorded on the request.")
20
+
21
+
22
+ def _list_table(rows: list[dict]):
23
+ return rows_table(
24
+ ["Request", "Kind", "Service", "Status", "Requested by", "Requested at", "You may"],
25
+ [(r.get("code"), kind_of(r), r.get("display_name"), r.get("status"),
26
+ (r.get("requested_by_name") or r.get("requested_by") or "") + (" (you)" if (r.get("you") or {}).get("mine") else ""),
27
+ (r.get("requested_at") or "")[:16].replace("T", " "),
28
+ ", ".join(k[4:] for k in ("can_approve", "can_deploy") if (r.get("you") or {}).get(k)) or "–") for r in rows],
29
+ )
30
+
31
+
32
+ @request_app.command("list")
33
+ def request_list(
34
+ ctx: typer.Context,
35
+ status: str | None = typer.Option(None, "--status", help="draft | submit | approved | rejected"),
36
+ service: str | None = typer.Option(None, "--service", help="Only requests on this service."),
37
+ env: str | None = _ENV,
38
+ region: str | None = _REGION,
39
+ mine: bool = typer.Option(False, "--mine", help="Only requests you raised."),
40
+ ):
41
+ """List change requests you raised or can act on."""
42
+ inv: Invocation = ctx.obj
43
+ resource_code = resolve_target(inv, Resolver(inv.api, inv.profile.name), service, env, region).config_code if service else None
44
+ rows = approvals_api.list_requests(inv.api, status=status, resource_code=resource_code)
45
+ if mine:
46
+ rows = [r for r in rows if (r.get("you") or {}).get("mine")]
47
+ inv.emit(rows, _list_table)
48
+
49
+
50
+ @request_app.command("show")
51
+ def request_show(ctx: typer.Context, ref: str = _REF, env: str | None = _ENV, region: str | None = _REGION):
52
+ """Show a request: who, status, and the frozen diff a reviewer sees."""
53
+ inv: Invocation = ctx.obj
54
+ target, rows = find_requests(inv, Resolver(inv.api, inv.profile.name), ref, env=env, region=region, statuses=None, mine_only=False)
55
+ full = [approvals_api.get_request(inv.api, r["code"]) for r in rows]
56
+ if not full:
57
+ from devlift_cli.errors import EXIT_NOT_FOUND, CliError
58
+ raise CliError(f"No request on {target.service_name}.", EXIT_NOT_FOUND)
59
+
60
+ def table(d):
61
+ for r in d:
62
+ print_request(r)
63
+ return kv_table([])
64
+ inv.emit(full, table)
65
+
66
+
67
+ @request_app.command("history")
68
+ def request_history(ctx: typer.Context, service: str = typer.Argument(..., help="Service name."), env: str | None = _ENV, region: str | None = _REGION):
69
+ """Every request ever raised against a service configuration."""
70
+ inv: Invocation = ctx.obj
71
+ target = resolve_target(inv, Resolver(inv.api, inv.profile.name), service, env, region)
72
+ inv.emit(approvals_api.history(inv.api, target.config_code), _list_table)
73
+
74
+
75
+ def _verb_command(verb: str, doc: str):
76
+ def command(ctx: typer.Context, ref: str = _REF, env: str | None = _ENV, region: str | None = _REGION, comment: str = _COMMENT):
77
+ inv: Invocation = ctx.obj
78
+ result = run_verb(inv, Resolver(inv.api, inv.profile.name), verb, ref, env=env, region=region, comment=comment)
79
+ inv.emit(result.to_dict(), lambda d: rows_table(["Request", "Kind", "Status"], [(r["code"], r["kind"], r["status"]) for r in d["requests"]], title=f"{d['service']}: {verb}"))
80
+ command.__doc__ = doc
81
+ return command
82
+
83
+
84
+ request_app.command("submit")(_verb_command("submit", "Send your draft for review (settings and routes together). The diff is frozen now."))
85
+ request_app.command("withdraw")(_verb_command("withdraw", "Pull your submitted request back out of review; it becomes a draft again."))
86
+ request_app.command("discard")(_verb_command("discard", "Throw your draft away."))
87
+
88
+
89
+ request_app.command("manual")(section_command("request, approval"))
@@ -0,0 +1,198 @@
1
+ """devlift s3 …"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import typer
8
+
9
+ from devlift_cli.commands.manual import section_command
10
+ from devlift_cli.context import Invocation
11
+ from devlift_cli.errors import InputError
12
+ from devlift_cli.api.infra_list import display_name
13
+ from devlift_cli.ops.resources import ResourceKind, create_resource, describe_resource, list_resources, update_resource
14
+ from devlift_cli.render.output import kv_table, rows_table
15
+ from devlift_cli.resolve import allowlist
16
+ from devlift_cli.resolve.names import Resolver
17
+
18
+ s3_app = typer.Typer(help="S3 buckets.", no_args_is_help=True)
19
+
20
+ _BUCKET_RE = re.compile(r"^[a-z0-9.-]+$")
21
+ _ACCOUNT_RE = re.compile(r"^\d{12}$")
22
+
23
+
24
+ def validate_bucket_name(raw: str) -> str | None:
25
+ name = raw.strip()
26
+ if not name:
27
+ return "required."
28
+ if not 3 <= len(name) <= 63:
29
+ return f"must be 3-63 characters (currently {len(name)})."
30
+ if not _BUCKET_RE.match(name):
31
+ return "use only lowercase letters, numbers, dots (.) and hyphens (-)."
32
+ if not name[0].isalnum() or not name[-1].isalnum():
33
+ return "must start and end with a letter or number."
34
+ return None
35
+
36
+
37
+ S3 = ResourceKind(
38
+ noun="bucket",
39
+ infra_type="s3_infrastructuretype_ref",
40
+ case_ref_code="create_bucket",
41
+ service=allowlist.SERVICE_S3,
42
+ validate_name=validate_bucket_name,
43
+ labels={"versioning": "Versioning", "enable_s3_replication": "Replication", "cross_account_account_id": "Replication account"},
44
+ )
45
+
46
+
47
+ @s3_app.command("list")
48
+ def s3_list(
49
+ ctx: typer.Context,
50
+ app: str | None = typer.Option(None, "--app", "--product", help="Only this application."),
51
+ env: str | None = typer.Option(None, "--env", help="Only this environment."),
52
+ region: str | None = typer.Option(None, "--region", help="Only this region."),
53
+ ):
54
+ """List S3 buckets registered in DevLift."""
55
+ inv: Invocation = ctx.obj
56
+ res = Resolver(inv.api, inv.profile.name)
57
+ rows = list_resources(inv, res, S3, app=app, env=env, region=region)
58
+ inv.emit(rows, lambda d: rows_table(
59
+ ["Bucket", "Environment", "Region", "Versioning", "Replication", "Status", "Code"],
60
+ [(display_name(r), r.get("environment"), r.get("geo_loc_mst_code"), (r.get("locator") or {}).get("versioning"), (r.get("locator") or {}).get("enable_s3_replication"), r.get("status"), r.get("code")) for r in d],
61
+ ))
62
+
63
+
64
+ @s3_app.command("describe")
65
+ def s3_describe(
66
+ ctx: typer.Context,
67
+ name: str = typer.Argument(..., help="Bucket name or resource code."),
68
+ env: str | None = typer.Option(None, "--env", help="Disambiguate when the name exists in several environments."),
69
+ ):
70
+ """Show one bucket: placement, settings, live status."""
71
+ inv: Invocation = ctx.obj
72
+ res = Resolver(inv.api, inv.profile.name)
73
+ detail = describe_resource(inv, res, S3, name, env=env)
74
+ loc = detail.get("locator") or {}
75
+ inv.emit(detail, lambda d: kv_table([
76
+ ("Bucket", display_name(d)),
77
+ ("Full name", loc.get("bucket_name")),
78
+ ("ARN", loc.get("bucket_arn")),
79
+ ("Code", d.get("code")),
80
+ ("Environment", d.get("environment")),
81
+ ("Region", f"{loc.get('region') or ''} ({d.get('geo_loc_mst_code')})"),
82
+ ("Account", loc.get("accountId")),
83
+ ("Versioning", loc.get("versioning")),
84
+ ("Replication", loc.get("enable_s3_replication")),
85
+ ("Status", d.get("infra_status")),
86
+ ], title=display_name(d)))
87
+
88
+
89
+ @s3_app.command("create")
90
+ def s3_create(
91
+ ctx: typer.Context,
92
+ name: str | None = typer.Option(None, "--name", help="Bucket name (3-63 chars, lowercase)."),
93
+ app: str | None = typer.Option(None, "--app", "--product", help="Application name or code."),
94
+ env: str | None = typer.Option(None, "--env", help="dev | stage | qa | prod"),
95
+ region: str | None = typer.Option(None, "--region", help="Region name or code (see `devlift regions list`)."),
96
+ versioning: bool | None = typer.Option(None, "--versioning/--no-versioning", help="Object versioning."),
97
+ replication: bool | None = typer.Option(None, "--replication/--no-replication", help="Cross-account replication."),
98
+ cross_account_id: str | None = typer.Option(None, "--cross-account-id", help="12-digit AWS account ID (with --replication)."),
99
+ do_wait: bool = typer.Option(False, "--wait", help="Follow the deployment until it finishes."),
100
+ ):
101
+ """Create an S3 bucket. Any flag you omit is asked for on a terminal.
102
+
103
+ Example: devlift s3 create --name my-logs --app core --env stage --region mumbai --versioning --no-replication -y
104
+ """
105
+ inv: Invocation = ctx.obj
106
+ res = Resolver(inv.api, inv.profile.name)
107
+
108
+ if name is None:
109
+ name = inv.ask("Bucket name", flag="--name")
110
+ if versioning is None:
111
+ versioning = inv.ask_bool("Enable versioning?", default=False, flag="--versioning/--no-versioning")
112
+ if replication is None:
113
+ replication = inv.ask_bool("Enable cross-account replication?", default=False, flag="--replication/--no-replication")
114
+ if replication:
115
+ if cross_account_id is None:
116
+ cross_account_id = inv.ask("Replication target AWS account ID (12 digits)", flag="--cross-account-id")
117
+ if not _ACCOUNT_RE.match(cross_account_id.strip()):
118
+ raise InputError("--cross-account-id must be a 12-digit AWS account ID.")
119
+ elif cross_account_id:
120
+ raise InputError("--cross-account-id only applies with --replication.")
121
+
122
+ attributes = {
123
+ "versioning": "true" if versioning else "false",
124
+ "enable_s3_replication": "true" if replication else "false",
125
+ "cross_account_account_id": cross_account_id.strip() if replication and cross_account_id else None,
126
+ }
127
+ result = create_resource(inv, res, S3, name=name, app=app, env=env, region=region, attributes=attributes, do_wait=do_wait)
128
+ data = {"bucket": name, **result.to_dict()}
129
+ inv.emit(data, lambda d: kv_table([
130
+ ("Bucket", d["bucket"]),
131
+ ("Resource code", d["resource_code"]),
132
+ ("Queue item", d["queue_code"]),
133
+ ("Status", d.get("status")),
134
+ ("Workflow", d.get("workflow_id")),
135
+ ("Pull request", d.get("pr_url")),
136
+ ]))
137
+
138
+
139
+ @s3_app.command("update")
140
+ def s3_update(
141
+ ctx: typer.Context,
142
+ name: str = typer.Argument(..., help="Bucket name or resource code."),
143
+ env: str | None = typer.Option(None, "--env", help="Narrows the lookup when the name exists in several environments."),
144
+ app: str | None = typer.Option(None, "--app", "--product", help="The bucket's application. Only needed on backends that do not report it."),
145
+ versioning: bool | None = typer.Option(None, "--versioning/--no-versioning", help="Object versioning."),
146
+ replication: bool | None = typer.Option(None, "--replication/--no-replication", help="Cross-account replication."),
147
+ cross_account_id: str | None = typer.Option(None, "--cross-account-id", help="12-digit AWS account ID (with --replication)."),
148
+ do_wait: bool = typer.Option(False, "--wait", help="Follow the deployment until it finishes."),
149
+ ):
150
+ """Change a bucket's settings and deploy the change.
151
+
152
+ Only the flags you pass change. The name, application, environment and
153
+ region cannot be changed — a bucket somewhere else is a new bucket.
154
+
155
+ Example: devlift s3 update my-logs --env stage --versioning -y
156
+ """
157
+ inv: Invocation = ctx.obj
158
+ res = Resolver(inv.api, inv.profile.name)
159
+
160
+ changes: dict = {}
161
+ if versioning is not None:
162
+ changes["versioning"] = "true" if versioning else "false"
163
+ if replication is not None:
164
+ changes["enable_s3_replication"] = "true" if replication else "false"
165
+ if not replication:
166
+ changes["cross_account_account_id"] = ""
167
+ if cross_account_id is not None:
168
+ if replication is False:
169
+ raise InputError("--cross-account-id only applies with --replication.")
170
+ if not _ACCOUNT_RE.match(cross_account_id.strip()):
171
+ raise InputError("--cross-account-id must be a 12-digit AWS account ID.")
172
+ changes["cross_account_account_id"] = cross_account_id.strip()
173
+ if not changes:
174
+ raise InputError(
175
+ "Nothing to change.",
176
+ hint="Pass --versioning/--no-versioning, --replication/--no-replication or --cross-account-id.",
177
+ )
178
+
179
+ def check(config: dict) -> str | None:
180
+ wants_replication = str(config.get("enable_s3_replication", "")).lower() == "true"
181
+ if wants_replication and not config.get("cross_account_account_id"):
182
+ return "Replication needs a target account: pass --cross-account-id with --replication."
183
+ return None
184
+
185
+ outcome = update_resource(inv, res, S3, name=name, env=env, app=app, changes=changes, check=check, do_wait=do_wait)
186
+ data = {"bucket": outcome.name, **outcome.to_dict()}
187
+ inv.emit(data, lambda d: kv_table([
188
+ ("Bucket", d["bucket"]),
189
+ ("Resource code", d["resource_code"]),
190
+ ("Changed", "yes" if d["changed"] else "no"),
191
+ ("Queue item", d.get("queue_code")),
192
+ ("Status", d.get("status")),
193
+ ("Workflow", d.get("workflow_id")),
194
+ ("Pull request", d.get("pr_url")),
195
+ ]))
196
+
197
+
198
+ s3_app.command("manual")(section_command("s3"))
@@ -0,0 +1,229 @@
1
+ """devlift sqs …"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import typer
8
+
9
+ from devlift_cli.commands.manual import section_command
10
+ from devlift_cli.api.infra_list import display_name
11
+ from devlift_cli.context import Invocation
12
+ from devlift_cli.errors import InputError
13
+ from devlift_cli.ops.resources import ResourceKind, create_resource, describe_resource, list_resources, update_resource
14
+ from devlift_cli.render.output import kv_table, rows_table
15
+ from devlift_cli.resolve import allowlist
16
+ from devlift_cli.resolve.names import Resolver
17
+
18
+ sqs_app = typer.Typer(help="SQS queues.", no_args_is_help=True)
19
+
20
+ _QUEUE_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
21
+ _ACCOUNT_RE = re.compile(r"^\d{12}$")
22
+
23
+ # Same bounds the dashboard enforces (lib/validation/infraFieldRules.ts).
24
+ _NUMBER_RULES = {
25
+ "max_receive_count": ("--max-receive-count", 1, 1000),
26
+ "visibility_timeout_seconds": ("--visibility-timeout", 0, 43200),
27
+ "message_retention_seconds": ("--retention", 60, 1209600),
28
+ "dlq_message_retention_seconds": ("--dlq-retention", 60, 1209600),
29
+ }
30
+
31
+
32
+ def validate_queue_name(raw: str) -> str | None:
33
+ name = raw.strip()
34
+ if not name:
35
+ return "required."
36
+ if len(name) > 80:
37
+ return f"must be 80 characters or fewer (currently {len(name)})."
38
+ if name.lower().endswith(".fifo"):
39
+ return "drop the '.fifo' suffix; it is added automatically for FIFO queues."
40
+ if not _QUEUE_RE.match(name):
41
+ return "use only letters, numbers, hyphens (-) and underscores (_)."
42
+ return None
43
+
44
+
45
+ SQS = ResourceKind(
46
+ noun="queue",
47
+ infra_type="sqs_infrastructuretype_ref",
48
+ case_ref_code="create_queue",
49
+ service=allowlist.SERVICE_SQS,
50
+ validate_name=validate_queue_name,
51
+ labels={
52
+ "fifo_queue": "FIFO",
53
+ "create_dlq": "Dead-letter queue",
54
+ "cross_account_ids": "Cross-account access",
55
+ "max_receive_count": "Max receive count",
56
+ "visibility_timeout_seconds": "Visibility timeout (s)",
57
+ "message_retention_seconds": "Retention (s)",
58
+ "dlq_message_retention_seconds": "DLQ retention (s)",
59
+ },
60
+ )
61
+
62
+
63
+ def _bounded(key: str, value: int | None) -> int | None:
64
+ if value is None:
65
+ return None
66
+ flag, lo, hi = _NUMBER_RULES[key]
67
+ if not lo <= value <= hi:
68
+ raise InputError(f"{flag} must be between {lo} and {hi}.")
69
+ return value
70
+
71
+
72
+ @sqs_app.command("list")
73
+ def sqs_list(
74
+ ctx: typer.Context,
75
+ app: str | None = typer.Option(None, "--app", "--product", help="Only this application."),
76
+ env: str | None = typer.Option(None, "--env", help="Only this environment."),
77
+ region: str | None = typer.Option(None, "--region", help="Only this region."),
78
+ ):
79
+ """List SQS queues registered in DevLift."""
80
+ inv: Invocation = ctx.obj
81
+ rows = list_resources(inv, Resolver(inv.api, inv.profile.name), SQS, app=app, env=env, region=region)
82
+ inv.emit(rows, lambda d: rows_table(
83
+ ["Queue", "Environment", "Region", "FIFO", "DLQ", "Status", "Code"],
84
+ [(display_name(r), r.get("environment"), r.get("geo_loc_mst_code"), (r.get("locator") or {}).get("fifo_queue"), (r.get("locator") or {}).get("create_dlq"), r.get("status"), r.get("code")) for r in d],
85
+ ))
86
+
87
+
88
+ @sqs_app.command("describe")
89
+ def sqs_describe(ctx: typer.Context, name: str = typer.Argument(..., help="Queue name or resource code."), env: str | None = typer.Option(None, "--env")):
90
+ """Show one queue."""
91
+ inv: Invocation = ctx.obj
92
+ detail = describe_resource(inv, Resolver(inv.api, inv.profile.name), SQS, name, env=env)
93
+ loc = detail.get("locator") or {}
94
+ inv.emit(detail, lambda d: kv_table([
95
+ ("Queue", display_name(d)),
96
+ ("Full name", loc.get("queue_name") or loc.get("name")),
97
+ ("URL", loc.get("queue_url")),
98
+ ("ARN", loc.get("queue_arn")),
99
+ ("Code", d.get("code")),
100
+ ("Environment", d.get("environment")),
101
+ ("Region", f"{loc.get('region') or ''} ({d.get('geo_loc_mst_code')})"),
102
+ ("FIFO", loc.get("fifo_queue")),
103
+ ("Dead-letter queue", loc.get("create_dlq")),
104
+ ("Visibility timeout", loc.get("visibility_timeout_seconds")),
105
+ ("Retention", loc.get("message_retention_seconds")),
106
+ ("Status", d.get("infra_status")),
107
+ ], title=display_name(d)))
108
+
109
+
110
+ @sqs_app.command("create")
111
+ def sqs_create(
112
+ ctx: typer.Context,
113
+ name: str | None = typer.Option(None, "--name", help="Queue name (no .fifo suffix)."),
114
+ app: str | None = typer.Option(None, "--app", "--product", help="Application name or code."),
115
+ env: str | None = typer.Option(None, "--env", help="dev | stage | qa | prod"),
116
+ region: str | None = typer.Option(None, "--region", help="Region name or code."),
117
+ fifo: bool | None = typer.Option(None, "--fifo/--no-fifo", help="FIFO queue."),
118
+ dlq: bool | None = typer.Option(None, "--dlq/--no-dlq", help="Create a dead-letter queue."),
119
+ cross_account_ids: list[str] = typer.Option([], "--cross-account-id", help="12-digit AWS account allowed to use the queue (repeatable)."),
120
+ max_receive_count: int | None = typer.Option(None, "--max-receive-count", help="Receives before a message goes to the DLQ (1-1000)."),
121
+ visibility_timeout: int | None = typer.Option(None, "--visibility-timeout", help="Seconds (0-43200)."),
122
+ retention: int | None = typer.Option(None, "--retention", help="Message retention in seconds (60-1209600)."),
123
+ dlq_retention: int | None = typer.Option(None, "--dlq-retention", help="DLQ retention in seconds (60-1209600)."),
124
+ do_wait: bool = typer.Option(False, "--wait", help="Follow the deployment until it finishes."),
125
+ ):
126
+ """Create an SQS queue. Any flag you omit is asked for on a terminal.
127
+
128
+ Example: devlift sqs create --name orders --app core --env stage --region mumbai --no-fifo --dlq -y
129
+ """
130
+ inv: Invocation = ctx.obj
131
+ res = Resolver(inv.api, inv.profile.name)
132
+ if name is None:
133
+ name = inv.ask("Queue name", flag="--name")
134
+ if fifo is None:
135
+ fifo = inv.ask_bool("FIFO queue?", default=False, flag="--fifo/--no-fifo")
136
+ if dlq is None:
137
+ dlq = inv.ask_bool("Create a dead-letter queue?", default=False, flag="--dlq/--no-dlq")
138
+ for acct in cross_account_ids:
139
+ if not _ACCOUNT_RE.match(acct.strip()):
140
+ raise InputError(f"--cross-account-id '{acct}' must be a 12-digit AWS account ID.")
141
+ attributes = {
142
+ "fifo_queue": "true" if fifo else "false",
143
+ "create_dlq": "true" if dlq else "false",
144
+ "cross_account_ids": [a.strip() for a in cross_account_ids] or None,
145
+ "max_receive_count": _bounded("max_receive_count", max_receive_count),
146
+ "visibility_timeout_seconds": _bounded("visibility_timeout_seconds", visibility_timeout),
147
+ "message_retention_seconds": _bounded("message_retention_seconds", retention),
148
+ "dlq_message_retention_seconds": _bounded("dlq_message_retention_seconds", dlq_retention),
149
+ }
150
+ result = create_resource(inv, res, SQS, name=name, app=app, env=env, region=region, attributes=attributes, do_wait=do_wait)
151
+ data = {"queue": name, **result.to_dict()}
152
+ inv.emit(data, lambda d: kv_table([
153
+ ("Queue", d["queue"]),
154
+ ("Resource code", d["resource_code"]),
155
+ ("Queue item", d["queue_code"]),
156
+ ("Status", d.get("status")),
157
+ ("Workflow", d.get("workflow_id")),
158
+ ("Pull request", d.get("pr_url")),
159
+ ]))
160
+
161
+
162
+ @sqs_app.command("update")
163
+ def sqs_update(
164
+ ctx: typer.Context,
165
+ name: str = typer.Argument(..., help="Queue name or resource code."),
166
+ env: str | None = typer.Option(None, "--env", help="Narrows the lookup when the name exists in several environments."),
167
+ app: str | None = typer.Option(None, "--app", "--product", help="The queue's application. Only needed on backends that do not report it."),
168
+ dlq: bool | None = typer.Option(None, "--dlq/--no-dlq", help="Dead-letter queue."),
169
+ cross_account_ids: list[str] = typer.Option([], "--cross-account-id", help="12-digit AWS account allowed to use the queue (repeatable; replaces the whole list)."),
170
+ clear_cross_accounts: bool = typer.Option(False, "--clear-cross-accounts", help="Remove every cross-account grant."),
171
+ max_receive_count: int | None = typer.Option(None, "--max-receive-count", help="Receives before a message goes to the DLQ (1-1000)."),
172
+ visibility_timeout: int | None = typer.Option(None, "--visibility-timeout", help="Seconds (0-43200)."),
173
+ retention: int | None = typer.Option(None, "--retention", help="Message retention in seconds (60-1209600)."),
174
+ dlq_retention: int | None = typer.Option(None, "--dlq-retention", help="DLQ retention in seconds (60-1209600)."),
175
+ do_wait: bool = typer.Option(False, "--wait", help="Follow the deployment until it finishes."),
176
+ ):
177
+ """Change a queue's settings and deploy the change.
178
+
179
+ Only the flags you pass change. The name, FIFO flag, application,
180
+ environment and region cannot be changed — FIFO is part of the queue's
181
+ real name, so a different one is a new queue.
182
+
183
+ Example: devlift sqs update orders --env stage --dlq --max-receive-count 5 -y
184
+ """
185
+ inv: Invocation = ctx.obj
186
+ res = Resolver(inv.api, inv.profile.name)
187
+
188
+ changes: dict = {}
189
+ if dlq is not None:
190
+ changes["create_dlq"] = "true" if dlq else "false"
191
+ if cross_account_ids and clear_cross_accounts:
192
+ raise InputError("--cross-account-id and --clear-cross-accounts cannot be combined.")
193
+ if cross_account_ids:
194
+ for acct in cross_account_ids:
195
+ if not _ACCOUNT_RE.match(acct.strip()):
196
+ raise InputError(f"--cross-account-id '{acct}' must be a 12-digit AWS account ID.")
197
+ changes["cross_account_ids"] = [a.strip() for a in cross_account_ids]
198
+ if clear_cross_accounts:
199
+ changes["cross_account_ids"] = []
200
+ for key, value in (
201
+ ("max_receive_count", max_receive_count),
202
+ ("visibility_timeout_seconds", visibility_timeout),
203
+ ("message_retention_seconds", retention),
204
+ ("dlq_message_retention_seconds", dlq_retention),
205
+ ):
206
+ bounded = _bounded(key, value)
207
+ if bounded is not None:
208
+ changes[key] = bounded
209
+ if not changes:
210
+ raise InputError(
211
+ "Nothing to change.",
212
+ hint="Pass --dlq/--no-dlq, --cross-account-id, --clear-cross-accounts, --max-receive-count, "
213
+ "--visibility-timeout, --retention or --dlq-retention.",
214
+ )
215
+
216
+ outcome = update_resource(inv, res, SQS, name=name, env=env, app=app, changes=changes, do_wait=do_wait)
217
+ data = {"queue": outcome.name, **outcome.to_dict()}
218
+ inv.emit(data, lambda d: kv_table([
219
+ ("Queue", d["queue"]),
220
+ ("Resource code", d["resource_code"]),
221
+ ("Changed", "yes" if d["changed"] else "no"),
222
+ ("Queue item", d.get("queue_code")),
223
+ ("Status", d.get("status")),
224
+ ("Workflow", d.get("workflow_id")),
225
+ ("Pull request", d.get("pr_url")),
226
+ ]))
227
+
228
+
229
+ sqs_app.command("manual")(section_command("sqs"))