2025-01-07 09:40:39 +01:00
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
2024-03-20 16:17:17 +01:00
using System.Diagnostics ;
namespace ReleaseBuilder ;
/// <summary>
/// Helper methods for executing a commandline program
/// </summary>
public static class ProcessHelper
{
/// <summary>
/// Starts a commandline program and waits for it to complete
/// </summary>
/// <param name="command"></param>
/// <param name="workingDirectory">The working directory to run in; <c>null</c> means current directory</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <param name="codeIsError">Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
/// Default value is <c>null</c> which will treat anything non-zero as an error</param>
2024-03-22 15:20:45 +01:00
/// <param name="suppressStdErr">If <c>true</c>, stderr is not forwarded to the console</param>
2024-04-04 15:44:28 +02:00
/// <param name="writeStdIn">Function to write to stdin</param>
2024-03-20 16:17:17 +01:00
/// <returns>An awaitable task</returns>
2025-02-25 16:20:56 +01:00
public static async Task Execute ( IEnumerable < string? > command , string? workingDirectory = null , CancellationToken cancellationToken = default , Func < int , bool >? codeIsError = null , bool suppressStdErr = false , Func < StreamWriter , Task >? writeStdIn = null )
2024-03-20 16:17:17 +01:00
{
if (! command . Any ())
throw new ArgumentException ( "Needs at least one command" , nameof ( command ));
2025-02-25 16:20:56 +01:00
var executable = command . First ();
if ( string . IsNullOrWhiteSpace ( executable ))
throw new ArgumentException ( "Executable name cannot be empty" , nameof ( command ));
2024-03-20 16:17:17 +01:00
workingDirectory ??= Environment . CurrentDirectory ;
if (! Directory . Exists ( workingDirectory ))
Directory . CreateDirectory ( workingDirectory );
codeIsError ??= ( x ) => x != 0 ;
2025-02-25 16:20:56 +01:00
var p = Process . Start ( new ProcessStartInfo ( executable , command . Skip ( 1 ). Where ( x => ! string . IsNullOrEmpty ( x )). Select ( x => x !))
2024-03-20 16:17:17 +01:00
{
WindowStyle = ProcessWindowStyle . Hidden ,
WorkingDirectory = workingDirectory ,
2024-03-22 15:20:45 +01:00
RedirectStandardError = ! suppressStdErr ,
2024-03-20 16:17:17 +01:00
RedirectStandardOutput = false ,
2024-04-04 15:44:28 +02:00
RedirectStandardInput = writeStdIn != null ,
2024-03-20 16:17:17 +01:00
UseShellExecute = false ,
2025-02-25 16:20:56 +01:00
}) ?? throw new Exception ( $"Failed to launch process {executable}, null returned" );
2024-03-20 16:17:17 +01:00
2024-03-22 12:15:53 +01:00
// Forward error messages to stderr
2024-03-22 15:20:45 +01:00
var t = suppressStdErr
? Task . CompletedTask
: p . StandardError . BaseStream . CopyToAsync ( Console . OpenStandardError (), cancellationToken );
2024-03-22 12:15:53 +01:00
2024-04-04 15:44:28 +02:00
if ( writeStdIn != null )
await writeStdIn ( p . StandardInput ). ConfigureAwait ( false );
2024-03-20 16:17:17 +01:00
await p . WaitForExitAsync ( cancellationToken ). ConfigureAwait ( false );
if ( codeIsError ( p . ExitCode ))
2025-02-25 16:20:56 +01:00
throw new Exception ( $"Execution of {executable} gave error code {p.ExitCode}" );
2024-03-22 12:15:53 +01:00
await t . ConfigureAwait ( false );
}
/// <summary>
/// Runs all commandline tasks in sequence
/// </summary>
/// <param name="commands">The commands to run</param>
/// <param name="workingDirectory">The working directory to run in; <c>null</c> means current directory</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <param name="codeIsError">Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
/// Default value is <c>null</c> which will treat anything non-zero as an error</param>
2024-03-22 15:20:45 +01:00
/// <param name="suppressStdErr">If <c>true</c>, stderr is not forwarded to the console</param>
2024-03-22 12:15:53 +01:00
/// <returns>An awaitable task</returns>
2025-02-25 16:20:56 +01:00
public static async Task ExecuteAll ( IEnumerable < IEnumerable < string? >> commands , string? workingDirectory = null , CancellationToken cancellationToken = default , Func < int , bool >? codeIsError = null , bool suppressStdErr = false )
2024-03-22 12:15:53 +01:00
{
foreach ( var c in commands )
2024-03-22 15:20:45 +01:00
await Execute ( c , workingDirectory , cancellationToken , codeIsError , suppressStdErr ). ConfigureAwait ( false );
2024-03-20 16:17:17 +01:00
}
/// <summary>
/// Starts a commandline program and returns the contents of stdout
/// </summary>
/// <param name="command"></param>
/// <param name="workingDirectory">The working directory to run in; <c>null</c> means current directory</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <param name="codeIsError">Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
/// Default value is <c>null</c> which will treat anything non-zero as an error</param>
2024-03-22 15:20:45 +01:00
/// <param name="suppressStdErr">If <c>true</c>, stderr is not forwarded to the console</param>
2024-04-04 15:44:28 +02:00
/// <param name="writeStdIn">Function to write to stdin</param>
2024-03-20 16:17:17 +01:00
/// <returns>The output from stdout</returns>
2025-02-25 16:20:56 +01:00
public static async Task < string > ExecuteWithOutput ( IEnumerable < string? > command , string? workingDirectory = null , CancellationToken cancellationToken = default , Func < int , bool >? codeIsError = null , bool suppressStdErr = false , Func < StreamWriter , Task >? writeStdIn = null )
2024-03-20 16:17:17 +01:00
{
if (! command . Any ())
throw new ArgumentException ( "Needs at least one command" , nameof ( command ));
2025-02-25 16:20:56 +01:00
var executable = command . First ();
if ( string . IsNullOrWhiteSpace ( executable ))
throw new ArgumentException ( "Executable name cannot be empty" , nameof ( command ));
2024-03-20 16:17:17 +01:00
workingDirectory ??= Environment . CurrentDirectory ;
if (! Directory . Exists ( workingDirectory ))
Directory . CreateDirectory ( workingDirectory );
codeIsError ??= ( x ) => x != 0 ;
2025-02-25 16:20:56 +01:00
var p = Process . Start ( new ProcessStartInfo ( executable , command . Skip ( 1 ). Where ( x => ! string . IsNullOrEmpty ( x )). Select ( x => x !))
2024-03-20 16:17:17 +01:00
{
WindowStyle = ProcessWindowStyle . Hidden ,
WorkingDirectory = workingDirectory ,
2024-03-22 15:20:45 +01:00
RedirectStandardError = ! suppressStdErr ,
2024-03-22 12:15:53 +01:00
RedirectStandardOutput = true ,
2024-04-04 15:44:28 +02:00
RedirectStandardInput = writeStdIn != null ,
2024-03-22 12:15:53 +01:00
UseShellExecute = false ,
2025-02-25 16:20:56 +01:00
}) ?? throw new Exception ( $"Failed to launch process {executable}, null returned" );
2024-03-22 12:15:53 +01:00
var tstdout = p . StandardOutput . ReadToEndAsync ( cancellationToken );
2024-03-22 15:20:45 +01:00
var tstderr = suppressStdErr
? Task . CompletedTask
: p . StandardError . BaseStream . CopyToAsync ( Console . OpenStandardError (), cancellationToken );
2024-03-22 12:15:53 +01:00
2024-04-04 15:44:28 +02:00
if ( writeStdIn != null )
await writeStdIn ( p . StandardInput ). ConfigureAwait ( false );
2024-03-22 12:15:53 +01:00
await p . WaitForExitAsync ( cancellationToken ). ConfigureAwait ( false );
if ( codeIsError ( p . ExitCode ))
2025-02-25 16:20:56 +01:00
throw new Exception ( $"Execution of {executable} gave error code {p.ExitCode}" );
2024-03-22 12:15:53 +01:00
await tstderr . ConfigureAwait ( false );
return await tstdout . ConfigureAwait ( false );
}
/// <summary>
/// Starts a commandline program and returns the contents of stdout
/// </summary>
/// <param name="command"></param>
/// <param name="stdout">The stream to write the output to</param>
/// <param name="workingDirectory">The working directory to run in; <c>null</c> means current directory</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <param name="codeIsError">Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
/// Default value is <c>null</c> which will treat anything non-zero as an error</param>
2024-03-22 15:20:45 +01:00
/// <param name="suppressStdErr">If <c>true</c>, stderr is not forwarded to the console</param>
2024-04-04 15:44:28 +02:00
/// <param name="writeStdIn">Function to write to stdin</param>
2024-03-22 12:15:53 +01:00
/// <returns>The output from stdout</returns>
2025-02-25 16:20:56 +01:00
public static async Task ExecuteWithOutput ( IEnumerable < string? > command , Stream stdout , string? workingDirectory = null , CancellationToken cancellationToken = default , Func < int , bool >? codeIsError = null , bool suppressStdErr = false , Func < StreamWriter , Task >? writeStdIn = null )
2024-03-22 12:15:53 +01:00
{
if (! command . Any ())
throw new ArgumentException ( "Needs at least one command" , nameof ( command ));
2025-02-25 16:20:56 +01:00
var executable = command . First ();
if ( string . IsNullOrWhiteSpace ( executable ))
throw new ArgumentException ( "Executable name cannot be empty" , nameof ( command ));
2024-03-22 12:15:53 +01:00
workingDirectory ??= Environment . CurrentDirectory ;
if (! Directory . Exists ( workingDirectory ))
Directory . CreateDirectory ( workingDirectory );
codeIsError ??= ( x ) => x != 0 ;
2025-02-25 16:20:56 +01:00
var p = Process . Start ( new ProcessStartInfo ( executable , command . Skip ( 1 ). Where ( x => ! string . IsNullOrWhiteSpace ( x )). Select ( x => x !))
2024-03-22 12:15:53 +01:00
{
WindowStyle = ProcessWindowStyle . Hidden ,
WorkingDirectory = workingDirectory ,
2024-03-22 15:20:45 +01:00
RedirectStandardError = ! suppressStdErr ,
2024-03-20 16:17:17 +01:00
RedirectStandardOutput = true ,
2024-04-04 15:44:28 +02:00
RedirectStandardInput = writeStdIn != null ,
2024-03-20 16:17:17 +01:00
UseShellExecute = false ,
2025-02-25 16:20:56 +01:00
}) ?? throw new Exception ( $"Failed to launch process {executable}, null returned" );
2024-03-20 16:17:17 +01:00
2024-03-22 12:15:53 +01:00
var tstdout = p . StandardOutput . BaseStream . CopyToAsync ( stdout , cancellationToken );
2024-03-22 15:20:45 +01:00
var tstderr = suppressStdErr
? Task . CompletedTask
: p . StandardError . BaseStream . CopyToAsync ( Console . OpenStandardError (), cancellationToken );
2024-03-20 16:17:17 +01:00
2024-04-04 15:44:28 +02:00
if ( writeStdIn != null )
await writeStdIn ( p . StandardInput ). ConfigureAwait ( false );
2024-03-20 16:17:17 +01:00
await p . WaitForExitAsync ( cancellationToken ). ConfigureAwait ( false );
if ( codeIsError ( p . ExitCode ))
2025-02-25 16:20:56 +01:00
throw new Exception ( $"Execution of {executable} gave error code {p.ExitCode}" );
2024-03-20 16:17:17 +01:00
2024-03-22 12:15:53 +01:00
await tstderr . ConfigureAwait ( false );
await tstdout . ConfigureAwait ( false );
2024-03-20 16:17:17 +01:00
}
/// <summary>
/// Starts a commandline program and waits for it to complete
/// </summary>
/// <param name="command"></param>
/// <param name="workingDirectory">The working directory to run in; <c>null</c> means current directory</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <param name="codeIsError">Callback method that is invoked with the error code from the process; the result indicates if the status code should be interpreted as an error.
/// <param name="logFolder"/>The folder where the log files are written</param>
/// <param name="logFilename">Function to create custom filenames for the log files</param>
/// Default value is <c>null</c> which will treat anything non-zero as an error</param>
2024-04-04 15:44:28 +02:00
/// <param name="writeStdIn">Function to write to stdin</param>
2024-03-20 16:17:17 +01:00
/// <returns>The output from stdout</returns>
2025-02-25 16:20:56 +01:00
public static async Task ExecuteWithLog ( IEnumerable < string? > command , string? workingDirectory = null , CancellationToken cancellationToken = default , Func < int , bool >? codeIsError = null , string? logFolder = null , Func < int , bool , string >? logFilename = null , Func < StreamWriter , Task >? writeStdIn = null )
2024-03-20 16:17:17 +01:00
{
if (! command . Any ())
throw new ArgumentException ( "Needs at least one command" , nameof ( command ));
2025-02-25 16:20:56 +01:00
var executable = command . First ();
if ( string . IsNullOrWhiteSpace ( executable ))
throw new ArgumentException ( "Executable name cannot be empty" , nameof ( command ));
2024-03-20 16:17:17 +01:00
workingDirectory ??= Environment . CurrentDirectory ;
if (! Directory . Exists ( workingDirectory ))
Directory . CreateDirectory ( workingDirectory );
logFolder ??= workingDirectory ;
codeIsError ??= ( x ) => x != 0 ;
2025-02-25 16:20:56 +01:00
var p = Process . Start ( new ProcessStartInfo ( executable , command . Skip ( 1 ). Where ( x => ! string . IsNullOrWhiteSpace ( x )). Select ( x => x !))
2024-03-20 16:17:17 +01:00
{
WindowStyle = ProcessWindowStyle . Hidden ,
WorkingDirectory = workingDirectory ,
RedirectStandardError = true ,
RedirectStandardOutput = true ,
2024-04-04 15:44:28 +02:00
RedirectStandardInput = writeStdIn != null ,
2024-03-20 16:17:17 +01:00
UseShellExecute = false ,
2025-02-25 16:20:56 +01:00
}) ?? throw new Exception ( $"Failed to launch process {executable}, null returned" );
2024-03-20 16:17:17 +01:00
2025-02-25 16:20:56 +01:00
logFilename ??= ( pid , isStdOut ) => $"{executable}-{p.Id}.{(isStdOut ? " stdout " : " stderr ")}.log" ;
2024-03-20 16:17:17 +01:00
using var logstdout = File . Create ( Path . Combine ( logFolder , logFilename ( p . Id , true )));
using var logstderr = File . Create ( Path . Combine ( logFolder , logFilename ( p . Id , false )));
var t1 = p . StandardOutput . BaseStream . CopyToAsync ( logstdout , cancellationToken );
var t2 = p . StandardError . BaseStream . CopyToAsync ( logstderr , cancellationToken );
2024-04-04 15:44:28 +02:00
if ( writeStdIn != null )
await writeStdIn ( p . StandardInput ). ConfigureAwait ( false );
2024-03-20 16:17:17 +01:00
await p . WaitForExitAsync ( cancellationToken ). ConfigureAwait ( false );
if ( codeIsError ( p . ExitCode ))
2025-02-25 21:54:10 +01:00
throw new Exception ( $"Execution of {executable} gave error code {p.ExitCode}, see log file {Path.Combine(logFolder, logFilename(p.Id, true))}" );
2024-03-20 16:17:17 +01:00
2024-03-22 12:15:53 +01:00
await t1 . ConfigureAwait ( false );
await t2 . ConfigureAwait ( false );
2024-03-20 16:17:17 +01:00
}
}