Skip to content

Commit c11fa48

Browse files
authored
operations.files: add files.unarchive (#1631)
1 parent fdddf51 commit c11fa48

12 files changed

Lines changed: 332 additions & 0 deletions

src/pyinfra/operations/files.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,3 +2120,140 @@ def block(
21202120
else:
21212121
cmd = StringCommand(f"awk '/{mark_1}/,/{mark_2}/ {{next}} 1'")
21222122
yield StringCommand(out_prep, cmd, q_path, "> $OUT", real_out)
2123+
2124+
2125+
_TAR_FORMATS = {
2126+
".tar": ["-x"],
2127+
".tar.gz": ["-xz"],
2128+
".tgz": ["-xz"],
2129+
".tar.bz2": ["-xj"],
2130+
".tbz2": ["-xj"],
2131+
".tar.xz": ["-xJ"],
2132+
".txz": ["-xJ"],
2133+
".tar.zst": ["-x", "--zstd"],
2134+
}
2135+
_ZIP_FORMATS = (".zip",)
2136+
_ARCHIVE_EXTENSIONS = tuple(_TAR_FORMATS.keys()) + _ZIP_FORMATS
2137+
2138+
2139+
def _get_archive_format(src: str) -> tuple[str, list[str]] | None:
2140+
lower = src.lower()
2141+
for ext, flags in _TAR_FORMATS.items():
2142+
if lower.endswith(ext):
2143+
return "tar", flags
2144+
for ext in _ZIP_FORMATS:
2145+
if lower.endswith(ext):
2146+
return "unzip", ["-o"]
2147+
return None
2148+
2149+
2150+
@operation()
2151+
def unarchive(
2152+
src: str,
2153+
dest: str,
2154+
remote_src: bool = False,
2155+
creates: str | None = None,
2156+
extra_opts: list[str] | None = None,
2157+
user: str | None = None,
2158+
group: str | None = None,
2159+
):
2160+
"""
2161+
Extract archive files on the remote system.
2162+
2163+
+ src: path to the archive file (local or remote depending on ``remote_src``)
2164+
+ dest: remote directory to extract into (must exist)
2165+
+ remote_src: set to ``True`` if the archive is already on the remote system
2166+
+ creates: if this path already exists, the operation is skipped (idempotency)
2167+
+ extra_opts: list of additional arguments to pass to the extract command
2168+
+ user: user to own the extracted files
2169+
+ group: group to own the extracted files
2170+
2171+
Supported formats:
2172+
``.tar``, ``.tar.gz``/``.tgz``, ``.tar.bz2``/``.tbz2``,
2173+
``.tar.xz``/``.txz``, ``.tar.zst``, ``.zip``
2174+
2175+
**Examples:**
2176+
2177+
.. code:: python
2178+
2179+
# Extract a remote archive
2180+
files.unarchive(
2181+
name="Extract app tarball",
2182+
src="/tmp/app.tar.gz",
2183+
dest="/opt/app",
2184+
remote_src=True,
2185+
)
2186+
2187+
# Upload and extract a local archive
2188+
files.unarchive(
2189+
name="Deploy release",
2190+
src="releases/app-v1.0.tar.gz",
2191+
dest="/opt/app",
2192+
creates="/opt/app/bin/start",
2193+
)
2194+
"""
2195+
2196+
# Idempotency: skip if creates path already exists
2197+
if creates:
2198+
if host.get_fact(File, path=creates) is not None:
2199+
host.noop("archive already extracted ({0} exists)".format(creates))
2200+
return
2201+
2202+
# Validate destination exists and is a directory
2203+
dest_info = host.get_fact(Directory, path=dest)
2204+
if not dest_info:
2205+
raise OperationError("Destination {0} is not an existing directory".format(dest))
2206+
2207+
archive_format = _get_archive_format(src)
2208+
if archive_format is None:
2209+
raise OperationValueError(
2210+
"Unsupported archive format for {0}. Supported: {1}".format(
2211+
src, ", ".join(_ARCHIVE_EXTENSIONS)
2212+
)
2213+
)
2214+
2215+
tool, flags = archive_format
2216+
2217+
if not remote_src:
2218+
# Upload the local archive to a temp location on the remote
2219+
temp_archive = host.get_temp_filename(src)
2220+
yield FileUploadCommand(src, temp_archive)
2221+
archive_path = temp_archive
2222+
else:
2223+
# Validate the remote archive exists
2224+
if host.get_fact(File, path=src) is None:
2225+
raise OperationError("Remote archive {0} does not exist".format(src))
2226+
archive_path = src
2227+
2228+
extras = list(extra_opts) if extra_opts else []
2229+
2230+
if tool == "tar":
2231+
# tar <flags> <extras> -f <archive> -C <dest>
2232+
# Keep -f adjacent to the archive path so extras never get mistaken for it.
2233+
yield StringCommand(
2234+
tool,
2235+
*flags,
2236+
*extras,
2237+
"-f",
2238+
QuoteString(archive_path),
2239+
"-C",
2240+
QuoteString(dest),
2241+
)
2242+
else:
2243+
# unzip <flags> <extras> <archive> -d <dest>
2244+
yield StringCommand(
2245+
tool,
2246+
*flags,
2247+
*extras,
2248+
QuoteString(archive_path),
2249+
"-d",
2250+
QuoteString(dest),
2251+
)
2252+
2253+
# Clean up uploaded temp file
2254+
if not remote_src:
2255+
yield StringCommand("rm", "-f", QuoteString(temp_archive))
2256+
2257+
# Set ownership if requested
2258+
if user or group:
2259+
yield file_utils.chown(dest, user, group, recursive=True)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"kwargs": {
3+
"src": "/tmp/app.tar.gz",
4+
"dest": "/opt/missing",
5+
"remote_src": true
6+
},
7+
"facts": {
8+
"files.Directory": {
9+
"path=/opt/missing": null
10+
}
11+
},
12+
"exception": {
13+
"name": "OperationError",
14+
"message": "Destination /opt/missing is not an existing directory"
15+
}
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"kwargs": {
3+
"src": "releases/app.tar.gz",
4+
"dest": "/opt/app"
5+
},
6+
"facts": {
7+
"files.Directory": {
8+
"path=/opt/app": true
9+
}
10+
},
11+
"commands": [
12+
["upload", "releases/app.tar.gz", "_tempfile_"],
13+
"tar -xz -f _tempfile_ -C /opt/app",
14+
"rm -f _tempfile_"
15+
]
16+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"kwargs": {
3+
"src": "/tmp/app.tar.gz",
4+
"dest": "/opt/app",
5+
"remote_src": true
6+
},
7+
"facts": {
8+
"files.Directory": {
9+
"path=/opt/app": true
10+
},
11+
"files.File": {
12+
"path=/tmp/app.tar.gz": true
13+
}
14+
},
15+
"commands": [
16+
"tar -xz -f /tmp/app.tar.gz -C /opt/app"
17+
]
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"kwargs": {
3+
"src": "/tmp/app.tar.zst",
4+
"dest": "/opt/app",
5+
"remote_src": true
6+
},
7+
"facts": {
8+
"files.Directory": {
9+
"path=/opt/app": true
10+
},
11+
"files.File": {
12+
"path=/tmp/app.tar.zst": true
13+
}
14+
},
15+
"commands": [
16+
"tar -x --zstd -f /tmp/app.tar.zst -C /opt/app"
17+
]
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"kwargs": {
3+
"src": "/tmp/app.zip",
4+
"dest": "/opt/app",
5+
"remote_src": true
6+
},
7+
"facts": {
8+
"files.Directory": {
9+
"path=/opt/app": true
10+
},
11+
"files.File": {
12+
"path=/tmp/app.zip": true
13+
}
14+
},
15+
"commands": [
16+
"unzip -o /tmp/app.zip -d /opt/app"
17+
]
18+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"kwargs": {
3+
"src": "/tmp/app.tar.gz",
4+
"dest": "/opt/app",
5+
"remote_src": true,
6+
"user": "www-data",
7+
"group": "www-data"
8+
},
9+
"facts": {
10+
"files.Directory": {
11+
"path=/opt/app": true
12+
},
13+
"files.File": {
14+
"path=/tmp/app.tar.gz": true
15+
}
16+
},
17+
"commands": [
18+
"tar -xz -f /tmp/app.tar.gz -C /opt/app",
19+
"chown -R www-data:www-data /opt/app"
20+
]
21+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"kwargs": {
3+
"src": "/tmp/app.tar.gz",
4+
"dest": "/opt/app",
5+
"remote_src": true,
6+
"extra_opts": ["--strip-components=1"]
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/opt/app": true
11+
},
12+
"files.File": {
13+
"path=/tmp/app.tar.gz": true
14+
}
15+
},
16+
"commands": [
17+
"tar -xz --strip-components=1 -f /tmp/app.tar.gz -C /opt/app"
18+
]
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"kwargs": {
3+
"src": "/tmp/app.zip",
4+
"dest": "/opt/app",
5+
"remote_src": true,
6+
"extra_opts": ["-q"]
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/opt/app": true
11+
},
12+
"files.File": {
13+
"path=/tmp/app.zip": true
14+
}
15+
},
16+
"commands": [
17+
"unzip -o -q /tmp/app.zip -d /opt/app"
18+
]
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"kwargs": {
3+
"src": "/tmp/missing.tar.gz",
4+
"dest": "/opt/app",
5+
"remote_src": true
6+
},
7+
"facts": {
8+
"files.Directory": {
9+
"path=/opt/app": true
10+
},
11+
"files.File": {
12+
"path=/tmp/missing.tar.gz": null
13+
}
14+
},
15+
"exception": {
16+
"name": "OperationError",
17+
"message": "Remote archive /tmp/missing.tar.gz does not exist"
18+
}
19+
}

0 commit comments

Comments
 (0)