On the other hand, Microsoft has kept the complete functionality of No Code Sandbox Solutions (NCSS) in SharePoint Online untouched so, technically, NCSS work as previously. Apparently, this is a wise decision that allows us to strip and convert only a server side code of legacy WSP-solutions and still keep the old working feature framework untouched*.
* Despite Microsoft insistently proposes to change the legacy solutions to so-called “Add-In model” this is not always possible to redesign complex legacy “Feature-based model” applications in a cost effective manner. There is an excellent article of Chris O'Brian on how to deal with conversions of similar legacy code.
- Shortly, you should get rid of any server side code in your wsp-solutions and exclude DLLs from the packages.
- "On-premises" versions of SharePoint can still use legacy server side code. For example, sandbox solutions that have been inherited from SharePoint 2010 and worked in SharePoint 2013 can still work in 2016.
About automatic redeployment of sandbox solutions
Certainly, there is a number of samples in various blogs that demonstrate how to redeploy sandbox solutions with a code.
For example, a couple of years ago I used a nice SharePoint Online Helper Library to support automatic redeployment of sandbox solutions in my projects. There is also a Powershell-based variant of the same solution developed by Jeffrey Paarhuis.
However, I have found none of those solutions could provide seamless and complete automation for both environments, "on-premises" SharePoint and SharePoint Online.
So I have developed a compact and more unified Powershell-script that covers both environments in my projects. The script works in SharePoint Online as well as in SharePoint 2013 and 2016 "on-premises". It may work in SharePoint 2010; I have not tested the script with that version, though.
The script
The important configurable settings with usage descriptions can be found in the "param" section of the script's header.
<# Unified automation script for (re)deployment of sandbox solutions to SharePoint Online and "on-premises" (2013, 2016). - Inspired by https://spohelper.codeplex.com and https://raw.githubusercontent.com/janikvonrotz/PowerShell-PowerUp/master/functions/SharePoint%20Online/Switch-SPOEnableDisableSolution.ps1 Copyright (c) Paul Borisov, 2016. 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. #> param( $siteCollectionUrl = "https://contoso.sharepoint.com/sites/sitecollection2016", # URL of either SharePoint Online or "on-premises" site collection $username = "paul.borisov@contoso.onmicrosoft.com", # Login name of either SharePoint Online account or "on-premises" Windows user in format DOMAIN\account #$siteCollectionUrl = "https://dev.contoso.local/sites/sitecollection2016", # URL of either SharePoint Online or "on-premises" site collection #$username = "vanilla\paul.borisov", # Login name of either SharePoint Online account or "on-premises" Windows user in format DOMAIN\account $password = "**********", # User's password $useLocalEnvironment = $false, # Set to $true if you intend to use local SharePoint environment. This can be usefull in case of "on-premises" site collections (not in the SharePoint Online). $useDefaultCredentials = $false, # Set to $true if you intend to use default network credentials instead of username and password $pathToDlls = "C:\Program Files\Common Files\microsoft shared\Web Server Extensions\15\ISAPI", # Full path to the folder that contains SharePoint Client components (use /15/ for SP2013 and /16/ for SP2016) $pathToFolderWIthWspSolutions = "$PSScriptRoot\WSPs" # Full path to the folder, which contains sandbox wsp-solutions to be (re)deployed ) ######################################################## FUNCTIONS ######################################################## function ActivateOrDeactivateSolution( $context, $cookieContainer, $solutionId, $activate, $useLocalEnvironment, $useDefaultNetworkCredentials, [ref]$errorMessage) { $op = $null if($activate) { $op = "ACT" } else { $op = "DEA" } $activationUrl = $context.Site.Url + "/_catalogs/solutions/forms/activate.aspx?Op=$op&ID=$solutionId" $request = $context.WebRequestExecutorFactory.CreateWebRequestExecutor($context, $activationUrl).WebRequest if( $cookieContainer -ne $null ) { $request.CookieContainer = $cookieContainer } elseif ( $useDefaultNetworkCredentials ) { $request.UseDefaultCredentials = $true } elseif( $useLocalEnvironment ) { $request.Credentials = $context.Credentials } $request.ContentLength = 0 $response = $request.GetResponse() # Decompress response stream if needed $stream = $response.GetResponseStream() try { if( -not([string]::IsNullOrEmpty($response.Headers["Content-Encoding"])) ) { if( $response.Headers["Content-Encoding"].ToLower().Contains("gzip") ) { $stream = New-Object System.IO.Compression.GZipStream($stream, [System.IO.Compression.CompressionMode]::Decompress) } elseif ( $response.Headers["Content-Encoding"].ToLower().Contains("deflate") ) { $stream = New-Object System.IO.Compression.DeflateStream($stream, [System.IO.Compression.CompressionMode]::Decompress) } } # Retrieve response content as string $reader = New-Object System.IO.StreamReader($stream) $content = $reader.ReadToEnd() $reader.Close() $reader.Dispose() } finally { $stream.Close() $stream.Dispose() } $inputMatches = $content | Select-String -AllMatches -Pattern "<input.+?\/??>" | select -Expand Matches $inputs = @{} # Iterate through inputs and add specific values to the dictionary for postback foreach( $match in $inputMatches ) { if( -not($match[0] -imatch "name=\""(.+?)\""") ) { continue } $name = $matches[1] if( -not($match[0] -imatch "value=\""(.+?)\""") ) { continue } $value = $matches[1] $inputs.Add($name, $value) } # Search for the id of the button "activate" $searchString = $null if ($activate) { $searchString = "ActivateSolutionItem" } else { $searchString = "DeactivateSolutionItem" } $match = $content -imatch "__doPostBack\(\&\#39\;(.*?$searchString)\&\#39\;" $inputs.Add("__EVENTTARGET", $Matches[1]) $response.Close() $response.Dispose() # Format inputs as postback data string, but exclude the one that ends with iidIOGoBack $strPost = $null foreach( $inputKey in $inputs.Keys ) { if( -not([String]::IsNullOrEmpty($inputKey)) -and -not($inputKey.EndsWith("iidIOGoBack")) ) { $strPost += [System.Uri]::EscapeDataString($inputKey) + "=" ` + [System.Uri]::EscapeDataString($inputs[$inputKey]) + "&" } } $strPost = $strPost.TrimEnd('&') $postData = [System.Text.Encoding]::UTF8.GetBytes($strPost); # Build postback request $activateRequest = $context.WebRequestExecutorFactory.CreateWebRequestExecutor($context, $activationUrl).WebRequest $activateRequest.Method = "POST" $activateRequest.Accept = "text/html, application/xhtml+xml, */*" if( $cookieContainer -ne $null ) { $activateRequest.CookieContainer = $cookieContainer } elseif ( $useDefaultNetworkCredentials ) { $activateRequest.UseDefaultCredentials = $true } elseif( $useLocalEnvironment ) { $activateRequest.Credentials = $context.Credentials } $activateRequest.ContentType = "application/x-www-form-urlencoded" $activateRequest.ContentLength = $postData.Length $activateRequest.UserAgent = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"; $activateRequest.Headers["Cache-Control"] = "no-cache"; $activateRequest.Headers["Accept-Encoding"] = "gzip, deflate"; # Add postback data to the request stream $stream = $activateRequest.GetRequestStream() try { $stream.Write($postData, 0, $postData.Length) } finally { $stream.Close(); $stream.Dispose() } # Perform the postback #$response = $activateRequest.GetResponse() #$response.Close() #$response.Dispose() $response = $activateRequest.GetResponse() $stream = $response.GetResponseStream() try { if( -not([string]::IsNullOrEmpty($response.Headers["Content-Encoding"])) ) { if( $response.Headers["Content-Encoding"].ToLower().Contains("gzip") ) { $stream = New-Object System.IO.Compression.GZipStream($stream, [System.IO.Compression.CompressionMode]::Decompress) } elseif ( $response.Headers["Content-Encoding"].ToLower().Contains("deflate") ) { $stream = New-Object System.IO.Compression.DeflateStream($stream, [System.IO.Compression.CompressionMode]::Decompress) } } # Retrieve response content as string $reader = New-Object System.IO.StreamReader($stream) $content = $reader.ReadToEnd() $match = [regex]::Match($content, '(?i)<div id="ms-error">.+<span id="ctl\d+_PlaceHolderMain_LabelMessage">(.[^<]+)<\/span>', "Singleline") if( $match.Success ) { $errorMessage.Value = $match.Groups[1].Value } $reader.Close() $reader.Dispose() } finally { $stream.Close() $stream.Dispose() } } function GetAuthenticationCookie($context, $siteCollectionUrl) { $sharePointUri = New-Object System.Uri($siteCollectionUrl) $authCookie = $context.Credentials.GetAuthenticationCookie($sharePointUri) if( $? -eq $false ) { return $null } else { $fedAuthString = $authCookie.TrimStart("SPOIDCRL=".ToCharArray()) $cookieContainer = New-Object System.Net.CookieContainer $cookieContainer.Add($sharePointUri, (New-Object System.Net.Cookie("SPOIDCRL", $fedAuthString))) return $cookieContainer } } function GetClientContext($siteCollectionUrl, $username, $password, $useLocalEnvironment, $useDefaultNetworkCredentials) { $context = New-Object Microsoft.SharePoint.Client.ClientContext($siteCollectionUrl) if( $useDefaultNetworkCredentials ) { $context.Credentials = [System.Net.CredentialCache]::DefaultCredentials } elseif( $useLocalEnvironment ) { #$domain = $null #$login = $null #if( $username.Contains("@") ) { # $domain = $username.Substring($username.IndexOf('@') + 1) # $login = $username.Substring(0, $username.IndexOf('@')) # $context.Credentials = (New-Object System.Net.NetworkCredential($login, $password, $domain)) #} elseif( $username.Contains("\") ) { # $domain = $username.Substring(0, $username.IndexOf('\')) # $login = $username.Substring($username.IndexOf('\') + 1) # $context.Credentials = (New-Object System.Net.NetworkCredential($login, $password, $domain)) #} else { $context.Credentials = (New-Object System.Net.NetworkCredential($username, $password)) #} } else { $securePassword = ConvertTo-SecureString $password -AsPlainText -Force $credentials = New-Object Microsoft.SharePoint.Client.SharePointOnlineCredentials($username, $securePassword) $context.Credentials = $credentials } $context.Load($context.Site) $context.Load($context.Site.RootWeb) $context.ExecuteQuery() if( $? -eq $false ) { return $null } else { return $context } } function GetSolutionFileWithListItem($context, $solutionName) { $solutionFileUrl = $context.Site.ServerRelativeUrl.TrimEnd('/') + "/_catalogs/solutions/" + $solutionName $solutionFile = $context.Site.RootWeb.GetFileByServerRelativeUrl($solutionFileUrl) $context.Load($solutionFile.ListItemAllFields) $context.ExecuteQuery() return $solutionFile } function GetSolutionId($context, $solutionName) { $solutionFile = GetSolutionFileWithListItem $context $solutionName return $solutionFile.ListItemAllFields.Id } function GetSolutionStatus($context, $solutionName) { $solutionFile = GetSolutionFileWithListItem $context $solutionName return $solutionFile.ListItemAllFields["Status"] } function UploadSolution($context, $filePath) { $fileInfo = New-Object Microsoft.SharePoint.Client.FileCreationInformation $fileInfo.Content = [System.IO.File]::ReadAllBytes($filePath) $fileInfo.Url = $filePath.Substring($filePath.LastIndexOf('\') + 1) $fileInfo.Overwrite = $true $folderUrl = $context.Site.Url + "/_catalogs/solutions" $folderUri = New-Object System.Uri($folderUrl) $solutionsFolder = $context.Site.RootWeb.GetFolderByServerRelativeUrl($folderUri.AbsolutePath) $uploadedFile = $solutionsFolder.Files.Add($fileInfo) $context.Load($uploadedFile) $context.ExecuteQuery() } ####################################################### //FUNCTIONS ####################################################### ####################################################### EXECUTION ######################################################### # Step 1. Ensure the client components loaded [Reflection.Assembly]::LoadFile(($pathToDlls.TrimEnd('\') + "\Microsoft.SharePoint.Client.dll")) | out-null [Reflection.Assembly]::LoadFile(($pathToDlls.TrimEnd('\') + "\Microsoft.SharePoint.Client.Runtime.dll")) | out-null # Step 2. Connect to SharePoint environment amd get a client context Write-Host Write-Host "Connecting to $siteCollectionUrl..." -NoNewLine $context = GetClientContext $siteCollectionUrl $username $password $useLocalEnvironment $useDefaultNetworkCredentials if( $context -eq $null ) { Write-Host "Connection failed. Further execution is impossible." return } Write-Host "Done" # Step 3. Retrieve authentication cookie out of a client context $cookieContainer = $null if( !$useLocalEnvironment -and !$useDefaultNetworkCredentials ) { Write-Host Write-Host "Retrieving authentication cookie..." -NoNewLine $cookieContainer = GetAuthenticationCookie $context $siteCollectionUrl if( $cookieContainer -eq $null ) { return } Write-Host "Done" } # Step 4. Deactivate existing solutions, (re)deploy, and (re)activate new versions of solutions Get-ChildItem -Path $pathToFolderWIthWspSolutions -Filter "*.wsp" | ? {!$_.PSIsContainer} | % { $filePath = $_.FullName $solutionName = $_.Name $solutionId = GetSolutionId $context $solutionName if( $solutionId -is [int] ) { $message = "Deactivating an old solution $solutionName..." Write-Host Write-Host $message -NoNewLine try { ActivateOrDeactivateSolution ` $context $cookieContainer $solutionId $false $useLocalEnvironment $useDefaultNetworkCredentials } catch { #The action can fail if the solution has already been deactivated earlier } Write-Host "Done" } $message = "Uploading a new solution $solutionName..." Write-Host Write-Host $message -NoNewLine UploadSolution $context $filePath if( $? -eq $false ) {continue} # Uploade error; no need for any further actions on this solution. Skip and continue to the next one. Write-Host "Done" # Refresh client context $context = GetClientContext $siteCollectionUrl $username $password $useLocalEnvironment $useDefaultNetworkCredentials $solutionId = GetSolutionId $context $solutionName if( $solutionId -is [int] ) { $message = "Activating a new solution $solutionName..." Write-Host Write-Host $message -NoNewLine $errorMessage = $null ActivateOrDeactivateSolution ` $context $cookieContainer $solutionId $true $useLocalEnvironment $useDefaultNetworkCredentials ([ref]$errorMessage) $status = GetSolutionStatus $context $solutionName if( $status -eq $null ) { write-host "Not activated" -ForegroundColor Red } elseif( $status.LookupValue -eq 1) { write-host "Activated" -ForegroundColor Green } else { write-host "Status is undefined. Check in the library /_catalogs/solutions." -ForegroundColor Yellow } if( $errorMessage -ne $null ) { write-host $errorMessage -ForegroundColor Yellow $errorMessage = $null } Write-Host "Done" } } Write-Host ###################################################### //EXECUTION ########################################################
Known issues
If your connection attempt failed with the error "The remote server returned an error: (403) Forbidden." make sure you are connecting to the correct environment using valid credentials and correct configuration parameters.
- If you connect to the SharePoint Online both configuration parameters $useLocalEnvironment and $useDefaultCredentials must be set to $false.
- If you connect to an "on-premises" SharePoint environment the configuration setting $useLocalEnvironment must be set to $true while $useDefaultCredentials can be $true or $false depending on the credentials you use to connect. If $useDefaultCredentials = $false is specified you should use valid $username and $password for your local environment.
- Verify the credentials specified in the configuration parameters $username and $password are correct and correspond to the chosen environment.
- Also verify a value for $pathToDlls. If you run the script on a machine that has a local SharePoint Farm installed this setting usually points a standard SP-hive. It can contain /15/ or /16/ depending on your local version of SharePoint.
- I have had a challenge once when I have tested the script on a Virtual Machine with SharePoint 2013 installed that also had a folder of 16-hive with a stuff for SharePoint 2016 added by some tool. And I have tried connecting to SharePoint Online.
- In that case the setting $pathToDlls = "C:\Program Files\Common Files\microsoft shared\Web Server Extensions\15\ISAPI" have worked perfectly while $pathToDlls = "C:\Program Files\Common Files\microsoft shared\Web Server Extensions\16\ISAPI" have refused to work producing the error 403 (see above).
No comments:
Post a Comment