|
1 | 1 | import asyncio |
| 2 | +import contextlib |
2 | 3 | import logging |
3 | 4 | import shlex |
| 5 | +import tarfile |
| 6 | +import tempfile |
4 | 7 | import time |
5 | 8 | import warnings |
6 | 9 | from abc import ABC, abstractmethod |
7 | | -from collections.abc import Sequence |
8 | | -from pathlib import Path, PurePath |
| 10 | +from collections.abc import Generator, Sequence |
| 11 | +from pathlib import Path, PurePath, PurePosixPath |
9 | 12 |
|
10 | 13 | from pydantic import BaseModel |
11 | 14 |
|
|
18 | 21 | from harbor.utils.scripts import quote_shell_arg |
19 | 22 |
|
20 | 23 | EnvironmentPath = str | PurePath |
| 24 | +_TRANSFER_TAR_FILENAME = ".hb-transfer.tar.gz" |
| 25 | +_ENV_TRANSFER_TAR_PATH = str(PurePosixPath("/tmp") / _TRANSFER_TAR_FILENAME) |
21 | 26 |
|
22 | 27 |
|
23 | 28 | class HealthcheckError(RuntimeError): |
@@ -165,6 +170,19 @@ def _resolve_user(self, user: str | int | None) -> str | int | None: |
165 | 170 | """ |
166 | 171 | return user if user is not None else self.default_user |
167 | 172 |
|
| 173 | + @contextlib.contextmanager |
| 174 | + def with_default_user( |
| 175 | + self, |
| 176 | + user: str | int | None, |
| 177 | + ) -> Generator[None, None, None]: |
| 178 | + """Temporarily set the default user for environment operations.""" |
| 179 | + previous = self.default_user |
| 180 | + self.default_user = user |
| 181 | + try: |
| 182 | + yield |
| 183 | + finally: |
| 184 | + self.default_user = previous |
| 185 | + |
168 | 186 | def _merge_env(self, env: dict[str, str] | None) -> dict[str, str] | None: |
169 | 187 | """Merge persistent env vars with per-exec env vars. |
170 | 188 |
|
@@ -468,6 +486,45 @@ async def download_dir(self, source_dir: str, target_dir: Path | str): |
468 | 486 | target_dir: The local path to which to copy the directory. |
469 | 487 | """ |
470 | 488 |
|
| 489 | + async def download_dir_with_exclusions( |
| 490 | + self, |
| 491 | + *, |
| 492 | + source_dir: str, |
| 493 | + target_dir: Path | str, |
| 494 | + exclude: list[str], |
| 495 | + ) -> None: |
| 496 | + """Download a directory through a temporary tar archive with excludes.""" |
| 497 | + target = Path(target_dir) |
| 498 | + target.mkdir(parents=True, exist_ok=True) |
| 499 | + |
| 500 | + exclude_flags = " ".join( |
| 501 | + f"--exclude={shlex.quote(pattern)}" for pattern in exclude |
| 502 | + ) |
| 503 | + env_tar_path = shlex.quote(_ENV_TRANSFER_TAR_PATH) |
| 504 | + source_path = shlex.quote(source_dir) |
| 505 | + |
| 506 | + result = await self.exec( |
| 507 | + f"tar czf {env_tar_path} {exclude_flags} -C {source_path} .", |
| 508 | + timeout_sec=120, |
| 509 | + user="root", |
| 510 | + ) |
| 511 | + if result.return_code != 0: |
| 512 | + output = result.stderr or result.stdout or "no output" |
| 513 | + raise RuntimeError( |
| 514 | + "Failed to create transfer archive for " |
| 515 | + f"{source_dir!r} with code {result.return_code}: {output}" |
| 516 | + ) |
| 517 | + |
| 518 | + with tempfile.TemporaryDirectory() as host_tmp_dir: |
| 519 | + host_tar_path = Path(host_tmp_dir) / _TRANSFER_TAR_FILENAME |
| 520 | + await self.download_file( |
| 521 | + source_path=_ENV_TRANSFER_TAR_PATH, |
| 522 | + target_path=host_tar_path, |
| 523 | + ) |
| 524 | + |
| 525 | + with tarfile.open(host_tar_path, "r:gz") as tf: |
| 526 | + tf.extractall(path=target, filter="data") |
| 527 | + |
471 | 528 | @abstractmethod |
472 | 529 | async def exec( |
473 | 530 | self, |
|
0 commit comments