Tuesday, December 17, 2024

No REST For The RESTful: Lifecycle Management APIs For Horizon

Omnissa's 2406 release of Horizon introduces Lifecycle Management (LCM) APIs to automate installs and upgrades of Connection Servers. It's an expansion of the Horizon Server API to include new RESTful API endpoints for managing updates.  Connection Servers where these endpoints are called upon leverage Microsoft’s WinRM service to communicate with a target machine, enabling the remote execution of a server installer package pulled from a web server. Practically speaking these LCM APIs allow Horizon admins to automate lifecycle management of Connection and Enrollment Servers using REST API calls, saving time and avoiding manual errors. 




Below is a video recording of an upgrade from Horizon 2312 to 2406 using the LCM APIs.  Postman is used to execute REST API calls against a Connection Server which in turn executes the upgrade on the target machine leveraging the WinRM service.  As the upgrade initiates on the target machine you'll see the download of the installer from a local web server followed by the silent execution of the Connection Server installer.  While the upgrade runs a status of the overall process is obtained through the retrieve-installer-status endpoint.




It's easy to see how a process like this could be quite helpful during a downtime. Say you have 7 Connection Servers in a POD to upgrade. You could easily knock out two upgrades at a time or even all 7 of them at once if you wanted to. This saves time during a critical update window while also eliminating the risk of manual error. Further, there's options to handle fresh Horizon installs as well, a capability that pairs wonderfully with LCM APIs support of Omnissa's Teraform provider for Horizon 8.  Finally, there's future plans to extend it's functionality to other Horizon infrastructure, so Horizon admins have plenty of motivation to explore these LCM APIs today.  

Guidance On LCM API Adoption

This post provides detailed guidance on the adoption of these new LCM APIs for Horizon. It builds upon previous articles within this No REST For The RESTful series, assuming familiarity with the Horizon Server API and RESTful principles that guide it's use.  If you're not already familiar with the Horizon Server API I highly recommend reading, No REST For The RESTful: Omnissa's Horizon Server API.  It will provide the foundational understanding necessary for this articles extensive review of the LCM API endpoints.  After this review we'll cover prerequisites for the LCM APIs, including technical deep dives on the current web server and WinRM requirements.  

Exploring The Lifecycle Management Endpoints Through Postman

While the official documentation explicitly details the different LCM API calls, to simplify their exploration you can import my Postman collection, Horizon Server API Shenanigans. The collection demonstrates various calls to the Horizon Server API, including the newer LCM APIs. You can easily import this collection into your own Postman environment by clicking on this Run in Postman button:



Once the import is complete you’ll have access to the entire Horizon Server API Shenanigans collection, including the, “Horizon LCM,” folder.


For more guidance on this Horizon Server API Shenanigans Postman collection, again, check out, No REST For The RESTful: Omnissa’s Horizon Server API.  We'll be using preconfigured calls within this collection's Horizon LCM folder as we explore the LCM APIs. 

Configuring The LCM Management Role

Once you’ve copied this collection into your own instance of Postman you can begin to tailor the calls for your Horizon environment. At minimum you’ll need to update key collection variables, including the baseUrl for your Connection server, along with the username, password and domain for the Horizon administrator you intend to use for the LCM APIs.


You'll use these credentials when retrieving an access token using the Login endpoint. 

Next, you’ll need to provide this Horizon admin account with the LCM management role. You can create this role on your server by leveraging the /config/v1/roles endpoint. Navigate to the LCM Configuration folder within Horizon Server API Shenanigans. There you’ll find a request named Create LCM Management Role.


After executing this call successfully you’ll see the new role on your Connection server. You can assign this role to your Horizon admin account either by leveraging the /config/v1/permissions endpoint or navigating through the Horizon admin interface and assigning it manually.



Registering The Server Installer Package

Next comes the critical task of registering the server installer package. This will instruct target machines where to download both the Connection Server installer and the LCM.zip bundle.


For the fileUrl parameter we’ll enter in the URL to the installer on our web server. Then, in the body of the request you populate metadata about the installer, including it’s build number, checksum and file size. A sample called Register Server Installer Package is available with the LCM configuration folder. For my own lab I used Horizon Connection Server 2406, build number 10070698069, so the request body is configured as follows:


To use a different version of Horizon Connection Server you’ll have to update these values accordingly. Upon successful execution you’ll get a 200 response, with a display of the new installer package ID.


You’ll leverage this package ID when you go on to execute an install or upgrade.

Validating Target Machine Requirements

There’s 3 different pre-check endpoints you can use to validate perquisites on the target machine regarding Active Directory, vCenter and general system requirements. These calls are relatively straightforward to configure and can be found within the LCM Target Machine Validation folder of Horizon Server API Shenanigans.  For example, to validate general system requirements we need only provide the FQDN of the target machine and Connection Server version we’re trying to upgrade to:


The Active Directory call requires nearly the same configuration, with the added requirement of a FQDN for AD:


Similarly, the vCenter pre-requisite check for vCenter requires 2 additional data points specific to the vCenter environment, the vCenter URL and vCenter version:



After receiving positive confirmation from these system check APIs we can confidently proceed with pushing out an install or upgrade. 

Executing An Upgrade

With our installer package created and prerequisite checks passed performing an actual upgrade is a relatively straight forward process. We need to provide the FQDN of the target machine along with the server installer package ID and admin credentials.


The admin credentials are going to map to the Horizon admin account we’ve entitled to the LCM role, including their username, domain and password.  This account will also need local admin access to the target machine. The installer package ID was initially provided in the response after a successful registration. If you didn’t copy it then, you can list all the registered builds and their associated attributes with the /config/v1/server-installer-packages endpoint.


After providing this ID and admin credentials to the upgrade-connection-server API you’re off to the races, with a fully automated Horizon Connection Server upgrade. Successful execution against the upgrade-connection-server endpoint will yield a 204 response code and within a matter of seconds you’ll see the upgrade begin to execute on the target machine. Task manager on the target machine will display a bunch of install activity, starting with a spike of PowerShell.

Executing A Fresh Install
 
While executing a fresh Connection Server installation using the LCM APIs isn’t rocket science there is a bit more data entry required in terms of properly populating the request body with required values. This reflects the more complex nature of a fresh install that requires additional input such as specifying the deployment type and recovery passwords.  If you navigate to the root of the Horizon LCM folder you'll find an example call to the install-connection-server endpoint.  


I’d say the most daunting part of this execution is providing the SID of the admin account. To simplify this process I’ve added a special call entitled, “Fetch Admin SID,” that displays attributes of the Horizon admin account currently used to interact with the Horizon Server API. It also assigns this account's SID to a variable called lcm_admin_cid.  This variable in turn is used for a sample call included in the LCM Sequence --> Install Sequence folder.  


More guidance on the required values for the install-connect-server endpoint is available from the Swagger instance on your local Connection Server:


This Swagger info, combined with an understanding of general install requirements, should get you everything you need for a successful install.

The LCM Sequence Folder 

The LCM Sequence folder within the Horizon LCM folder is my attempt to simplify as much as possible the execution of LCM API endpoints for a Horizon admin's specific environment.  Most of the required attributes to adapt these calls are configured through the collection scope variables for Horizon Server API Shenanigans.  These includes the baseUrl and horizon admin credentials.  


There's also some LCM specific variables like lcm_target_machine or lcm_target_cs_version.


The overall idea of these folders is to illustrate the natural sequence of calls one might execute for a typical upgrade or fresh install.  More details are available on the folders overview tab within Postman. 

The Prerequisites At A High Level

To successfully execute these LCM APIs you need a Connection Server running at least Horizon 2406, the first version to offer these APIs. You’ll also need a Horizon admin account with special LCM management privileges as well as local administrative access to target machines. For the actual execution of the installer you’ll need the Windows Remote Management (WinRM) service running on both the target machine getting updated and the Horizon Connection server where the APIs are invoked. Most notably, you’ll need a web server to host the Connection Server installer and LCM bundle originally downloaded from Customer Connect. These requirements are called out in the Omnissa Horizon 8 Installation And Upgrade guide.

The current requirement for a web server with a legitimate SSL cert is tough, as it falls slightly outside the required skill set of your typical Horizon admin.  Though future releases of the LCM API will support file shares for hosting the installer, in the current release a web server is an absolutely necessary. Fortunately, Horizon admins do typically have access to Windows servers and the following guidance will walk you through accommodating the web server requirement using Microsoft IIS.  Along with digging deeper into this web server pre-requisite we'll cover some WinRM deployment considerations and testing options.

Accommodating The Web Host Requirement With Microsoft's Internet Information Server (IIS) 

If you already have access to a web host, awesome possum, use it!  For those who don't I've put together this simple to follow recipe for meeting the requirement with Microsoft IIS.  The setup isn't rocket science, but there are some unforgiving aspects of the process we need to get right in order to test the LCM API successfully, particularly in regard to the SSL cert.  Three major steps to setting up a fresh IIS instance are: 

          Setup IIS through Server Administrator 
          Add a virtual directory to the default web site 
          Add a valid SSL certificate

I've gone ahead and broken down each of these tasks in the following sections.  (Note: a default install of IIS on your Connection Server will break Horizon, so be sure to setup IIS on a separate server.)

Setup IIS Through Server Administrator 

You can easily install IIS on Windows Server through Server Manager's Add Roles and Features Wizard.  Add the Web Server (IIS) role along with the required IIS Management Console feature.  Then next your way through this wizard, accepting all the defaults and never looking back.

 
Assuming everything goes as expected you'll soon have access to a default web site accessible over http from the localhost. After the installation is complete navigating to http://localhost on the local machine will yield the default IIS page.


Next, you need to add a virtual directory to this default web site. 

Add A Virtual Directory To The Default Website 

After adding the IIS role to your server open IIS manager and navigate to the Default Web Site. Click on the explore option:


This will take you to the physical file system representing the root of your website. Create a new folder at the root and name it whatever you'd like.  Copy the LCM.zip bundle and Connection server installer there.



At this point add a virtual directory to the website. Navigate back to IIS Manager, right click on the Default Web Site and select, “Add Virtual Directory."  Define an alias for this virtual directory and define the physical path for this virtual directory by navigating to the folder you've just created.  



Next, navigate to this new virtual folder under the Default Web Site.  Select Directory Browsing. 


Select the option to enable directory browsing available to the right.
 

Now, along with the default IIS page, you should be able to navigate to the virtual directory from the local browser.  For example, if you created a virtual directory called installer you should be able to browser it by pointing your browser to http://localhost/installer.
 


At last, it’s time for everyone’s least favorite thing to do: setup a proper SSL cert.

Add A Valid SSL Cert

If you go with a publicly trusted CA authority meeting the SSL requirement is a fairly straightforward process. For example, in my own lab I generated a CSR from IIS, submitted it to Namecheap.com and after following the normal process received my cert and CA bundle.  Then I followed a standard process for converting the cert and CA bundle to .p7b format, adding it to the IIS server, and then binding it to port 443 for the default web site.   


At this point, I had secure URL for my installer to work with.  


If by chance you're looking to use an internal Microsoft CA, i.e., one that's not publicly trusted, there's an additional step required to get things working properly.  You must import your root CA certificate for your internal CA into the JRE keystore located at JAVA_HOME\jre\lib\security\cacerts on your Connection Server.   Otherwise, when you go to register your server installer build the LCM API won't recognize the file path to your Connection Server installer, complaining you're using an "Invalid file URL," despite having a valid URL when testing with a browser. 


To avoid this challenge we need to add trust for this internal certificate authority to the JRE leveraged by Horizon, otherwise, the trust isn't there. Yes, this is the case even if the cert is trusted by your Windows host.  There's a distinction between certs trusted by the Windows OS and certificates trusted by Java, a nuance distinction, but utterly critical to the success of this endeavor.   

The first step is  to get a copy of the root CA certificate for your internal CA.  For my lab, since every desktop in the domain trusted the ca, I navigated to cert administrator on my Horizon Connection server.  Having opened cert management against the local computer I navigated to Trusted Root Certificate Authorities --> Certificates, and located my root CA.  Then I right clicked on it selecting All Tasks, then Export. 


From here, I selected to export the CA in the DER encoded format, leading to a exported copy of my cert named evengooder_internal_ca.cer. Then I navigated to the directory:

C:\Program Files\VMware\VMware View\Server\jre\lib\security>

From there I ran the command:

..\\..\bin\keytool.exe -import -trustcacerts -alias <ALIAS_NAME> -file <PATH_INTERNAL_CA_ROOT_CERT> -keystore cacerts

More specifically, I went with: 

..\\..\bin\keytool.exe -import -trustcacerts -alias evengooderCA -file c:\software\evengooder_internal_ca.cer -keystore cacerts


When executing the command you'll be prompted for the keystore password.  The default password for JRE keystore is, "changeit". 

I know it's messy, but it's necessary.  After executing this command you'll need to restart the horizon service, but at that point, you're JRE instance should trust certs issued from you're internal ca.  You can confirm the trust is there by running keytool with the -list option.   For example, I did this to get a list of trusted ca's redirected to a text file for searching.  

C:\Program Files\VMware\VMware View\Server\jre\bin>keytool.exe -list -cacerts >> c:\software\trusted_ca.txt


When navigating through the output I was able to locate my internal certificate authority.  The ultimate confirmation that things had gone right was when my server installer package started registering properly, without the,  "Invalid file url," error I was getting prior. 

The WinRM Service

In my lab there were no steps necessary to meet requirements for the Windows Remote Management (WinRM) service. It was automatically started by default on both my Connection Server and target machine. Further, since both my Connection Server and target machine were Windows 2022 servers joined to the same domain, no additional configuration was necessary.


For testing purposes, I made sure I could access the target machine from the Connection Server using Enter-PSSesssion, a PowerShell command that enables an interactive session with a remote computer using WinRM.

Enter-PSSession -ComputerName <RemoteComputerName> -Credential <Domain\Username>


At this point, I was able to run commands like dir, or even PowerShell commands like Get-Process against the remote target machine.  I was even able to launch notepad.exe on the remote target machine using Start-Process.  


I had originally anticipated more WinRM configuration, but it just wasn’t necessary in my lab. To my understanding, because both machines were joined to the same domain it wasn’t necessary to run, “winrm quickconfig,” or add trusted hosts.   Folks with unusual situations where the Connection Server is on a different domain than the target machine might require additional WinRM configs to get things working.  Otherwise, I think this WinRM requirement should be easy for typical Horizon environments to handle.  
 
Conclusion

Though adopting the LCM APIs is not for the faint of heart it's well worth the trouble.  In addition to it's already impressive functionality there's plans to extend it's scope to other Horizon infrastructure. Most Horizon admins stand to benefit from it's current capabilities, let alone it's future expanded ones. If you end up taking this technology for a test spin please let me know how it goes in the comments below.  

Wednesday, October 30, 2024

No REST for the RESTful: Python And The Horizon Server API

Expanding upon earlier articles in this No REST for the RESTful series, today's post explores Horizon automation using Python.  In the previous articles we detailed how to automate Horizon administration through a sequence of calls executed from Postman. With our logic and syntax for the Horizon Server API already sorted out via this prior work, the path forward to a Python script is relatively straight forward. We can adopt logic from Postman into Python using the Requests module, "an elegant and simple HTTP library for Python, built for human beings." 


The Requests module is an ideal mechanism for making HTTP requests from Python, providing an easy way to retrieve and process data from the Horizon Server API.  Adopting Postman logic into Python revolves around translating Postman requests to calls made from Python's Requests module.  My sample scripts are available here at GitHubThis article will provide a a quick primer on the Requests module that drives these scripts, then walk through some of the functions I've created to simplify interactions between Python and the Horizon Server API. 

Python's Requests Module 

The Requests module is a widely adopted and well documented mechanism for making HTTP requests from Python.  It allows you to make requests with as little as a single line of code.  Further, with simple to use parameters for building complex requests, along with features like it's JSON decoder, the requests module makes it easy to retrieve and parse data provided from a REST API. 

https://requests.readthedocs.io/en/latest/







Once you have the request module installed, making HTTP requests is a snap.  To make a simple request against GitHub, you can type these two lines within the Python interpreter: 

import requests
r = requests.get('https://api.github.com/events')


The response is stored in an object called r you can start to pull information from.  For example, to confirm the status of the call you can leverage the status_code attribute of the r object. 

print(r.status_code) 

Or, to get the JSON response body of the request you can leverage the JSON decoder:

print(r.json()) 

For Python interactions with the Horizon Server API I lean heavily on the GET and POST methods of the Requests module.  The more complex calls are made using Requests's parameters like params, headers and JSON.  These parameters allowed me to replicate the calls made from the Horizon Server API Shenanigans Postman collection.  

https://requests.readthedocs.io/en/latest/api/

To simplify the adoption of Horizon Server Shenanigans to Python I've created four different functions. The remainder of this article will review how these functions help automate tasks against the Horizon Server API.  


Handling Authentication With The get_access_token Function

All the sample Python scripts lean on a function for handling authentication/authorization called get_access_token.  This function replicates the login call used in the Horizon Server API Shenanigans Postman collection.  For context, here's what the Login request looks like in Postman:









To replicate this process in Python the get_access_token function gathers the ingredients required to make an identical call using the Requests module.  It's first argument, the target variable, is the full address to the login endpoint in the target Horizon environment.  The following 3 variables are the AD login credentials for an admin account in the Horizon environment.  These make their way into the JSON request body through the Requests's module JSON parameter.  

def get_access_token(target, username, password, domain):
    json_data = {
    "username": username,
    "password": password,
    "domain": domain
    }

    response = requests.post(
        target,
        json=json_data,
        verify=True
    )

    return response.json()["access_token"]

If all goes well the request module will return the request response back in the response object.  We can then use the Requests module's .json() decoder method to get the desired JSON value of access_token. This access token is an absolute requirement for any other calls to the Horizon Server API.  All the scripts start off by obtaining the access token with this function and assigning it to the variable JWtoken:

JWtoken = get_access_token(target, username, password, domain)

This JWtoken variable is then referenced by every other function that makes calls to the Horizon Server API, inserted in the headers of these calls: 

headers = {
    'authorization': 'Bearer ' + JWtoken
}


So the successful execution of this get_access_token function is absolutely required for any of the other functions to work.   


Filtering Out JSON Responses With getR_got_w_filter

A critical requirement for these scripts is to leverage filter query parameters to narrow down JSON response results. Calls to endpoints like external/v1/ad-users-or-groups can yield thousands of results depending on the size of a production environment.  Fortunately, many Horizon Server API endpoints support filtering and we can access this capability from Python scripts in a similar manner we've done already with Postman.  If you haven't already read No REST for The RESTful: Chaining Together Calls To The Horizon Server API, halfway through the article I provide a primer of filter query parameters.  

One of the most prominent uses of filtering within the Horizon Server API Shenanigans Postman collection is to narrow down a JSON array response of AD users to a single user.   Here's an example from that collection:







Yes, leveraging filter objects leads to some messy visuals in Postman, but well worth it when you consider the benefits. Thankfully, it's little less messy to use filters with Python's Requests module.   To begin with, here's what the function looks like:

def getR_got_api_w_filter(target, filter_param):
    headers = {
        'authorization': 'Bearer ' + JWtoken
    }

    params = {
        'filter': filter_param
    }

    response = requests.get(
        target,
        params=params,
        headers=headers,
        verify=True,
    )

    return response.json()


Fortunately, creating the filter object for use with the Requests module is a little less involved.  Instead of having to minify AND encode the object filter - see ugly Postman screenshot above - we only have to minify the object filter.  The Requests object handles the encoding for us by default.  So, when creating the filter object argument for getR_got_w_filter, the scripts have this relatively simple line of code: 

ad_users_or_groups_filter = '{"type":"Equals","name":"login_name","value":"' + login_name + '"}'

This add_user_or_groups variable is then passed on as an argument to getR_got_w_filter, along with a URL to the target endpoint:

responseBodyJSON = getR_got_api_w_filter(target_url, ad_users_or_groups_filter)

If all goes well we're returned a single JSON object within an array.  We can grab the internal Horizon ID from this object and assign it to target_user_id.   

target_user_id = responseBodyJSON[0]["id"]

Translating a short AD login name to this internal ID used by Horizon is a task performed in all 4 sample scripts. A lot of the more interesting tasks to address with the Horizon Server API require this process.


Making Posts To The Horizon Server API Using getR_posted_w_JSON

The culmination of these sample Horizon automations is a Post request of some sort.  All four of the automations start with the gathering of prerequisite information that eventually finds it's way in a climatic Post request.  Typically, these Post methods with the Horizon Server API require a JSON body request with specific parameters depending on the endpoint being called.  For example, there's this request that sends a message to an active Horizon session:








Calls like these are simplified within my Python scripts using the gerR_posted_w_JSON function. It crafts a POST request by taking in a target URL and JSON body as an argument.   Here's the function:

def getR_posted_w_JSON(target, json_4_request):
    headers = {
        'authorization': 'Bearer ' + JWtoken
    }

    response = requests.post(
        target,
        json=json_4_request,
        headers=headers,
        verify=True
    )

    return response

Note that, unlike the previous function, in this one the Requests module leverages the POST method, request.post,  when making it's call.   It's critical 2nd argument is a JSON body response that does most of the heavy lifting.  For example, in the Message_N_Disconnect.py script, the JSON body is delivered via a json_data variable configured like this:   

json_data = {
    "message": "Your session will be disconnected in about 8 seconds.",
    "message_type": "INFO",
    "session_ids": [ target_session_id ]
}

If you look back at the Postman screenshot directly above this variable configuration should look familiar.  

While this getR_posted_w_json method is simple enough, it plays a star role in 3 of the 4 Python scripts.

 

Searching Through Returned Arrays Of JSON Objects With getR_got_w_object_search

No all endpoints of the Horizon Server API support filters.  In those situations there's sometimes a need to do some filtering on your own by looping through an array of objects in search of a key with a unique value.  The Kill Specific App In Session automation is a great example.  The last call in this sequence returns an array of JSON objects, with each object representing a separate application actively running within a Horizon session. To narrow in on the target app within this array we can loop through each object, checking for a match between one of the objects keys called, "name," that has a value matching the target application we wish to terminate. Within Postman we accomplished this using a forEach method against the JSON response data.  If you look at the post-response script used by this call you'll see the JavaScript in action: 









To adapt this Postman request to Python I came up with the getR_got_w_object_search.  Note the foreach function leveraged at the tail end of this function.  It mimics the JavaScript logic above, looping through the entire array in search of an object with a specific key holding a searched for unique value.  Take a look:

def getR_got_w_object_search(target, params, object_key_name, object_key_value):
    headers = {
        'authorization': 'Bearer ' + JWtoken
    }

    response = requests.get(
        target,
        params=params,
        headers=headers,
        verify=True
    )

    object_array = response.json()

    for json_object in object_array:
        if json_object[ object_key_name ] == object_key_value:
            return json_object


With this function there are 4 required arguments, two of which specify the name and value to search against.   The object_key_name variable specifies the specific key of each object to search against, while object_key_value represents a value to search for.   For example, in the Python adaptation of the Kill Specific App In Session automation, the object_key_name is, "name," and the value getting searched for is, "Calculator."  When an object with the value of, "Calculator," for it's name is located, it's returned by the function.  

application_object = getR_got_w_object_search(target, params, "name", target_app)

Then the value of it's remote_application_id is pulled from the application_object returned from getR_got_w_object.

remote_app_id = application_object[ "remote_application_id" ]

This remote_app_id is then leveraged later on in a call to the end-remote-application endpoint.  

The ability to loop through an array of objects like this comes up several times throughout the four automations.  It's a requirement that's bound to show up when an endpoint doesn't support filtering or there's a situation when multiple objects need to be extracted from a response.  


Communicating Securely With The REST API

By default, Python's Requests module leverages the same trusted root authorities as the Mozilla browser.  Specifically, it uses a bundle of trusted CA certificates from the certify library, which uses a curated collection of Root Certificates from Mozilla. So if you have a publicly trusted SSL on your Connection server there's a good chance you'll be fine running requests against it.  Quite often though customers user an SSL cert issued from their on internal certificate authority, not a publicly trusted authority.  In those situations the Requests module will fail to make a secure connection and these scripts will fail.  To get around that challenge, there's two options.  The first one is rather easy and suitable if you're working within a lab environment.   You can just set Verify to false when making the requests.   For example:

    response = requests.post(
        target,
        json=json_data,
        verify=True
    )

This will disable SSL verification and allow the request to be made against an untrusted URL.   That's certainly fine for a lab environment, but isn't suitable for a production deployment.  To make secure requests against a Connection server using a SSL cert from an internal CA, you can instruct the Requests module to trust the internal certificate authority by specifying a path to a custom CA bundle to use.   In my environment I've pointed the requests module to an export of my internal certificate authority.  

    response = requests.post(
        target,
        json=json_data,
        verify="root_cert.cer"
    )

While it's easier to just set verify to false and move on to the automation fun, given you're sending AD credentials over these connections, in any situations outside a lab environment you'll need to get this sorted out properly. 

What About Error Handling? 

These sample Python scripts are designed to demonstrate bare bones functionality, so they're missing a few features required for a production environment.  Specifically, there's no error handling.  For example if you run the Message_N_Disconnect.py and the target user doesn't have an active Horizon session, the script just breaks with no useful output.  Same goes for the Kill_App_In_Session.py script.  If the target app isn't running, the script just breaks with no useful output.   The main intent of these scripts is to demonstrate as simply as possible how Python can be leveraged for the Horizon Server API, a MVP for educational purposes.   If someone intends to leverage these for production they are going to need to add some tweaks for error handling and flow control.  In the future I might update these scripts accordingly, but dadda's tired and needs a rest. 

If you want to see an example of Python scripts for the Horizon Server API that are more fine tuned for production, check out the Python module developed by Wouter Kursten.  If you look through his code you'll see he has some pretty extensive error handling in place.  You might also notice he's leaning on the Requests module to do a lot of the heavy lifting. You can view his demonstration in this VMworld 2021 video


Conclusion

My primary motivation for writing this overview is that Python is currently the programing language I'm most comfortable with.   About a year ago I leveraged Python to create troubleshoot.evengooder.com, so it's relatively fresh in my head and easy to work with.  That said, I'm no expert. Simply a geek that used to get quite frisky with VBScript in the oughts and now has developed some familiarity with Python.  So, this article isn't intended to be a tutorial on elegant Python programing.  It's a demonstration of how we can easily pivot to whatever language or mechanisms we like once we've gotten our Horizon Server API syntax down.  That's the beauty of the Horizon Server API's  REST based architecture: folks can use their programing language of choice.

Friday, September 20, 2024

No REST for The RESTful: Chaining Together Calls To The Horizon Server API

My previous post focused on getting familiar with the Horizon Server API using Swagger and Postman.  This post will review how to chain together Horizon Server API calls to automate Horizon tasks.  Using Postman collections we can configure sequences of calls that build upon each other, passing variables from request to request.  More specifically, there's JavaScript wrapped around each request through pre-request and post-requests scripts that share variables across the collection.



Postman has a rich offering for testing out API responses including a Java Script API, code snippets, and custom AI generated code.  For the purposes of automating Horizon tasks we can get an awful lot done with very little JavaScript, in some instances just two lines of code.  So, while this post will get into some JavaScript weeds, it's more about understanding how to interface with the Horizon Server API, how to interpret it's JSON responses, and chaining multiple requests together to achieve a complex outcome.  These lessons will be applicable regardless of what programing language you go on to leverage for the Horizon Server API. 

Pulling Values From Horizon Server API Responses

Responses from the Horizon Server API will include a JSON object or array of JSON objects. JSON objects are collections of key\value pairs, separated by colons and  commas, wrapped in brackets.   The values of these key\value pairs can be strings, other objects, arrays or even arrays of other objects.  To access specific data in these responses you can leverage Postman's JavaScript API within the post-response script.  For example, pm.response.json() is used to parse the response body into a JavaScript object:

const jsonData = pm.response.json();

At this point, we can leverage the jsonData object to explore the values of the JSON object programmatically with JavaScript, pulling any data off of it we need.  The Login call included in the Horizon Server API Shenanigans collection does just that.  After a successful call to the login endpoint a JSON response is returned that includes an access_token key/value pair.  The post-response script then assigns this JSON response to jsonData, an object we can harvest the access_token value from by referencing jsonData.access_token.  













The access_token value is assigned to the global JWtoken variable using pm.globals.set("JWtoken", jsonData.access_token), another example of Postman's JavaScript API.  The rest of the calls throughout the collection are configured to insert the value of this JWtoken variable within the header of their requests.   If you navigate to the authorization tab of the root of this collection you can see how Postman is configured to automatically leverage this token for authentication.   
















To illustrate further here's an example of the JSON object returned by the external/v1/ad-users-or-groups/{id}, a mechanism for retrieving a specific Active Directory user:    



















Now, say we want to grab the id value from this JSON object.  Similar to the  post-resonse script for Login, we can add the following two lines of code: 

const jsonData = pm.response.json();
pm.collectionVariables.set("ad_user_id", jsonData.id);

Once again pm.response.json() converts the JSON response into a JavaScript object, jsonData, that we can use to extract our desired data from.   Then we access the id value using jsonData.id, assigning it to the collection variable ad_user_id.  We could just as easily grab other values from the object as needed.  Say we wanted to retrieve not only the id value, but also the login name and email.  No problem:  

pm.collectionVariables.set("ad_user_id", jsonData.id);
pm.collectionVariables.set("ad_login_name", jsonData.login_name);
pm.collectionVariables.set("ad_email", jsonData.email);

Now, we've yanked 3 values off the JSON object, assigning them to the collection variables ad_user_id, ad_login_name and ad_email.  


Pulling Values From JSON Objects With Objects For Values

Now, what about situations where you have a JSON object with other JSON objects as values?  For example, the output of the inventory/v2/sessions/{id} endpoint.  Note the assigned values for client_data and security_gateway_data are JSON objects. 
















Say for instance you want to extract the IP address of the client.   Similar to the examples above, you use pm.response.json() to parse the JSON response into a JavaScript object, jsonData.  Then, to access the client ip address you go with jsonData.client_data.addressYou can easily confirm it's value using console.log()










If you look at the console after execution you'll see the value of the address yielded:







While this should all seem fairly straight forward so far, something to keep in mind is that often responses yield arrays of JSON objects to sort through, in some cases, very, very large numbers of them.  Fortunately, some of the Horizon Server API endpoints support filtering, a way to narrow down the JSON objects returned within a response.  


Filter Query Params - Using Objects To Filter Objects

A subset of Horizon Server API endpoint include support for filter parameters to narrow down results when multiple JSON objects are returned. Examples are inventory/v2/sessions or external/v1/ad-users-or-groups, endpoints that could easily return thousands of objects. In a nutshell, these endpoints accept filter query params, JSON objects that define how to filter and what value to filter by.   Here's an example: 

{
    "type": "Equals",
    “name": “login_name",
    "value": “cmoltisanti”
}


The, "name," value represents the name attribute we're we're focusing the filter on.   In the case above we're talking about the login_name.   The, "value," represents the value for the login name we're focusing on.  In the example above it's cmoltisanti.  Finally, the type represents the type of filter we're using.  In the object above it's, "equal."  (Possible values for filter types are Equals, NotEquals, Contains, StartsWith, Between, And, Not and Or.)  So the filter object example above is targeting objects with a login_name that is equal to cmoltisanti.  

In order to pass a filter query object to an endpoint we have to do two things to it.  We have minify it and then encode it.  Minifying amounts to removing all the unnecessary white space.  You could do it manually or use a utility online.  After using the first utility I found outline, I ended up with this: 

{"type":"Equals","name":"login_name","value":"cmoltisanti"}

Then, to encode it I went to https://www.urlencoder.org/.   I ended up with this: 

%7B%22type%22%3A%22Equals%22%2C%22name%22%3A%22login_name%22%2C%22value%22%3A%22cmoltisanti%22%7D

Going back to the ad-user-or-groups call in Postman, you add a new query param called, "filter," with the encoded query object as a value.   










Now, instead of getting every single user and group from my AD environment, I get the specific record for the login_name cmoltisanti. 
























It's an absolutely ugly URL, but the results are gorgeous.  Instead of having possibly thousands of objects to sort through, I've got the single AD account I'm looking for.  Also, I can easily reuse this ugly filter string to target any other user by simply replacing cmoltisanti with another valid login name.  























For more guidance on filtering, check out the Horizon Server REST, Pagination and Sorting Guide


Nailing Down The Filter Syntax With Swagger

To illustrate how handy this process can get, I'm going to review a challenge I had developing a call to inventory/v5/machines.  I needed to leverage advanced filters and for one reason or another I just couldn't get the syntax right within Postman.  So I headed over to the Swagger interface and navigated to inventory/v5/machines, then clicked the option, "Try it out." The interface already had a template in place for a filtering option.  







This preconfigured suggestion for filtering looks like this: 

{ "type": "And", "filters": [ { "type": "Equals", "name": "", "value": "<>" } ]}

To get it configured I populated it with the name of the value I wanted to match against and the actual value. So the object's value I wanted to match against was, "name" and the value was Win10-22H2-Manual-Pool. So, I edited the filter accordingly.

{ "type": "And", "filters": [ { "type": "Equals", "name": "name", "value": "Win10-22H2-Manual-Pool" } ]}

After making the edits, not only did I get successful execution against the local Horizon Connection server, but I was provided with syntax for making the call with the proper filter.   Note the long nasty request URL: 












When copying this request URL directly into Postman it automatically recognized the filter parameter and organized it in the interface as such.  













How was I messing up originally?  I don't care anymore. I used Swagger to get the answer I needed and was able to move on to life's other mysteries. 


Pulling Values From Filtered JSON Object Arrays

Earlier in this article we reviewed parsing a json response with pm.response.json(), creating a JavaScript object called jsonData that we could use to access different values of the parsed object.   Specifically, we used jsonData.access_token to harvest the access token from a successful response from the login endpoint.  We performed a similar process to pull data from a successful response from inventory/v2/sessions/{id}, using jsonData.client_data.address to retrieve the ip address of a client connection.   There's a similar but significantly different process for yanking info off our object retrieved from the filtered query above.  Take a second look at the output of that filter against the cmontisanti login name.  While there's a single JSON object displayed, it's actually an array with a single JSON object in it.  Make a note of brackets:  

























To more clearly illustrate this nuance lets leverage console.log() again, taking jsonData from our filtered query and printing it to the console.  Using console.log() in this manner provides a way to explore the structure of our JSON response.  















After executing our call we can see the results in the console.   Here we can clearly see that jsonData is actually an array with a single element in it, the JSON object representing cmoltisanti.



Fortunately, because this filter does it's search based on a unique attribute, the AD login_name, we're only getting a single object array.  Getting access to it's data is simply a matter of calling out the index of this array object, 0 because there's only a single object to choose from.  So we specify jsonData[0].what_json_object_name_we_want_the_value_of.  In this example, we're looking for the user id, so it's jsonData[0].id.  












Pulling Requests Together For Desktop Pool Entitlement And Session Management

Using the lessons we've reviewed about parsing and filtering JSON responses we can chain four separate calls together to entitle a user to a desktop pool.   After importing the Horizon Server API Shenanigans collection, navigate to the Assign User To Desktop Pool folder within the Horizon Automation folder: 











The Login request uses the login endpoint to authenticate and then retrieve an access token.  Then a filtered call to external/v1/ad-users-or-groups is used to obtain the ID of the target user.  In a similar fashion, a filtered call to inventory/v4/desktop-pools obtains the ID of the target desktop pool.  Finally, both IDs are passed as variables into the body request of a call to entitlements/v1/desktop-pools, where the final magic happens.  

To get started using this sequence, along with the variables for login credentials, you need to set two collection scoped variables, target_user and target_pool.














Populate these variables and then begin sending these calls in sequence one a time.  You'll end up with a new entitlement.  





















Or, you can run the entire folder at once by right clicking on it and selecting, "Run Folder."  Postman's Collection Runner will spring into action, executing the sequence in order, then provide a summary of the execution. 





























Whether you run the sequence through Collection Runner or manually execute the preconfigured calls one at a time the general process is the same.  We start with some preconfigured collection scope variables, then each call retrieves additional required variables, building upon each other till we achieve the final outcome of entitling the user to the target pool. The Message User And Disconnect task sequence within the Horizon Automation folder works very similarly, except you need only set the login credentials and target_user collection variable before kicking it off.  


Looping Through Arrays Of JSON Objects

When dealing with responses that are arrays of JSON objects there's times where it's necessary to iterate over every object of the array. For that we can turn to JavaScripts forEach() method. An example of this is the Get target_app_id for target_app call within the Kill Specific App In Session automation.



In this example I use the forEach() method to iterate over every object within the jsonData object returned by pm.response.json() after a call to helpdesk/v1/performance/remote-application.  In this scenario jsonData is an array of JSON objects that represent different applications running within an active session.  The forEach() method allows me to walk through each one of these objects, comparing the apps name to the target app I'm looking for. 
 
    jsonData.forEach(function(da_apps){
        if (da_apps.name.toLowerCase()  == targetApp.toLowerCase() ) {
        pm.collectionVariables.set("target_app_id", da_apps.remote_application_id);
        }
    })

If there's a match the collection variable target_app_id is assigned the remote_application_id associated with the matching JSON object.  This variable is consumed by the next call, Kill target_app, to terminate the application within the session.  

In this example it was necessary to iterate through every JSON object partially because their was no filter supported by the helpdesk/v1/performance/remote-application endpoint.   So I had to roll up my sleeves and walk through the entire array of returned objects to find what I'm looking for.  I dealt with a similar challenge in my Add VMs To Manual Pool automation.  In that scenario I had to locate a target VM from an array of JSON objects returned from a call to external/v1/virtual-machines. Again, there was no filter option supported by the API to narrow my search down to a single JSON object response.  So I used the forEach() method to iterate over each object till I found a matching VM name, then harvested it's vCenter VM id.  



This is probably as technical as I've had to get in terms of my understanding of JavaScript coding. When working with Postman the forEach() method is something you can fall back to pin point a single object or harvest information from multiple objects in an array of JSON objects.   It's a strategy I'll explore in different languages in future posts. 


Now Doowutchalike

While Postman is an excellent way to explore the Horizon Server API and share requests with other admins, you're free to choose pretty much any programing language, OS, or endpoint your wish to develop your solution on.  That's a major benefit of the modernization of Horizon's API to a REST based architecture.  Though Postman can make for a very compelling option in some use cases, it's been used in these last two posts mainly for teaching and illustration purposes.  You should have an easy time adapting these lessons to any language of your choice with a few adjustments.