autop-runner 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.
- autop_runner/__init__.py +19 -0
- autop_runner/cli.py +429 -0
- autop_runner/client.py +240 -0
- autop_runner/config.py +267 -0
- autop_runner/credentials/__init__.py +18 -0
- autop_runner/credentials/broker.py +208 -0
- autop_runner/credentials/env.py +82 -0
- autop_runner/credentials/gh_wrapper.py +136 -0
- autop_runner/credentials/git_helper.py +51 -0
- autop_runner/credstore.py +69 -0
- autop_runner/executor/__init__.py +15 -0
- autop_runner/executor/address_review.py +739 -0
- autop_runner/executor/credential_check.py +165 -0
- autop_runner/executor/diagnostics.py +131 -0
- autop_runner/executor/epic.py +283 -0
- autop_runner/executor/external.py +559 -0
- autop_runner/executor/fixes.py +270 -0
- autop_runner/executor/flowkit.py +136 -0
- autop_runner/executor/gitops.py +551 -0
- autop_runner/executor/implement.py +896 -0
- autop_runner/executor/implement_credential_recovery.py +315 -0
- autop_runner/executor/implement_pre_review.py +379 -0
- autop_runner/executor/implement_publish.py +241 -0
- autop_runner/executor/implement_recovery.py +721 -0
- autop_runner/executor/implement_reuse.py +530 -0
- autop_runner/executor/implementation_hold.py +131 -0
- autop_runner/executor/issue_comment.py +137 -0
- autop_runner/executor/issue_mention.py +397 -0
- autop_runner/executor/outcome.py +430 -0
- autop_runner/executor/phase_watchdog.py +435 -0
- autop_runner/executor/pre_review.py +685 -0
- autop_runner/executor/pre_review_outcome.py +271 -0
- autop_runner/executor/prepare.py +245 -0
- autop_runner/executor/prompts.py +292 -0
- autop_runner/executor/publication.py +630 -0
- autop_runner/executor/publication_push.py +413 -0
- autop_runner/executor/reap.py +435 -0
- autop_runner/executor/reap_inspect.py +208 -0
- autop_runner/executor/registry.py +60 -0
- autop_runner/executor/remote_proof.py +319 -0
- autop_runner/executor/retro.py +225 -0
- autop_runner/executor/retro_codex.py +397 -0
- autop_runner/executor/review.py +626 -0
- autop_runner/executor/review_completion.py +399 -0
- autop_runner/executor/review_coverage.py +430 -0
- autop_runner/executor/review_diff.py +624 -0
- autop_runner/executor/review_preflight.py +303 -0
- autop_runner/executor/review_runtime.py +218 -0
- autop_runner/executor/review_task.py +110 -0
- autop_runner/executor/review_worktree.py +642 -0
- autop_runner/executor/routing.py +430 -0
- autop_runner/executor/rubric.py +425 -0
- autop_runner/executor/runtime.py +725 -0
- autop_runner/executor/runtime_prep.py +309 -0
- autop_runner/executor/settings.py +151 -0
- autop_runner/executor/spec_inputs.py +172 -0
- autop_runner/executor/spec_staging.py +123 -0
- autop_runner/executor/suggestion.py +209 -0
- autop_runner/executor/telemetry.py +490 -0
- autop_runner/executor/transport.py +318 -0
- autop_runner/gitenv.py +48 -0
- autop_runner/hooks/pre_pr_review_guard.sh +848 -0
- autop_runner/hooks/publish_guard.sh +108 -0
- autop_runner/issues.py +489 -0
- autop_runner/protocol.py +63 -0
- autop_runner/redact.py +66 -0
- autop_runner/setup/__init__.py +12 -0
- autop_runner/setup/agentenv.py +106 -0
- autop_runner/setup/container.py +191 -0
- autop_runner/setup/layout.py +125 -0
- autop_runner/setup/preflight.py +135 -0
- autop_runner/setup/prompts.py +94 -0
- autop_runner/setup/service.py +108 -0
- autop_runner/setup/templates/autop-runner.service.tmpl +40 -0
- autop_runner/setup/templates/compose.yml.tmpl +43 -0
- autop_runner/setup/templates/dev.autop.runner.plist.tmpl +74 -0
- autop_runner/setup/wizard.py +668 -0
- autop_runner/skills.py +133 -0
- autop_runner/tools/__init__.py +0 -0
- autop_runner/tools/dependency_guard.py +142 -0
- autop_runner/tools/tool_output.py +235 -0
- autop_runner/tools/verification_guard.py +170 -0
- autop_runner/worker.py +440 -0
- autop_runner-0.1.0.dist-info/METADATA +35 -0
- autop_runner-0.1.0.dist-info/RECORD +89 -0
- autop_runner-0.1.0.dist-info/WHEEL +5 -0
- autop_runner-0.1.0.dist-info/entry_points.txt +2 -0
- autop_runner-0.1.0.dist-info/licenses/LICENSE +22 -0
- autop_runner-0.1.0.dist-info/top_level.txt +1 -0
autop_runner/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Autop runner — the customer-side executor (installs the `autop` command).
|
|
2
|
+
|
|
3
|
+
It dials out to the control plane, claims leased jobs, and runs every
|
|
4
|
+
LLM-backed Autopilot flow locally with the customer's own clones, worktrees,
|
|
5
|
+
and coding-agent CLI. It never holds a GitHub App key and never reads the
|
|
6
|
+
provider credential the CLI uses.
|
|
7
|
+
|
|
8
|
+
Modules (see `~/Projects/ATC/specs/001-atc-platform/plan.md`):
|
|
9
|
+
|
|
10
|
+
- `cli` — `autop runner enroll | run | status | drain`
|
|
11
|
+
- `config` — local, non-secret runner config (schema v1)
|
|
12
|
+
- `protocol` — protocol version range and message constants
|
|
13
|
+
- `client` — register/claim/heartbeat/events/complete/fail/abandon (phase 2)
|
|
14
|
+
- `executor/` — extracted from the Autopilot core's `dispatcher.py` (phase 3)
|
|
15
|
+
- `credentials/` — git credential helper + gh wrapper fetching per-job tokens (phase 3)
|
|
16
|
+
- `hooks/` — Claude Code hooks ported from the core (phase 3)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
autop_runner/cli.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""`autop` command line.
|
|
2
|
+
|
|
3
|
+
The customer-facing entrypoint. Runner operations live under `autop runner`;
|
|
4
|
+
authoring helpers are sibling groups: `autop issue` files
|
|
5
|
+
backlog work as the person's own `gh` login (`issues.py`), `autop skills`
|
|
6
|
+
installs the public Agent Skills (`skills.py`).
|
|
7
|
+
|
|
8
|
+
* `setup` — the interactive install: preflight, questions, files, enrollment,
|
|
9
|
+
start (D67; the one-liner's `install.sh` ends in this command).
|
|
10
|
+
* `status` — local readiness, no network.
|
|
11
|
+
* `enroll` — exchange the console's one-time token for the runner credential
|
|
12
|
+
(saved 0600, never printed).
|
|
13
|
+
* `run` — register and claim/execute until stopped; SIGTERM/SIGINT drain
|
|
14
|
+
(finish current work, claim nothing new, exit).
|
|
15
|
+
* `drain` — send that SIGTERM to the running `autop runner run`.
|
|
16
|
+
|
|
17
|
+
Execution mode (FR-026): `run` requires `AUTOP_EXECUTION`. The container
|
|
18
|
+
image sets `container` (the default install, deploy/Dockerfile). Running the
|
|
19
|
+
agents directly on the host needs the explicit `direct-host` opt-in and prints
|
|
20
|
+
why that is discouraged: the agent CLIs run with permission prompts skipped
|
|
21
|
+
(the core's `--dangerously-skip-permissions`, guarded by hooks and PATH
|
|
22
|
+
shims), and provider terms expect such runs to be confined.
|
|
23
|
+
|
|
24
|
+
Exit codes: 0 ok, 1 local configuration error, 4 credential rejected (re-enroll),
|
|
25
|
+
5 protocol incompatible (upgrade), 2 usage.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import getpass
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import pathlib
|
|
35
|
+
import platform
|
|
36
|
+
import signal
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
from autop_runner import __version__, credstore, issues, skills
|
|
40
|
+
from autop_runner.client import Incompatible, PlaneClient, PlaneError, Unauthorized
|
|
41
|
+
from autop_runner.config import ConfigError, RunnerConfig
|
|
42
|
+
from autop_runner.protocol import PROTOCOL_MAX, PROTOCOL_MIN
|
|
43
|
+
from autop_runner.setup.wizard import Answers, run_setup
|
|
44
|
+
from autop_runner.worker import EXIT_INCOMPATIBLE, EXIT_UNAUTHORIZED, Worker
|
|
45
|
+
|
|
46
|
+
DEFAULT_CONFIG = pathlib.Path("~/.autop/runner.json").expanduser()
|
|
47
|
+
PID_FILE = pathlib.Path("~/.autop/runner.pid").expanduser()
|
|
48
|
+
ENV_ENROLL_TOKEN = "AUTOP_ENROLL_TOKEN"
|
|
49
|
+
|
|
50
|
+
# Customer-facing environment (secrets live only here, never in the config file).
|
|
51
|
+
ENV_URL = "AUTOP_URL"
|
|
52
|
+
ENV_RUNNER_ID = "AUTOP_RUNNER_ID"
|
|
53
|
+
ENV_RUNNER_TOKEN = "AUTOP_RUNNER_TOKEN"
|
|
54
|
+
ENV_EXECUTION = "AUTOP_EXECUTION"
|
|
55
|
+
EXECUTION_MODES = ("container", "direct-host")
|
|
56
|
+
|
|
57
|
+
DIRECT_HOST_WARNING = (
|
|
58
|
+
"WARNING: direct-host execution. Agent CLIs run on this machine with "
|
|
59
|
+
"permission prompts skipped, confined only by Autop's hooks and command "
|
|
60
|
+
"guards, with access to everything your user can read. Provider terms "
|
|
61
|
+
"expect permission-skipped runs to be sandboxed; the container install "
|
|
62
|
+
"(deploy/compose.yml) is the supported default."
|
|
63
|
+
)
|
|
64
|
+
UNSET_EXECUTION = (
|
|
65
|
+
f"run: {ENV_EXECUTION} is not set. Autop runs agents in a container by "
|
|
66
|
+
"default (see deploy/INSTALL.md in the runner package). To run agents "
|
|
67
|
+
f"directly on this host anyway, set {ENV_EXECUTION}=direct-host."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parser() -> argparse.ArgumentParser:
|
|
72
|
+
p = argparse.ArgumentParser(prog="autop", description="Autop command line.")
|
|
73
|
+
p.add_argument("--version", action="version", version=f"autop {__version__}")
|
|
74
|
+
groups = p.add_subparsers(dest="group", required=True)
|
|
75
|
+
|
|
76
|
+
runner = groups.add_parser("runner", help="operate this machine's Autop runner")
|
|
77
|
+
runner.add_argument(
|
|
78
|
+
"--config",
|
|
79
|
+
type=pathlib.Path,
|
|
80
|
+
default=DEFAULT_CONFIG,
|
|
81
|
+
help=f"runner config file (default {DEFAULT_CONFIG})",
|
|
82
|
+
)
|
|
83
|
+
sub = runner.add_subparsers(dest="command", required=True)
|
|
84
|
+
for name, help_ in (
|
|
85
|
+
("status", "print local readiness without contacting Autop"),
|
|
86
|
+
("enroll", "exchange a one-time enrollment token for a credential"),
|
|
87
|
+
("run", "claim and execute jobs until stopped"),
|
|
88
|
+
("drain", "stop claiming, finish current work, exit"),
|
|
89
|
+
):
|
|
90
|
+
sp = sub.add_parser(name, help=help_)
|
|
91
|
+
# Accept --config after the subcommand too; people type it both ways.
|
|
92
|
+
sp.add_argument(
|
|
93
|
+
"--config", type=pathlib.Path, dest="config_after", default=None
|
|
94
|
+
)
|
|
95
|
+
sp.add_argument(
|
|
96
|
+
"--credential-file",
|
|
97
|
+
type=pathlib.Path,
|
|
98
|
+
default=credstore.DEFAULT_PATH,
|
|
99
|
+
help=f"runner credential (default {credstore.DEFAULT_PATH})",
|
|
100
|
+
)
|
|
101
|
+
if name == "enroll":
|
|
102
|
+
sp.add_argument("--url", help=f"Autop API URL (or {ENV_URL})")
|
|
103
|
+
sp.add_argument(
|
|
104
|
+
"--token-stdin",
|
|
105
|
+
action="store_true",
|
|
106
|
+
help=f"read the enrollment token from stdin (else {ENV_ENROLL_TOKEN} "
|
|
107
|
+
"or a hidden prompt)",
|
|
108
|
+
)
|
|
109
|
+
if name == "run":
|
|
110
|
+
sp.add_argument(
|
|
111
|
+
"--once", action="store_true", help="run a single job, then exit"
|
|
112
|
+
)
|
|
113
|
+
# `setup` stays out of that loop: it derives the config and credential paths
|
|
114
|
+
# from the layout it installs (container and direct host differ, D64), so
|
|
115
|
+
# --config / --credential-file would be two ways to say the same thing.
|
|
116
|
+
st = sub.add_parser(
|
|
117
|
+
"setup", help="install and configure this machine's runner interactively"
|
|
118
|
+
)
|
|
119
|
+
st.add_argument("--url", help=f"Autop API URL (or {ENV_URL})")
|
|
120
|
+
st.add_argument(
|
|
121
|
+
"--execution",
|
|
122
|
+
choices=EXECUTION_MODES,
|
|
123
|
+
default="container",
|
|
124
|
+
help="where agents run (default container)",
|
|
125
|
+
)
|
|
126
|
+
st.add_argument(
|
|
127
|
+
"--repos-dir", type=pathlib.Path, help="directory holding your clones"
|
|
128
|
+
)
|
|
129
|
+
st.add_argument("--image", help="runner image to pin (default the published one)")
|
|
130
|
+
st.add_argument(
|
|
131
|
+
"--build", action="store_true", help="build the image from this checkout"
|
|
132
|
+
)
|
|
133
|
+
st.add_argument(
|
|
134
|
+
"--upgrade", action="store_true", help="re-pin the image and restart"
|
|
135
|
+
)
|
|
136
|
+
st.add_argument(
|
|
137
|
+
"--yes", action="store_true", help="no questions: flags and environment only"
|
|
138
|
+
)
|
|
139
|
+
_issue_parser(groups)
|
|
140
|
+
_skills_parser(groups)
|
|
141
|
+
return p
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _issue_parser(groups) -> None:
|
|
145
|
+
issue = groups.add_parser(
|
|
146
|
+
"issue", help="file backlog work for Autop with your own gh login"
|
|
147
|
+
)
|
|
148
|
+
sub = issue.add_subparsers(dest="issue_command", required=True)
|
|
149
|
+
add = sub.add_parser(
|
|
150
|
+
"add", help="create an issue, label it, put it on the board as Todo, link it"
|
|
151
|
+
)
|
|
152
|
+
add.add_argument("--board", required=True, help="<org>/<number> or the board URL")
|
|
153
|
+
add.add_argument("--repo", required=True, help="owner/repo to file in")
|
|
154
|
+
add.add_argument("--title", required=True)
|
|
155
|
+
add.add_argument(
|
|
156
|
+
"--body-file", required=True, help="Markdown body ('-' reads stdin)"
|
|
157
|
+
)
|
|
158
|
+
add.add_argument("--priority", required=True, choices=issues.PRIORITIES)
|
|
159
|
+
add.add_argument(
|
|
160
|
+
"--spec", help="owner/<project>-autopilot@<sha>:specs/…/spec.md#user-story-N"
|
|
161
|
+
)
|
|
162
|
+
add.add_argument("--epic", action="store_true", help="label it an epic")
|
|
163
|
+
add.add_argument(
|
|
164
|
+
"--human", action="store_true", help="human-task: Autop never picks it"
|
|
165
|
+
)
|
|
166
|
+
add.add_argument("--parent", help="epic to attach to as a native sub-issue")
|
|
167
|
+
add.add_argument(
|
|
168
|
+
"--blocked-by",
|
|
169
|
+
action="append",
|
|
170
|
+
metavar="REF",
|
|
171
|
+
help="blocking issue (repeatable): native link plus a Blocked-by: line",
|
|
172
|
+
)
|
|
173
|
+
link = sub.add_parser("link", help="add sub-issue/blocked-by links to an issue")
|
|
174
|
+
link.add_argument("issue", help="owner/repo#N or URL")
|
|
175
|
+
link.add_argument("--parent")
|
|
176
|
+
link.add_argument("--blocked-by", action="append", metavar="REF")
|
|
177
|
+
labels = sub.add_parser("labels", help="create or refresh Autop's labels")
|
|
178
|
+
labels.add_argument("repos", nargs="+", metavar="owner/repo")
|
|
179
|
+
subs = sub.add_parser("subs", help="list an epic's native sub-issues")
|
|
180
|
+
subs.add_argument("epic", help="owner/repo#N or URL")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _skills_parser(groups) -> None:
|
|
184
|
+
sk = groups.add_parser("skills", help="install public Autop Agent Skills")
|
|
185
|
+
sub = sk.add_subparsers(dest="skills_command", required=True)
|
|
186
|
+
listing = sub.add_parser("list", help="list installed managed skills")
|
|
187
|
+
listing.add_argument("--target", choices=("all", *skills.TARGETS), default="all")
|
|
188
|
+
listing.add_argument(
|
|
189
|
+
"--dest", type=pathlib.Path, help="list this directory instead"
|
|
190
|
+
)
|
|
191
|
+
inst = sub.add_parser(
|
|
192
|
+
"install", help="copy them into ~/.claude/skills and ~/.agents/skills"
|
|
193
|
+
)
|
|
194
|
+
inst.add_argument("--ref", help="pin a tag or commit instead of following main")
|
|
195
|
+
inst.add_argument("--target", choices=("all", *skills.TARGETS), default="all")
|
|
196
|
+
inst.add_argument(
|
|
197
|
+
"--dest", type=pathlib.Path, help="install into this directory instead"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_status(config_path: pathlib.Path) -> int:
|
|
202
|
+
print(f"autop {__version__}")
|
|
203
|
+
print(f"protocol {PROTOCOL_MIN}..{PROTOCOL_MAX}")
|
|
204
|
+
print(f"os {platform.system().lower()} {platform.machine()}")
|
|
205
|
+
print(f"{ENV_URL} {'set' if os.environ.get(ENV_URL) else 'unset'}")
|
|
206
|
+
print(f"execution {execution_mode() or f'unset (set {ENV_EXECUTION})'}")
|
|
207
|
+
print(
|
|
208
|
+
f"{ENV_RUNNER_TOKEN} {'set' if os.environ.get(ENV_RUNNER_TOKEN) else 'unset'}"
|
|
209
|
+
)
|
|
210
|
+
cred_path = credstore.DEFAULT_PATH
|
|
211
|
+
print(f"credential {cred_path}: {'present' if cred_path.exists() else 'absent'}")
|
|
212
|
+
try:
|
|
213
|
+
cfg = RunnerConfig.load(config_path)
|
|
214
|
+
except ConfigError as exc:
|
|
215
|
+
print(f"config {config_path}: {exc}")
|
|
216
|
+
return 1
|
|
217
|
+
ready_repos = cfg.ready_repos()
|
|
218
|
+
ready = {r.full_name for r in ready_repos}
|
|
219
|
+
print(
|
|
220
|
+
f"config {config_path}: ok (name={cfg.name}, max_jobs={cfg.max_concurrent_jobs})"
|
|
221
|
+
)
|
|
222
|
+
explicit = {r.full_name for r in cfg.repos}
|
|
223
|
+
for r in cfg.repos:
|
|
224
|
+
# Same test as registration (`ready_repos`): a checkout, not a directory.
|
|
225
|
+
if r.full_name in ready:
|
|
226
|
+
state = "ready"
|
|
227
|
+
elif r.workspace.is_dir():
|
|
228
|
+
state = "not a clone (no .git)"
|
|
229
|
+
else:
|
|
230
|
+
state = "workspace missing"
|
|
231
|
+
print(f" repo {r.full_name}: {state}")
|
|
232
|
+
for r in ready_repos:
|
|
233
|
+
if r.full_name not in explicit:
|
|
234
|
+
print(f" repo {r.full_name}: ready (discovered)")
|
|
235
|
+
if cfg.repos_dir is not None:
|
|
236
|
+
print(f" repos_dir {cfg.repos_dir}")
|
|
237
|
+
print(f" runtimes {', '.join(cfg.runtimes) or '(none)'}")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _client(url: str, token: str) -> PlaneClient:
|
|
242
|
+
"""Seam for tests (they substitute an in-process fake plane)."""
|
|
243
|
+
return PlaneClient(url, token)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def report(cfg: RunnerConfig) -> dict:
|
|
247
|
+
"""What this machine offers: only ready repos, only configured runtimes.
|
|
248
|
+
The plane intersects it with the tenant's policy (it can only narrow)."""
|
|
249
|
+
return {
|
|
250
|
+
"name": cfg.name,
|
|
251
|
+
"os": platform.system().lower(),
|
|
252
|
+
"arch": platform.machine(),
|
|
253
|
+
"labels": list(cfg.labels),
|
|
254
|
+
"max_concurrent_jobs": cfg.max_concurrent_jobs,
|
|
255
|
+
"repos_ready": sorted(r.full_name for r in cfg.ready_repos()),
|
|
256
|
+
"runtimes_ready": list(cfg.runtimes),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_enroll(args, config_path: pathlib.Path) -> int:
|
|
261
|
+
url = args.url or os.environ.get(ENV_URL)
|
|
262
|
+
if not url:
|
|
263
|
+
print(f"enroll: pass --url or set {ENV_URL}")
|
|
264
|
+
return 1
|
|
265
|
+
try:
|
|
266
|
+
cfg = RunnerConfig.load(config_path)
|
|
267
|
+
except ConfigError as exc:
|
|
268
|
+
print(f"config {config_path}: {exc}")
|
|
269
|
+
return 1
|
|
270
|
+
if args.token_stdin:
|
|
271
|
+
token = sys.stdin.readline().strip()
|
|
272
|
+
else:
|
|
273
|
+
token = os.environ.get(ENV_ENROLL_TOKEN) or getpass.getpass(
|
|
274
|
+
"Enrollment token: "
|
|
275
|
+
)
|
|
276
|
+
if not token.startswith("autop_enroll_"):
|
|
277
|
+
print("enroll: that is not an Autop enrollment token (autop_enroll_…)")
|
|
278
|
+
return 1
|
|
279
|
+
try:
|
|
280
|
+
client = _client(url, token)
|
|
281
|
+
reg = client.register(report(cfg))
|
|
282
|
+
except ValueError as exc:
|
|
283
|
+
print(f"enroll: {exc}")
|
|
284
|
+
return 1
|
|
285
|
+
except Unauthorized:
|
|
286
|
+
print(
|
|
287
|
+
"enroll: the token was rejected (used, expired, or revoked); "
|
|
288
|
+
"create a new one in the console"
|
|
289
|
+
)
|
|
290
|
+
return EXIT_UNAUTHORIZED
|
|
291
|
+
except Incompatible as exc:
|
|
292
|
+
print(f"enroll: {exc}")
|
|
293
|
+
return EXIT_INCOMPATIBLE
|
|
294
|
+
except PlaneError as exc:
|
|
295
|
+
print(f"enroll: {exc}")
|
|
296
|
+
return 1
|
|
297
|
+
if not reg.get("enrolled"):
|
|
298
|
+
print("enroll: Autop did not issue a credential")
|
|
299
|
+
return 1
|
|
300
|
+
credstore.save(
|
|
301
|
+
args.credential_file,
|
|
302
|
+
credstore.Credential(
|
|
303
|
+
url=client.url, runner_id=reg["runner_id"], token=client.token
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
client.close()
|
|
307
|
+
caps = reg.get("capabilities") or {}
|
|
308
|
+
print(
|
|
309
|
+
f"enrolled runner {reg['runner_id']}; credential saved to "
|
|
310
|
+
f"{args.credential_file}"
|
|
311
|
+
)
|
|
312
|
+
print(f" repos {', '.join(caps.get('repos') or []) or '(none)'}")
|
|
313
|
+
print(f" runtimes {', '.join(caps.get('runtimes') or []) or '(none)'}")
|
|
314
|
+
ignored = reg.get("ignored") or {}
|
|
315
|
+
if ignored.get("repos") or ignored.get("runtimes"):
|
|
316
|
+
print(f" not permitted by your Autop policy: {ignored}")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def execution_mode(env=None) -> str | None:
|
|
321
|
+
"""`container` or `direct-host` when declared, else None (refuse)."""
|
|
322
|
+
mode = (os.environ if env is None else env).get(ENV_EXECUTION, "").strip()
|
|
323
|
+
return mode if mode in EXECUTION_MODES else None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_run(args, config_path: pathlib.Path) -> int:
|
|
327
|
+
logging.basicConfig(
|
|
328
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
|
329
|
+
)
|
|
330
|
+
mode = execution_mode()
|
|
331
|
+
if mode is None:
|
|
332
|
+
print(UNSET_EXECUTION)
|
|
333
|
+
return 1
|
|
334
|
+
if mode == "direct-host":
|
|
335
|
+
print(DIRECT_HOST_WARNING, file=sys.stderr)
|
|
336
|
+
logging.getLogger("autop.runner").warning("execution mode: direct-host")
|
|
337
|
+
try:
|
|
338
|
+
cfg = RunnerConfig.load(config_path)
|
|
339
|
+
cred = credstore.load(args.credential_file)
|
|
340
|
+
client = _client(cred.url, cred.token)
|
|
341
|
+
except (ConfigError, credstore.CredentialError, ValueError) as exc:
|
|
342
|
+
print(f"run: {exc}")
|
|
343
|
+
return 1
|
|
344
|
+
from autop_runner.executor.registry import build_executors
|
|
345
|
+
from autop_runner.executor.settings import RuntimeSettings
|
|
346
|
+
|
|
347
|
+
executors = build_executors(cfg, RuntimeSettings.from_env())
|
|
348
|
+
worker = Worker(client, report(cfg), executors=executors)
|
|
349
|
+
|
|
350
|
+
def drain(signum, _frame):
|
|
351
|
+
logging.getLogger("autop.runner").info(
|
|
352
|
+
"signal %s: draining (finishing current work)", signum
|
|
353
|
+
)
|
|
354
|
+
worker.stop.set()
|
|
355
|
+
|
|
356
|
+
signal.signal(signal.SIGTERM, drain)
|
|
357
|
+
signal.signal(signal.SIGINT, drain)
|
|
358
|
+
pid_file = args.credential_file.parent / PID_FILE.name
|
|
359
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
360
|
+
pid_file.write_text(str(os.getpid()))
|
|
361
|
+
try:
|
|
362
|
+
return worker.run(max_jobs=1 if args.once else None)
|
|
363
|
+
finally:
|
|
364
|
+
client.close()
|
|
365
|
+
if pid_file.exists() and pid_file.read_text() == str(os.getpid()):
|
|
366
|
+
pid_file.unlink()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cmd_drain(args) -> int:
|
|
370
|
+
pid_file = args.credential_file.parent / PID_FILE.name
|
|
371
|
+
try:
|
|
372
|
+
pid = int(pid_file.read_text())
|
|
373
|
+
except (FileNotFoundError, ValueError):
|
|
374
|
+
print("drain: no running runner found")
|
|
375
|
+
return 1
|
|
376
|
+
try:
|
|
377
|
+
os.kill(pid, signal.SIGTERM)
|
|
378
|
+
except ProcessLookupError:
|
|
379
|
+
print("drain: the recorded runner is not running")
|
|
380
|
+
pid_file.unlink(missing_ok=True)
|
|
381
|
+
return 1
|
|
382
|
+
print(f"drain: asked runner (pid {pid}) to finish current work and exit")
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def cmd_setup(args) -> int:
|
|
387
|
+
url = args.url or os.environ.get(ENV_URL)
|
|
388
|
+
if not url:
|
|
389
|
+
print(f"setup: pass --url or set {ENV_URL}")
|
|
390
|
+
return 1
|
|
391
|
+
return run_setup(
|
|
392
|
+
Answers(
|
|
393
|
+
url=url,
|
|
394
|
+
mode=args.execution,
|
|
395
|
+
# The token may come from the environment (`install.sh` exports
|
|
396
|
+
# nothing; an unattended install may). It is never echoed.
|
|
397
|
+
token=os.environ.get(ENV_ENROLL_TOKEN),
|
|
398
|
+
repos_dir=args.repos_dir,
|
|
399
|
+
image=args.image,
|
|
400
|
+
build=args.build,
|
|
401
|
+
upgrade=args.upgrade,
|
|
402
|
+
assume_yes=args.yes,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main(argv: list[str] | None = None) -> int:
|
|
408
|
+
args = _parser().parse_args(argv)
|
|
409
|
+
if args.group == "issue":
|
|
410
|
+
return issues.main(args)
|
|
411
|
+
if args.group == "skills":
|
|
412
|
+
return skills.main(args)
|
|
413
|
+
if args.group == "runner":
|
|
414
|
+
if args.command == "setup":
|
|
415
|
+
return cmd_setup(args)
|
|
416
|
+
config_path = args.config_after or args.config
|
|
417
|
+
if args.command == "status":
|
|
418
|
+
return cmd_status(config_path)
|
|
419
|
+
if args.command == "enroll":
|
|
420
|
+
return cmd_enroll(args, config_path)
|
|
421
|
+
if args.command == "run":
|
|
422
|
+
return cmd_run(args, config_path)
|
|
423
|
+
if args.command == "drain":
|
|
424
|
+
return cmd_drain(args)
|
|
425
|
+
return 2
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
if __name__ == "__main__":
|
|
429
|
+
sys.exit(main())
|
autop_runner/client.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Protocol client for `/api/runner/v1` (the plane's side is `atc/api/runner_v1.py`).
|
|
2
|
+
|
|
3
|
+
Outbound HTTPS only (D2): the runner dials the plane; nothing dials the runner.
|
|
4
|
+
Every failure maps to a typed exception so the worker can decide without
|
|
5
|
+
parsing strings:
|
|
6
|
+
|
|
7
|
+
* `Unauthorized` — credential unknown/revoked: stop; re-enroll.
|
|
8
|
+
* `Incompatible` — disjoint protocol ranges (426): drain and exit.
|
|
9
|
+
* `StaleLease(reason)` — this attempt no longer owns its job: stop the work,
|
|
10
|
+
send nothing further for it.
|
|
11
|
+
* `SequenceGap(expected)` — resend events from `expected`.
|
|
12
|
+
* `Refused(reason)` — the plane's 422: a tenancy rule refused the request
|
|
13
|
+
(for a publishing token: a paused project, unverified org ownership, a
|
|
14
|
+
repo outside the project, an uncovered repo). `reason` is the plane's
|
|
15
|
+
tenant-facing sentence, non-secret by construction (`atc.db.Rejected`).
|
|
16
|
+
* `PlaneUnavailable` — network error or 5xx: retry with backoff. Every call is
|
|
17
|
+
safe to retry: claims carry a request id the plane replays, terminal reports
|
|
18
|
+
replay to the same answer, events are idempotent by sequence.
|
|
19
|
+
|
|
20
|
+
The credential is sent only in the Authorization header and never appears in
|
|
21
|
+
an exception message or `repr`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import urllib.parse
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from autop_runner import __version__
|
|
32
|
+
from autop_runner.protocol import PROTOCOL_MAX, PROTOCOL_MIN
|
|
33
|
+
|
|
34
|
+
BUILD_TAG = f"autop-runner/{__version__}"
|
|
35
|
+
_LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PlaneError(RuntimeError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Unauthorized(PlaneError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Incompatible(PlaneError):
|
|
47
|
+
def __init__(self, plane_min: int, plane_max: int):
|
|
48
|
+
super().__init__(
|
|
49
|
+
f"protocol {PROTOCOL_MIN}..{PROTOCOL_MAX} is incompatible with the "
|
|
50
|
+
f"plane's {plane_min}..{plane_max}; upgrade autop-runner"
|
|
51
|
+
)
|
|
52
|
+
self.plane_min, self.plane_max = plane_min, plane_max
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class StaleLease(PlaneError):
|
|
56
|
+
def __init__(self, reason: str):
|
|
57
|
+
super().__init__(f"lease is {reason}")
|
|
58
|
+
self.reason = reason
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SequenceGap(PlaneError):
|
|
62
|
+
def __init__(self, expected: int):
|
|
63
|
+
super().__init__(f"plane expects event sequence {expected}")
|
|
64
|
+
self.expected = expected
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PlaneUnavailable(PlaneError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Refused(PlaneError):
|
|
72
|
+
def __init__(self, reason: str):
|
|
73
|
+
super().__init__(f"Autop refused: {reason}")
|
|
74
|
+
self.reason = reason
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _refusal_reason(response: httpx.Response) -> str:
|
|
78
|
+
"""The 422 body's `detail` (a `Rejected` message), bounded; never raw
|
|
79
|
+
bytes the plane did not mean to show."""
|
|
80
|
+
try:
|
|
81
|
+
detail = response.json().get("detail")
|
|
82
|
+
except (ValueError, AttributeError):
|
|
83
|
+
detail = None
|
|
84
|
+
return " ".join(str(detail).split())[:200] if isinstance(detail, str) else ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_url(url: str) -> str:
|
|
88
|
+
"""HTTPS required (FR-008), except loopback for local development."""
|
|
89
|
+
parsed = urllib.parse.urlparse(url)
|
|
90
|
+
if parsed.scheme == "https" and parsed.hostname:
|
|
91
|
+
return url.rstrip("/")
|
|
92
|
+
if parsed.scheme == "http" and parsed.hostname in _LOCAL_HOSTS:
|
|
93
|
+
return url.rstrip("/")
|
|
94
|
+
raise ValueError("AUTOP_URL must be https:// (http:// only for localhost)")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PlaneClient:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
url: str,
|
|
101
|
+
token: str,
|
|
102
|
+
*,
|
|
103
|
+
transport: httpx.BaseTransport | None = None,
|
|
104
|
+
timeout: float = 20,
|
|
105
|
+
):
|
|
106
|
+
self.url = check_url(url)
|
|
107
|
+
self._token = token
|
|
108
|
+
self._http = httpx.Client(
|
|
109
|
+
transport=transport,
|
|
110
|
+
timeout=httpx.Timeout(timeout, read=timeout + 70), # long-poll headroom
|
|
111
|
+
headers={"User-Agent": BUILD_TAG},
|
|
112
|
+
follow_redirects=False,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
return f"PlaneClient(url={self.url!r})"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def token(self) -> str:
|
|
120
|
+
return self._token
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
self._http.close()
|
|
124
|
+
|
|
125
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
126
|
+
try:
|
|
127
|
+
r = self._http.post(
|
|
128
|
+
f"{self.url}/api/runner/v1{path}",
|
|
129
|
+
json=body,
|
|
130
|
+
headers={"Authorization": f"Bearer {self._token}"},
|
|
131
|
+
)
|
|
132
|
+
except httpx.HTTPError as exc:
|
|
133
|
+
raise PlaneUnavailable(f"cannot reach Autop: {type(exc).__name__}") from exc
|
|
134
|
+
if r.status_code == 200:
|
|
135
|
+
return r.json()
|
|
136
|
+
if r.status_code == 401:
|
|
137
|
+
raise Unauthorized("the runner credential was rejected; re-enroll")
|
|
138
|
+
if r.status_code == 426:
|
|
139
|
+
data = r.json()
|
|
140
|
+
raise Incompatible(int(data["plane_min"]), int(data["plane_max"]))
|
|
141
|
+
if r.status_code == 409:
|
|
142
|
+
data = r.json()
|
|
143
|
+
if data.get("detail") == "stale_lease":
|
|
144
|
+
raise StaleLease(str(data.get("reason")))
|
|
145
|
+
if data.get("detail") == "sequence_gap":
|
|
146
|
+
raise SequenceGap(int(data["expected_sequence"]))
|
|
147
|
+
if r.status_code == 422:
|
|
148
|
+
reason = _refusal_reason(r)
|
|
149
|
+
if reason:
|
|
150
|
+
raise Refused(reason)
|
|
151
|
+
if r.status_code >= 500 or r.status_code == 429:
|
|
152
|
+
raise PlaneUnavailable(f"Autop answered {r.status_code} for {path}")
|
|
153
|
+
raise PlaneError(f"Autop answered {r.status_code} for {path}")
|
|
154
|
+
|
|
155
|
+
def _get_json(self, path: str) -> dict[str, Any] | None:
|
|
156
|
+
"""GET for the installer's reads. `None` means the plane does not have
|
|
157
|
+
the route (a plane older than protocol 2), which is a fallback, not an
|
|
158
|
+
error."""
|
|
159
|
+
try:
|
|
160
|
+
r = self._http.get(
|
|
161
|
+
f"{self.url}/api/runner/v1{path}",
|
|
162
|
+
headers={"Authorization": f"Bearer {self._token}"},
|
|
163
|
+
)
|
|
164
|
+
except httpx.HTTPError as exc:
|
|
165
|
+
raise PlaneUnavailable(f"cannot reach Autop: {type(exc).__name__}") from exc
|
|
166
|
+
if r.status_code == 200:
|
|
167
|
+
return r.json()
|
|
168
|
+
if r.status_code == 404:
|
|
169
|
+
return None
|
|
170
|
+
if r.status_code == 401:
|
|
171
|
+
raise Unauthorized("the token was rejected (used, expired or revoked)")
|
|
172
|
+
if r.status_code >= 500 or r.status_code == 429:
|
|
173
|
+
raise PlaneUnavailable(f"Autop answered {r.status_code} for {path}")
|
|
174
|
+
raise PlaneError(f"Autop answered {r.status_code} for {path}")
|
|
175
|
+
|
|
176
|
+
# ── calls ────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def enrollment(self) -> dict[str, Any] | None:
|
|
179
|
+
"""The runner's configured policy behind an enrollment token, without
|
|
180
|
+
consuming it (D70). `None` when the plane predates protocol 2."""
|
|
181
|
+
return self._get_json("/enrollment")
|
|
182
|
+
|
|
183
|
+
def register(self, report: dict[str, Any]) -> dict[str, Any]:
|
|
184
|
+
"""Register (or refresh). With an enrollment token, the response carries
|
|
185
|
+
the new credential once; the client switches to it immediately."""
|
|
186
|
+
body = {
|
|
187
|
+
"protocol_min": PROTOCOL_MIN,
|
|
188
|
+
"protocol_max": PROTOCOL_MAX,
|
|
189
|
+
"build_tag": BUILD_TAG,
|
|
190
|
+
**report,
|
|
191
|
+
}
|
|
192
|
+
data = self._post("/register", body)
|
|
193
|
+
credential = data.pop("credential", None)
|
|
194
|
+
if credential:
|
|
195
|
+
self._token = credential
|
|
196
|
+
data["enrolled"] = True
|
|
197
|
+
return data
|
|
198
|
+
|
|
199
|
+
def claim(self, *, wait_seconds: int, request_id: str) -> dict[str, Any]:
|
|
200
|
+
return self._post(
|
|
201
|
+
"/claim",
|
|
202
|
+
{
|
|
203
|
+
"protocol_min": PROTOCOL_MIN,
|
|
204
|
+
"protocol_max": PROTOCOL_MAX,
|
|
205
|
+
"wait_seconds": wait_seconds,
|
|
206
|
+
"request_id": request_id,
|
|
207
|
+
"build_tag": BUILD_TAG,
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _lease(self, job: dict[str, Any], path: str, **extra: Any) -> dict[str, Any]:
|
|
212
|
+
body = {"attempt_id": job["attempt_id"], "epoch": job["epoch"], **extra}
|
|
213
|
+
return self._post(f"/jobs/{job['job_id']}/{path}", body)
|
|
214
|
+
|
|
215
|
+
def heartbeat(self, job: dict[str, Any]) -> dict[str, Any]:
|
|
216
|
+
return self._lease(job, "heartbeat")
|
|
217
|
+
|
|
218
|
+
def events(self, job: dict[str, Any], events: list[dict[str, Any]]) -> int:
|
|
219
|
+
return int(self._lease(job, "events", events=events)["next_sequence"])
|
|
220
|
+
|
|
221
|
+
def publishing_token(self, job: dict[str, Any]) -> dict[str, Any]:
|
|
222
|
+
"""A fresh Coder token for the job's repo under the current epoch (D-A)."""
|
|
223
|
+
return self._lease(job, "token")
|
|
224
|
+
|
|
225
|
+
def finish(
|
|
226
|
+
self,
|
|
227
|
+
job: dict[str, Any],
|
|
228
|
+
outcome: str,
|
|
229
|
+
detail: dict[str, Any],
|
|
230
|
+
*,
|
|
231
|
+
retryable: bool = False,
|
|
232
|
+
effect_free: bool = False,
|
|
233
|
+
) -> str:
|
|
234
|
+
path = {"succeeded": "complete", "failed": "fail", "abandoned": "abandon"}[
|
|
235
|
+
outcome
|
|
236
|
+
]
|
|
237
|
+
data = self._lease(
|
|
238
|
+
job, path, outcome=detail, retryable=retryable, effect_free=effect_free
|
|
239
|
+
)
|
|
240
|
+
return str(data["job_state"])
|