puremethod 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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
app/scan.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The one cross-tenant query: what needs a worker (08-app.md §5 step 2). Returns ids only.
|
|
2
|
+
Supervisor side; `app.api` may not import it (import-linter)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from app import leases
|
|
11
|
+
from app.db import Database
|
|
12
|
+
from app.statuses import DRIVABLE
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("app.scan")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Work:
|
|
19
|
+
kind: str # run | preflight | credcheck | llmsearch | profilecheck | literature
|
|
20
|
+
tenant_id: str
|
|
21
|
+
id: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def lease_key(self) -> str:
|
|
25
|
+
return f"{self.kind}:{self.id}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def work(db: Database, status_of: Callable[[str], str | None], now: float) -> list[Work]:
|
|
29
|
+
"""Runs that are queued, interrupted at start, have pending commands, or are drivable;
|
|
30
|
+
pending executor preflights and credential checks. Anything whose lease is held is
|
|
31
|
+
skipped."""
|
|
32
|
+
with db.tx() as c:
|
|
33
|
+
runs = c.execute(
|
|
34
|
+
"SELECT r.tenant_id, r.id, r.state, r.ledger_run_id, r.worker_error, "
|
|
35
|
+
"EXISTS (SELECT 1 FROM commands m WHERE m.run_id = r.id AND m.state = 'pending') "
|
|
36
|
+
"FROM runs r WHERE r.state <> 'start_failed' ORDER BY r.created_at, r.id")
|
|
37
|
+
checks = c.execute("SELECT tenant_id, id FROM preflight_checks WHERE state = 'pending' "
|
|
38
|
+
"ORDER BY created_at, id")
|
|
39
|
+
creds = c.execute("SELECT tenant_id, id FROM credential_checks WHERE state = 'pending' "
|
|
40
|
+
"ORDER BY created_at, id")
|
|
41
|
+
searches = c.execute("SELECT tenant_id, id FROM llm_searches WHERE state = 'pending' "
|
|
42
|
+
"ORDER BY created_at, id")
|
|
43
|
+
profile_checks = c.execute("SELECT tenant_id, id FROM profile_checks "
|
|
44
|
+
"WHERE state = 'pending' ORDER BY created_at, id")
|
|
45
|
+
# `starting` too: a start whose worker died is taken up again once its lease is free.
|
|
46
|
+
bundled = c.execute("SELECT tenant_id FROM bundled_services "
|
|
47
|
+
"WHERE state IN ('pending', 'starting') ORDER BY updated_at, tenant_id")
|
|
48
|
+
out: list[Work] = []
|
|
49
|
+
for tenant_id, run_id, state, ledger_run_id, worker_error, pending in runs:
|
|
50
|
+
if worker_error:
|
|
51
|
+
continue # a new command clears it (TenantRepo.add_command)
|
|
52
|
+
wanted = state in ("queued", "starting") or bool(pending)
|
|
53
|
+
if not wanted and ledger_run_id is not None:
|
|
54
|
+
try:
|
|
55
|
+
wanted = status_of(ledger_run_id) in DRIVABLE
|
|
56
|
+
except Exception as exc: # one unreadable run must not stop scheduling the rest
|
|
57
|
+
log.warning("cannot read the status of run %s: %s", run_id, type(exc).__name__)
|
|
58
|
+
if wanted:
|
|
59
|
+
out.append(Work("run", tenant_id, run_id))
|
|
60
|
+
out += [Work("preflight", t, i) for t, i in checks]
|
|
61
|
+
out += [Work("credcheck", t, i) for t, i in creds]
|
|
62
|
+
out += [Work("llmsearch", t, i) for t, i in searches]
|
|
63
|
+
out += [Work("profilecheck", t, i) for t, i in profile_checks]
|
|
64
|
+
out += [Work("literature", t, t) for (t,) in bundled]
|
|
65
|
+
return [w for w in out if not leases.held(db, w.lease_key, now)]
|
app/server.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Builds what the API process holds (08-app.md §5 step 8): the app database, the ledger
|
|
2
|
+
behind `ReadOnlyLedger`, a status-only `Loop`, sign-in, the output store and the link key."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from app.accounts import LocalTokenAuth, Sessions, SigninCodes
|
|
7
|
+
from app.api.common import Ctx
|
|
8
|
+
from app.db import Database
|
|
9
|
+
from app.keys import link_key
|
|
10
|
+
from app.outputs import LocalOutputs
|
|
11
|
+
from app.readonly import ReadOnlyLedger, status_loop
|
|
12
|
+
from app.settings import Settings
|
|
13
|
+
from ledger import Ledger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_ctx(settings: Settings, db: Database | None = None) -> Ctx:
|
|
17
|
+
settings.ensure_dirs()
|
|
18
|
+
db = db or Database(settings.app_db)
|
|
19
|
+
ledger = ReadOnlyLedger(Ledger(settings.ledger_url, settings.artifact_root))
|
|
20
|
+
return Ctx(settings=settings, db=db, ledger=ledger,
|
|
21
|
+
status=status_loop(ledger, settings.output_root), auth=LocalTokenAuth(db),
|
|
22
|
+
sessions=Sessions(db),
|
|
23
|
+
outputs=LocalOutputs(settings.output_root, settings.tmp_dir),
|
|
24
|
+
link_key=link_key(settings.master_key), codes=SigninCodes(db))
|
app/settings.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Deployment settings, read from `PM_*` environment variables (08-app.md §5 steps 3, 8, 9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from app import paths
|
|
14
|
+
|
|
15
|
+
KEY_BYTES = paths.KEY_BYTES
|
|
16
|
+
Mode = Literal["self-hosted", "hosted"]
|
|
17
|
+
# An installed wheel carries the built UI inside the package (08-app.md §5 step 11); a source
|
|
18
|
+
# checkout uses the static export that `pnpm build` in implementation/web writes.
|
|
19
|
+
_PACKAGED_WEB_DIR = Path(__file__).resolve().parent / "_web"
|
|
20
|
+
DEFAULT_WEB_DIR = (_PACKAGED_WEB_DIR if (_PACKAGED_WEB_DIR / "index.html").is_file()
|
|
21
|
+
else Path(__file__).resolve().parents[2] / "web" / "out")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SettingsError(Exception):
|
|
25
|
+
"""The deployment cannot start as configured."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Settings:
|
|
30
|
+
mode: Mode
|
|
31
|
+
data_dir: Path
|
|
32
|
+
master_key: bytes = field(repr=False)
|
|
33
|
+
host: str = "127.0.0.1"
|
|
34
|
+
port: int = 8400
|
|
35
|
+
purescholar_url: str | None = None
|
|
36
|
+
puretrace_url: str | None = None
|
|
37
|
+
tectonic_image: str | None = None
|
|
38
|
+
purescholar_image: str | None = None # development and tests only (08 §5 step 12)
|
|
39
|
+
web_dir: Path | None = None # the built web UI (static export); served at `/` if present
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def app_db(self) -> Path:
|
|
43
|
+
return self.data_dir / "app.db"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ledger_url(self) -> str:
|
|
47
|
+
return f"sqlite:///{self.data_dir / 'ledger.db'}"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def artifact_root(self) -> Path:
|
|
51
|
+
return self.data_dir / "artifacts"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def output_root(self) -> Path:
|
|
55
|
+
return self.data_dir / "outputs"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def executor_root(self) -> Path:
|
|
59
|
+
return self.data_dir / "executors"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def tmp_dir(self) -> Path:
|
|
63
|
+
return self.data_dir / "tmp"
|
|
64
|
+
|
|
65
|
+
def ensure_dirs(self) -> None:
|
|
66
|
+
# 0700 on POSIX: the app database holds token hashes and credential ciphertexts.
|
|
67
|
+
self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
68
|
+
for d in (self.artifact_root, self.output_root, self.executor_root, self.tmp_dir):
|
|
69
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> Settings:
|
|
73
|
+
env = os.environ if env is None else env
|
|
74
|
+
mode = env.get("PM_MODE", "self-hosted")
|
|
75
|
+
if mode not in ("self-hosted", "hosted"):
|
|
76
|
+
raise SettingsError("PM_MODE is self-hosted or hosted")
|
|
77
|
+
data_dir = data_dir_from(env)
|
|
78
|
+
return cls(mode=mode, # type: ignore[arg-type]
|
|
79
|
+
data_dir=data_dir, master_key=load_master_key(env, data_dir),
|
|
80
|
+
host=env.get("PM_HOST", "127.0.0.1"), port=int(env.get("PM_PORT", "8400")),
|
|
81
|
+
purescholar_url=env.get("PM_PURESCHOLAR_URL") or None,
|
|
82
|
+
puretrace_url=env.get("PM_PURETRACE_URL") or None,
|
|
83
|
+
tectonic_image=env.get("PM_TECTONIC_IMAGE") or None,
|
|
84
|
+
purescholar_image=env.get("PM_PURESCHOLAR_IMAGE") or None,
|
|
85
|
+
web_dir=Path(env["PM_WEB_DIR"]).resolve() if env.get("PM_WEB_DIR")
|
|
86
|
+
else DEFAULT_WEB_DIR)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def data_dir_from(env: Mapping[str, str]) -> Path:
|
|
90
|
+
return (Path(env["PM_DATA_DIR"]) if env.get("PM_DATA_DIR")
|
|
91
|
+
else paths.default_data_dir()).resolve()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def key_file(env: Mapping[str, str]) -> Path:
|
|
95
|
+
"""The key file `serve` reads when `PM_MASTER_KEY` is not set, and the launcher creates."""
|
|
96
|
+
return Path(env["PM_MASTER_KEY_FILE"]).resolve() if env.get("PM_MASTER_KEY_FILE") \
|
|
97
|
+
else paths.default_key_file().resolve()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_master_key(env: Mapping[str, str], data_dir: Path) -> bytes:
|
|
101
|
+
"""32 bytes from `PM_MASTER_KEY` (base64), or from a key file outside the data directory:
|
|
102
|
+
`PM_MASTER_KEY_FILE`, else the default location (KB-025). No key, no start (KB-021);
|
|
103
|
+
only the launcher creates the file. Errors never echo key material."""
|
|
104
|
+
raw: bytes
|
|
105
|
+
if env.get("PM_MASTER_KEY"):
|
|
106
|
+
try:
|
|
107
|
+
raw = base64.b64decode(env["PM_MASTER_KEY"], validate=True)
|
|
108
|
+
except (binascii.Error, ValueError):
|
|
109
|
+
raise SettingsError("PM_MASTER_KEY is not valid base64") from None
|
|
110
|
+
elif env.get("PM_MASTER_KEY_FILE") or key_file(env).is_file():
|
|
111
|
+
path = key_file(env)
|
|
112
|
+
if path.is_relative_to(data_dir):
|
|
113
|
+
raise SettingsError("the master key file must be outside the data directory")
|
|
114
|
+
try:
|
|
115
|
+
raw = base64.b64decode(path.read_bytes().strip(), validate=True)
|
|
116
|
+
except OSError:
|
|
117
|
+
raise SettingsError(f"cannot read the master key file {path}") from None
|
|
118
|
+
except (binascii.Error, ValueError):
|
|
119
|
+
raise SettingsError("the master key file does not hold valid base64") from None
|
|
120
|
+
else:
|
|
121
|
+
raise SettingsError("no master key: run `puremethod`, which creates one, or set "
|
|
122
|
+
"PM_MASTER_KEY or PM_MASTER_KEY_FILE")
|
|
123
|
+
if len(raw) != KEY_BYTES:
|
|
124
|
+
raise SettingsError(f"the master key must be {KEY_BYTES} bytes")
|
|
125
|
+
return raw
|
app/setup.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Setup status (08-app.md §5 steps 13 and 14, KB-029), computed from configuration already
|
|
2
|
+
stored: credential checks, executor preflights and the service settings. There is no separate
|
|
3
|
+
setup state. Reads metadata only; nothing here decrypts (INV-2)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from app.repo import TenantRepo
|
|
12
|
+
|
|
13
|
+
PURESCHOLAR_CREDENTIAL = "purescholar"
|
|
14
|
+
PURETRACE_CREDENTIAL = "puretrace"
|
|
15
|
+
LOCAL_NOT_YET = "the bundled PureScholar is not available in this version; use a remote one"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ServiceStatus:
|
|
20
|
+
state: str # stopped | starting | ready | failed
|
|
21
|
+
reason: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Setup:
|
|
26
|
+
model_key: bool
|
|
27
|
+
literature: ServiceStatus
|
|
28
|
+
executor: bool
|
|
29
|
+
missing: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def complete(self) -> bool:
|
|
33
|
+
return not self.missing
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {"model_key": self.model_key, "executor": self.executor,
|
|
37
|
+
"literature": {"state": self.literature.state,
|
|
38
|
+
"reason": self.literature.reason},
|
|
39
|
+
"missing": self.missing, "complete": self.complete}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
LocalStatus = Callable[[TenantRepo], ServiceStatus]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def local_not_yet(repo: TenantRepo) -> ServiceStatus:
|
|
46
|
+
return ServiceStatus("failed", LOCAL_NOT_YET)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _passed(report: dict[str, Any] | None) -> bool:
|
|
50
|
+
return bool(report and report.get("ok"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def literature_status(repo: TenantRepo, local: LocalStatus = local_not_yet) -> ServiceStatus:
|
|
54
|
+
setting = repo.service_setting("literature")
|
|
55
|
+
if setting is None:
|
|
56
|
+
return ServiceStatus("stopped", "no literature service is configured")
|
|
57
|
+
if setting.get("mode") == "local":
|
|
58
|
+
return local(repo)
|
|
59
|
+
url = setting.get("url")
|
|
60
|
+
cred = next((c for c in repo.list_credentials() if c.name == PURESCHOLAR_CREDENTIAL), None)
|
|
61
|
+
if cred is None:
|
|
62
|
+
return ServiceStatus("failed", f"no {PURESCHOLAR_CREDENTIAL!r} credential is stored")
|
|
63
|
+
check = repo.latest_credential_check(cred.id)
|
|
64
|
+
if check is None or check.request.get("url") != url:
|
|
65
|
+
return ServiceStatus("failed", "the PureScholar key has not been checked against "
|
|
66
|
+
"this URL; check it again")
|
|
67
|
+
if check.state == "pending":
|
|
68
|
+
return ServiceStatus("starting", "checking the PureScholar key")
|
|
69
|
+
if _passed(check.report):
|
|
70
|
+
return ServiceStatus("ready")
|
|
71
|
+
problems = (check.report or {}).get("problems") or ["the check failed"]
|
|
72
|
+
return ServiceStatus("failed", "; ".join(str(p) for p in problems))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def setup_status(repo: TenantRepo, local: LocalStatus = local_not_yet) -> Setup:
|
|
76
|
+
# 08 §5 step 14 (KB-037): a passed credential check, or a passed profile check (a model
|
|
77
|
+
# that needs no key has no credential to check).
|
|
78
|
+
model_key = any(
|
|
79
|
+
c.type == "llm-key" and _passed(getattr(repo.latest_credential_check(c.id),
|
|
80
|
+
"report", None))
|
|
81
|
+
for c in repo.list_credentials()) or any(
|
|
82
|
+
repo.profile_checked(p) for p in repo.list_profiles())
|
|
83
|
+
executor = any(_passed(getattr(repo.latest_preflight(x.id), "report", None))
|
|
84
|
+
for x in repo.list_executors())
|
|
85
|
+
literature = literature_status(repo, local)
|
|
86
|
+
missing = []
|
|
87
|
+
if not model_key:
|
|
88
|
+
missing.append("model: choose a model and check it")
|
|
89
|
+
if literature.state != "ready":
|
|
90
|
+
missing.append(f"literature service: {literature.reason or literature.state}")
|
|
91
|
+
if not executor:
|
|
92
|
+
missing.append("executor: add one and run its check")
|
|
93
|
+
return Setup(model_key, literature, executor, missing)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
LOCAL = "local" # a run's PureScholar is the bundled one (08 §5 step 12)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_services(repo: TenantRepo, purescholar_fallback: str | None,
|
|
100
|
+
puretrace_fallback: str | None) -> dict[str, str | None]:
|
|
101
|
+
"""What a new run records about its services, like its executor: later setting changes
|
|
102
|
+
never move a run to another service (review of slice 3, Auditor MEDIUM-2)."""
|
|
103
|
+
lit = repo.service_setting("literature")
|
|
104
|
+
ps = LOCAL if lit and lit.get("mode") == "local" else \
|
|
105
|
+
service_url(repo, "literature", purescholar_fallback)
|
|
106
|
+
return {"purescholar": ps, "puretrace": service_url(repo, "puretrace", puretrace_fallback)}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def service_url(repo: TenantRepo, service: str, fallback: str | None) -> str | None:
|
|
110
|
+
"""The URL a run uses (08 §4, KB-029): the tenant's remote setting, else the `PM_*`
|
|
111
|
+
fallback. Local mode's URL comes from the bundled containers (§5 step 12)."""
|
|
112
|
+
setting = repo.service_setting(service)
|
|
113
|
+
if setting and setting.get("mode") == "local":
|
|
114
|
+
return None # the bundled PureScholar's address comes from app.bundled, never a fallback
|
|
115
|
+
if setting and setting.get("url"):
|
|
116
|
+
return str(setting["url"])
|
|
117
|
+
return fallback
|
app/ssh_chain.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""SSH connections through jump hosts, for `app`'s own SSH work (KB-036): the Docker probe
|
|
2
|
+
of onboarding step 3 and, with `via_ssh` model endpoints, tunnels. `executor` has its own
|
|
3
|
+
connection code for jobs; `app` does not reach into it (05-executor.md §3).
|
|
4
|
+
|
|
5
|
+
Every hop trusts exactly its confirmed host key and refuses any other (INV-10). Failures
|
|
6
|
+
name the hop."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import paramiko
|
|
16
|
+
|
|
17
|
+
from app.ssh_hosts import load_private, parse_host_key
|
|
18
|
+
|
|
19
|
+
TIMEOUT_S = 15.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HopFailed(OSError):
|
|
23
|
+
"""A hop could not be reached or refused us. The message names the hop, never key data."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Chain:
|
|
28
|
+
"""Open connections, first hop first; `client` is the target's."""
|
|
29
|
+
clients: list[paramiko.SSHClient]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def client(self) -> paramiko.SSHClient:
|
|
33
|
+
return self.clients[-1]
|
|
34
|
+
|
|
35
|
+
def close(self) -> None:
|
|
36
|
+
for c in reversed(self.clients):
|
|
37
|
+
c.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _trusting(host: str, port: int, host_key: str) -> paramiko.SSHClient:
|
|
41
|
+
hk = parse_host_key(host_key)
|
|
42
|
+
client = paramiko.SSHClient()
|
|
43
|
+
name = host if port == 22 else f"[{host}]:{port}"
|
|
44
|
+
client.get_host_keys().add(name, hk.type, paramiko.PKey.from_type_string(
|
|
45
|
+
hk.type, base64.b64decode(hk.key)))
|
|
46
|
+
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
|
47
|
+
return client
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _reason(exc: Exception, host: str, port: int, user: str) -> str:
|
|
51
|
+
if isinstance(exc, paramiko.BadHostKeyException):
|
|
52
|
+
return (f"{host}'s host key does not match the confirmed one; the server may have been "
|
|
53
|
+
"replaced or impersonated")
|
|
54
|
+
if isinstance(exc, paramiko.AuthenticationException):
|
|
55
|
+
return f"{host} refused the SSH key for user {user!r}"
|
|
56
|
+
if isinstance(exc, ValueError):
|
|
57
|
+
return f"the SSH key for {host} is missing or is not a supported private key"
|
|
58
|
+
return f"cannot reach {host}:{port} ({type(exc).__name__})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def connect(hops: list[dict[str, Any]], private_key: Callable[[str], str],
|
|
62
|
+
timeout: float = TIMEOUT_S) -> Chain:
|
|
63
|
+
"""Connects through `hops` in order; the last is the target. Each hop is a dict with
|
|
64
|
+
host, user, port, key_credential and host_key. `private_key(name)` returns a key's text."""
|
|
65
|
+
clients: list[paramiko.SSHClient] = []
|
|
66
|
+
sock: Any = None
|
|
67
|
+
try:
|
|
68
|
+
for i, hop in enumerate(hops):
|
|
69
|
+
host, port, user = str(hop["host"]), int(hop.get("port", 22)), str(hop["user"])
|
|
70
|
+
if not hop.get("host_key"):
|
|
71
|
+
raise HopFailed(f"{host} has no confirmed host key")
|
|
72
|
+
client = _trusting(host, port, str(hop["host_key"]))
|
|
73
|
+
try:
|
|
74
|
+
client.connect(host, port=port, username=user, sock=sock,
|
|
75
|
+
pkey=load_private(private_key(str(hop["key_credential"]))),
|
|
76
|
+
timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
|
|
77
|
+
allow_agent=False, look_for_keys=False)
|
|
78
|
+
except (paramiko.SSHException, OSError, EOFError, ValueError) as exc:
|
|
79
|
+
client.close()
|
|
80
|
+
raise HopFailed(_reason(exc, host, port, user)) from None
|
|
81
|
+
clients.append(client)
|
|
82
|
+
if i + 1 < len(hops):
|
|
83
|
+
nxt = hops[i + 1]
|
|
84
|
+
transport = client.get_transport()
|
|
85
|
+
try:
|
|
86
|
+
if transport is None:
|
|
87
|
+
raise paramiko.SSHException("no transport")
|
|
88
|
+
sock = transport.open_channel(
|
|
89
|
+
"direct-tcpip", (str(nxt["host"]), int(nxt.get("port", 22))),
|
|
90
|
+
("127.0.0.1", 0), timeout=timeout)
|
|
91
|
+
except (paramiko.SSHException, OSError) as exc:
|
|
92
|
+
raise HopFailed(f"{host} cannot reach {nxt['host']}:{nxt.get('port', 22)} "
|
|
93
|
+
f"({type(exc).__name__})") from None
|
|
94
|
+
except BaseException:
|
|
95
|
+
Chain(clients).close()
|
|
96
|
+
raise
|
|
97
|
+
return Chain(clients)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def hops_of(config: dict[str, Any]) -> list[dict[str, Any]]:
|
|
101
|
+
"""The connection order for an `ssh` executor config: its jumps, then the machine."""
|
|
102
|
+
return [*config.get("jumps", []), config]
|
app/ssh_hosts.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""SSH conveniences for adding an `ssh` executor (08-app.md §3 Onboarding step 3, KB-035).
|
|
2
|
+
|
|
3
|
+
Self-hosted only, except `fetch_host_key`: the user's `~/.ssh/config` hosts, their key files,
|
|
4
|
+
and `known_hosts`. Host keys are confirmed by fingerprint before an executor stores one
|
|
5
|
+
(INV-10). Private keys are returned only to be sealed as credentials: never logged, never in
|
|
6
|
+
a response.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import io
|
|
14
|
+
import socket
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
import paramiko
|
|
21
|
+
from cryptography.hazmat.primitives import serialization
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
23
|
+
|
|
24
|
+
FETCH_TIMEOUT_S = 10.0
|
|
25
|
+
KnownState = Literal["known", "unknown", "changed"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SSHSetupError(ValueError):
|
|
29
|
+
"""A reason the user can act on. Never carries key material."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ssh_dir() -> Path:
|
|
33
|
+
"""The user's `~/.ssh`. Tests point it elsewhere."""
|
|
34
|
+
return Path.home() / ".ssh"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---- ~/.ssh/config ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SSHHost:
|
|
41
|
+
name: str
|
|
42
|
+
address: str
|
|
43
|
+
user: str | None
|
|
44
|
+
port: int
|
|
45
|
+
has_key_file: bool
|
|
46
|
+
proxy_jump: tuple[str, ...] = () # hops in order, as written: names or [user@]host[:port]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _config() -> paramiko.SSHConfig:
|
|
50
|
+
path = ssh_dir() / "config"
|
|
51
|
+
return paramiko.SSHConfig.from_path(str(path)) if path.is_file() else paramiko.SSHConfig()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _key_files(entry: paramiko.SSHConfigDict) -> list[Path]:
|
|
55
|
+
return [Path(p).expanduser() for p in entry.get("identityfile", [])]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_hosts() -> list[SSHHost]:
|
|
59
|
+
"""Named hosts from `~/.ssh/config`; wildcard patterns are not hosts."""
|
|
60
|
+
cfg = _config()
|
|
61
|
+
out = []
|
|
62
|
+
for name in sorted(cfg.get_hostnames()):
|
|
63
|
+
if any(ch in name for ch in "*?!"):
|
|
64
|
+
continue
|
|
65
|
+
entry = cfg.lookup(name)
|
|
66
|
+
jump = str(entry.get("proxyjump", "")).strip()
|
|
67
|
+
hops = tuple(h.strip() for h in jump.split(",") if h.strip()) \
|
|
68
|
+
if jump.lower() not in ("", "none") else ()
|
|
69
|
+
out.append(SSHHost(name=name, address=str(entry.get("hostname", name)),
|
|
70
|
+
user=entry.get("user"), port=int(entry.get("port", 22)),
|
|
71
|
+
has_key_file=any(p.is_file() for p in _key_files(entry)),
|
|
72
|
+
proxy_jump=hops))
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_hop(text: str) -> tuple[str | None, str, int]:
|
|
77
|
+
"""`[user@]host[:port]` from a `ProxyJump` list: (user, host, port)."""
|
|
78
|
+
user, _, rest = text.rpartition("@")
|
|
79
|
+
host, port = rest, 22
|
|
80
|
+
if rest.startswith("["): # [v6]:port
|
|
81
|
+
host, _, tail = rest[1:].partition("]")
|
|
82
|
+
port = int(tail[1:]) if tail.startswith(":") else 22
|
|
83
|
+
elif rest.count(":") == 1:
|
|
84
|
+
host, _, p = rest.partition(":")
|
|
85
|
+
port = int(p)
|
|
86
|
+
return (user or None), host, port
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_key_for(name: str) -> tuple[str, str]:
|
|
90
|
+
"""The private key file `~/.ssh/config` names for host `name`, read once:
|
|
91
|
+
(private key text, public key line). Passphrase-protected keys are refused."""
|
|
92
|
+
if name not in {h.name for h in list_hosts()}:
|
|
93
|
+
raise SSHSetupError(f"no host named {name!r} in ~/.ssh/config")
|
|
94
|
+
files = [p for p in _key_files(_config().lookup(name)) if p.is_file()]
|
|
95
|
+
if not files:
|
|
96
|
+
raise SSHSetupError(f"~/.ssh/config names no existing key file for {name!r}")
|
|
97
|
+
text = files[0].read_text(encoding="utf-8")
|
|
98
|
+
return text, public_line(load_private(text))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---- keys ------------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def load_private(text: str) -> paramiko.PKey:
|
|
104
|
+
for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
|
|
105
|
+
try:
|
|
106
|
+
return cls.from_private_key(io.StringIO(text))
|
|
107
|
+
except paramiko.PasswordRequiredException:
|
|
108
|
+
raise SSHSetupError("the key is protected by a passphrase; puremethod cannot use "
|
|
109
|
+
"it. Generate a key here instead, or use one without a "
|
|
110
|
+
"passphrase") from None
|
|
111
|
+
except (paramiko.SSHException, ValueError):
|
|
112
|
+
continue
|
|
113
|
+
raise SSHSetupError("the file is not a supported private key (Ed25519, ECDSA or RSA)")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def public_line(key: paramiko.PKey) -> str:
|
|
117
|
+
return f"{key.get_name()} {key.get_base64()} puremethod"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def generate() -> tuple[str, str]:
|
|
121
|
+
"""A new Ed25519 key pair: (OpenSSH private key text, public key line)."""
|
|
122
|
+
private = Ed25519PrivateKey.generate()
|
|
123
|
+
text = private.private_bytes(serialization.Encoding.PEM,
|
|
124
|
+
serialization.PrivateFormat.OpenSSH,
|
|
125
|
+
serialization.NoEncryption()).decode()
|
|
126
|
+
pub = private.public_key().public_bytes(serialization.Encoding.OpenSSH,
|
|
127
|
+
serialization.PublicFormat.OpenSSH).decode()
|
|
128
|
+
return text, f"{pub} puremethod"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---- host keys (INV-10) ----------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class HostKey:
|
|
135
|
+
type: str
|
|
136
|
+
key: str # base64
|
|
137
|
+
fingerprint: str # "SHA256:<base64>", as `ssh` prints it
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def line(self) -> str:
|
|
141
|
+
return f"{self.type} {self.key}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fingerprint(key_b64: str) -> str:
|
|
145
|
+
digest = hashlib.sha256(base64.b64decode(key_b64)).digest()
|
|
146
|
+
return "SHA256:" + base64.b64encode(digest).decode().rstrip("=")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_host_key(line: str) -> HostKey:
|
|
150
|
+
"""Validates a stored `<type> <base64>` host key."""
|
|
151
|
+
parts = line.split()
|
|
152
|
+
if len(parts) != 2:
|
|
153
|
+
raise SSHSetupError("a host key is '<type> <base64>'")
|
|
154
|
+
try:
|
|
155
|
+
paramiko.PKey.from_type_string(parts[0], base64.b64decode(parts[1], validate=True))
|
|
156
|
+
except (ValueError, paramiko.SSHException):
|
|
157
|
+
raise SSHSetupError("the host key is not a valid SSH public key") from None
|
|
158
|
+
return HostKey(parts[0], parts[1], fingerprint(parts[1]))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def fetch_host_key(host: str, port: int, timeout: float = FETCH_TIMEOUT_S) -> HostKey:
|
|
162
|
+
"""The key `host` presents, without logging in."""
|
|
163
|
+
try:
|
|
164
|
+
sock = socket.create_connection((host, port), timeout=timeout)
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
raise SSHSetupError(f"cannot reach {host}:{port} ({type(exc).__name__})") from None
|
|
167
|
+
t = paramiko.Transport(sock)
|
|
168
|
+
try:
|
|
169
|
+
t.start_client(timeout=timeout)
|
|
170
|
+
k = t.get_remote_server_key()
|
|
171
|
+
except (paramiko.SSHException, OSError, EOFError) as exc:
|
|
172
|
+
raise SSHSetupError(f"{host}:{port} did not answer as an SSH server "
|
|
173
|
+
f"({type(exc).__name__})") from None
|
|
174
|
+
finally:
|
|
175
|
+
t.close()
|
|
176
|
+
return HostKey(k.get_name(), k.get_base64(), fingerprint(k.get_base64()))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def known_state(host: str, port: int, key: HostKey) -> KnownState:
|
|
180
|
+
"""Whether the user's `~/.ssh/known_hosts` trusts this key for this host."""
|
|
181
|
+
path = ssh_dir() / "known_hosts"
|
|
182
|
+
if not path.is_file():
|
|
183
|
+
return "unknown"
|
|
184
|
+
try:
|
|
185
|
+
hk = paramiko.HostKeys(str(path))
|
|
186
|
+
except (OSError, paramiko.SSHException, ValueError):
|
|
187
|
+
return "unknown"
|
|
188
|
+
name = host if port == 22 else f"[{host}]:{port}"
|
|
189
|
+
entry = hk.lookup(name)
|
|
190
|
+
if not entry:
|
|
191
|
+
return "unknown"
|
|
192
|
+
if key.type in entry:
|
|
193
|
+
return "known" if entry[key.type].get_base64() == key.key else "changed"
|
|
194
|
+
return "unknown" # trusted under another key type; this one is confirmed by fingerprint
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def known_key(host: str, port: int) -> HostKey | None:
|
|
198
|
+
"""The key `~/.ssh/known_hosts` trusts for this host, if any (ed25519 first). For a host
|
|
199
|
+
the engine cannot reach directly, such as one behind a jump host, a matching entry is the
|
|
200
|
+
confirmation (INV-10, KB-036)."""
|
|
201
|
+
path = ssh_dir() / "known_hosts"
|
|
202
|
+
if not path.is_file():
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
entry = paramiko.HostKeys(str(path)).lookup(host if port == 22 else f"[{host}]:{port}")
|
|
206
|
+
except (OSError, paramiko.SSHException, ValueError):
|
|
207
|
+
return None
|
|
208
|
+
if not entry:
|
|
209
|
+
return None
|
|
210
|
+
for kind in ("ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "ssh-rsa",
|
|
211
|
+
*entry.keys()):
|
|
212
|
+
if kind in entry:
|
|
213
|
+
b64 = entry[kind].get_base64()
|
|
214
|
+
return HostKey(type=kind, key=b64, fingerprint=fingerprint(b64))
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---- Docker on the host (onboarding step 3) ----------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def docker_answers(host: str, port: int, user: str, private_key: str, host_key: str,
|
|
221
|
+
timeout: float = FETCH_TIMEOUT_S) -> bool:
|
|
222
|
+
"""Whether `docker version` answers for `user` on the host, over the confirmed host key."""
|
|
223
|
+
hop: dict[str, object] = {"host": host, "port": port, "user": user, "key_credential": "target",
|
|
224
|
+
"host_key": host_key}
|
|
225
|
+
return docker_answers_via([hop], lambda _: private_key, timeout)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def docker_answers_via(hops: list[dict[str, object]], private_key: Callable[[str], str],
|
|
229
|
+
timeout: float = FETCH_TIMEOUT_S) -> bool:
|
|
230
|
+
"""`docker_answers` through jump hosts: `hops` ends with the target (KB-036)."""
|
|
231
|
+
from app import ssh_chain # imports this module, so not at the top
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
chain = ssh_chain.connect(list(hops), private_key, timeout)
|
|
235
|
+
except (paramiko.SSHException, OSError, EOFError, SSHSetupError):
|
|
236
|
+
return False
|
|
237
|
+
try:
|
|
238
|
+
_, out, _ = chain.client.exec_command("docker version --format '{{.Server.Version}}'",
|
|
239
|
+
timeout=timeout)
|
|
240
|
+
return out.channel.recv_exit_status() == 0 and bool(out.read().strip())
|
|
241
|
+
except (paramiko.SSHException, OSError, EOFError):
|
|
242
|
+
return False
|
|
243
|
+
finally:
|
|
244
|
+
chain.close()
|