cubething_psync 0.2.1.dev2__tar.gz → 0.4.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.dev2/src/cubething_psync.egg-info → cubething_psync-0.4.0}/PKG-INFO +1 -1
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/pyproject.toml +18 -1
- cubething_psync-0.4.0/src/client/args.py +207 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/client/main.py +71 -64
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/common/data.py +44 -22
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0/src/cubething_psync.egg-info}/PKG-INFO +1 -1
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/cubething_psync.egg-info/SOURCES.txt +2 -1
- cubething_psync-0.4.0/src/server/args.py +107 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/server/main.py +91 -87
- cubething_psync-0.4.0/test/test_integration.py +153 -0
- cubething_psync-0.2.1.dev2/src/client/args.py +0 -94
- cubething_psync-0.2.1.dev2/src/server/args.py +0 -69
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/LICENSE +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/README.md +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/setup.cfg +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/client/__init__.py +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/client/__main__.py +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/common/__init__.py +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/common/log.py +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/cubething_psync.egg-info/dependency_links.txt +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/cubething_psync.egg-info/entry_points.txt +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/cubething_psync.egg-info/requires.txt +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/cubething_psync.egg-info/top_level.txt +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.0}/src/server/__init__.py +0 -0
- {cubething_psync-0.2.1.dev2 → cubething_psync-0.4.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.4.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,23 @@ 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"]
|
|
44
|
+
|
|
45
|
+
[tool.basedpyright]
|
|
46
|
+
ignore=["**/__pycache__", "build", "dist"]
|
|
47
|
+
include=["src", "test"]
|
|
48
|
+
failOnWarnings=false
|
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from _typeshed import SupportsWrite
|
|
13
|
+
Logfile = SupportsWrite[str] | Path | None
|
|
14
|
+
else:
|
|
15
|
+
Logfile = Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Args:
|
|
20
|
+
"""
|
|
21
|
+
Client arguments.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
target_path: str
|
|
25
|
+
"""
|
|
26
|
+
``--path -p <target_path>``
|
|
27
|
+
|
|
28
|
+
**Required.** Path to the target executable.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
assets: list[str] = field(default_factory=list)
|
|
32
|
+
"""
|
|
33
|
+
``--assets -A <env>``
|
|
34
|
+
|
|
35
|
+
Extra files or directories to be synced to the destination path.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
39
|
+
"""
|
|
40
|
+
``--env -e <env>``
|
|
41
|
+
|
|
42
|
+
Space separated list of environment variables which will be passed to the executable.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
args: list[str] = field(default_factory=list)
|
|
46
|
+
"""
|
|
47
|
+
``--args -a <args>``
|
|
48
|
+
|
|
49
|
+
Arguments passed to the executable.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
server_ip: str = os.environ.get("PSYNC_SERVER_IP", "127.0.0.1")
|
|
53
|
+
"""
|
|
54
|
+
environ: ``PSYNC_SERVER_IP``
|
|
55
|
+
|
|
56
|
+
Server IP address.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
server_port: int = int(os.environ.get("PSYNC_SERVER_PORT", "5000"))
|
|
60
|
+
"""
|
|
61
|
+
environ: ``PSYNC_SERVER_PORT``
|
|
62
|
+
|
|
63
|
+
Server port.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
server_ssh_port: int = int(os.environ.get("PSYNC_SSH_PORT", "5022"))
|
|
67
|
+
"""
|
|
68
|
+
environ: ``PSYNC_SSH_PORT``
|
|
69
|
+
|
|
70
|
+
SSH port on the server host. Client must be authenticated with a shared public key.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
ssh_args: str = os.environ.get("PSYNC_SSH_ARGS", "-l psync")
|
|
74
|
+
"""
|
|
75
|
+
environ: ``PSYNC_SSH_ARGS``
|
|
76
|
+
|
|
77
|
+
Arguments passed to SSH. Under the hood, psync runs
|
|
78
|
+
``rsync -e "/usr/bin/ssh {PSYNC_SSH_ARGS} -p {PSYNC_SSH_PORT}"``
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
server_dest: str = os.environ.get("PSYNC_SERVER_DEST", "/home/psync")
|
|
82
|
+
"""
|
|
83
|
+
environ: ``PSYNC_SERVER_DEST``
|
|
84
|
+
|
|
85
|
+
Base path on the server where the files should be synced.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
ssl_cert_path: str = os.environ.get(
|
|
89
|
+
"PSYNC_CERT_PATH", "~/.local/share/psync/cert.pem"
|
|
90
|
+
)
|
|
91
|
+
"""
|
|
92
|
+
environ: ``PSYNC_CERT_PATH``
|
|
93
|
+
|
|
94
|
+
Public SSL certificate used to trust the psync server.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
client_origin: str = os.environ.get("PSYNC_CLIENT_ORIGIN", "127.0.0.1")
|
|
98
|
+
"""
|
|
99
|
+
environ: ``PSYNC_CLIENT_ORIGIN``
|
|
100
|
+
|
|
101
|
+
Domain name. Should match the origins set in the server's ``PSYNC_ORIGINS``
|
|
102
|
+
variable.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
logfile: Logfile = None
|
|
106
|
+
"""
|
|
107
|
+
environ: ``PSYNC_LOG_FILE``
|
|
108
|
+
|
|
109
|
+
Optional file where the executable's logs will be output.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def project_hash(self) -> str:
|
|
113
|
+
"""
|
|
114
|
+
Hash value generated from the target path. Used as the directory name for the project.
|
|
115
|
+
"""
|
|
116
|
+
return hashlib.blake2s(self.target_path.encode(), digest_size=8).hexdigest()
|
|
117
|
+
|
|
118
|
+
def rsync_url(self) -> str:
|
|
119
|
+
"""
|
|
120
|
+
{server_ip}:{server_dest}/{project_hash}
|
|
121
|
+
"""
|
|
122
|
+
return f"{self.server_ip}:{self.server_dest}/{self.project_hash()}/"
|
|
123
|
+
|
|
124
|
+
def destination_path(self) -> Path:
|
|
125
|
+
"""
|
|
126
|
+
{server_dest}/{project_hash}/{basename(target_path)}
|
|
127
|
+
"""
|
|
128
|
+
return Path(self.server_dest) / self.project_hash() / basename(self.target_path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
parser = argparse.ArgumentParser(
|
|
132
|
+
prog="psync-client",
|
|
133
|
+
usage="""\
|
|
134
|
+
Client for the psync server.
|
|
135
|
+
|
|
136
|
+
In addition to the options below, the client is configurable through environment
|
|
137
|
+
variables.
|
|
138
|
+
|
|
139
|
+
Variable | Default
|
|
140
|
+
--------------------+-------------------------------
|
|
141
|
+
PSYNC_SERVER_IP | 127.0.0.1
|
|
142
|
+
PSYNC_SERVER_PORT | 5000
|
|
143
|
+
PSYNC_SSH_PORT | 5022
|
|
144
|
+
PSYNC_SERVER_DEST | /home/psync/
|
|
145
|
+
PSYNC_SSH_ARGS | -l psync
|
|
146
|
+
PSYNC_CERT_PATH | ~/.local/share/psync/cert.pem
|
|
147
|
+
PSYNC_CLIENT_ORIGIN | 127.0.0.1
|
|
148
|
+
PSYNC_LOG_FILE | None (stdout)
|
|
149
|
+
|
|
150
|
+
SSH arguments will be append with "-p {PSYNC_SSH_PORT}"
|
|
151
|
+
|
|
152
|
+
For more info, please read the docs:
|
|
153
|
+
https://psync.readthedocs.io/
|
|
154
|
+
""",
|
|
155
|
+
)
|
|
156
|
+
_action = parser.add_argument(
|
|
157
|
+
"--path",
|
|
158
|
+
"-p",
|
|
159
|
+
required=True,
|
|
160
|
+
help="Path to the target exectuable.",
|
|
161
|
+
)
|
|
162
|
+
_action = parser.add_argument(
|
|
163
|
+
"--assets",
|
|
164
|
+
"-A",
|
|
165
|
+
nargs="+",
|
|
166
|
+
help="Extra files or directories to be synced to the destination path.",
|
|
167
|
+
)
|
|
168
|
+
_action = parser.add_argument(
|
|
169
|
+
"--env",
|
|
170
|
+
"-e",
|
|
171
|
+
help="Environment variables to set in the remote execution environment. Variables must be space-sepated or double-quoted.",
|
|
172
|
+
)
|
|
173
|
+
_action = parser.add_argument(
|
|
174
|
+
"--args", "-a", help="Arguments passed to the executable."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_args() -> Args:
|
|
179
|
+
args = vars(parser.parse_args())
|
|
180
|
+
|
|
181
|
+
target_path = str(args.get("path"))
|
|
182
|
+
target_path = Path(target_path)
|
|
183
|
+
if not target_path.is_file():
|
|
184
|
+
logging.error(f"Could not file at {target_path}")
|
|
185
|
+
exit(1)
|
|
186
|
+
|
|
187
|
+
extra: list[str] = []
|
|
188
|
+
extra_raw = args.get("extra")
|
|
189
|
+
if extra_raw is not None:
|
|
190
|
+
extra = extra_raw # pyright: ignore[reportAny]
|
|
191
|
+
|
|
192
|
+
client_args: list[str] = []
|
|
193
|
+
raw_args = args.get("args")
|
|
194
|
+
if raw_args is not None:
|
|
195
|
+
client_args = shlex.split(str(raw_args)) # pyright: ignore[reportAny]
|
|
196
|
+
|
|
197
|
+
env: dict[str, str] = dict()
|
|
198
|
+
raw_env = args.get("env")
|
|
199
|
+
if raw_env is not None:
|
|
200
|
+
env = deserialize_env(f"env='{raw_env}'")
|
|
201
|
+
|
|
202
|
+
return Args(
|
|
203
|
+
target_path=str(target_path),
|
|
204
|
+
assets=extra or [],
|
|
205
|
+
env=env,
|
|
206
|
+
args=client_args,
|
|
207
|
+
)
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
psync client
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from io import TextIOWrapper
|
|
6
|
+
import sys
|
|
5
7
|
import asyncio
|
|
6
|
-
import hashlib
|
|
7
8
|
import os
|
|
8
|
-
import
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
import signal
|
|
10
11
|
import ssl
|
|
11
12
|
import subprocess
|
|
12
13
|
import websockets
|
|
14
|
+
from websockets.typing import Origin
|
|
13
15
|
from common.data import (
|
|
14
16
|
ErrorResp,
|
|
15
17
|
ExitResp,
|
|
@@ -17,90 +19,92 @@ from common.data import (
|
|
|
17
19
|
LogResp,
|
|
18
20
|
OkayResp,
|
|
19
21
|
OpenReq,
|
|
22
|
+
SetPidResp,
|
|
20
23
|
deserialize,
|
|
21
24
|
serialize,
|
|
22
25
|
)
|
|
23
26
|
import logging
|
|
24
27
|
from common.log import InterceptHandler
|
|
25
28
|
from client.args import (
|
|
26
|
-
SERVER_IP,
|
|
27
|
-
SERVER_PORT,
|
|
28
|
-
SERVER_SSH_PORT,
|
|
29
|
-
USER,
|
|
30
|
-
SSL_CERT_PATH,
|
|
31
|
-
SERVER_DEST,
|
|
32
29
|
Args,
|
|
33
30
|
parse_args,
|
|
34
31
|
)
|
|
35
32
|
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from _typeshed import SupportsWrite
|
|
37
|
+
Logfile = SupportsWrite[str]
|
|
38
|
+
else:
|
|
39
|
+
Logfile = Any
|
|
40
|
+
|
|
36
41
|
|
|
37
42
|
class PsyncClient:
|
|
38
43
|
"""
|
|
39
44
|
The primary interface for psync. The client CLI allows users to sync files with
|
|
40
45
|
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
46
|
"""
|
|
64
47
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
__quit: bool = False
|
|
48
|
+
args: Args
|
|
49
|
+
pid: int | None = None
|
|
50
|
+
"""ID of the current connection."""
|
|
69
51
|
__force_exit: bool = False
|
|
52
|
+
__outfile: Logfile
|
|
53
|
+
|
|
54
|
+
def __init__(self, args: Args):
|
|
55
|
+
self.args = args
|
|
56
|
+
if isinstance(self.args.logfile, Path):
|
|
57
|
+
self.__outfile = open(self.args.logfile, "w")
|
|
58
|
+
elif self.args.logfile is not None:
|
|
59
|
+
self.__outfile = self.args.logfile
|
|
60
|
+
else:
|
|
61
|
+
self.__outfile = sys.stdout
|
|
70
62
|
|
|
71
|
-
def
|
|
72
|
-
self
|
|
73
|
-
|
|
74
|
-
|
|
63
|
+
def __enter__(self):
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self):
|
|
67
|
+
if isinstance(self.__outfile, TextIOWrapper):
|
|
68
|
+
self.__outfile.close()
|
|
75
69
|
|
|
76
70
|
def __mk_handler(self, ws: websockets.ClientConnection):
|
|
77
71
|
async def inner():
|
|
78
72
|
if not self.__force_exit:
|
|
79
73
|
logging.info("Gracefully shutting down...")
|
|
80
74
|
self.__force_exit = True
|
|
81
|
-
|
|
75
|
+
if self.pid is not None:
|
|
76
|
+
await ws.send(serialize(KillReq(pid=self.pid)))
|
|
82
77
|
await ws.close()
|
|
83
78
|
asyncio.get_event_loop().stop()
|
|
79
|
+
raise SystemExit(130)
|
|
84
80
|
else:
|
|
85
81
|
logging.warning("Got second SIGINT, shutting down")
|
|
86
82
|
asyncio.get_event_loop().stop()
|
|
87
|
-
raise
|
|
83
|
+
raise SystemExit(1)
|
|
88
84
|
|
|
89
85
|
return lambda: asyncio.create_task(inner())
|
|
90
86
|
|
|
91
87
|
async def run(self):
|
|
92
88
|
"""Run the client instance."""
|
|
93
89
|
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
94
|
-
ssl_ctx.load_verify_locations(
|
|
90
|
+
ssl_ctx.load_verify_locations(Path(self.args.ssl_cert_path).expanduser())
|
|
95
91
|
ssl_ctx.check_hostname = False # not ideal
|
|
96
92
|
async with websockets.connect(
|
|
97
|
-
f"wss://{
|
|
93
|
+
f"wss://{self.args.server_ip}:{self.args.server_port}",
|
|
94
|
+
ssl=ssl_ctx,
|
|
95
|
+
origin=Origin(f"wss://{self.args.client_origin}"),
|
|
98
96
|
) as ws:
|
|
99
97
|
asyncio.get_event_loop().add_signal_handler(
|
|
100
98
|
signal.SIGINT, self.__mk_handler(ws)
|
|
101
99
|
)
|
|
102
100
|
await ws.send(
|
|
103
|
-
serialize(
|
|
101
|
+
serialize(
|
|
102
|
+
OpenReq(
|
|
103
|
+
path=self.args.destination_path(),
|
|
104
|
+
env=self.args.env,
|
|
105
|
+
args=self.args.args,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
104
108
|
)
|
|
105
109
|
async for data in ws:
|
|
106
110
|
if isinstance(data, bytes):
|
|
@@ -115,47 +119,50 @@ class PsyncClient:
|
|
|
115
119
|
f"Failed to deserialize message '{msg}' with error '{e}'"
|
|
116
120
|
)
|
|
117
121
|
await ws.close()
|
|
118
|
-
|
|
122
|
+
raise Exception(e)
|
|
119
123
|
|
|
120
124
|
match resp:
|
|
121
125
|
case LogResp():
|
|
122
|
-
print(resp.msg, end="")
|
|
126
|
+
print(resp.msg, end="", file=self.__outfile)
|
|
123
127
|
case ErrorResp():
|
|
124
128
|
logging.error(f"Received server error: {resp.msg}")
|
|
125
129
|
await ws.close()
|
|
126
|
-
|
|
130
|
+
raise Exception(resp.msg)
|
|
127
131
|
case ExitResp():
|
|
128
132
|
logging.info(f"Exiting with code {resp.exit_code}")
|
|
129
133
|
await ws.close()
|
|
130
|
-
|
|
134
|
+
raise SystemExit(resp.exit_code)
|
|
135
|
+
case SetPidResp():
|
|
136
|
+
logging.info(f"Remote PID = {resp.pid}")
|
|
137
|
+
self.pid = resp.pid
|
|
131
138
|
case OkayResp():
|
|
132
|
-
logging.info("
|
|
139
|
+
logging.info("OK.")
|
|
133
140
|
case _:
|
|
134
141
|
logging.warning(f"Got unknown request {resp}")
|
|
135
142
|
|
|
136
143
|
|
|
137
|
-
def __rsync(
|
|
144
|
+
def __rsync(args: Args):
|
|
138
145
|
"""Runs rsync."""
|
|
139
|
-
url = f"{SERVER_IP}:{SERVER_DEST}/{project_hash}/"
|
|
140
146
|
rsync_args = [
|
|
141
147
|
"rsync",
|
|
142
148
|
"-avzr",
|
|
143
149
|
"-e",
|
|
144
|
-
f"/usr/bin/ssh
|
|
150
|
+
f"/usr/bin/ssh {args.ssh_args} -p {str(args.server_ssh_port)}",
|
|
145
151
|
"--progress",
|
|
146
152
|
"--mkpath",
|
|
147
153
|
args.target_path,
|
|
148
|
-
*args.
|
|
149
|
-
|
|
154
|
+
*args.assets,
|
|
155
|
+
args.rsync_url(),
|
|
150
156
|
]
|
|
151
157
|
logging.info(" ".join(rsync_args))
|
|
152
158
|
p = subprocess.run(rsync_args)
|
|
153
159
|
if p.returncode != 0:
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
msg = f"Rsync failed with exit code {p.returncode}"
|
|
161
|
+
logging.error(msg)
|
|
162
|
+
raise Exception(msg)
|
|
156
163
|
|
|
157
164
|
|
|
158
|
-
def main():
|
|
165
|
+
def main(args: Args | None = None):
|
|
159
166
|
"""
|
|
160
167
|
The main executable.
|
|
161
168
|
Sync project files with rsync, then run the client.
|
|
@@ -163,15 +170,15 @@ def main():
|
|
|
163
170
|
log_level = os.environ.get("PSYNC_LOG", "INFO").upper()
|
|
164
171
|
logging.basicConfig(handlers=[InterceptHandler()], level=log_level, force=True)
|
|
165
172
|
|
|
166
|
-
|
|
167
|
-
args
|
|
168
|
-
__rsync(project_hash, args)
|
|
173
|
+
args = parse_args() if args is None else args
|
|
174
|
+
__rsync(args)
|
|
169
175
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
try:
|
|
177
|
+
asyncio.run(PsyncClient(args).run())
|
|
178
|
+
except SystemExit as e:
|
|
179
|
+
exit(e.code)
|
|
180
|
+
except Exception:
|
|
181
|
+
exit(1)
|
|
175
182
|
|
|
176
183
|
|
|
177
184
|
if __name__ == "__main__":
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
from collections.abc import Mapping
|
|
2
1
|
from dataclasses import dataclass
|
|
3
2
|
import logging
|
|
4
3
|
from pathlib import Path
|
|
@@ -15,6 +14,7 @@ class Mode(Enum):
|
|
|
15
14
|
class ReqKind(Enum):
|
|
16
15
|
Open = "open"
|
|
17
16
|
Kill = "kill"
|
|
17
|
+
HealthCheck = "hc"
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class RespKind(Enum):
|
|
@@ -22,18 +22,20 @@ class RespKind(Enum):
|
|
|
22
22
|
Error = "error"
|
|
23
23
|
Exit = "exit"
|
|
24
24
|
Okay = "ok"
|
|
25
|
+
SetPid = "set_pid"
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
@dataclass
|
|
28
29
|
class OpenReq:
|
|
29
30
|
path: Path
|
|
30
31
|
args: list[str]
|
|
31
|
-
env:
|
|
32
|
+
env: dict[str,str]
|
|
32
33
|
kind: ReqKind = ReqKind.Open
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
@dataclass
|
|
36
37
|
class KillReq:
|
|
38
|
+
pid: int
|
|
37
39
|
kind: ReqKind = ReqKind.Kill
|
|
38
40
|
|
|
39
41
|
|
|
@@ -60,28 +62,44 @@ class OkayResp:
|
|
|
60
62
|
kind: RespKind = RespKind.Okay
|
|
61
63
|
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
@dataclass
|
|
66
|
+
class HealthCheckReq:
|
|
67
|
+
kind: ReqKind = ReqKind.HealthCheck
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class SetPidResp:
|
|
72
|
+
pid: int
|
|
73
|
+
kind: RespKind = RespKind.SetPid
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
Req = OpenReq | KillReq | HealthCheckReq
|
|
77
|
+
Resp = LogResp | ExitResp | ErrorResp | OkayResp | SetPidResp
|
|
65
78
|
|
|
66
79
|
|
|
67
80
|
def serialize(msg: Req | Resp) -> str:
|
|
81
|
+
value = msg.kind.value
|
|
68
82
|
match msg:
|
|
69
83
|
case OpenReq():
|
|
70
84
|
args = " ".join(msg.args)
|
|
71
85
|
env: list[str] = []
|
|
72
|
-
for
|
|
73
|
-
env.append(f'{
|
|
74
|
-
return f"
|
|
86
|
+
for k, v in msg.env.items():
|
|
87
|
+
env.append(f'{k}="{v}"')
|
|
88
|
+
return f"{value} path='{msg.path}' args='{args}' env='{' '.join(env)}'"
|
|
75
89
|
case KillReq():
|
|
76
|
-
return "
|
|
90
|
+
return f"{value} {msg.pid}"
|
|
91
|
+
case SetPidResp():
|
|
92
|
+
return f"{value} {msg.pid}"
|
|
77
93
|
case LogResp():
|
|
78
|
-
return f"
|
|
94
|
+
return f"{value} {msg.msg}"
|
|
79
95
|
case ExitResp():
|
|
80
|
-
return f"
|
|
96
|
+
return f"{value} {msg.exit_code}"
|
|
81
97
|
case ErrorResp():
|
|
82
|
-
return f"
|
|
98
|
+
return f"{value} {msg.msg}"
|
|
83
99
|
case OkayResp():
|
|
84
|
-
return "
|
|
100
|
+
return f"{value}"
|
|
101
|
+
case HealthCheckReq():
|
|
102
|
+
return f"{value}"
|
|
85
103
|
|
|
86
104
|
|
|
87
105
|
path_expr = re.compile(r"path='([^']+)'")
|
|
@@ -106,13 +124,9 @@ def deserialize(msg: str) -> Req | Resp:
|
|
|
106
124
|
logging.debug(f"Got message {msg}")
|
|
107
125
|
try:
|
|
108
126
|
[kind, rest] = msg.split(" ", 1)
|
|
109
|
-
except Exception
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
elif msg == "kill":
|
|
113
|
-
return KillReq()
|
|
114
|
-
else:
|
|
115
|
-
raise e
|
|
127
|
+
except Exception:
|
|
128
|
+
kind = msg.strip()
|
|
129
|
+
rest = ""
|
|
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)
|