Bulk Create Jenkins Nodes

If permanent slaves are required and hundreds of them, it’s very manual and tedious tasks. I have created a PowerShell script to create Jenkins nodes in bulk. CloudBees’ version of Jenkins seems to have nice API to create nodes but open source/free version doesn’t seem to have one though Jenkins CLI could be utilized to accomplish it. Please follow my previous blog article to make sure your Jenkins CLI works before moving on with this article. Please note that this script does not install Jenkins slaves on separate machines but I am planning to create a script to do that in the future.

Prerequisites

  • Windows (The script I am going to introduce utilizes PowerShell, so Windows is the safest choice of OS.)
  • Java
  • Donwloaded jenkins-cli.jar

The PowerShell Script Execution

The PowerShell script that I wrote can generate nodes in bulk. It can be found here in GitHub.

For example, execute the script with the parameters like below.

.\Jenkins_BulkCreateNodes.ps1 -JenkinsServerUrl https://jenkins.linux-mint.local/ -Username myadminuser -ApiToken 1197adsfasdfadsfasdfasdfasdfasdf3a -NodeCount 100 -NodeNamePattern "winnode-###" -OverwriteNodeIfExists $True

The command above creates nodes in Jenkins master from winnode-001 to winnode-100. The red cross icon you see below only indicates that they are offline and no actual slaves is communicating with them yet.

You can change the naming pattern specifying -NodeNamePattern.

By specifying -OverwriteNodeIfExists parameter to $True, it can delete the node and recreate. It can be dangerous, so its default value is $False and it’s an optional parameter.

The PowerShell Code

I am pasting all the code I have written for this operation. But if you just want the file, please go to my GitHub repo. All the details of each parameters are documented in the code.

<#
    .SYNOPSIS
       Generates Jenkins nodes.
    .DESCRIPTION
        This script bulk generates Jenkins nodes on the master so that nodes will be ready for Jenkins slaves to talk to them. 
        It utilizes Jenkins CLI, so Java (https://java.com/en/download/) and downloaded jenkins-cli.jar file from Jenkins master is required.
    .PARAMETER JenkinsServerUrl
        Required. Base URL of the Jenkins master. e.g. https://myjenkins.com/
    .PARAMETER Username
        The username to use for this process.
    .PARAMETER ApiToken
        Generated API Token of the username.
    .PARAMETER NodeCount
        Specifies how many nodes to created.
    .PARAMETER NodeNamePattern
        Specify the pattern of node name here. If 20 nodes to be created and "foobar-###" is passed, foobar-001 to foobar-020 would be generated.
    .PARAMETER Description
        Optional. Specify the description of each node. Use this field to specify what kind of fleet you are creating.
    .PARAMETER RemoteRootDir
        Optional. Defaults to "C:\slave". Specify any directory where you expect slave bits to reside on each node.
    .PARAMETER Mode
        Optional. Defaults to NORMAL.
    .PARAMETER NumExecutors
        Optional. Defaults to 1.
    .PARAMETER Labels
        Optional. e.g. "WINDOWS 2019". Separate labels by space.
    .PARAMETER JenkinsCliJarFilePath
        Optional. When you download jenkins-cli.jar, the file is usually at ~/Download/jenkins-cli.jar. Downaload the file from https://your-jenkins-server/jnlpJars/jenkins-cli.jar
        There is a download link on https://your-jenkins-server/cli
    .PARAMETER OverwriteNodeIfExists
        Optional. If the node to be created already exists on the Jenkins master, it deletes it and recreates it if $True is passed. If $False, it is skipped.
    .PARAMETER KeepNodeXmlFiles
        Optional. Defaults to $False. If $True is passed, the XML files created for the nodes will be kept in the same directory as this script. This option could be used to reuse the XML files in different environments.
    
    
    
#>
[CmdletBinding()]
param 
(
    [Parameter(Mandatory=$True)] 
    [string]$JenkinsServerUrl,
    [Parameter(Mandatory=$True)]
    [string]$Username,
    [Parameter(Mandatory=$True)]
    [string]$ApiToken,
    [Parameter(Mandatory=$False)]
    [int]$NodeCount = 5,
    [Parameter(Mandatory=$False)]
    [string]$NodeNamePattern = "winnode-###",
    [Parameter(Mandatory=$False)]
    [string]$Description = "Bulk gen'ed node",
    [Parameter(Mandatory=$False)]
    [string]$RemoteRootDir = "C:\slave",
    [Parameter(Mandatory=$False)]
    [string]$Mode = "NORMAL",
    [Parameter(Mandatory=$False)]
    [int]$NumExecutors = 1,
    [Parameter(Mandatory=$False)]
    [string]$Labels = "",
    [Parameter(Mandatory=$False)]
    [string]$JenkinsCliJarFilePath = "$env:HOMEPATH\Downloads\jenkins-cli.jar",
    [Parameter(Mandatory=$False)]
    [bool]$OverwriteNodeIfExists = $False,
    [Parameter(Mandatory=$False)]
    [bool]$KeepNodeXmlFiles = $False
)

$digitCount = ($NodeNamePattern.ToCharArray() | Where-Object {$_ -eq '#'} | Measure-Object).Count

ForEach ($index In 1..$NodeCount)
{
    $number = "{0:D$digitCount}" -f $index
    $nodeName = $NodeNamePattern -replace "#{$digitCount}", $number 
    $xmlSettings = New-Object System.Xml.XmlWriterSettings
    $xmlSettings.Indent = $true
    $xmlSettings.Encoding = [System.Text.Encoding]::UTF8
    $xmlSettings.OmitXmlDeclaration = $True
    $xmlSettings.ConformanceLevel = "Document"

    $sw = New-Object System.IO.StringWriter
    $xw = [System.Xml.XmlWriter]::Create($sw, $xmlSettings)
    
    try 
    {
        $xw.WriteStartElement("slave")

        $xw.WriteElementString("name", $nodeName)
        $xw.WriteElementString("description", $Description)
        $xw.WriteElementString("remoteFS", $RemoteRootDir)
        $xw.WriteElementString("numExecutors", $NumExecutors)
        $xw.WriteElementString("mode", $Mode)

        $xw.WriteStartElement("retentionStrategy")
        $xw.WriteAttributeString("class", 'hudson.slaves.RetentionStrategy$Always')
        $xw.WriteEndElement() # retentionStrategy

        $xw.WriteStartElement("launcher")
        $xw.WriteAttributeString("class", "hudson.slaves.JNLPLauncher")

        $xw.WriteStartElement("workDirSettings")
        $xw.WriteElementString("disabled", "false")
        $xw.WriteElementString("internalDir", "remoting")
        $xw.WriteElementString("failIfWorkDirIsMissing", "false")
        $xw.WriteEndElement() # workDirSettings
        $xw.WriteElementString("websocket", "false")
        $xw.WriteEndElement() # launcher

        $xw.WriteElementString("label", $Labels)
        $xw.WriteElementString("nodeProperties", "")
        $xw.WriteEndElement() #slave
        $xw.WriteEndDocument()
        $xw.Flush()
    }
    catch [System.Exception]
    {
        Write-Host $_
        exit 1
    }
    finally 
    {
        $xw.Close()
    }

    # Write out the node XML to file to be used for standard input below.
    Set-Content -Path "$nodeName.xml" -Value $sw.ToString() -Force

    # Check the existence of the node.

    $processInfo = New-Object System.Diagnostics.ProcessStartInfo
    $processInfo.FileName = "java"
    $processInfo.RedirectStandardError = $True
    $processInfo.RedirectStandardOutput = $True
    $processInfo.RedirectStandardInput = $True
    $processInfo.Arguments = "-jar $JenkinsCliJarFilePath -s $JenkinsServerUrl -auth $Username`:$ApiToken get-node $nodeName"
    $processInfo.UseShellExecute = $False

    $process = New-Object System.Diagnostics.Process
    $process.StartInfo = $processInfo
    $process.Start() | Out-Null
    $process.WaitForExit()
    $stdo = $process.StandardOutput

    If ($process.ExitCode -ne 0)
    {
        Start-Process -FilePath java -NoNewWindow -Wait -ArgumentList "-jar $JenkinsCliJarFilePath","-s $JenkinsServerUrl","-auth $Username`:$ApiToken","create-node" -RedirectStandardInput "$nodeName.xml"
        Write-Host "$nodeName created ($(Get-Date))"
    }
    else 
    {
        If ($OverwriteNodeIfExists)
        {
            Write-Host "Deleting $nodeName..."
            Start-Process -FilePath java -NoNewWindow -Wait -ArgumentList "-jar $JenkinsCliJarFilePath","-s $JenkinsServerUrl","-auth $Username`:$ApiToken","delete-node $nodeName"

            Write-Host "Recreating $nodeName..."
            Start-Process -FilePath java -NoNewWindow -Wait -ArgumentList "-jar $JenkinsCliJarFilePath","-s $JenkinsServerUrl","-auth $Username`:$ApiToken","create-node" -RedirectStandardInput "$nodeName.xml"
        }
        else 
        {
            Write-Host "Node $nodeName exists."   
        }

        If (!$KeepNodeXmlFiles)
        {
            Remove-Item -Path "$nodeName.xml" -Force
        }
    }
}

Next Step

The script I created can bulk create the “receivers” for the actual slaves to talk to the Jenkins master. I will write another PowerShell to script to automatically install and communicates with the Jenkins master.

Recap

Pull Requests, any suggestion or even forking is welcome. This should make Jenkins permanent node/slave administration a little easier.

PowerShell $profile and mklink

I have an issue with PowerShell as I have VS Code on my Windows machine. When I’m using a regular PowerShell console, it uses one profile and when I’m on VS Code’s PowerShell console in Terminal, it uses different profile. I don’t want to have to manage both. I want to be able to consolidate to one.

When you are in regular PowerShell console, if you type $profile and hit enter, you will get a path like the following.

C:\Users\amaterasu48\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1

When you do the same in VS Code’s PowerShell terminal, you get something like this.

C:\Users\amaterasu48\Documents\WindowsPowerShell\Microsoft.VSCode_profile.ps1

Microsoft.PowerShell_profile.ps1 but Microsoft.VSCode_profile.ps1 does not exist. So I thought I would create a symlink. Here what you can do. cd into the directory first and execute the following command as an administartor.

 mklink Microsoft.VSCode_profile.ps1 Microsoft.PowerShell_profile.ps1

It creates a symlink like the image below.

This way, both regular PowerShell console and VS Code PowerShell console share the same profile.

I read somewhere that you could change the value of $profile but I choose to just create a symlink. That may be another option to consolidate the two or more PowerShell profile files into one.

Adding a DNS ANAME on Windows Server Core

I’m getting ready to have an integrated environment on my server. I have a Windows domain controller up and running and I’m about to get my CentOS 7 join the domain.

Before I can go on, CentOS 7 needs to be able to communicate with the DNS server that I created on the Windows Server Core. The IP address of the DNS server in my network is 192.168.1.26. And the domain name is homenet.iriumi.ad.

A DNS server can have multiple zones. Let’s see what kind of zones I have by executing the following command.

$ Get-DnsServerZone

I have the following zones on my DNS server.

Now let’s see what DNS ANAMEs we have.

$ Get-DnsServerResourceRecord -ZoneName "homenet.iriumi.ad"

It gives you the list of DNS entries in the zone. I have a CentOS 7 host that I have assigned a static IP address to and I’m going to make sure I can resolve it.

$ Add-DnsServerResourceRecordA -ZoneName "homenet.iriumi.ad" -AllowUpdateAny -Name "dockerhost01" -IPv4Address "192.168.1.27"

By executing the command above, as long as a machine can talk to the DNS server, it can resolve dockerhost01.homenet.iriumi.ad to 192.168.1.27. In other words, dockerhost01.homenet.iriumi.ad is mapped to 192.168.1.27.

If necessary, you can remove the DNS entry by executing the following command.

$ Remove-DnsServerResourceRecord -ZoneName "homenet.iriumi.ad" -RRType "A" -Name "dockerhost01"

Next, I’m going to ssh into my CentOS 7 VM and then configure it so that it asks the DNS server on Windows Server Core to resolve names.

# vi /etc/sysconfig/network-script/ifcfg-[your network interface]

In the text file, add the following entry. Change the IP address and the domain name to fit your environment, obviously.

DNS1=192.168.1.26
DNS2=208.67.222.222
DNS3=208.67.220.220
DOMAIN=homenet.iriumi.ad

I have DNS1 point to my Windows Server Core with DNS server. And DNS2 and DNS3 are pointing to OpenDNS. Save and get out by :wq in vi.

Restart the network by executing the following command.

# systemctl restart network

Once that’s done, the system writes these data in /etc/resolv.conf. Check it by executing the following command.

# cat /etc/resolv.conf

Now try pinging dockerhost01.homenet.iriumi.ad and the IP address is resolved and get a response.

Now we are ready to get this host to join the Windows domain!

Creating an AD User on Windows Server Core

Windows Server Core has been around for a while but I have not used it as much as I should. I love headless Linux because it doesn’t have the unnecessary GUI overhead and Windows Server Core is supposed to be the headless Windows Server.

I have installed Windows Server 2019 in server core mode and I have promoted it to a domain controller. There are many articles out there regarding promoting Windows Server to a domain controller if you look it up.

What I want to do in this article is to summarize the steps to create a AD user and have it belong to the correct AD group.

Listing AD Groups

I want to make sure that I know in which AD group to create a new AD user. Let’s see how we can list them.

Let’s login to server core and type powershell to start PowerShell console.

Get-ADGroup -Filter * | Select name | more

You will see a result that shows all the AD groups on the domain controller.

Get-ADGroup result

Let’s take a look at Domain Admins group by entering the following command.

Get-AdGroup -Filter {name -eq "Domain Admins"}

Then you will get details of the group.

Adding a New AD User

I am intending to create a user that belongs to Domain Admins group. Here is the script for it.

$pass = "YourPassword" | ConvertTo-SecureString -AsPlainText -Force
$givenName = "FirstName"
$surName = "LastName"
$fullName = "$givenName $surName"

$username = "Your SamAccountName e.g. hiriumi"

New-ADUser -Name $fullName -GivenName $givenName -Surname $surName -SamAccountName "$username" -UserPrincipalName "$username@[Your Domain e.g. homenet.iriumi.ad]" -AccountPassword $pass -Enabled $true

Add-ADGroupMember -Identity "Domain Admins" -Members "$username"

The script above basically creates a user with a password and then adds the user to Domain Admins group. This will allow the user to do pretty much all the administrative work such as getting computers belong to the domain, managing accounts and so forth.

Let’s finally check if the user I just created actually is a part of Domain Admins group by executing the following command.

Get-ADGroupMember -Identity "Domain Admins"

It’s definitely created my user within the group I wanted to belong to.

Recap

Creating users in appropriate AD group is the first thing to do before you can start to manage domain controller. It’s important to be able to manage them with PowerShell.

I will write about how Linux machines can belong to Windows domain later in my blog.

PowerShell Custom Object to and from JSON

Imagine a situation where you need to keep some complex data and want to be able to work with it in PowerShell. Traditionally, we used XML but JSON is much lighter way to do it.

There are situations where you want to deal with complex data within your script. Let’s try a basic PSCustomObject.

$data = [PSCustomObject]@{
    attr1 = "value1"
    attr2 = "value2"
    attr3 = @{
        attr4 = "value4"
        attr5 = "value5"
    }
}

Write-Host $data

When you execute the code above, you will see an output like the following.

@{attr1=value1; attr2=value2; attr3=System.Collections.Hashtable}

So it’s quite easy to nest the object. Let’s see if we can have a collection.

# sample2
$data2 = [PSCustomObject]@{
    attr1 = "value1"
    attr2 = "value2"
    attr3 = @(
        "val1", "val2", "val3"
    )
}

Write-Host $data2

Executing the code will give us an output like the following.

@{attr1=value1; attr2=value2; attr3=System.Object[]}

This example may look useless but it is useful in a sense that you can deal with parameters as one object which contains complex data. To make it more useful, we can serialize and deserialize the data to and from JSON file. Let’s give it a try. I will use the second sample in this article to generate JSON file from the PSCustomObject.

# sample3
$data3 = [PSCustomObject]@{
    attr1 = "value1"
    attr2 = "value2"
    attr3 = @(
        "val1", "val2", "val3"
    )
}

$data3 | ConvertTo-Json | Set-Content -Path "data3.json"

$data3 object is carried over the pipe and converted to JSON format and then the string data is yet again carried over the second pipe and saved into disk by Set-Content command. The content of data3.json looks like the following.

{
    "attr1":  "value1",
    "attr2":  "value2",
    "attr3":  [
                  "val1",
                  "val2",
                  "val3"
              ]
}

If you give -Compress option to ConverTo-Json, it literally compresses the data. It’s hard to read, but it saves a lot of disk space especially when you have to deal with big data.

{"attr1":"value1","attr2":"value2","attr3":["val1","val2","val3"]}

So converting the data to JSON format and saving it to a file is half useful. The best thing is to be able to deserialize the data back to PSObject and start to work on it again. Let’s try it.

# sample 4
$data4 = Get-Content "data3.json" | ConvertFrom-Json
Write-Host $data4.attr1
Write-Host $data4.attr2
ForEach($v in $data4.attr3)
{
    Write-Host $v
}

How easy it is to retrieve data from file and create an useful object in memory in just a line!

This technique can be used for any object. I will try it on Get-ChildItem.

# sample 5
Get-ChildItem | ConvertTo-Json -Compress | Set-Content -Path "file-system-data.json"

Get-Service, Get-Process and whatever the command that returns objects can be utilized to save the data into JSON object and even deserialized from the JSON data.

Get-NthIndexOf

It turned out that I don’t need to use this function I wrote but I’m sure there will be time when I will need it, so I’m pasting the code here. This function basically gives you the index of the character at the #th occurrence.

Function Get-NthIndexOf
{
    [CmdletBinding()]
    Param
    (
        [Parameter(Position=0,Mandatory=$true)] 
        [string] $TargetString,
        [Parameter(Position=1,Mandatory=$true)] 
        [char] $CharToFind,
        [Parameter(Position=2,Mandatory=$true)] 
        [int] $Nth,
        [Parameter(Position=3,Mandatory=$false)] 
        [boolean] $Forward = $true
    )

    If ($Forward)
    {
        $occurence = 0
        For ([int] $i = 0; $i -lt $TargetString.Length; $i++)
        {
            If ($TargetString[$i] -eq $CharToFind)
            {
                $occurence++
                If ($Nth -eq $occurence)
                {
                    Return $i
                }
            }
        }
    }
    Else
    {
        $occurence = 0
        For ([int] $i = $TargetString.Length - 1; $i -gt 0; $i--)
        {
            If ($TargetString[$i] -eq $CharToFind)
            {
                $occurence++
                If ($Nth -eq $occurence)
                {
                    Return $i
                }
            }
        }
    }

    #Code falling here means it didn't find the char at all or $occurence just didn't reach $Nth
    Return -1

}