Skip to content

Commit 064226b

Browse files
committed
Copy files with same destination in single rsync
1 parent 3fa96b2 commit 064226b

File tree

4 files changed

+162
-62
lines changed

4 files changed

+162
-62
lines changed

tmt/steps/execute/__init__.py

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,11 @@ class ScriptTemplate(Script):
133133
context: dict[str, str]
134134

135135
_rendered_script_path: Optional[Path] = None
136+
_delete_on_exit: bool = True # Flag to control cleanup
136137

137138
def __enter__(self) -> Path:
139+
# Ensure flag is reset for each use
140+
self._delete_on_exit = True
138141
with NamedTemporaryFile(mode='w', delete=False) as rendered_script:
139142
rendered_script.write(
140143
render_template_file(
@@ -147,8 +150,19 @@ def __enter__(self) -> Path:
147150
return self._rendered_script_path
148151

149152
def __exit__(self, *args: object) -> None:
150-
assert self._rendered_script_path
151-
os.unlink(self._rendered_script_path)
153+
# Only cleanup if the flag is set
154+
if self._delete_on_exit:
155+
self.cleanup()
156+
157+
def cleanup(self) -> None:
158+
"""Explicitly cleans up the rendered script file."""
159+
if self._rendered_script_path and self._rendered_script_path.exists():
160+
self._rendered_script_path.unlink()
161+
self._rendered_script_path = None
162+
163+
def keep_rendered_file(self) -> None:
164+
"""Prevents automatic deletion on __exit__."""
165+
self._delete_on_exit = False
152166

153167

154168
def effective_scripts_dest_dir(default: Path = DEFAULT_SCRIPTS_DEST_DIR) -> Path:
@@ -794,28 +808,107 @@ def prepare_tests(self, guest: Guest, logger: tmt.log.Logger) -> list[TestInvoca
794808

795809
def prepare_scripts(self, guest: "tmt.steps.provision.Guest") -> None:
796810
"""
797-
Prepare additional scripts for testing
798-
"""
799-
800-
# Make sure scripts directory exists
801-
command = Command("mkdir", "-p", f"{guest.scripts_path}")
811+
Prepare additional scripts for testing by copying them to the guest.
802812
813+
Scripts destined for the default guest scripts path are pushed in
814+
a single batch operation. Scripts with custom destination paths are
815+
handled individually. Aliases are created as symbolic links after
816+
the scripts are pushed.
817+
"""
818+
# Ensure the main default scripts directory exists on the guest
819+
scripts_dest_dir = guest.scripts_path
820+
mkdir_command = Command("mkdir", "-p", f"{scripts_dest_dir}")
803821
if not guest.facts.is_superuser:
804-
command = Command("sudo") + command
805-
806-
guest.execute(command)
822+
mkdir_command = Command("sudo") + mkdir_command
823+
guest.execute(mkdir_command)
824+
825+
# Group scripts by their target directory on the guest
826+
# Key: Target directory path on guest
827+
# Value: List of source paths (local paths) to copy into that directory
828+
scripts_by_target_dir: dict[Path, list[Path]] = {}
829+
# Store aliases: key is the target path on guest, value is list of alias names
830+
# Aliases are created in the default scripts_dest_dir
831+
aliases_to_create: dict[Path, list[str]] = {}
832+
# Keep track of ScriptTemplate instances for later cleanup
833+
templates_to_cleanup: list[ScriptTemplate] = []
807834

808-
# Install all scripts on guest
809835
for script in self.scripts:
836+
if not script.enabled(guest):
837+
continue
838+
810839
with script as source:
811-
for filename in [script.source_filename, *script.aliases]:
812-
if script.enabled(guest):
813-
guest.push(
814-
source=source,
815-
destination=script.destination_path or guest.scripts_path / filename,
816-
options=["-p", "--chmod=755"],
817-
superuser=guest.facts.is_superuser is not True,
818-
)
840+
# If it's a template, prevent immediate cleanup and track it
841+
if isinstance(script, ScriptTemplate):
842+
script.keep_rendered_file()
843+
templates_to_cleanup.append(script)
844+
# Use the actual rendered path as the source
845+
source_path = script._rendered_script_path
846+
assert source_path is not None # Should be set by __enter__
847+
else:
848+
source_path = source # Use the path from __enter__ directly
849+
850+
destination_path = script.destination_path
851+
target_dir: Path
852+
final_target_path_on_guest: Path
853+
854+
if destination_path is None:
855+
# Default destination directory
856+
target_dir = scripts_dest_dir
857+
final_target_path_on_guest = target_dir / script.source_filename
858+
# Store aliases associated with the final target path in the default dir
859+
if script.aliases:
860+
aliases_to_create[final_target_path_on_guest] = script.aliases
861+
else:
862+
# Custom destination path - treat it as the final file path
863+
target_dir = destination_path.parent
864+
final_target_path_on_guest = destination_path
865+
# Ensure the custom parent directory exists
866+
mkdir_parent_cmd = Command("mkdir", "-p", f"{target_dir}")
867+
if not guest.facts.is_superuser:
868+
mkdir_parent_cmd = Command("sudo") + mkdir_parent_cmd
869+
guest.execute(mkdir_parent_cmd)
870+
871+
# Add the source path to the list for its target directory
872+
if target_dir not in scripts_by_target_dir:
873+
scripts_by_target_dir[target_dir] = []
874+
scripts_by_target_dir[target_dir].append(source_path)
875+
876+
# Push script batches grouped by target directory
877+
for target_dir, source_paths in scripts_by_target_dir.items():
878+
if not source_paths:
879+
continue
880+
881+
self.debug(f"Pushing script batch ({len(source_paths)} files) to {target_dir}")
882+
guest.push(
883+
source=source_paths,
884+
destination=target_dir,
885+
options=["-s", "-p", "--chmod=755"],
886+
superuser=guest.facts.is_superuser is not True,
887+
)
888+
889+
# Create aliases on the guest (only within the default scripts_dest_dir)
890+
full_alias_command = ShellScript("") # Start with an empty script
891+
alias_count = 0
892+
for target_path, aliases in aliases_to_create.items():
893+
for alias in aliases:
894+
link_path = scripts_dest_dir / alias
895+
# Use absolute path for symlink target for simplicity/robustness
896+
single_alias_cmd = ShellScript(
897+
f"ln -sf {target_path.as_posix()} {link_path.as_posix()}"
898+
)
899+
if alias_count == 0:
900+
full_alias_command = single_alias_cmd
901+
else:
902+
full_alias_command &= single_alias_cmd # Use the '&' operator (__and__)
903+
alias_count += 1
904+
905+
if alias_count > 0:
906+
self.debug("Creating script aliases on guest.")
907+
guest.execute(full_alias_command, friendly_command="Create script aliases")
908+
909+
# Cleanup rendered templates now that they've been pushed
910+
for template_script in templates_to_cleanup:
911+
template_script.cleanup()
819912

820913
def _tmt_report_results_filepath(self, invocation: TestInvocation) -> Path:
821914
"""

tmt/steps/provision/__init__.py

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,13 +1607,19 @@ def execute(
16071607

16081608
def push(
16091609
self,
1610-
source: Optional[Path] = None,
1610+
source: Optional[Union[Path, list[Path]]] = None,
16111611
destination: Optional[Path] = None,
16121612
options: Optional[list[str]] = None,
16131613
superuser: bool = False,
16141614
) -> None:
16151615
"""
1616-
Push files to the guest
1616+
Push files or directories to the guest.
1617+
1618+
:param source: Path or list of paths on the local machine to push.
1619+
If None, the plan workdir is pushed.
1620+
:param destination: Path on the guest where to push. If None, defaults to '/'.
1621+
:param options: List of rsync options to use.
1622+
:param superuser: If True, run rsync with sudo on the guest.
16171623
"""
16181624

16191625
raise NotImplementedError
@@ -2416,21 +2422,19 @@ def execute(
24162422

24172423
def push(
24182424
self,
2419-
source: Optional[Path] = None,
2425+
source: Optional[Union[Path, list[Path]]] = None,
24202426
destination: Optional[Path] = None,
24212427
options: Optional[list[str]] = None,
24222428
superuser: bool = False,
24232429
) -> None:
24242430
"""
2425-
Push files to the guest
2426-
2427-
By default the whole plan workdir is synced to the same location
2428-
on the guest. Use the 'source' and 'destination' to sync custom
2429-
location and the 'options' parameter to modify default options
2430-
which are '-Rrz --links --safe-links --delete'.
2431+
Push files or directories to the guest using rsync.
24312432
2432-
Set 'superuser' if rsync command has to run as root or passwordless
2433-
sudo on the Guest (e.g. pushing to r/o destination)
2433+
:param source: Path or list of paths on the local machine to push.
2434+
If None, the plan workdir is pushed.
2435+
:param destination: Path on the guest where to push. If None, defaults to '/'.
2436+
:param options: List of rsync options to use.
2437+
:param superuser: If True, run rsync with sudo on the guest.
24342438
"""
24352439

24362440
# Abort if guest is unavailable
@@ -2441,43 +2445,47 @@ def push(
24412445
options = options or DEFAULT_RSYNC_PUSH_OPTIONS
24422446
if destination is None:
24432447
destination = Path("/")
2448+
2449+
sources: list[Path]
2450+
log_message: str
2451+
24442452
if source is None:
24452453
# FIXME: cast() - https://github.com/teemtee/tmt/issues/1372
24462454
parent = cast(Provision, self.parent)
2447-
24482455
assert parent.plan.workdir is not None
2456+
sources = [parent.plan.workdir]
2457+
log_message = f"Push workdir to guest '{self.primary_address}'."
2458+
elif isinstance(source, Path):
2459+
sources = [source]
2460+
log_message = f"Copy '{source}' to '{destination}' on the guest."
2461+
else: # source is a list of Paths
2462+
sources = source
2463+
log_message = f"Copy {len(sources)} files/dirs to '{destination}' on the guest."
24492464

2450-
source = parent.plan.workdir
2451-
self.debug(f"Push workdir to guest '{self.primary_address}'.")
2452-
else:
2453-
self.debug(f"Copy '{source}' to '{destination}' on the guest.")
2465+
self.debug(log_message)
24542466

24552467
def rsync() -> None:
2456-
"""
2457-
Run the rsync command
2458-
"""
2459-
2460-
# In closure, mypy has hard times to reason about the state of used variables.
2461-
assert options
2462-
assert source
2463-
assert destination
2468+
"""Run the rsync command"""
2469+
assert options is not None
2470+
assert sources is not None
2471+
assert destination is not None
24642472

24652473
cmd = ['rsync']
24662474
if superuser and self.user != 'root':
24672475
cmd += ['--rsync-path', 'sudo rsync']
24682476

2469-
self._run_guest_command(
2470-
Command(
2471-
*cmd,
2472-
*options,
2473-
"-e",
2474-
self._ssh_command.to_element(),
2475-
source,
2476-
f"{self._ssh_guest}:{destination}",
2477-
),
2478-
silent=True,
2477+
# Construct the rsync command with potentially multiple sources
2478+
rsync_cmd = Command(
2479+
*cmd,
2480+
*options,
2481+
"-e",
2482+
self._ssh_command.to_element(),
2483+
*[str(s) for s in sources], # Unpack all source paths
2484+
f"{self._ssh_guest}:{destination}",
24792485
)
24802486

2487+
self._run_guest_command(rsync_cmd, silent=True)
2488+
24812489
# Try to push twice, check for rsync after the first failure
24822490
try:
24832491
rsync()

tmt/steps/provision/local.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def reboot(
160160

161161
def push(
162162
self,
163-
source: Optional[Path] = None,
163+
source: Optional[Union[Path, list[Path]]] = None,
164164
destination: Optional[Path] = None,
165165
options: Optional[list[str]] = None,
166166
superuser: bool = False,

tmt/steps/provision/podman.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ def execute(
452452

453453
def push(
454454
self,
455-
source: Optional[Path] = None,
455+
source: Optional[Union[Path, list[Path]]] = None,
456456
destination: Optional[Path] = None,
457457
options: Optional[list[str]] = None,
458458
superuser: bool = False,
@@ -483,16 +483,15 @@ def push(
483483
# to the container. If running in toolbox, make sure to copy from the toolbox
484484
# container instead of localhost.
485485
if source and destination:
486+
sources = source if isinstance(source, list) else [source]
486487
container_name: Optional[str] = None
487488
if self.parent.plan.my_run.runner.facts.is_toolbox:
488489
container_name = self.parent.plan.my_run.runner.facts.toolbox_container_name
489-
self.podman(
490-
Command(
491-
"cp",
492-
f"{container_name}:{source}" if container_name else source,
493-
f"{self.container}:{destination}",
494-
)
495-
)
490+
491+
for src in sources:
492+
source_spec = f"{container_name}:{src}" if container_name else str(src)
493+
dest_spec = f"{self.container}:{destination}"
494+
self.podman(Command("cp", source_spec, dest_spec))
496495

497496
def pull(
498497
self,

0 commit comments

Comments
 (0)