cubething_psync 0.2.1.dev2__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.
Files changed (25) hide show
  1. {cubething_psync-0.2.1.dev2/src/cubething_psync.egg-info → cubething_psync-0.3.0}/PKG-INFO +1 -1
  2. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/pyproject.toml +13 -1
  3. cubething_psync-0.3.0/src/client/args.py +190 -0
  4. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/client/main.py +46 -63
  5. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/common/data.py +40 -18
  6. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0/src/cubething_psync.egg-info}/PKG-INFO +1 -1
  7. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/SOURCES.txt +2 -1
  8. cubething_psync-0.3.0/src/server/args.py +107 -0
  9. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/server/main.py +78 -82
  10. cubething_psync-0.3.0/test/test_integration.py +83 -0
  11. cubething_psync-0.2.1.dev2/src/client/args.py +0 -94
  12. cubething_psync-0.2.1.dev2/src/server/args.py +0 -69
  13. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/LICENSE +0 -0
  14. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/README.md +0 -0
  15. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/setup.cfg +0 -0
  16. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/client/__init__.py +0 -0
  17. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/client/__main__.py +0 -0
  18. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/common/__init__.py +0 -0
  19. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/common/log.py +0 -0
  20. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/dependency_links.txt +0 -0
  21. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/entry_points.txt +0 -0
  22. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/requires.txt +0 -0
  23. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/cubething_psync.egg-info/top_level.txt +0 -0
  24. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/server/__init__.py +0 -0
  25. {cubething_psync-0.2.1.dev2 → cubething_psync-0.3.0}/src/server/__main__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cubething_psync
3
- Version: 0.2.1.dev2
3
+ Version: 0.3.0
4
4
  Summary: Simple project synchronization tool.
5
5
  Author-email: ada mandala <ada@cubething.dev>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cubething_psync"
3
- version = "0.2.1.dev2"
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 pathlib
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
- __args: list[str]
66
- __env: dict[str, str]
67
- __path: pathlib.Path
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: list[str], env: dict[str, str], path: pathlib.Path):
72
- self.__args = args
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
- await ws.send(serialize(KillReq()))
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 Exception("Forced shutdown")
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(pathlib.Path(SSL_CERT_PATH).expanduser())
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://{SERVER_IP}:{SERVER_PORT}", ssl=ssl_ctx
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(OpenReq(path=self.__path, env=self.__env, args=self.__args))
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
- exit(1)
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
- exit(1)
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
- exit(resp.exit_code)
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("Running exectuable...")
115
+ logging.info("OK.")
133
116
  case _:
134
117
  logging.warning(f"Got unknown request {resp}")
135
118
 
136
119
 
137
- def __rsync(project_hash: str, args: Args):
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 -l {USER} -p {str(SERVER_SSH_PORT)}",
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.extra,
149
- url,
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
- logging.error(f"Rsync failed with exit code {p.returncode}")
155
- exit(1)
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
- project_hash = hashlib.blake2s(os.getcwd().encode(), digest_size=8).hexdigest()
167
- args = parse_args()
168
- __rsync(project_hash, args)
149
+ args = parse_args() if args is None else args
150
+ __rsync(args)
169
151
 
170
- dest_path = pathlib.Path(
171
- f"{SERVER_DEST}/{project_hash}/{os.path.basename(args.target_path)}"
172
- )
173
- client = PsyncClient(args=args.args, env=args.env, path=dest_path)
174
- asyncio.run(client.run())
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
- Req = OpenReq | KillReq
64
- Resp = LogResp | ExitResp | ErrorResp | OkayResp
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"open path='{msg.path}' args='{args}' env='{' '.join(env)}'"
89
+ return f"{value} path='{msg.path}' args='{args}' env='{' '.join(env)}'"
75
90
  case KillReq():
76
- return "kill"
91
+ return f"{value} {msg.pid}"
92
+ case SetPidResp():
93
+ return f"{value} {msg.pid}"
77
94
  case LogResp():
78
- return f"log {msg.msg}"
95
+ return f"{value} {msg.msg}"
79
96
  case ExitResp():
80
- return f"exit {msg.exit_code}"
97
+ return f"{value} {msg.exit_code}"
81
98
  case ErrorResp():
82
- return f"error {msg.msg}"
99
+ return f"{value} {msg.msg}"
83
100
  case OkayResp():
84
- return "okay"
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 as e:
110
- if msg == "okay":
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(msg=rest)
154
+ return LogResp(rest)
139
155
  case RespKind.Exit.value:
140
- return ExitResp(exit_code=rest)
156
+ return ExitResp(rest)
141
157
  case RespKind.Error.value:
142
- return ErrorResp(msg=rest)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cubething_psync
3
- Version: 0.2.1.dev2
3
+ Version: 0.3.0
4
4
  Summary: Simple project synchronization tool.
5
5
  Author-email: ada mandala <ada@cubething.dev>
6
6
  License-Expression: MIT
@@ -17,4 +17,5 @@ src/cubething_psync.egg-info/top_level.txt
17
17
  src/server/__init__.py
18
18
  src/server/__main__.py
19
19
  src/server/args.py
20
- src/server/main.py
20
+ src/server/main.py
21
+ test/test_integration.py
@@ -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
- __args: Args
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.__args = args
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.__args.cert_path).expanduser(),
96
- pathlib.Path(self.__args.key_path).expanduser(),
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.__args.host,
102
- self.__args.port,
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
- logging.info("Gracefully shutting down...")
136
- await ws.close()
137
- _ = self.__coroutine.cancel() # pyright: ignore[reportOptionalMemberAccess]
138
- asyncio.get_event_loop().stop()
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.__args.use_base_env else {**environ, **req.env}
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 != {} and env is not None:
173
+ if env != {}:
193
174
  info_log += f"\n... with env {pprint(env)}"
194
- if self.__args.user is not None:
195
- info_log += f"... as user {self.__args.user}"
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.__args.user,
186
+ user=self.args.user,
206
187
  )
207
- self.__tasks[host] = asyncio.create_task(self.__log(ws, p))
208
- resp = OkayResp()
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 run process with error {e}")
212
- resp = ErrorResp(msg=f"Server error: {e}")
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, _req: KillReq, ws: ServerConnection):
223
+ async def __kill(self, req: KillReq, ws: ServerConnection):
236
224
  host = self.__get_host(ws)
237
- ptask = self.__tasks.get(host)
238
- process = ptask.process
239
- task = ptask.task
240
- if process is not None and task is not None:
241
- logging.info(f"Killing PID {process.pid}")
242
- process.kill()
243
- code = await process.wait()
244
- resp = ExitResp(str(code))
245
- await ws.send(serialize(resp))
246
- else:
247
- logging.error(
248
- f"Tried to kill process for host {host}, but no process was running."
249
- )
250
- await ws.send(
251
- serialize(
252
- ErrorResp(msg="Tried to kill process, but no process was running.")
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
- logging.basicConfig(handlers=[InterceptHandler()], level=PSYNC_LOG, force=True)
261
- asyncio.run(PsyncServer(parse_args()).serve())
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)