fm-weck 1.4.8__py3-none-any.whl → 1.5.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.
- fm_weck/__init__.py +1 -1
- fm_weck/capture.py +31 -0
- fm_weck/cli.py +279 -22
- fm_weck/engine.py +144 -12
- fm_weck/exceptions.py +58 -0
- fm_weck/file_util.py +5 -0
- fm_weck/grpc_service/__init__.py +9 -0
- fm_weck/grpc_service/fm_weck_client.py +148 -0
- fm_weck/grpc_service/fm_weck_server.py +175 -0
- fm_weck/grpc_service/proto/__init__.py +0 -0
- fm_weck/grpc_service/proto/fm_weck_service.proto +139 -0
- fm_weck/grpc_service/proto/fm_weck_service_pb2.py +73 -0
- fm_weck/grpc_service/proto/fm_weck_service_pb2.pyi +151 -0
- fm_weck/grpc_service/proto/fm_weck_service_pb2_grpc.py +331 -0
- fm_weck/grpc_service/proto/generate_protocol_files.sh +18 -0
- fm_weck/grpc_service/request_handling.py +332 -0
- fm_weck/grpc_service/run_store.py +38 -0
- fm_weck/grpc_service/server_utils.py +27 -0
- fm_weck/image_mgr.py +3 -1
- fm_weck/resources/Containerfile +1 -2
- fm_weck/resources/__init__.py +26 -8
- fm_weck/resources/c_program_example.c +627 -0
- fm_weck/run_result.py +1 -1
- fm_weck/serve.py +21 -14
- fm_weck/smoke_test_mode.py +82 -0
- {fm_weck-1.4.8.dist-info → fm_weck-1.5.1.dist-info}/METADATA +3 -1
- {fm_weck-1.4.8.dist-info → fm_weck-1.5.1.dist-info}/RECORD +29 -14
- {fm_weck-1.4.8.dist-info → fm_weck-1.5.1.dist-info}/WHEEL +0 -0
- {fm_weck-1.4.8.dist-info → fm_weck-1.5.1.dist-info}/entry_points.txt +0 -0
fm_weck/__init__.py
CHANGED
fm_weck/capture.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# This file is part of fm-weck: executing fm-tools in containerized environments.
|
|
2
|
+
# https://gitlab.com/sosy-lab/software/fm-weck
|
|
3
|
+
#
|
|
4
|
+
# SPDX-FileCopyrightText: 2024 Dirk Beyer <https://www.sosy-lab.org>
|
|
5
|
+
#
|
|
6
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Capture:
|
|
15
|
+
def __init__(self, target: io.TextIOBase, stream: Literal["stdout, stderr, stdin"] = "stdout"):
|
|
16
|
+
self.io_stream = getattr(sys, stream)
|
|
17
|
+
self._copy = os.dup(self.io_stream.fileno())
|
|
18
|
+
os.dup2(target.fileno(), self.io_stream.fileno())
|
|
19
|
+
|
|
20
|
+
def __del__(self):
|
|
21
|
+
# Ensure cleanup in case the context manager isn't used
|
|
22
|
+
self._cleanup()
|
|
23
|
+
|
|
24
|
+
def _cleanup(self):
|
|
25
|
+
os.dup2(self._copy, self.io_stream.fileno())
|
|
26
|
+
|
|
27
|
+
def __enter__(self):
|
|
28
|
+
return self # Return the instance for use within the context
|
|
29
|
+
|
|
30
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
31
|
+
self._cleanup() # Clean up resources upon leaving the context
|
fm_weck/cli.py
CHANGED
|
@@ -11,7 +11,6 @@ import logging
|
|
|
11
11
|
import os
|
|
12
12
|
from argparse import Namespace
|
|
13
13
|
from dataclasses import dataclass
|
|
14
|
-
from functools import cache
|
|
15
14
|
from pathlib import Path
|
|
16
15
|
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple, Union
|
|
17
16
|
|
|
@@ -39,7 +38,7 @@ import contextlib
|
|
|
39
38
|
from fm_weck import Config
|
|
40
39
|
from fm_weck.cache_mgr import ask_and_clear, clear_cache
|
|
41
40
|
from fm_weck.config import _SEARCH_ORDER
|
|
42
|
-
from fm_weck.resources import iter_fm_data,
|
|
41
|
+
from fm_weck.resources import fm_tools_choice_map, iter_fm_data, property_choice_map
|
|
43
42
|
|
|
44
43
|
from . import __version__
|
|
45
44
|
from .exceptions import NoImageError
|
|
@@ -112,9 +111,9 @@ class ShellCompletion:
|
|
|
112
111
|
def add_tool_arg(parser, nargs: str | None = "?"):
|
|
113
112
|
parser.add_argument(
|
|
114
113
|
"TOOL",
|
|
115
|
-
help="The tool to obtain the container from. Can be the form <tool>:<version>. "
|
|
114
|
+
help="The tool to obtain the container from. Can be in the form <tool>:<version>. "
|
|
116
115
|
"The TOOL is either the name of a bundled tool (c.f. fm-weck --list) or "
|
|
117
|
-
"the path to
|
|
116
|
+
"the path to an fm-tools YAML file.",
|
|
118
117
|
type=ToolQualifier,
|
|
119
118
|
nargs=nargs,
|
|
120
119
|
).completer = ShellCompletion.tool_completer
|
|
@@ -131,6 +130,27 @@ def add_shared_args_for_run_modes(parser):
|
|
|
131
130
|
add_tool_arg(parser, nargs=None)
|
|
132
131
|
|
|
133
132
|
|
|
133
|
+
def add_shared_args_for_client(parser):
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--host",
|
|
136
|
+
action="store",
|
|
137
|
+
dest="host",
|
|
138
|
+
type=str,
|
|
139
|
+
help=("Specifies the IP address and the port of the server."),
|
|
140
|
+
required=True,
|
|
141
|
+
default=None,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--timelimit",
|
|
146
|
+
action="store",
|
|
147
|
+
dest="timelimit",
|
|
148
|
+
type=str,
|
|
149
|
+
help=("Specifies the maximum amount of time to wait for the server to finish a run, in seconds."),
|
|
150
|
+
default=10,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
134
154
|
def parse(raw_args: list[str]) -> Tuple[Callable[[], None], Namespace]:
|
|
135
155
|
parser = argparse.ArgumentParser(description="fm-weck")
|
|
136
156
|
|
|
@@ -192,9 +212,9 @@ def parse(raw_args: list[str]) -> Tuple[Callable[[], None], Namespace]:
|
|
|
192
212
|
"--spec",
|
|
193
213
|
action="store",
|
|
194
214
|
help=(
|
|
195
|
-
"Property
|
|
215
|
+
"Property that is forwarded to the fm-tool."
|
|
196
216
|
" Either a path to a property file or a property name from SV-COMP or Test-Comp."
|
|
197
|
-
" Use fm-weck serve --list to
|
|
217
|
+
" Use fm-weck serve --list to view all properties that can be called by name."
|
|
198
218
|
),
|
|
199
219
|
required=False,
|
|
200
220
|
default=None,
|
|
@@ -318,6 +338,107 @@ def parse(raw_args: list[str]) -> Tuple[Callable[[], None], Namespace]:
|
|
|
318
338
|
).completer = ShellCompletion.versions_completer # type: ignore[assignment]
|
|
319
339
|
versions.set_defaults(main=main_versions)
|
|
320
340
|
|
|
341
|
+
server = subparsers.add_parser("server", aliases=["s"], help="Run fm-weck remotely on a server.")
|
|
342
|
+
server.add_argument(
|
|
343
|
+
"--port",
|
|
344
|
+
action="store",
|
|
345
|
+
dest="port",
|
|
346
|
+
type=str,
|
|
347
|
+
help=("Specifies the port number on which the server will listen."),
|
|
348
|
+
required=True,
|
|
349
|
+
default=None,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
server.add_argument(
|
|
353
|
+
"--listen",
|
|
354
|
+
action="store",
|
|
355
|
+
dest="ipaddr",
|
|
356
|
+
type=str,
|
|
357
|
+
help=("Specifies the IP address on which the server will listen."),
|
|
358
|
+
required=True,
|
|
359
|
+
default=None,
|
|
360
|
+
)
|
|
361
|
+
server.set_defaults(main=main_server)
|
|
362
|
+
|
|
363
|
+
client = subparsers.add_parser("remote-run", aliases=["rr"], help="Execute tasks remotely.")
|
|
364
|
+
client.add_argument(
|
|
365
|
+
"-p",
|
|
366
|
+
"--property",
|
|
367
|
+
"--spec",
|
|
368
|
+
action="store",
|
|
369
|
+
help=(
|
|
370
|
+
"Property that is forwarded to the fm-tool."
|
|
371
|
+
" Either a path to a property file or a property name from SV-COMP or Test-Comp."
|
|
372
|
+
" Use fm-weck serve --list to view all properties that can be called by name."
|
|
373
|
+
),
|
|
374
|
+
required=True,
|
|
375
|
+
default=None,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
add_shared_args_for_client(client)
|
|
379
|
+
add_tool_arg(client, nargs=None)
|
|
380
|
+
client.add_argument("files", metavar="FILES", nargs="+", help="Files to pass to the tool.")
|
|
381
|
+
client.set_defaults(main=main_client)
|
|
382
|
+
|
|
383
|
+
client_expert = subparsers.add_parser(
|
|
384
|
+
"remote-expert", aliases=["re"], help="Execute tasks remotely in expert mode."
|
|
385
|
+
)
|
|
386
|
+
add_shared_args_for_client(client_expert)
|
|
387
|
+
add_tool_arg(client_expert, nargs=None)
|
|
388
|
+
client_expert.add_argument("argument_list", metavar="args", nargs="*", help="Arguments for the fm-tool.")
|
|
389
|
+
client_expert.set_defaults(main=main_client_expert)
|
|
390
|
+
|
|
391
|
+
client_get_run = subparsers.add_parser(
|
|
392
|
+
"get-run", aliases=["gr"], help="Get the result for a remotely executed task."
|
|
393
|
+
)
|
|
394
|
+
add_shared_args_for_client(client_get_run)
|
|
395
|
+
client_get_run.add_argument("run_id", metavar="RUN-ID", nargs=1, help="The run ID for which to get the result.")
|
|
396
|
+
client_get_run.set_defaults(main=main_client_get_run)
|
|
397
|
+
|
|
398
|
+
client_query_files = subparsers.add_parser(
|
|
399
|
+
"query-files", aliases=["qf"], help="Get the resulting files for a remotely executed task."
|
|
400
|
+
)
|
|
401
|
+
client_query_files.add_argument("run_id", metavar="RUN-ID", nargs=1, help="The run ID for which to get the result.")
|
|
402
|
+
client_query_files.add_argument("file_names", metavar="files", nargs="*", help="Files to query for.")
|
|
403
|
+
add_shared_args_for_client(client_query_files)
|
|
404
|
+
client_query_files.add_argument(
|
|
405
|
+
"--output-path",
|
|
406
|
+
action="store",
|
|
407
|
+
dest="output_path",
|
|
408
|
+
type=Path,
|
|
409
|
+
help=(
|
|
410
|
+
"Specifies the location where the incoming files from the server will be stored, "
|
|
411
|
+
"relative to the current working directory."
|
|
412
|
+
),
|
|
413
|
+
default=Path.cwd(),
|
|
414
|
+
)
|
|
415
|
+
client_query_files.set_defaults(main=main_client_query_files)
|
|
416
|
+
|
|
417
|
+
smoke_test = subparsers.add_parser("smoke-test", help="Run a smoke test on the tool.")
|
|
418
|
+
smoke_test.add_argument(
|
|
419
|
+
"TOOL",
|
|
420
|
+
help="The tool for which to run the smoke test.",
|
|
421
|
+
type=ToolQualifier,
|
|
422
|
+
).completer = ShellCompletion.versions_completer # type: ignore[assignment]
|
|
423
|
+
|
|
424
|
+
smoke_test.add_argument(
|
|
425
|
+
"--gitlab-ci-mode",
|
|
426
|
+
action="store_true",
|
|
427
|
+
help="Run in GitLab CI mode: directly install required packages with apt instead of building/pulling images.",
|
|
428
|
+
required=False,
|
|
429
|
+
default=False,
|
|
430
|
+
)
|
|
431
|
+
smoke_test.add_argument(
|
|
432
|
+
"--competition-year",
|
|
433
|
+
action="store",
|
|
434
|
+
type=int,
|
|
435
|
+
help="Automatically select the tool version used in the specified competition year (e.g., 2025). "
|
|
436
|
+
"Searches for SV-COMP or Test-Comp participation in that year.",
|
|
437
|
+
required=False,
|
|
438
|
+
default=None,
|
|
439
|
+
)
|
|
440
|
+
smoke_test.set_defaults(main=main_smoke_test)
|
|
441
|
+
|
|
321
442
|
with contextlib.suppress(ImportError):
|
|
322
443
|
import argcomplete
|
|
323
444
|
|
|
@@ -341,22 +462,6 @@ def parse(raw_args: list[str]) -> Tuple[Callable[[], None], Namespace]:
|
|
|
341
462
|
return help_callback, parser.parse_args(raw_args)
|
|
342
463
|
|
|
343
464
|
|
|
344
|
-
@cache
|
|
345
|
-
def fm_tools_choice_map():
|
|
346
|
-
ignore = {
|
|
347
|
-
"schema.yml",
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
actors = {actor_def.stem: actor_def for actor_def in iter_fm_data() if (actor_def.name not in ignore)}
|
|
351
|
-
|
|
352
|
-
return actors
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
@cache
|
|
356
|
-
def property_choice_map():
|
|
357
|
-
return {spec_path.stem: spec_path for spec_path in iter_properties() if spec_path.suffix != ".license"}
|
|
358
|
-
|
|
359
|
-
|
|
360
465
|
def list_known_tools():
|
|
361
466
|
return fm_tools_choice_map().keys()
|
|
362
467
|
|
|
@@ -380,6 +485,48 @@ def resolve_property(prop_name: str) -> Path:
|
|
|
380
485
|
return property_choice_map()[prop_name]
|
|
381
486
|
|
|
382
487
|
|
|
488
|
+
def resolve_property_for_server(prop_name: str) -> Union[Path, str]:
|
|
489
|
+
if (as_path := Path(prop_name)).exists() and as_path.is_file():
|
|
490
|
+
return as_path
|
|
491
|
+
|
|
492
|
+
return prop_name
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def get_version_for_competition_year(tool_path: Path, year: int) -> Optional[str]:
|
|
496
|
+
"""
|
|
497
|
+
Find the tool version used in a competition for the given year.
|
|
498
|
+
Searches for SV-COMP or Test-Comp participation entries.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
tool_path: Path to the tool's YAML file
|
|
502
|
+
year: Competition year (e.g., 2025)
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Version string if found, None otherwise
|
|
506
|
+
"""
|
|
507
|
+
import yaml
|
|
508
|
+
|
|
509
|
+
if not tool_path.exists() or not tool_path.is_file():
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
with tool_path.open("r") as f:
|
|
513
|
+
data = yaml.safe_load(f)
|
|
514
|
+
|
|
515
|
+
competition_participations = data.get("competition_participations", [])
|
|
516
|
+
|
|
517
|
+
# Search for competition entries matching the year
|
|
518
|
+
for participation in competition_participations:
|
|
519
|
+
competition = participation.get("competition", "")
|
|
520
|
+
# Match "SV-COMP 2025", "Test-Comp 2025", etc.
|
|
521
|
+
if f"{year}" in competition and ("SV-COMP" in competition or "Test-Comp" in competition):
|
|
522
|
+
version = participation.get("tool_version")
|
|
523
|
+
if version:
|
|
524
|
+
logger.info("Found version '%s' for %s in %s", version, tool_path.stem, competition)
|
|
525
|
+
return version
|
|
526
|
+
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
383
530
|
def set_log_options(loglevel: Optional[str], logfile: Optional[str], config: dict[str, Any]):
|
|
384
531
|
level = "WARNING"
|
|
385
532
|
level = loglevel.upper() if loglevel else config.get("logging", {}).get("level", level)
|
|
@@ -519,6 +666,116 @@ def main_versions(args: argparse.Namespace):
|
|
|
519
666
|
VersionListing(tool_paths).print_versions()
|
|
520
667
|
|
|
521
668
|
|
|
669
|
+
def main_server(args: argparse.Namespace):
|
|
670
|
+
from .grpc_service import serve
|
|
671
|
+
|
|
672
|
+
serve(ipaddr=args.ipaddr, port=args.port)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def main_client(args: argparse.Namespace):
|
|
676
|
+
from .grpc_service import client_run
|
|
677
|
+
|
|
678
|
+
tool = resolve_tool(args.TOOL)
|
|
679
|
+
property = resolve_property(args.property)
|
|
680
|
+
timelimit, _ = check_client_options(args.timelimit)
|
|
681
|
+
|
|
682
|
+
client_run((tool, args.TOOL.version), args.host, property, args.files, timelimit)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def main_client_expert(args: argparse.Namespace):
|
|
686
|
+
from .grpc_service import client_expert_run
|
|
687
|
+
|
|
688
|
+
tool = resolve_tool(args.TOOL)
|
|
689
|
+
command = args.argument_list
|
|
690
|
+
client_expert_run((tool, args.TOOL.version), args.host, command, args.timelimit)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def main_client_get_run(args: argparse.Namespace):
|
|
694
|
+
from .grpc_service import client_get_run
|
|
695
|
+
|
|
696
|
+
timelimit, _ = check_client_options(args.timelimit)
|
|
697
|
+
client_get_run(args.host, args.run_id[0], timelimit)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def main_client_query_files(args: argparse.Namespace):
|
|
701
|
+
from .grpc_service import query_files
|
|
702
|
+
|
|
703
|
+
timelimit, _ = check_client_options(args.timelimit)
|
|
704
|
+
file_names = args.file_names
|
|
705
|
+
|
|
706
|
+
query_files(args.host, args.run_id[0], file_names, timelimit, args.output_path)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def check_client_options(timelimit_arg, output_path=None):
|
|
710
|
+
try:
|
|
711
|
+
timelimit = int(timelimit_arg)
|
|
712
|
+
except ValueError:
|
|
713
|
+
logger.error("Invalid timelimit value: %s. It must be an integer.", timelimit_arg)
|
|
714
|
+
exit(1)
|
|
715
|
+
|
|
716
|
+
if output_path:
|
|
717
|
+
output_path = Path.cwd() / Path(output_path)
|
|
718
|
+
if not output_path.exists():
|
|
719
|
+
try:
|
|
720
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
721
|
+
except Exception as e:
|
|
722
|
+
logger.error("Failed to create output path %s: %s", output_path, e)
|
|
723
|
+
exit(1)
|
|
724
|
+
|
|
725
|
+
return timelimit, output_path
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def main_smoke_test(args: argparse.Namespace):
|
|
729
|
+
from .serve import setup_fm_tool
|
|
730
|
+
from .smoke_test_mode import run_smoke_test, run_smoke_test_gitlab_ci
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
tool = resolve_tool(args.TOOL)
|
|
734
|
+
except KeyError:
|
|
735
|
+
logger.error("Unknown tool: %s", args.TOOL.tool)
|
|
736
|
+
return 1
|
|
737
|
+
|
|
738
|
+
# Handle --competition-year flag
|
|
739
|
+
version = args.TOOL.version
|
|
740
|
+
if args.competition_year:
|
|
741
|
+
if version:
|
|
742
|
+
logger.warning(
|
|
743
|
+
"Both explicit version '%s' and --competition-year %d specified. "
|
|
744
|
+
"Using competition year to determine version.",
|
|
745
|
+
version,
|
|
746
|
+
args.competition_year,
|
|
747
|
+
)
|
|
748
|
+
competition_version = get_version_for_competition_year(tool, args.competition_year)
|
|
749
|
+
if competition_version:
|
|
750
|
+
logger.info("Using version '%s' for competition year %d", competition_version, args.competition_year)
|
|
751
|
+
version = competition_version
|
|
752
|
+
else:
|
|
753
|
+
logger.error("No competition participation found for year %d in tool %s", args.competition_year, tool.stem)
|
|
754
|
+
return 1
|
|
755
|
+
|
|
756
|
+
fm_data, shelve_space = setup_fm_tool(
|
|
757
|
+
fm_tool=tool,
|
|
758
|
+
version=version,
|
|
759
|
+
configuration=Config(),
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
if args.gitlab_ci_mode:
|
|
763
|
+
from subprocess import CalledProcessError
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
run_smoke_test_gitlab_ci(fm_data, shelve_space)
|
|
767
|
+
except CalledProcessError as e:
|
|
768
|
+
return e.returncode
|
|
769
|
+
|
|
770
|
+
return 0
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
run_smoke_test(fm_data, shelve_space, Config())
|
|
774
|
+
except ValueError as e:
|
|
775
|
+
logger.error(e)
|
|
776
|
+
return 1
|
|
777
|
+
|
|
778
|
+
|
|
522
779
|
def log_no_image_error(tool, config):
|
|
523
780
|
order = []
|
|
524
781
|
for path in _SEARCH_ORDER:
|
fm_weck/engine.py
CHANGED
|
@@ -7,17 +7,23 @@
|
|
|
7
7
|
|
|
8
8
|
import io
|
|
9
9
|
import logging
|
|
10
|
+
import os
|
|
10
11
|
import platform
|
|
12
|
+
import shlex
|
|
11
13
|
import shutil
|
|
12
14
|
import signal
|
|
13
15
|
import subprocess
|
|
14
16
|
import sys
|
|
17
|
+
import threading
|
|
15
18
|
from abc import ABC, abstractmethod
|
|
16
19
|
from functools import cached_property, singledispatchmethod
|
|
17
20
|
from pathlib import Path
|
|
18
21
|
from tempfile import mkdtemp
|
|
22
|
+
from threading import Thread
|
|
19
23
|
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Union
|
|
20
24
|
|
|
25
|
+
from fm_weck.capture import Capture
|
|
26
|
+
from fm_weck.file_util import ensure_linux_style
|
|
21
27
|
from fm_weck.run_result import RunResult
|
|
22
28
|
|
|
23
29
|
try:
|
|
@@ -56,8 +62,11 @@ class Engine(ABC):
|
|
|
56
62
|
print_output_to_stdout: bool = True
|
|
57
63
|
add_benchexec_capabilities: bool = False
|
|
58
64
|
add_mounting_capabilities: bool = True
|
|
65
|
+
_use_overlay: bool = False
|
|
66
|
+
overlay_tool_dir: Optional[str] = None
|
|
59
67
|
image: Optional[str] = None
|
|
60
68
|
dry_run: bool = False
|
|
69
|
+
work_dir: Optional[Path] = Path(CWD_MOUNT_LOCATION)
|
|
61
70
|
|
|
62
71
|
def __init__(self, image: Union[str, FmImageConfig]):
|
|
63
72
|
self._tmp_output_dir = Path(mkdtemp("fm_weck_output")).resolve()
|
|
@@ -77,11 +86,13 @@ class Engine(ABC):
|
|
|
77
86
|
def get_workdir(self) -> str:
|
|
78
87
|
return Path(CWD_MOUNT_LOCATION).as_posix()
|
|
79
88
|
|
|
80
|
-
def
|
|
81
|
-
self.log_file =
|
|
89
|
+
def set_log_file(self, log_file: Path):
|
|
90
|
+
self.log_file = log_file
|
|
91
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
82
92
|
|
|
83
|
-
def
|
|
84
|
-
self.output_dir =
|
|
93
|
+
def set_output_dir(self, output_dir: Path):
|
|
94
|
+
self.output_dir = output_dir
|
|
95
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
85
96
|
|
|
86
97
|
def mount(self, source: str, target: str):
|
|
87
98
|
self.extra_args["mounts"] = self.extra_args.get("mounts", []) + [
|
|
@@ -120,6 +131,10 @@ class Engine(ABC):
|
|
|
120
131
|
def add_environment(self):
|
|
121
132
|
return sum([["-e", f"{key}={value}"] for key, value in self.env.items()], [])
|
|
122
133
|
|
|
134
|
+
def use_overlay(self, overlay_dir: str):
|
|
135
|
+
self._use_overlay = True
|
|
136
|
+
self.overlay_tool_dir = overlay_dir
|
|
137
|
+
|
|
123
138
|
def setup_command(self):
|
|
124
139
|
return [
|
|
125
140
|
"--security-opt",
|
|
@@ -184,9 +199,32 @@ class Engine(ABC):
|
|
|
184
199
|
base.append(value)
|
|
185
200
|
|
|
186
201
|
_command = self._prep_command(command)
|
|
202
|
+
|
|
203
|
+
if self._use_overlay:
|
|
204
|
+
_command = (f"{CACHE_MOUNT_LOCATION}/.scripts/run_with_overlay.sh", self.overlay_tool_dir, *_command)
|
|
205
|
+
|
|
187
206
|
return base + [self.image, *_command]
|
|
188
207
|
|
|
189
|
-
def
|
|
208
|
+
def assemble_smoke_test_command(self, command: tuple[str, ...]):
|
|
209
|
+
base = self.base_command()
|
|
210
|
+
if self.add_benchexec_capabilities:
|
|
211
|
+
base += self.benchexec_capabilities()
|
|
212
|
+
|
|
213
|
+
if self.add_mounting_capabilities:
|
|
214
|
+
base += self.mounting_capabilities()
|
|
215
|
+
|
|
216
|
+
if self.interactive:
|
|
217
|
+
base += self.interactive_command()
|
|
218
|
+
|
|
219
|
+
for value in self.extra_args.values():
|
|
220
|
+
if isinstance(value, list) and not isinstance(value, str):
|
|
221
|
+
base += value
|
|
222
|
+
else:
|
|
223
|
+
base.append(value)
|
|
224
|
+
|
|
225
|
+
return base + command
|
|
226
|
+
|
|
227
|
+
def _prep_command(self, command: tuple[str, ...]) -> tuple[str, ...]:
|
|
190
228
|
"""We want to map absolute paths of the current working directory to the
|
|
191
229
|
working directory of the container."""
|
|
192
230
|
|
|
@@ -204,7 +242,7 @@ class Engine(ABC):
|
|
|
204
242
|
return p
|
|
205
243
|
mapped = _map_path(Path(p))
|
|
206
244
|
if Path(p) == mapped:
|
|
207
|
-
return
|
|
245
|
+
return ensure_linux_style(p)
|
|
208
246
|
else:
|
|
209
247
|
return Path(mapped).as_posix()
|
|
210
248
|
|
|
@@ -234,6 +272,8 @@ class Engine(ABC):
|
|
|
234
272
|
return Docker
|
|
235
273
|
if engine == "podman":
|
|
236
274
|
return Podman
|
|
275
|
+
if engine == "runexec" or engine == "benchexec":
|
|
276
|
+
return Runexec
|
|
237
277
|
|
|
238
278
|
raise ValueError(f"Unknown engine {engine}")
|
|
239
279
|
|
|
@@ -360,9 +400,13 @@ class Engine(ABC):
|
|
|
360
400
|
logging.info("Received signal %s. Terminating container process.", signal_received)
|
|
361
401
|
process.send_signal(signal.SIGTERM)
|
|
362
402
|
|
|
403
|
+
def register_signal(signum, handler) -> None:
|
|
404
|
+
if threading.current_thread() is threading.main_thread():
|
|
405
|
+
signal.signal(signum, handler)
|
|
406
|
+
|
|
363
407
|
# Register signal handler
|
|
364
|
-
|
|
365
|
-
|
|
408
|
+
register_signal(signal.SIGINT, terminate_process_group)
|
|
409
|
+
register_signal(signal.SIGTERM, terminate_process_group)
|
|
366
410
|
|
|
367
411
|
logger.debug("\n\nRunning command:\n%s\n\n", " ".join(map(str, command)))
|
|
368
412
|
|
|
@@ -378,11 +422,14 @@ class Engine(ABC):
|
|
|
378
422
|
if self.print_output_to_stdout:
|
|
379
423
|
sys.stdout.write(line)
|
|
380
424
|
|
|
425
|
+
file_handle = None
|
|
381
426
|
if self.log_file is None:
|
|
382
|
-
run_and_poll(full_stdout.write)
|
|
427
|
+
polling_thread = Thread(target=run_and_poll, args=(full_stdout.write,))
|
|
383
428
|
else:
|
|
384
|
-
|
|
385
|
-
|
|
429
|
+
file_handle = self.log_file.open("w")
|
|
430
|
+
polling_thread = Thread(target=run_and_poll, args=(file_handle.write,))
|
|
431
|
+
|
|
432
|
+
polling_thread.start()
|
|
386
433
|
|
|
387
434
|
assert process is not None, "Process should be defined at this point."
|
|
388
435
|
try:
|
|
@@ -391,6 +438,10 @@ class Engine(ABC):
|
|
|
391
438
|
process.terminate()
|
|
392
439
|
process.wait()
|
|
393
440
|
|
|
441
|
+
polling_thread.join()
|
|
442
|
+
if file_handle is not None:
|
|
443
|
+
file_handle.close()
|
|
444
|
+
|
|
394
445
|
if self.log_file is not None:
|
|
395
446
|
with self.log_file.open("r") as log:
|
|
396
447
|
return RunResult(command, process.returncode, log.read())
|
|
@@ -405,7 +456,7 @@ class Engine(ABC):
|
|
|
405
456
|
logger.debug("Running: %s", command)
|
|
406
457
|
if self.dry_run:
|
|
407
458
|
print("Command to be executed:")
|
|
408
|
-
print(
|
|
459
|
+
print(shlex.join(command))
|
|
409
460
|
return RunResult(command, 0, "Dry run: no command executed.")
|
|
410
461
|
|
|
411
462
|
if self.interactive or not self.handle_io:
|
|
@@ -506,3 +557,84 @@ class Docker(Engine):
|
|
|
506
557
|
"label=disable",
|
|
507
558
|
"-v /sys/fs/cgroup:/sys/fs/cgroup",
|
|
508
559
|
]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class Runexec(Engine):
|
|
563
|
+
def __init__(self, image: Union[str, FmImageConfig]):
|
|
564
|
+
super().__init__("unused")
|
|
565
|
+
self._engine = "runexec"
|
|
566
|
+
|
|
567
|
+
def image_from(self, containerfile: Path):
|
|
568
|
+
logging.info("Engine 'runexec' does not support building images. Continuing without image.")
|
|
569
|
+
raise NotImplementedError("runexec does not support building images.")
|
|
570
|
+
|
|
571
|
+
def benchexec_capabilities(self):
|
|
572
|
+
return []
|
|
573
|
+
|
|
574
|
+
def base_command(self):
|
|
575
|
+
return ["runexec"]
|
|
576
|
+
|
|
577
|
+
def setup_command(self):
|
|
578
|
+
return []
|
|
579
|
+
|
|
580
|
+
def _get_dir_modes(self):
|
|
581
|
+
from benchexec.container import DIR_HIDDEN, DIR_OVERLAY, DIR_READ_ONLY
|
|
582
|
+
|
|
583
|
+
dir_modes = {
|
|
584
|
+
"/": DIR_READ_ONLY,
|
|
585
|
+
"/home": DIR_HIDDEN,
|
|
586
|
+
os.getcwd(): DIR_OVERLAY,
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if not Config().cache_location.resolve().is_relative_to(Path.cwd().resolve()):
|
|
590
|
+
dir_modes[str(Config().cache_location.resolve())] = DIR_OVERLAY
|
|
591
|
+
|
|
592
|
+
return dir_modes
|
|
593
|
+
|
|
594
|
+
def run(self, *command, timeout_sec: Optional[float] = None) -> RunResult:
|
|
595
|
+
import threading
|
|
596
|
+
from tempfile import TemporaryFile
|
|
597
|
+
|
|
598
|
+
from benchexec.containerexecutor import ContainerExecutor
|
|
599
|
+
|
|
600
|
+
executor = ContainerExecutor(
|
|
601
|
+
network_access=True, container_system_config=False, cgroup_access=True, dir_modes=self._get_dir_modes()
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def signal_handler_kill(signum, frame):
|
|
605
|
+
executor.stop()
|
|
606
|
+
|
|
607
|
+
signal.signal(signal.SIGTERM, signal_handler_kill)
|
|
608
|
+
signal.signal(signal.SIGQUIT, signal_handler_kill)
|
|
609
|
+
signal.signal(signal.SIGINT, signal_handler_kill)
|
|
610
|
+
|
|
611
|
+
working_dir = Config().cache_location / self.overlay_tool_dir if self.overlay_tool_dir else Path.cwd()
|
|
612
|
+
|
|
613
|
+
log_creator = TemporaryFile
|
|
614
|
+
if self.log_file:
|
|
615
|
+
log_creator = self.log_file.open
|
|
616
|
+
|
|
617
|
+
log_str = ""
|
|
618
|
+
|
|
619
|
+
def stop_executor_after_timeout():
|
|
620
|
+
# If timeout is None, this waits indefinitely
|
|
621
|
+
threading.Event().wait(timeout_sec)
|
|
622
|
+
executor.stop()
|
|
623
|
+
|
|
624
|
+
eventual_stopping_thread = threading.Thread(target=stop_executor_after_timeout, daemon=True)
|
|
625
|
+
|
|
626
|
+
with log_creator("w+") as log, Capture(log), Capture(log, "stderr"):
|
|
627
|
+
eventual_stopping_thread.start()
|
|
628
|
+
exit_code = executor.execute_run(
|
|
629
|
+
args=command,
|
|
630
|
+
output_dir=self.output_dir,
|
|
631
|
+
workingDir=str(working_dir.absolute()),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
log.seek(0)
|
|
635
|
+
log_str = log.read()
|
|
636
|
+
|
|
637
|
+
if self.print_output_to_stdout:
|
|
638
|
+
print(log_str)
|
|
639
|
+
|
|
640
|
+
return RunResult(command, exit_code.raw, log_str)
|