diff --git a/src/pyinfra/operations/files.py b/src/pyinfra/operations/files.py index de0d2a08a..bd09b0c32 100644 --- a/src/pyinfra/operations/files.py +++ b/src/pyinfra/operations/files.py @@ -2120,3 +2120,140 @@ def block( else: cmd = StringCommand(f"awk '/{mark_1}/,/{mark_2}/ {{next}} 1'") yield StringCommand(out_prep, cmd, q_path, "> $OUT", real_out) + + +_TAR_FORMATS = { + ".tar": ["-x"], + ".tar.gz": ["-xz"], + ".tgz": ["-xz"], + ".tar.bz2": ["-xj"], + ".tbz2": ["-xj"], + ".tar.xz": ["-xJ"], + ".txz": ["-xJ"], + ".tar.zst": ["-x", "--zstd"], +} +_ZIP_FORMATS = (".zip",) +_ARCHIVE_EXTENSIONS = tuple(_TAR_FORMATS.keys()) + _ZIP_FORMATS + + +def _get_archive_format(src: str) -> tuple[str, list[str]] | None: + lower = src.lower() + for ext, flags in _TAR_FORMATS.items(): + if lower.endswith(ext): + return "tar", flags + for ext in _ZIP_FORMATS: + if lower.endswith(ext): + return "unzip", ["-o"] + return None + + +@operation() +def unarchive( + src: str, + dest: str, + remote_src: bool = False, + creates: str | None = None, + extra_opts: list[str] | None = None, + user: str | None = None, + group: str | None = None, +): + """ + Extract archive files on the remote system. + + + src: path to the archive file (local or remote depending on ``remote_src``) + + dest: remote directory to extract into (must exist) + + remote_src: set to ``True`` if the archive is already on the remote system + + creates: if this path already exists, the operation is skipped (idempotency) + + extra_opts: list of additional arguments to pass to the extract command + + user: user to own the extracted files + + group: group to own the extracted files + + Supported formats: + ``.tar``, ``.tar.gz``/``.tgz``, ``.tar.bz2``/``.tbz2``, + ``.tar.xz``/``.txz``, ``.tar.zst``, ``.zip`` + + **Examples:** + + .. code:: python + + # Extract a remote archive + files.unarchive( + name="Extract app tarball", + src="/tmp/app.tar.gz", + dest="/opt/app", + remote_src=True, + ) + + # Upload and extract a local archive + files.unarchive( + name="Deploy release", + src="releases/app-v1.0.tar.gz", + dest="/opt/app", + creates="/opt/app/bin/start", + ) + """ + + # Idempotency: skip if creates path already exists + if creates: + if host.get_fact(File, path=creates) is not None: + host.noop("archive already extracted ({0} exists)".format(creates)) + return + + # Validate destination exists and is a directory + dest_info = host.get_fact(Directory, path=dest) + if not dest_info: + raise OperationError("Destination {0} is not an existing directory".format(dest)) + + archive_format = _get_archive_format(src) + if archive_format is None: + raise OperationValueError( + "Unsupported archive format for {0}. Supported: {1}".format( + src, ", ".join(_ARCHIVE_EXTENSIONS) + ) + ) + + tool, flags = archive_format + + if not remote_src: + # Upload the local archive to a temp location on the remote + temp_archive = host.get_temp_filename(src) + yield FileUploadCommand(src, temp_archive) + archive_path = temp_archive + else: + # Validate the remote archive exists + if host.get_fact(File, path=src) is None: + raise OperationError("Remote archive {0} does not exist".format(src)) + archive_path = src + + extras = list(extra_opts) if extra_opts else [] + + if tool == "tar": + # tar -f -C + # Keep -f adjacent to the archive path so extras never get mistaken for it. + yield StringCommand( + tool, + *flags, + *extras, + "-f", + QuoteString(archive_path), + "-C", + QuoteString(dest), + ) + else: + # unzip -d + yield StringCommand( + tool, + *flags, + *extras, + QuoteString(archive_path), + "-d", + QuoteString(dest), + ) + + # Clean up uploaded temp file + if not remote_src: + yield StringCommand("rm", "-f", QuoteString(temp_archive)) + + # Set ownership if requested + if user or group: + yield file_utils.chown(dest, user, group, recursive=True) diff --git a/tests/operations/files.unarchive/dest_not_directory.json b/tests/operations/files.unarchive/dest_not_directory.json new file mode 100644 index 000000000..747e6809b --- /dev/null +++ b/tests/operations/files.unarchive/dest_not_directory.json @@ -0,0 +1,16 @@ +{ + "kwargs": { + "src": "/tmp/app.tar.gz", + "dest": "/opt/missing", + "remote_src": true + }, + "facts": { + "files.Directory": { + "path=/opt/missing": null + } + }, + "exception": { + "name": "OperationError", + "message": "Destination /opt/missing is not an existing directory" + } +} diff --git a/tests/operations/files.unarchive/extract_local_tar_gz.json b/tests/operations/files.unarchive/extract_local_tar_gz.json new file mode 100644 index 000000000..9a1812495 --- /dev/null +++ b/tests/operations/files.unarchive/extract_local_tar_gz.json @@ -0,0 +1,16 @@ +{ + "kwargs": { + "src": "releases/app.tar.gz", + "dest": "/opt/app" + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + } + }, + "commands": [ + ["upload", "releases/app.tar.gz", "_tempfile_"], + "tar -xz -f _tempfile_ -C /opt/app", + "rm -f _tempfile_" + ] +} diff --git a/tests/operations/files.unarchive/extract_remote_tar_gz.json b/tests/operations/files.unarchive/extract_remote_tar_gz.json new file mode 100644 index 000000000..54a039c87 --- /dev/null +++ b/tests/operations/files.unarchive/extract_remote_tar_gz.json @@ -0,0 +1,18 @@ +{ + "kwargs": { + "src": "/tmp/app.tar.gz", + "dest": "/opt/app", + "remote_src": true + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + }, + "files.File": { + "path=/tmp/app.tar.gz": true + } + }, + "commands": [ + "tar -xz -f /tmp/app.tar.gz -C /opt/app" + ] +} diff --git a/tests/operations/files.unarchive/extract_remote_tar_zst.json b/tests/operations/files.unarchive/extract_remote_tar_zst.json new file mode 100644 index 000000000..06d2c65b4 --- /dev/null +++ b/tests/operations/files.unarchive/extract_remote_tar_zst.json @@ -0,0 +1,18 @@ +{ + "kwargs": { + "src": "/tmp/app.tar.zst", + "dest": "/opt/app", + "remote_src": true + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + }, + "files.File": { + "path=/tmp/app.tar.zst": true + } + }, + "commands": [ + "tar -x --zstd -f /tmp/app.tar.zst -C /opt/app" + ] +} diff --git a/tests/operations/files.unarchive/extract_remote_zip.json b/tests/operations/files.unarchive/extract_remote_zip.json new file mode 100644 index 000000000..b4664e765 --- /dev/null +++ b/tests/operations/files.unarchive/extract_remote_zip.json @@ -0,0 +1,18 @@ +{ + "kwargs": { + "src": "/tmp/app.zip", + "dest": "/opt/app", + "remote_src": true + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + }, + "files.File": { + "path=/tmp/app.zip": true + } + }, + "commands": [ + "unzip -o /tmp/app.zip -d /opt/app" + ] +} diff --git a/tests/operations/files.unarchive/extract_with_chown.json b/tests/operations/files.unarchive/extract_with_chown.json new file mode 100644 index 000000000..880de42ec --- /dev/null +++ b/tests/operations/files.unarchive/extract_with_chown.json @@ -0,0 +1,21 @@ +{ + "kwargs": { + "src": "/tmp/app.tar.gz", + "dest": "/opt/app", + "remote_src": true, + "user": "www-data", + "group": "www-data" + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + }, + "files.File": { + "path=/tmp/app.tar.gz": true + } + }, + "commands": [ + "tar -xz -f /tmp/app.tar.gz -C /opt/app", + "chown -R www-data:www-data /opt/app" + ] +} diff --git a/tests/operations/files.unarchive/extract_with_extra_opts.json b/tests/operations/files.unarchive/extract_with_extra_opts.json new file mode 100644 index 000000000..e8a6314b2 --- /dev/null +++ b/tests/operations/files.unarchive/extract_with_extra_opts.json @@ -0,0 +1,19 @@ +{ + "kwargs": { + "src": "/tmp/app.tar.gz", + "dest": "/opt/app", + "remote_src": true, + "extra_opts": ["--strip-components=1"] + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + }, + "files.File": { + "path=/tmp/app.tar.gz": true + } + }, + "commands": [ + "tar -xz --strip-components=1 -f /tmp/app.tar.gz -C /opt/app" + ] +} diff --git a/tests/operations/files.unarchive/extract_zip_with_extra_opts.json b/tests/operations/files.unarchive/extract_zip_with_extra_opts.json new file mode 100644 index 000000000..9e0c8e1c9 --- /dev/null +++ b/tests/operations/files.unarchive/extract_zip_with_extra_opts.json @@ -0,0 +1,19 @@ +{ + "kwargs": { + "src": "/tmp/app.zip", + "dest": "/opt/app", + "remote_src": true, + "extra_opts": ["-q"] + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + }, + "files.File": { + "path=/tmp/app.zip": true + } + }, + "commands": [ + "unzip -o -q /tmp/app.zip -d /opt/app" + ] +} diff --git a/tests/operations/files.unarchive/remote_archive_missing.json b/tests/operations/files.unarchive/remote_archive_missing.json new file mode 100644 index 000000000..468bdbec2 --- /dev/null +++ b/tests/operations/files.unarchive/remote_archive_missing.json @@ -0,0 +1,19 @@ +{ + "kwargs": { + "src": "/tmp/missing.tar.gz", + "dest": "/opt/app", + "remote_src": true + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + }, + "files.File": { + "path=/tmp/missing.tar.gz": null + } + }, + "exception": { + "name": "OperationError", + "message": "Remote archive /tmp/missing.tar.gz does not exist" + } +} diff --git a/tests/operations/files.unarchive/skip_creates_exists.json b/tests/operations/files.unarchive/skip_creates_exists.json new file mode 100644 index 000000000..2c1641d62 --- /dev/null +++ b/tests/operations/files.unarchive/skip_creates_exists.json @@ -0,0 +1,15 @@ +{ + "kwargs": { + "src": "/tmp/app.tar.gz", + "dest": "/opt/app", + "remote_src": true, + "creates": "/opt/app/bin/start" + }, + "facts": { + "files.File": { + "path=/opt/app/bin/start": true + } + }, + "commands": [], + "noop_description": "archive already extracted (/opt/app/bin/start exists)" +} diff --git a/tests/operations/files.unarchive/unsupported_format.json b/tests/operations/files.unarchive/unsupported_format.json new file mode 100644 index 000000000..d432bb880 --- /dev/null +++ b/tests/operations/files.unarchive/unsupported_format.json @@ -0,0 +1,16 @@ +{ + "kwargs": { + "src": "/tmp/app.rar", + "dest": "/opt/app", + "remote_src": true + }, + "facts": { + "files.Directory": { + "path=/opt/app": true + } + }, + "exception": { + "name": "OperationValueError", + "message": "Unsupported archive format for /tmp/app.rar. Supported: .tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz, .txz, .tar.zst, .zip" + } +}