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,180 @@
|
|
|
1
|
+
"""login / logout / whoami / configure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from devlift_cli.api.context import get_context
|
|
10
|
+
from devlift_cli.auth import oauth, storage
|
|
11
|
+
from devlift_cli.config import Profile, list_profiles, load_profile, save_profile
|
|
12
|
+
from devlift_cli.context import Invocation
|
|
13
|
+
from devlift_cli.errors import EXIT_NOT_FOUND, CliError
|
|
14
|
+
from devlift_cli.render import output
|
|
15
|
+
from devlift_cli.render.output import kv_table, rows_table
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def login(ctx: typer.Context, no_browser: bool = typer.Option(False, "--no-browser", help="Print the sign-in URL instead of opening a browser.")):
|
|
19
|
+
"""Sign in to DevLift through your browser."""
|
|
20
|
+
inv: Invocation = ctx.obj
|
|
21
|
+
existing = storage.load(inv.profile.name)
|
|
22
|
+
creds = oauth.login(inv.profile.base_url, existing=existing, open_browser=not no_browser, echo=output.info)
|
|
23
|
+
storage.save(inv.profile.name, creds)
|
|
24
|
+
who = get_context(inv.api)
|
|
25
|
+
output.success(f"Signed in as {who.email or who.user_code} ({who.tenant_name or who.tenant_code}) on profile '{inv.profile.name}'.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def logout(ctx: typer.Context):
|
|
29
|
+
"""Sign out and forget the stored credentials for this profile."""
|
|
30
|
+
inv: Invocation = ctx.obj
|
|
31
|
+
creds = storage.load(inv.profile.name)
|
|
32
|
+
if creds:
|
|
33
|
+
oauth.revoke(inv.profile.base_url, creds)
|
|
34
|
+
removed = storage.clear(inv.profile.name)
|
|
35
|
+
output.info("Signed out." if removed else "Nothing to sign out of.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def whoami(ctx: typer.Context):
|
|
39
|
+
"""Show who you are signed in as, and what the server enables."""
|
|
40
|
+
inv: Invocation = ctx.obj
|
|
41
|
+
who = get_context(inv.api)
|
|
42
|
+
data = who.model_dump()
|
|
43
|
+
data["profile"] = inv.profile.name
|
|
44
|
+
data["base_url"] = inv.profile.base_url
|
|
45
|
+
if inv.debug:
|
|
46
|
+
data["token_source"] = "DEVLIFT_TOKEN" if inv.api.tokens.from_environment else storage.backend_name()
|
|
47
|
+
|
|
48
|
+
def table(d):
|
|
49
|
+
enabled = ", ".join(sorted(k for k, v in d["features"].items() if v)) or "none"
|
|
50
|
+
return kv_table([
|
|
51
|
+
("User", f"{d.get('email') or ''} ({d['user_code']})"),
|
|
52
|
+
("Tenant", f"{d.get('tenant_name') or ''} ({d['tenant_code']})"),
|
|
53
|
+
("Profile", d["profile"]),
|
|
54
|
+
("Backend", d["base_url"]),
|
|
55
|
+
("Dashboard", d.get("frontend_base_url") or ""),
|
|
56
|
+
("Features", enabled),
|
|
57
|
+
*([("Token source", d["token_source"])] if "token_source" in d else []),
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
inv.emit(data, table)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
profile_app = typer.Typer(help="Profiles: which backend commands talk to.", no_args_is_help=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _profile_rows() -> list[dict]:
|
|
67
|
+
current = load_profile().name
|
|
68
|
+
return [
|
|
69
|
+
{"name": name, "base_url": p.get("base_url"), "output": p.get("output", "table"), "default": name == current}
|
|
70
|
+
for name, p in list_profiles().items()
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _profiles_table(rows: list[dict]):
|
|
75
|
+
return rows_table(
|
|
76
|
+
["Profile", "Backend", "Output", "Default"],
|
|
77
|
+
[(r["name"], r.get("base_url"), r.get("output", "table"), "*" if r["default"] else "") for r in rows],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@profile_app.command("list")
|
|
82
|
+
def profile_list(ctx: typer.Context):
|
|
83
|
+
"""List profiles. The default one is starred."""
|
|
84
|
+
inv: Invocation = ctx.obj
|
|
85
|
+
inv.emit(_profile_rows(), _profiles_table)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@profile_app.command("show")
|
|
89
|
+
def profile_show(ctx: typer.Context):
|
|
90
|
+
"""Show the profile this shell is currently using, and why."""
|
|
91
|
+
inv: Invocation = ctx.obj
|
|
92
|
+
from_env = os.environ.get("DEVLIFT_PROFILE")
|
|
93
|
+
url_override = os.environ.get("DEVLIFT_BASE_URL")
|
|
94
|
+
data = {
|
|
95
|
+
"name": inv.profile.name,
|
|
96
|
+
"base_url": inv.profile.base_url,
|
|
97
|
+
"output": inv.profile.output,
|
|
98
|
+
"chosen_by": "DEVLIFT_PROFILE" if from_env else "the saved default",
|
|
99
|
+
# An exported DEVLIFT_BASE_URL silently beats the profile's own URL and
|
|
100
|
+
# is the usual cause of "signed in to X, but this command targets Y".
|
|
101
|
+
"base_url_overridden_by_env": bool(url_override),
|
|
102
|
+
}
|
|
103
|
+
inv.emit(data, lambda d: kv_table([
|
|
104
|
+
("Profile", d["name"]),
|
|
105
|
+
("Backend", d["base_url"] + (" (from DEVLIFT_BASE_URL)" if d["base_url_overridden_by_env"] else "")),
|
|
106
|
+
("Output", d["output"]),
|
|
107
|
+
("Chosen by", d["chosen_by"]),
|
|
108
|
+
]))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@profile_app.command("use")
|
|
112
|
+
def profile_use(
|
|
113
|
+
ctx: typer.Context,
|
|
114
|
+
name: str = typer.Argument(..., help="Profile to switch to (see `devlift profile list`)."),
|
|
115
|
+
):
|
|
116
|
+
"""Switch to a profile, for this and every later command."""
|
|
117
|
+
inv: Invocation = ctx.obj
|
|
118
|
+
profiles = list_profiles()
|
|
119
|
+
if name not in profiles:
|
|
120
|
+
known = ", ".join(sorted(profiles)) or "none yet"
|
|
121
|
+
raise CliError(
|
|
122
|
+
f"No profile named '{name}'.",
|
|
123
|
+
EXIT_NOT_FOUND,
|
|
124
|
+
hint=f"Known profiles: {known}. Create one with: devlift --profile {name} configure --base-url <url>",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
saved = profiles[name]
|
|
128
|
+
save_profile(
|
|
129
|
+
Profile(name=name, base_url=(saved.get("base_url") or "").rstrip("/"), output=saved.get("output") or "table"),
|
|
130
|
+
make_default=True,
|
|
131
|
+
)
|
|
132
|
+
output.success(f"Now using profile '{name}' ({saved.get('base_url')}).")
|
|
133
|
+
|
|
134
|
+
# A switch that leaves you signed out of the new backend looks like a
|
|
135
|
+
# broken switch, so say it here rather than at the next command's exit 2.
|
|
136
|
+
if not storage.load(name):
|
|
137
|
+
output.info(f"Not signed in on '{name}' yet. Run: devlift login")
|
|
138
|
+
if os.environ.get("DEVLIFT_PROFILE") and os.environ["DEVLIFT_PROFILE"] != name:
|
|
139
|
+
output.warn(
|
|
140
|
+
f"DEVLIFT_PROFILE is set to '{os.environ['DEVLIFT_PROFILE']}' in this shell and overrides the default. "
|
|
141
|
+
f"Run: unset DEVLIFT_PROFILE"
|
|
142
|
+
)
|
|
143
|
+
if os.environ.get("DEVLIFT_BASE_URL"):
|
|
144
|
+
output.warn(
|
|
145
|
+
f"DEVLIFT_BASE_URL is set to '{os.environ['DEVLIFT_BASE_URL']}' and overrides every profile's backend. "
|
|
146
|
+
"Run: unset DEVLIFT_BASE_URL"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def configure(
|
|
151
|
+
ctx: typer.Context,
|
|
152
|
+
base_url: str | None = typer.Option(None, "--base-url", help="DevLift backend, e.g. http://localhost:8000"),
|
|
153
|
+
default_output: str | None = typer.Option(None, "--default-output", help="table | json | yaml"),
|
|
154
|
+
make_default: bool = typer.Option(False, "--default", help="Make this the default profile."),
|
|
155
|
+
show: bool = typer.Option(False, "--list", help="List profiles and exit."),
|
|
156
|
+
):
|
|
157
|
+
"""Create or change a profile (use --profile NAME to pick which)."""
|
|
158
|
+
inv: Invocation = ctx.obj
|
|
159
|
+
if show:
|
|
160
|
+
profiles = list_profiles()
|
|
161
|
+
current = load_profile().name
|
|
162
|
+
data = [{"name": n, **p, "default": n == current} for n, p in profiles.items()]
|
|
163
|
+
inv.emit(data, lambda d: rows_table(["Profile", "Backend", "Output", "Default"], [(r["name"], r.get("base_url"), r.get("output", "table"), "*" if r["default"] else "") for r in d]))
|
|
164
|
+
return
|
|
165
|
+
if default_output and default_output not in output.FORMATS:
|
|
166
|
+
raise typer.BadParameter(f"--default-output must be one of {', '.join(output.FORMATS)}")
|
|
167
|
+
if not base_url and not default_output and not make_default:
|
|
168
|
+
if not inv.interactive:
|
|
169
|
+
raise typer.BadParameter("Pass --base-url and/or --default-output.")
|
|
170
|
+
from rich.prompt import Prompt
|
|
171
|
+
|
|
172
|
+
base_url = Prompt.ask("DevLift backend URL", default=inv.profile.base_url, console=output.err_console)
|
|
173
|
+
default_output = Prompt.ask("Default output", choices=list(output.FORMATS), default=inv.profile.output, console=output.err_console)
|
|
174
|
+
profile = Profile(
|
|
175
|
+
name=inv.profile.name,
|
|
176
|
+
base_url=(base_url or inv.profile.base_url).rstrip("/"),
|
|
177
|
+
output=default_output or inv.profile.output,
|
|
178
|
+
)
|
|
179
|
+
save_profile(profile, make_default=make_default)
|
|
180
|
+
output.success(f"Profile '{profile.name}' saved: {profile.base_url} (output {profile.output}).")
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The things you pick from: applications, environments, regions, resource
|
|
2
|
+
types, resource groups, services."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from devlift_cli.api import catalog
|
|
9
|
+
from devlift_cli.context import Invocation
|
|
10
|
+
from devlift_cli.render import output
|
|
11
|
+
from devlift_cli.render.output import kv_table, rows_table
|
|
12
|
+
from devlift_cli.resolve import allowlist
|
|
13
|
+
from devlift_cli.resolve.names import ENVIRONMENTS, Resolver
|
|
14
|
+
|
|
15
|
+
applications_app = typer.Typer(help="Applications (products) in your tenant.", no_args_is_help=True)
|
|
16
|
+
environments_app = typer.Typer(help="Environments and where each application can be placed.", no_args_is_help=True)
|
|
17
|
+
regions_app = typer.Typer(help="Regions you can place resources in.", no_args_is_help=True)
|
|
18
|
+
resource_types_app = typer.Typer(help="Infrastructure types DevLift knows about.", no_args_is_help=True)
|
|
19
|
+
resource_groups_app = typer.Typer(help="Resource groups (the permission boundary for services).", no_args_is_help=True)
|
|
20
|
+
services_app = typer.Typer(help="Services in your tenant.", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
_NO_CACHE = typer.Option(False, "--no-cache", help="Refetch instead of using the short-lived local cache.")
|
|
23
|
+
_ALL = typer.Option(False, "--all", help="Everything the backend reports, without the placement allowlist.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolver(inv: Invocation, no_cache: bool) -> Resolver:
|
|
27
|
+
return Resolver(inv.api, inv.profile.name, use_cache=not no_cache)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _hidden(before: int, after: int) -> None:
|
|
31
|
+
"""Say when the allowlist removed rows, so the absence is never a mystery."""
|
|
32
|
+
if after < before:
|
|
33
|
+
output.info(f"{before - after} of {before} hidden by the placement allowlist; --all shows everything.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@applications_app.command("list")
|
|
37
|
+
def applications_list(ctx: typer.Context, active_only: bool = typer.Option(False, "--active", help="Only active applications."), show_all: bool = _ALL, no_cache: bool = _NO_CACHE):
|
|
38
|
+
"""List applications that have a real placement (see section 5)."""
|
|
39
|
+
inv: Invocation = ctx.obj
|
|
40
|
+
res = _resolver(inv, no_cache)
|
|
41
|
+
apps = res.applications()
|
|
42
|
+
if active_only:
|
|
43
|
+
apps = [a for a in apps if a.get("is_active")]
|
|
44
|
+
if not show_all:
|
|
45
|
+
before = len(apps)
|
|
46
|
+
apps = allowlist.filter_applications(apps, res.placements(), res.tenant_code())
|
|
47
|
+
_hidden(before, len(apps))
|
|
48
|
+
inv.emit(apps, lambda d: rows_table(
|
|
49
|
+
["Name", "Code", "Services", "Groups", "Status"],
|
|
50
|
+
[(a.get("application_name"), a.get("application_code"), a.get("services_count"), a.get("resource_groups_count"), a.get("status")) for a in d],
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@environments_app.command("list")
|
|
55
|
+
def environments_list(ctx: typer.Context, app: str | None = typer.Option(None, "--app", "--product", help="Only this application."), show_all: bool = _ALL, no_cache: bool = _NO_CACHE):
|
|
56
|
+
"""List environments, and for each application the regions it can use."""
|
|
57
|
+
inv: Invocation = ctx.obj
|
|
58
|
+
res = _resolver(inv, no_cache)
|
|
59
|
+
app_code = res.application(app)["application_code"] if app else None
|
|
60
|
+
rows = res.regions_for(app_code)
|
|
61
|
+
if not show_all:
|
|
62
|
+
before = len(rows)
|
|
63
|
+
rows = allowlist.filter_placements(rows, res.tenant_code())
|
|
64
|
+
_hidden(before, len(rows))
|
|
65
|
+
grouped: dict[tuple[str, str], list[str]] = {}
|
|
66
|
+
for r in rows:
|
|
67
|
+
grouped.setdefault((r["product"], r["environment"]), []).append(r["region"])
|
|
68
|
+
data = [
|
|
69
|
+
{"application": product, "environment": env, "regions": sorted(regions)}
|
|
70
|
+
for (product, env), regions in sorted(grouped.items(), key=lambda kv: (kv[0][0], ENVIRONMENTS.index(kv[0][1]) if kv[0][1] in ENVIRONMENTS else 99))
|
|
71
|
+
]
|
|
72
|
+
inv.emit(data, lambda d: rows_table(["Application", "Environment", "Regions"], [(r["application"], r["environment"], ", ".join(r["regions"])) for r in d]))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@regions_app.command("list")
|
|
76
|
+
def regions_list(
|
|
77
|
+
ctx: typer.Context,
|
|
78
|
+
app: str | None = typer.Option(None, "--app", "--product", help="Only placements of this application."),
|
|
79
|
+
env: str | None = typer.Option(None, "--env", help="Only this environment."),
|
|
80
|
+
vendor: str | None = typer.Option(None, "--vendor", help="Show the cloud vendor's raw region list instead (e.g. aws)."),
|
|
81
|
+
for_service: str | None = typer.Option(None, "--for", help="Only regions that offer this: s3 | sqs | dynamo | eks | gateway | database."),
|
|
82
|
+
show_all: bool = _ALL,
|
|
83
|
+
no_cache: bool = _NO_CACHE,
|
|
84
|
+
):
|
|
85
|
+
"""List the regions (placements) available to you. These are the --region values.
|
|
86
|
+
|
|
87
|
+
Filtered by the placement allowlist (section 5), which is narrower than
|
|
88
|
+
what the backend reports. `--for s3` narrows further to one service, the
|
|
89
|
+
same check a create command makes. `--all` skips the filter.
|
|
90
|
+
"""
|
|
91
|
+
inv: Invocation = ctx.obj
|
|
92
|
+
if vendor:
|
|
93
|
+
regions = catalog.list_vendor_regions(inv.api, vendor)
|
|
94
|
+
inv.emit(regions, lambda d: rows_table(["Name", "Code", "Identifier"], [(r.get("name"), r.get("code"), r.get("region_identifier")) for r in d]))
|
|
95
|
+
return
|
|
96
|
+
res = _resolver(inv, no_cache)
|
|
97
|
+
app_code = res.application(app)["application_code"] if app else None
|
|
98
|
+
environment = res.environment(env) if env else None
|
|
99
|
+
rows = res.regions_for(app_code, environment)
|
|
100
|
+
if not show_all:
|
|
101
|
+
before = len(rows)
|
|
102
|
+
rows = allowlist.filter_placements(rows, res.tenant_code(), for_service.strip().lower() if for_service else None)
|
|
103
|
+
_hidden(before, len(rows))
|
|
104
|
+
seen = {}
|
|
105
|
+
for r in rows:
|
|
106
|
+
seen.setdefault(r["geo_loc_mst_code"], {"region": r["region"], "geo_loc_mst_code": r["geo_loc_mst_code"], "placements": []})
|
|
107
|
+
seen[r["geo_loc_mst_code"]]["placements"].append(f"{r['product']}/{r['environment']}")
|
|
108
|
+
data = sorted(seen.values(), key=lambda r: r["region"] or "")
|
|
109
|
+
inv.emit(data, lambda d: rows_table(["Region", "Code", "Available in"], [(r["region"], r["geo_loc_mst_code"], ", ".join(r["placements"])) for r in d]))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@resource_types_app.command("list")
|
|
113
|
+
def resource_types_list(ctx: typer.Context, family: str | None = typer.Option(None, "--family", help="Filter by family (compute, storage, network, …)."), no_cache: bool = _NO_CACHE):
|
|
114
|
+
"""List infrastructure types."""
|
|
115
|
+
inv: Invocation = ctx.obj
|
|
116
|
+
types = _resolver(inv, no_cache).infrastructure_types()
|
|
117
|
+
if family:
|
|
118
|
+
types = [t for t in types if (t.get("infra_family") or "").lower() == family.lower()]
|
|
119
|
+
inv.emit(types, lambda d: rows_table(["Name", "Code", "Vendor", "Family"], [(t.get("name"), t.get("code"), t.get("infra_vendor"), t.get("infra_family")) for t in d]))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@resource_groups_app.command("list")
|
|
123
|
+
def resource_groups_list(ctx: typer.Context, app: str | None = typer.Option(None, "--app", "--product", help="Only groups of this application."), show_all: bool = _ALL, no_cache: bool = _NO_CACHE):
|
|
124
|
+
"""List resource groups. Services are created inside one.
|
|
125
|
+
|
|
126
|
+
Only groups of applications you can actually place something in, so the
|
|
127
|
+
list matches what `eks create` will accept. Two applications can each
|
|
128
|
+
have a group of the same name, and seeing both with no way to tell them
|
|
129
|
+
apart is worse than seeing only the one that applies.
|
|
130
|
+
"""
|
|
131
|
+
inv: Invocation = ctx.obj
|
|
132
|
+
res = _resolver(inv, no_cache)
|
|
133
|
+
groups = res.resource_groups()
|
|
134
|
+
if app:
|
|
135
|
+
code = res.application(app)["application_code"]
|
|
136
|
+
groups = [g for g in groups if g.get("applications_mst_code") == code]
|
|
137
|
+
elif not show_all:
|
|
138
|
+
before = len(groups)
|
|
139
|
+
allowed = {r["application_code"] for r in allowlist.filter_placements(res.placements(), res.tenant_code())}
|
|
140
|
+
if allowed:
|
|
141
|
+
groups = [g for g in groups if g.get("applications_mst_code") in allowed]
|
|
142
|
+
_hidden(before, len(groups))
|
|
143
|
+
inv.emit(groups, lambda d: rows_table(["Name", "Code", "Application", "Kind", "Services"], [(g.get("name"), g.get("code"), g.get("application_name"), g.get("kind"), g.get("services_count")) for g in d]))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@services_app.command("list")
|
|
147
|
+
def services_list(
|
|
148
|
+
ctx: typer.Context,
|
|
149
|
+
app: str | None = typer.Option(None, "--app", "--product", help="Only services of this application."),
|
|
150
|
+
search: str | None = typer.Option(None, "--search", help="Name contains this text."),
|
|
151
|
+
no_cache: bool = _NO_CACHE,
|
|
152
|
+
):
|
|
153
|
+
"""List services."""
|
|
154
|
+
inv: Invocation = ctx.obj
|
|
155
|
+
res = _resolver(inv, no_cache)
|
|
156
|
+
services = res.services()
|
|
157
|
+
if app:
|
|
158
|
+
code = res.application(app)["application_code"]
|
|
159
|
+
services = [s for s in services if s.get("application_code") == code]
|
|
160
|
+
if search:
|
|
161
|
+
services = [s for s in services if search.lower() in (s.get("service_name") or "").lower()]
|
|
162
|
+
inv.emit(services, lambda d: rows_table(
|
|
163
|
+
["Name", "Application", "Resource group", "Type", "Public", "Status"],
|
|
164
|
+
[(s.get("service_name"), s.get("application_name"), s.get("resource_group_name"), s.get("service_type"), "yes" if s.get("is_public_facing") else "", s.get("status")) for s in d],
|
|
165
|
+
))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@services_app.command("describe")
|
|
169
|
+
def services_describe(ctx: typer.Context, service: str = typer.Argument(..., help="Service name or code."), no_cache: bool = _NO_CACHE):
|
|
170
|
+
"""Show one service: owner, application, resource group, counts."""
|
|
171
|
+
inv: Invocation = ctx.obj
|
|
172
|
+
found = _resolver(inv, no_cache).service(service)
|
|
173
|
+
detail = catalog.get_service(inv.api, found["service_code"])
|
|
174
|
+
inv.emit(detail, lambda d: kv_table([
|
|
175
|
+
("Name", d.get("service_name")),
|
|
176
|
+
("Code", d.get("service_code")),
|
|
177
|
+
("Description", d.get("description")),
|
|
178
|
+
("Application", f"{d.get('application_name')} ({d.get('application_code')})"),
|
|
179
|
+
("Resource group", f"{d.get('resource_group_name')} ({d.get('resource_group_kind')})"),
|
|
180
|
+
("Type", d.get("service_type")),
|
|
181
|
+
("Public facing", "yes" if d.get("is_public_facing") else "no"),
|
|
182
|
+
("Status", d.get("status")),
|
|
183
|
+
("Owner", f"{d.get('owner_name') or ''} {('<' + d['owner_email'] + '>') if d.get('owner_email') else ''}".strip()),
|
|
184
|
+
("Infrastructure", d.get("infrastructure_count")),
|
|
185
|
+
("Alerts", f"{d.get('alerts_configured')}/{d.get('alerts_total')}"),
|
|
186
|
+
("Created", d.get("created_at")),
|
|
187
|
+
], title=d.get("service_name")))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""devlift clusters … — the EKS and ECS clusters services can be placed on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from devlift_cli.api import infra_list
|
|
8
|
+
from devlift_cli.commands.manual import section_command
|
|
9
|
+
from devlift_cli.context import Invocation
|
|
10
|
+
from devlift_cli.errors import InputError
|
|
11
|
+
from devlift_cli.render import output
|
|
12
|
+
from devlift_cli.render.output import rows_table
|
|
13
|
+
from devlift_cli.resolve import allowlist
|
|
14
|
+
from devlift_cli.resolve.names import Resolver
|
|
15
|
+
|
|
16
|
+
clusters_app = typer.Typer(help="EKS and ECS clusters (the --cluster values).", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
_TYPES = {"eks": "eks_infrastructuretype_ref", "ecs": "ecs_ec2_infrastructuretype_ref"}
|
|
19
|
+
# The placement-allowlist service key each cluster type is listed under.
|
|
20
|
+
_SERVICE_OF = {"EKS": allowlist.SERVICE_EKS, "ECS EC2": "ecs"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _placeable(res: Resolver, rows: list[dict]) -> list[dict]:
|
|
24
|
+
"""Keep clusters in a placement a service could really be created in.
|
|
25
|
+
|
|
26
|
+
Two filters, the same pair every create goes through: the backend only
|
|
27
|
+
reports placements this deployment serves, and the allowlist says which
|
|
28
|
+
of those offer that kind of service. A cluster outside both is real but
|
|
29
|
+
unusable from here, which is why it is hidden rather than listed.
|
|
30
|
+
"""
|
|
31
|
+
placements = res.placements()
|
|
32
|
+
tenant = res.tenant_code()
|
|
33
|
+
allowed: dict[str, set] = {}
|
|
34
|
+
for service in set(_SERVICE_OF.values()):
|
|
35
|
+
allowed[service] = {
|
|
36
|
+
(p["application_code"], p["environment"], p["geo_loc_mst_code"])
|
|
37
|
+
for p in allowlist.filter_placements(placements, tenant, service)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
kept = []
|
|
41
|
+
for row in rows:
|
|
42
|
+
service = _SERVICE_OF.get(row.get("cluster_type") or "")
|
|
43
|
+
if service is None:
|
|
44
|
+
continue
|
|
45
|
+
keys = allowed[service]
|
|
46
|
+
here = (row.get("environment"), row.get("geo_loc_mst_code"))
|
|
47
|
+
app = row.get("applications_mst_code")
|
|
48
|
+
if app:
|
|
49
|
+
match = (app, *here) in keys
|
|
50
|
+
else: # tenant-level: any product will do
|
|
51
|
+
match = any((e, g) == here for _, e, g in keys)
|
|
52
|
+
if match:
|
|
53
|
+
kept.append(row)
|
|
54
|
+
return kept
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@clusters_app.command("list")
|
|
58
|
+
def clusters_list(
|
|
59
|
+
ctx: typer.Context,
|
|
60
|
+
app: str | None = typer.Option(None, "--app", "--product", help="Only clusters of this application (tenant-level ones are always shown)."),
|
|
61
|
+
env: str | None = typer.Option(None, "--env", help="Only this environment."),
|
|
62
|
+
region: str | None = typer.Option(None, "--region", help="Only this region."),
|
|
63
|
+
kind: str | None = typer.Option(None, "--type", help="eks | ecs. Both when omitted."),
|
|
64
|
+
search: str | None = typer.Option(None, "--search", help="Match on the cluster name."),
|
|
65
|
+
show_all: bool = typer.Option(False, "--all", help="Every cluster the backend reports, without the placement filter."),
|
|
66
|
+
):
|
|
67
|
+
"""List the clusters a service can run on.
|
|
68
|
+
|
|
69
|
+
Narrowed to placements this deployment serves and the placement allowlist
|
|
70
|
+
permits, so a cluster listed here is one a service could actually be
|
|
71
|
+
created on. `--all` shows everything the backend reports.
|
|
72
|
+
|
|
73
|
+
"Registered" is the flag that matters within that: only a registered
|
|
74
|
+
cluster may take a new service, and `eks create` will not offer the
|
|
75
|
+
others. A cluster with no application is tenant-level and is the fallback
|
|
76
|
+
for every product that has none of its own.
|
|
77
|
+
"""
|
|
78
|
+
inv: Invocation = ctx.obj
|
|
79
|
+
res = Resolver(inv.api, inv.profile.name)
|
|
80
|
+
if kind and kind.strip().lower() not in _TYPES:
|
|
81
|
+
raise InputError("--type must be eks or ecs.")
|
|
82
|
+
app_code = res.application(app)["application_code"] if app else None
|
|
83
|
+
rows = infra_list.list_clusters(
|
|
84
|
+
inv.api,
|
|
85
|
+
infra_type=_TYPES[kind.strip().lower()] if kind else None,
|
|
86
|
+
environment=res.environment(env) if env else None,
|
|
87
|
+
geo_loc_mst_code=None,
|
|
88
|
+
search=search,
|
|
89
|
+
)
|
|
90
|
+
if region:
|
|
91
|
+
wanted = region.strip().lower()
|
|
92
|
+
rows = [r for r in rows if wanted in (str(r.get("geo_loc_name", "")).lower(), str(r.get("geo_loc_mst_code", "")).lower())]
|
|
93
|
+
if app_code:
|
|
94
|
+
rows = [r for r in rows if r.get("applications_mst_code") in (app_code, None)]
|
|
95
|
+
if not show_all:
|
|
96
|
+
before = len(rows)
|
|
97
|
+
rows = _placeable(res, rows)
|
|
98
|
+
if before > len(rows):
|
|
99
|
+
output.info(f"{before - len(rows)} of {before} hidden: not a placement this deployment offers; --all shows everything.")
|
|
100
|
+
inv.emit(rows, lambda d: rows_table(
|
|
101
|
+
["Cluster", "Type", "Environment", "Region", "Application", "Registered", "Status", "Code"],
|
|
102
|
+
[(r.get("cluster_name") or r.get("name"), r.get("cluster_type"), r.get("environment"),
|
|
103
|
+
r.get("geo_loc_name") or r.get("geo_loc_mst_code"), r.get("application_name") or "tenant-level",
|
|
104
|
+
"yes" if r.get("is_registered") else "no", r.get("status"), r.get("code")) for r in d],
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
clusters_app.command("manual")(section_command("clusters"))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""devlift deployment … and devlift queue …"""
|
|
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 deployments as deployments_api
|
|
9
|
+
from devlift_cli.api import infra
|
|
10
|
+
from devlift_cli.context import Invocation
|
|
11
|
+
from devlift_cli.ops.status import deployment_detail, history_table, print_deployment
|
|
12
|
+
from devlift_cli.render.output import kv_table, rows_table
|
|
13
|
+
from devlift_cli.resolve.names import Resolver
|
|
14
|
+
|
|
15
|
+
deployment_app = typer.Typer(help="Deployments: history and progress, as the dashboard's tracker shows them.", no_args_is_help=True)
|
|
16
|
+
queue_app = typer.Typer(help="Your transaction queue (what is waiting to deploy).", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@deployment_app.command("list")
|
|
20
|
+
def deployment_list(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
status: str | None = typer.Option(None, "--status", help="running | completed | failed | timedout"),
|
|
23
|
+
app: str | None = typer.Option(None, "--app", "--product", help="Only this application."),
|
|
24
|
+
env: str | None = typer.Option(None, "--env", help="Only this environment."),
|
|
25
|
+
mine: bool = typer.Option(False, "--mine", help="Only deployments you started."),
|
|
26
|
+
limit: int = typer.Option(20, "--limit", help="How many (1-100)."),
|
|
27
|
+
):
|
|
28
|
+
"""List deployments, newest first."""
|
|
29
|
+
inv: Invocation = ctx.obj
|
|
30
|
+
res = Resolver(inv.api, inv.profile.name)
|
|
31
|
+
user_code = None
|
|
32
|
+
if mine:
|
|
33
|
+
from devlift_cli.api.context import get_context
|
|
34
|
+
user_code = get_context(inv.api).user_code
|
|
35
|
+
rows, total = deployments_api.history(
|
|
36
|
+
inv.api,
|
|
37
|
+
status=status.upper() if status else None,
|
|
38
|
+
application_code=res.application(app)["application_code"] if app else None,
|
|
39
|
+
environment=res.environment(env) if env else None,
|
|
40
|
+
user_code=user_code,
|
|
41
|
+
limit=max(1, min(limit, 100)),
|
|
42
|
+
)
|
|
43
|
+
if total > len(rows):
|
|
44
|
+
from devlift_cli.render import output
|
|
45
|
+
output.info(f"Showing {len(rows)} of {total}.")
|
|
46
|
+
inv.emit(rows, history_table)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@deployment_app.command("status")
|
|
50
|
+
def deployment_status(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
workflow_id: str = typer.Argument(..., help="Workflow id printed by a create, update or deploy command."),
|
|
53
|
+
do_wait: bool = typer.Option(False, "--wait", help="Keep polling until it finishes."),
|
|
54
|
+
):
|
|
55
|
+
"""Show a deployment: status, pull request, resources and pipeline stages."""
|
|
56
|
+
inv: Invocation = ctx.obj
|
|
57
|
+
detail = deployment_detail(inv, workflow_id, do_wait=do_wait)
|
|
58
|
+
|
|
59
|
+
def table(d):
|
|
60
|
+
print_deployment(d)
|
|
61
|
+
return kv_table([])
|
|
62
|
+
inv.emit(detail, table)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@queue_app.command("list")
|
|
66
|
+
def queue_list(ctx: typer.Context, env: str | None = typer.Option(None, "--env", help="Only this environment.")):
|
|
67
|
+
"""List your queue items (pending, approved, PR raised)."""
|
|
68
|
+
inv: Invocation = ctx.obj
|
|
69
|
+
environment = Resolver(inv.api, inv.profile.name).environment(env) if env else None
|
|
70
|
+
items = infra.list_queue(inv.api, environment)
|
|
71
|
+
inv.emit(items, lambda d: rows_table(
|
|
72
|
+
["Item", "Name", "Kind", "Case", "Status", "Updated"],
|
|
73
|
+
[(i.get("code"), i.get("display_name"), i.get("table_name"), i.get("case_ref_code"), i.get("status"), str(i.get("status_last_updated_at") or "")[:19]) for i in d],
|
|
74
|
+
))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
deployment_app.command("manual")(section_command("deployment, queue"))
|