duplicati/Duplicati/Library/Modules/Builtin/RunScript.cs

493 lines
24 KiB
C#

// 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.
using System;
using System.IO;
using System.Collections.Generic;
using Duplicati.Library.Utility;
using Duplicati.Library.Interface;
using Duplicati.Library.Modules.Builtin.ResultSerialization;
using System.Threading.Tasks;
using System.Text.RegularExpressions;
using System.Linq;
using Duplicati.Library.Logging;
namespace Duplicati.Library.Modules.Builtin
{
public class RunScript : IGenericCallbackModule, IGenericPriorityModule
{
/// <summary>
/// The tag used for logging
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType<RunScript>();
/// <summary>
/// The default log level
/// </summary>
private const Logging.LogMessageType DEFAULT_LOG_LEVEL = Logging.LogMessageType.Warning;
/// <summary>
/// The regex used to parse arguments
/// </summary>
private static readonly Regex ARGREGEX = new Regex(
@"(?<arg>(?<=\s|^)(""(?<value>[^""\\]*(?:\\.[^""\\]*)*)""|'(?<value>[^'\\]*(?:\\.[^'\\]*)*)'|(?<value>[^\s]+))\s?)",
RegexOptions.Compiled | RegexOptions.ExplicitCapture
);
private const string STARTUP_OPTION = "run-script-before";
private const string FINISH_OPTION = "run-script-after";
private const string REQUIRED_OPTION = "run-script-before-required";
private const string TIMEOUT_OPTION = "run-script-timeout";
private const string ENABLE_ARGUMENTS_OPTION = "run-script-with-arguments";
/// <summary>
/// Option used to set the log level for mail reports
/// </summary>
private const string OPTION_LOG_LEVEL = "run-script-log-level";
/// <summary>
/// Option used to set the log filters for mail reports
/// </summary>
private const string OPTION_LOG_FILTER = "run-script-log-filter";
private const string RESULT_FORMAT_OPTION = "run-script-result-output-format";
private const string DEFAULT_TIMEOUT = "60s";
private string m_requiredScript = null;
private string m_startScript = null;
private string m_finishScript = null;
private int m_timeout = 0;
private bool m_enableArguments = false;
private string m_operationName;
private string m_remoteurl;
private string[] m_localpath;
private IDictionary<string, string> m_options;
private IResultFormatSerializer resultFormatSerializer;
/// <summary>
/// The log scope that should be disposed
/// </summary>
private IDisposable m_logscope;
/// <summary>
/// The log storage
/// </summary>
private FileBackedStringList m_logstorage;
/// <summary>
/// The priority of the module, used to determine the order in which modules are executed.
/// </summary>
public int Priority => 100;
#region IGenericModule implementation
public void Configure(IDictionary<string, string> commandlineOptions)
{
commandlineOptions.TryGetValue(STARTUP_OPTION, out m_startScript);
commandlineOptions.TryGetValue(REQUIRED_OPTION, out m_requiredScript);
commandlineOptions.TryGetValue(FINISH_OPTION, out m_finishScript);
m_enableArguments = Utility.Utility.ParseBoolOption(commandlineOptions.AsReadOnly(), ENABLE_ARGUMENTS_OPTION);
ResultExportFormat resultFormat;
if (!commandlineOptions.TryGetValue(RESULT_FORMAT_OPTION, out var tmpResultFormat))
{
resultFormat = ResultExportFormat.Duplicati;
}
else if (!Enum.TryParse(tmpResultFormat, true, out resultFormat))
{
resultFormat = ResultExportFormat.Duplicati;
}
resultFormatSerializer = ResultFormatSerializerProvider.GetSerializer(resultFormat);
if (!commandlineOptions.TryGetValue(TIMEOUT_OPTION, out var t))
t = DEFAULT_TIMEOUT;
m_timeout = (int)Utility.Timeparser.ParseTimeSpan(t).TotalMilliseconds;
m_options = commandlineOptions;
m_options.TryGetValue(OPTION_LOG_FILTER, out var logfilterstring);
var filter = FilterExpression.ParseLogFilter(logfilterstring);
var logLevel = Utility.Utility.ParseEnumOption(m_options.AsReadOnly(), OPTION_LOG_LEVEL, DEFAULT_LOG_LEVEL);
m_logstorage = new FileBackedStringList();
m_logscope = Logging.Log.StartScope(m => m_logstorage.Add(m.AsString(true)), m =>
{
if (filter.Matches(m.FilterTag, out var result, out var match))
return result;
else if (m.Level < logLevel)
return false;
return true;
});
}
public string Key { get { return "runscript"; } }
public string DisplayName { get { return Strings.RunScript.DisplayName; } }
public string Description { get { return Strings.RunScript.Description; } }
public bool LoadAsDefault { get { return true; } }
public IList<ICommandLineArgument> SupportedCommands
{
get
{
string[] resultOutputFormatOptions = [ResultExportFormat.Duplicati.ToString(), ResultExportFormat.Json.ToString()];
return new List<ICommandLineArgument>([
new CommandLineArgument(STARTUP_OPTION, CommandLineArgument.ArgumentType.Path, Strings.RunScript.StartupoptionShort, Strings.RunScript.StartupoptionLong),
new CommandLineArgument(FINISH_OPTION, CommandLineArgument.ArgumentType.Path, Strings.RunScript.FinishoptionShort, Strings.RunScript.FinishoptionLong),
new CommandLineArgument(REQUIRED_OPTION, CommandLineArgument.ArgumentType.Path, Strings.RunScript.RequiredoptionShort, Strings.RunScript.RequiredoptionLong),
new CommandLineArgument(ENABLE_ARGUMENTS_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.RunScript.EnableArgumentsShort, Strings.RunScript.EnableArgumentsLong),
new CommandLineArgument(RESULT_FORMAT_OPTION,
CommandLineArgument.ArgumentType.Enumeration,
Strings.RunScript.ResultFormatShort,
Strings.RunScript.ResultFormatLong(resultOutputFormatOptions),
ResultExportFormat.Duplicati.ToString(),
null,
resultOutputFormatOptions),
new CommandLineArgument(TIMEOUT_OPTION, CommandLineArgument.ArgumentType.Timespan, Strings.RunScript.TimeoutoptionShort, Strings.RunScript.TimeoutoptionLong, DEFAULT_TIMEOUT),
new CommandLineArgument(OPTION_LOG_LEVEL, CommandLineArgument.ArgumentType.Enumeration, Strings.ReportHelper.OptionLoglevelShort, Strings.ReportHelper.OptionLoglevelLong, DEFAULT_LOG_LEVEL.ToString(), null, Enum.GetNames(typeof(Logging.LogMessageType))),
new CommandLineArgument(OPTION_LOG_FILTER, CommandLineArgument.ArgumentType.String, Strings.ReportHelper.OptionLogfilterShort, Strings.ReportHelper.OptionLogfilterLong),
]);
}
}
#endregion
#region IGenericCallbackModule implementation
public void OnStart(string operationname, ref string remoteurl, ref string[] localpath)
{
if (!string.IsNullOrEmpty(m_requiredScript))
Execute(m_requiredScript, "BEFORE", operationname, ref remoteurl, ref localpath, m_timeout, true, m_enableArguments, m_options, null, null);
if (!string.IsNullOrEmpty(m_startScript))
Execute(m_startScript, "BEFORE", operationname, ref remoteurl, ref localpath, m_timeout, false, m_enableArguments, m_options, null, null);
// Save options that might be set by a --run-script-before script so that the OnFinish method
// references the same values.
m_operationName = operationname;
m_remoteurl = remoteurl;
m_localpath = localpath;
}
public void OnFinish(IBasicResults result, Exception exception)
{
// Dispose the current log scope
if (m_logscope != null)
{
try { m_logscope.Dispose(); }
catch { }
m_logscope = null;
}
if (string.IsNullOrEmpty(m_finishScript))
return;
ParsedResultType level;
OperationAbortException oae = exception as OperationAbortException;
if (oae != null)
{
switch (oae.AbortReason)
{
case OperationAbortReason.Error:
level = ParsedResultType.Error;
break;
case OperationAbortReason.Normal:
level = ParsedResultType.Success;
break;
case OperationAbortReason.Warning:
level = ParsedResultType.Warning;
break;
default:
level = ParsedResultType.Unknown;
break;
}
}
else if (exception != null)
level = ParsedResultType.Fatal;
else if (result != null)
level = result.ParsedResult;
else
level = ParsedResultType.Error;
using (TempFile tmpfile = new TempFile())
{
using (var streamWriter = new StreamWriter(tmpfile))
streamWriter.Write(resultFormatSerializer.Serialize(result, exception, m_logstorage, null));
Execute(m_finishScript, "AFTER", m_operationName, ref m_remoteurl, ref m_localpath, m_timeout, false, m_enableArguments, m_options, tmpfile, level);
}
}
#endregion
private static void Execute(string scriptpath, string eventname, string operationname, ref string remoteurl, ref string[] localpath, int timeout, bool requiredScript, bool enableArguments, IDictionary<string, string> options, string datafile, ParsedResultType? level)
{
try
{
var arguments = new List<string>();
if (enableArguments)
{
var args = ARGREGEX.Matches(scriptpath);
if (args.Any())
{
arguments = args.AsEnumerable().Select(m => m.Groups["value"].Value).ToList();
scriptpath = arguments[0];
arguments.RemoveAt(0);
}
}
var psi = new System.Diagnostics.ProcessStartInfo(scriptpath, arguments)
{
WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden,
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = false
};
foreach (KeyValuePair<string, string> kv in options)
psi.EnvironmentVariables["DUPLICATI__" + kv.Key.Replace('-', '_')] = kv.Value;
if (!options.ContainsKey("backup-name"))
psi.EnvironmentVariables["DUPLICATI__backup_name"] = System.IO.Path.GetFileNameWithoutExtension(Duplicati.Library.Utility.Utility.getEntryAssembly().Location);
psi.EnvironmentVariables["DUPLICATI__EVENTNAME"] = eventname;
psi.EnvironmentVariables["DUPLICATI__OPERATIONNAME"] = operationname;
psi.EnvironmentVariables["DUPLICATI__REMOTEURL"] = remoteurl;
if (level != null)
psi.EnvironmentVariables["DUPLICATI__PARSED_RESULT"] = level.Value.ToString();
if (localpath != null)
psi.EnvironmentVariables["DUPLICATI__LOCALPATH"] = string.Join(System.IO.Path.PathSeparator.ToString(), localpath);
string stderr = null;
string stdout = null;
if (!string.IsNullOrEmpty(datafile))
psi.EnvironmentVariables["DUPLICATI__RESULTFILE"] = datafile;
using (System.Diagnostics.Process p = System.Diagnostics.Process.Start(psi))
{
var cs = new ConsoleDataHandler(p);
if (timeout <= 0)
p.WaitForExit();
else
p.WaitForExit(timeout);
if (requiredScript)
{
if (!p.HasExited)
throw new UserInformationException(Strings.RunScript.ScriptTimeoutError(scriptpath), "RunScriptTimeout");
else if (p.ExitCode != 0)
throw new UserInformationException(Strings.RunScript.InvalidExitCodeError(scriptpath, p.ExitCode), "RunScriptInvalidExitCode");
}
if (p.HasExited)
{
cs.WaitForCompletion();
stderr = cs.StandardError;
stdout = cs.StandardOutput;
SendStdOutToLogs(stdout);
if (p.ExitCode != 0)
{
if (!requiredScript)
{
// We log a warning or an error depending on the exit code
switch (p.ExitCode)
{
case 0:
case 1:
// No messages here
break;
case 2:
case 3:
Logging.Log.WriteWarningMessage(LOGTAG, "InvalidExitCode", null, Strings.RunScript.ExitCodeError(scriptpath, p.ExitCode, stderr));
stderr = null;
break;
case 4:
case 5:
default:
Logging.Log.WriteErrorMessage(LOGTAG, "InvalidExitCode", null, Strings.RunScript.ExitCodeError(scriptpath, p.ExitCode, stderr));
stderr = null;
break;
}
// If this is the start event, we abort the backup
if (eventname == "BEFORE")
{
switch (p.ExitCode)
{
case 0: // OK, run operation
case 2: // Warning, run operation
case 4: // Error, run operation
break;
case 1: // OK, don't run operation
throw new OperationAbortException(OperationAbortReason.Normal, Strings.RunScript.InvalidExitCodeError(scriptpath, p.ExitCode));
case 3: // Warning, don't run operation
throw new OperationAbortException(OperationAbortReason.Warning, Strings.RunScript.InvalidExitCodeError(scriptpath, p.ExitCode));
default: // Error don't run operation
throw new OperationAbortException(OperationAbortReason.Error, Strings.RunScript.InvalidExitCodeError(scriptpath, p.ExitCode));
}
}
}
else
Logging.Log.WriteWarningMessage(LOGTAG, "InvalidExitCode", null, Strings.RunScript.InvalidExitCodeError(scriptpath, p.ExitCode));
}
}
else
{
Logging.Log.WriteWarningMessage(LOGTAG, "ScriptTimeout", null, Strings.RunScript.ScriptTimeoutError(scriptpath));
}
}
if (!string.IsNullOrEmpty(stderr))
Logging.Log.WriteWarningMessage(LOGTAG, "StdErrorNotEmpty", null, Strings.RunScript.StdErrorReport(scriptpath, stderr));
//We only allow setting parameters on startup
if (eventname == "BEFORE" && stdout != null)
{
foreach (string rawline in stdout.Split(new string[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries))
{
string line = rawline.Trim();
if (!line.StartsWith("--", StringComparison.Ordinal))
continue; //Ignore anything that does not start with --
line = line.Substring(2);
int lix = line.IndexOf('=');
if (lix == 0) //Skip --= as that makes no sense
continue;
string key;
string value;
if (lix < 0)
{
key = line.Trim();
value = "";
}
else
{
key = line.Substring(0, lix).Trim();
value = line.Substring(lix + 1).Trim();
if (value.Length >= 2 && value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal))
value = value.Substring(1, value.Length - 2);
}
if (string.Equals(key, "remoteurl", StringComparison.OrdinalIgnoreCase))
{
remoteurl = value;
}
else if (string.Equals(key, "localpath", StringComparison.OrdinalIgnoreCase))
{
localpath = value.Split(System.IO.Path.PathSeparator);
}
else if (
string.Equals(key, "eventname", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "operationname", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "main-action", StringComparison.OrdinalIgnoreCase) ||
key == ""
)
{
//Ignore
}
else
options[key] = value;
}
}
}
catch (OperationAbortException)
{
// Do not log this, it is already logged
throw;
}
catch (Exception ex)
{
Logging.Log.WriteWarningMessage(LOGTAG, "ScriptExecuteError", ex, Strings.RunScript.ScriptExecuteError(scriptpath, ex.Message));
if (requiredScript)
throw;
}
}
/// <summary>
/// Helper class to extract output from the program
/// </summary>
private class ConsoleDataHandler
{
public ConsoleDataHandler(System.Diagnostics.Process p)
{
m_task = Task.WhenAll(
Task.Run(async () => StandardOutput = await p.StandardOutput.ReadToEndAsync()),
Task.Run(async () => StandardError = await p.StandardError.ReadToEndAsync())
);
}
private readonly Task m_task;
public string StandardOutput { get; private set; }
public string StandardError { get; private set; }
public void WaitForCompletion()
{
// NOTE: This is ugly, but there is a race where "HasExited" is set,
// but the stdout/stderr streams have not yet completed.
// If we wait a little here, we eventually get the data.
// If the streams have completed we do not wait.
m_task.Wait(TimeSpan.FromSeconds(5));
}
}
/// <summary>
/// Define the log actions for the different prefixes
/// </summary>
private static readonly Dictionary<string, Action<string>> LogActions = new()
{
["LOG:WARN"] = msg => Log.WriteWarningMessage(LOGTAG, "ScriptOutput", null, msg),
["LOG:ERROR"] = msg => Log.WriteErrorMessage(LOGTAG, "ScriptOutput", null, msg),
["LOG:INFO"] = msg => Log.WriteInformationMessage(LOGTAG, "ScriptOutput", msg)
};
/// <summary>
/// Parses the STDOUT of the script and sends it to the logs according to the prefix
/// </summary>
/// <param name="stdout">Captured stdout stream from the process</param>
private static void SendStdOutToLogs(string stdout)
{
if (String.IsNullOrWhiteSpace(stdout))
return;
// Explicit CR/LF types for all OSes instead of Environment.NewLine in case stdout producer
// script explicitly uses a different line ending from the OS the process is ran on.
foreach (var line in stdout.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries))
{
var prefix = LogActions.Keys.FirstOrDefault(p => line.StartsWith(p));
if (prefix == null) continue;
var message = line.Substring(prefix.Length).Trim();
LogActions[prefix](message);
}
}
}
}