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/manifests.py ADDED
@@ -0,0 +1,124 @@
1
+ """Reading a repo's manifests on the developer's own machine.
2
+
3
+ THIS IS THE ONE PLACE CUSTOMER CODE IS IMPORTED BY CHOICE, and where it belongs:
4
+ the customer's own laptop, running their own code, at their own request. The
5
+ deploy service never does this — it reads `manifests.json` produced inside the
6
+ build sandbox, because a manifest is the result of EXECUTING a module and the
7
+ trust boundary there has to be a process rather than a parser.
8
+
9
+ The asymmetry is the design. Here there is no boundary to cross: a developer
10
+ running `elan validate` on their own repo is already running their own code
11
+ every time they run their own tests.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+
23
+ class ManifestError(Exception):
24
+ """The repo's targets could not be read, with the reason the developer needs."""
25
+
26
+
27
+ class NoRuntime(ManifestError):
28
+ """The chosen interpreter does not have the platform runtime in it.
29
+
30
+ A SEPARATE TYPE BECAUSE IT IS A SEPARATE PROBLEM, and for a while it wore
31
+ the other one's message: "this runs your own code, so the error above is
32
+ from your repo". It is not from their repo. Nothing they can write in an
33
+ agent module fixes a missing `agentkit_runtime`, so that sentence sent
34
+ somebody reading it to look at the one place the answer is not.
35
+ """
36
+
37
+
38
+ def interpreter(root: Path, override: str | None = None) -> tuple[str, str]:
39
+ """Which Python runs the exporter, and WHY — the reason goes in the error.
40
+
41
+ THE CLI'S OWN INTERPRETER IS THE LAST RESORT, not the first. `elan` is
42
+ installed as a tool, which on purpose puts it in an isolated environment
43
+ containing the CLI and nothing else — so `sys.executable` is the one
44
+ interpreter guaranteed NOT to have the repo's dependencies in it. Reaching
45
+ for it first is what made a missing environment look like broken code.
46
+
47
+ AN UNACTIVATED `.venv` STILL COUNTS. `uv venv` creates one without
48
+ activating it, and a developer who has just run it and not sourced
49
+ anything is in the commonest state there is.
50
+ """
51
+ if override:
52
+ return override, "--python"
53
+ active = os.environ.get("VIRTUAL_ENV")
54
+ if active:
55
+ return str(Path(active) / "bin" / "python"), "$VIRTUAL_ENV"
56
+ local = root / ".venv" / "bin" / "python"
57
+ if local.exists():
58
+ return str(local), f"{local.parent.parent}"
59
+ return sys.executable, "the elan CLI's own environment"
60
+
61
+
62
+ def read(targets_root: Path, *, python: str | None = None) -> list[dict]:
63
+ """Every target's manifest, by running the repo's own exporter.
64
+
65
+ A SUBPROCESS, NOT AN IMPORT INTO THIS PROCESS, and that is worth the cost.
66
+ Importing a tenant's package here would put their `sys.modules`, their
67
+ monkeypatches and their import-time side effects inside the CLI for the rest
68
+ of its life — so `elan new agent` after `elan validate` would run in an
69
+ interpreter the repo had already modified. It also means a module that
70
+ raises on import produces a traceback the developer can read, rather than
71
+ taking the CLI down with it.
72
+
73
+ THE SAME ENTRYPOINT THE BUILD USES. `python -m agentkit_runtime.manifest_export`
74
+ runs in the build sandbox too, so what the developer validates against is
75
+ what the deploy service will later be handed — not a second implementation
76
+ that agrees with it today.
77
+ """
78
+ chosen, why = interpreter(targets_root, python)
79
+ out = subprocess.run(
80
+ # `--root`, and no `--out`: the exporter writes JSON to stdout and its
81
+ # diagnostics to stderr, so the two never need separating here.
82
+ [chosen, "-m", "agentkit_runtime.manifest_export",
83
+ "--root", str(targets_root)],
84
+ capture_output=True, text=True, cwd=targets_root,
85
+ )
86
+ if out.returncode != 0:
87
+ detail = out.stderr.strip() or out.stdout.strip()
88
+ # TOLD APART BY THE MODULE THAT IS MISSING. `agentkit_runtime` is the
89
+ # exporter itself, so its absence means the environment, and anything
90
+ # else means the repo.
91
+ if "No module named 'agentkit_runtime'" in detail:
92
+ raise NoRuntime(
93
+ "this repo has no environment to read its agents with.\n\n"
94
+ f"Tried: {chosen}\n"
95
+ f" ({why})\n\n"
96
+ "Checking elan.yaml against your MANIFESTs means IMPORTING\n"
97
+ "your agents, which needs the platform runtime and whatever\n"
98
+ "else they import. The elan CLI deliberately does not carry\n"
99
+ "those — it would drag a web server and a database driver\n"
100
+ "onto your machine for a command that only reads files.\n\n"
101
+ "Everything above this line was checked and is fine. What was\n"
102
+ "skipped is the cross-check between the two files.\n\n"
103
+ "Create one:\n"
104
+ " elan env\n\n"
105
+ "It makes a `.venv` beside elan.yaml, which is then found\n"
106
+ "without being named. `--python` points at one elsewhere."
107
+ )
108
+ raise ManifestError(
109
+ "could not read this repo's targets.\n\n"
110
+ f"{detail}\n\n"
111
+ f"Read with {chosen}\n"
112
+ f"({why})\n\n"
113
+ "That interpreter has the runtime, so this is from your repo —\n"
114
+ "a module that raises on import, a missing MANIFEST, or two\n"
115
+ "targets declaring the same slug. The build would fail the same\n"
116
+ "way, which is why this check exists here."
117
+ )
118
+ try:
119
+ return json.loads(out.stdout)
120
+ except ValueError as exc:
121
+ raise ManifestError(
122
+ f"the exporter did not produce JSON: {exc}. "
123
+ f"First 200 characters: {out.stdout[:200]!r}"
124
+ ) from exc
elan_cli/project.py ADDED
@@ -0,0 +1,137 @@
1
+ """Finding a tenant's repo, and editing its deploy file without losing anything.
2
+
3
+ TWO FILES THAT MUST NOT DRIFT. `elan.yaml` declares what the platform will
4
+ provision; each target's Python MANIFEST declares what it does. A target present
5
+ in one and absent from the other is refused at deploy time in BOTH directions —
6
+ so a scaffolding command that writes the module and leaves the developer to
7
+ remember the YAML has built the failure it was meant to prevent.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from elan_agentkit_contracts.deploy_spec import DeploySpec, spec_from_yaml
16
+
17
+ #: The deploy file's name is fixed, and deliberately not configurable. A flag
18
+ #: for it would mean two repos could disagree about where their own contract
19
+ #: lives, and every error message would have to name the file it happened to be
20
+ #: reading.
21
+ SPEC_FILENAME = "elan.yaml"
22
+
23
+
24
+ class ProjectError(Exception):
25
+ """Something about the repo's shape, phrased for whoever has to fix it."""
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Project:
30
+ """A tenant's repo as the CLI needs it."""
31
+
32
+ #: Where `elan.yaml` is. Every path the CLI prints is relative to this, so a
33
+ #: developer can paste one into an editor without adjusting it.
34
+ root: Path
35
+ spec_path: Path
36
+ spec: DeploySpec
37
+
38
+ @property
39
+ def targets_root(self) -> Path:
40
+ """Where `agents/` and `workflows/` live.
41
+
42
+ RELATIVE TO elan.yaml, NOT TO THE WORKING DIRECTORY. `targetsRoot` is
43
+ read by the build too, which runs with the repo root as its cwd — so
44
+ resolving it against wherever the developer happened to type the command
45
+ would make `elan validate` and the build disagree about which files
46
+ exist.
47
+ """
48
+ return (self.root / self.spec.source.targets_root).resolve()
49
+
50
+
51
+ def find(start: Path | None = None) -> Project:
52
+ """The nearest enclosing repo, searched upward from `start`.
53
+
54
+ UPWARD, like git. A developer editing `agents/invoice/agent.py` should be
55
+ able to run `elan validate` there without first working out how many
56
+ directories up the deploy file is — and being told "no elan.yaml here" while
57
+ standing inside the repo is the kind of small friction that makes a tool feel
58
+ hostile.
59
+ """
60
+ here = (start or Path.cwd()).resolve()
61
+ for directory in [here, *here.parents]:
62
+ candidate = directory / SPEC_FILENAME
63
+ if candidate.is_file():
64
+ return load(candidate)
65
+ raise ProjectError(
66
+ f"no {SPEC_FILENAME} in {here} or any directory above it.\n"
67
+ f" `elan init` creates one, or run this from inside a project."
68
+ )
69
+
70
+
71
+ def load(spec_path: Path) -> Project:
72
+ try:
73
+ text = spec_path.read_text()
74
+ except OSError as exc:
75
+ raise ProjectError(f"cannot read {spec_path}: {exc}") from exc
76
+ try:
77
+ spec = spec_from_yaml(text)
78
+ except Exception as exc:
79
+ # THE SCHEMA'S OWN MESSAGE, NOT A SUMMARY OF IT. Pydantic names the
80
+ # field and the rule it broke; rewording that here would lose the field
81
+ # path, which is the only part that says where to look.
82
+ raise ProjectError(f"{spec_path}:\n{exc}") from exc
83
+ return Project(root=spec_path.parent.resolve(), spec_path=spec_path, spec=spec)
84
+
85
+
86
+ def add_target(spec_path: Path, slug: str) -> None:
87
+ """Append a `targets:` entry, preserving everything already in the file.
88
+
89
+ A TEXT EDIT, NOT A PARSE-AND-DUMP, and the reason is the file itself. This
90
+ one is dense with comments explaining why each block says what it says —
91
+ `yaml.safe_dump` would silently delete every one of them, reorder the keys,
92
+ and hand the developer back a diff touching a file they did not edit. A
93
+ scaffolding command that does that is one nobody runs twice.
94
+
95
+ So the entry is inserted as text, under the existing `targets:` key, and the
96
+ surrounding bytes are untouched.
97
+ """
98
+ text = spec_path.read_text()
99
+ lines = text.splitlines(keepends=True)
100
+
101
+ # TWO FORMS, BECAUSE `elan init` HAS TO WRITE THE SECOND. A bare `targets:`
102
+ # with nothing under it is YAML `None`, not an empty mapping, so a freshly
103
+ # scaffolded file would not parse at all — it is written as `targets: {}`
104
+ # and collapsed back to a plain key here the first time something is added.
105
+ index = next((i for i, line in enumerate(lines)
106
+ if line.rstrip() in ("targets:", "targets: {}")), None)
107
+ if index is None:
108
+ raise ProjectError(
109
+ f"{spec_path} has no top-level `targets:` key to add to. "
110
+ "Add one and re-run, or declare the target by hand."
111
+ )
112
+ if lines[index].rstrip() == "targets: {}":
113
+ lines[index] = "targets:\n"
114
+
115
+ entry = (
116
+ f"\n {slug}:\n"
117
+ f" # Takes every value from `defaults:` above. Override any block\n"
118
+ f" # here — a block replaces the default entirely rather than\n"
119
+ f" # merging field by field, so state all of it or none of it.\n"
120
+ f" database: {{enabled: true}}\n"
121
+ )
122
+
123
+ # AFTER THE LAST TARGET, NOT IMMEDIATELY AFTER THE KEY. Inserting at the top
124
+ # would put the new entry above targets that were there first, so every
125
+ # `elan new` would reorder somebody else's file and the diff would show
126
+ # changes nobody made.
127
+ end = len(lines)
128
+ for i in range(index + 1, len(lines)):
129
+ stripped = lines[i]
130
+ if stripped.strip() and not stripped.startswith((" ", "\t")):
131
+ end = i
132
+ break
133
+ while end > index + 1 and not lines[end - 1].strip():
134
+ end -= 1
135
+
136
+ lines.insert(end, entry)
137
+ spec_path.write_text("".join(lines))
elan_cli/projects.py ADDED
@@ -0,0 +1,72 @@
1
+ """`elan projects` and `elan agents` — what exists, and what it is running.
2
+
3
+ READ FROM THE SERVICE, NOT FROM elan.yaml. The file says what this checkout
4
+ intends; these say what the platform actually has. Those differ whenever
5
+ somebody else deployed, or a deploy half-failed, which is exactly when
6
+ somebody runs them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+
13
+ from elan_cli.client import Api, ApiError
14
+
15
+
16
+ def ls(api: Api) -> int:
17
+ try:
18
+ rows = api.request("GET", "/v1/projects").get("projects", [])
19
+ except ApiError as exc:
20
+ print(f"{exc}", file=sys.stderr)
21
+ return 1
22
+ if not rows:
23
+ print("No projects yet. `elan projects create <name>`.")
24
+ return 0
25
+
26
+ print(f"{'PROJECT':<18} {'AGENTS':>6} {'DEPLOYS':>7} {'LAST':<10} REPOSITORY")
27
+ for row in rows:
28
+ # A PROJECT NOBODY CREATED still appears — it exists because something
29
+ # was deployed to it — and says so rather than showing blanks that
30
+ # read as missing data.
31
+ repo = row.get("repo_url") or ("from deploy history"
32
+ if not row.get("registered") else "-")
33
+ print(f"{row['slug']:<18} {row.get('agents', 0):>6} "
34
+ f"{row.get('deployments', 0):>7} "
35
+ f"{(row.get('last_state') or '-'):<10} {repo}")
36
+ return 0
37
+
38
+
39
+ def create(api: Api, slug: str, name: str, repo: str) -> int:
40
+ try:
41
+ api.request("POST", "/v1/projects",
42
+ json={"slug": slug, "display_name": name,
43
+ "repo_url": repo})
44
+ except ApiError as exc:
45
+ print(f"{exc}", file=sys.stderr)
46
+ return 1
47
+ print(f"Created {slug}.")
48
+ print(f" elan init --tenant <you> --project {slug}")
49
+ return 0
50
+
51
+
52
+ def agents(api: Api, project: str) -> int:
53
+ path = "/v1/agents" + (f"?project={project}" if project else "")
54
+ try:
55
+ rows = api.request("GET", path).get("agents", [])
56
+ except ApiError as exc:
57
+ print(f"{exc}", file=sys.stderr)
58
+ return 1
59
+ if not rows:
60
+ print("Nothing deployed." if project
61
+ else "Nothing deployed in any project.")
62
+ return 0
63
+
64
+ print(f"{'AGENT':<24} {'PROJECT':<16} {'VERSION':<10} DIGEST")
65
+ for row in rows:
66
+ # EACH AGENT'S OWN DIGEST. Targets share an image lineage and move
67
+ # independently, so a single "current version" for a project would be
68
+ # a lie whenever a deploy moved some and not others.
69
+ digest = str(row.get("digest", "")).rsplit("@", 1)[-1][:19] or "-"
70
+ print(f"{row['slug']:<24} {row.get('project', ''):<16} "
71
+ f"{(row.get('version') or '-'):<10} {digest}")
72
+ return 0
elan_cli/rollback.py ADDED
@@ -0,0 +1,68 @@
1
+ """`elan rollback` — put a project back the way a previous deploy left it.
2
+
3
+ NO REBUILD. The images it moves back to already exist, were already scanned
4
+ and are already attested, which is what makes this fast enough to reach for
5
+ during an incident. Rebuilding the same source would produce a DIFFERENT
6
+ digest — timestamps, base-image drift — so it would be a new deploy wearing
7
+ old code, and Binary Authorization would have to attest it first.
8
+
9
+ IT ASKS BEFORE IT ACTS, unless told not to. A rollback is not destructive in
10
+ the way `destroy` is, but it does move every target at once and without the
11
+ canary staging a deploy gets — so the confirmation prints what will change and
12
+ names the deployment being restored.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+
19
+ from elan_cli.client import Api, ApiError
20
+ from elan_cli.deploy import _watch
21
+ from elan_cli.project import find
22
+
23
+
24
+ def run(api: Api, to: str, *, wait: bool = True, assume_yes: bool = False) -> int:
25
+ """Roll `project` back to deployment `to`."""
26
+ project = find()
27
+
28
+ # WHAT IT IS GOING BACK TO, FETCHED FIRST. A rollback names a deployment
29
+ # id, which is opaque — printing the date and what it ran is the
30
+ # difference between confirming a decision and confirming a string.
31
+ try:
32
+ target = api.request("GET", f"/v1/deployments/{to}")
33
+ except ApiError as exc:
34
+ print(f"{exc}", file=sys.stderr)
35
+ return 1
36
+
37
+ rollout = target.get("rollout") or {}
38
+ print(f"Rolling {project.spec.project} back to {to}")
39
+ print(f" started {target.get('started_at', 'unknown')}")
40
+ print(f" state {target.get('state', 'unknown')}")
41
+ print(f" targets {len(rollout)}")
42
+ for slug, digest in sorted(rollout.items()):
43
+ print(f" {slug:<24} {digest.split('@')[-1][:19]}")
44
+
45
+ if not assume_yes:
46
+ # NOT A y/N PROMPT ON A NAME NOBODY TYPED. The project is read from
47
+ # elan.yaml and the deployment id was given as an argument, so the
48
+ # confirmation is about the CONSEQUENCE, not about spelling.
49
+ print("\nEvery target above moves at once — a rollback is not staged "
50
+ "the way a deploy is.")
51
+ if input("Continue? [y/N] ").strip().lower() not in {"y", "yes"}:
52
+ print("Nothing was changed.")
53
+ return 1
54
+
55
+ try:
56
+ started = api.request(
57
+ "POST", f"/v1/projects/{project.spec.project}/rollback",
58
+ json={"to_deployment_id": to})
59
+ except ApiError as exc:
60
+ print(f"{exc}", file=sys.stderr)
61
+ return 1
62
+
63
+ deployment_id = started["deployment_id"]
64
+ print(f"\nrollback {deployment_id}")
65
+ if not wait:
66
+ print(f" elan status {deployment_id}")
67
+ return 0
68
+ return _watch(api, deployment_id)