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/__init__.py +6 -0
- elan_cli/adk.py +197 -0
- elan_cli/auth.py +391 -0
- elan_cli/client.py +115 -0
- elan_cli/config.py +139 -0
- elan_cli/deploy.py +247 -0
- elan_cli/destroy.py +77 -0
- elan_cli/doctor.py +63 -0
- elan_cli/env.py +96 -0
- elan_cli/files.py +166 -0
- elan_cli/invoke.py +196 -0
- elan_cli/logs.py +149 -0
- elan_cli/main.py +664 -0
- elan_cli/manifests.py +124 -0
- elan_cli/project.py +137 -0
- elan_cli/projects.py +72 -0
- elan_cli/rollback.py +68 -0
- elan_cli/scaffold.py +408 -0
- elan_cli/secret.py +105 -0
- elan_cli/stream.py +162 -0
- elan_cli/tenant.py +59 -0
- elan_cli/validate.py +91 -0
- elan_cli/wallet.py +213 -0
- elan_cli-0.1.0.dist-info/METADATA +96 -0
- elan_cli-0.1.0.dist-info/RECORD +28 -0
- elan_cli-0.1.0.dist-info/WHEEL +4 -0
- elan_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elan_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|