elan-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.
elan_cli/client.py ADDED
@@ -0,0 +1,115 @@
1
+ """Talking to the deploy service, with a token that is fresh when it is used.
2
+
3
+ ONE PLACE THAT ATTACHES A CREDENTIAL, so there is one place to get it wrong.
4
+ Every command goes through `Api`, which refreshes before it sends rather than
5
+ after it fails — a token that expires mid-request comes back as a 401 the CLI
6
+ cannot tell from a revoked session, and retrying a POST that may have been
7
+ accepted is worse than not retrying it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import httpx
13
+
14
+ from elan_cli import auth
15
+ from elan_cli.config import Config
16
+
17
+
18
+ class ApiError(Exception):
19
+ """A refusal from the service, already phrased for the developer."""
20
+
21
+
22
+ class Api:
23
+ def __init__(self, config: Config, *, client: httpx.Client | None = None,
24
+ tokens: auth.Tokens | None = None) -> None:
25
+ self.config = config
26
+ self._client = client or httpx.Client(timeout=120.0)
27
+ self._tokens = tokens
28
+
29
+ def token(self) -> str:
30
+ """An access token, refreshed only when there is not a live one.
31
+
32
+ THE CACHE IS WHY THIS IS NOT A ROUND TRIP EVERY TIME. `self._tokens`
33
+ lives on the instance, so a fresh process always started with none and
34
+ every command — `invoke`, `agents`, `status` — paid a keyring read, a
35
+ TLS handshake to the issuer and a token refresh before doing any work
36
+ of its own. Roughly a second, on commands whose own work is one API
37
+ call.
38
+ """
39
+ if self._tokens is not None and self._tokens.valid:
40
+ return self._tokens.access_token
41
+
42
+ # ACROSS PROCESSES, not just within one.
43
+ held = auth.cached_access(self.config.issuer)
44
+ if held is not None:
45
+ self._tokens = held
46
+ return held.access_token
47
+
48
+ stored = auth.stored(self.config.issuer)
49
+ if not stored:
50
+ raise ApiError("not signed in. Run `elan login`.")
51
+ self._tokens = auth.refresh(self.config, stored, client=self._client)
52
+ # ROTATION HANDLED, because an issuer may return a NEW refresh token
53
+ # and invalidate the old one. Keeping the original would work once and
54
+ # then sign the developer out with no explanation.
55
+ #
56
+ # AND THE ORDER MATTERS: `auth.store` clears the cached access token,
57
+ # so the new one is written AFTER it. Reversed, a rotation would
58
+ # silently discard the token it had just minted and the next command
59
+ # would refresh again — the cache would appear to do nothing.
60
+ if self._tokens.refresh_token:
61
+ auth.store(self.config.issuer, self._tokens.refresh_token)
62
+ auth.cache_access(self.config.issuer, self._tokens)
63
+ return self._tokens.access_token
64
+
65
+ def request(self, method: str, path: str, **kwargs) -> dict:
66
+ url = f"{self.config.api}{path}"
67
+ try:
68
+ response = self._client.request(
69
+ method, url,
70
+ headers={"Authorization": f"Bearer {self.token()}"}, **kwargs)
71
+ except httpx.HTTPError as exc:
72
+ raise ApiError(f"cannot reach {url}: {exc}") from exc
73
+
74
+ if response.status_code == 401:
75
+ raise ApiError(
76
+ "the service rejected your token. Run `elan login` again.")
77
+ if response.status_code == 403:
78
+ # 403 AND NOT 404 IS DELIBERATE ON THE SERVICE SIDE, so the message
79
+ # here should not soften it into "not found": the resource exists
80
+ # and belongs to somebody else.
81
+ raise ApiError(
82
+ "that belongs to another tenant. Your token says who you are, "
83
+ "and the path disagreed with it.")
84
+ if response.status_code == 404:
85
+ raise ApiError(f"no such thing: {path}")
86
+ if response.status_code >= 400:
87
+ raise ApiError(_refusal(response))
88
+ return response.json() if response.content else {}
89
+
90
+ def close(self) -> None:
91
+ self._client.close()
92
+
93
+
94
+ def _refusal(response: httpx.Response) -> str:
95
+ """The service's own problems, one per line.
96
+
97
+ `cross_check` returns EVERY problem rather than the first, and the CLI must
98
+ not collapse that back to one: a developer fixing one, pushing, and waiting
99
+ for the next is the afternoon the service's behaviour exists to prevent.
100
+ """
101
+ try:
102
+ payload = response.json()
103
+ except ValueError:
104
+ return f"HTTP {response.status_code}: {response.text[:300]}"
105
+
106
+ problems = payload.get("problems")
107
+ if problems:
108
+ lines = [f"refused — {len(problems)} problem(s):", ""]
109
+ for problem in problems:
110
+ lines.append(f" {problem.get('where', '?')}")
111
+ for line in str(problem.get("message", "")).splitlines():
112
+ lines.append(f" {line}")
113
+ lines.append("")
114
+ return "\n".join(lines).rstrip()
115
+ return payload.get("error") or f"HTTP {response.status_code}"
elan_cli/config.py ADDED
@@ -0,0 +1,139 @@
1
+ """Where the CLI points, and where it keeps what it is given.
2
+
3
+ THE DEVELOPER SUPPLIES ONE THING: the platform's URL. Everything else about
4
+ signing in — the issuer, the client id, the audience — is ASKED FOR at
5
+ `GET /v1/auth` rather than configured. Nobody can be expected to know a
6
+ generated project id, and making them paste one is how a command line tool
7
+ acquires a setup page.
8
+
9
+ It also makes rotation a server-side change. Re-pointing at a new issuer, or
10
+ replacing the CLI's own application, would otherwise mean every developer
11
+ re-running a configure command before anything worked again.
12
+
13
+ TWO KINDS OF THING, KEPT APART. What is written here is PUBLIC: a URL, and the
14
+ public sign-in values the platform answered with. All of them appear in a
15
+ discovery document or in every token the issuer mints, so they live in an
16
+ ordinary file a developer can read, diff and check into a team's dotfiles.
17
+ CREDENTIALS ARE NOT HERE — they go to the OS keyring, which is the difference
18
+ between a laptop that is lost and a laptop that is lost along with a
19
+ credential in plaintext under the user's home directory.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ #: The platform this CLI is built for.
30
+ #:
31
+ #: EMPTY UNTIL THERE IS A PRODUCTION ONE, and empty rather than pointed at
32
+ #: non-prod. A client that ships aimed at a staging environment is a client
33
+ #: that quietly deploys somebody's first agent to the wrong place — and unlike
34
+ #: a missing default, that failure does not announce itself. When production
35
+ #: exists this becomes its URL and `--api` becomes the override for everyone
36
+ #: else, which is how `gh` and `stripe` work and what a developer expects.
37
+ DEFAULT_API = ""
38
+
39
+
40
+ def config_home() -> Path:
41
+ """`$ELAN_HOME`, else XDG, else `~/.config/elan`.
42
+
43
+ XDG rather than `~/.elan`, because a dotfile per tool is how home
44
+ directories became unreadable. `$ELAN_HOME` exists so a test — or a
45
+ developer with two platforms — can point elsewhere without touching the
46
+ real one.
47
+ """
48
+ if override := os.environ.get("ELAN_HOME"):
49
+ return Path(override)
50
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
51
+ return Path(base) / "elan"
52
+
53
+
54
+ class ConfigError(Exception):
55
+ """Phrased for whoever has to fix it, which is always the developer."""
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Config:
60
+ """One platform, as the CLI talks to it."""
61
+
62
+ #: The only thing a developer supplies, and one day not even this.
63
+ api: str
64
+ #: Answered by the platform at `GET /v1/auth` and cached here so `whoami`
65
+ #: and `deploy` do not need a round trip before every request. Empty until
66
+ #: the first `elan login`.
67
+ issuer: str = ""
68
+ client_id: str = ""
69
+ audience: str = ""
70
+ token_scope: str = ""
71
+ """The whole OAuth scope to ask for, ANSWERED RATHER THAN COMPOSED.
72
+
73
+ `audience` alone was not enough: Zitadel addresses a token through a scope,
74
+ so a client has to send `urn:zitadel:iam:org:project:id:<audience>:aud` and a
75
+ token missing it is well-formed, correctly signed and refused everywhere.
76
+ Four places here built that string from `audience`; this is the platform
77
+ handing over the finished one.
78
+
79
+ Empty against a platform that predates it, and `scopes()` then composes it —
80
+ which is why `audience` is still cached.
81
+ """
82
+ device_url: str = ""
83
+ """Where to approve a device sign-in — OUR page, not the issuer's.
84
+
85
+ ANSWERED BY THE PLATFORM rather than composed here, for the same reason
86
+ every other value on this object is: the CLI knows one URL and asks for the
87
+ rest. Empty against a platform that predates it, and `elan login` then
88
+ falls back to the issuer's own `verification_uri` — a sign-in on the wrong
89
+ page beats one that cannot start.
90
+ """
91
+
92
+ @property
93
+ def signed_in_possible(self) -> bool:
94
+ return bool(self.issuer and self.client_id)
95
+
96
+ @classmethod
97
+ def load(cls) -> Config:
98
+ path = config_home() / "config.json"
99
+ data: dict = {}
100
+ if path.is_file():
101
+ try:
102
+ data = json.loads(path.read_text())
103
+ except ValueError as exc:
104
+ raise ConfigError(f"{path} is not valid JSON: {exc}") from exc
105
+
106
+ api = os.environ.get("ELAN_API") or data.get("api") or DEFAULT_API
107
+ if not api:
108
+ raise ConfigError(
109
+ "this CLI does not know which platform to talk to.\n"
110
+ " Pass it once — `elan login --api https://your-platform` —\n"
111
+ " and it is remembered. There is no built-in default yet,\n"
112
+ " deliberately: a client aimed at the wrong environment\n"
113
+ " deploys somebody's first agent there without saying so."
114
+ )
115
+ return cls(api=api.rstrip("/"), issuer=data.get("issuer", ""),
116
+ client_id=data.get("client_id", ""),
117
+ audience=data.get("audience", ""),
118
+ token_scope=data.get("token_scope", ""),
119
+ device_url=data.get("device_url", ""))
120
+
121
+ def save(self) -> Path:
122
+ path = config_home() / "config.json"
123
+ path.parent.mkdir(parents=True, exist_ok=True)
124
+ path.write_text(json.dumps({
125
+ "api": self.api, "issuer": self.issuer,
126
+ "client_id": self.client_id, "audience": self.audience,
127
+ "token_scope": self.token_scope, "device_url": self.device_url,
128
+ }, indent=2, sort_keys=True) + "\n")
129
+ # 0600 even though nothing here is secret. It costs nothing, and the
130
+ # day somebody adds a field that IS secret the file is already right.
131
+ path.chmod(0o600)
132
+ return path
133
+
134
+ def with_auth(self, discovered: dict) -> Config:
135
+ return Config(api=self.api, issuer=discovered["issuer"].rstrip("/"),
136
+ client_id=discovered["client_id"],
137
+ audience=discovered.get("audience", ""),
138
+ token_scope=discovered.get("token_scope", ""),
139
+ device_url=discovered.get("device_url", ""))
elan_cli/deploy.py ADDED
@@ -0,0 +1,247 @@
1
+ """`elan deploy` and `elan status`.
2
+
3
+ A DEPLOY IS PROJECT-SCOPED, NOT AGENT-SCOPED, because one build serves the
4
+ whole repo. What moves to the new image is decided by what CHANGED: a diff
5
+ under one agent's directory moves that agent, a shared-code diff moves them
6
+ all, and a target nobody touched keeps the digest it is already running. So the
7
+ useful output is not "deployed" — it is a table saying which of your agents
8
+ moved and which did not.
9
+
10
+ `--target` NARROWS THE ROLLOUT, NEVER THE BUILD. There is one image per
11
+ project; building a subset of a repo is not a thing that exists.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from elan_cli.client import Api, ApiError
21
+ from elan_cli.project import Project, ProjectError, find
22
+
23
+ #: How often to ask the service how it is getting on. A build takes minutes, so
24
+ #: this is about the developer not staring at nothing rather than about
25
+ #: latency.
26
+ POLL_SECONDS = 5.0
27
+
28
+ #: States the service will not move out of.
29
+ TERMINAL = {"succeeded", "failed", "refused"}
30
+
31
+
32
+ def resolve_ref(project: Project, ref: str) -> str:
33
+ """Turn whatever the developer said into a 40-character sha.
34
+
35
+ RESOLVED HERE AND WRITTEN INTO THE FILE, which is what makes a deployment
36
+ reproducible from `elan.yaml` alone. Submitting a branch name would mean
37
+ the build resolved it later — so two deploys of "main" could build
38
+ different code while both claiming to be the same deployment, and a
39
+ rollback would have nothing exact to go back to.
40
+ """
41
+ try:
42
+ out = subprocess.run(["git", "rev-parse", f"{ref}^{{commit}}"],
43
+ cwd=project.root, capture_output=True, text=True)
44
+ except OSError as exc:
45
+ raise ProjectError(f"cannot run git: {exc}") from exc
46
+ if out.returncode != 0:
47
+ raise ProjectError(
48
+ f"git cannot resolve {ref!r} in {project.root}:\n"
49
+ f" {out.stderr.strip()}"
50
+ )
51
+ return out.stdout.strip()
52
+
53
+
54
+ def write_ref(project: Project, sha: str) -> bool:
55
+ """Rewrite `source.ref` in place, as a text edit.
56
+
57
+ A TEXT EDIT FOR THE SAME REASON `add_target` IS ONE: this file is mostly
58
+ comments, and a parse-and-dump would delete every one of them on every
59
+ deploy. Returns whether anything changed, so a re-deploy of the same commit
60
+ does not produce a diff.
61
+ """
62
+ if project.spec.source.ref == sha:
63
+ return False
64
+ lines = project.spec_path.read_text().splitlines(keepends=True)
65
+ for i, line in enumerate(lines):
66
+ stripped = line.strip()
67
+ if stripped.startswith("ref:"):
68
+ indent = line[:len(line) - len(line.lstrip())]
69
+ lines[i] = f'{indent}ref: "{sha}"\n'
70
+ project.spec_path.write_text("".join(lines))
71
+ return True
72
+ raise ProjectError(
73
+ f"{project.spec_path} has no `ref:` line under `source:` to rewrite."
74
+ )
75
+
76
+
77
+ def on_a_remote(project: Project, sha: str) -> bool:
78
+ """Whether any remote-tracking branch contains this commit.
79
+
80
+ ── WHY THIS IS CHECKED AT ALL ───────────────────────────────────────────
81
+ THE BUILDER CLONES THE REMOTE. `resolve_ref` turns whatever you said into a
82
+ local sha and writes it into `elan.yaml`, which is what makes a deployment
83
+ reproducible — but a commit that exists only on your machine cannot be
84
+ cloned by anything. Submitted anyway, the build runs for a minute or two
85
+ and dies inside a Cloud Build log with
86
+
87
+ fatal: reference is not a tree: 56ee2b5c847d…
88
+
89
+ which names neither the cause nor the fix, and reaches the developer as
90
+ `BuildFailed … run elan logs to see why`. One `git branch -r --contains`
91
+ answers it before anything is submitted.
92
+
93
+ ── AND WHY IT READS LOCAL REMOTE-TRACKING REFS ──────────────────────────
94
+ `git ls-remote` would be authoritative and costs a network round trip on
95
+ every deploy — and cannot answer "is my commit an ancestor of theirs"
96
+ without objects we may not have. Remote-tracking refs answer it offline and
97
+ are wrong only in the recoverable direction: a ref that is stale because you
98
+ pushed from somewhere else says "not pushed" for something that is, and the
99
+ refusal names `git fetch`.
100
+ """
101
+ out = subprocess.run(["git", "branch", "-r", "--contains", sha],
102
+ cwd=project.root, capture_output=True, text=True)
103
+ # A REPOSITORY WITH NO REMOTE AT ALL is not this check's business — it
104
+ # fails `git`'s own way, and refusing here would only duplicate that.
105
+ if out.returncode != 0:
106
+ return True
107
+ return bool(out.stdout.strip())
108
+
109
+
110
+ def run(api: Api, *, ref: str = "HEAD", wait: bool = True,
111
+ start: Path | None = None, sleep=time.sleep) -> int:
112
+ project = find(start)
113
+ sha = resolve_ref(project, ref)
114
+
115
+ # BEFORE THE SPEC IS REWRITTEN, so a refused deploy leaves no diff behind.
116
+ if not on_a_remote(project, sha):
117
+ raise ProjectError(
118
+ f"{sha[:12]} is not on any remote branch, and the build clones the "
119
+ f"remote — so this would fail with `reference is not a tree` after "
120
+ f"a minute of building.\n"
121
+ f" git push if the commit is only here\n"
122
+ f" git fetch if you pushed it from somewhere else, "
123
+ f"so this check can see it"
124
+ )
125
+
126
+ if write_ref(project, sha):
127
+ print(f"{project.spec_path.name}: source.ref -> {sha}")
128
+ # RE-READ, because everything below reports on the spec as submitted
129
+ # and the one in memory is now one edit behind.
130
+ project = find(start)
131
+
132
+ print(f"deploying {project.spec.tenant}/{project.spec.project} at {sha[:12]}")
133
+
134
+ try:
135
+ accepted = api.request(
136
+ "POST", f"/v1/projects/{project.spec.project}/deployments",
137
+ json={"spec_yaml": project.spec_path.read_text(), "ref": sha},
138
+ )
139
+ except ApiError as exc:
140
+ print(f"\n{exc}")
141
+ return 1
142
+
143
+ deployment_id = accepted["deployment_id"]
144
+ print(f" {deployment_id}")
145
+ if not wait:
146
+ print(f"\n elan status {deployment_id}")
147
+ return 0
148
+
149
+ return _watch(api, deployment_id, sleep=sleep)
150
+
151
+
152
+ def _watch(api: Api, deployment_id: str, *, sleep=time.sleep) -> int:
153
+ """Follow the phases, printing each one ONCE.
154
+
155
+ A line per poll would scroll a ten-minute build off the screen and bury the
156
+ one phase that mattered; a spinner would hide which phase is slow. So a
157
+ phase prints when it is entered and not again.
158
+ """
159
+ seen: str | None = None
160
+ while True:
161
+ try:
162
+ status = api.request("GET", f"/v1/deployments/{deployment_id}")
163
+ except ApiError as exc:
164
+ print(f"\n{exc}")
165
+ return 1
166
+
167
+ phase = status.get("phase")
168
+ if phase and phase != seen:
169
+ print(f" {phase}")
170
+ seen = phase
171
+
172
+ if str(status.get("state")) in TERMINAL:
173
+ return report(status)
174
+ sleep(POLL_SECONDS)
175
+
176
+
177
+ def report(status: dict) -> int:
178
+ """What happened, in the shape somebody actually asks about."""
179
+ state = str(status.get("state"))
180
+ print()
181
+
182
+ if problems := status.get("problems"):
183
+ print(f"refused — {len(problems)} problem(s):\n")
184
+ for problem in problems:
185
+ print(f" {problem.get('where', '?')}")
186
+ for line in str(problem.get("message", "")).splitlines():
187
+ print(f" {line}")
188
+ print()
189
+ return 1
190
+
191
+ if state == "failed":
192
+ print(f"failed: {status.get('error') or 'no reason recorded'}")
193
+ if status.get("needs_teardown"):
194
+ # SAID OUT LOUD, because a half-provisioned deploy leaves cloud
195
+ # resources that cost money and that nothing will clean up on its
196
+ # own. Discovering it on an invoice is the alternative.
197
+ print("\n This deploy provisioned something before it failed.")
198
+ print(" Those resources still exist and are still billed.")
199
+ return 1
200
+
201
+ digest = status.get("digest") or ""
202
+ if digest:
203
+ print(f"image {digest}")
204
+
205
+ _rollout(status.get("rollout") or {}, digest)
206
+ _scan(status.get("scan") or {})
207
+ return 0
208
+
209
+
210
+ def _rollout(rollout: dict, digest: str) -> None:
211
+ """Which targets moved, and which kept what they were running.
212
+
213
+ THE SECOND COLUMN IS THE POINT. Every target in the project is listed,
214
+ including the ones that did not move — because "what is my agent running"
215
+ is the question, and a list of only the changed ones cannot answer it.
216
+ """
217
+ if not rollout:
218
+ return
219
+ print("\n target digest")
220
+ for slug, target_digest in sorted(rollout.items()):
221
+ shown = str(target_digest).rsplit("@", 1)[-1][:19] or "-"
222
+ moved = " <- new" if digest and target_digest == digest else ""
223
+ print(f" {slug:<26} {shown}{moved}")
224
+
225
+
226
+ def _scan(scan: dict) -> None:
227
+ """WHETHER IT WAS SCANNED, not only what was found. A tenant reading
228
+ nothing cannot tell a clean image from one nobody looked at."""
229
+ if not scan:
230
+ return
231
+ if scan.get("skipped"):
232
+ print(f"\n not scanned: {scan.get('reason', 'no reason recorded')}")
233
+ return
234
+ findings = scan.get("findings")
235
+ if findings:
236
+ print(f"\n scan: {findings}")
237
+
238
+
239
+ def status(api: Api, deployment_id: str) -> int:
240
+ try:
241
+ payload = api.request("GET", f"/v1/deployments/{deployment_id}")
242
+ except ApiError as exc:
243
+ print(exc)
244
+ return 1
245
+ print(f"{payload['deployment_id']} {payload.get('state')} "
246
+ f"{payload.get('phase') or ''}".rstrip())
247
+ return report(payload)
elan_cli/destroy.py ADDED
@@ -0,0 +1,77 @@
1
+ """`elan destroy` — remove an agent or a project and everything it was using.
2
+
3
+ WHAT SURVIVES AND WHY. The IMAGE is left alone: targets share one image per
4
+ repo, so deleting it would take every sibling with it. Artifact Registry's
5
+ keep-recent policy collects superseded digests on its own, and the last
6
+ target's removal is what makes the image collectable.
7
+
8
+ WHAT DOES NOT SURVIVE is the agent's database, and that is why this asks for
9
+ the name rather than a yes. The platform snapshots it first — ADR-0038 — so
10
+ the data is recoverable for ninety days, but the deployment is not, and the
11
+ difference between an agent that is gone and one that was never meant to be
12
+ is a slug somebody typed while looking at something else.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+
19
+ from elan_cli.client import Api, ApiError
20
+ from elan_cli.project import find
21
+
22
+
23
+ def run(api: Api, agent: str = "", *, whole_project: bool = False,
24
+ assume_yes: bool = False) -> int:
25
+ project = find().spec.project
26
+ what = project if whole_project else agent
27
+ if not what:
28
+ print("Name an agent, or pass --project to destroy the whole thing.",
29
+ file=sys.stderr)
30
+ return 2
31
+
32
+ print(f"This removes {what!r} and everything provisioned for it:")
33
+ if whole_project:
34
+ print(" every agent in the project, its databases, topics, secrets,")
35
+ print(" its egress allocation and its namespace.")
36
+ else:
37
+ print(" its database, its topics, its secrets and its IAM.")
38
+ print("\nThe database is snapshotted first and kept for ninety days.")
39
+ print("The image is left alone — its siblings are running it.")
40
+
41
+ if not assume_yes:
42
+ # THE NAME, NOT A y/N. A yes is a keystroke somebody makes while
43
+ # reading the next line; typing the name is the one confirmation that
44
+ # cannot be given to the wrong thing by accident.
45
+ typed = input(f"\nType {what!r} to confirm: ").strip()
46
+ if typed != what:
47
+ print("Nothing was destroyed.")
48
+ return 1
49
+
50
+ path = (f"/v1/projects/{project}/destroy" if whole_project
51
+ else f"/v1/agents/{agent}?project={project}")
52
+ method = "POST" if whole_project else "DELETE"
53
+ try:
54
+ result = api.request(method, path, json={"confirm": what})
55
+ except ApiError as exc:
56
+ print(f"{exc}", file=sys.stderr)
57
+ return 1
58
+
59
+ print(f"\nremoved {result.get('removed', 0)}")
60
+ if result.get("already_gone"):
61
+ print(f"already gone {result['already_gone']}")
62
+ if result.get("commit"):
63
+ print(f"commit {result['commit']}")
64
+
65
+ failed = result.get("failed") or []
66
+ if failed:
67
+ # LISTED, NOT SUMMARISED. Each one is a resource still standing and
68
+ # still billing, and the operator needs to know which.
69
+ print(f"\n{len(failed)} could not be removed and still exist:",
70
+ file=sys.stderr)
71
+ for item in failed:
72
+ print(f" {item.get('kind')} {item.get('id')}: "
73
+ f"{item.get('error')}", file=sys.stderr)
74
+ print("\nRe-running is safe: what is already gone is skipped.",
75
+ file=sys.stderr)
76
+ return 1
77
+ return 0
elan_cli/doctor.py ADDED
@@ -0,0 +1,63 @@
1
+ """`elan doctor` — does the platform's record match what exists?
2
+
3
+ GHOSTS ARE WHAT THIS FINDS: a register row whose resource is gone. That is the
4
+ direction provisioning creates deliberately — rows are written BEFORE the
5
+ thing they describe, so a crash between the two leaves a row describing
6
+ nothing. ADR-0038 calls that the safe failure, and safe is not harmless: a
7
+ ghost makes `elan destroy` report success for a resource that was never there.
8
+
9
+ ORPHANS — resources with no row — are not here, and the omission is honest.
10
+ Finding one means enumerating every provider and then deciding whose it is,
11
+ and a Postgres role carries no label to decide by. That is an operator's sweep
12
+ across the whole platform, not a question a tenant can be given a scoped
13
+ answer to.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+
20
+ from elan_cli.client import Api, ApiError
21
+
22
+
23
+ def run(api: Api) -> int:
24
+ try:
25
+ report = api.request("GET", "/v1/doctor")
26
+ except ApiError as exc:
27
+ print(f"{exc}", file=sys.stderr)
28
+ return 1
29
+
30
+ checked = report.get("checked", 0)
31
+ ghosts = report.get("ghosts") or []
32
+ unknown = report.get("unknown") or []
33
+
34
+ print(f"checked {checked} recorded resource"
35
+ f"{'' if checked == 1 else 's'}")
36
+
37
+ if not ghosts and not unknown:
38
+ print("Everything the platform recorded still exists.")
39
+ return 0
40
+
41
+ if ghosts:
42
+ print(f"\n{len(ghosts)} recorded but ABSENT at the provider:")
43
+ for item in ghosts:
44
+ where = "/".join(x for x in (item.get("project"),
45
+ item.get("agent")) if x)
46
+ print(f" {item.get('kind'):<16} {item.get('id')}"
47
+ f"{' (' + where + ')' if where else ''}")
48
+ print("\nThese are safe — nothing is running that should not be — but "
49
+ "a destroy will report success for them.")
50
+
51
+ if unknown:
52
+ # SAID SEPARATELY FROM THE GHOSTS. "We could not tell" and "it is
53
+ # gone" are different answers, and collapsing them would turn a
54
+ # temporary API failure into a resource somebody goes looking for.
55
+ print(f"\n{len(unknown)} could not be checked:")
56
+ for item in unknown:
57
+ print(f" {item.get('kind'):<16} {item.get('id')}: "
58
+ f"{item.get('detail')}")
59
+
60
+ # NOT A FAILURE EXIT. A ghost is a bookkeeping discrepancy, not a broken
61
+ # deployment, and a non-zero exit here would fail somebody's CI for a
62
+ # condition that needs a person rather than a rollback.
63
+ return 0