Sunday, June 28, 2009

Extending STSADM...Develop your own commands : Part 2

In part 1, we have discussed how to extend STSADM using Visual Basic. We have developed our FramManifest command which we use to output the SharePoint farm content hierarchy into an XML file.

We have deployed our new command manually, which is not the way we deploy things in SharePoint. The best way is to build a SharePoint solution file (WSP), then deploy it to the farm.

A SharePoint solution is a cab file with a .wsp extension that tells SharePoint how to install this solution and how to use it.

There are two ways to build a SharePoint solution file :

- The automated way, using tools available on the web such as Wspbuilder or STSDEV or VSeWSS.

- The other way is to do the whole thing yourself. This is the best way if you want to understand how solutions files are built, and that's what we are going to do for our Farm Manifest STSADM extension project.

There are four steps to manually create and deploy a SharePoint solution file :

To begin, here is how our project looks in the solution explorer :

The project as you have noticed, contains two folders : "Config" whose content will be copied to the "/Config" folder of the 12 hive, and the folder "Solution" where the generated solution file will go.

Step 1 : Create the manifest.xml file

The manifest.xml contains a unique ID for the solution and the files this solution will deploy :
<?xml version="1.0" encoding="utf-8" ?>
<Solution
SolutionId="2D7B9843-6285-4e67-B393-CB210278E976"
xmlns="http://schemas.microsoft.com/sharepoint/">

<Assemblies>
<Assembly Location="MyStsadmCommands.dll" DeploymentTarget="GlobalAssemblyCache" />
</Assemblies>

<RootFiles>
<RootFile Location="Config\stsadmcommands.MyStsadmCommands.xml" />
<RootFile Location="MyStsadmCommands.dll" />
</RootFiles>

</Solution>

Step 2 : Create the the DDF (Diamond Directive File)

The .ddf file is used with the makecab.exe tool to build the .wsp file
OPTION EXPLICIT
.Set CabinetNameTemplate=MyStsadmCommands.wsp
.Set DiskDirectory1=Solution
.Set Cabinet=on
.Set MaxDiskSize=0
.Set CompressionType=MSZIP;
.Set DiskDirectoryTemplate=CDROM;

manifest.xml manifest.xml

CONFIG\stsadmcommands.MyStsadmCommands.xml config\stsadmcommands.MyStsadmCommands.xml

bin\Debug\MyStsadmCommands.dll MyStsadmCommands.dll


Step 3 : Build the solution file (wsp)

I usually use a command file "MakeSolution.cmd" to build the solution :
@echo off
makecab.exe /f Cab.ddf

The execution of this command file is done in the post build events of the project :


Now that every thing is ready, right-click on the project name in the solution explorer window then click "Build" or just press CTRL+SHIF+B. the "MyStsadmCommands.wsp" file should be by now created in the "/Solution" folder of the project.

Step 4 : Deploy the solution

To deploy the solution, we have to first add it to the solutions store by using the command :

Stsadm -o AddSolution -filename c:\vsprojects\Mystsadmcommands\solution\MyStsadmCommands.wsp 'Or whatever is the path of your solution file.

Then we have to deploy the solution to the farm :

Stsadm -o DeploySolution -name
MyStsadmCommands.wsp -immediate -AllowGacDeployment

To verify that the solution is really deployed and can be used, go to "Central Administration > Operations > Solution Management". You should see it in the solutions list as shown below :



That's it. Hope this helps.

Tuesday, June 16, 2009

Extending STSADM...Develop your own commands : Part 1

It's been a long time since I last wrote about STSADM, this amazing and necessary tool for every SharePoint administrator. In this post, I will show you how to extend this tool and develop your own commands using Visual Studio.

You can read the MSDN article first to understand the different steps needed to develop custom STSADM commands : How to: Extend the STSADM Utility

Let's begin by enumerating what it takes to develop our custom command :

  1. We need to know what should our command do
  2. Create a Visual Studio Class Library project and add a references to "Microsoft.SharePoint"
  3. Create a class that implements the "ISPStsadmCommand" interface
  4. Write the "GetHelpMessage" method
  5. Write the "Run" method
  6. Write the XML file that informs STSADM of our new command
  7. Sign, build and deploy the DLL of our project to the Global Assembly Cache.

What sould our command do?

The STSADM command we will develop will help us have the content of our SharePoint farm in an XML file. We'll call it "FarmManifest". The command will iterate through the farm content service and write to the XML file the content hierarchy as shown below :
  • Web Applications
    • Content Databases
    • Site Collections
      • Webs
        • Lists
        • Web Features
      • Site Features
    • Web Application Features
  • Solutions
  • Farm Features Definition
Our command should have the following syntaxe :
stsadm -o FarmManifest
-Filename (required)
[-IncludeAll] (optional) : Include all sites, webs and lists
[-IncludeSites] (optional) : Include only sites
[-IncludeWebs] (optional) : Include webs if IncludeSites
[-IncludeLists] (optional) : Include lists if IncludeSites and IncludeWebs

You may ask what's this command for? Honestly, I think it can be used for many purposes. Let's just say that it can show you all the content of your farm and help you compare the content of two farms, especially when it comes to compare features and solutions. Furthermore, you can enhance this command to include other content such as list items, event handlers, jobs, etc.

The project

Now that we know what should our command do, let's get to work. In Visual Studio we will :

- Create a new Visual Basic Class Library Project. Let's call it "MyStsadmCommands"
- Add a reference to "Windows SharePoint Services"
- Rename the default class "Class1.vb" to "FarmManifest.vb"
- Add the necessary "Imports" to the class :
Imports Microsoft.SharePoint
Imports Microsoft.SharePoint.StsAdmin
Imports Microsoft.SharePoint.Administration
Imports System.Text
Imports System.Xml
Imports System.IO

- Add a
"GetHelpMessage" method to our class :
    Public Function GetHelpMessage(ByVal command As String) As String Implements Microsoft.SharePoint.StsAdmin.ISPStsadmCommand.GetHelpMessage
        Dim strHelpMsg As String = String.Empty
        strHelpMsg = "The help message goes here ..."
        Return strHelpMsg
    End Function

- Add a "Run" method to the class :
    Public Function Run(ByVal command As String, ByVal keyValues As System.Collections.Specialized.StringDictionary, ByRef output As String) As Integer Implements Microsoft.SharePoint.StsAdmin.ISPStsadmCommand.Run
        ' Here goes the code of what sould the command do....
    End Function

- For our particular command the "FarmManifest.vb" class when finished, should look like this :

Imports Microsoft.SharePoint
Imports Microsoft.SharePoint.StsAdmin
Imports Microsoft.SharePoint.Administration
Imports System.Text
Imports System.Xml
Imports System.IO

''' <summary>
''' Classe implementing the StsAdm Extension
''' </summary> 
''' <remarks></remarks>
Public Class FarmManifest
    Implements ISPStsadmCommand
    'Private variables
    Private ManifestFile As String = String.Empty
    Private writer As XmlTextWriter
    Private mincludeAll As Boolean = False
    Private mincludeSites As Boolean = False
    Private mincludeWebs As Boolean = False
    Private mincludeLists As Boolean = False
 
    ''' <summary>
    ''' Methode to return the help message
    ''' </summary>
    ''' <param name="command"></param>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Function GetHelpMessage(ByVal command As String) As String Implements Microsoft.SharePoint.StsAdmin.ISPStsadmCommand.GetHelpMessage
        Dim strHelpMsg As String = String.Empty
        strHelpMsg = "stsadm -o FarmManifest" + Environment.NewLine
        strHelpMsg = strHelpMsg + "       -Filename <filename> (required)" + Environment.NewLine
        strHelpMsg = strHelpMsg + "       [-IncludeAll]   (optional) : Include all sites, webs and lists" + Environment.NewLine
        strHelpMsg = strHelpMsg + "       [-IncludeSites] (optional) : Include only sites" + Environment.NewLine
        strHelpMsg = strHelpMsg + "       [-IncludeWebs]  (optional) : Include webs if IncludeSites" + Environment.NewLine
        strHelpMsg = strHelpMsg + "       [-IncludeLists] (optional) : Include lists if IncludeSites and IncludeWebs" + Environment.NewLine
        Return strHelpMsg
    End Function

    ''' <summary>
    ''' Method to execute the STSADM command
    ''' </summary>
    ''' <param name="command"></param>
    ''' <param name="keyValues"></param>
    ''' <param name="output"></param>
    ''' <returns></returns>
    ''' <remarks></remarks>
    Public Function Run(ByVal command As String, ByVal keyValues As System.Collections.Specialized.StringDictionary, ByRef output As String) As Integer Implements Microsoft.SharePoint.StsAdmin.ISPStsadmCommand.Run
        'Read the commad entered parameters
        If keyValues.ContainsKey("IncludeAll") Then
            mincludeAll = True
        Else
            If keyValues.ContainsKey("IncludeSites") Then
                mincludeSites = True
                If keyValues.ContainsKey("IncludeWebs") Then
                    mincludeWebs = True
                    If keyValues.ContainsKey("IncludeLists") Then
                        mincludeLists = True
                    End If
                End If
            End If
        End If
        'Using the farm content service
        Dim contentService As SPWebService = SPWebService.ContentService
        If keyValues.ContainsKey("FileName") And keyValues("FileName") IsNot Nothing Then
            Try
                ' Open a new XML file stream for writing
                Dim stream As IO.FileStream
                stream = File.OpenWrite(keyValues("FileName"))
                writer = New XmlTextWriter(stream, Encoding.UTF8)
                ' Causes child elements to be indented
                writer.Formatting = Formatting.Indented
                writer.WriteProcessingInstruction("xml", "version=""1.0"" encoding=""utf-8""")
                writer.WriteStartElement("Manifest")
                ' Web Applications element
                writer.WriteStartElement("WebApplications")
                writer.WriteAttributeString("Count", contentService.WebApplications.Count.ToString)
                Dim webApp As SPWebApplication
                For Each webApp In contentService.WebApplications
                    If Not webApp.IsAdministrationWebApplication Then
                        writer.WriteStartElement("WebApplication")
                        writer.WriteAttributeString("ID", webApp.Id.ToString)
                        writer.WriteAttributeString("Name", webApp.Name)
                        writer.WriteAttributeString("AppPool", webApp.ApplicationPool.Name)
                        'Content databases element
                        writer.WriteStartElement("ContentDataBases")
                        writer.WriteAttributeString("Count", webApp.ContentDatabases.Count.ToString)
                        Dim contentDatabases As SPContentDatabaseCollection = webApp.ContentDatabases
                        Dim database As SPContentDatabase
                        For Each database In contentDatabases
                            writer.WriteStartElement("ContentDataBase")
                            writer.WriteAttributeString("Name", database.Name)
                            writer.WriteAttributeString("DataBaseServer", database.Server)
                            writer.WriteAttributeString("SiteCount", database.Sites.Count.ToString)
                            writer.WriteAttributeString("WarningSiteCount", database.WarningSiteCount.ToString)
                            writer.WriteAttributeString("MaximumSiteCount", database.MaximumSiteCount.ToString)
                            writer.WriteEndElement() ' Content DataBase
                        Next database
                        writer.WriteEndElement() ' Content DataBases
                        'Site Collections
                        If mincludeAll Or mincludeSites Then
                            writer.WriteStartElement("SiteCollections")
                            writer.WriteAttributeString("Count", webApp.Sites.Count.ToString)
                            For Each site As SPSite In webApp.Sites
                                writer.WriteStartElement("Site")
                                writer.WriteAttributeString("ID", site.ID.ToString)
                                writer.WriteAttributeString("Url", site.Url)
                                writer.WriteAttributeString("OwnerName", site.Owner.Name)
                                writer.WriteAttributeString("OwnerEmail", site.Owner.Email)
                                ' Webs
                                If mincludeAll Or mincludeWebs Then
                                    For Each web As SPWeb In site.AllWebs
                                        If web.IsRootWeb Then
                                            'RootWeb
                                            writer.WriteStartElement("RootWeb")
                                            writer.WriteAttributeString("ID", web.ID.ToString)
                                            writer.WriteAttributeString("Url", web.Url)
                                            writer.WriteAttributeString("Title", web.Title)
                                            writer.WriteAttributeString("Template", web.WebTemplate & "#" & web.WebTemplateId.ToString)
                                            writer.WriteStartElement("SubWebs")
                                            writer.WriteAttributeString("Count", web.Webs.Count.ToString)
                                            'Webs
                                            ProcessWebs(web.Webs)
                                            writer.WriteEndElement() 'SubWebs
                                            writer.WriteEndElement() ' RootWeb
                                        End If
                                        web.Dispose()
                                    Next
                                End If
                                'Site features
                                ProcessFeatures(site.Features, "SiteFeatures")
                                writer.WriteEndElement() ' Site
                                site.Dispose()
                            Next site
                            writer.WriteEndElement() 'Site Collections
                        End If
                        'Web App features
                        ProcessFeatures(webApp.Features, "WebAppFeatures")
                        writer.WriteEndElement() ' Web Application
                    End If
                Next webApp
                writer.WriteEndElement() ' Web Applications
                'Solutions
                writer.WriteStartElement("Solutions")
                writer.WriteAttributeString("Count", SPFarm.Local.Solutions.Count.ToString)
                For Each solution As SPSolution In SPFarm.Local.Solutions
                    writer.WriteStartElement("Solution")
                    writer.WriteAttributeString("ID", solution.Id.ToString)
                    writer.WriteAttributeString("Name", solution.Name)
                    writer.WriteAttributeString("Deployed", solution.Deployed.ToString)
                    writer.WriteAttributeString("ContainsGlobalAssembly", solution.ContainsGlobalAssembly.ToString)
                    writer.WriteAttributeString("ContainsCodeAccessSecurityPolicy", solution.ContainsCasPolicy.ToString)
                    writer.WriteAttributeString("LastOperationResult", solution.LastOperationResult.ToString)
                    writer.WriteAttributeString("LastOperationTime", solution.LastOperationEndTime.ToString)
                    writer.WriteEndElement() ' Solution
                Next solution
                writer.WriteEndElement() ' Farm features
                'Features
                writer.WriteStartElement("Features")
                writer.WriteAttributeString("Count", SPFarm.Local.FeatureDefinitions.Count.ToString)
                For Each feature As SPFeatureDefinition In SPFarm.Local.FeatureDefinitions
                    writer.WriteStartElement("Feature")
                    writer.WriteAttributeString("ID", feature.Id.ToString)
                    writer.WriteAttributeString("Name", feature.DisplayName)
                    writer.WriteAttributeString("Scope", feature.Scope.ToString)
                    writer.WriteAttributeString("SolutionID", feature.SolutionId.ToString)
                    writer.WriteEndElement() ' Feature
                Next feature
                writer.WriteEndElement() ' Farm features
                writer.WriteEndElement() ' Manifest
                ' Flush the writer and close the stream
                writer.Flush()
                stream.Close()
            Catch ex As Exception
                Console.WriteLine(ex.Message)
            End Try
        Else
            Throw New ArgumentException("FileName parameter is empty")
        End If
    End Function
    ''' <summary>
    ''' 'Recusive method to process webs and sub webs
    ''' </summary>
    ''' <param name="webcollection"></param>
    ''' <remarks></remarks>
    Private Sub ProcessWebs(ByVal webcollection As SPWebCollection)
        For Each web As SPWeb In webcollection
            writer.WriteStartElement("Web")
            writer.WriteAttributeString("ID", web.ID.ToString)
            writer.WriteAttributeString("Url", web.Url)
            writer.WriteAttributeString("Title", web.Title)
            writer.WriteAttributeString("Template", web.WebTemplate & "#" & web.WebTemplateId.ToString)
            'Web Lists
            If mincludeLists Or mincludeAll Then
                writer.WriteStartElement("Lists")
                writer.WriteAttributeString("Count", web.Lists.Count.ToString)
                For Each list As SPList In web.Lists
                    writer.WriteStartElement("List")
                    writer.WriteAttributeString("ID", list.ID.ToString)
                    writer.WriteAttributeString("Title", list.Title)
                    writer.WriteAttributeString("Author", list.Author.Name)
                    writer.WriteAttributeString("Template", list.BaseTemplate.ToString)
                    writer.WriteAttributeString("ItemsCount", list.ItemCount.ToString)
                    writer.WriteEndElement() ' List
                Next
                writer.WriteEndElement() 'Lists
            End If
            'Web features
            ProcessFeatures(web.Features, "WebFeatures")
            'Sub Webs
            ProcessWebs(web.Webs)
            writer.WriteEndElement() ' Web
            web.Dispose()
        Next
    End Sub

    ''' <summary>
    ''' Processing features
    ''' </summary>
    ''' <param name="features"></param>
    ''' <param name="element"></param>
    ''' <remarks></remarks>
    Private Sub ProcessFeatures(ByVal features As SPFeatureCollection, ByVal element As String)
        writer.WriteStartElement(element)
        writer.WriteAttributeString("Count", features.Count.ToString)
        For Each feature As SPFeature In features
            writer.WriteStartElement("Feature")
            writer.WriteAttributeString("ID", feature.Definition.Id.ToString)
            writer.WriteAttributeString("Name", feature.Definition.DisplayName)
            writer.WriteAttributeString("Scope", feature.Definition.Scope.ToString)
            writer.WriteAttributeString("SolutionID", feature.Definition.SolutionId.ToString)
            writer.WriteEndElement() ' Feature
        Next feature
        writer.WriteEndElement() 'Features
    End Sub
End Class



- Now that our custom command is ready, it's time to tell STSADM about it. To do that, we have to write a specific xml file and store it in "/config" directory of the "12 hive" (C:\Program Files\Common Files\Microsoft Shared\web server extensions\12\CONFIG). The name of the xml file must begin with "stsadmcommands." Let's call ours "stsadmcommands.MyStsadmCommands.xml". In this file is a very simple xml, we put a "Commands" element under which we declare our custom commands. A line for each command. Each command must have a name and a class. In our case, we have just one command "FarmManifest", remember! So our file should look as below :

<?xml version="1.0" encoding="utf-8" ?>
<commands>
<command name="FarmManifest"
class="MyStsadmCommands.FarmManifest, MyStsadmCommands, Version=1.0.0.0, Culture=neutral, PublicKeyToken=606cd03fbee78308" />
</commands>

As you certainly have noticed, the class attribute of the command is a string containing {the class name, the namespace, the version, the culture, the public key token}. To obtain the public key token, we have to sign our project first. To do so, go to Project properties (right click on the projet name), select the "Signing" tab, select the "Sign the assembly" option and choose in the drop down list and give the name
"MyStsadmCommands" to the signature key file :


The "MyStsadmCommands.snk" file is now added to the project. Now, build the project. We still do not have our public key token, I know ! Check out this link : Visual Studio tip to get the public key token.

For now, to begin to test our fresh StsAdm command, we only have to copy the
"stsadmcommands.MyStsadmCommands.xml" file to "C:\Program Files\Common Files\Microsoft Shared\web server extensions\12\CONFIG" and register the "MyStsadmCommand.dll" into the GAC.

Congratulation! Now in a command line window, try this :

stsadm -o FarmManifest -filename c:\FarmManifest.xml -includeall

Warning : If you have a large farm, this command may take long to execute.

In part 2 of this post, I'll explain how to build a SharePoint solution file (wsp) to deploy our custom command. Until then, if you have any question, feel free to ask.