teuthology 1.1.0__py3-none-any.whl → 1.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scripts/describe.py +1 -0
- scripts/dispatcher.py +55 -26
- scripts/exporter.py +18 -0
- scripts/lock.py +1 -1
- scripts/node_cleanup.py +58 -0
- scripts/openstack.py +9 -9
- scripts/results.py +12 -11
- scripts/schedule.py +4 -0
- scripts/suite.py +57 -16
- scripts/supervisor.py +44 -0
- scripts/update_inventory.py +10 -4
- teuthology/__init__.py +24 -26
- teuthology/beanstalk.py +4 -3
- teuthology/config.py +16 -6
- teuthology/contextutil.py +18 -14
- teuthology/describe_tests.py +25 -18
- teuthology/dispatcher/__init__.py +210 -35
- teuthology/dispatcher/supervisor.py +140 -58
- teuthology/exceptions.py +43 -0
- teuthology/exporter.py +347 -0
- teuthology/kill.py +76 -81
- teuthology/lock/cli.py +3 -3
- teuthology/lock/ops.py +135 -61
- teuthology/lock/query.py +61 -44
- teuthology/ls.py +1 -1
- teuthology/misc.py +61 -75
- teuthology/nuke/__init__.py +12 -353
- teuthology/openstack/__init__.py +4 -3
- teuthology/openstack/openstack-centos-7.0-user-data.txt +1 -1
- teuthology/openstack/openstack-centos-7.1-user-data.txt +1 -1
- teuthology/openstack/openstack-centos-7.2-user-data.txt +1 -1
- teuthology/openstack/openstack-debian-8.0-user-data.txt +1 -1
- teuthology/openstack/openstack-opensuse-42.1-user-data.txt +1 -1
- teuthology/openstack/openstack-teuthology.cron +0 -1
- teuthology/orchestra/cluster.py +49 -7
- teuthology/orchestra/connection.py +17 -4
- teuthology/orchestra/console.py +111 -50
- teuthology/orchestra/daemon/cephadmunit.py +15 -2
- teuthology/orchestra/daemon/state.py +8 -1
- teuthology/orchestra/daemon/systemd.py +4 -4
- teuthology/orchestra/opsys.py +30 -11
- teuthology/orchestra/remote.py +405 -338
- teuthology/orchestra/run.py +3 -3
- teuthology/packaging.py +19 -16
- teuthology/provision/__init__.py +30 -10
- teuthology/provision/cloud/openstack.py +12 -6
- teuthology/provision/cloud/util.py +1 -2
- teuthology/provision/downburst.py +4 -3
- teuthology/provision/fog.py +68 -20
- teuthology/provision/openstack.py +5 -4
- teuthology/provision/pelagos.py +1 -1
- teuthology/repo_utils.py +43 -13
- teuthology/report.py +57 -35
- teuthology/results.py +5 -3
- teuthology/run.py +13 -14
- teuthology/run_tasks.py +27 -43
- teuthology/schedule.py +4 -3
- teuthology/scrape.py +28 -22
- teuthology/suite/__init__.py +74 -45
- teuthology/suite/build_matrix.py +34 -24
- teuthology/suite/fragment-merge.lua +105 -0
- teuthology/suite/matrix.py +31 -2
- teuthology/suite/merge.py +175 -0
- teuthology/suite/placeholder.py +6 -9
- teuthology/suite/run.py +175 -100
- teuthology/suite/util.py +64 -218
- teuthology/task/__init__.py +1 -1
- teuthology/task/ansible.py +101 -32
- teuthology/task/buildpackages.py +2 -2
- teuthology/task/ceph_ansible.py +13 -6
- teuthology/task/cephmetrics.py +2 -1
- teuthology/task/clock.py +33 -14
- teuthology/task/exec.py +18 -0
- teuthology/task/hadoop.py +2 -2
- teuthology/task/install/__init__.py +29 -7
- teuthology/task/install/bin/adjust-ulimits +16 -0
- teuthology/task/install/bin/daemon-helper +114 -0
- teuthology/task/install/bin/stdin-killer +263 -0
- teuthology/task/install/deb.py +1 -1
- teuthology/task/install/rpm.py +17 -5
- teuthology/task/install/util.py +3 -3
- teuthology/task/internal/__init__.py +41 -10
- teuthology/task/internal/edit_sudoers.sh +10 -0
- teuthology/task/internal/lock_machines.py +2 -9
- teuthology/task/internal/redhat.py +31 -1
- teuthology/task/internal/syslog.py +31 -8
- teuthology/task/kernel.py +152 -145
- teuthology/task/lockfile.py +1 -1
- teuthology/task/mpi.py +10 -10
- teuthology/task/pcp.py +1 -1
- teuthology/task/selinux.py +16 -8
- teuthology/task/ssh_keys.py +4 -4
- teuthology/timer.py +3 -3
- teuthology/util/loggerfile.py +19 -0
- teuthology/util/scanner.py +159 -0
- teuthology/util/sentry.py +52 -0
- teuthology/util/time.py +52 -0
- teuthology-1.2.1.data/scripts/adjust-ulimits +16 -0
- teuthology-1.2.1.data/scripts/daemon-helper +114 -0
- teuthology-1.2.1.data/scripts/stdin-killer +263 -0
- teuthology-1.2.1.dist-info/METADATA +88 -0
- teuthology-1.2.1.dist-info/RECORD +168 -0
- {teuthology-1.1.0.dist-info → teuthology-1.2.1.dist-info}/WHEEL +1 -1
- {teuthology-1.1.0.dist-info → teuthology-1.2.1.dist-info}/entry_points.txt +3 -2
- scripts/nuke.py +0 -47
- scripts/worker.py +0 -37
- teuthology/lock/test/__init__.py +0 -0
- teuthology/lock/test/test_lock.py +0 -7
- teuthology/nuke/actions.py +0 -456
- teuthology/openstack/test/__init__.py +0 -0
- teuthology/openstack/test/openstack-integration.py +0 -286
- teuthology/openstack/test/test_config.py +0 -35
- teuthology/openstack/test/test_openstack.py +0 -1695
- teuthology/orchestra/test/__init__.py +0 -0
- teuthology/orchestra/test/integration/__init__.py +0 -0
- teuthology/orchestra/test/integration/test_integration.py +0 -94
- teuthology/orchestra/test/test_cluster.py +0 -240
- teuthology/orchestra/test/test_connection.py +0 -106
- teuthology/orchestra/test/test_console.py +0 -217
- teuthology/orchestra/test/test_opsys.py +0 -404
- teuthology/orchestra/test/test_remote.py +0 -185
- teuthology/orchestra/test/test_run.py +0 -286
- teuthology/orchestra/test/test_systemd.py +0 -54
- teuthology/orchestra/test/util.py +0 -12
- teuthology/task/tests/__init__.py +0 -110
- teuthology/task/tests/test_locking.py +0 -25
- teuthology/task/tests/test_run.py +0 -40
- teuthology/test/__init__.py +0 -0
- teuthology/test/fake_archive.py +0 -107
- teuthology/test/fake_fs.py +0 -92
- teuthology/test/integration/__init__.py +0 -0
- teuthology/test/integration/test_suite.py +0 -86
- teuthology/test/task/__init__.py +0 -205
- teuthology/test/task/test_ansible.py +0 -624
- teuthology/test/task/test_ceph_ansible.py +0 -176
- teuthology/test/task/test_console_log.py +0 -88
- teuthology/test/task/test_install.py +0 -337
- teuthology/test/task/test_internal.py +0 -57
- teuthology/test/task/test_kernel.py +0 -243
- teuthology/test/task/test_pcp.py +0 -379
- teuthology/test/task/test_selinux.py +0 -35
- teuthology/test/test_config.py +0 -189
- teuthology/test/test_contextutil.py +0 -68
- teuthology/test/test_describe_tests.py +0 -316
- teuthology/test/test_email_sleep_before_teardown.py +0 -81
- teuthology/test/test_exit.py +0 -97
- teuthology/test/test_get_distro.py +0 -47
- teuthology/test/test_get_distro_version.py +0 -47
- teuthology/test/test_get_multi_machine_types.py +0 -27
- teuthology/test/test_job_status.py +0 -60
- teuthology/test/test_ls.py +0 -48
- teuthology/test/test_misc.py +0 -391
- teuthology/test/test_nuke.py +0 -290
- teuthology/test/test_packaging.py +0 -763
- teuthology/test/test_parallel.py +0 -28
- teuthology/test/test_repo_utils.py +0 -225
- teuthology/test/test_report.py +0 -77
- teuthology/test/test_results.py +0 -155
- teuthology/test/test_run.py +0 -239
- teuthology/test/test_safepath.py +0 -55
- teuthology/test/test_schedule.py +0 -45
- teuthology/test/test_scrape.py +0 -167
- teuthology/test/test_timer.py +0 -80
- teuthology/test/test_vps_os_vers_parameter_checking.py +0 -84
- teuthology/test/test_worker.py +0 -303
- teuthology/worker.py +0 -354
- teuthology-1.1.0.dist-info/METADATA +0 -76
- teuthology-1.1.0.dist-info/RECORD +0 -213
- {teuthology-1.1.0.dist-info → teuthology-1.2.1.dist-info}/LICENSE +0 -0
- {teuthology-1.1.0.dist-info → teuthology-1.2.1.dist-info}/top_level.txt +0 -0
teuthology/timer.py
CHANGED
@@ -2,7 +2,7 @@ import logging
|
|
2
2
|
import time
|
3
3
|
import yaml
|
4
4
|
|
5
|
-
|
5
|
+
import datetime
|
6
6
|
|
7
7
|
log = logging.getLogger(__name__)
|
8
8
|
|
@@ -68,8 +68,8 @@ class Timer(object):
|
|
68
68
|
|
69
69
|
:param time: Time in seconds; like from time.time()
|
70
70
|
"""
|
71
|
-
_datetime = datetime.
|
72
|
-
return datetime.strftime(
|
71
|
+
_datetime = datetime.datetime.fromtimestamp(time, datetime.timezone.utc)
|
72
|
+
return datetime.datetime.strftime(
|
73
73
|
_datetime,
|
74
74
|
self.datetime_format,
|
75
75
|
)
|
@@ -0,0 +1,19 @@
|
|
1
|
+
import logging
|
2
|
+
|
3
|
+
class LoggerFile(object):
|
4
|
+
"""
|
5
|
+
A thin wrapper around a logging.Logger instance that provides a file-like
|
6
|
+
interface.
|
7
|
+
|
8
|
+
Used by Ansible.execute_playbook() when it calls pexpect.run()
|
9
|
+
"""
|
10
|
+
def __init__(self, logger: logging.Logger, level: int):
|
11
|
+
self.logger = logger
|
12
|
+
self.level = level
|
13
|
+
|
14
|
+
def write(self, string):
|
15
|
+
self.logger.log(self.level, string.decode('utf-8', 'ignore'))
|
16
|
+
|
17
|
+
def flush(self):
|
18
|
+
pass
|
19
|
+
|
@@ -0,0 +1,159 @@
|
|
1
|
+
import logging
|
2
|
+
import yaml
|
3
|
+
from typing import Optional, Tuple
|
4
|
+
from collections import defaultdict
|
5
|
+
from lxml import etree
|
6
|
+
|
7
|
+
log = logging.getLogger(__name__)
|
8
|
+
|
9
|
+
|
10
|
+
class Scanner():
|
11
|
+
def __init__(self, remote=None) -> None:
|
12
|
+
self.summary_data = []
|
13
|
+
self.remote = remote
|
14
|
+
|
15
|
+
def _parse(self, file_content) -> Tuple[str, dict]:
|
16
|
+
"""
|
17
|
+
This parses file_content and returns:
|
18
|
+
:returns: a message string
|
19
|
+
:returns: data dictionary with additional info
|
20
|
+
|
21
|
+
Just an abstract method in Scanner class,
|
22
|
+
to be defined in inherited classes.
|
23
|
+
"""
|
24
|
+
raise NotImplementedError
|
25
|
+
|
26
|
+
def scan_file(self, path: str) -> Optional[str]:
|
27
|
+
if not path:
|
28
|
+
return None
|
29
|
+
try:
|
30
|
+
file = self.remote._sftp_open_file(path, 'r')
|
31
|
+
file_content = file.read()
|
32
|
+
txt, data = self._parse(file_content)
|
33
|
+
if data:
|
34
|
+
data["file_path"] = path
|
35
|
+
self.summary_data += [data]
|
36
|
+
file.close()
|
37
|
+
return txt
|
38
|
+
except Exception as exc:
|
39
|
+
log.error(str(exc))
|
40
|
+
|
41
|
+
def scan_all_files(self, path_regex: str) -> [str]:
|
42
|
+
"""
|
43
|
+
Scans all files matching path_regex
|
44
|
+
and collect additional data in self.summary_data
|
45
|
+
|
46
|
+
:param path_regex: Regex string to find all the files which have to be scanned.
|
47
|
+
Example: /path/to/dir/*.xml
|
48
|
+
"""
|
49
|
+
(_, stdout, _) = self.remote.ssh.exec_command(f'ls -d {path_regex}', timeout=200)
|
50
|
+
|
51
|
+
files = stdout.read().decode().split('\n')
|
52
|
+
|
53
|
+
extracted_txts = []
|
54
|
+
for fpath in files:
|
55
|
+
txt = self.scan_file(fpath)
|
56
|
+
if txt:
|
57
|
+
extracted_txts += [txt]
|
58
|
+
return extracted_txts
|
59
|
+
|
60
|
+
def write_summary(self, yaml_path: str) -> None:
|
61
|
+
"""
|
62
|
+
Create yaml file locally
|
63
|
+
with self.summary_data.
|
64
|
+
"""
|
65
|
+
if self.summary_data and yaml_path:
|
66
|
+
with open(yaml_path, 'a') as f:
|
67
|
+
yaml.safe_dump(self.summary_data, f, default_flow_style=False)
|
68
|
+
else:
|
69
|
+
log.info("summary_data or yaml_file is empty!")
|
70
|
+
|
71
|
+
|
72
|
+
class UnitTestScanner(Scanner):
|
73
|
+
def __init__(self, remote=None) -> None:
|
74
|
+
super().__init__(remote)
|
75
|
+
|
76
|
+
def _parse(self, file_content: str) -> Tuple[Optional[str], Optional[dict]]:
|
77
|
+
xml_tree = etree.fromstring(file_content)
|
78
|
+
|
79
|
+
failed_testcases = xml_tree.xpath('.//failure/.. | .//error/..')
|
80
|
+
if len(failed_testcases) == 0:
|
81
|
+
return None, None
|
82
|
+
|
83
|
+
exception_txt = ""
|
84
|
+
error_data = defaultdict(list)
|
85
|
+
for testcase in failed_testcases:
|
86
|
+
testcase_name = testcase.get("name", "test-name")
|
87
|
+
testcase_suitename = testcase.get("classname", "suite-name")
|
88
|
+
for child in testcase:
|
89
|
+
if child.tag in ['failure', 'error']:
|
90
|
+
fault_kind = child.tag
|
91
|
+
reason = child.get('message', 'No message found in xml output, check logs.')
|
92
|
+
short_reason = (reason[:200].strip() + '...') if len(reason) > 200 else reason.strip()
|
93
|
+
error_data[testcase_suitename] += [{
|
94
|
+
"kind": fault_kind,
|
95
|
+
"testcase": testcase_name,
|
96
|
+
"message": reason,
|
97
|
+
}]
|
98
|
+
if not exception_txt:
|
99
|
+
exception_txt = f'{fault_kind.upper()}: Test `{testcase_name}` of `{testcase_suitename}`. Reason: {short_reason}.'
|
100
|
+
|
101
|
+
return exception_txt, { "failed_testsuites": dict(error_data), "num_of_failures": len(failed_testcases) }
|
102
|
+
|
103
|
+
@property
|
104
|
+
def num_of_total_failures(self):
|
105
|
+
total_failed_testcases = 0
|
106
|
+
if self.summary_data:
|
107
|
+
for file_data in self.summary_data:
|
108
|
+
failed_tests = file_data.get("num_of_failures", 0)
|
109
|
+
total_failed_testcases += failed_tests
|
110
|
+
return total_failed_testcases
|
111
|
+
|
112
|
+
def scan_and_write(self, path_regex: str, summary_path: str) -> Optional[str]:
|
113
|
+
"""
|
114
|
+
Scan all files matching 'path_regex'
|
115
|
+
and write summary in 'summary_path'.
|
116
|
+
"""
|
117
|
+
try:
|
118
|
+
errors = self.scan_all_files(path_regex)
|
119
|
+
self.write_summary(summary_path)
|
120
|
+
if errors:
|
121
|
+
count = self.num_of_total_failures
|
122
|
+
return f"(total {count} failed) " + errors[0]
|
123
|
+
except Exception as scanner_exc:
|
124
|
+
log.error(str(scanner_exc))
|
125
|
+
|
126
|
+
|
127
|
+
class ValgrindScanner(Scanner):
|
128
|
+
def __init__(self, remote=None) -> None:
|
129
|
+
super().__init__(remote)
|
130
|
+
|
131
|
+
def _parse(self, file_content: str) -> Tuple[Optional[str], Optional[dict]]:
|
132
|
+
xml_tree = etree.fromstring(file_content)
|
133
|
+
if xml_tree is None:
|
134
|
+
return None, None
|
135
|
+
|
136
|
+
error_tree = xml_tree.find('error')
|
137
|
+
if error_tree is None:
|
138
|
+
return None, None
|
139
|
+
|
140
|
+
error_data = {
|
141
|
+
"kind": error_tree.findtext("kind"),
|
142
|
+
"traceback": [],
|
143
|
+
}
|
144
|
+
for frame in error_tree.xpath("stack/frame"):
|
145
|
+
if len(error_data["traceback"]) >= 5:
|
146
|
+
break
|
147
|
+
curr_frame = {
|
148
|
+
"file": f"{frame.findtext('dir', '')}/{frame.findtext('file', '')}",
|
149
|
+
"line": frame.findtext("line", ''),
|
150
|
+
"function": frame.findtext("fn", ''),
|
151
|
+
}
|
152
|
+
error_data["traceback"].append(curr_frame)
|
153
|
+
|
154
|
+
traceback_functions = "\n".join(
|
155
|
+
frame.get("function", "N/A")
|
156
|
+
for frame in error_data["traceback"][:3]
|
157
|
+
)
|
158
|
+
exception_text = f"valgrind error: {error_data['kind']}\n{traceback_functions}"
|
159
|
+
return exception_text, error_data
|
@@ -0,0 +1,52 @@
|
|
1
|
+
import logging
|
2
|
+
import sentry_sdk
|
3
|
+
|
4
|
+
from copy import deepcopy
|
5
|
+
|
6
|
+
from teuthology.config import config as teuth_config
|
7
|
+
from teuthology.misc import get_http_log_path
|
8
|
+
|
9
|
+
log = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
def report_error(job_config, exception, task_name=None):
|
13
|
+
if not teuth_config.sentry_dsn:
|
14
|
+
return None
|
15
|
+
sentry_sdk.init(teuth_config.sentry_dsn)
|
16
|
+
job_config = deepcopy(job_config)
|
17
|
+
|
18
|
+
tags = {
|
19
|
+
'task': task_name,
|
20
|
+
'owner': job_config.get("owner"),
|
21
|
+
}
|
22
|
+
optional_tags = ('teuthology_branch', 'branch', 'suite',
|
23
|
+
'machine_type', 'os_type', 'os_version')
|
24
|
+
for tag in optional_tags:
|
25
|
+
if tag in job_config:
|
26
|
+
tags[tag] = job_config[tag]
|
27
|
+
|
28
|
+
# Remove ssh keys from reported config
|
29
|
+
if 'targets' in job_config:
|
30
|
+
targets = job_config['targets']
|
31
|
+
for host in targets.keys():
|
32
|
+
targets[host] = '<redacted>'
|
33
|
+
|
34
|
+
job_id = job_config.get('job_id')
|
35
|
+
archive_path = job_config.get('archive_path')
|
36
|
+
extras = dict(config=job_config)
|
37
|
+
if job_id:
|
38
|
+
extras['logs'] = get_http_log_path(archive_path, job_id)
|
39
|
+
|
40
|
+
fingerprint = exception.fingerprint() if hasattr(exception, 'fingerprint') else None
|
41
|
+
exc_id = sentry_sdk.capture_exception(
|
42
|
+
error=exception,
|
43
|
+
tags=tags,
|
44
|
+
extras=extras,
|
45
|
+
fingerprint=fingerprint,
|
46
|
+
)
|
47
|
+
event_url = "{server}/?query={id}".format(
|
48
|
+
server=teuth_config.sentry_server.strip('/'), id=exc_id)
|
49
|
+
log.exception(" Sentry event: %s" % event_url)
|
50
|
+
return event_url
|
51
|
+
|
52
|
+
|
teuthology/util/time.py
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
import re
|
2
|
+
|
3
|
+
from datetime import datetime, timedelta, timezone
|
4
|
+
|
5
|
+
# When we're not using ISO format, we're using this
|
6
|
+
TIMESTAMP_FMT = "%Y-%m-%d_%H:%M:%S"
|
7
|
+
|
8
|
+
def parse_timestamp(timestamp: str) -> datetime:
|
9
|
+
"""
|
10
|
+
timestamp: A string either in ISO 8601 format or TIMESTAMP_FMT.
|
11
|
+
If no timezone is specified, UTC is assumed.
|
12
|
+
|
13
|
+
:returns: a datetime object
|
14
|
+
"""
|
15
|
+
try:
|
16
|
+
dt = datetime.fromisoformat(timestamp)
|
17
|
+
except ValueError:
|
18
|
+
dt = datetime.strptime(timestamp, TIMESTAMP_FMT)
|
19
|
+
if dt.tzinfo is None:
|
20
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
21
|
+
return dt
|
22
|
+
|
23
|
+
def parse_offset(offset: str) -> timedelta:
|
24
|
+
"""
|
25
|
+
offset: A string consisting of digits followed by one of the following
|
26
|
+
characters:
|
27
|
+
s: seconds
|
28
|
+
m: minutes
|
29
|
+
h: hours
|
30
|
+
d: days
|
31
|
+
w: weeks
|
32
|
+
"""
|
33
|
+
err_msg = "Offsets must either be an ISO 8601-formatted timestamp or " \
|
34
|
+
f"a relative value like '2w', '1d', '7h', '45m', '90s'. Got: {offset}"
|
35
|
+
match = re.match(r'(\d+)(s|m|h|d|w)$', offset)
|
36
|
+
if match is None:
|
37
|
+
raise ValueError(err_msg)
|
38
|
+
num = int(match.groups()[0])
|
39
|
+
unit = match.groups()[1]
|
40
|
+
match unit:
|
41
|
+
case 's':
|
42
|
+
return timedelta(seconds=num)
|
43
|
+
case 'm':
|
44
|
+
return timedelta(minutes=num)
|
45
|
+
case 'h':
|
46
|
+
return timedelta(hours=num)
|
47
|
+
case 'd':
|
48
|
+
return timedelta(days=num)
|
49
|
+
case 'w':
|
50
|
+
return timedelta(weeks=num)
|
51
|
+
case _:
|
52
|
+
raise ValueError(err_msg)
|
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/bin/sh
|
2
|
+
# If we're running as root, allow large amounts of open files.
|
3
|
+
USER=$(whoami)
|
4
|
+
|
5
|
+
# If a ulimit call fails, exit immediately.
|
6
|
+
set -e
|
7
|
+
|
8
|
+
if [ "$USER" = "root" ]
|
9
|
+
then
|
10
|
+
# Enable large number of open files
|
11
|
+
ulimit -n 65536
|
12
|
+
fi
|
13
|
+
|
14
|
+
# Enable core dumps for everything
|
15
|
+
ulimit -c unlimited
|
16
|
+
exec "$@"
|
@@ -0,0 +1,114 @@
|
|
1
|
+
#!python
|
2
|
+
|
3
|
+
"""
|
4
|
+
Helper script for running long-living processes.
|
5
|
+
|
6
|
+
(Name says daemon, but that is intended to mean "long-living", we
|
7
|
+
assume child process does not double-fork.)
|
8
|
+
|
9
|
+
We start the command passed as arguments, with /dev/null as stdin, and
|
10
|
+
then wait for EOF on stdin.
|
11
|
+
|
12
|
+
When EOF is seen on stdin, the child process is killed.
|
13
|
+
|
14
|
+
When the child process exits, this helper exits too.
|
15
|
+
|
16
|
+
Usage:
|
17
|
+
daemon-helper <signal> [--kill-group] [nostdin] COMMAND ...
|
18
|
+
"""
|
19
|
+
|
20
|
+
from __future__ import print_function
|
21
|
+
|
22
|
+
import fcntl
|
23
|
+
import os
|
24
|
+
import select
|
25
|
+
import signal
|
26
|
+
import struct
|
27
|
+
import subprocess
|
28
|
+
import sys
|
29
|
+
from argparse import ArgumentParser
|
30
|
+
|
31
|
+
parser = ArgumentParser(epilog=
|
32
|
+
'The remaining parameters are the command to be run. If these\n' +
|
33
|
+
'parameters start wih nostdin, then no stdin input is expected.')
|
34
|
+
parser.add_argument('signal')
|
35
|
+
parser.add_argument('--kill-group', action='store_true',
|
36
|
+
help='kill all processes in the group')
|
37
|
+
parser.add_argument('--nostdin', action='store_true',
|
38
|
+
help='no stdin input expected')
|
39
|
+
parsed, args = parser.parse_known_args()
|
40
|
+
end_signal = signal.SIGKILL
|
41
|
+
if parsed.signal == 'term':
|
42
|
+
end_signal = signal.SIGTERM
|
43
|
+
group = parsed.kill_group
|
44
|
+
nostdin = parsed.nostdin
|
45
|
+
skip_nostdin = 0
|
46
|
+
try:
|
47
|
+
if args[0] == 'nostdin':
|
48
|
+
nostdin = True
|
49
|
+
skip_nostdin = 1
|
50
|
+
except IndexError:
|
51
|
+
print('No command specified')
|
52
|
+
sys.exit(1)
|
53
|
+
|
54
|
+
|
55
|
+
proc = None
|
56
|
+
if nostdin:
|
57
|
+
if len(args) - skip_nostdin == 0:
|
58
|
+
print('No command specified')
|
59
|
+
sys.exit(1)
|
60
|
+
proc = subprocess.Popen(
|
61
|
+
args=args[skip_nostdin:],
|
62
|
+
)
|
63
|
+
else:
|
64
|
+
with open('/dev/null', 'rb') as devnull:
|
65
|
+
proc = subprocess.Popen(
|
66
|
+
args=args,
|
67
|
+
stdin=devnull,
|
68
|
+
preexec_fn=os.setsid,
|
69
|
+
)
|
70
|
+
|
71
|
+
flags = fcntl.fcntl(0, fcntl.F_GETFL)
|
72
|
+
fcntl.fcntl(0, fcntl.F_SETFL, flags | os.O_NDELAY)
|
73
|
+
|
74
|
+
saw_eof = False
|
75
|
+
while True:
|
76
|
+
r,w,x = select.select([0], [], [0], 0.2)
|
77
|
+
if r:
|
78
|
+
data = os.read(0, 1)
|
79
|
+
if not data:
|
80
|
+
saw_eof = True
|
81
|
+
if not group:
|
82
|
+
proc.send_signal(end_signal)
|
83
|
+
else:
|
84
|
+
os.killpg(proc.pid, end_signal)
|
85
|
+
break
|
86
|
+
else:
|
87
|
+
sig, = struct.unpack('!b', data)
|
88
|
+
if not group:
|
89
|
+
proc.send_signal(sig)
|
90
|
+
else:
|
91
|
+
os.killpg(proc.pid, end_signal)
|
92
|
+
|
93
|
+
|
94
|
+
if proc.poll() is not None:
|
95
|
+
# child exited
|
96
|
+
break
|
97
|
+
|
98
|
+
exitstatus = proc.wait()
|
99
|
+
if exitstatus > 0:
|
100
|
+
print('{me}: command failed with exit status {exitstatus:d}'.format(
|
101
|
+
me=os.path.basename(sys.argv[0]),
|
102
|
+
exitstatus=exitstatus,
|
103
|
+
), file=sys.stderr)
|
104
|
+
sys.exit(exitstatus)
|
105
|
+
elif exitstatus < 0:
|
106
|
+
if saw_eof and exitstatus == -end_signal:
|
107
|
+
# suppress error from the exit we intentionally caused
|
108
|
+
pass
|
109
|
+
else:
|
110
|
+
print('{me}: command crashed with signal {signal:d}'.format(
|
111
|
+
me=os.path.basename(sys.argv[0]),
|
112
|
+
signal=-exitstatus,
|
113
|
+
), file=sys.stderr)
|
114
|
+
sys.exit(1)
|
@@ -0,0 +1,263 @@
|
|
1
|
+
#!python
|
2
|
+
|
3
|
+
# Forward stdin to a subcommand. If EOF is read from stdin or
|
4
|
+
# stdin/stdout/stderr are closed or hungup, then give the command "timeout"
|
5
|
+
# seconds to complete before it is killed.
|
6
|
+
#
|
7
|
+
# The command is run in a separate process group. This is mostly to simplify
|
8
|
+
# killing the set of processes (if well-behaving). You can configure that with
|
9
|
+
# --setpgrp switch.
|
10
|
+
|
11
|
+
# usage: stdin-killer [-h] [--timeout TIMEOUT] [--debug DEBUG] [--signal SIGNAL] [--verbose] [--setpgrp {no,self,child}] command [arguments ...]
|
12
|
+
#
|
13
|
+
# wait for stdin EOF then kill forked subcommand
|
14
|
+
#
|
15
|
+
# positional arguments:
|
16
|
+
# command command to execute
|
17
|
+
# arguments arguments to command
|
18
|
+
#
|
19
|
+
# options:
|
20
|
+
# -h, --help show this help message and exit
|
21
|
+
# --timeout TIMEOUT time to wait for forked subcommand to willing terminate
|
22
|
+
# --debug DEBUG debug file
|
23
|
+
# --signal SIGNAL signal to send
|
24
|
+
# --verbose increase debugging
|
25
|
+
# --setpgrp {no,self,child}
|
26
|
+
# create process group
|
27
|
+
|
28
|
+
|
29
|
+
import argparse
|
30
|
+
import fcntl
|
31
|
+
import logging
|
32
|
+
import os
|
33
|
+
import select
|
34
|
+
import signal
|
35
|
+
import struct
|
36
|
+
import subprocess
|
37
|
+
import sys
|
38
|
+
import time
|
39
|
+
|
40
|
+
NAME = "stdin-killer"
|
41
|
+
|
42
|
+
log = logging.getLogger(NAME)
|
43
|
+
PAGE_SIZE = 4096
|
44
|
+
|
45
|
+
POLL_HANGUP = select.POLLHUP | (select.POLLRDHUP if hasattr(select, 'POLLRDHUP') else 0) | select.POLLERR
|
46
|
+
|
47
|
+
|
48
|
+
def handle_event(poll, buffer, fd, event, p):
|
49
|
+
if sigfdr == fd:
|
50
|
+
b = os.read(sigfdr, 1)
|
51
|
+
(signum,) = struct.unpack("B", b)
|
52
|
+
log.debug("got signal %d", signum)
|
53
|
+
try:
|
54
|
+
p.wait(timeout=0)
|
55
|
+
return True
|
56
|
+
except subprocess.TimeoutExpired:
|
57
|
+
pass
|
58
|
+
elif 0 == fd:
|
59
|
+
if event & POLL_HANGUP:
|
60
|
+
log.debug("peer closed connection, waiting for process exit")
|
61
|
+
poll.unregister(0)
|
62
|
+
sys.stdin.close()
|
63
|
+
if len(buffer) == 0 and p.stdin is not None:
|
64
|
+
p.stdin.close()
|
65
|
+
p.stdin = None
|
66
|
+
return True
|
67
|
+
elif event & select.POLLIN:
|
68
|
+
b = os.read(0, PAGE_SIZE)
|
69
|
+
if b == b"":
|
70
|
+
log.debug("read EOF")
|
71
|
+
poll.unregister(0)
|
72
|
+
sys.stdin.close()
|
73
|
+
if len(buffer) == 0:
|
74
|
+
p.stdin.close()
|
75
|
+
return True
|
76
|
+
if p.stdin is not None:
|
77
|
+
buffer += b
|
78
|
+
# ignore further POLLIN until buffer is written to p.stdin
|
79
|
+
poll.register(0, POLL_HANGUP)
|
80
|
+
poll.register(p.stdin.fileno(), select.POLLOUT)
|
81
|
+
elif p.stdin is not None and p.stdin.fileno() == fd:
|
82
|
+
assert event & select.POLLOUT
|
83
|
+
b = buffer[:PAGE_SIZE]
|
84
|
+
log.debug("sending %d bytes to process", len(b))
|
85
|
+
try:
|
86
|
+
n = p.stdin.write(b)
|
87
|
+
p.stdin.flush()
|
88
|
+
log.debug("wrote %d bytes", n)
|
89
|
+
buffer = buffer[n:]
|
90
|
+
poll.register(0, select.POLLIN | POLL_HANGUP)
|
91
|
+
poll.unregister(p.stdin.fileno())
|
92
|
+
except BrokenPipeError:
|
93
|
+
log.debug("got SIGPIPE")
|
94
|
+
poll.unregister(p.stdin.fileno())
|
95
|
+
p.stdin.close()
|
96
|
+
p.stdin = None
|
97
|
+
return True
|
98
|
+
except BlockingIOError:
|
99
|
+
poll.register(p.stdin.fileno(), select.POLLOUT | POLL_HANGUP)
|
100
|
+
elif 1 == fd:
|
101
|
+
assert event & POLL_HANGUP
|
102
|
+
log.debug("stdout pipe has closed")
|
103
|
+
poll.unregister(1)
|
104
|
+
return True
|
105
|
+
elif 2 == fd:
|
106
|
+
assert event & POLL_HANGUP
|
107
|
+
log.debug("stderr pipe has closed")
|
108
|
+
poll.unregister(2)
|
109
|
+
return True
|
110
|
+
else:
|
111
|
+
assert False
|
112
|
+
return False
|
113
|
+
|
114
|
+
|
115
|
+
def listen_for_events(sigfdr, p, timeout):
|
116
|
+
poll = select.poll()
|
117
|
+
# listen for data on stdin
|
118
|
+
poll.register(0, select.POLLIN | POLL_HANGUP)
|
119
|
+
# listen for stdout/stderr to be closed, if they are closed then my parent
|
120
|
+
# is gone and I should expire the command and myself.
|
121
|
+
poll.register(1, POLL_HANGUP)
|
122
|
+
poll.register(2, POLL_HANGUP)
|
123
|
+
# for SIGCHLD
|
124
|
+
poll.register(sigfdr, select.POLLIN)
|
125
|
+
buffer = bytearray()
|
126
|
+
expired = 0.0
|
127
|
+
while True:
|
128
|
+
if expired > 0.0:
|
129
|
+
since = time.monotonic() - expired
|
130
|
+
wait = int((timeout - since) * 1000.0)
|
131
|
+
if wait <= 0:
|
132
|
+
return
|
133
|
+
else:
|
134
|
+
wait = 5000
|
135
|
+
log.debug("polling for %d milliseconds", wait)
|
136
|
+
events = poll.poll(wait)
|
137
|
+
for fd, event in events:
|
138
|
+
log.debug("event: (%d, %d)", fd, event)
|
139
|
+
if handle_event(poll, buffer, fd, event, p):
|
140
|
+
if p.returncode is not None:
|
141
|
+
return
|
142
|
+
if expired == 0.0:
|
143
|
+
expired = time.monotonic()
|
144
|
+
log.info(
|
145
|
+
"expiration expected; waiting %d seconds for command to complete",
|
146
|
+
NS.timeout,
|
147
|
+
)
|
148
|
+
|
149
|
+
|
150
|
+
if __name__ == "__main__":
|
151
|
+
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
152
|
+
try:
|
153
|
+
(sigfdr, sigfdw) = os.pipe2(os.O_NONBLOCK | os.O_CLOEXEC)
|
154
|
+
except AttributeError:
|
155
|
+
# pipe2 is only available on "some flavors of Unix"
|
156
|
+
# https://docs.python.org/3.10/library/os.html?highlight=pipe2#os.pipe2
|
157
|
+
pipe_ends = os.pipe()
|
158
|
+
for fd in pipe_ends:
|
159
|
+
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
160
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK | os.O_CLOEXEC)
|
161
|
+
(sigfdr, sigfdw) = pipe_ends
|
162
|
+
|
163
|
+
signal.set_wakeup_fd(sigfdw)
|
164
|
+
|
165
|
+
def do_nothing(signum, frame):
|
166
|
+
pass
|
167
|
+
|
168
|
+
signal.signal(signal.SIGCHLD, do_nothing)
|
169
|
+
|
170
|
+
P = argparse.ArgumentParser(
|
171
|
+
description="wait for stdin EOF then kill forked subcommand"
|
172
|
+
)
|
173
|
+
P.add_argument(
|
174
|
+
"--timeout",
|
175
|
+
action="store",
|
176
|
+
default=5,
|
177
|
+
help="time to wait for forked subcommand to willing terminate",
|
178
|
+
type=int,
|
179
|
+
)
|
180
|
+
P.add_argument("--debug", action="store", help="debug file", type=str)
|
181
|
+
P.add_argument(
|
182
|
+
"--signal",
|
183
|
+
action="store",
|
184
|
+
help="signal to send",
|
185
|
+
type=int,
|
186
|
+
default=signal.SIGKILL,
|
187
|
+
)
|
188
|
+
P.add_argument("--verbose", action="store_true", help="increase debugging")
|
189
|
+
P.add_argument(
|
190
|
+
"--setpgrp",
|
191
|
+
action="store",
|
192
|
+
choices=["no", "self", "child"],
|
193
|
+
default="self",
|
194
|
+
help="create process group",
|
195
|
+
)
|
196
|
+
P.add_argument(
|
197
|
+
"cmd", metavar="command", type=str, nargs=1, help="command to execute"
|
198
|
+
)
|
199
|
+
P.add_argument(
|
200
|
+
"args", metavar="arguments", type=str, nargs="*", help="arguments to command"
|
201
|
+
)
|
202
|
+
NS = P.parse_args()
|
203
|
+
|
204
|
+
logargs = {}
|
205
|
+
if NS.debug is not None:
|
206
|
+
logargs["filename"] = NS.debug
|
207
|
+
else:
|
208
|
+
logargs["stream"] = sys.stderr
|
209
|
+
if NS.verbose:
|
210
|
+
logargs["level"] = logging.DEBUG
|
211
|
+
else:
|
212
|
+
logargs["level"] = logging.INFO
|
213
|
+
logargs["format"] = f"%(asctime)s {NAME} %(levelname)s: %(message)s"
|
214
|
+
logargs["datefmt"] = "%Y-%m-%dT%H:%M:%S"
|
215
|
+
logging.basicConfig(**logargs)
|
216
|
+
|
217
|
+
cargs = NS.cmd + NS.args
|
218
|
+
popen_kwargs = {
|
219
|
+
"stdin": subprocess.PIPE,
|
220
|
+
}
|
221
|
+
|
222
|
+
if NS.setpgrp == "self":
|
223
|
+
pgrp = os.getpgrp()
|
224
|
+
if pgrp != os.getpid():
|
225
|
+
os.setpgrp()
|
226
|
+
pgrp = os.getpgrp()
|
227
|
+
elif NS.setpgrp == "child":
|
228
|
+
popen_kwargs["preexec_fn"] = os.setpgrp
|
229
|
+
pgrp = None
|
230
|
+
elif NS.setpgrp == "no":
|
231
|
+
pgrp = 0
|
232
|
+
else:
|
233
|
+
assert False
|
234
|
+
|
235
|
+
log.debug("executing %s", cargs)
|
236
|
+
p = subprocess.Popen(cargs, **popen_kwargs)
|
237
|
+
if pgrp is None:
|
238
|
+
pgrp = p.pid
|
239
|
+
flags = fcntl.fcntl(p.stdin.fileno(), fcntl.F_GETFL)
|
240
|
+
fcntl.fcntl(p.stdin.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
241
|
+
|
242
|
+
listen_for_events(sigfdr, p, NS.timeout)
|
243
|
+
|
244
|
+
if p.returncode is None:
|
245
|
+
log.error("timeout expired: sending signal %d to command and myself", NS.signal)
|
246
|
+
if pgrp == 0:
|
247
|
+
os.kill(p.pid, NS.signal)
|
248
|
+
else:
|
249
|
+
os.killpg(pgrp, NS.signal) # should kill me too
|
250
|
+
os.kill(os.getpid(), NS.signal) # to exit abnormally with same signal
|
251
|
+
log.error("signal did not cause termination, sending myself SIGKILL")
|
252
|
+
os.kill(os.getpid(), signal.SIGKILL) # failsafe
|
253
|
+
rc = p.returncode
|
254
|
+
log.debug("rc = %d", rc)
|
255
|
+
assert rc is not None
|
256
|
+
if rc < 0:
|
257
|
+
log.error("command terminated with signal %d: sending same signal to myself!", -rc)
|
258
|
+
os.kill(os.getpid(), -rc) # kill myself with the same signal
|
259
|
+
log.error("signal did not cause termination, sending myself SIGKILL")
|
260
|
+
os.kill(os.getpid(), signal.SIGKILL) # failsafe
|
261
|
+
else:
|
262
|
+
log.info("command exited with status %d: exiting normally with same code!", rc)
|
263
|
+
sys.exit(rc)
|