Monday, 7 January 2019

Sitecore Commerce 9 - Custom Search Index

Update: The commerce team have put up a page on Creating a Custom Index which is a similar, but slightly different way of doing this




There are many scenarios where you might want to have a custom Solr (/Azure, though I will be focusing on Solr in this post) index on the Commerce Engine side of things - in our case we were storing a large number of a particular custom Entity in Commerce, which should be searchable by (a search box in) Sitecore.  If the search was on one particular field and/or an exact match on a field, it might have been quicker and easier to use managed lists, however in our case it was very much a search query (wildcard etc.) across a few different fields.

In the examples below I will refer to a dummy Entity called MyObject. You can replace this with the name of your custom Entity.  Obvious disclaimer: I haven't spent hours testing this code, so don't go throwing it straight into a production environment.

Solr 

core + managed-schema


Duplicate one of the existing Solr core folders (eg. OrdersScope) by taking its conf folder and copying it into a new folder called MyObjectsScope. Don't add the core to Solr yet.

In your new conf\managed-schema file swap out the specific fields for those that you want to index.  eg. in the Orders managed-schema these are the fields under the comment <!-- CommerceEngine Order -->.

In the next section below (with <copyField ... dest="_text">), in the source attributes, put each of the fields that you want to be able to search from your Sitecore (/Postman) call.  This will include the field values you've specified in each source attribute into the field called _text_ (defined a couple of sections above, in the file) which is the field that the Search endpoint searches.

Once you've finished with your config modifications, add the core to Solr.

Commerce Engine 

A quick look at the ISearchPipeline 

If you open Postman, expand the SitecoreCommerce_DevOps collection and hit the 'Get Registered Pipelines' endpoint, you can see that the search pipeline consists of the following (without any customisations):
  • Sitecore.Commerce.Plugin.Search.Azure.QueryDocumentsBlock
  • Sitecore.Commerce.Plugin.Search.Azure.ProcessDocumentSearchResultBlock
  • Sitecore.Commerce.Plugin.Search.Solr.ParseQueryTermBlock
  • Sitecore.Commerce.Plugin.Search.Solr.CreateFilterListForQueryBlock
  • Sitecore.Commerce.Plugin.Search.Solr.QueryDocumentsBlock
  • Sitecore.Commerce.Plugin.Search.Solr.ProcessDocumentSearchResultBlock
  • Sitecore.Commerce.Plugin.Search.ProcessDocumentSearchResultBlock
  • Sitecore.Commerce.Plugin.Customers.ProcessDocumentSearchResultBlock
  • Sitecore.Commerce.Plugin.Orders.ProcessDocumentSearchResultBlock
  • Sitecore.Commerce.Plugin.Catalog.ProcessDocumentSearchResultBlock
  • Sitecore.Commerce.EntityViews.IFormatEntityViewPipeline
If you want to get the gist of how search works I'd recommend having a look at the 3 blocks in bold, which make use of the policies, fields, and parameters we will define below. The first builds and calls the Solr query, the second parses the Solr response, and the third formats the results a bit more generically.

If you want to modify any part of how the query is built, you will need to customise (swap out) the QueryDocumentsBlock (either Solr or Azure version depending on what you're using).  For example, you'll see the ...Solr.QueryDocumentsBlock calls SolrContextCommand.QueryDocuments(...) which adds the artifactstoreid filter to your Solr query (to filter results on the current store) - this may not be functionality that you want / require.

PlugIn.Search.PolicySet-1.0.0.json

In the section of type Sitecore.Commerce.Core.PolicySet at the top, in the Policies array, duplicate one of the objects and replace with your Entity name. eg:
{
  "$type": "Sitecore.Commerce.Plugin.Search.SearchViewPolicy, Sitecore.Commerce.Plugin.Search",
  "SearchScopeName": "MyObjectsScope",
  "ViewName": "MyObjectsDashboard"
},

I'm not sure if ^ this part is required (haven't tested without it), but it's probably good to have in case you add functionality to the business tools later.

Duplicate one of the SearchScopePolicy sections and rename the entities/lists so you have something like this:
{
  "$type": "Sitecore.Commerce.Plugin.Search.SearchScopePolicy, Sitecore.Commerce.Plugin.Search",
  "Name": "MyObjectsScope",
  "IncrementalListName": "MyObjectsIndex",
  "FullListName": "MyObjects",
  "DeletedListName": "DeletedMyObjectsIndex",
  "EntityTypeNames": {
    "$type": "System.Collections.Generic.List`1[[System.String, mscorlib]], mscorlib",
    "$values": [
    "Feature.MyFeature.Engine.Entities.MyObject"
    ]
  },
  "ResultDetailsTags": {
    "$type": "System.Collections.Generic.List`1[[Sitecore.Commerce.Core.Tag, Sitecore.Commerce.Core]], mscorlib",
    "$values": [{
      "$type": "Sitecore.Commerce.Core.Tag, Sitecore.Commerce.Core",
      "Name": "MyObjectsList"
    }]
  }
},

Duplicate one of the IndexablePolicy sections and rename the name and properties:
{
  "$type": "Sitecore.Commerce.Plugin.Search.IndexablePolicy, Sitecore.Commerce.Plugin.Search",
  "SearchScopeName": "MyObjectsScope",
  "Properties": {
    "EntityId": {
      "TypeName": "System.String",
      "IsKey": true,
      "IsSearchable": true,
      "IsFilterable": false,
      "IsSortable": false,
      "IsFacetable": false,
      "IsRetrievable": true
    },
    "ArtifactStoreId": {
      "TypeName": "System.String",
      "IsKey": false,
      "IsSearchable": false,
      "IsFilterable": true,
      "IsSortable": false,
      "IsFacetable": false,
      "IsRetrievable": false
    },
    "Name": {
      "TypeName": "System.String",
      "IsKey": false,
      "IsSearchable": true,
      "IsFilterable": false,
      "IsSortable": true,
      "IsFacetable": false,
      "IsRetrievable": true
    },
    "DisplayName": {
      "TypeName": "System.String",
      "IsKey": false,
      "IsSearchable": true,
      "IsFilterable": false,
      "IsSortable": true,
      "IsFacetable": false,
      "IsRetrievable": true
    },
    // ... etc. for rest of your fields
}

You will need to include ArtifactStoreId, as Sitecore will filter on this field automatically (with the ID of the current store) when generating the Solr query.  I haven't tested which fields are mandatory, but I think it's a good idea to index these 4 at a minimum.

Plugin.Habitat.CommerceMinions.json

In order to call the full / incremental index minions (or have them run automatically) to have your object indexed in Solr, you will need to create a couple of policies referencing your object.
{
  "$type": "Sitecore.Commerce.Core.MinionPolicy, Sitecore.Commerce.Core",
  "ListToWatch": "MyObjects",
  "FullyQualifiedName": "Sitecore.Commerce.Plugin.Search.FullIndexMinion, Sitecore.Commerce.Plugin.Search",
  "ItemsPerBatch": 10
},
{
  "$type": "Sitecore.Commerce.Core.MinionPolicy, Sitecore.Commerce.Core",
  "WakeupInterval": "00:03:00",
  "ListToWatch": "MyObjectsIndex",
  "FullyQualifiedName": "Sitecore.Commerce.Plugin.Search.IncrementalIndexMinion, Sitecore.Commerce.Plugin.Search",
  "ItemsPerBatch": 10,
  "SleepBetweenBatches": 500
},
{
  "$type": "Sitecore.Commerce.Core.MinionPolicy, Sitecore.Commerce.Core",
  "WakeupInterval": "00:03:00",
  "ListToWatch": "DeletedMyObjectsIndex",
  "FullyQualifiedName": "Sitecore.Commerce.Plugin.Search.DeleteIndexDocumentsMinion, Sitecore.Commerce.Plugin.Search",
  "ItemsPerBatch": 10,
  "SleepBetweenBatches": 500
}

InitializeMyObjectsIndexingViewBlock.cs

Again we can base this off an existing class (eg. Sitecore.Commerce.Plugin.Orders.InitializeOrdersIndexingViewBlock).  This block is run during the index (full or incremental depending on what you put in ConfigureSitecore.cs below) and sets the values of the properties to be indexed.

public class InitializeMyObjectsIndexingViewBlock : PipelineBlock<EntityView, EntityView, CommercePipelineExecutionContext>
{
  public override Task<EntityView> Run(EntityView arg, CommercePipelineExecutionContext context)
  {
    Condition.Requires(arg).IsNotNull(string.Format("{0}: argument cannot be null.", Name));
    SearchIndexMinionArgument indexMinionArgument = context.CommerceContext.GetObjects<SearchIndexMinionArgument>().FirstOrDefault();
    if (string.IsNullOrEmpty(indexMinionArgument?.Policy?.Name))
      return Task.FromResult(arg);
    List<CommerceEntity> entities = indexMinionArgument.Entities;
    List<Entities.MyObject> source = entities != null ? entities.OfType<Entities.MyObject>().ToList() : null;
    if (source == null || !source.Any())
      return Task.FromResult(arg);
    KnownSearchViewsPolicy searchViewNames = context.GetPolicy<KnownSearchViewsPolicy>();
    source.ForEach(myObject =>
    {
      EntityView entityView = arg.ChildViews.Cast<EntityView>().FirstOrDefault(v =>
      {
        if (v.EntityId.Equals(myObject.Id, StringComparison.OrdinalIgnoreCase))
          return v.Name.Equals(searchViewNames.Document, StringComparison.OrdinalIgnoreCase);
        return false;
      });
      if (entityView == null)
      {
        entityView = new EntityView()
        {
          Name = context.GetPolicy<KnownSearchViewsPolicy>().Document,
          EntityId = myObject.Id
        };
        arg.ChildViews.Add(entityView);
      }

      entityView.Properties.Add(new ViewProperty()
      {
        Name = "EntityId",
        RawValue = myObject.Id
      });
      entityView.Properties.Add(new ViewProperty()
      {
        Name = "ArtifactStoreId",
        RawValue = context.CommerceContext.Environment.ArtifactStoreId
      });
      entityView.Properties.Add(new ViewProperty()
      {
        Name = "Name",
        RawValue = myObject.Name
      });
      entityView.Properties.Add(new ViewProperty()
      {
        Name = "DisplayName",
        RawValue = myObject.DisplayName
      });
   
      // ... etc. for rest of the fields
   
      }
    });
    return Task.FromResult(arg);
  }
}

You can also call other pipelines, or grab Components, (pretty much do whatever you like) to set property values.  You apparently cannot set values to null (unlike computed fields in Sitecore), you will have to use an empty string.

ConfigureSitecore.cs

From (again) taking a look at how the existing blocks are added, we can add ours:


.ConfigurePipeline<IIncrementalIndexMinionPipeline>(c => c.Add<InitializeMyObjectsIndexingViewBlock>().After<InitializeIndexingViewBlock>())
.ConfigurePipeline<IFullIndexMinionPipeline>(c => c.Add<InitializeMyObjectsIndexingViewBlock>().After<InitializeIndexingViewBlock>())
// Also add this (see next section)
.ConfigurePipeline<IConfigureServiceApiPipeline>(configure => configure.Add<ConfigureServiceApiBlock>().After<Sitecore.Commerce.Plugin.Search.ConfigureServiceApiBlock>())

See the file below for an explanation for why we want to add our ConfigureServiceApiBlock after the one added by the Search plugin.

ConfigureServiceApiBlock.cs

What could we possibly need to add here? For some reason the existing implementation of the Search(...) call in the Engine (in Sitecore.Commerce.Plugin.Search.ConfigureServiceApiBlock) has omitted the "scope" parameter, so let's add it back in.  This is so that we can specify that we want to search our custom index (scope).

ActionConfiguration search = modelBuilder.Procedures.FirstOrDefault(p => p.Name == "Search") as ActionConfiguration;
if (search != null)
{
  search.Parameter("scope");
}

This code assumes the procedure has already been added by the Search plugin, which is why we need to patch our block after the Search block (the section above ^).  Note that this will add the "scope" parameter after the other parameters (ie. as the last parameter).  I'm sure you could add a bunch of extra code to swap the parameters around, but that is left as an exercise for the reader.

Test the Engine index/search endpoints

At this point you should be able to run the Run FullIndex Minion call in Postman (in the SitecoreCommerce_DevOps collection) with your scope name in the "WithListToWatch" body parameter.  After a few seconds you should see some entries in your Solr index.

You can then open the SearchApiSamples/API/Search call in Postman, change the name of the "scope" body parameter to the name of your index, and should see your indexed results towards the bottom of the response (search for "Name": "SearchResult" in the json response).

Sitecore

Sitecore.Commerce.ServiceProxy project

Make sure you update your CommerceShops endpoint, as it should now have the Search method with the scope parameter included!

MyObjectManager.cs

(Or wherever you want to search from in your code)
You can use the following code to call the Search endpoint and return a list of results:

public MyObjectsResult SearchMyObjects(string shopName, string customerId, string term = "*", string filter = "", string orderBy = "", int top = 10, int skip = 0)
{
  var myObjectsResult = new MyObjectsResult();
  try
  {
    Sitecore.Commerce.Engine.Container container = EngineConnectUtility.GetShopsContainer(shopName: shopName, customerId: customerId);
    var result = Proxy.GetValue(container.Search(term, filter, orderBy, top.ToString(), skip.ToString(), MyObjectsScope));
    myObjectsResult.Success = true;
    if (result != null && result.Models.Count > 0)
    {
      EntityView ev = result.Models.First() as EntityView;
      if (ev != null && ev.ChildViews.Count > 0)
      {
        // ev.ChildViews is the list of results
        myObjectsResult.MyObjects = ev.ChildViews.Select(cv =>
        {
          EntityView myObj = cv as EntityView;
          return new Engine.Entities.MyObject
          {
            Id = myObj.GetPropertyValue("entityid"),
            Name = myObj.GetPropertyValue("name"),
            DisplayName = myObj.GetPropertyValue("displayname"),
            // ... etc.
          };
        });
      }
    }
  }
  catch (Exception ex)
  {
    Log.Error($"Unable to search myobjects using term:'{term}', filter:'{filter}', orderBy:'{orderBy}', top:'{top}', skip:'{skip}'", ex, this);
  }
  return myObjectsResult;
}

Which is referencing an extension method I've created, to grab a property from the EntityView
public static string GetPropertyValue(this EntityView ev, string property)
{
  return ev.Properties.FirstOrDefault(p => p.Name == property)?.Value;
}

Test it out!

That's all there is to it! If you could get results from your Postman call in the Engine section above, you should now get the same results in Sitecore.

Hopefully this comes in handy for some of you out there doing Commerce work.  Let me know if you come across any issues or better ways of going about searching a Commerce index.

Big thanks to Andrew Sutherland (knower of all things Commerce) for his Commerce help. Check out his blog for lots of great Commerce posts.

Tuesday, 12 December 2017

Error invoking Powershell script on Azure web apps via Kudu API

My team is running a bunch of Powershell scripts (like this) on web apps as part of our deployment process, for example to enable Solr (Sitecore configs). This was all working fine until this morning when we were getting the following error:

Invoking Kudu Api: https://mysite.scm.azurewebsites.net/api/command
@{Output=; Error=File D:\home\site\wwwroot\App_Data\Scripts\myscript.ps1 cannot be
loaded because running scripts is disabled on this system. For more
information, see about_Execution_Policies at
http://go.microsoft.com/fwlink/?LinkID=135170.
    + CategoryInfo          : SecurityError: (:) [], ParentContainsErrorRecord
   Exception
    + FullyQualifiedErrorId : UnauthorizedAccess
; ExitCode=0}
Kudu Api Successfully invoked.

Obviously following the link tells you all about Powershell security policies, and I could see that on our particular apps (and slots) via Kudu powershell, when running the command Get-ExecutionPolicy -List these were set to:

                                  Scope                         ExecutionPolicy
                                  -----                         ---------------
                          MachinePolicy                               Undefined
                             UserPolicy                               Undefined
                                Process                            RemoteSigned
                            CurrentUser                               Undefined
                           LocalMachine                               Undefined


whereas on the rest of our servers the LocalMachine value was set to RemoteSigned. There doesn't appear to be any way to change this via command line, or in the app properties. What's more I could run the desired Powershell script through the Kudu command line just fine, but the Kudu API wasn't working (so they must be different under the covers?).

After a lot of hunting around and bashing my head against the wall, I realised that somehow the web apps properties had been set to 32-bit mode, rather than their usual 64-bit. I have no idea why this affects the LocalMachine execution policy (or any of them for that matter), but this seems to be the fix for this particular issue.

Hopefully this helps someone out there!

Tuesday, 5 December 2017

Enhancing Sitecore ARM templates to be production-ready

The Sitecore ARM templates are great for most environments, and better yet they are extensible, however in a production environment you're probably going to need a few more things in your environment; things like staging slots, your own hostname(s), and maybe some storage / networking / traffic manager.

As I mentioned, Sitecore has made the their templates in a fairly modular fashion: you have your base deployment which takes all parameters, and runs a sub-deployment for '-infrastructure' (your Azure resources), '-application' (Sitecore package installation), and a sub-deployment for each Sitecore module.
This gives us the option of just running a separate template after running the base Sitecore templates, or we can integrate our additional deployment as another sub-deployment in the base template in the same way.  Obviously we want to keep our enhancements as separate as possible so that when there is an update to Sitecore or their templates it doesn't mean a massive and painful re-write of our enhancements.

I've uploaded an ARM template as a gist, and included the extra Powershell code below. They do the following:
  • Change your CM + CD hosting plan to Standard
  • Add staging slots to CM + CD
  • Copy files from your CM + CD to the staging slot
  • Add custom hostnames
  • Add an SSL certificate and enable HTTPS for all your hostnames
  • Add connection strings to your Rep / Prc web apps so you can swap them
  • Add custom firewall rules
See the gist of the ARM template which you can run on its own, or as a sub-deployment, in which case you should place this in the "nested" folder, along with the infrastructure.json file. You can then include this into your primary ARM template by duplicating the "-infrastructure" (deployment) resource and changing the name to "-infrastructure-prod" and the reference to this new json file.

You can upload your SSL cert through the ARM template, by providing the binary like in the provided gist; alternatively you can upload the certificate to a key vault (which also creates a secret in the vault). If you choose to use the vault, you must give the ARM deployment service access to your key vault by running the following Powershell command (the service principal ID is an Azure Guid, so the same for everyone):
Set-AzureRmKeyVaultAccessPolicy -VaultName your-keyvault-name -ServicePrincipalName abfa0a7c-a6b6-4736-8310-5855508787cd -PermissionsToSecrets get

You can then substitute the certificate section in the gist with the following, which uses the key vault ID and secret name:
{
  "apiVersion": "[variables('certificateApiVersion')]",
  "location": "[parameters('location')]",
  "name": "[variables('sslCertificateNameTidy')]",
  "type": "Microsoft.Web/certificates",
  "properties": {
    "keyVaultId": "[parameters('keyVaultId')]",
    "keyVaultSecretName": "[parameters('sslKeyVaultCertificateName')]"
  } 
}

In your Powershell script, after the part which does the deployment, add the following to copy the app settings and files to your staging slots:

function copyAppSettings($rg, $webApp, $slot) {
 $props = (Invoke-AzureRmResourceAction -ResourceGroupName $rg `
    -ResourceType Microsoft.Web/sites/Config -Name $webApp/appsettings `
    -Action list -ApiVersion 2015-08-01 -Force).Properties

 $hash = @{}
 $props | Get-Member -MemberType NoteProperty | % { $hash[$_.Name] = $props.($_.Name) }

 Set-AzureRMWebAppSlot -ResourceGroupName $rg -Name $webApp -Slot $slot -AppSettings $hash
}

# Copy app settings to staging slot
Write-Host "Copying app settings to staging slots";
copyAppSettings $ResourceGroupName "$($DeploymentId)-cd" "cd-staging"
copyAppSettings $ResourceGroupName "$($DeploymentId)-cm" "cm-staging"
Write-Host "Done copying app settings";
  
# Copy files to staging slot
Write-Host "Copying files to staging CD";
..\sync_slots -SubscriptionId $SubscriptionId -ResourceGroupName $ResourceGroupName -WebAppName "$($DeploymentId)-cd" -SlotName "cd-staging"
Write-Host "Copying files to staging CM";
..\sync_slots -SubscriptionId $SubscriptionId -ResourceGroupName $ResourceGroupName -WebAppName "$($DeploymentId)-cm" -SlotName "cm-staging"
Write-Host "Done copying files to staging";