cubething_psync 0.2.1.dev1__tar.gz → 0.3.0__tar.gz
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.
- {cubething_psync-0.2.1.dev1/src/cubething_psync.egg-info → cubething_psync-0.3.0}/PKG-INFO +1 -1
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/pyproject.toml +13 -1
- cubething_psync-0.3.0/src/client/args.py +190 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/client/main.py +46 -63
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/common/data.py +40 -18
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0/src/cubething_psync.egg-info}/PKG-INFO +1 -1
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/SOURCES.txt +2 -1
- cubething_psync-0.3.0/src/server/args.py +107 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/server/main.py +78 -82
- cubething_psync-0.3.0/test/test_integration.py +83 -0
- cubething_psync-0.2.1.dev1/src/client/args.py +0 -94
- cubething_psync-0.2.1.dev1/src/server/args.py +0 -69
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/LICENSE +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/README.md +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/setup.cfg +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/client/__init__.py +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/client/__main__.py +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/common/__init__.py +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/common/log.py +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/dependency_links.txt +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/entry_points.txt +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/requires.txt +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/top_level.txt +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/server/__init__.py +0 -0
- {cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/server/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cubething_psync"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Simple project synchronization tool."
|
|
5
5
|
authors = [{name = "ada mandala", email="ada@cubething.dev"}]
|
|
6
6
|
readme = "README.md"
|
|
@@ -26,6 +26,18 @@ build-backend = "setuptools.build_meta"
|
|
|
26
26
|
[tool.setuptools.packages.find]
|
|
27
27
|
where= ["src"]
|
|
28
28
|
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"basedpyright>=1.35.0",
|
|
32
|
+
"pytest>=9.0.1",
|
|
33
|
+
"ruff>=0.14.7",
|
|
34
|
+
"testcontainers>=4.13.3",
|
|
35
|
+
]
|
|
36
|
+
|
|
29
37
|
[project.scripts]
|
|
30
38
|
psync-server = "server.main:main"
|
|
31
39
|
psync-client = "client.main:main"
|
|
40
|
+
|
|
41
|
+
[pytest]
|
|
42
|
+
testpaths="test"
|
|
43
|
+
addopts = ["--import-mode=importlib"]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from os.path import basename
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shlex
|
|
9
|
+
from common.data import deserialize_env
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Args:
|
|
14
|
+
"""
|
|
15
|
+
Client arguments.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
target_path: str
|
|
19
|
+
"""
|
|
20
|
+
``--path -p <target_path>``
|
|
21
|
+
|
|
22
|
+
**Required.** Path to the target executable.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
assets: list[str] = field(default_factory=list)
|
|
26
|
+
"""
|
|
27
|
+
``--assets -A <env>``
|
|
28
|
+
|
|
29
|
+
Extra files or directories to be synced to the destination path.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
"""
|
|
34
|
+
``--env -e <env>``
|
|
35
|
+
|
|
36
|
+
Space separated list of environment variables which will be passed to the executable.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
args: list[str] = field(default_factory=list)
|
|
40
|
+
"""
|
|
41
|
+
``--args -a <args>``
|
|
42
|
+
|
|
43
|
+
Arguments passed to the executable.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
server_ip: str = os.environ.get("PSYNC_SERVER_IP", "127.0.0.1")
|
|
47
|
+
"""
|
|
48
|
+
environ: ``PSYNC_SERVER_IP``
|
|
49
|
+
|
|
50
|
+
Server IP address.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
server_port: int = int(os.environ.get("PSYNC_SERVER_PORT", "5000"))
|
|
54
|
+
"""
|
|
55
|
+
environ: ``PSYNC_SERVER_PORT``
|
|
56
|
+
|
|
57
|
+
Server port.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
server_ssh_port: int = int(os.environ.get("PSYNC_SSH_PORT", "5022"))
|
|
61
|
+
"""
|
|
62
|
+
environ: ``PSYNC_SSH_PORT``
|
|
63
|
+
|
|
64
|
+
SSH port on the server host. Client must be authenticated with a shared public key.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
ssh_args: str = os.environ.get("PSYNC_SSH_ARGS", "-l psync")
|
|
68
|
+
"""
|
|
69
|
+
environ: ``PSYNC_SSH_ARGS``
|
|
70
|
+
|
|
71
|
+
Arguments passed to SSH. Under the hood, psync runs
|
|
72
|
+
``rsync -e "/usr/bin/ssh {PSYNC_SSH_ARGS} -p {PSYNC_SSH_PORT}"``
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
server_dest: str = os.environ.get("PSYNC_SERVER_DEST", "/home/psync")
|
|
76
|
+
"""
|
|
77
|
+
environ: ``PSYNC_SERVER_DEST``
|
|
78
|
+
|
|
79
|
+
Base path on the server where the files should be synced.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
ssl_cert_path: str = os.environ.get(
|
|
83
|
+
"PSYNC_CERT_PATH", "~/.local/share/psync/cert.pem"
|
|
84
|
+
)
|
|
85
|
+
"""
|
|
86
|
+
environ: ``PSYNC_CERT_PATH``
|
|
87
|
+
|
|
88
|
+
Public SSL certificate used to trust the psync server.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
client_origin: str = os.environ.get("PSYNC_CLIENT_ORIGIN", "127.0.0.1")
|
|
92
|
+
"""
|
|
93
|
+
environ: ``PSYNC_CLIENT_ORIGIN``
|
|
94
|
+
|
|
95
|
+
Domain name. Should match the origins set in the server's ``PSYNC_ORIGINS``
|
|
96
|
+
variable.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def project_hash(self) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Hash value generated from the target path. Used as the directory name for the project.
|
|
102
|
+
"""
|
|
103
|
+
return hashlib.blake2s(self.target_path.encode(), digest_size=8).hexdigest()
|
|
104
|
+
|
|
105
|
+
def rsync_url(self) -> str:
|
|
106
|
+
"""
|
|
107
|
+
{server_ip}:{server_dest}/{project_hash}
|
|
108
|
+
"""
|
|
109
|
+
return f"{self.server_ip}:{self.server_dest}/{self.project_hash()}/"
|
|
110
|
+
|
|
111
|
+
def destination_path(self) -> Path:
|
|
112
|
+
"""
|
|
113
|
+
{server_dest}/{project_hash}/{basename(target_path)}
|
|
114
|
+
"""
|
|
115
|
+
return Path(self.server_dest) / self.project_hash() / basename(self.target_path)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
parser = argparse.ArgumentParser(
|
|
119
|
+
prog="psync-client",
|
|
120
|
+
usage="""\
|
|
121
|
+
Client for the psync server.
|
|
122
|
+
|
|
123
|
+
In addition to the options below, the client is configurable through environment
|
|
124
|
+
variables.
|
|
125
|
+
|
|
126
|
+
Variable | Default
|
|
127
|
+
--------------------+-------------------------------
|
|
128
|
+
PSYNC_SERVER_IP | 127.0.0.1
|
|
129
|
+
PSYNC_SERVER_PORT | 5000
|
|
130
|
+
PSYNC_SSH_PORT | 5022
|
|
131
|
+
PSYNC_SERVER_DEST | /home/psync/
|
|
132
|
+
PSYNC_SSH_ARGS | -l psync
|
|
133
|
+
PSYNC_CERT_PATH | ~/.local/share/psync/cert.pem
|
|
134
|
+
PSYNC_CLIENT_ORIGIN | 127.0.0.1
|
|
135
|
+
|
|
136
|
+
SSH arguments will be append with "-p {PSYNC_SSH_PORT}"
|
|
137
|
+
""",
|
|
138
|
+
)
|
|
139
|
+
_action = parser.add_argument(
|
|
140
|
+
"--path",
|
|
141
|
+
"-p",
|
|
142
|
+
required=True,
|
|
143
|
+
help="Path to the target exectuable.",
|
|
144
|
+
)
|
|
145
|
+
_action = parser.add_argument(
|
|
146
|
+
"--assets",
|
|
147
|
+
"-A",
|
|
148
|
+
nargs="+",
|
|
149
|
+
help="Extra files or directories to be synced to the destination path.",
|
|
150
|
+
)
|
|
151
|
+
_action = parser.add_argument(
|
|
152
|
+
"--env",
|
|
153
|
+
"-e",
|
|
154
|
+
help="Environment variables to set in the remote execution environment. Variables must be space-sepated or double-quoted.",
|
|
155
|
+
)
|
|
156
|
+
_action = parser.add_argument(
|
|
157
|
+
"--args", "-a", help="Arguments passed to the executable."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_args() -> Args:
|
|
162
|
+
args = vars(parser.parse_args())
|
|
163
|
+
|
|
164
|
+
target_path = str(args.get("path"))
|
|
165
|
+
target_path = Path(target_path)
|
|
166
|
+
if not target_path.is_file():
|
|
167
|
+
logging.error(f"Could not file at {target_path}")
|
|
168
|
+
exit(1)
|
|
169
|
+
|
|
170
|
+
extra: list[str] = []
|
|
171
|
+
extra_raw = args.get("extra")
|
|
172
|
+
if extra_raw is not None:
|
|
173
|
+
extra = extra_raw # pyright: ignore[reportAny]
|
|
174
|
+
|
|
175
|
+
client_args: list[str] = []
|
|
176
|
+
raw_args = args.get("args")
|
|
177
|
+
if raw_args is not None:
|
|
178
|
+
client_args = shlex.split(str(raw_args)) # pyright: ignore[reportAny]
|
|
179
|
+
|
|
180
|
+
env: dict[str, str] = dict()
|
|
181
|
+
raw_env = args.get("env")
|
|
182
|
+
if raw_env is not None:
|
|
183
|
+
env = deserialize_env(f"env='{raw_env}'")
|
|
184
|
+
|
|
185
|
+
return Args(
|
|
186
|
+
target_path=str(target_path),
|
|
187
|
+
assets=extra or [],
|
|
188
|
+
env=env,
|
|
189
|
+
args=client_args,
|
|
190
|
+
)
|
|
@@ -3,13 +3,13 @@ psync client
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import hashlib
|
|
7
6
|
import os
|
|
8
|
-
import
|
|
7
|
+
from pathlib import Path
|
|
9
8
|
import signal
|
|
10
9
|
import ssl
|
|
11
10
|
import subprocess
|
|
12
11
|
import websockets
|
|
12
|
+
from websockets.typing import Origin
|
|
13
13
|
from common.data import (
|
|
14
14
|
ErrorResp,
|
|
15
15
|
ExitResp,
|
|
@@ -17,18 +17,13 @@ from common.data import (
|
|
|
17
17
|
LogResp,
|
|
18
18
|
OkayResp,
|
|
19
19
|
OpenReq,
|
|
20
|
+
SetPidResp,
|
|
20
21
|
deserialize,
|
|
21
22
|
serialize,
|
|
22
23
|
)
|
|
23
24
|
import logging
|
|
24
25
|
from common.log import InterceptHandler
|
|
25
26
|
from client.args import (
|
|
26
|
-
SERVER_IP,
|
|
27
|
-
SERVER_PORT,
|
|
28
|
-
SERVER_SSH_PORT,
|
|
29
|
-
USER,
|
|
30
|
-
SSL_CERT_PATH,
|
|
31
|
-
SERVER_DEST,
|
|
32
27
|
Args,
|
|
33
28
|
parse_args,
|
|
34
29
|
)
|
|
@@ -38,69 +33,54 @@ class PsyncClient:
|
|
|
38
33
|
"""
|
|
39
34
|
The primary interface for psync. The client CLI allows users to sync files with
|
|
40
35
|
rsync, then execute them remotely while receiving the logs.
|
|
41
|
-
|
|
42
|
-
CLI arguments: ::
|
|
43
|
-
-h, --help show this help message and exit
|
|
44
|
-
--path, -p PATH Path to the target exectuable.
|
|
45
|
-
--extra, -E EXTRA [EXTRA ...]
|
|
46
|
-
Extra files or directories to be synced to the destination path.
|
|
47
|
-
--env, -e ENV Environment variables to set in the remote execution environment. Variables
|
|
48
|
-
must be space-sepated or double-quoted.
|
|
49
|
-
--args, -a ARGS Arguments with which to run the remote executable.
|
|
50
|
-
|
|
51
|
-
Environment configuration:
|
|
52
|
-
PSYNC_SERVER_IP: The IP address of the server instance.
|
|
53
|
-
Default: 127.0.0.1
|
|
54
|
-
PSYNC_SERVER_PORT: The port of the server instance.
|
|
55
|
-
Default: 5000
|
|
56
|
-
PSYNC_SSH_PORT: The server instance's SSH port.
|
|
57
|
-
Default: 5022
|
|
58
|
-
PSYNC_SSH_USER: The server instance's SSH user.
|
|
59
|
-
Default: psync
|
|
60
|
-
PSYNC_CERT_PATH: Path to the SSL certificate. Used to trust self-signed certs. Should
|
|
61
|
-
match the server's certificate.
|
|
62
|
-
Default: ~/.local/share/psync/cert.pem
|
|
63
36
|
"""
|
|
64
37
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
__quit: bool = False
|
|
38
|
+
args: Args
|
|
39
|
+
pid: int | None = None
|
|
40
|
+
"""ID of the current connection."""
|
|
69
41
|
__force_exit: bool = False
|
|
70
42
|
|
|
71
|
-
def __init__(self, args:
|
|
72
|
-
self.
|
|
73
|
-
self.__env = env
|
|
74
|
-
self.__path = path
|
|
43
|
+
def __init__(self, args: Args):
|
|
44
|
+
self.args = args
|
|
75
45
|
|
|
76
46
|
def __mk_handler(self, ws: websockets.ClientConnection):
|
|
77
47
|
async def inner():
|
|
78
48
|
if not self.__force_exit:
|
|
79
49
|
logging.info("Gracefully shutting down...")
|
|
80
50
|
self.__force_exit = True
|
|
81
|
-
|
|
51
|
+
if self.pid is not None:
|
|
52
|
+
await ws.send(serialize(KillReq(pid=self.pid)))
|
|
82
53
|
await ws.close()
|
|
83
54
|
asyncio.get_event_loop().stop()
|
|
55
|
+
raise SystemExit(130)
|
|
84
56
|
else:
|
|
85
57
|
logging.warning("Got second SIGINT, shutting down")
|
|
86
58
|
asyncio.get_event_loop().stop()
|
|
87
|
-
raise
|
|
59
|
+
raise SystemExit(1)
|
|
88
60
|
|
|
89
61
|
return lambda: asyncio.create_task(inner())
|
|
90
62
|
|
|
91
63
|
async def run(self):
|
|
92
64
|
"""Run the client instance."""
|
|
93
65
|
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
94
|
-
ssl_ctx.load_verify_locations(
|
|
66
|
+
ssl_ctx.load_verify_locations(Path(self.args.ssl_cert_path).expanduser())
|
|
95
67
|
ssl_ctx.check_hostname = False # not ideal
|
|
96
68
|
async with websockets.connect(
|
|
97
|
-
f"wss://{
|
|
69
|
+
f"wss://{self.args.server_ip}:{self.args.server_port}",
|
|
70
|
+
ssl=ssl_ctx,
|
|
71
|
+
origin=Origin(f"wss://{self.args.client_origin}"),
|
|
98
72
|
) as ws:
|
|
99
73
|
asyncio.get_event_loop().add_signal_handler(
|
|
100
74
|
signal.SIGINT, self.__mk_handler(ws)
|
|
101
75
|
)
|
|
102
76
|
await ws.send(
|
|
103
|
-
serialize(
|
|
77
|
+
serialize(
|
|
78
|
+
OpenReq(
|
|
79
|
+
path=self.args.destination_path(),
|
|
80
|
+
env=self.args.env,
|
|
81
|
+
args=self.args.args,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
104
84
|
)
|
|
105
85
|
async for data in ws:
|
|
106
86
|
if isinstance(data, bytes):
|
|
@@ -115,7 +95,7 @@ class PsyncClient:
|
|
|
115
95
|
f"Failed to deserialize message '{msg}' with error '{e}'"
|
|
116
96
|
)
|
|
117
97
|
await ws.close()
|
|
118
|
-
|
|
98
|
+
raise Exception(e)
|
|
119
99
|
|
|
120
100
|
match resp:
|
|
121
101
|
case LogResp():
|
|
@@ -123,39 +103,42 @@ class PsyncClient:
|
|
|
123
103
|
case ErrorResp():
|
|
124
104
|
logging.error(f"Received server error: {resp.msg}")
|
|
125
105
|
await ws.close()
|
|
126
|
-
|
|
106
|
+
raise Exception(resp.msg)
|
|
127
107
|
case ExitResp():
|
|
128
108
|
logging.info(f"Exiting with code {resp.exit_code}")
|
|
129
109
|
await ws.close()
|
|
130
|
-
|
|
110
|
+
raise SystemExit(resp.exit_code)
|
|
111
|
+
case SetPidResp():
|
|
112
|
+
logging.info(f"Remote PID = {resp.pid}")
|
|
113
|
+
self.pid = resp.pid
|
|
131
114
|
case OkayResp():
|
|
132
|
-
logging.info("
|
|
115
|
+
logging.info("OK.")
|
|
133
116
|
case _:
|
|
134
117
|
logging.warning(f"Got unknown request {resp}")
|
|
135
118
|
|
|
136
119
|
|
|
137
|
-
def __rsync(
|
|
120
|
+
def __rsync(args: Args):
|
|
138
121
|
"""Runs rsync."""
|
|
139
|
-
url = f"{SERVER_IP}:{SERVER_DEST}/{project_hash}/"
|
|
140
122
|
rsync_args = [
|
|
141
123
|
"rsync",
|
|
142
124
|
"-avzr",
|
|
143
125
|
"-e",
|
|
144
|
-
f"/usr/bin/ssh
|
|
126
|
+
f"/usr/bin/ssh {args.ssh_args} -p {str(args.server_ssh_port)}",
|
|
145
127
|
"--progress",
|
|
146
128
|
"--mkpath",
|
|
147
129
|
args.target_path,
|
|
148
|
-
*args.
|
|
149
|
-
|
|
130
|
+
*args.assets,
|
|
131
|
+
args.rsync_url(),
|
|
150
132
|
]
|
|
151
133
|
logging.info(" ".join(rsync_args))
|
|
152
134
|
p = subprocess.run(rsync_args)
|
|
153
135
|
if p.returncode != 0:
|
|
154
|
-
|
|
155
|
-
|
|
136
|
+
msg = f"Rsync failed with exit code {p.returncode}"
|
|
137
|
+
logging.error(msg)
|
|
138
|
+
raise Exception(msg)
|
|
156
139
|
|
|
157
140
|
|
|
158
|
-
def main():
|
|
141
|
+
def main(args: Args | None = None):
|
|
159
142
|
"""
|
|
160
143
|
The main executable.
|
|
161
144
|
Sync project files with rsync, then run the client.
|
|
@@ -163,15 +146,15 @@ def main():
|
|
|
163
146
|
log_level = os.environ.get("PSYNC_LOG", "INFO").upper()
|
|
164
147
|
logging.basicConfig(handlers=[InterceptHandler()], level=log_level, force=True)
|
|
165
148
|
|
|
166
|
-
|
|
167
|
-
args
|
|
168
|
-
__rsync(project_hash, args)
|
|
149
|
+
args = parse_args() if args is None else args
|
|
150
|
+
__rsync(args)
|
|
169
151
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
152
|
+
try:
|
|
153
|
+
asyncio.run(PsyncClient(args).run())
|
|
154
|
+
except SystemExit as e:
|
|
155
|
+
exit(e.code)
|
|
156
|
+
except Exception:
|
|
157
|
+
exit(1)
|
|
175
158
|
|
|
176
159
|
|
|
177
160
|
if __name__ == "__main__":
|
|
@@ -15,6 +15,7 @@ class Mode(Enum):
|
|
|
15
15
|
class ReqKind(Enum):
|
|
16
16
|
Open = "open"
|
|
17
17
|
Kill = "kill"
|
|
18
|
+
HealthCheck = "hc"
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class RespKind(Enum):
|
|
@@ -22,6 +23,7 @@ class RespKind(Enum):
|
|
|
22
23
|
Error = "error"
|
|
23
24
|
Exit = "exit"
|
|
24
25
|
Okay = "ok"
|
|
26
|
+
SetPid = "set_pid"
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
@dataclass
|
|
@@ -34,6 +36,7 @@ class OpenReq:
|
|
|
34
36
|
|
|
35
37
|
@dataclass
|
|
36
38
|
class KillReq:
|
|
39
|
+
pid: int
|
|
37
40
|
kind: ReqKind = ReqKind.Kill
|
|
38
41
|
|
|
39
42
|
|
|
@@ -60,28 +63,44 @@ class OkayResp:
|
|
|
60
63
|
kind: RespKind = RespKind.Okay
|
|
61
64
|
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
@dataclass
|
|
67
|
+
class HealthCheckReq:
|
|
68
|
+
kind: ReqKind = ReqKind.HealthCheck
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class SetPidResp:
|
|
73
|
+
pid: int
|
|
74
|
+
kind: RespKind = RespKind.SetPid
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
Req = OpenReq | KillReq | HealthCheckReq
|
|
78
|
+
Resp = LogResp | ExitResp | ErrorResp | OkayResp | SetPidResp
|
|
65
79
|
|
|
66
80
|
|
|
67
81
|
def serialize(msg: Req | Resp) -> str:
|
|
82
|
+
value = msg.kind.value
|
|
68
83
|
match msg:
|
|
69
84
|
case OpenReq():
|
|
70
85
|
args = " ".join(msg.args)
|
|
71
86
|
env: list[str] = []
|
|
72
87
|
for key, value in msg.env.items():
|
|
73
88
|
env.append(f'{key}="{value}"')
|
|
74
|
-
return f"
|
|
89
|
+
return f"{value} path='{msg.path}' args='{args}' env='{' '.join(env)}'"
|
|
75
90
|
case KillReq():
|
|
76
|
-
return "
|
|
91
|
+
return f"{value} {msg.pid}"
|
|
92
|
+
case SetPidResp():
|
|
93
|
+
return f"{value} {msg.pid}"
|
|
77
94
|
case LogResp():
|
|
78
|
-
return f"
|
|
95
|
+
return f"{value} {msg.msg}"
|
|
79
96
|
case ExitResp():
|
|
80
|
-
return f"
|
|
97
|
+
return f"{value} {msg.exit_code}"
|
|
81
98
|
case ErrorResp():
|
|
82
|
-
return f"
|
|
99
|
+
return f"{value} {msg.msg}"
|
|
83
100
|
case OkayResp():
|
|
84
|
-
return "
|
|
101
|
+
return f"{value}"
|
|
102
|
+
case HealthCheckReq():
|
|
103
|
+
return f"{value}"
|
|
85
104
|
|
|
86
105
|
|
|
87
106
|
path_expr = re.compile(r"path='([^']+)'")
|
|
@@ -106,13 +125,8 @@ def deserialize(msg: str) -> Req | Resp:
|
|
|
106
125
|
logging.debug(f"Got message {msg}")
|
|
107
126
|
try:
|
|
108
127
|
[kind, rest] = msg.split(" ", 1)
|
|
109
|
-
except Exception
|
|
110
|
-
|
|
111
|
-
return OkayResp()
|
|
112
|
-
elif msg == "kill":
|
|
113
|
-
return KillReq()
|
|
114
|
-
else:
|
|
115
|
-
raise e
|
|
128
|
+
except Exception:
|
|
129
|
+
kind = msg.strip()
|
|
116
130
|
|
|
117
131
|
match kind:
|
|
118
132
|
case ReqKind.Open.value:
|
|
@@ -134,12 +148,20 @@ def deserialize(msg: str) -> Req | Resp:
|
|
|
134
148
|
|
|
135
149
|
return OpenReq(path=Path(path), args=args, env=env)
|
|
136
150
|
|
|
151
|
+
case ReqKind.Kill.value:
|
|
152
|
+
return KillReq(int(rest))
|
|
137
153
|
case RespKind.Log.value:
|
|
138
|
-
return LogResp(
|
|
154
|
+
return LogResp(rest)
|
|
139
155
|
case RespKind.Exit.value:
|
|
140
|
-
return ExitResp(
|
|
156
|
+
return ExitResp(rest)
|
|
141
157
|
case RespKind.Error.value:
|
|
142
|
-
return ErrorResp(
|
|
158
|
+
return ErrorResp(rest)
|
|
159
|
+
case RespKind.Okay.value:
|
|
160
|
+
return OkayResp()
|
|
161
|
+
case ReqKind.HealthCheck.value:
|
|
162
|
+
return HealthCheckReq()
|
|
163
|
+
case RespKind.SetPid.value:
|
|
164
|
+
return SetPidResp(int(rest))
|
|
143
165
|
|
|
144
166
|
case _:
|
|
145
167
|
raise ValueError("Could not match kind for message", msg)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from os import environ
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Args:
|
|
9
|
+
"""
|
|
10
|
+
Server arguments.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
use_base_env: bool
|
|
14
|
+
"""
|
|
15
|
+
flag: ``--use-base-env, -E``
|
|
16
|
+
|
|
17
|
+
Use the current environment in addition to the values specified in the websocket request.
|
|
18
|
+
"""
|
|
19
|
+
cert_path: Path = Path(
|
|
20
|
+
environ.get("PSYNC_SSL_CERT_PATH", "~/.local/share/psync/cert.pem")
|
|
21
|
+
).expanduser()
|
|
22
|
+
"""
|
|
23
|
+
environ: ``PSYNC_SSL_CERT_PATH``
|
|
24
|
+
|
|
25
|
+
Path to the SSL certificate used to authenticate this server.
|
|
26
|
+
"""
|
|
27
|
+
key_path: Path = Path(
|
|
28
|
+
environ.get("PSYNC_SSL_KEY_PATH", "~/.local/share/psync/key.pem")
|
|
29
|
+
).expanduser()
|
|
30
|
+
"""
|
|
31
|
+
environ: ``PSYNC_SSL_KEY_PATH``
|
|
32
|
+
|
|
33
|
+
Path to the SSL private key.
|
|
34
|
+
"""
|
|
35
|
+
host: str = environ.get("PSYNC_SERVER_IP", "0.0.0.0")
|
|
36
|
+
"""
|
|
37
|
+
environ: ``PSYNC_SERVER_IP``
|
|
38
|
+
|
|
39
|
+
Host IP on which to listen for incoming connections.
|
|
40
|
+
"""
|
|
41
|
+
port: str = environ.get("PSYNC_SERVER_PORT", "5000")
|
|
42
|
+
"""
|
|
43
|
+
environ: ``PSYNC_SERVER_PORT``
|
|
44
|
+
|
|
45
|
+
Host port on which to listen for incoming connections.
|
|
46
|
+
"""
|
|
47
|
+
origins: list[str] = field(
|
|
48
|
+
default_factory=lambda: environ.get(
|
|
49
|
+
"PSYNC_ORIGINS", "localhost 127.0.0.1"
|
|
50
|
+
).split()
|
|
51
|
+
)
|
|
52
|
+
"""
|
|
53
|
+
environ: ``PSYNC_ORIGINS``
|
|
54
|
+
|
|
55
|
+
Accepted client origins. Should match the HTTP Origin header.
|
|
56
|
+
"""
|
|
57
|
+
log_level: str = environ.get("PSYNC_LOG_LEVEL", "INFO").upper()
|
|
58
|
+
"""
|
|
59
|
+
environ: ``PSYNC_LOG_LEVEL``
|
|
60
|
+
|
|
61
|
+
Log level.
|
|
62
|
+
"""
|
|
63
|
+
user: str | None = environ.get("PSYNC_USER", None)
|
|
64
|
+
"""
|
|
65
|
+
environ: ``PSYNC_USER``
|
|
66
|
+
|
|
67
|
+
User used to execute the requested binaries.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
parser = argparse.ArgumentParser(
|
|
72
|
+
prog="psync-server",
|
|
73
|
+
usage="""\
|
|
74
|
+
Server for project syncrhonization.
|
|
75
|
+
|
|
76
|
+
In addition to the options below, the client is configurable through environment
|
|
77
|
+
variables.
|
|
78
|
+
|
|
79
|
+
SSL_CERT_PATH - Path to SSL cert
|
|
80
|
+
Default: ./cert.pem
|
|
81
|
+
SSL_KEY_PATH - Path to SSL key
|
|
82
|
+
Default: ./key.pem
|
|
83
|
+
PSYNC_SERVER_IP - IP address on which to listen
|
|
84
|
+
Default: 0.0.0.0
|
|
85
|
+
PSYNC_SERVER_PORT - Port on which to listen
|
|
86
|
+
Default: 5000
|
|
87
|
+
PSYNC_ORIGINS - Space-separated list of accepted incoming IP addresses
|
|
88
|
+
Default: "127.0.0.1 localhost"
|
|
89
|
+
PSYNC_LOG - Log level
|
|
90
|
+
Default: "INFO"
|
|
91
|
+
PSYNC_USER - User to run the synced executables. Try not to use root.
|
|
92
|
+
Default: None (current user)
|
|
93
|
+
""",
|
|
94
|
+
)
|
|
95
|
+
_action = parser.add_argument(
|
|
96
|
+
"--use-base-env",
|
|
97
|
+
"-E",
|
|
98
|
+
help="Use the current environment in addition to the requested values.",
|
|
99
|
+
action="store_true",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_args() -> Args:
|
|
104
|
+
args = vars(parser.parse_args())
|
|
105
|
+
return Args(
|
|
106
|
+
use_base_env=args["use_base_env"], # pyright: ignore[reportAny]
|
|
107
|
+
)
|
|
@@ -17,11 +17,10 @@ from typing import Callable
|
|
|
17
17
|
from websockets import (
|
|
18
18
|
ConnectionClosedError,
|
|
19
19
|
ConnectionClosedOK,
|
|
20
|
-
Request,
|
|
21
|
-
Response,
|
|
22
20
|
ServerConnection,
|
|
23
21
|
)
|
|
24
22
|
from websockets.asyncio.server import serve
|
|
23
|
+
from websockets.typing import Origin
|
|
25
24
|
from common.data import (
|
|
26
25
|
ErrorResp,
|
|
27
26
|
ExitResp,
|
|
@@ -29,13 +28,14 @@ from common.data import (
|
|
|
29
28
|
LogResp,
|
|
30
29
|
OkayResp,
|
|
31
30
|
OpenReq,
|
|
31
|
+
SetPidResp,
|
|
32
|
+
HealthCheckReq,
|
|
32
33
|
serialize,
|
|
33
34
|
deserialize,
|
|
34
35
|
)
|
|
35
36
|
import logging
|
|
36
37
|
from common.log import InterceptHandler
|
|
37
38
|
from server.args import (
|
|
38
|
-
PSYNC_LOG,
|
|
39
39
|
Args,
|
|
40
40
|
parse_args,
|
|
41
41
|
)
|
|
@@ -45,6 +45,10 @@ pprint = PrettyPrinter().pformat
|
|
|
45
45
|
|
|
46
46
|
@dataclass
|
|
47
47
|
class PTask:
|
|
48
|
+
"""
|
|
49
|
+
Simple wrapper class for task-based process execution.
|
|
50
|
+
"""
|
|
51
|
+
|
|
48
52
|
task: Task[None]
|
|
49
53
|
process: Process
|
|
50
54
|
|
|
@@ -52,34 +56,20 @@ class PTask:
|
|
|
52
56
|
class PsyncServer:
|
|
53
57
|
"""
|
|
54
58
|
The main interface for the psync websocker server.
|
|
55
|
-
|
|
56
|
-
Configuration environment variables:
|
|
57
|
-
PSYNC_HOST: IP for the server.
|
|
58
|
-
Default: "0.0.0.0"
|
|
59
|
-
PSYNC_PORT: Port for the server.
|
|
60
|
-
Default: 5000
|
|
61
|
-
PSYNC_ORIGINS: Space-separated list of allowed foreign origins.
|
|
62
|
-
Default: "localhost"
|
|
63
|
-
SSL_CERT_PATH: Path to the SSL certification file.
|
|
64
|
-
Default: "./cert.pem"
|
|
65
|
-
SSL_KEY_PATH: Path to the SSL key file.
|
|
66
|
-
Default: "./key.pem"
|
|
67
|
-
|
|
68
|
-
.. _logger: https://docs.python.org/3/library/logging.html#logging-levels
|
|
69
59
|
"""
|
|
70
60
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
__tasks: dict[str, PTask] = {}
|
|
74
|
-
"""Active sessions. A dict of IP addresses and the running log task. IP
|
|
75
|
-
addresses _must_ match those in origins."""
|
|
61
|
+
args: Args
|
|
76
62
|
|
|
63
|
+
__tasks: dict[str, dict[int, PTask]] = {}
|
|
64
|
+
"""{[host: str]: {[pid: str]: PTask} }"""
|
|
77
65
|
__coroutine: Task[None] | None = None
|
|
78
66
|
"""The main coroutine for this server."""
|
|
79
67
|
|
|
68
|
+
__force_shutdown: bool = False
|
|
69
|
+
|
|
80
70
|
def __init__(self, args: Args):
|
|
81
71
|
logging.debug(pprint(args))
|
|
82
|
-
self.
|
|
72
|
+
self.args = args
|
|
83
73
|
|
|
84
74
|
def __get_host(self, ws: ServerConnection) -> str:
|
|
85
75
|
addrs: tuple[str, str] = ws.remote_address # pyright: ignore[reportAny]
|
|
@@ -92,16 +82,16 @@ class PsyncServer:
|
|
|
92
82
|
"""
|
|
93
83
|
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
94
84
|
ssl_ctx.load_cert_chain(
|
|
95
|
-
pathlib.Path(self.
|
|
96
|
-
pathlib.Path(self.
|
|
85
|
+
pathlib.Path(self.args.cert_path).expanduser(),
|
|
86
|
+
pathlib.Path(self.args.key_path).expanduser(),
|
|
97
87
|
)
|
|
98
88
|
logging.debug(pprint(ssl_ctx.get_ca_certs()))
|
|
99
89
|
server = await serve(
|
|
100
90
|
(self.__handle()),
|
|
101
|
-
self.
|
|
102
|
-
self.
|
|
103
|
-
process_request=self.__process_request(),
|
|
91
|
+
self.args.host,
|
|
92
|
+
int(self.args.port),
|
|
104
93
|
ssl=ssl_ctx,
|
|
94
|
+
origins=list(map(lambda x: Origin(f"wss://{x}"), self.args.origins)),
|
|
105
95
|
)
|
|
106
96
|
self.__coroutine = asyncio.create_task(server.serve_forever())
|
|
107
97
|
try:
|
|
@@ -111,17 +101,6 @@ class PsyncServer:
|
|
|
111
101
|
logging.info(f"Got error {e}")
|
|
112
102
|
pass
|
|
113
103
|
|
|
114
|
-
def __process_request(
|
|
115
|
-
self,
|
|
116
|
-
) -> Callable[[ServerConnection, Request], Response | None]:
|
|
117
|
-
def inner(ws: ServerConnection, _req: Request) -> Response | None:
|
|
118
|
-
addrs: tuple[str, str] = ws.remote_address # pyright: ignore[reportAny]
|
|
119
|
-
(host, _port) = addrs
|
|
120
|
-
if host not in self.__args.origins:
|
|
121
|
-
return ws.respond(400, "Client address not recognized.")
|
|
122
|
-
|
|
123
|
-
return inner
|
|
124
|
-
|
|
125
104
|
async def __end_session(self, ws: ServerConnection):
|
|
126
105
|
host = self.__get_host(ws)
|
|
127
106
|
try:
|
|
@@ -132,10 +111,16 @@ class PsyncServer:
|
|
|
132
111
|
|
|
133
112
|
def __mk_handle_signal(self, ws: ServerConnection):
|
|
134
113
|
async def inner():
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
114
|
+
if not self.__force_shutdown:
|
|
115
|
+
logging.info("Gracefully shutting down...")
|
|
116
|
+
self.__force_shutdown = True
|
|
117
|
+
await ws.close()
|
|
118
|
+
_ = self.__coroutine.cancel() # pyright: ignore[reportOptionalMemberAccess]
|
|
119
|
+
asyncio.get_event_loop().stop()
|
|
120
|
+
raise SystemExit(130)
|
|
121
|
+
else:
|
|
122
|
+
logging.warning("Second Ctrl-C detected, forcing shutdown.")
|
|
123
|
+
raise SystemExit(1)
|
|
139
124
|
|
|
140
125
|
return lambda: asyncio.create_task(inner())
|
|
141
126
|
|
|
@@ -163,6 +148,10 @@ class PsyncServer:
|
|
|
163
148
|
await self.__open(req, ws)
|
|
164
149
|
case KillReq():
|
|
165
150
|
await self.__kill(req, ws)
|
|
151
|
+
case HealthCheckReq():
|
|
152
|
+
logging.info("Health check OK")
|
|
153
|
+
await ws.send(serialize(OkayResp()))
|
|
154
|
+
await ws.close()
|
|
166
155
|
case _:
|
|
167
156
|
logging.warning(f"Got unknown request {req}")
|
|
168
157
|
except ConnectionClosedOK:
|
|
@@ -176,23 +165,15 @@ class PsyncServer:
|
|
|
176
165
|
|
|
177
166
|
async def __open(self, req: OpenReq, ws: ServerConnection):
|
|
178
167
|
host = self.__get_host(ws)
|
|
179
|
-
if self.__tasks.get(host) is not None:
|
|
180
|
-
ptask = self.__tasks[host]
|
|
181
|
-
logging.warn(
|
|
182
|
-
f"Cancelling previous task for host {host} (PID {ptask.process.pid})"
|
|
183
|
-
)
|
|
184
|
-
await ptask.process.kill()
|
|
185
|
-
await ptask.task.cancel()
|
|
186
|
-
self.__tasks.pop(host)
|
|
187
168
|
path = pathlib.Path.expanduser(req.path).resolve()
|
|
188
169
|
args = [str(path), *req.args]
|
|
189
|
-
env = req.env if not self.
|
|
170
|
+
env = req.env if not self.args.use_base_env else {**environ, **req.env}
|
|
190
171
|
|
|
191
172
|
info_log = f"Running `[...]/{basename(args[0])} {' '.join(args[1:])}`..."
|
|
192
|
-
if env != {}
|
|
173
|
+
if env != {}:
|
|
193
174
|
info_log += f"\n... with env {pprint(env)}"
|
|
194
|
-
if self.
|
|
195
|
-
info_log += f"... as user {self.
|
|
175
|
+
if self.args.user is not None:
|
|
176
|
+
info_log += f"... as user {self.args.user}"
|
|
196
177
|
|
|
197
178
|
logging.info(info_log)
|
|
198
179
|
|
|
@@ -202,14 +183,21 @@ class PsyncServer:
|
|
|
202
183
|
env=env,
|
|
203
184
|
stdout=asyncio.subprocess.PIPE,
|
|
204
185
|
stderr=asyncio.subprocess.STDOUT,
|
|
205
|
-
user=self.
|
|
186
|
+
user=self.args.user,
|
|
206
187
|
)
|
|
207
|
-
|
|
208
|
-
|
|
188
|
+
task = PTask(asyncio.create_task(self.__log(ws, p)), p)
|
|
189
|
+
|
|
190
|
+
if self.__tasks.get(host) is None:
|
|
191
|
+
self.__tasks[host] = {p.pid: task}
|
|
192
|
+
else:
|
|
193
|
+
self.__tasks[host][p.pid] = task
|
|
194
|
+
|
|
195
|
+
resp = SetPidResp(pid=p.pid)
|
|
209
196
|
await ws.send(serialize(resp))
|
|
197
|
+
|
|
210
198
|
except Exception as e:
|
|
211
|
-
logging.error(f"Failed to
|
|
212
|
-
resp = ErrorResp(
|
|
199
|
+
logging.error(f"Failed to start process `{args[0]}` with error {e}")
|
|
200
|
+
resp = ErrorResp(f"Server error: {e}")
|
|
213
201
|
await ws.send(serialize(resp))
|
|
214
202
|
|
|
215
203
|
async def __log(self, ws: ServerConnection, process: asyncio.subprocess.Process):
|
|
@@ -232,33 +220,41 @@ class PsyncServer:
|
|
|
232
220
|
except (KeyError, ConnectionClosedError, ConnectionClosedOK):
|
|
233
221
|
pass
|
|
234
222
|
|
|
235
|
-
async def __kill(self,
|
|
223
|
+
async def __kill(self, req: KillReq, ws: ServerConnection):
|
|
236
224
|
host = self.__get_host(ws)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
logging.error(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
225
|
+
tasks = self.__tasks.get(host)
|
|
226
|
+
if tasks is None:
|
|
227
|
+
msg = f"Tried to kill process for host {host}, but no process was running."
|
|
228
|
+
logging.error(msg)
|
|
229
|
+
await ws.send(serialize(ErrorResp(msg)))
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
task = tasks.get(req.pid)
|
|
233
|
+
if task is None:
|
|
234
|
+
msg = f"Tried to kill process {req.pid}, but it was not found."
|
|
235
|
+
logging.error(msg)
|
|
236
|
+
await ws.send(serialize(ErrorResp(msg)))
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
process = task.process
|
|
240
|
+
logging.info(f"Killing PID {process.pid}")
|
|
241
|
+
process.kill()
|
|
242
|
+
code = await process.wait()
|
|
243
|
+
resp = ExitResp(str(code))
|
|
244
|
+
await ws.send(serialize(resp))
|
|
255
245
|
await ws.close()
|
|
256
246
|
|
|
257
247
|
|
|
258
|
-
def main():
|
|
248
|
+
def main(args: Args | None = None):
|
|
259
249
|
"""Run the server as an executable."""
|
|
260
|
-
|
|
261
|
-
|
|
250
|
+
args = parse_args() if args is None else args
|
|
251
|
+
logging.basicConfig(handlers=[InterceptHandler()], level=args.log_level, force=True)
|
|
252
|
+
try:
|
|
253
|
+
asyncio.run(PsyncServer(args).serve())
|
|
254
|
+
except SystemExit as e:
|
|
255
|
+
exit(e.code)
|
|
256
|
+
except Exception:
|
|
257
|
+
exit(1)
|
|
262
258
|
|
|
263
259
|
|
|
264
260
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import multiprocessing
|
|
3
|
+
import os
|
|
4
|
+
from os.path import basename
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from signal import Signals
|
|
8
|
+
|
|
9
|
+
from testcontainers.core.generic import DockerContainer # pyright: ignore[reportMissingTypeStubs]
|
|
10
|
+
|
|
11
|
+
from client.args import Args as ClientArgs
|
|
12
|
+
from client.main import PsyncClient
|
|
13
|
+
from client.main import __rsync as rsync # pyright: ignore[reportPrivateUsage]
|
|
14
|
+
from test.conftest import assets_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def template(args: ClientArgs, server: DockerContainer, kill: bool = False):
|
|
18
|
+
try:
|
|
19
|
+
rsync(args)
|
|
20
|
+
exec_result = server.exec(["ls", str(args.destination_path())])
|
|
21
|
+
print(f"ls {args.destination_path()}\n --- \n {exec_result.output}")
|
|
22
|
+
assert exec_result.output.decode().__contains__(basename(args.target_path))
|
|
23
|
+
client = PsyncClient(args)
|
|
24
|
+
|
|
25
|
+
def run(code: int):
|
|
26
|
+
try:
|
|
27
|
+
asyncio.run(client.run())
|
|
28
|
+
except SystemExit as e:
|
|
29
|
+
assert str(e.code) == str(code)
|
|
30
|
+
|
|
31
|
+
if not kill:
|
|
32
|
+
run(0)
|
|
33
|
+
else:
|
|
34
|
+
p = multiprocessing.Process(target=run, args=[130])
|
|
35
|
+
p.start()
|
|
36
|
+
while p.pid is None:
|
|
37
|
+
pass
|
|
38
|
+
asyncio.run(asyncio.sleep(1))
|
|
39
|
+
os.kill(p.pid, Signals.SIGINT)
|
|
40
|
+
p.join(3)
|
|
41
|
+
|
|
42
|
+
# check that the pid closed
|
|
43
|
+
stdout, stderr = server.get_logs()
|
|
44
|
+
pat = re.compile(r"Running process with PID (\d+)")
|
|
45
|
+
res = pat.search(stderr.decode())
|
|
46
|
+
if res is None:
|
|
47
|
+
raise Exception("Could not get PID from stdout!")
|
|
48
|
+
pid = res.group(1)
|
|
49
|
+
|
|
50
|
+
exec_result = server.exec(
|
|
51
|
+
["sh", "-c", f"ps -p {pid} > /dev/null; echo $?"],
|
|
52
|
+
)
|
|
53
|
+
assert exec_result.output.decode().strip() == "1"
|
|
54
|
+
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f"Got exception:\n {e}", file=sys.stderr)
|
|
57
|
+
stdout, stderr = server.get_logs()
|
|
58
|
+
print(
|
|
59
|
+
f"Server logs:\n--- stdout ---\n{stdout.decode()}\n--- stderr ---\n{stderr.decode()}",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
assert False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_test_args(file: str, server: DockerContainer):
|
|
66
|
+
return ClientArgs(
|
|
67
|
+
target_path=assets_path.joinpath(file).__str__(),
|
|
68
|
+
ssh_args=f"-i {(assets_path / 'ssh-key').resolve()} -l psync -o StrictHostKeyChecking=no",
|
|
69
|
+
ssl_cert_path=(assets_path / "cert.pem").resolve().__str__(),
|
|
70
|
+
server_ip="127.0.0.1",
|
|
71
|
+
server_port=server.get_exposed_port(5000),
|
|
72
|
+
server_ssh_port=server.get_exposed_port(5022),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_basic(server: DockerContainer):
|
|
77
|
+
args = get_test_args("example_basic.py", server)
|
|
78
|
+
template(args, server)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_sigint(server: DockerContainer):
|
|
82
|
+
args = get_test_args("example.py", server)
|
|
83
|
+
template(args, server, True)
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import argparse
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
import logging
|
|
4
|
-
import os
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
import shlex
|
|
7
|
-
from common.data import deserialize_env
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass
|
|
11
|
-
class Args:
|
|
12
|
-
target_path: str
|
|
13
|
-
extra: list[str]
|
|
14
|
-
env: dict[str, str]
|
|
15
|
-
args: list[str]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
parser = argparse.ArgumentParser(
|
|
19
|
-
prog="psync-client",
|
|
20
|
-
usage="""\
|
|
21
|
-
Client for the psync server.
|
|
22
|
-
|
|
23
|
-
In addition to the options below, the client is configurable through environment
|
|
24
|
-
variables.
|
|
25
|
-
|
|
26
|
-
Variable | Default
|
|
27
|
-
------------------+-------------------------------
|
|
28
|
-
PSYNC_SERVER_IP | 127.0.0.1
|
|
29
|
-
PSYNC_SERVER_PORT | 5000
|
|
30
|
-
PSYNC_SSH_PORT | 5022
|
|
31
|
-
PSYNC_SERVER_DEST | /home/psync/
|
|
32
|
-
PSYNC_SSH_USER | psync
|
|
33
|
-
PSYNC_CERT_PATH | ~/.local/share/psync/cert.pem
|
|
34
|
-
""",
|
|
35
|
-
)
|
|
36
|
-
_action = parser.add_argument(
|
|
37
|
-
"--path",
|
|
38
|
-
"-p",
|
|
39
|
-
required=True,
|
|
40
|
-
help="Path to the target exectuable.",
|
|
41
|
-
)
|
|
42
|
-
_action = parser.add_argument(
|
|
43
|
-
"--extra",
|
|
44
|
-
"-E",
|
|
45
|
-
nargs="+",
|
|
46
|
-
help="Extra files or directories to be synced to the destination path.",
|
|
47
|
-
)
|
|
48
|
-
_action = parser.add_argument(
|
|
49
|
-
"--env",
|
|
50
|
-
"-e",
|
|
51
|
-
help="Environment variables to set in the remote execution environment. Variables must be space-sepated or double-quoted.",
|
|
52
|
-
)
|
|
53
|
-
_action = parser.add_argument(
|
|
54
|
-
"--args", "-a", help="Arguments with which to run the remote executable."
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
SERVER_IP: str = os.environ.get("PSYNC_SERVER_IP", "127.0.0.1")
|
|
58
|
-
SERVER_PORT: int = int(os.environ.get("PSYNC_SERVER_PORT", "5000"))
|
|
59
|
-
SERVER_SSH_PORT: int = int(os.environ.get("PSYNC_SSH_PORT", "5022"))
|
|
60
|
-
SERVER_DEST: str = os.environ.get("PSYNC_SERVER_DEST", "/home/psync")
|
|
61
|
-
USER: str = os.environ.get("PSYNC_SSH_USER", "psync")
|
|
62
|
-
SSL_CERT_PATH: str = os.environ.get("PSYNC_CERT_PATH", "~/.local/share/psync/cert.pem")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def parse_args() -> Args:
|
|
66
|
-
args = vars(parser.parse_args())
|
|
67
|
-
|
|
68
|
-
target_path = str(args.get("path"))
|
|
69
|
-
target_path = Path(target_path)
|
|
70
|
-
if not target_path.is_file():
|
|
71
|
-
logging.error(f"Could not file at {target_path}")
|
|
72
|
-
exit(1)
|
|
73
|
-
|
|
74
|
-
extra: list[str] = []
|
|
75
|
-
extra_raw = args.get("extra")
|
|
76
|
-
if extra_raw is not None:
|
|
77
|
-
extra = extra_raw # pyright: ignore[reportAny]
|
|
78
|
-
|
|
79
|
-
client_args: list[str] = []
|
|
80
|
-
raw_args = args.get("args")
|
|
81
|
-
if raw_args is not None:
|
|
82
|
-
client_args = shlex.split(str(raw_args)) # pyright: ignore[reportAny]
|
|
83
|
-
|
|
84
|
-
env: dict[str, str] = dict()
|
|
85
|
-
raw_env = args.get("env")
|
|
86
|
-
if raw_env is not None:
|
|
87
|
-
env = deserialize_env(f"env='{raw_env}'")
|
|
88
|
-
|
|
89
|
-
return Args(
|
|
90
|
-
target_path=str(target_path),
|
|
91
|
-
extra=extra or [],
|
|
92
|
-
env=env,
|
|
93
|
-
args=client_args,
|
|
94
|
-
)
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import argparse
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
from os import environ
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
SSL_CERT_PATH: str = environ.get("SSL_CERT_PATH", "~/.local/share/psync/cert.pem")
|
|
7
|
-
SSL_KEY_PATH: str = environ.get("SSL_KEY_PATH", "~/.local/share/psync/key.pem")
|
|
8
|
-
PSYNC_HOST: str = environ.get("PSYNC_SERVER_IP", "0.0.0.0")
|
|
9
|
-
PSYNC_PORT: str = environ.get("PSYNC_SERVER_PORT", "5000")
|
|
10
|
-
PSYNC_ORIGINS: str = environ.get("PSYNC_ORIGINS", "localhost 127.0.0.1")
|
|
11
|
-
PSYNC_LOG: str = environ.get("PSYNC_LOG", "INFO").upper()
|
|
12
|
-
PSYNC_USER: str | None = environ.get("PSYNC_USER", None)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@dataclass
|
|
16
|
-
class Args:
|
|
17
|
-
use_base_env: bool
|
|
18
|
-
host: str
|
|
19
|
-
port: str
|
|
20
|
-
origins: list[str]
|
|
21
|
-
user: str | None
|
|
22
|
-
cert_path: Path
|
|
23
|
-
key_path: Path
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
parser = argparse.ArgumentParser(
|
|
27
|
-
prog="psync-server",
|
|
28
|
-
usage="""\
|
|
29
|
-
Server for project syncrhonization.
|
|
30
|
-
|
|
31
|
-
In addition to the options below, the client is configurable through environment
|
|
32
|
-
variables.
|
|
33
|
-
|
|
34
|
-
SSL_CERT_PATH - Path to SSL cert
|
|
35
|
-
Default: ./cert.pem
|
|
36
|
-
SSL_KEY_PATH - Path to SSL key
|
|
37
|
-
Default: ./key.pem
|
|
38
|
-
PSYNC_SERVER_IP - IP address on which to listen
|
|
39
|
-
Default: 0.0.0.0
|
|
40
|
-
PSYNC_SERVER_PORT - Port on which to listen
|
|
41
|
-
Default: 5000
|
|
42
|
-
PSYNC_ORIGINS - Space-separated list of accepted incoming IP addresses
|
|
43
|
-
Default: "127.0.0.1 localhost"
|
|
44
|
-
PSYNC_LOG - Log level
|
|
45
|
-
Default: "INFO"
|
|
46
|
-
PSYNC_USER - User to run the synced executables. Try not to use root.
|
|
47
|
-
Default: None (current user)
|
|
48
|
-
""",
|
|
49
|
-
)
|
|
50
|
-
_action = parser.add_argument(
|
|
51
|
-
"--use-base-env",
|
|
52
|
-
"-E",
|
|
53
|
-
help="Use the current environment in addition to the requested values.",
|
|
54
|
-
action="store_true",
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def parse_args() -> Args:
|
|
59
|
-
args = vars(parser.parse_args())
|
|
60
|
-
return Args(
|
|
61
|
-
use_base_env=args["use_base_env"],
|
|
62
|
-
host=PSYNC_HOST,
|
|
63
|
-
port=PSYNC_PORT,
|
|
64
|
-
origins=PSYNC_ORIGINS.split(),
|
|
65
|
-
user=PSYNC_USER,
|
|
66
|
-
cert_path=Path(SSL_CERT_PATH).expanduser(),
|
|
67
|
-
key_path=Path(SSL_KEY_PATH).expanduser(),
|
|
68
|
-
)
|
|
69
|
-
print(args)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/requires.txt
RENAMED
|
File without changes
|
{cubething_psync-0.2.1.dev1 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|