Skip to content

Commit 8a022e9

Browse files
authored
Fix/some rework on remote service & remote resource (#1182)
- turn remote service start into steps, thus delay remote driver construction on remote host - fix certain remote cmd gen utils - fix empty report check in certain exporters - add cleanup for dangling symlink due to workspace imitation on remote host, rearrange some source in remote resource as well - fix a bug in driver dep graph type check - kill ssh proc at rmt svc stop failure
1 parent 2bf9814 commit 8a022e9

File tree

13 files changed

+183
-45
lines changed

13 files changed

+183
-45
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve error logging for :py:class:`~testplan.common.remote.remote_service.RemoteService`; fix incorrect imitated workspace on remote due to leftover symlink from previous run.

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
"gherkin-official==4.1.3",
6363
"parse",
6464
"orjson; python_version>='3.8'",
65-
"flask-orjson; python_version>='3.8'"
65+
"flask-orjson; python_version>='3.8'",
66+
"exceptiongroup"
6667
]
6768
requires-python = ">=3.7"
6869

testplan/common/remote/remote_driver.py

+26-8
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,38 @@ class RemoteDriver:
1212

1313
def __init__(self, remote_service, driver_cls, **options):
1414

15-
self.__dict__["rpyc_connection"] = remote_service.rpyc_connection
16-
self.__dict__["_driver_cls"] = getattr(
17-
self.rpyc_connection.modules[driver_cls.__module__],
18-
driver_cls.__name__,
19-
)
15+
self.__dict__["_remote_service"] = remote_service
16+
self.__dict__["_driver_cls"] = driver_cls
17+
self.__dict__["_options"] = options
18+
self.__dict__["_handle"] = None
2019

21-
options["runpath"] = "/".join(
22-
[remote_service._remote_resource_runpath, options["name"]]
20+
def _init_handle(self):
21+
rmt_driver_cls = getattr(
22+
self._remote_service.rpyc_connection.modules[
23+
self._driver_cls.__module__
24+
],
25+
self._driver_cls.__name__,
26+
)
27+
self._options["runpath"] = "/".join(
28+
[
29+
self._remote_service._remote_resource_runpath,
30+
self._options["name"],
31+
]
2332
)
33+
self.__dict__["_handle"] = rmt_driver_cls(**self._options)
2434

25-
self.__dict__["_handle"] = self._driver_cls(**options)
35+
def uid(self) -> str:
36+
# driver uid needed in env constructing check
37+
# whether remote or local, uid should be the same since it's cfg based
38+
# XXX: would it be a bad idea to have it inited locally?
39+
return self._driver_cls(**self._options).uid()
2640

2741
def __getattr__(self, name):
42+
if self._handle is None:
43+
self._init_handle()
2844
return getattr(self._handle, name)
2945

3046
def __setattr__(self, name, value):
47+
if self._handle is None:
48+
self._init_handle()
3149
setattr(self._handle, name, value)

testplan/common/remote/remote_resource.py

+85-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import getpass
22
import itertools
33
import os
4+
import shlex
45
import sys
56
from typing import Callable, Iterator, List, Tuple, Union, Dict, Optional
67

@@ -70,6 +71,8 @@ def get_options(cls):
7071
ConfigOption("remote_runpath", default=None): str,
7172
ConfigOption("testplan_path", default=None): str,
7273
ConfigOption("remote_workspace", default=None): str,
74+
# proposing cfg clobber_remote_workspace,
75+
# to overwrite what's in remote_workspace when set to True
7376
ConfigOption("clean_remote", default=False): bool,
7477
ConfigOption("push", default=[]): Or(list, None),
7578
ConfigOption("push_exclude", default=[]): Or(list, None),
@@ -152,33 +155,43 @@ def __init__(
152155
status_wait_timeout: int = 60,
153156
**options,
154157
) -> None:
155-
156158
if not worker_is_remote(remote_host):
159+
# TODO: allow connecting to local for testing purpose?
157160
raise RuntimeError(
158161
"Cannot create remote resource on the same host that Testplan runs."
159162
)
160163
options.update(self.filter_locals(locals()))
161164
super(RemoteResource, self).__init__(**options)
162165

166+
self.ssh_cfg = {
167+
"host": self.cfg.remote_host,
168+
"port": self.cfg.ssh_port,
169+
}
170+
# if 0 != self._execute_cmd_remote("uname"):
171+
# raise NotImplementedError(
172+
# "RemoteResource not supported on Windows remote hosts."
173+
# )
174+
163175
self._remote_plan_runpath = None
164176
self._remote_resource_runpath = None
165177
self._child_paths = _LocationPaths()
166178
self._testplan_import_path = _LocationPaths()
167179
self._workspace_paths = _LocationPaths()
168180
self._working_dirs = _LocationPaths()
169181

170-
self.ssh_cfg = {
171-
"host": self.cfg.remote_host,
172-
"port": self.cfg.ssh_port,
173-
}
174-
175182
self.setup_metadata = WorkerSetupMetadata()
176183
self._user = getpass.getuser()
184+
# TODO: allow specifying remote env
177185
self.python_binary = (
178186
os.environ["PYTHON3_REMOTE_BINARY"] if IS_WIN else sys.executable
179187
)
180188
self._error_exec = []
181189

190+
# remote file system obj outside runpath that needs to be cleaned upon
191+
# exit when clean_remote is True, otherwise it will break workspace
192+
# detect etc. in next run
193+
self._dangling_remote_fs_obj = None
194+
182195
@property
183196
def error_exec(self) -> list:
184197
return self._error_exec
@@ -252,8 +265,6 @@ def _remote_working_dir(self) -> None:
252265
def _create_remote_dirs(self) -> None:
253266
"""Create mandatory directories in remote host."""
254267

255-
exist_on_remote = self._check_workspace()
256-
257268
if 0 != self._execute_cmd_remote(
258269
cmd=filepath_exist_cmd(self._remote_runid_file),
259270
label="runid file availability check",
@@ -275,7 +286,18 @@ def _create_remote_dirs(self) -> None:
275286
label="create remote runid file",
276287
)
277288

289+
# TODO: remote venv setup
290+
# TODO: testplan_lib will resolved to site-packages under venv,
291+
# TODO: while rpyc_classic.py under bin isn't included
292+
293+
exist_on_remote = self._check_workspace()
278294
self._prepare_workspace(exist_on_remote)
295+
296+
# NOTE: if workspace under testplan_lib (testplan installed in
297+
# NOTE: editable mode), actual testplan package will never be
298+
# NOTE: transferred
299+
# NOTE: nevertheless, this impl won't work with venv in the first
300+
# NOTE: place
279301
self._copy_testplan_package()
280302

281303
self._execute_cmd_remote(
@@ -382,6 +404,7 @@ def _prepare_workspace(self, exist_on_remote: bool) -> None:
382404
self,
383405
self.ssh_cfg["host"],
384406
)
407+
# proposed: if clobber_remote_workspace set, overwrite remote
385408
self._execute_cmd_remote(
386409
cmd=link_cmd(
387410
path=self._workspace_paths.local,
@@ -400,31 +423,68 @@ def _prepare_workspace(self, exist_on_remote: bool) -> None:
400423
)
401424
self._transfer_data(
402425
# join with "" to add trailing "/" to source
403-
# this will copy everything under local import path to to testplan_lib
426+
# this will copy everything under local workspace path to fetched_workspace
404427
source=os.path.join(self._workspace_paths.local, ""),
405428
target=self._workspace_paths.remote,
406429
remote_target=True,
407430
exclude=self.cfg.workspace_exclude,
408431
)
432+
433+
if IS_WIN:
434+
return
435+
409436
self.logger.info(
410437
"%s: creating symlink to imitate local workspace path on %s, "
411438
"pointing to runpath/fetched_workspace",
412439
self,
413440
self.ssh_cfg["host"],
414441
)
415-
self._execute_cmd_remote(
442+
rmt_non_existing = None
443+
# TODO: uncomment later
444+
# TODO: there is another issue related to created dir cleanup
445+
# TODO: if push given, pushed files under created dir and delete_pushed
446+
# TODO: set to False, what to do?
447+
# r, w = os.pipe()
448+
# if 0 == self._execute_cmd_remote(
449+
# cmd="/bin/bash -c "
450+
# + shlex.quote(
451+
# 'e=""; for i in '
452+
# + " ".join(
453+
# map(
454+
# shlex.quote,
455+
# self._workspace_paths.local.split(os.sep)[1:],
456+
# )
457+
# )
458+
# + '; do e+="/${i}"; if [ ! -e "$e" ]; then echo "$e"; break; fi; done'
459+
# ),
460+
# label="imitate local workspace path on remote - detect non-existing",
461+
# stdout=os.fdopen(w),
462+
# check=False, # XXX: bash might not be there
463+
# ):
464+
# rmt_non_existing = os.fdopen(r).read().strip() or None
465+
if 0 == self._execute_cmd_remote(
416466
cmd=mkdir_cmd(os.path.dirname(self._workspace_paths.local)),
417467
label="imitate local workspace path on remote - mkdir",
418468
check=False, # just best effort
419-
)
420-
self._execute_cmd_remote(
469+
) and 0 == self._execute_cmd_remote(
421470
cmd=link_cmd(
422471
path=self._workspace_paths.remote,
423472
link=self._workspace_paths.local,
424473
),
425474
label="imitate local workspace path on remote - ln",
426475
check=False, # just best effort
427-
)
476+
):
477+
# NOTE: we shall always remove created symlink
478+
self._dangling_remote_fs_obj = (
479+
rmt_non_existing or self._workspace_paths.local
480+
)
481+
self.logger.info(
482+
"%s: on %s, %s and its possible descendants are "
483+
"created to imitate local workspace path",
484+
self,
485+
self.ssh_cfg["host"],
486+
self._dangling_remote_fs_obj,
487+
)
428488

429489
def _push_files(self) -> None:
430490
"""Push files and directories to remote host."""
@@ -601,10 +661,21 @@ def _clean_remote(self) -> None:
601661
)
602662

603663
self._execute_cmd_remote(
604-
cmd=f"/bin/rm -rf {self._remote_plan_runpath}",
664+
cmd=rm_cmd(self._remote_plan_runpath),
605665
label="Clean remote root runpath",
606666
)
607667

668+
if self._dangling_remote_fs_obj:
669+
self._execute_cmd_remote(
670+
cmd=rm_cmd(self._dangling_remote_fs_obj),
671+
label=f"Remove imitated workspace outside runpath",
672+
)
673+
self._dangling_remote_fs_obj = None
674+
675+
if self.cfg.delete_pushed:
676+
# TODO
677+
...
678+
608679
def _pull_files(self) -> None:
609680
"""Pull custom files from remote host."""
610681

testplan/common/remote/remote_service.py

+6-7
Original file line numberDiff line numberDiff line change
@@ -240,15 +240,14 @@ def stopping(self) -> None:
240240
"""
241241
Stops remote rpyc process.
242242
"""
243-
remote_pid = self.rpyc_connection.modules.os.getpid()
244243
try:
245-
self.rpyc_connection.modules.os.kill(remote_pid, signal.SIGTERM)
244+
self.rpyc_connection.modules.os.kill(self.rpyc_pid, signal.SIGTERM)
246245
except EOFError:
247246
pass
248-
249-
# actually if remote rpyc server is shutdown, ssh proc is also finished
250-
# but calling kill_process just in case
251-
if self.proc:
252-
kill_process(self.proc, self.cfg.stop_timeout)
247+
finally:
248+
# if remote rpyc server is shutdown successfully, ssh proc is also finished
249+
# otherwise we need to manual kill this orphaned ssh procc
250+
if self.proc:
251+
kill_process(self.proc, self.cfg.stop_timeout)
253252

254253
self.status.change(self.STATUS.STOPPED)

testplan/common/utils/path.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ def rebase_path(path, old_base, new_base):
254254
"""
255255

256256
rel_path = os.path.relpath(path, old_base).split(os.sep)
257-
return "/".join([new_base] + rel_path)
257+
return os.path.join(new_base, *rel_path)
258258

259259

260260
def is_subdir(child, parent):

testplan/common/utils/remote.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Remote execution utilities."""
22

3+
import getpass
34
import os
45
import platform
6+
import shlex
57
import socket
6-
import sys
7-
import getpass
88
import subprocess
9+
import sys
910

1011
IS_WIN = platform.system() == "Windows"
1112
USER = getpass.getuser()
@@ -119,19 +120,19 @@ def copy_cmd(source, target, exclude=None, port=None, deref_links=False):
119120

120121
def link_cmd(path, link):
121122
"""Returns link creation command."""
122-
return " ".join(["ln", "-sfn", path, link])
123+
return " ".join(["/bin/ln", "-sfn", shlex.quote(path), shlex.quote(link)])
123124

124125

125126
def mkdir_cmd(path):
126127
"""Return mkdir command"""
127-
return " ".join(["/bin/mkdir", "-p", path])
128+
return " ".join(["/bin/mkdir", "-p", shlex.quote(path)])
128129

129130

130131
def rm_cmd(path):
131132
"""Return rm command"""
132-
return " ".join(["/bin/rm", "-rf", path])
133+
return " ".join(["/bin/rm", "-rf", shlex.quote(path)])
133134

134135

135136
def filepath_exist_cmd(path):
136137
"""Checks if filepath exists."""
137-
return " ".join(["test", "-e", path])
138+
return " ".join(["/bin/test", "-e", shlex.quote(path)])

testplan/exporters/testing/json/base.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def export(
113113
result = None
114114
json_path = pathlib.Path(self.cfg.json_path).resolve()
115115

116-
if len(source):
116+
if not source.is_empty():
117117
json_path.parent.mkdir(parents=True, exist_ok=True)
118118

119119
test_plan_schema = TestReportSchema()

testplan/exporters/testing/pdf/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def export(
250250
exporter=self, export_context=export_context
251251
)
252252
result = None
253-
if len(source):
253+
if not source.is_empty():
254254
pdf_path = self.create_pdf(source)
255255
self.logger.user_info("PDF generated at %s", pdf_path)
256256

testplan/exporters/testing/webserver/base.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def export(
8787
exporter=self, export_context=export_context
8888
)
8989
result = None
90-
if len(source):
90+
if not source.is_empty():
9191
exporter = JSONExporter(
9292
json_path=self.cfg.json_path, split_json_report=False
9393
)

0 commit comments

Comments
 (0)