I have had to write several SharePoint
2013 timer jobs for a recent project and decided it would be a good thing to
capture and share my top 10 tips for developing and testing SharePoint timer
jobs.
Each of these is explained in further
detail below. They are:
1.
Develop with a Windows Console Application
Start by creating a console application with your timer job logic in it. This is because it is MUCH easier and quicker to develop, debug, and test your code directly in a console application than by deploying a timer job and attaching to the owstimer service.
Start by creating a console application with your timer job logic in it. This is because it is MUCH easier and quicker to develop, debug, and test your code directly in a console application than by deploying a timer job and attaching to the owstimer service.
2.
Make your job configurable
Use web or web application property bags to parameterize your timer job. This gives you control and flexibility over your job, often avoiding a re-deployment to make routine changes to the timer job processing.
Use web or web application property bags to parameterize your timer job. This gives you control and flexibility over your job, often avoiding a re-deployment to make routine changes to the timer job processing.
3.
Name your timer jobs
Give your jobs a common naming structure, and preferably one that will show up on the first page of timer jobs in Central Administration. Giving them a common name groups your jobs together, and naming them such that they show up on the first page eliminates paging through the 100s of SharePoint OOB timer jobs to find your jobs.
Give your jobs a common naming structure, and preferably one that will show up on the first page of timer jobs in Central Administration. Giving them a common name groups your jobs together, and naming them such that they show up on the first page eliminates paging through the 100s of SharePoint OOB timer jobs to find your jobs.
4.
Populate the job description
Override the Description property to insert your own timer job description text. Include a version number in the description text so that you’ll always know which code version is deployed and a general description of what the job does.
Override the Description property to insert your own timer job description text. Include a version number in the description text so that you’ll always know which code version is deployed and a general description of what the job does.
5.
Update the progress bar
Calculate updates to the UpdateProgress property so that the progress bar displayed in Central Administration while your job is running accurately indicates the percent complete of your job processing.
Calculate updates to the UpdateProgress property so that the progress bar displayed in Central Administration while your job is running accurately indicates the percent complete of your job processing.
6.
Track job statistics
Provision counters and other variables to keep track of the work your timer job is doing and it’s elapsed runtime so that they can be logged or emailed upon job completion. This is helpful in evaluating unexpectedly long running or short running jobs.
Provision counters and other variables to keep track of the work your timer job is doing and it’s elapsed runtime so that they can be logged or emailed upon job completion. This is helpful in evaluating unexpectedly long running or short running jobs.
7.
Strategize errors and exceptions
Carefully consider errors/exceptions that should terminate the job immediately and mark the job unsuccessful verses errors/exceptions that should simply be logged and communicated.
Carefully consider errors/exceptions that should terminate the job immediately and mark the job unsuccessful verses errors/exceptions that should simply be logged and communicated.
8.
Log errors/exceptions and success
Log errors and job success to the windows event log and SharePoint logs. Numbering and proving details in these logs will help you when your jobs unexpectedly fail and in seeing when the jobs run successfully.
Log errors and job success to the windows event log and SharePoint logs. Numbering and proving details in these logs will help you when your jobs unexpectedly fail and in seeing when the jobs run successfully.
9.
Mark features with a custom image
Use a custom image for your features so that you can easily spot your custom
features verses SharePoint OOB features.
10. Script deployment with PowerShell
Use a PowerShell script to deploy your timer job solution, restart the SPTimerV4 service, and perform an IISRESET so that your deployment to each environment is repeatable and consistent.
Use a PowerShell script to deploy your timer job solution, restart the SPTimerV4 service, and perform an IISRESET so that your deployment to each environment is repeatable and consistent.
Details:
#1 – Develop with a Windows Console Application
This the biggest and most
time saving tip of them all. The reason is the speed and ease at which you will
be able to develop, debug, and test your code greatly outweighs the little bit
of extra work it will take to convert your console application to a timer job
once you have all of the logic debugged and tested. I even keep the console
application in source code for future job troubleshooting or enhancements.
To create a SharePoint
console application in Visual Studio (2013 Premium) you will need to do the
following:
v Create a new Visual C# (or VB) Windows Console Application,
make sure you select the current .Net Framework (4.5 for SharePoint 2013).
v Under project properties - Build, set your platform target
to x64
v Add a Reference to the “Microsoft SharePoint.dll” which is
located in C:\Program Files\Common Files\microsoft shared\Web Server
Extensions\15\ISAPI
Now you are ready to
start adding your code. You need to keep in mind that you are emulating a timer
job. The only thing the timer job will know is its parent WebApplication. In the
actual timer job in the Execute method override of the SPjobDefinition class,
that will simply be this.WebApplication
However, in our console job we need a line of code like this;
SPWebApplication webapp = SPWebApplication.Lookup(new Uri(″http://sharepoint.contoso.com″));
SPWebApplication webapp = SPWebApplication.Lookup(new Uri(″http://sharepoint.contoso.com″));
The current
WebApplication is all you can assume you have to start with. You must build your
code from there. I suggest using the Console Application Program.cs Main method
as the equivalent of the timer job Execute method. Be sure to use classes,
methods, and properties just like you intend to use in your timer job.
The advantages of
approaching your development like this are:
ü Run immediately from Visual Studio
ü Set breakpoints as needed
ü Write data to console (remove these when converting to timer
job)
ü Change variable values as needed via the debugger
ü Terminate execution as needed
ü No need to deploy, restart SPTimerV4, IISReset, attach to
owstimer service each time you wish to test or debug your code
The disadvantages I’ve found
are:
Ø console applications require your methods and properties to
be static whereas timer jobs do not so some conversion may be necessary
Ø You should remove all Console commands before converting to
an actual timer job
Ø If you have existing classes in a timer job solution you
wish to leverage, you have to recreate them in your console application
#2 – Make your job configurable
I have already written a
blog post of this here, so I will not repeat it in its entirety. In terms of a
timer job, I think this tip is especially useful because there are few other
options for making the behavior of your timer job logic configurable and
dynamic.
The tricky part of using
property bags with a timer job is determining where the property bag will be.
On one hand it makes sense to put the property bag at the web application
level, however, I have found setting and maintaining the properties at the web
application level to be more challenging than setting them at the web level.
Also, if your timer job spans sites/webs, it might be useful to be able to
dynamically modify behavior on a per site basis.
In the last
implementation of this I did, I had a Content Hub site that I know will always
exist at the same relative path and therefore I used the property bag on the
RootWeb of the Content Hub site for storage and retrieval of my dynamic
properties.
I have used properties to
modify timer job behavior in the following ways:
·
Turn on/off email
notifications
·
Set email TO: and CC:
email addresses
·
Set query attributes,
such as a cutoff date or number of hours
·
Set a list of sites to
process
·
Set a SQL database server
and database name since these often vary between development, testing, and
production
Essentially, you can use
properties to parameterize your timer jobs and increase their flexibility.
#3 – Name your timer jobs
The naming of your timer
jobs can make finding you timer jobs in the list of 100s of SharePoint Timer
Jobs easier or more difficult. Giving them a common prefix will group your jobs
together, and making that prefix alphabetically early will help get your jobs
on the first page of timer jobs displayed in SharePoint Central Administration,
under Monitoring – Review Job Definitions.
Although I have redacted
some information you can see in the screen capture below, that my 4 timer jobs
are named with a common prefix, grouped together, and occur within the first
100 timer jobs displayed by default.
Central Administration: Job Defintions |
How do you name your
jobs? This must be done in the feature event receiver. See the C# code snippet below,
look for the section commented as TIP #3.
C# (feature
receiver code snippet)
public class RecordDestructionEventReceiver : SPFeatureReceiver
{
// TIP #3: Create a constant with our job name to use throughout
const string RecordDestruction_JobName
= "ABC.Contoso.TimerJobs: RecordDestruction;
#region Public
Overrides
/// <summary>
/// When feature is activated, delete and re-add the
timer job
/// </summary>
public
override void FeatureActivated(SPFeatureReceiverProperties properties)
{
// Get a reference to the web
application
SPWebApplication webApp
= properties.Feature.Parent as SPWebApplication;
// Delete job if exists
DeleteJob(webApp,
RecordDestruction_JobName);
// Re add the job
CreateRecordDestructionJob(webApp);
} // end FeatureActivated
/// <summary>
/// When feature is deactivated, delete the timer
job
/// </summary>
public override
void Deactivating(SPFeatureReceiverProperties properties)
{
// Get a reference to the web
application
SPWebApplication webApp
= properties.Feature.Parent as SPWebApplication;
// Delete job if exists
DeleteJob(webApp, RecordDestruction_JobName);
} // end FeatureDeacctivating
#endregion Public
Overrides
#region Private
Methods
/// <summary>
/// Create and schedule a the timer job
/// </summary>
/// <param name="webApp">
/// SPWebApplication: The web application object to
create the timer job on
/// </param>
private static
void CreateRecordDestructionJob(SPWebApplication webApp)
{
// Create the job
RecordDestruction job
= new RecordDestruction(RecordDestruction_JobName, webApp);
// Set the default schedule
SPDailySchedule dailySchedule
= new SPDailySchedule();
dailySchedule.BeginHour = 4;
dailySchedule.EndHour = 5;
// Apply the schedule to the job
job.Schedule = dailySchedule;
job.Update();
} // end CreateRecordDestructionJob
/// <summary>
/// Delete the timer job with the passed job name
/// </summary>
/// <param name="webApp">
/// SPWebApplication: The web application object to delete
the job from
/// </param>
/// <param name="jobName">
/// string: The name of the job to delete
/// </param>
private static void DeleteJob(SPWebApplication
webApp, string jobName)
{
// Loop through all job definitions
foreach (SPJobDefinition job in webApp.JobDefinitions)
{
// When found, delete it and exit the loop
if ((job.Name == jobName))
{
job.Delete();
break;
}
} // end for each job
} // end DeleteJob
#endregion Private
Methods
|
#4 – Populate the job description
When you click on a timer
job in Central Administrator, it will be helpful to you and others to see a
description of what the job does and if there are any restrictions related to
the job. I have found it also helpful to display the job version number here so
I can always be sure of exactly which version of my timer job is deployed. The
following figure shows what my job looks like in Central Admin:
Central Administration: Job Definition |
You set the description
by overriding the Description property of the SPJobDefinition class. For
example, see TIP #4 in the code snippet below:
C# (SPJobDefinition
code snippet)
class
RecordDestruction
: SPJobDefinition
{
// Create constants with our job name, description and version
// to use throughout
const string RecordDestruction_JobName
= "ABC.Contoso.TimerJobs: RecordDestruction;
const string RecordDestruction_JobDescription
= "This timer job processes each record series, quieries for records
eligible for destruction and manages the destruction approval process for
Contoso records";
const string RecordDestruction_JobVersion
= "V000.07";
#region Constructors
public RecordDestruction()
: base()
{
} // end RecordDestruction()
public RecordDestruction(string
jobName, SPService service, SPServer server, SPJobLockType lockType)
: base(jobName, service, server, lockType)
{
this.Title
=
RecordDestruction_JobName;
} // end RecordDestruction(jobname, service, server,
lockType)
public RecordDestruction(string
jobName, SPWebApplication webApp)
:
base(jobName, webApp, null, SPJobLockType.Job)
{
this.Title = RecordDestruction_JobName;
} // end RecordDestruction(jobname, webApp)
#endregion Constructors
#region Overrides
///
<summary>
/// TIP #4: Override the
description to get our custom description text
///
</summary>
public override string Description
{
get
{
return string.Format("{0} -
{1}", RecordDestruction_Version, RecordDestruction_Description);
}
} // end Description
///
<summary>
/// The execute method is where all of your timer job logic
is placed
///
</summary>
public override void Execute(Guid
targetInstanceId)
{
// Capture the job start time
JobStartTime = DateTime.Now;
// Do timer job logic here
} // end Execute
#endregion Overrides
|
#5 – Update the progress bar
When a timer job is
running, SharePoint displays a progress bar.
Central Administration Running Jobs |
You can have your timer
job update this progress bar. Doing so may help you to determine if your job is
stuck or still making progress. This is simple to do in the SPJobDefinition
class but you’ll have to come up with your own formula for calculating the
percent complete. See the example below
for how I calculate the percentage completion in this fictional example.
C# (SPJobDefinition
code snippet)
class
RecordDestruction
: SPJobDefinition
{
#region Constructors
#region Overrides
///
<summary>
/// The execute method is where all of your timer job logic
is placed
///
</summary>
public override void Execute(Guid
targetInstanceId)
{
// Query for records to process
SPListItemCollection lic = QueryRecords();
// Create a current record counter
int CurrentRecord =
0;
// Process each queried record
foreach
(SPListItem
li in lic)
{
// Increment the record counter
CurrentRecord
+=
1;
// Process the record
ProcessRecord(li);
// TIP #5: Update the progress
bar
this.UpdateProgress(CurrentRecord / lic.Count);
}
} // end Execute
#endregion Overrides
|
#6 – Track job statistics
Since there is virtually
no user interaction with a timer job, I like to track some statistics about my
job and log them to the event and/or ULS log when my job completes. Each job
will be unique in terms of what to log as it will depend on what your job is
doing, but here are a few examples of the types of things I have logged:
·
Job start time
·
Job end time
·
Job elapsed time
·
Number of records queried
·
Number of records updated
Here is an example of a
successful timer job completion event log:
Event Viewer: Job Completion Event |
For code snippets
regarding how to create log entries, see Tip #8.
#7 – Strategize errors and exceptions
With timer jobs you need
to think about what errors and exceptions should terminate your job immediately
versus which errors/exception can simply be logged and allow the timer job to
continue.
For example, let’s say we
have a timer job that processes some type of requests that are queued up in a
list. If our job can’t find the list, or the web that hosts the list – that
would be an example of an error that should terminate the timer job immediately
with a failure. However, if our job is processing each request and hits a
request that doesn’t make sense then that can simply be logged and the timer
job can continue and process all the other requests in the list.
The following fictitious
code snippet attempts to illustrate this point:
C# (SPJobDefinition
code snippet)
class
RecordDestruction
: SPJobDefinition
{
#region Constructors (omitted)
#region Overrides
///
<summary>
/// The execute method is where all of your timer job logic
is placed
///
</summary>
public override void Execute(Guid
targetInstanceId)
{
try
{
// Instantiate a ProcessingLog to capture processing
information
StringBuilder
ProcessingLog = new StringBuilder();
using (SPSite site = new SPSite(weburl)
{
using (SPWeb web =
site.OpenWeb())
{
//
Get the list of items to process
SPList
list = web.Lists.TryGetList(listname);
if (list == null)
{
// If the list is not found, we cannot continue
throw new ArgumentNullException(listname, string.Format("List {0} not found on web
{1}", listname, weburl));
}
// Process the items in the list
foreach
(SPListItem
item in list.Items)
{
// Call a method to perform some validation of the item
if (ValidateItemProperties(item)
{
// If validated, process the item
}
else
{
// TIP #7: Otherwise, handle non-fatal error
ProcessingLog.AppendFormat("\nItem {0} ({1}): Failed validation.",
item.Title, item.Id);
}
} // end foreach item
} // end using web
} // end using site
// Call a method to log the final
results, passing the log
LogJobCompletion(ProcessingLog);
} // end try
catch (Exception ex)
{
// Log the error here, see Tip #8
LoggingService.LogMessage(
16210,
16210,
LoggingService.LogCategory.Log_Exception.ToString(),
EventSeverity.Error,
TraceSeverity.High,
String.Format("Fatal exception caught in YourJobName.Execute: {0}", ex.Message),
ex.StackTrace
);
// Re-Throw the exception to
force the job to fail
throw ex;
} // end catch
} // end Execute
#endregion Overrides
|
#8 – Log errors/exceptions and success
Log timer job errors/exceptions
and success to the Event Log and/or the SharePoint ULS log. This is the same
way SharePoint logs timer job errors and success. Create your own Source/Area
name and your own numbering scheme so that you can filter the logs easily to
see only events associated with your custom timer jobs.
You can do this by
creating your own methods within a LoggingService based on the
SPDiagnosticsServiceBase class (namespace Microsoft.SharePoint.Administration).
A simplified example of what this class might look like is below, an example of
how to call it is above in TIP #7:
C# (LoggingService
class code snippet)
class
LoggingService
: SPDiagnosticsServiceBase
{
/// <summary>
/// Define categories
/// </summary>
public enum LogCategory
{
None = 0,
Log_Info = 1,
Log_Error = 2,
Log_Exception = 3,
}
/// <summary>
/// Define our area name
/// </summary>
private static string Area_Name = "your
area name";
private static LoggingService _current;
/// <summary>
/// Custom Logging Service
/// </summary>
private LoggingService() :
base("<your prefix>.Logging Service",
SPFarm.Local)
{ }
/// <summary>
/// Logging service
/// </summary>
public static LoggingService Current
{
get
{
if (_current == null)
_current = new LoggingService();
return _current;
}
} // end Current
/// <summary>
/// Areas and category mapping
/// </summary>
protected override IEnumerable<SPDiagnosticsArea>
ProvideAreas()
{
List<SPDiagnosticsArea>
areas = new List<SPDiagnosticsArea>{
new SPDiagnosticsArea(Area_Name, new List<SPDiagnosticsCategory>{
new SPDiagnosticsCategory(LogCategory.Log_Info.ToString(),
TraceSeverity.Verbose,
EventSeverity.Information),
new SPDiagnosticsCategory(LogCategory.Log_Error.ToString(),
TraceSeverity.Unexpected,
EventSeverity.Warning),
new SPDiagnosticsCategory(LogCategory.Log_Exception.ToString(),
TraceSeverity.High,
EventSeverity.Error),
})
};
return areas;
} // end ProvideAreas
/// <summary>
/// Log message to the ULS log
/// </summary>
public static void LogMessageToULS(UInt32
traceID, string categoryName, TraceSeverity
traceSeverity, string message, params object[] data)
{
SPDiagnosticsCategory
category = LoggingService.Current.Areas[Area_Name].Categories[categoryName];
LoggingService.Current.WriteTrace(traceID,
category, traceSeverity, message, data);
} // end LogMessageToULS
/// <summary>
/// Log message to the event log
/// </summary>
public static void LogMessageToEventLog(UInt16
eventID, string categoryName, EventSeverity
eventSeverity, string message, params object[] data)
{
SPDiagnosticsCategory
category =
LoggingService.Current.Areas[Area_Name].Categories[categoryName];
LoggingService.Current.WriteEvent(eventID,
category, eventSeverity, message, data);
} // end LogMessageToEventLog
/// <summary>
/// Log message to the ULS and event log
/// </summary>
public static void LogMessage(UInt16
eventID, UInt32 traceID, string
categoryName, EventSeverity eventSeverity, TraceSeverity
traceSeverity, string message, params object[] data)
{
SPDiagnosticsCategory
category = LoggingService.Current.Areas[Area_Name].Categories[categoryName];
LoggingService.Current.WriteEvent(eventID,
category, eventSeverity, message, data);
LoggingService.Current.WriteTrace(traceID,
category, traceSeverity, message, data);
} // end LogMessage
} //
end class
|
#9 – Mark features with a custom image
This tip applies to any
custom features you develop, not just timer job features. Providing a custom
image, naming your features with a common prefix, and providing a good
description will group your custom features together, make then easy to
differentiate against SharePoint features, and help viewers understand what the
feature does. For example, see the Web Application Features image below where
my timer jobs are grouped together and use a custom image:
Central Administration: Web Application Features |
What size should my image be? For
feature logos your image size should be 31 pixels wide by 21 pixels tall.
How do I associate my image to my
feature? You can configure them in Visual Studio, or code them directly into
your feature manifest. Note this image capture also points out the feature
description, and alternate text for the image should it not be available.
Visual Studio: Feature Properties |
Finally, where do I put my images so
that they can be referenced? There are probably multiple opinions regarding the
answer to this question each with their own merits and issues. Since I
generally install a MasterPage solution, I like to include my custom images (as
well as custom .css and .js files) with my master page. This will put my images
in a subfolder in the /layouts/images folder of the hive where they are accessible
to all sites on the farm. This may not be the best solution in all
circumstances and I encourage you to think about your farms configuration and
needs before choosing a location for your images.
#10 – Script deployments with PowerShell
In most cases your will
develop your timer jobs in a development environment, test them in one or more
integrated test environments, and deploy them to a production environment.
Scripting your deployment with PowerShell helps you insure consistent
deployment across multiple environments.
What are the steps needed
in a script to deploy, install, and activate a timer job solution?
1.
Deactivate features
2.
Uninstall solution
3.
Remove solution
4.
Add solution
5.
Install solution
6.
Activate features
7.
Restart OWS Timer Service
8.
Reset IIS
So you would think maybe
10-20 lines of PowerShell could handle this, but my deployment script is about 130
lines.
PowerShell (deploy a timer job)
param(
[Parameter(Mandatory=$true)][System.String]$webappurl,
[Parameter(Mandatory=$true)][System.String]$wsppath,
[Parameter(Mandatory=$true)][System.String]$wsp,
[Parameter(Mandatory=$true)][System.String[]]$featureids)
try {
# Verify the wsp
$wspfullpath
=
("{0}\{1}" -f $wsppath,
$wsp);
if (!(test-path $wspfullpath))
{
throw ("'{0}'
does not exist!" -f $wspfullpath);
}
# 1 - Deactivate FeatureIds
write-host;
write-host -f green " 1. Deactivating features...";
foreach ($featureid in
$featureids)
{
write-host -f green " Feature ID: " -nonewline;
write-host -f white $featureid;
$Feature
=
Get-SPFeature -WebApplication
$webappurl | where { $_.Id -eq $featureid
}
if ($Feature -ne $null)
{
Disable-SPFeature -Identity
$featureid
-url $webappurl -force -confirm:$false;
$i = 0;
While ($Feature -ne $null)
{
write-host -f
yellow " Waiting for feature to
deactivate...";
Start-Sleep -s
10;
$Feature
=
Get-SPFeature -WebApplication
$webappurl | where { $_.Id -eq $featureid
}
}
}
else
{
write-host -f yellow " Feature is not currently
active...";
}
}
# 2 - Uninstall the solution
write-host -f green " 2. Uninstalling solution: " -nonewline;
write-host -f white $wsp;
$Sols = Get-SPSolution | where { $_.Name -eq $wsp }
if (($Sols -ne $null)
-and
($Sols.Deployed -eq "True"))
{
Uninstall-SPSolution -Identity
$wsp
-confirm:$false;
}
else
{
write-host -f yellow " Solution uninstall not
required.";
}
# If solution exists, wait until uninstalled before
removing
$Sols = Get-SPSolution | where { $_.Name -eq $wsp }
While (($Sols -ne $null) -and ($Sols.Deployed
-eq
"True"))
{
write-host -f yellow " Waiting for solution to
uninstall...";
Start-Sleep -s 10;
$Sols = Get-SPSolution | where { $_.Name -eq $wsp }
}
# 3 - Remove the solution
write-host -f green " 3. Removing solution...";
if ($Sols -ne $null)
{
Remove-SPSolution -Identity
$wsp
-confirm:$false;
}
else
{
write-host -f yellow " Solution removal not required.";
}
# 4 - Add the solution
write-host -f green " 4. Adding solution...";
Add-SPSolution $wspfullpath | out-null;
$Sols = Get-SPSolution | where { $_.Name -eq $wsp }
While ($Sols -eq $null)
{
write-host -f yellow " Waiting for solution to be
added...";
Start-Sleep -s 10;
$Sols = Get-SPSolution | where { $_.Name -eq $wsp }
}
# 5 - Install the solution
write-host -f green " 5. Installing solution...";
Install-SPSolution -Identity
$wsp
-GACDeployment -force;
While ($Sols.Deployed
-ne
"True")
{
write-host -f yellow " Waiting for solution to be
installed...";
Start-Sleep -s 10;
$Sols = Get-SPSolution | where { $_.Name -eq $wsp }
}
# 6 - Activate features
write-host -f green " 6. Activating features...";
foreach ($featureid in $featureids) {
write-host -f green " Feature ID: " -nonewline;
write-host -f white $featureid;
$Feature
=
Get-SPFeature -WebApplication
$webappurl | where { $_.Id -eq $featureid
}
if ($Feature -eq $null)
{
Enable-SPFeature
-Identity $featureid -url $webappurl;
}
While ($Feature -eq $null)
{
write-host -f yellow " Waiting for feature to
activate...";
Start-Sleep -s 10;
$Feature
=
Get-SPFeature -WebApplication
$webappurl | where { $_.Id -eq $featureid
}
}
}
# 7 - Restart the timer service
write-host -f green " 7. Restarting OWS Timer Service...";
restart-service sptimerv4;
# 8 - IISRESET
write-host -f green " 8. Performing iisreset...";
iisreset;
# Return success
exit 0;
} # end try
catch {
# Display information
write-host;
write-host -f red -BackgroundColor
yellow ("Exception
caught in {0}: " -f $MyInvocation.MyCommand.Name)
-nonewline;
write-host -f red $_.Exception.Message;
# Return failure
exit 99;
} # end catch
|
No comments:
Post a Comment