mirror of
https://github.com/duplicati/duplicati.git
synced 2025-11-27 19:10:29 +08:00
493 lines
24 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|