runtime-sdk 0.4.49__py3-none-win_amd64.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.
runtime_sdk/secrets.py ADDED
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from runtime_sdk import output as cli_output
6
+ from runtime_sdk.auth import authenticated_client
7
+ from runtime_sdk.config import RuntimeConfig, RuntimeConfigError
8
+
9
+
10
+ def _coerce_secret_set_slug(slug: str | None) -> str | None:
11
+ value = str(slug or "").strip()
12
+ return value or None
13
+
14
+
15
+ def _parse_secret_pairs(pairs: list[str] | None) -> dict[str, str]:
16
+ values: dict[str, str] = {}
17
+ for pair in pairs or []:
18
+ if "=" not in pair:
19
+ raise RuntimeConfigError(f"invalid secret pair: {pair}")
20
+ key, value = pair.split("=", 1)
21
+ key = key.strip()
22
+ if not key:
23
+ raise RuntimeConfigError(f"invalid secret pair: {pair}")
24
+ values[key] = value
25
+ return values
26
+
27
+
28
+ def _parse_secret_delivery(
29
+ proxy_specs: list[str] | None, values: dict[str, str]
30
+ ) -> dict[str, dict[str, str]]:
31
+ delivery: dict[str, dict[str, str]] = {}
32
+ for spec in proxy_specs or []:
33
+ if "=" not in spec:
34
+ raise RuntimeConfigError(f"invalid proxy delivery: {spec}")
35
+ key, host = spec.split("=", 1)
36
+ key = key.strip()
37
+ host = host.strip()
38
+ if not key or not host or key not in values:
39
+ raise RuntimeConfigError(f"invalid proxy delivery: {spec}")
40
+ delivery[key] = {
41
+ "delivery": "proxy",
42
+ "target_host": host,
43
+ "header": "Authorization",
44
+ "scheme": "Bearer",
45
+ }
46
+ return delivery
47
+
48
+
49
+ def _parse_env_file(path: str) -> dict[str, str]:
50
+ try:
51
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
52
+ except OSError as exc:
53
+ raise RuntimeConfigError(f"failed to read env file: {exc}") from exc
54
+ values: dict[str, str] = {}
55
+ for raw_line in lines:
56
+ line = raw_line.strip()
57
+ if not line or line.startswith("#"):
58
+ continue
59
+ if line.startswith("export "):
60
+ line = line[len("export ") :].strip()
61
+ if "=" not in line:
62
+ raise RuntimeConfigError(f"invalid env line: {raw_line}")
63
+ key, value = line.split("=", 1)
64
+ key = key.strip()
65
+ if not key:
66
+ raise RuntimeConfigError(f"invalid env line: {raw_line}")
67
+ value = value.strip()
68
+ if value and value[0] == value[-1] and value[0] in {'"', "'"}:
69
+ value = value[1:-1]
70
+ values[key] = value
71
+ return values
72
+
73
+
74
+ def handle_secrets_list(config: RuntimeConfig) -> int:
75
+ if (err := cli_output.require_auth(config)) is not None:
76
+ return err
77
+ client = authenticated_client(config)
78
+ return cli_output.report_success(client.list_secret_sets())
79
+
80
+
81
+ def handle_secrets_show(config: RuntimeConfig, slug: str | None) -> int:
82
+ if (err := cli_output.require_auth(config)) is not None:
83
+ return err
84
+ resolved_slug = _coerce_secret_set_slug(slug)
85
+ if resolved_slug is None:
86
+ if not cli_output.interactive():
87
+ return cli_output.report_error("secret set slug is required")
88
+ resolved_slug = cli_output.prompt_text("Secret set slug")
89
+ if not resolved_slug:
90
+ return cli_output.report_error("secret set slug is required")
91
+ client = authenticated_client(config)
92
+ return cli_output.report_success(client.get_secret_set(resolved_slug))
93
+
94
+
95
+ def handle_secrets_set(
96
+ config: RuntimeConfig,
97
+ slug: str | None,
98
+ pairs: list[str] | None,
99
+ proxy_specs: list[str] | None,
100
+ ) -> int:
101
+ if (err := cli_output.require_auth(config)) is not None:
102
+ return err
103
+ resolved_slug = _coerce_secret_set_slug(slug)
104
+ if resolved_slug is None:
105
+ if not cli_output.interactive():
106
+ return cli_output.report_error("secret set slug is required")
107
+ resolved_slug = cli_output.prompt_text("Secret set slug")
108
+ if not resolved_slug:
109
+ return cli_output.report_error("secret set slug is required")
110
+ values = _parse_secret_pairs(pairs)
111
+ if not values:
112
+ return cli_output.report_error("at least one KEY=VALUE pair is required")
113
+ delivery = _parse_secret_delivery(proxy_specs, values)
114
+ client = authenticated_client(config)
115
+ secret_set = client.put_secret_set(resolved_slug, values, delivery)
116
+ return cli_output.report_success({"secret_set": secret_set})
117
+
118
+
119
+ def handle_secrets_import(
120
+ config: RuntimeConfig,
121
+ slug: str | None,
122
+ env_file: str,
123
+ proxy_specs: list[str] | None,
124
+ ) -> int:
125
+ if (err := cli_output.require_auth(config)) is not None:
126
+ return err
127
+ resolved_slug = _coerce_secret_set_slug(slug)
128
+ if resolved_slug is None:
129
+ if not cli_output.interactive():
130
+ return cli_output.report_error("secret set slug is required")
131
+ resolved_slug = cli_output.prompt_text("Secret set slug")
132
+ if not resolved_slug:
133
+ return cli_output.report_error("secret set slug is required")
134
+ values = _parse_env_file(env_file)
135
+ if not values:
136
+ return cli_output.report_error("env file did not contain any secrets")
137
+ delivery = _parse_secret_delivery(proxy_specs, values)
138
+ client = authenticated_client(config)
139
+ secret_set = client.put_secret_set(resolved_slug, values, delivery)
140
+ return cli_output.report_success({"secret_set": secret_set})
141
+
142
+
143
+ def handle_secrets_delete(config: RuntimeConfig, slug: str | None) -> int:
144
+ if (err := cli_output.require_auth(config)) is not None:
145
+ return err
146
+ resolved_slug = _coerce_secret_set_slug(slug)
147
+ if resolved_slug is None:
148
+ if not cli_output.interactive():
149
+ return cli_output.report_error("secret set slug is required")
150
+ resolved_slug = cli_output.prompt_text("Secret set slug")
151
+ if not resolved_slug:
152
+ return cli_output.report_error("secret set slug is required")
153
+ client = authenticated_client(config)
154
+ client.delete_secret_set(resolved_slug)
155
+ return cli_output.report_success({"slug": resolved_slug, "deleted": True})
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ from runtime_sdk import arguments, computers, output as cli_output
7
+ from runtime_sdk.auth import authenticated_client
8
+ from runtime_sdk.config import RuntimeConfig
9
+
10
+
11
+ def handle_startup_show(config: RuntimeConfig, computer_id: str | None) -> int:
12
+ if (err := cli_output.require_auth(config)) is not None:
13
+ return err
14
+
15
+ if computer_id is None:
16
+ if not cli_output.interactive():
17
+ return cli_output.report_error("computer id is required")
18
+ picked = computers.pick_computer(config, "Pick a computer")
19
+ if picked is None:
20
+ return 0
21
+ computer_id = picked.get("id") or picked.get("slug")
22
+ if not computer_id:
23
+ return cli_output.report_error("computer is missing an id")
24
+
25
+ client = authenticated_client(config)
26
+ computer_id = computers.resolve_computer_ref(client, computer_id)
27
+ result = client.get_startup_config(computer_id)
28
+ return cli_output.report_success(result)
29
+
30
+
31
+ def handle_startup_set(
32
+ config: RuntimeConfig,
33
+ computer_id: str | None,
34
+ command: str | None,
35
+ cwd: str | None,
36
+ port: int | None,
37
+ ) -> int:
38
+ if (err := cli_output.require_auth(config)) is not None:
39
+ return err
40
+ if not computer_id:
41
+ return cli_output.report_error("computer id is required")
42
+ if not command:
43
+ return cli_output.report_error("startup command is required")
44
+ if port is None:
45
+ return cli_output.report_error("port is required")
46
+ if port <= 0 or port > 65535:
47
+ return cli_output.report_error("port must be between 1 and 65535")
48
+
49
+ client = authenticated_client(config)
50
+ computer_id = computers.resolve_computer_ref(client, computer_id)
51
+ result = client.set_startup_config(computer_id, command=command, cwd=cwd, port=port)
52
+ return cli_output.report_success(result)
53
+
54
+
55
+ def handle_startup_clear(config: RuntimeConfig, computer_id: str | None) -> int:
56
+ if (err := cli_output.require_auth(config)) is not None:
57
+ return err
58
+ if not computer_id:
59
+ return cli_output.report_error("computer id is required")
60
+
61
+ client = authenticated_client(config)
62
+ computer_id = computers.resolve_computer_ref(client, computer_id)
63
+ result = client.clear_startup_config(computer_id)
64
+
65
+ return cli_output.report_success(result or {"configured": False})
66
+
67
+
68
+ def handle_service_run(
69
+ config: RuntimeConfig,
70
+ computer_id: str | None,
71
+ port: int | None,
72
+ cwd: str | None,
73
+ command_parts: list[str] | None,
74
+ ) -> int:
75
+ if (err := cli_output.require_auth(config)) is not None:
76
+ return err
77
+ if not computer_id:
78
+ return cli_output.report_error("computer id is required")
79
+ if port is None or port <= 0 or port > 65535:
80
+ return cli_output.report_error("port must be between 1 and 65535")
81
+ command = arguments.command_from_remainder(command_parts)
82
+ if not command:
83
+ return cli_output.report_error("service run requires -- <command...>")
84
+
85
+ client = authenticated_client(config)
86
+ computer_id = computers.resolve_computer_ref(client, computer_id)
87
+ result = client.run_service(computer_id, command=command, cwd=cwd, port=port)
88
+ return cli_output.report_success(result)
89
+
90
+
91
+ def handle_service_show(config: RuntimeConfig, computer_id: str | None) -> int:
92
+ if (err := cli_output.require_auth(config)) is not None:
93
+ return err
94
+
95
+ if computer_id is None:
96
+ if not cli_output.interactive():
97
+ return cli_output.report_error("computer id is required")
98
+ picked = computers.pick_computer(config, "Pick a computer")
99
+ if picked is None:
100
+ return 0
101
+ computer_id = picked.get("id") or picked.get("slug")
102
+ if not computer_id:
103
+ return cli_output.report_error("computer is missing an id")
104
+
105
+ client = authenticated_client(config)
106
+ computer_id = computers.resolve_computer_ref(client, computer_id)
107
+ result = client.get_service_config(computer_id)
108
+ return cli_output.report_success(result)
109
+
110
+
111
+ def handle_service_logs(
112
+ config: RuntimeConfig, computer_id: str | None, follow: bool
113
+ ) -> int:
114
+ if (err := cli_output.require_auth(config)) is not None:
115
+ return err
116
+ if not computer_id:
117
+ return cli_output.report_error("computer id is required")
118
+ client = authenticated_client(config)
119
+ computer_id = computers.resolve_computer_ref(client, computer_id)
120
+ for line in client.stream_service_logs(computer_id, follow=follow):
121
+ try:
122
+ frame = json.loads(line)
123
+ except ValueError:
124
+ print(line)
125
+ continue
126
+ stdout = frame.get("stdout") or ""
127
+ stderr = frame.get("stderr") or ""
128
+ data = frame.get("data") or ""
129
+ error = frame.get("error") or ""
130
+ if stdout:
131
+ print(stdout, end="")
132
+ if stderr:
133
+ print(stderr, end="", file=sys.stderr)
134
+ if data:
135
+ print(data, end="")
136
+ if error:
137
+ print(error, file=sys.stderr)
138
+ return 0
139
+
140
+
141
+ def handle_service_restart(config: RuntimeConfig, computer_id: str | None) -> int:
142
+ if (err := cli_output.require_auth(config)) is not None:
143
+ return err
144
+ if not computer_id:
145
+ return cli_output.report_error("computer id is required")
146
+ client = authenticated_client(config)
147
+ computer_id = computers.resolve_computer_ref(client, computer_id)
148
+ result = client.restart_service(computer_id)
149
+ return cli_output.report_success(result)
150
+
151
+
152
+ def handle_service_stop(config: RuntimeConfig, computer_id: str | None) -> int:
153
+ if (err := cli_output.require_auth(config)) is not None:
154
+ return err
155
+ if not computer_id:
156
+ return cli_output.report_error("computer id is required")
157
+ client = authenticated_client(config)
158
+ computer_id = computers.resolve_computer_ref(client, computer_id)
159
+ result = client.stop_service(computer_id)
160
+ return cli_output.report_success(result)
161
+
162
+
163
+ def handle_service_clear(config: RuntimeConfig, computer_id: str | None) -> int:
164
+ if (err := cli_output.require_auth(config)) is not None:
165
+ return err
166
+ if not computer_id:
167
+ return cli_output.report_error("computer id is required")
168
+
169
+ client = authenticated_client(config)
170
+ computer_id = computers.resolve_computer_ref(client, computer_id)
171
+ result = client.clear_service_config(computer_id)
172
+
173
+ return cli_output.report_success(result or {"configured": False})
runtime_sdk/ssh.py ADDED
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import secrets
5
+ import shlex
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Callable
12
+
13
+ from runtime_sdk import computers, output as cli_output, proxy
14
+ from runtime_sdk.auth import authenticated_client
15
+ from runtime_sdk.client import RuntimeAPIError, RuntimeClient
16
+ from runtime_sdk.config import RuntimeConfig, RuntimeConfigError
17
+
18
+
19
+ def handle_ssh(
20
+ config: RuntimeConfig, computer_id: str | None, ssh_args: list[str] | None = None
21
+ ) -> int:
22
+ if (err := cli_output.require_auth(config)) is not None:
23
+ return err
24
+ ssh_bin = shutil.which("ssh")
25
+ if not ssh_bin:
26
+ return cli_output.report_error("ssh is not installed")
27
+ ssh_keygen_bin = shutil.which("ssh-keygen")
28
+ if not ssh_keygen_bin:
29
+ return cli_output.report_error("ssh-keygen is not installed")
30
+
31
+ if computer_id is None:
32
+ if not cli_output.interactive():
33
+ return cli_output.report_error("computer id is required")
34
+ picked = computers.pick_computer(
35
+ config,
36
+ "Pick a computer to ssh",
37
+ lambda c: computers.computer_internal_status(c) in ("running", "cold"),
38
+ )
39
+ if picked is None:
40
+ return 0
41
+ computer_id = picked.get("id") or picked.get("slug")
42
+ if not computer_id:
43
+ return cli_output.report_error("computer is missing an id")
44
+
45
+ client = authenticated_client(config)
46
+ resolved_id = computers.resolve_computer_ref(client, str(computer_id))
47
+ args = _ssh_extra_args(ssh_args or [])
48
+
49
+ with tempfile.TemporaryDirectory(prefix="runtime-ssh-") as temp_dir:
50
+ temp = Path(temp_dir)
51
+ key_path = temp / "id_ed25519"
52
+ known_hosts = temp / "known_hosts"
53
+ marker = "runtimevm-ssh-" + secrets.token_hex(8)
54
+ if (key_status := _generate_ssh_key(ssh_keygen_bin, key_path)) != 0:
55
+ return cli_output.report_error(
56
+ f"ssh-keygen failed with exit code {key_status}"
57
+ )
58
+ public_key = (
59
+ key_path.with_suffix(key_path.suffix + ".pub")
60
+ .read_text(encoding="utf-8")
61
+ .strip()
62
+ )
63
+
64
+ client.create_terminal_ticket(resolved_id)
65
+ cleanup_proxy: Callable[[], None] | None = None
66
+ try:
67
+ _install_temporary_ssh_key(client, resolved_id, public_key, marker)
68
+ local_port, cleanup_proxy = proxy.start_temporary_proxy(
69
+ config, resolved_id, 22
70
+ )
71
+ ssh_cmd = [
72
+ ssh_bin,
73
+ "-i",
74
+ str(key_path),
75
+ "-p",
76
+ str(local_port),
77
+ "-o",
78
+ "IdentitiesOnly=yes",
79
+ "-o",
80
+ f"UserKnownHostsFile={known_hosts}",
81
+ "-o",
82
+ "StrictHostKeyChecking=accept-new",
83
+ "-o",
84
+ "LogLevel=ERROR",
85
+ "runtime@127.0.0.1",
86
+ ]
87
+ ssh_cmd.extend(args)
88
+ return subprocess.run(ssh_cmd).returncode
89
+ finally:
90
+ if cleanup_proxy is not None:
91
+ cleanup_proxy()
92
+ _remove_temporary_ssh_key(client, resolved_id, marker)
93
+
94
+
95
+ def _ssh_extra_args(args: list[str]) -> list[str]:
96
+ if args and args[0] == "--":
97
+ return args[1:]
98
+ return args
99
+
100
+
101
+ def _generate_ssh_key(ssh_keygen_bin: str, key_path: Path) -> int:
102
+ return subprocess.run(
103
+ [
104
+ ssh_keygen_bin,
105
+ "-q",
106
+ "-t",
107
+ "ed25519",
108
+ "-N",
109
+ "",
110
+ "-C",
111
+ "runtimevm-ephemeral",
112
+ "-f",
113
+ str(key_path),
114
+ ]
115
+ ).returncode
116
+
117
+
118
+ def _install_temporary_ssh_key(
119
+ client: RuntimeClient, computer_id: str, public_key: str, marker: str
120
+ ) -> None:
121
+ encoded_key = base64.b64encode(public_key.encode()).decode()
122
+ script = f"""set -euo pipefail
123
+ if ! command -v sshd >/dev/null 2>&1 && [ ! -x /usr/sbin/sshd ]; then
124
+ echo "openssh-server is not installed in this VM image" >&2
125
+ exit 127
126
+ fi
127
+ mkdir -p /run/sshd
128
+ if command -v systemctl >/dev/null 2>&1; then
129
+ systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null || /usr/sbin/sshd
130
+ else
131
+ /usr/sbin/sshd
132
+ fi
133
+ install -d -m 755 -o runtime -g runtime /home/runtime/.runtime
134
+ cat > /home/runtime/.runtime/ssh-session <<'RUNTIME_SSH_SESSION'
135
+ #!/bin/bash
136
+ set -e
137
+ if [ -f /home/runtime/.runtime/prompt.sh ]; then
138
+ . /home/runtime/.runtime/prompt.sh
139
+ fi
140
+ if [ -f /home/runtime/.runtime/branch-workspace.sh ]; then
141
+ . /home/runtime/.runtime/branch-workspace.sh
142
+ fi
143
+ if [ -n "${{SSH_ORIGINAL_COMMAND:-}}" ]; then
144
+ exec /bin/bash -lc "$SSH_ORIGINAL_COMMAND"
145
+ fi
146
+ exec /bin/bash -l
147
+ RUNTIME_SSH_SESSION
148
+ chown runtime:runtime /home/runtime/.runtime/ssh-session
149
+ chmod 755 /home/runtime/.runtime/ssh-session
150
+ install -d -m 700 -o runtime -g runtime /home/runtime/.ssh
151
+ auth=/home/runtime/.ssh/authorized_keys
152
+ touch "$auth"
153
+ chown runtime:runtime "$auth"
154
+ chmod 600 "$auth"
155
+ tmp="$(mktemp)"
156
+ grep -Fv -- {shlex.quote(marker)} "$auth" > "$tmp" || true
157
+ cat "$tmp" > "$auth"
158
+ rm -f "$tmp"
159
+ key="$(printf %s {shlex.quote(encoded_key)} | base64 -d)"
160
+ printf 'command="/home/runtime/.runtime/ssh-session" %s %s\\n' "$key" {shlex.quote(marker)} >> "$auth"
161
+ chown runtime:runtime "$auth"
162
+ chmod 600 "$auth"
163
+ """
164
+ result = client.run_command(
165
+ computer_id, script, uid=0, gid=0, cwd="/", shell="/bin/bash"
166
+ )
167
+ if int(result.get("exit_code") or 0) != 0:
168
+ stderr = str(result.get("stderr") or "").strip()
169
+ raise RuntimeConfigError(stderr or "failed to install temporary SSH key")
170
+
171
+
172
+ def _remove_temporary_ssh_key(
173
+ client: RuntimeClient, computer_id: str, marker: str
174
+ ) -> None:
175
+ script = f"""set -euo pipefail
176
+ auth=/home/runtime/.ssh/authorized_keys
177
+ [ -f "$auth" ] || exit 0
178
+ tmp="$(mktemp)"
179
+ grep -Fv -- {shlex.quote(marker)} "$auth" > "$tmp" || true
180
+ cat "$tmp" > "$auth"
181
+ rm -f "$tmp"
182
+ chown runtime:runtime "$auth"
183
+ chmod 600 "$auth"
184
+ """
185
+ try:
186
+ result = client.run_command(
187
+ computer_id, script, uid=0, gid=0, cwd="/", shell="/bin/bash"
188
+ )
189
+ except RuntimeAPIError as exc:
190
+ print(f"warning: failed to remove temporary SSH key: {exc}", file=sys.stderr)
191
+ return
192
+ if int(result.get("exit_code") or 0) != 0:
193
+ stderr = str(result.get("stderr") or "").strip()
194
+ print(
195
+ f"warning: failed to remove temporary SSH key: {stderr or 'cleanup command failed'}",
196
+ file=sys.stderr,
197
+ )