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