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,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"))
|