atex 0.11__py3-none-any.whl → 0.12__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.
atex/cli/testingfarm.py CHANGED
@@ -2,7 +2,6 @@ import sys
2
2
  import json
3
3
  import pprint
4
4
  import collections
5
- from datetime import datetime, timedelta, UTC
6
5
 
7
6
  from .. import util
8
7
  from ..provisioner.testingfarm import api as tf
@@ -32,7 +31,8 @@ def composes(args):
32
31
  comps = api.composes(ranch=args.ranch)
33
32
  comps_list = comps["composes"]
34
33
  for comp in comps_list:
35
- print(comp["name"])
34
+ if comp["type"] == "compose":
35
+ print(comp["name"])
36
36
 
37
37
 
38
38
  def get_request(args):
@@ -48,36 +48,63 @@ def cancel(args):
48
48
 
49
49
  def search_requests(args):
50
50
  api = _get_api(args)
51
- reply = api.search_requests(
52
- state=args.state,
53
- mine=not args.all,
54
- user_id=args.user_id,
55
- token_id=args.token_id,
56
- ranch=args.ranch,
57
- created_before=args.before,
58
- created_after=args.after,
59
- )
60
- if not reply:
61
- return
62
51
 
63
- if args.json:
64
- for req in sorted(reply, key=lambda x: x["created"]):
65
- print(json.dumps(req))
52
+ func_kwargs = {
53
+ "mine": not args.all,
54
+ "user_id": args.user_id,
55
+ "token_id": args.token_id,
56
+ "ranch": args.ranch,
57
+ "created_before": args.before,
58
+ "created_after": args.after,
59
+ }
60
+
61
+ if args.page is not None:
62
+ reply = api.search_requests_paged(
63
+ state=args.state,
64
+ page=args.page,
65
+ **func_kwargs,
66
+ )
67
+ if not reply:
68
+ return
66
69
  else:
67
- for req in sorted(reply, key=lambda x: x["created"]):
68
- req_id = req["id"]
69
- created = req["created"].partition(".")[0]
70
+ reply = api.search_requests(
71
+ state=args.state,
72
+ **func_kwargs,
73
+ )
74
+ if not reply:
75
+ return
76
+ reply = sorted(reply, key=lambda x: x["created"])
70
77
 
71
- envs = []
72
- for env in req["environments_requested"]:
73
- if "os" in env and env["os"] and "compose" in env["os"]:
74
- compose = env["os"]["compose"]
75
- arch = env["arch"]
76
- if compose and arch:
77
- envs.append(f"{compose}@{arch}")
78
- envs_str = ", ".join(envs)
78
+ if args.json:
79
+ for req in reply:
80
+ print(json.dumps(req))
81
+ return
79
82
 
80
- print(f"{created} {req_id} : {envs_str}")
83
+ for req in reply:
84
+ req_id = req["id"]
85
+ created = req["created"].partition(".")[0]
86
+
87
+ if "fmf" in req["test"] and req["test"]["fmf"]:
88
+ test = req["test"]["fmf"]["url"]
89
+ elif "tmt" in req["test"] and req["test"]["tmt"]:
90
+ test = req["test"]["tmf"]["url"]
91
+ else:
92
+ test = ""
93
+
94
+ envs = []
95
+ for env in req["environments_requested"]:
96
+ if "os" in env and env["os"] and "compose" in env["os"]:
97
+ compose = env["os"]["compose"]
98
+ arch = env["arch"]
99
+ if compose and arch:
100
+ envs.append(f"{compose}@{arch}")
101
+
102
+ print(f"{created} {req_id}", end="")
103
+ if test:
104
+ print(f" | test:{test}", end="")
105
+ if envs:
106
+ print(f" | envs:[{', '.join(envs)}]", end="")
107
+ print()
81
108
 
82
109
 
83
110
  def stats(args):
@@ -110,35 +137,29 @@ def stats(args):
110
137
  print(f"{count:>{digits}} {repo_url}")
111
138
 
112
139
  def request_search_results():
113
- for state in args.states.split(","):
114
- result = api.search_requests(
115
- state=state,
116
- ranch=args.ranch,
117
- mine=False,
118
- )
119
- if result:
120
- yield from result
121
-
122
- def multiday_request_search_results():
123
- now = datetime.now(UTC)
124
- for day in range(0,args.days):
125
- before = now - timedelta(days=day)
126
- after = now - timedelta(days=day+1)
140
+ if args.before is not None or args.after is not None:
127
141
  for state in args.states.split(","):
128
- result = api.search_requests(
142
+ reply = api.search_requests_paged(
129
143
  state=state,
130
- created_before=before.replace(microsecond=0).isoformat(),
131
- created_after=after.replace(microsecond=0).isoformat(),
144
+ page=args.page,
145
+ mine=False,
132
146
  ranch=args.ranch,
147
+ created_before=args.before,
148
+ created_after=args.after,
149
+ )
150
+ if reply:
151
+ yield from reply
152
+ else:
153
+ for state in args.states.split(","):
154
+ reply = api.search_requests(
155
+ state=state,
133
156
  mine=False,
157
+ ranch=args.ranch,
134
158
  )
135
- if result:
136
- yield from result
159
+ if reply:
160
+ yield from reply
137
161
 
138
- if args.days is not None:
139
- top_users_repos(multiday_request_search_results())
140
- else:
141
- top_users_repos(request_search_results())
162
+ top_users_repos(request_search_results())
142
163
 
143
164
 
144
165
  def reserve(args):
@@ -258,12 +279,15 @@ def parse_args(parser):
258
279
  cmd.add_argument("--before", help="only requests created before ISO8601")
259
280
  cmd.add_argument("--after", help="only requests created after ISO8601")
260
281
  cmd.add_argument("--json", help="full details, one request per line", action="store_true")
282
+ cmd.add_argument("--page", help="do paged search, page interval in secs", type=int)
261
283
 
262
284
  cmd = cmds.add_parser(
263
285
  "stats",
264
286
  help="print out TF usage statistics",
265
287
  )
266
- cmd.add_argument("--days", type=int, help="query last N days instead of all TF requests")
288
+ cmd.add_argument("--before", help="only requests created before ISO8601")
289
+ cmd.add_argument("--after", help="only requests created after ISO8601")
290
+ cmd.add_argument("--page", help="do paged search, page interval in secs", type=int)
267
291
  cmd.add_argument("ranch", help="Testing Farm ranch name")
268
292
  cmd.add_argument("states", help="comma-separated TF request states")
269
293
 
atex/executor/__init__.py CHANGED
@@ -1,2 +1,23 @@
1
- from . import testcontrol # noqa: F401
2
- from .executor import Executor # noqa: F401
1
+ class ExecutorError(Exception):
2
+ """
3
+ Raised by class Executor.
4
+ """
5
+ pass
6
+
7
+
8
+ class TestSetupError(ExecutorError):
9
+ """
10
+ Raised when the preparation for test execution (ie. pkg install) fails.
11
+ """
12
+ pass
13
+
14
+
15
+ class TestAbortedError(ExecutorError):
16
+ """
17
+ Raised when an infrastructure-related issue happened while running a test.
18
+ """
19
+ pass
20
+
21
+
22
+ from . import testcontrol # noqa: F401, E402
23
+ from .executor import Executor # noqa: F401, E402
atex/executor/executor.py CHANGED
@@ -8,18 +8,11 @@ import subprocess
8
8
  from pathlib import Path
9
9
 
10
10
  from .. import util, fmf
11
- from . import testcontrol, scripts
11
+ from . import TestSetupError, TestAbortedError, testcontrol, scripts
12
12
  from .duration import Duration
13
13
  from .reporter import Reporter
14
14
 
15
15
 
16
- class TestAbortedError(Exception):
17
- """
18
- Raised when an infrastructure-related issue happened while running a test.
19
- """
20
- pass
21
-
22
-
23
16
  class Executor:
24
17
  """
25
18
  Logic for running tests on a remote system and processing results
@@ -222,16 +215,6 @@ class Executor:
222
215
  output_dir = Path(output_dir)
223
216
  test_data = self.fmf_tests.tests[test_name]
224
217
 
225
- # run a setup script, preparing wrapper + test scripts
226
- setup_script = scripts.test_setup(
227
- test=scripts.Test(test_name, test_data, self.fmf_tests.test_dirs[test_name]),
228
- tests_dir=self.tests_dir,
229
- wrapper_exec=f"{self.work_dir}/wrapper.sh",
230
- test_exec=f"{self.work_dir}/test.sh",
231
- test_yaml=f"{self.work_dir}/metadata.yaml",
232
- )
233
- self.conn.cmd(("bash",), input=setup_script, text=True, check=True)
234
-
235
218
  # start with fmf-plan-defined environment
236
219
  env_vars = {
237
220
  **self.fmf_tests.plan_env,
@@ -253,6 +236,28 @@ class Executor:
253
236
  duration = Duration(test_data.get("duration", "5m"))
254
237
  control = testcontrol.TestControl(reporter=reporter, duration=duration)
255
238
 
239
+ # run a setup script, preparing wrapper + test scripts
240
+ setup_script = scripts.test_setup(
241
+ test=scripts.Test(test_name, test_data, self.fmf_tests.test_dirs[test_name]),
242
+ tests_dir=self.tests_dir,
243
+ wrapper_exec=f"{self.work_dir}/wrapper.sh",
244
+ test_exec=f"{self.work_dir}/test.sh",
245
+ test_yaml=f"{self.work_dir}/metadata.yaml",
246
+ )
247
+ setup_proc = self.conn.cmd(
248
+ ("bash",),
249
+ input=setup_script,
250
+ stdout=subprocess.PIPE,
251
+ stderr=subprocess.STDOUT,
252
+ text=True,
253
+ )
254
+ if setup_proc.returncode != 0:
255
+ reporter.report({
256
+ "status": "infra",
257
+ "note": f"TestSetupError({setup_proc.stdout})",
258
+ })
259
+ raise TestSetupError(setup_proc.stdout)
260
+
256
261
  test_proc = None
257
262
  control_fd = None
258
263
  stack.callback(lambda: os.close(control_fd) if control_fd else None)
atex/executor/reporter.py CHANGED
@@ -85,16 +85,15 @@ class Reporter:
85
85
  file_path.parent.mkdir(parents=True, exist_ok=True)
86
86
  return file_path
87
87
 
88
- def open_file(self, file_name, result_name=None, mode="wb"):
88
+ def open_fd(self, file_name, mode, result_name=None):
89
89
  """
90
90
  Open a file named 'file_name' in a directory relevant to 'result_name'.
91
- Returns an opened file-like object that can be used in a context manager
92
- just like with regular open().
91
+ Returns an opened file descriptor that can be closed with os.close().
93
92
 
94
93
  If 'result_name' (typically a subtest) is not given, open the file
95
94
  for the test (name) itself.
96
95
  """
97
- return open(self._dest_path(file_name, result_name), mode)
96
+ return os.open(self._dest_path(file_name, result_name), mode)
98
97
 
99
98
  def link_testout(self, file_name, result_name=None):
100
99
  # TODO: docstring
atex/executor/scripts.py CHANGED
@@ -86,7 +86,8 @@ def test_wrapper(*, test, tests_dir, test_exec):
86
86
  out += ")\n"
87
87
 
88
88
  # write test exitcode to test control stream
89
- out += "echo exitcode $? >&$orig_stdout\n"
89
+ if os.environ.get("ATEX_DEBUG_NO_EXITCODE") != "1":
90
+ out += "echo exitcode $? >&$orig_stdout\n"
90
91
 
91
92
  # always exit the wrapper with 0 if test execution was normal
92
93
  out += "exit 0\n"
@@ -94,17 +95,6 @@ def test_wrapper(*, test, tests_dir, test_exec):
94
95
  return out
95
96
 
96
97
 
97
- def _install_packages(pkgs, extra_opts=None):
98
- pkgs_str = " ".join(pkgs)
99
- extra_opts = extra_opts or ()
100
- dnf = ["dnf", "-y", "--setopt=install_weak_deps=False", "install", *extra_opts]
101
- dnf_str = " ".join(dnf)
102
- return util.dedent(fr"""
103
- not_installed=$(rpm -q --qf '' {pkgs_str} | sed -nr 's/^package ([^ ]+) is not installed$/\1/p')
104
- [[ $not_installed ]] && {dnf_str} $not_installed
105
- """) # noqa: E501
106
-
107
-
108
98
  def test_setup(*, test, wrapper_exec, test_exec, test_yaml, **kwargs):
109
99
  """
110
100
  Generate a bash script that should prepare the remote end for test
@@ -133,9 +123,19 @@ def test_setup(*, test, wrapper_exec, test_exec, test_yaml, **kwargs):
133
123
  # install test dependencies
134
124
  # - only strings (package names) in require/recommend are supported
135
125
  if require := list(fmf.test_pkg_requires(test.data, "require")):
136
- out += _install_packages(require) + "\n"
126
+ pkgs_str = " ".join(require)
127
+ out += util.dedent(fr"""
128
+ not_installed=$(rpm -q --qf '' {pkgs_str} | sed -nr 's/^package ([^ ]+) is not installed$/\1/p')
129
+ [[ $not_installed ]] && dnf -y --setopt=install_weak_deps=False install $not_installed
130
+ """) + "\n" # noqa: E501
137
131
  if recommend := list(fmf.test_pkg_requires(test.data, "recommend")):
138
- out += _install_packages(recommend, ("--skip-broken",)) + "\n"
132
+ pkgs_str = " ".join(recommend)
133
+ out += util.dedent(fr"""
134
+ have_dnf5=$(command -v dnf5) || true
135
+ skip_bad="--skip-broken${{have_dnf5:+ --skip-unavailable}}"
136
+ not_installed=$(rpm -q --qf '' {pkgs_str} | sed -nr 's/^package ([^ ]+) is not installed$/\1/p')
137
+ [[ $not_installed ]] && dnf -y --setopt=install_weak_deps=False install $skip_bad $not_installed
138
+ """) + "\n" # noqa: E501
139
139
 
140
140
  # write out test data
141
141
  out += f"cat > '{test_yaml}' <<'ATEX_SETUP_EOF'\n"
@@ -267,40 +267,45 @@ class TestControl:
267
267
  except ValueError as e:
268
268
  raise BadReportJSONError(f"file entry {file_name} length: {str(e)}") from None
269
269
 
270
+ fd = self.reporter.open_fd(file_name, os.O_WRONLY | os.O_CREAT, name)
270
271
  try:
271
- with self.reporter.open_file(file_name, name) as f:
272
- fd = f.fileno()
273
- while file_length > 0:
272
+ # Linux can't do splice(2) on O_APPEND fds, so we open it above
273
+ # as O_WRONLY and just seek to the end, simulating append
274
+ os.lseek(fd, 0, os.SEEK_END)
275
+
276
+ while file_length > 0:
277
+ try:
278
+ # try a more universal sendfile first, fall back to splice
274
279
  try:
275
- # try a more universal sendfile first, fall back to splice
276
- try:
277
- written = os.sendfile(fd, self.control_fd, None, file_length)
278
- except OSError as e:
279
- if e.errno == 22: # EINVAL
280
- written = os.splice(self.control_fd, fd, file_length)
281
- else:
282
- raise
283
- except BlockingIOError:
284
- yield
285
- continue
286
- if written == 0:
287
- raise BadControlError("EOF when reading data")
288
- file_length -= written
280
+ written = os.sendfile(fd, self.control_fd, None, file_length)
281
+ except OSError as e:
282
+ if e.errno == 22: # EINVAL
283
+ written = os.splice(self.control_fd, fd, file_length)
284
+ else:
285
+ raise
286
+ except BlockingIOError:
289
287
  yield
290
- except FileExistsError:
291
- raise BadReportJSONError(f"file '{file_name}' already exists") from None
288
+ continue
289
+ if written == 0:
290
+ raise BadControlError("EOF when reading data")
291
+ file_length -= written
292
+ yield
293
+ finally:
294
+ os.close(fd)
292
295
 
293
296
  # either store partial result + return,
294
297
  # or load previous partial result and merge into it
295
- partial = result.get("partial", False)
296
- if partial:
297
- # do not store the 'partial' key in the result
298
+ partial = result.get("partial")
299
+ if partial is not None:
300
+ # do not store the 'partial' key in the result, even if False
298
301
  del result["partial"]
299
- # note that nameless result will get None as dict key,
300
- # which is perfectly fine
301
- self._merge(self.partial_results[name], result)
302
- # partial = do nothing
303
- return
302
+ # if it exists and is True
303
+ if partial:
304
+ # note that nameless result will get None as dict key,
305
+ # which is perfectly fine
306
+ self._merge(self.partial_results[name], result)
307
+ # partial = do nothing
308
+ return
304
309
 
305
310
  # if previously-stored partial result exist, merge the current one
306
311
  # into it, but then use the merged result
@@ -1,5 +1,4 @@
1
1
  import tempfile
2
- import collections
3
2
  import concurrent.futures
4
3
  from pathlib import Path
5
4
 
@@ -60,7 +59,7 @@ class AdHocOrchestrator(Orchestrator):
60
59
 
61
60
  def __init__(
62
61
  self, platform, fmf_tests, provisioners, aggregator, tmp_dir, *,
63
- max_remotes=1, max_spares=0, max_reruns=2, max_failed_setups=10, env=None,
62
+ max_remotes=1, max_spares=0, max_failed_setups=10, env=None,
64
63
  ):
65
64
  """
66
65
  'platform' is a string with platform name.
@@ -84,15 +83,15 @@ class AdHocOrchestrator(Orchestrator):
84
83
  speed up test reruns as Remote reservation happens asynchronously
85
84
  to test execution. Spares are reserved on top of 'max_remotes'.
86
85
 
87
- 'max_reruns' is an integer of how many times to re-try running a failed
88
- test (which exited with non-0 or caused an Executor exception).
89
-
90
86
  'max_failed_setups' is an integer of how many times an Executor's
91
87
  plan setup (uploading tests, running prepare scripts, etc.) may fail
92
88
  before FailedSetupError is raised.
93
89
 
94
90
  'env' is a dict of extra environment variables to pass to Executor.
95
91
  """
92
+ if not fmf_tests.tests:
93
+ raise ValueError("'fmf_tests' has no tests (bad discover params?)")
94
+
96
95
  self.platform = platform
97
96
  self.fmf_tests = fmf_tests
98
97
  self.provisioners = tuple(provisioners)
@@ -101,11 +100,11 @@ class AdHocOrchestrator(Orchestrator):
101
100
  self.failed_setups_left = max_failed_setups
102
101
  self.max_remotes = max_remotes
103
102
  self.max_spares = max_spares
104
- # indexed by test name, value being integer of how many times
105
- self.reruns = collections.defaultdict(lambda: max_reruns)
106
103
  self.env = env
107
104
  # tests still waiting to be run
108
105
  self.to_run = set(fmf_tests.tests)
106
+ # number of Remotes being provisioned + set up (not running tests)
107
+ self.remotes_requested = 0
109
108
  # running tests as a dict, indexed by test name, with RunningInfo values
110
109
  self.running_tests = {}
111
110
  # thread queue for actively running tests
@@ -159,22 +158,6 @@ class AdHocOrchestrator(Orchestrator):
159
158
  'finfo' is a FinishedInfo instance.
160
159
  """
161
160
  test_data = self.fmf_tests.tests[finfo.test_name]
162
-
163
- # TODO: somehow move logging from was_successful and should_be_rerun here,
164
- # probably print just some generic info from those functions that doesn't
165
- # imply any outcome, ie.
166
- # {remote_with_test} threw {exception}
167
- # {remote_with_test} exited with {code}
168
- # {remote_with_test} has {N} reruns left
169
- # {remote_with_test} has 0 reruns left
170
- # and then log the decision separately, here below, such as
171
- # {remote_with_test} failed, re-running
172
- # {remote_with_test} completed, ingesting result
173
- # {remote_with_test} was destructive, releasing remote
174
- # {remote_with_test} ...., running next test
175
- # That allows the user to override the functions, while keeping critical
176
- # flow reliably logged here.
177
-
178
161
  remote_with_test = f"{finfo.remote}: '{finfo.test_name}'"
179
162
 
180
163
  if not self.was_successful(finfo, test_data) and self.should_be_rerun(finfo, test_data):
@@ -215,31 +198,33 @@ class AdHocOrchestrator(Orchestrator):
215
198
  tmp_dir=None,
216
199
  )
217
200
 
218
- # if destroyed, release the remote and request a replacement
219
- # (Executor exception is always considered destructive)
220
- if finfo.exception or self.destructive(finfo, test_data):
221
- util.debug(f"{remote_with_test} was destructive, releasing remote")
201
+ # if there are still tests to be run and the last test was not
202
+ # destructive, just run a new test on it
203
+ if self.to_run and not (finfo.exception or self.destructive(finfo, test_data)):
204
+ util.debug(f"{remote_with_test} was non-destructive, running next test")
205
+ self._run_new_test(finfo)
206
+ return
207
+
208
+ # we are not running a new test right now, serve_once() might run it
209
+ # some time later, just decide what to do with the current remote
210
+
211
+ if self.remotes_requested >= len(self.to_run):
212
+ # we have enough remotes in the pipe to run every test,
213
+ # we don't need a new one - just release the current one
214
+ util.debug(f"{finfo.remote} no longer useful, releasing it")
222
215
  self.release_queue.start_thread(
223
216
  finfo.remote.release,
224
217
  remote=finfo.remote,
225
218
  )
226
- # TODO: should this be conditioned by 'self.to_run:' ? to not uselessly fall
227
- # into setup spares and get immediately released after setup?
228
- finfo.provisioner.provision(1)
229
-
230
- # if still not destroyed, run another test on it
231
- # (without running plan setup, re-using already set up remote)
232
- elif self.to_run:
233
- util.debug(f"{remote_with_test} was non-destructive, running next test")
234
- self._run_new_test(finfo)
235
-
236
- # no more tests to run, release the remote
237
219
  else:
238
- util.debug(f"{finfo.remote} no longer useful, releasing it")
220
+ # we need more remotes and the last test was destructive,
221
+ # get a new one and let serve_once() run a test later
222
+ util.debug(f"{remote_with_test} was destructive, getting a new Remote")
239
223
  self.release_queue.start_thread(
240
224
  finfo.remote.release,
241
225
  remote=finfo.remote,
242
226
  )
227
+ finfo.provisioner.provision(1)
243
228
 
244
229
  def serve_once(self):
245
230
  """
@@ -286,6 +271,7 @@ class AdHocOrchestrator(Orchestrator):
286
271
  except util.ThreadQueue.Empty:
287
272
  break
288
273
 
274
+ self.remotes_requested -= 1
289
275
  sinfo = treturn.sinfo
290
276
 
291
277
  if treturn.exception:
@@ -295,10 +281,11 @@ class AdHocOrchestrator(Orchestrator):
295
281
  sinfo.remote.release,
296
282
  remote=sinfo.remote,
297
283
  )
298
- if (reruns_left := self.failed_setups_left) > 0:
299
- util.warning(f"{msg}, re-trying ({reruns_left} setup retries left)")
284
+ if (retries_left := self.failed_setups_left) > 0:
285
+ util.warning(f"{msg}, re-trying ({retries_left} setup retries left)")
300
286
  self.failed_setups_left -= 1
301
287
  sinfo.provisioner.provision(1)
288
+ self.remotes_requested += 1
302
289
  else:
303
290
  util.warning(f"{msg}, setup retries exceeded, giving up")
304
291
  raise FailedSetupError("setup retries limit exceeded, broken infra?")
@@ -318,8 +305,9 @@ class AdHocOrchestrator(Orchestrator):
318
305
  treturn.sinfo.remote.release,
319
306
  remote=treturn.sinfo.remote,
320
307
  )
308
+ self.remotes_requested -= 1
321
309
 
322
- # try to get new remotes from Provisioners - if we get some, start
310
+ # try to get new Remotes from Provisioners - if we get some, start
323
311
  # running setup on them
324
312
  for provisioner in self.provisioners:
325
313
  while (remote := provisioner.get_remote(block=False)) is not None:
@@ -371,9 +359,12 @@ class AdHocOrchestrator(Orchestrator):
371
359
  for prov in self.provisioners:
372
360
  prov.start()
373
361
 
362
+ # just the base remotes, no spares
363
+ self.remotes_requested = min(self.max_remotes, len(self.fmf_tests.tests))
364
+
374
365
  # start up initial reservations, balanced evenly across all available
375
366
  # provisioner instances
376
- count = min(self.max_remotes, len(self.fmf_tests.tests)) + self.max_spares
367
+ count = self.remotes_requested + self.max_spares
377
368
  provisioners = self.provisioners[:count]
378
369
  for idx, prov in enumerate(provisioners):
379
370
  if count % len(provisioners) > idx:
@@ -480,24 +471,20 @@ class AdHocOrchestrator(Orchestrator):
480
471
  'test_data' is a dict of fully resolved fmf test metadata of that test.
481
472
  """
482
473
  remote_with_test = f"{info.remote}: '{info.test_name}'"
483
-
484
474
  # executor (or test) threw exception
485
475
  if info.exception:
486
476
  exc_str = f"{type(info.exception).__name__}({info.exception})"
487
477
  util.info(f"{remote_with_test} threw {exc_str} during test runtime")
488
478
  return False
489
-
490
479
  # the test exited as non-0
491
480
  if info.exit_code != 0:
492
481
  util.info(f"{remote_with_test} exited with non-zero: {info.exit_code}")
493
482
  return False
494
-
495
483
  # otherwise we good
496
484
  return True
497
485
 
498
- # TODO: @staticmethod and remove ARG002
499
- #@staticmethod
500
- def should_be_rerun(self, info, test_data): # noqa: ARG004, ARG002
486
+ @staticmethod
487
+ def should_be_rerun(info, test_data): # noqa: ARG004
501
488
  """
502
489
  Return a boolean result whether a finished test failed in a way
503
490
  that another execution attempt might succeed, due to race conditions
@@ -507,17 +494,5 @@ class AdHocOrchestrator(Orchestrator):
507
494
 
508
495
  'test_data' is a dict of fully resolved fmf test metadata of that test.
509
496
  """
510
- remote_with_test = f"{info.remote}: '{info.test_name}'"
511
-
512
- # TODO: remove self.reruns and the whole X-reruns logic from AdHocOrchestrator,
513
- # leave it up to the user to wrap should_be_rerun() with an external dict
514
- # of tests, counting reruns for each
515
- # - allows the user to adjust counts per-test (ie. test_data metadata)
516
- # - allows this template to be @staticmethod
517
- reruns_left = self.reruns[info.test_name]
518
- util.info(f"{remote_with_test}: {reruns_left} reruns left")
519
- if reruns_left > 0:
520
- self.reruns[info.test_name] -= 1
521
- return True
522
- else:
523
- return False
497
+ # never rerun by default
498
+ return False
@@ -1,3 +1,5 @@
1
+ import collections
2
+
1
3
  from .. import util
2
4
  from .adhoc import AdHocOrchestrator
3
5
 
@@ -26,9 +28,18 @@ class ContestOrchestrator(AdHocOrchestrator):
26
28
  """
27
29
  content_dir_on_remote = "/root/upstream-content"
28
30
 
29
- def __init__(self, *args, content_dir, **kwargs):
30
- self.content_dir = content_dir
31
+ def __init__(self, *args, content_dir, max_reruns=1, **kwargs):
32
+ """
33
+ 'content_dir' is a filesystem path to ComplianceAsCode/content local
34
+ directory, to be uploaded to the tested systems.
35
+
36
+ 'max_reruns' is an integer of how many times to re-try running a failed
37
+ test (which exited with non-0 or caused an Executor exception).
38
+ """
31
39
  super().__init__(*args, **kwargs)
40
+ self.content_dir = content_dir
41
+ # indexed by test name, value being integer of how many times
42
+ self.reruns = collections.defaultdict(lambda: max_reruns)
32
43
 
33
44
  def run_setup(self, sinfo):
34
45
  super().run_setup(sinfo)
@@ -72,8 +83,8 @@ class ContestOrchestrator(AdHocOrchestrator):
72
83
  # fallback to the default next_test()
73
84
  return super().next_test(to_run, all_tests, previous)
74
85
 
75
- @classmethod
76
- def destructive(cls, info, test_data):
86
+ @staticmethod
87
+ def destructive(info, test_data):
77
88
  # if Executor ended with an exception (ie. duration exceeded),
78
89
  # consider the test destructive
79
90
  if info.exception:
@@ -92,3 +103,14 @@ class ContestOrchestrator(AdHocOrchestrator):
92
103
  return True
93
104
 
94
105
  return False
106
+
107
+ def should_be_rerun(self, info, test_data): # noqa: ARG004, ARG002
108
+ remote_with_test = f"{info.remote}: '{info.test_name}'"
109
+
110
+ reruns_left = self.reruns[info.test_name]
111
+ util.info(f"{remote_with_test}: {reruns_left} reruns left")
112
+ if reruns_left > 0:
113
+ self.reruns[info.test_name] -= 1
114
+ return True
115
+ else:
116
+ return False
@@ -2,6 +2,7 @@ import os
2
2
  import re
3
3
  import time
4
4
  import tempfile
5
+ import datetime
5
6
  import textwrap
6
7
  import threading
7
8
  import subprocess
@@ -14,11 +15,11 @@ from ... import util
14
15
  import json
15
16
  import urllib3
16
17
 
17
- DEFAULT_API_URL = "https://api.testing-farm.io/v0.1"
18
+ DEFAULT_API_URL = "https://api.testing-farm.io"
18
19
 
19
20
  DEFAULT_RESERVE_TEST = {
20
21
  "url": "https://github.com/RHSecurityCompliance/atex-reserve",
21
- "ref": "0.11",
22
+ "ref": "0.12",
22
23
  "path": ".",
23
24
  "name": "/plans/reserve",
24
25
  }
@@ -85,8 +86,8 @@ class TestingFarmAPI:
85
86
  self.api_url = url
86
87
  self.api_token = token or os.environ.get("TESTING_FARM_API_TOKEN")
87
88
 
88
- def _query(self, method, path, *args, headers=None, auth=True, **kwargs):
89
- url = f"{self.api_url}{path}"
89
+ def _query(self, method, path, *args, headers=None, auth=True, version="v0.1", **kwargs):
90
+ url = f"{self.api_url}/{version}{path}"
90
91
  if self.api_token and auth:
91
92
  if headers is not None:
92
93
  headers["Authorization"] = f"Bearer {self.api_token}"
@@ -137,7 +138,7 @@ class TestingFarmAPI:
137
138
  if not self.api_token:
138
139
  raise ValueError("composes() requires an auth token to identify ranch")
139
140
  ranch = self.whoami()["token"]["ranch"]
140
- return self._query("GET", f"/composes/{ranch}")
141
+ return self._query("GET", f"/composes/{ranch}", version="v0.2")
141
142
 
142
143
  def search_requests(
143
144
  self, *, state, ranch=None,
@@ -180,6 +181,52 @@ class TestingFarmAPI:
180
181
 
181
182
  return self._query("GET", "/requests", fields=fields, auth=mine)
182
183
 
184
+ def search_requests_paged(self, *args, page=43200, **kwargs):
185
+ """
186
+ An unofficial wrapper for search_requests() that can search a large
187
+ interval incrementally (in "pages") and yield batches of results.
188
+
189
+ Needs 'created_after', with 'created_before' defaulting to now().
190
+
191
+ 'page' specifies the time interval of one page, in seconds.
192
+
193
+ 'args' and 'kwargs' are passed to search_requests().
194
+ """
195
+ assert "created_after" in kwargs, "at least 'created_after' is needed for paging"
196
+
197
+ def from_iso8601(date):
198
+ dt = datetime.datetime.fromisoformat(date)
199
+ # if no TZ is specified, treat it as UTC, not localtime
200
+ if dt.tzinfo is None:
201
+ dt = dt.replace(tzinfo=datetime.UTC)
202
+ # convert to UTC
203
+ else:
204
+ dt = dt.astimezone(datetime.UTC)
205
+ return dt
206
+
207
+ after = from_iso8601(kwargs["created_after"])
208
+ if kwargs.get("created_before"):
209
+ before = from_iso8601(kwargs["created_before"])
210
+ else:
211
+ before = datetime.datetime.now(datetime.UTC)
212
+
213
+ # scale down page size to fit between after/before
214
+ page = min(page, (before - after).total_seconds())
215
+
216
+ start = after
217
+ while start < before:
218
+ end = start + datetime.timedelta(seconds=page)
219
+ # clamp to real 'before'
220
+ end = min(end, before)
221
+ new_kwargs = kwargs | {
222
+ "created_after": start.isoformat(),
223
+ "created_before": end.isoformat(),
224
+ }
225
+ found = self.search_requests(*args, **new_kwargs)
226
+ if found is not None:
227
+ yield from found
228
+ start = end
229
+
183
230
  def get_request(self, request_id):
184
231
  """
185
232
  'request_id' is the UUID (string) of the request.
@@ -570,7 +617,8 @@ class Reserve:
570
617
  # installs our ssh pubkey into authorized_keys)
571
618
  ssh_attempt_cmd = (
572
619
  "ssh", "-q", "-i", ssh_key.absolute(), "-oConnectionAttempts=60",
573
- "-oStrictHostKeyChecking=no", "-oUserKnownHostsFile=/dev/null",
620
+ "-oStrictHostKeyChecking=no", "-oUserKnownHostsFile=/dev/null",
621
+ "-oBatchMode=yes",
574
622
  f"{ssh_user}@{ssh_host}", "exit 123",
575
623
  )
576
624
  while True:
atex/util/log.py CHANGED
@@ -30,7 +30,7 @@ skip_levels = {
30
30
 
31
31
  def _log_msg(logger_func, *args, stacklevel=1, **kwargs):
32
32
  # inspect.stack() is MUCH slower
33
- caller = inspect.currentframe().f_back.f_back
33
+ caller = inspect.currentframe().f_back.f_back # TODO: sys._getframe(2)
34
34
  extra_levels = 2 # skip this func and the debug/info/warning parent
35
35
  while caller.f_back:
36
36
  code = caller.f_code
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atex
3
- Version: 0.11
3
+ Version: 0.12
4
4
  Summary: Ad-hoc Test EXecutor
5
5
  Project-URL: Homepage, https://github.com/RHSecurityCompliance/atex
6
6
  License-Expression: GPL-3.0-or-later
@@ -5,19 +5,19 @@ atex/aggregator/json.py,sha256=tpoUZoZM8EMYhZKwVr4LRtgEIDjRxC11BIKVXZKYPOs,10441
5
5
  atex/cli/__init__.py,sha256=Ew2z-gC0jvOmU_DqYgXVQla3p1rTnrz64I63q52aHv4,2899
6
6
  atex/cli/fmf.py,sha256=pvj_OIp6XT_nVUwziL7-v_HNbyAtuUmb7k_Ey_KkFJc,3616
7
7
  atex/cli/libvirt.py,sha256=6tt5ANb8XBBRXOQsYPTWILThKqf-gvt5AZh5Dctg2PA,3782
8
- atex/cli/testingfarm.py,sha256=ovgoogmIM2TglS7iQD3liMiEYYtcykS_HRRKbltpW2I,10131
8
+ atex/cli/testingfarm.py,sha256=MXfcKnzbmr71LrTpJcIghC6SfB4EBkKXh3Yy5SZlUUc,10744
9
9
  atex/connection/__init__.py,sha256=dj8ZBcEspom7Z_UjecfLGBRNvLZ3dyGR9q19i_B4xpY,3880
10
10
  atex/connection/podman.py,sha256=1T56gh1TgbcQWpTIJHL4NaxZOI6aMg7Xp7sn6PQQyBk,1911
11
11
  atex/connection/ssh.py,sha256=9A57b9YR_HI-kIu06Asic1y__JPVXEheDZxjbG2Qcsc,13460
12
- atex/executor/__init__.py,sha256=XCfhi7QDELjey7N1uzhMjc46Kp1Jsd5bOCf52I27SCE,85
12
+ atex/executor/__init__.py,sha256=nmYJCbC36fRGGkjoniFJmsq-sqFw8YS2ndf4q_loVM0,471
13
13
  atex/executor/duration.py,sha256=x06sItKOZi6XA8KszQwZGpIb1Z_L-HWqIwZKo2SDo0s,1759
14
- atex/executor/executor.py,sha256=toyLVQCDzfw381iEGrvOXoKPsd4SqxMZHwlDSTJGqKk,15792
15
- atex/executor/reporter.py,sha256=MceFmHFt0bTEClBZbRI1WnFbfMhR0e1noOzcu7gjKuQ,3403
16
- atex/executor/scripts.py,sha256=riJAQWsV-BFGkJwR2Dmf3R0ZRRZJs9w9iYnPpYaQNaE,5618
17
- atex/executor/testcontrol.py,sha256=mVrLwQUnDRfUq-5diz-80UvCWWxn1TkcBgmAKhKNb5E,12696
14
+ atex/executor/executor.py,sha256=WJXPWQo6VQhZgXORVVyvTDAdOQbbZz26E7FpwizbGIk,16126
15
+ atex/executor/reporter.py,sha256=QbzBkaXuhI6lsTYrTlp7O5W9d6etR0KjDdH-J59cXWM,3357
16
+ atex/executor/scripts.py,sha256=1u5ZEGJ7nIvkqbRK3uVusOkineVM8DXo4kAlH2MdQbg,5877
17
+ atex/executor/testcontrol.py,sha256=iju_Cl32D8NHH1ePN1lykR1noP8-0eBDLQ5-V_9DqF0,12834
18
18
  atex/orchestrator/__init__.py,sha256=8Q1YknyibilXLjWRYkHm_Mr2HMm0SRw8Zv39KypeASM,2059
19
- atex/orchestrator/adhoc.py,sha256=QpYoPeyQzYFDBM1zgFJKMXH1RtdJixbH5whVX0OP-14,21003
20
- atex/orchestrator/contest.py,sha256=ADmRlsZPQx-MJ6fWHmBcJOIy3DSPnvwVheVL9Upwtg0,3703
19
+ atex/orchestrator/adhoc.py,sha256=VUwHX71Vb6eRLzW3Z3KDZdck7p0PiwzAZrOuUKMkwtM,19667
20
+ atex/orchestrator/contest.py,sha256=SuxT9uZtcs_DEsA3hHyKgrIWNrDeqCCWd3-hy3sHytY,4572
21
21
  atex/provisioner/__init__.py,sha256=6hZxQlvTQ0yWWqCRCPqWMoYuim5wDMCcDIYHF-nIfMs,4013
22
22
  atex/provisioner/libvirt/VM_PROVISION,sha256=7pkZ-ozgTyK4qNGC-E-HUznr4IhbosWSASbB72Gknl8,2664
23
23
  atex/provisioner/libvirt/__init__.py,sha256=pKG5IpZSC2IHs5wL2ecQx_fd9AzAXEbZmDzA7RyZsfM,119
@@ -27,19 +27,19 @@ atex/provisioner/libvirt/setup-libvirt.sh,sha256=oCMy9SCnbC_QuAzO2sFwvB5ui1kMQ6u
27
27
  atex/provisioner/podman/__init__.py,sha256=dM0JzQXWX7edtWSc0KH0cMFXAjArFn2Vme4j_ZMsdYA,138
28
28
  atex/provisioner/podman/podman.py,sha256=ztRypoakSf-jF04iER58tEMUZ4Y6AuzIpNpFXp44bB4,4997
29
29
  atex/provisioner/testingfarm/__init__.py,sha256=kZncgLGdRCR4FMaRQr2GTwJ8vjlA-24ri8JO2ueZJuw,113
30
- atex/provisioner/testingfarm/api.py,sha256=dlXe9brzHERawIx2UTv34u2tOSskdZtXD68-u1MnOHk,21726
30
+ atex/provisioner/testingfarm/api.py,sha256=K3s87rWNqZ6q1AMkjYaNTNqIOZR-Bl1JonbKx67O9pQ,23549
31
31
  atex/provisioner/testingfarm/testingfarm.py,sha256=yvQzWat92B4UnJNZzCLI8mpAKf_QvHUKyKbjlk5123Q,8573
32
32
  atex/util/__init__.py,sha256=cWHFbtQ4mDlKe6lXyPDWRmWJOTcHDGfVuW_-GYa8hB0,1473
33
33
  atex/util/dedent.py,sha256=SEuJMtLzqz3dQ7g7qyZzEJ9VYynVlk52tQCJY-FveXo,603
34
34
  atex/util/libvirt.py,sha256=kDZmT6xLYEZkQNLZY98gJ2M48DDWXxHF8rQY9PnjB3U,660
35
- atex/util/log.py,sha256=KVR7ep8n5wtghsvBFCtHiPsMAQBdAmK83E_Jec5t4cU,2230
35
+ atex/util/log.py,sha256=GfdbLtpRkQoIkRU7AqWDWbJV7yZIpS4MsXhUomZqWjQ,2256
36
36
  atex/util/named_mapping.py,sha256=UBMe9TetjV-DGPhjYjJ42YtC40FVPKAAEROXl9MA5fo,4700
37
37
  atex/util/path.py,sha256=x-kXqiWCVodfZWbEwtC5A8LFvutpDIPYv2m0boZSlXU,504
38
38
  atex/util/ssh_keygen.py,sha256=9yuSl2yBV7pG3Qfsf9tossVC00nbIUrAeLdbwTykpjk,384
39
39
  atex/util/subprocess.py,sha256=_oQN8CNgGoH9GAR6nZlpujYe2HjXFBcCuIkLPw-IxJ4,2971
40
40
  atex/util/threads.py,sha256=c8hsEc-8SqJGodInorv_6JxpiHiSkGFGob4qbMmOD2M,3531
41
- atex-0.11.dist-info/METADATA,sha256=3fRMLBrkoRIHwbY2GheyNkrx4mNVmL_95wlxGjZsORc,3050
42
- atex-0.11.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
43
- atex-0.11.dist-info/entry_points.txt,sha256=pLqJdcfeyQTgup2h6dWb6SvkHhtOl-W5Eg9zV8moK0o,39
44
- atex-0.11.dist-info/licenses/COPYING.txt,sha256=oEuj51jdmbXcCUy7pZ-KE0BNcJTR1okudRp5zQ0yWnU,670
45
- atex-0.11.dist-info/RECORD,,
41
+ atex-0.12.dist-info/METADATA,sha256=v2DW_JNAKGa7yF6IVOpICXMant1I2qQNlj69Vxcv3rs,3050
42
+ atex-0.12.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
43
+ atex-0.12.dist-info/entry_points.txt,sha256=pLqJdcfeyQTgup2h6dWb6SvkHhtOl-W5Eg9zV8moK0o,39
44
+ atex-0.12.dist-info/licenses/COPYING.txt,sha256=oEuj51jdmbXcCUy7pZ-KE0BNcJTR1okudRp5zQ0yWnU,670
45
+ atex-0.12.dist-info/RECORD,,
File without changes