@@ -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 )
0 commit comments