Continued from “Building a Windows Service – Part 3: Creating a Service Installer

In the previous post we created a rough draft of our service installer. In this post we will focus on extending the capabilities of the default ServiceInstaller class and enhancing the behavior of the default class. Here are the goals:

  1. Use declarative attributes on our service to define display name, description etc
  2. Add the ability to control the services command-line arguments during install
  3. Add the ability to set the DelayAutoStart flag on the service (already available in .NET 4.0)
  4. Add the ability to control the ShutdownTimeout for the service
  5. Add the ability to control the failure auto-restart options of the service
  6. Customize the service’s access control during installation
  7. Allow settings to be controlled by the command-line when appropriate
  8. Extend the command-line help text to provide help information for the settings available

So that is a big to-do list so let’s get started. First up we need a simple attribute to define some service specific things. Currently I’m using the following to define the default startup mode for the service and just reusing the System.ComponentModel’s attributes for DisplayName and Description. The ServiceAccessAttribute currently depends on a custom enumeration, ServiceAccessRights, created from these values.

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class ServiceAttribute : Attribute
{
    public readonly string ServiceName;
    public ServiceStartMode StartMode = ServiceStartMode.Manual;
    public int AutoRestartAttempts = 0;
    public int AutoRestartDelayMilliseconds = 1000;
    public int ResetFailureDelaySeconds = 86400;

    public ServiceAttribute() : this(null) { }
    public ServiceAttribute(string serviceName)
    {
        ServiceName = serviceName;
    }
}

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class ServiceAccessAttribute : Attribute
{
    public readonly WellKnownSidType Sid;
    public readonly AceQualifier Qualifier;
    public readonly ServiceAccessRights AccessMask;

    public ServiceAccessAttribute(WellKnownSidType sid, AceQualifier qualifier, ServiceAccessRights accessMask)
    {
        Sid = sid;
        Qualifier = qualifier;
        AccessMask = accessMask;
    }
}

//And apply the attributes on our service class as follows:
[DisplayName("My Service Name")]
[Description("This is a description of the service")]
[Service("testSvc", StartMode = ServiceStartMode.Automatic, AutoRestartAttempts = 2)]
[ServiceAccess(WellKnownSidType.BuiltinAdministratorsSid, AceQualifier.AccessAllowed, ServiceAccessRights.SERVICE_ALL_ACCESS)]
[ServiceAccess(WellKnownSidType.BuiltinUsersSid, AceQualifier.AccessAllowed, ServiceAccessRights.GENERIC_READ | ServiceAccessRights.GENERIC_EXECUTE)]
class ServiceImplementation : IDisposable
{
    ...

The ServiceAttribute exposes a default constructor as well as one that takes a service name. Using the default constructor no service name will be defined unless the command-line option is specified. Now that we have the attributes defined we can create a derivation of the ServiceInstaller to handle our customization. Unfortunately installers are not compatible with generics as so we can’t use that, but a simple type argument for the constructor will suffice. During construction we will use reflection to interrogate the service type for the attributes we defined. The values found in those attributes will be copied to properties of this class, or it’s base class for use during installation.

    public class CustomServiceInstaller : ServiceInstaller
    {
        public CustomServiceInstaller(Type serviceType)
        {
            foreach (ServiceAttribute attr in serviceType
                .GetCustomAttributes(typeof(ServiceAttribute), true))
            {
                if (!String.IsNullOrEmpty(attr.ServiceName))
                    ServiceName = attr.ServiceName;

                StartType = attr.StartMode;
                AutoRestartAttempts = attr.AutoRestartAttempts;
                AutoRestartDelayMilliseconds = attr.AutoRestartDelayMilliseconds;
                ResetFailureDelaySeconds = attr.ResetFailureDelaySeconds;
            }

            foreach (DisplayNameAttribute attr in serviceType
                .GetCustomAttributes(typeof(DisplayNameAttribute), true))
                DisplayName = attr.DisplayName;

            foreach (DescriptionAttribute attr in serviceType
                .GetCustomAttributes(typeof(DescriptionAttribute), true))
                Description = attr.Description;

            List aces = new List();
            foreach (ServiceAccessAttribute attr in serviceType
                .GetCustomAttributes(typeof(ServiceAccessAttribute), true))
                aces.Add(attr);

            if (aces.Count > 0)
                ServiceAccess = aces.ToArray();
        }

        [DefaultValue("")]
        [Description("Gets or sets the command-line arguments provided to the service executable.")]
        public string ServiceArguments { get; set; }

        [DefaultValue(false)]
        [Description("Gets or sets a value to delay the service startup.")]
        public bool DelayAutoStart { get; set; }

        [DefaultValue(300)]
        [Description("Gets or sets the timeout in seconds for a service shutdown.")]
        public int ShutdownTimeoutSeconds { get; set; }

        [Description("Gets or sets the access control entries to be defined for the service.")]
        public ServiceAccessAttribute[] ServiceAccess { get; set; }

        [DefaultValue(0)]//no restart
        [Description("Gets or sets the number of times to automatically reset the service, 0, 1, 2, or 3.")]
        public int AutoRestartAttempts { get; set; }

        [DefaultValue(1000)]//1 second
        [Description("Gets or sets the milliseconds to delay between failure and automatic restart.")]
        public int AutoRestartDelayMilliseconds { get; set; }

        [DefaultValue(86400)]//24 hours
        [Description("Gets or sets the time in seconds after which to reset the failure count to zero if there are no failures.")]
        public int ResetFailureDelaySeconds { get; set; }

So now for the install right? Well not exactly, we are going to need a few helpers to get there. The first thing we want to do is to build a routine to populate all the properties on this class from the Context.Parameters collection.

private void FillSettings(StringDictionary arguments)
{
    foreach (PropertyInfo pi in GetType().GetProperties())
    {
        if(arguments.ContainsKey(pi.Name))
        {
            object value = Convert.ChangeType(arguments[pi.Name], pi.PropertyType,
                                              System.Globalization.CultureInfo.InvariantCulture);
            pi.SetValue(this, value, null);
        }
    }
}

Then of course we actually need to enforce the service name exists and is not empty. Normally this value would always be provided on the command-line for a service install and pulled from the savedState collection when performing a Rollback or Uninstall. The reality is that InstallUtil’s data store is not the most trustworthy of things. As such, we will always allow the command-line to override the service name for install and uninstall. There might be a danger here of using this as a generic service removal tool; however, if they have admin rights they could accomplish the same from regedit so the user get’s what he asks for :). If we were truly worried about it we could verify that the service was indeed bound to the executable path for the assembly. Anyway here is our GetServiceName routine.

public string GetServiceName(StringDictionary parameters) { return GetServiceName(parameters, null, ServiceName); }
private string GetServiceName(IDictionary savedState) { return GetServiceName(Context.Parameters, savedState, ServiceName); }
private static string GetServiceName(StringDictionary parameters, IDictionary savedState, string defaultValue)
{
    string name = parameters["ServiceName"];
    if (String.IsNullOrEmpty(name) && savedState != null)
        name = (string)savedState["ServiceName"];
    if (String.IsNullOrEmpty(name))
        name = defaultValue;

    if (String.IsNullOrEmpty(name))
        throw new ArgumentException("Missing required parameter 'ServiceName'.");
    return name;
}

The public overload for this allows us to use the same routine from the parent installer to obtain the correct service name. The other private overload is going to be used by us during install. First let’s look at the easy stuff, here are the overloads that use the above routines to make this install work.

public override void Install(IDictionary stateSaver)
{
    FillSettings(Context.Parameters);
    stateSaver["ServiceName"] = ServiceName = GetServiceName((IDictionary)null);

    //run the install to completion
    base.Install(stateSaver);

    //now we need to augment the installation options
    using (ServiceController svc = new ServiceController(ServiceName))
    {
        Win32Services.SetServiceExeArgs(svc, Context.Parameters["assemblypath"], ServiceArguments);
        Win32Services.SetDelayAutostart(svc, DelayAutoStart);
        Win32Services.SetShutdownTimeout(svc, TimeSpan.FromSeconds(ShutdownTimeoutSeconds));
        if (ServiceAccess != null)
            Win32Services.SetAccess(svc, ServiceAccess);
        if (AutoRestartAttempts > 0)
            Win32Services.SetRestartOnFailure(svc, AutoRestartAttempts, AutoRestartDelayMilliseconds, ResetFailureDelaySeconds);
    }
}

public override void Commit(IDictionary savedState)
{
    ServiceName = GetServiceName(savedState);
    base.Commit(savedState);
}

public override void Uninstall(IDictionary savedState)
{
    ServiceName = GetServiceName(savedState);
    base.Uninstall(savedState);
}

public override void Rollback(IDictionary savedState)
{
    ServiceName = GetServiceName(savedState);
    base.Rollback(savedState);
}

Yep easy and clean with the exception of those Win32Services methods… Not wanting to bloat this post with a bunch of pinvoke junk, I’ve just uploaded it so you can click here to view Win32Services.cs.

So this wraps up most of our installer work with the EventLog stuff still remaining. We will deal with that at a later time, but right now we can build, install and uninstall service and finally it should all run with a few small changes to the main installer. So cracking open the installer we wrote in the last post we will start by removing the Rollback and Uninstall methods. Then we need to change the type of the _installService property to be our new CustomServiceInstaller passing in the type of our decorated service (ServiceImplementation) to the constructor. The assignment of the StartType property can also be removed from the constructor. Finally we need to retool the Install method a little. We are going to remove the assignment of the ServiceName and DisplayName properties as well as the manipulation of the savedState. We will still need to obtain the service name so that we can inject it onto the command-line. So here is what our new Install method looks like:

public override void Install(IDictionary stateSaver)
{
    if (Context.Parameters.ContainsKey("username"))
        _installProcess.Account = ServiceAccount.User;

    string svcName = _installService.GetServiceName(Context.Parameters);
    _installService.ServiceArguments = ("-service " + svcName + " " + _installService.ServiceArguments).Trim();

    //run the install
    base.Install(stateSaver);
}

Now when our service is run by the SCM it always runs the ‘-service’ command we added at the end of part 2 and always provides the service name to run as. We now have a working service and installer! The next thing to do is to modify those command-line options again so that we can install and uninstall by simply running the executable.

Continued on “Building a Windows Service – Part 5: Adding command-line installation

Comments