recto-core 1.0.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.
- recto/__init__.py +20 -0
- recto/__main__.py +16 -0
- recto/_launcher_run.py +151 -0
- recto/_migrate.py +167 -0
- recto/adminui.py +618 -0
- recto/bitcoin.py +772 -0
- recto/bootloader/__init__.py +54 -0
- recto/bootloader/server.py +4187 -0
- recto/bootloader/sessions.py +317 -0
- recto/bootloader/state.py +2002 -0
- recto/capability/__init__.py +76 -0
- recto/capability/jwt.py +441 -0
- recto/capability/manifest.py +278 -0
- recto/capability/manifest_v1.json +176 -0
- recto/capability/types.py +229 -0
- recto/cli.py +2767 -0
- recto/comms.py +482 -0
- recto/config.py +587 -0
- recto/ethereum.py +875 -0
- recto/healthz.py +235 -0
- recto/joblimit.py +423 -0
- recto/launcher.py +421 -0
- recto/nssm.py +457 -0
- recto/profile/__init__.py +45 -0
- recto/profile/manage.py +917 -0
- recto/profile/store.py +453 -0
- recto/profile/types.py +410 -0
- recto/profile/usb_backup.py +359 -0
- recto/reconcile.py +257 -0
- recto/restart.py +141 -0
- recto/ripple.py +464 -0
- recto/secrets/__init__.py +144 -0
- recto/secrets/base.py +146 -0
- recto/secrets/credman.py +424 -0
- recto/secrets/dpapi_machine.py +386 -0
- recto/secrets/enclave_stub.py +187 -0
- recto/secrets/env.py +56 -0
- recto/sign_helper.py +428 -0
- recto/solana.py +350 -0
- recto/stellar.py +378 -0
- recto/telemetry.py +276 -0
- recto/tron.py +358 -0
- recto_core-1.0.0.dist-info/METADATA +134 -0
- recto_core-1.0.0.dist-info/RECORD +48 -0
- recto_core-1.0.0.dist-info/WHEEL +5 -0
- recto_core-1.0.0.dist-info/entry_points.txt +2 -0
- recto_core-1.0.0.dist-info/licenses/LICENSE +201 -0
- recto_core-1.0.0.dist-info/top_level.txt +1 -0
recto/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Recto — modern Windows-service wrapper.
|
|
2
|
+
|
|
3
|
+
Public API surface:
|
|
4
|
+
recto.config.load_config — parse + validate a service.yaml
|
|
5
|
+
recto.config.ServiceConfig — top-level dataclass
|
|
6
|
+
recto.secrets.SecretSource — ABC for pluggable secret backends
|
|
7
|
+
recto.secrets.SecretMaterial — sealed type returned by SecretSource.fetch
|
|
8
|
+
recto.secrets.DirectSecret — variant: secret materialized as a string
|
|
9
|
+
recto.secrets.SigningCapability — variant: secret never leaves enclave
|
|
10
|
+
recto.secrets.EnvSource — passthrough backend reading os.environ
|
|
11
|
+
recto.secrets.CredManSource — Windows Credential Manager backend
|
|
12
|
+
recto.secrets.register_source — third-party backend registration
|
|
13
|
+
recto.launcher.launch — read config, fetch secrets, spawn child
|
|
14
|
+
|
|
15
|
+
Higher-level entry points (CLI, healthz probe loop, restart policy, comms
|
|
16
|
+
webhook dispatch) are wired in alongside the launcher in v0.1.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0.dev0"
|
|
20
|
+
__all__ = ["__version__"]
|
recto/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""`python -m recto` entry point.
|
|
2
|
+
|
|
3
|
+
Forwards to recto.cli:main(). The console-script entry registered
|
|
4
|
+
in pyproject.toml (`recto = "recto.cli:main"`) hits the same target,
|
|
5
|
+
so `recto launch ...` and `python -m recto launch ...` are identical.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from recto.cli import main
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__": # pragma: no cover
|
|
16
|
+
sys.exit(main())
|
recto/_launcher_run.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Restart-loop entry point split out of recto.launcher.
|
|
2
|
+
|
|
3
|
+
Lives in its own module to keep recto/launcher.py under the
|
|
4
|
+
cross-mount Write-tool truncation threshold encountered during v0.1
|
|
5
|
+
work. Re-exported as `recto.launcher.run` at the bottom of
|
|
6
|
+
recto/launcher.py.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
|
|
15
|
+
from recto.adminui import AdminUIServer, EventBuffer
|
|
16
|
+
from recto.config import ServiceConfig
|
|
17
|
+
from recto.healthz import HealthzProbe
|
|
18
|
+
from recto.joblimit import JobLimit
|
|
19
|
+
from recto.restart import MaxAttemptsReachedError, next_delay, should_restart
|
|
20
|
+
from recto.secrets import SecretSource
|
|
21
|
+
from recto.telemetry import TelemetryClient
|
|
22
|
+
|
|
23
|
+
__all__ = ["run"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(
|
|
27
|
+
config: ServiceConfig,
|
|
28
|
+
*,
|
|
29
|
+
sources: Mapping[str, SecretSource] | None = None,
|
|
30
|
+
popen: Callable[..., "subprocess.Popen"] = subprocess.Popen,
|
|
31
|
+
base_env: Mapping[str, str] | None = None,
|
|
32
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
33
|
+
probe_factory: Callable[..., HealthzProbe] = HealthzProbe,
|
|
34
|
+
poll_interval_seconds: float = 0.5,
|
|
35
|
+
terminate_grace_seconds: float = 5.0,
|
|
36
|
+
dispatcher_factory: Callable[..., object] | None = None,
|
|
37
|
+
joblimit_factory: Callable[..., JobLimit] = JobLimit,
|
|
38
|
+
telemetry_factory: Callable[..., TelemetryClient] = TelemetryClient,
|
|
39
|
+
buffer_factory: Callable[[], EventBuffer] = EventBuffer,
|
|
40
|
+
adminui_factory: Callable[..., AdminUIServer] = AdminUIServer,
|
|
41
|
+
) -> int:
|
|
42
|
+
"""Run the supervised child with the configured restart policy.
|
|
43
|
+
|
|
44
|
+
Compared to launch():
|
|
45
|
+
- launch() is one-shot (single spawn, single bracket).
|
|
46
|
+
- run() brackets init/teardown ONCE around the whole loop and
|
|
47
|
+
re-spawns per restart policy. Long-lived backends (Vault session,
|
|
48
|
+
hardware-enclave handle) stay open across restarts. The
|
|
49
|
+
TelemetryClient also lives across the whole loop -- one OTel
|
|
50
|
+
span covers every restart attempt. Likewise the EventBuffer +
|
|
51
|
+
AdminUIServer cover the whole loop, so /api/events shows the
|
|
52
|
+
full lifecycle history rather than just the latest spawn.
|
|
53
|
+
|
|
54
|
+
Returns the LAST child invocation's exit code, whether the policy
|
|
55
|
+
decided "no restart" or max_attempts_reached fired.
|
|
56
|
+
"""
|
|
57
|
+
# Local imports avoid the recto.launcher <-> recto._launcher_run
|
|
58
|
+
# circular import at module load time.
|
|
59
|
+
from recto.launcher import (
|
|
60
|
+
_bracket_lifecycle,
|
|
61
|
+
_build_dispatcher,
|
|
62
|
+
_emit_event,
|
|
63
|
+
_spawn_and_wait,
|
|
64
|
+
build_child_env,
|
|
65
|
+
resolve_sources,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if sources is None:
|
|
69
|
+
sources = resolve_sources(config)
|
|
70
|
+
|
|
71
|
+
telemetry = telemetry_factory(config.spec.telemetry)
|
|
72
|
+
telemetry.start_run(
|
|
73
|
+
config.metadata.name,
|
|
74
|
+
attributes={
|
|
75
|
+
"recto.healthz.type": config.spec.healthz.type,
|
|
76
|
+
"recto.restart.policy": config.spec.restart.policy,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
buffer = buffer_factory()
|
|
80
|
+
adminui = adminui_factory(
|
|
81
|
+
config.spec.admin_ui,
|
|
82
|
+
service_name=config.metadata.name,
|
|
83
|
+
buffer=buffer,
|
|
84
|
+
config=config,
|
|
85
|
+
)
|
|
86
|
+
adminui.start()
|
|
87
|
+
last_rc = 0
|
|
88
|
+
try:
|
|
89
|
+
with _bracket_lifecycle(
|
|
90
|
+
config, sources, telemetry=telemetry, buffer=buffer
|
|
91
|
+
):
|
|
92
|
+
env = build_child_env(config.spec, sources, base_env=base_env)
|
|
93
|
+
dispatcher = _build_dispatcher(config, env, dispatcher_factory)
|
|
94
|
+
attempt = 0
|
|
95
|
+
while True:
|
|
96
|
+
last_rc = _spawn_and_wait(
|
|
97
|
+
config,
|
|
98
|
+
env,
|
|
99
|
+
popen,
|
|
100
|
+
probe_factory=probe_factory,
|
|
101
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
102
|
+
terminate_grace_seconds=terminate_grace_seconds,
|
|
103
|
+
dispatcher=dispatcher,
|
|
104
|
+
joblimit_factory=joblimit_factory,
|
|
105
|
+
telemetry=telemetry,
|
|
106
|
+
buffer=buffer,
|
|
107
|
+
)
|
|
108
|
+
if not should_restart(last_rc, config.spec.restart):
|
|
109
|
+
_emit_event(
|
|
110
|
+
config,
|
|
111
|
+
"run.final_exit",
|
|
112
|
+
{"returncode": last_rc, "restart_attempts": attempt},
|
|
113
|
+
dispatcher=dispatcher,
|
|
114
|
+
telemetry=telemetry,
|
|
115
|
+
buffer=buffer,
|
|
116
|
+
)
|
|
117
|
+
return last_rc
|
|
118
|
+
attempt += 1
|
|
119
|
+
try:
|
|
120
|
+
delay = next_delay(attempt, config.spec.restart)
|
|
121
|
+
except MaxAttemptsReachedError:
|
|
122
|
+
_emit_event(
|
|
123
|
+
config,
|
|
124
|
+
"max_attempts_reached",
|
|
125
|
+
{
|
|
126
|
+
"max_attempts": config.spec.restart.max_attempts,
|
|
127
|
+
"last_returncode": last_rc,
|
|
128
|
+
},
|
|
129
|
+
dispatcher=dispatcher,
|
|
130
|
+
telemetry=telemetry,
|
|
131
|
+
buffer=buffer,
|
|
132
|
+
)
|
|
133
|
+
return last_rc
|
|
134
|
+
_emit_event(
|
|
135
|
+
config,
|
|
136
|
+
"restart.attempt",
|
|
137
|
+
{
|
|
138
|
+
"attempt": attempt,
|
|
139
|
+
"delay_seconds": delay,
|
|
140
|
+
"previous_returncode": last_rc,
|
|
141
|
+
"backoff": config.spec.restart.backoff,
|
|
142
|
+
},
|
|
143
|
+
dispatcher=dispatcher,
|
|
144
|
+
telemetry=telemetry,
|
|
145
|
+
buffer=buffer,
|
|
146
|
+
)
|
|
147
|
+
sleep(delay)
|
|
148
|
+
finally:
|
|
149
|
+
adminui.stop()
|
|
150
|
+
telemetry.end_run(last_rc)
|
|
151
|
+
telemetry.shutdown()
|
recto/_migrate.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Helpers for `recto migrate-from-nssm`. Extracted from recto.cli.
|
|
2
|
+
|
|
3
|
+
These functions are pure (no I/O, no side effects) and small. They
|
|
4
|
+
live in their own module so cli.py stays under the Cowork Write-tool
|
|
5
|
+
size threshold while still being a thin dispatcher rather than a
|
|
6
|
+
1000-line god-module. Same pattern as ``recto._launcher_run`` --
|
|
7
|
+
private (underscore-prefix) module, re-imported by name from
|
|
8
|
+
``recto.cli``.
|
|
9
|
+
|
|
10
|
+
If you're reading this in v0.3+: feel free to inline these back into
|
|
11
|
+
cli.py once the Cowork sandbox is no longer the operator's primary
|
|
12
|
+
authoring environment.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from recto.nssm import NssmConfig
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"build_migration_plan",
|
|
24
|
+
"escape_yaml",
|
|
25
|
+
"generate_service_yaml",
|
|
26
|
+
"partition_env_entries",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def partition_env_entries(
|
|
31
|
+
entries: list[tuple[str, str]],
|
|
32
|
+
*,
|
|
33
|
+
keep_as_env: list[str] | None = None,
|
|
34
|
+
) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
|
|
35
|
+
"""Split NSSM AppEnvironmentExtra entries into (secrets, plain_env).
|
|
36
|
+
|
|
37
|
+
Operators pass ``--keep-as-env=NAME[,NAME...]`` on the
|
|
38
|
+
``migrate-from-nssm`` CLI to route specific keys into the
|
|
39
|
+
generated YAML's ``spec.env:`` block instead of Credential
|
|
40
|
+
Manager. The default (``keep_as_env=None`` or empty list) is
|
|
41
|
+
conservative: every entry goes to CredMan, matching v0.1
|
|
42
|
+
behavior. No surprise allow-list -- operators opt in
|
|
43
|
+
per-migration.
|
|
44
|
+
|
|
45
|
+
Returns ``(secrets, plain_env)``. Both lists preserve the input
|
|
46
|
+
order.
|
|
47
|
+
"""
|
|
48
|
+
keep_set = set(keep_as_env or ())
|
|
49
|
+
secrets: list[tuple[str, str]] = []
|
|
50
|
+
plain_env: list[tuple[str, str]] = []
|
|
51
|
+
for k, v in entries:
|
|
52
|
+
if k in keep_set:
|
|
53
|
+
plain_env.append((k, v))
|
|
54
|
+
else:
|
|
55
|
+
secrets.append((k, v))
|
|
56
|
+
return secrets, plain_env
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_migration_plan(
|
|
60
|
+
*,
|
|
61
|
+
nssm_cfg: NssmConfig,
|
|
62
|
+
secrets: list[tuple[str, str]],
|
|
63
|
+
yaml_out: Path,
|
|
64
|
+
python_exe: str,
|
|
65
|
+
plain_env: list[tuple[str, str]] | None = None,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""Build a dict describing what migrate-from-nssm WOULD do.
|
|
68
|
+
|
|
69
|
+
Secret VALUES are masked (replaced with '<redacted>') so the plan
|
|
70
|
+
can be JSON-printed without leaking. Plain-env values (from
|
|
71
|
+
``--keep-as-env``) are NOT masked since they're explicitly
|
|
72
|
+
operator-tagged as non-secret.
|
|
73
|
+
"""
|
|
74
|
+
plain_env = plain_env or []
|
|
75
|
+
return {
|
|
76
|
+
"service": nssm_cfg.service,
|
|
77
|
+
"current_app_path": nssm_cfg.app_path,
|
|
78
|
+
"current_app_parameters": nssm_cfg.app_parameters,
|
|
79
|
+
"current_app_directory": nssm_cfg.app_directory,
|
|
80
|
+
"current_environment_extra_count": len(secrets) + len(plain_env),
|
|
81
|
+
"secrets_to_install": [
|
|
82
|
+
{"name": k, "value": "<redacted>"} for k, _ in secrets
|
|
83
|
+
],
|
|
84
|
+
"plain_env_to_yaml": [
|
|
85
|
+
{"name": k, "value": v} for k, v in plain_env
|
|
86
|
+
],
|
|
87
|
+
"yaml_out": str(yaml_out),
|
|
88
|
+
"new_app_path": python_exe,
|
|
89
|
+
"new_app_parameters": f"-m recto launch {yaml_out}",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def generate_service_yaml(
|
|
94
|
+
*,
|
|
95
|
+
service: str,
|
|
96
|
+
nssm_cfg: NssmConfig,
|
|
97
|
+
secret_keys: list[str],
|
|
98
|
+
plain_env: list[tuple[str, str]] | None = None,
|
|
99
|
+
secret_backend: str = "credman",
|
|
100
|
+
) -> str:
|
|
101
|
+
"""Produce the generated service.yaml text.
|
|
102
|
+
|
|
103
|
+
Hand-rolled (not via PyYAML's dump) so we control formatting:
|
|
104
|
+
fields appear in a stable order, comments are preserved, and the
|
|
105
|
+
output is human-reviewable. PyYAML's default style would munge
|
|
106
|
+
quoting on values with `:` or `#` and lose readability.
|
|
107
|
+
|
|
108
|
+
``plain_env`` (from ``--keep-as-env``) is rendered into the
|
|
109
|
+
YAML's ``spec.env:`` block. ``secret_keys`` becomes the
|
|
110
|
+
``spec.secrets:`` block.
|
|
111
|
+
"""
|
|
112
|
+
plain_env = plain_env or []
|
|
113
|
+
lines: list[str] = []
|
|
114
|
+
a = lines.append
|
|
115
|
+
a("# Generated by `recto migrate-from-nssm`.")
|
|
116
|
+
a(f"# Source NSSM service: {nssm_cfg.service}")
|
|
117
|
+
a("# Review and edit before relying on this in production.")
|
|
118
|
+
a("apiVersion: recto/v1")
|
|
119
|
+
a("kind: Service")
|
|
120
|
+
a("metadata:")
|
|
121
|
+
a(f" name: {service}")
|
|
122
|
+
# Map NSSM's DisplayName -> YAML metadata.display_name (Papercut #3
|
|
123
|
+
# additive field, v0.2.x+) and NSSM's Description -> YAML
|
|
124
|
+
# metadata.description independently. Pre-Papercut-#3 behavior
|
|
125
|
+
# collapsed NSSM DisplayName into a single YAML `description`
|
|
126
|
+
# field, which on round-trip through `recto apply` then wrote the
|
|
127
|
+
# same string back into both NSSM registry parameters -- lossy. New
|
|
128
|
+
# migrations produce two distinct fields when both are set on the
|
|
129
|
+
# source NSSM service. If only DisplayName is set, only display_name
|
|
130
|
+
# is emitted (so `recto apply` won't pollute NSSM's Description from
|
|
131
|
+
# "" to the DisplayName string on round-trip).
|
|
132
|
+
if nssm_cfg.display_name:
|
|
133
|
+
a(f" display_name: \"{escape_yaml(nssm_cfg.display_name)}\"")
|
|
134
|
+
if nssm_cfg.description:
|
|
135
|
+
a(f" description: \"{escape_yaml(nssm_cfg.description)}\"")
|
|
136
|
+
a("spec:")
|
|
137
|
+
a(f" exec: \"{escape_yaml(nssm_cfg.app_path)}\"")
|
|
138
|
+
if nssm_cfg.app_parameters:
|
|
139
|
+
a(" args:")
|
|
140
|
+
for w in nssm_cfg.app_parameters.split():
|
|
141
|
+
a(f" - \"{escape_yaml(w)}\"")
|
|
142
|
+
if nssm_cfg.app_directory:
|
|
143
|
+
a(f" working_dir: \"{escape_yaml(nssm_cfg.app_directory)}\"")
|
|
144
|
+
if secret_keys:
|
|
145
|
+
a(" secrets:")
|
|
146
|
+
for key in secret_keys:
|
|
147
|
+
a(f" - name: {key}")
|
|
148
|
+
a(f" source: {secret_backend}")
|
|
149
|
+
a(f" target_env: {key}")
|
|
150
|
+
a(" required: true")
|
|
151
|
+
if plain_env:
|
|
152
|
+
a(" env:")
|
|
153
|
+
for k, v in plain_env:
|
|
154
|
+
a(f" {k}: \"{escape_yaml(v)}\"")
|
|
155
|
+
a(" restart:")
|
|
156
|
+
a(" policy: always")
|
|
157
|
+
a(" backoff: exponential")
|
|
158
|
+
a(" initial_delay_seconds: 1")
|
|
159
|
+
a(" max_delay_seconds: 60")
|
|
160
|
+
a(" max_attempts: 10")
|
|
161
|
+
a("")
|
|
162
|
+
return "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def escape_yaml(s: str) -> str:
|
|
166
|
+
"""Escape backslashes and double-quotes for YAML double-quoted strings."""
|
|
167
|
+
return s.replace("\\", "\\\\").replace("\"", "\\\"")
|