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.
Files changed (170) hide show
  1. scripts/describe.py +1 -0
  2. scripts/dispatcher.py +55 -26
  3. scripts/exporter.py +18 -0
  4. scripts/lock.py +1 -1
  5. scripts/node_cleanup.py +58 -0
  6. scripts/openstack.py +9 -9
  7. scripts/results.py +12 -11
  8. scripts/schedule.py +4 -0
  9. scripts/suite.py +57 -16
  10. scripts/supervisor.py +44 -0
  11. scripts/update_inventory.py +10 -4
  12. teuthology/__init__.py +24 -26
  13. teuthology/beanstalk.py +4 -3
  14. teuthology/config.py +16 -6
  15. teuthology/contextutil.py +18 -14
  16. teuthology/describe_tests.py +25 -18
  17. teuthology/dispatcher/__init__.py +210 -35
  18. teuthology/dispatcher/supervisor.py +140 -58
  19. teuthology/exceptions.py +43 -0
  20. teuthology/exporter.py +347 -0
  21. teuthology/kill.py +76 -81
  22. teuthology/lock/cli.py +3 -3
  23. teuthology/lock/ops.py +135 -61
  24. teuthology/lock/query.py +61 -44
  25. teuthology/ls.py +1 -1
  26. teuthology/misc.py +61 -75
  27. teuthology/nuke/__init__.py +12 -353
  28. teuthology/openstack/__init__.py +4 -3
  29. teuthology/openstack/openstack-centos-7.0-user-data.txt +1 -1
  30. teuthology/openstack/openstack-centos-7.1-user-data.txt +1 -1
  31. teuthology/openstack/openstack-centos-7.2-user-data.txt +1 -1
  32. teuthology/openstack/openstack-debian-8.0-user-data.txt +1 -1
  33. teuthology/openstack/openstack-opensuse-42.1-user-data.txt +1 -1
  34. teuthology/openstack/openstack-teuthology.cron +0 -1
  35. teuthology/orchestra/cluster.py +49 -7
  36. teuthology/orchestra/connection.py +17 -4
  37. teuthology/orchestra/console.py +111 -50
  38. teuthology/orchestra/daemon/cephadmunit.py +15 -2
  39. teuthology/orchestra/daemon/state.py +8 -1
  40. teuthology/orchestra/daemon/systemd.py +4 -4
  41. teuthology/orchestra/opsys.py +30 -11
  42. teuthology/orchestra/remote.py +405 -338
  43. teuthology/orchestra/run.py +3 -3
  44. teuthology/packaging.py +19 -16
  45. teuthology/provision/__init__.py +30 -10
  46. teuthology/provision/cloud/openstack.py +12 -6
  47. teuthology/provision/cloud/util.py +1 -2
  48. teuthology/provision/downburst.py +4 -3
  49. teuthology/provision/fog.py +68 -20
  50. teuthology/provision/openstack.py +5 -4
  51. teuthology/provision/pelagos.py +1 -1
  52. teuthology/repo_utils.py +43 -13
  53. teuthology/report.py +57 -35
  54. teuthology/results.py +5 -3
  55. teuthology/run.py +13 -14
  56. teuthology/run_tasks.py +27 -43
  57. teuthology/schedule.py +4 -3
  58. teuthology/scrape.py +28 -22
  59. teuthology/suite/__init__.py +74 -45
  60. teuthology/suite/build_matrix.py +34 -24
  61. teuthology/suite/fragment-merge.lua +105 -0
  62. teuthology/suite/matrix.py +31 -2
  63. teuthology/suite/merge.py +175 -0
  64. teuthology/suite/placeholder.py +6 -9
  65. teuthology/suite/run.py +175 -100
  66. teuthology/suite/util.py +64 -218
  67. teuthology/task/__init__.py +1 -1
  68. teuthology/task/ansible.py +101 -32
  69. teuthology/task/buildpackages.py +2 -2
  70. teuthology/task/ceph_ansible.py +13 -6
  71. teuthology/task/cephmetrics.py +2 -1
  72. teuthology/task/clock.py +33 -14
  73. teuthology/task/exec.py +18 -0
  74. teuthology/task/hadoop.py +2 -2
  75. teuthology/task/install/__init__.py +29 -7
  76. teuthology/task/install/bin/adjust-ulimits +16 -0
  77. teuthology/task/install/bin/daemon-helper +114 -0
  78. teuthology/task/install/bin/stdin-killer +263 -0
  79. teuthology/task/install/deb.py +1 -1
  80. teuthology/task/install/rpm.py +17 -5
  81. teuthology/task/install/util.py +3 -3
  82. teuthology/task/internal/__init__.py +41 -10
  83. teuthology/task/internal/edit_sudoers.sh +10 -0
  84. teuthology/task/internal/lock_machines.py +2 -9
  85. teuthology/task/internal/redhat.py +31 -1
  86. teuthology/task/internal/syslog.py +31 -8
  87. teuthology/task/kernel.py +152 -145
  88. teuthology/task/lockfile.py +1 -1
  89. teuthology/task/mpi.py +10 -10
  90. teuthology/task/pcp.py +1 -1
  91. teuthology/task/selinux.py +16 -8
  92. teuthology/task/ssh_keys.py +4 -4
  93. teuthology/timer.py +3 -3
  94. teuthology/util/loggerfile.py +19 -0
  95. teuthology/util/scanner.py +159 -0
  96. teuthology/util/sentry.py +52 -0
  97. teuthology/util/time.py +52 -0
  98. teuthology-1.2.1.data/scripts/adjust-ulimits +16 -0
  99. teuthology-1.2.1.data/scripts/daemon-helper +114 -0
  100. teuthology-1.2.1.data/scripts/stdin-killer +263 -0
  101. teuthology-1.2.1.dist-info/METADATA +88 -0
  102. teuthology-1.2.1.dist-info/RECORD +168 -0
  103. {teuthology-1.1.0.dist-info → teuthology-1.2.1.dist-info}/WHEEL +1 -1
  104. {teuthology-1.1.0.dist-info → teuthology-1.2.1.dist-info}/entry_points.txt +3 -2
  105. scripts/nuke.py +0 -47
  106. scripts/worker.py +0 -37
  107. teuthology/lock/test/__init__.py +0 -0
  108. teuthology/lock/test/test_lock.py +0 -7
  109. teuthology/nuke/actions.py +0 -456
  110. teuthology/openstack/test/__init__.py +0 -0
  111. teuthology/openstack/test/openstack-integration.py +0 -286
  112. teuthology/openstack/test/test_config.py +0 -35
  113. teuthology/openstack/test/test_openstack.py +0 -1695
  114. teuthology/orchestra/test/__init__.py +0 -0
  115. teuthology/orchestra/test/integration/__init__.py +0 -0
  116. teuthology/orchestra/test/integration/test_integration.py +0 -94
  117. teuthology/orchestra/test/test_cluster.py +0 -240
  118. teuthology/orchestra/test/test_connection.py +0 -106
  119. teuthology/orchestra/test/test_console.py +0 -217
  120. teuthology/orchestra/test/test_opsys.py +0 -404
  121. teuthology/orchestra/test/test_remote.py +0 -185
  122. teuthology/orchestra/test/test_run.py +0 -286
  123. teuthology/orchestra/test/test_systemd.py +0 -54
  124. teuthology/orchestra/test/util.py +0 -12
  125. teuthology/task/tests/__init__.py +0 -110
  126. teuthology/task/tests/test_locking.py +0 -25
  127. teuthology/task/tests/test_run.py +0 -40
  128. teuthology/test/__init__.py +0 -0
  129. teuthology/test/fake_archive.py +0 -107
  130. teuthology/test/fake_fs.py +0 -92
  131. teuthology/test/integration/__init__.py +0 -0
  132. teuthology/test/integration/test_suite.py +0 -86
  133. teuthology/test/task/__init__.py +0 -205
  134. teuthology/test/task/test_ansible.py +0 -624
  135. teuthology/test/task/test_ceph_ansible.py +0 -176
  136. teuthology/test/task/test_console_log.py +0 -88
  137. teuthology/test/task/test_install.py +0 -337
  138. teuthology/test/task/test_internal.py +0 -57
  139. teuthology/test/task/test_kernel.py +0 -243
  140. teuthology/test/task/test_pcp.py +0 -379
  141. teuthology/test/task/test_selinux.py +0 -35
  142. teuthology/test/test_config.py +0 -189
  143. teuthology/test/test_contextutil.py +0 -68
  144. teuthology/test/test_describe_tests.py +0 -316
  145. teuthology/test/test_email_sleep_before_teardown.py +0 -81
  146. teuthology/test/test_exit.py +0 -97
  147. teuthology/test/test_get_distro.py +0 -47
  148. teuthology/test/test_get_distro_version.py +0 -47
  149. teuthology/test/test_get_multi_machine_types.py +0 -27
  150. teuthology/test/test_job_status.py +0 -60
  151. teuthology/test/test_ls.py +0 -48
  152. teuthology/test/test_misc.py +0 -391
  153. teuthology/test/test_nuke.py +0 -290
  154. teuthology/test/test_packaging.py +0 -763
  155. teuthology/test/test_parallel.py +0 -28
  156. teuthology/test/test_repo_utils.py +0 -225
  157. teuthology/test/test_report.py +0 -77
  158. teuthology/test/test_results.py +0 -155
  159. teuthology/test/test_run.py +0 -239
  160. teuthology/test/test_safepath.py +0 -55
  161. teuthology/test/test_schedule.py +0 -45
  162. teuthology/test/test_scrape.py +0 -167
  163. teuthology/test/test_timer.py +0 -80
  164. teuthology/test/test_vps_os_vers_parameter_checking.py +0 -84
  165. teuthology/test/test_worker.py +0 -303
  166. teuthology/worker.py +0 -354
  167. teuthology-1.1.0.dist-info/METADATA +0 -76
  168. teuthology-1.1.0.dist-info/RECORD +0 -213
  169. {teuthology-1.1.0.dist-info → teuthology-1.2.1.dist-info}/LICENSE +0 -0
  170. {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
- from datetime import datetime
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.utcfromtimestamp(time)
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
+
@@ -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)