We see some problem around the scheduling/handleing of time.
We are working on a script, that would use a schedule / Cron to deprovision users.
The entire script of the API is quite lengthy, but would look like this:
param(
[Parameter(Mandatory = $true)]
[string]$RequestId,
# [Parameter(Mandatory = $false)]
# [string]$ForwardEmailAddress,
# [Parameter(Mandatory = $false)]
# [string]$DirectReportList,
[Parameter(Mandatory = $false)]
[string]$ServiceDeskBaseUrl = "https://ictportal/api/v3/requests/",
[Parameter(Mandatory = $false)]
[string]$AuthToken = "<token removed>", #TODO: move this to PSU-s "secrets"
[Parameter(Mandatory = $false)]
[string]$ADCredentialName = "ADCredentialPRD",
[Parameter(Mandatory = $false)]
[string]$FunctionFolder = "D:\FS_Workspace\powershell\MESDP-API_interactions\leaver-functions", #TODO: is this really needed?
[Parameter(Mandatory = $false)]
[string]$ComputerName = ($env:COMPUTERNAME),
[Parameter(Mandatory = $false)]
[string]$ControllerScript = "controller-leaver.ps1", #TODO: ensure this is in PSU!
[Parameter(Mandatory = $false)]
[string]$UpdateTicketScript = "D:\FS_Workspace\powershell\MESDP-API_interactions\Update-Ticket.ps1" #TODO: this script might need to be relocated --> path is temporary
)
begin {
# Initialize result object to track operations throughout the script
$resultObject = [PSCustomObject]@{
Status = "In Progress"
Steps = @()
Errors = @()
UserInfo = $null
ExpiryDate = $null
ScheduleName = $null
CronExpression = $null
UserNotFound = $false
AttemptedUser = $null
}
function Add-ProcessStep {
param(
[string]$StepName,
[string]$Status,
[string]$Message
)
$resultObject.Steps += [PSCustomObject]@{
Step = $StepName
Status = $Status
Message = $Message
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
function Add-Error {
param(
[string]$StepName,
[string]$ErrorMessage
)
$resultObject.Errors += [PSCustomObject]@{
Step = $StepName
Message = $ErrorMessage
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
Add-ProcessStep -StepName $StepName -Status "Failed" -Message $ErrorMessage
}
function Convert-UnixTimeToCron {
param(
[Parameter(Mandatory = $true)]
[int64]$InputUnixTime
)
try {
# Get the current time
$currentTime = Get-Date
# Start from epoch (1970-01-01 00:00:00)
$epochStart = Get-Date "1970-01-01 00:00:00"
# Convert Unix time (milliseconds) to DateTime
$dateTime = $epochStart.AddSeconds($InputUnixTime / 1000)
# Check if the time is in the past
if ($dateTime -lt $currentTime) {
# If in the past, use current date and add a few minutes
$dateTime = $currentTime.AddMinutes(5) # Add 5 minutes (adjust as needed)
Add-ProcessStep -StepName "TimeConversion" -Status "Warning" -Message "Specified time was in the past. Adjusted to current time + 5 minutes."
}
# Extract minute, hour, day, month, and weekday from the adjusted dateTime
$minute = $dateTime.Minute
$hour = $dateTime.Hour
$day = $dateTime.Day
$month = $dateTime.Month
$weekday = $dateTime.DayOfWeek
# Map weekday to cron format (0 = Sunday, 6 = Saturday)
$weekdayArray = @(0, 1, 2, 3, 4, 5, 6)
$cronWeekday = $weekdayArray[$weekday]
# Return the cron expression
Write-Host "SETTING SCHEDULE: '$minute $hour $day $month $cronWeekday'"
return "$minute $hour $day $month $cronWeekday"
}
catch {
Add-Error -StepName "TimeConversion" -ErrorMessage "Failed to convert Unix time to Cron: $_"
throw
}
}
function Convert-UnixTimeToUtcDateTime {
param (
[Parameter(Mandatory)]
[int64]$InputUnixTime
)
$utcTime = [System.DateTimeOffset]::FromUnixTimeMilliseconds($InputUnixTime).UtcDateTime
$timezone = [System.TimeZoneInfo]::FindSystemTimeZoneById("GMT Standard Time")
if ($timezone.IsDaylightSavingTime($utcTime)) {
return [System.TimeZoneInfo]::ConvertTimeFromUtc($utcTime, $timezone).ToString("yyyy-MM-dd HH:mm:ss")
}
return $utcTime.ToString("yyyy-MM-dd HH:mm:ss")
}
function Get-SAMAccountName {
param (
[Parameter(Mandatory = $true)]
[string]$firstName,
[Parameter(Mandatory = $true)]
[string]$surname,
[Parameter(Mandatory = $true)]
[string]$requestId
)
try {
# Ensure that the Active Directory module is loaded
if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
$errorMsg = "Active Directory module not found."
Add-Error -StepName "ADModuleCheck" -ErrorMessage $errorMsg
throw $errorMsg
}
Import-Module ActiveDirectory
# Search for the user(s) in Active Directory
$users = Get-ADUser -Filter { GivenName -eq $firstName -and Surname -eq $surname } -Properties SAMAccountName
# Check if no users were found
if ($users.Count -eq 0) {
$errorMsg = "No users found with the given first name ($firstName) and last name ($surname)."
Add-Error -StepName "ADUserLookup" -ErrorMessage $errorMsg
# Set user not found information in the result object
$resultObject.UserNotFound = $true
$resultObject.AttemptedUser = [PSCustomObject]@{
FirstName = $firstName
Surname = $surname
RequestId = $requestId
}
throw $errorMsg
}
Add-ProcessStep -StepName "ADUserLookup" -Status "Success" -Message "Found user $firstName $surname with SAMAccountName: $($users[0].SAMAccountName)"
# Output the SAMAccountName
return $users | Select-Object -ExpandProperty SAMAccountName
}
catch {
if ($_.Exception.Message -ne "No users found with the given first name ($firstName) and last name ($surname).") {
Add-Error -StepName "ADUserLookup" -ErrorMessage "Error finding user: $_"
}
throw
}
}
function Get-RequestData {
param(
[Parameter(Mandatory = $true)]
[string]$RequestId,
[Parameter(Mandatory = $true)]
[string]$AuthToken,
[Parameter(Mandatory = $true)]
[string]$ServiceDeskBaseUrl
)
try {
$url = "$ServiceDeskBaseUrl$RequestID"
$technician_key = @{
"authtoken" = $AuthToken
"accept" = 'application/v3.0+json'
}
$response = Invoke-RestMethod -Uri $url -Method Get -Headers $technician_Key -ErrorAction Stop
$requestData = @{
"firstName" = $response.request.udf_fields.udf_sline_926
"surname" = $response.request.udf_fields.udf_sline_925
"date" = $response.request.udf_fields.udf_date_5116.value
"business" = $response.request.udf_fields.udf_pick_4294
"directReports" = $response.request.udf_fields.udf_pick_16971.name
"directReportList" = $response.request.udf_fields.udf_sline_16972
"newManager" = $response.request.udf_fields.udf_sline_16973
"mailboxConversion" = $response.request.udf_fields.udf_pick_16905.name
"forwardEmailAddress" = $response.request.udf_fields.udf_sline_16906
}
Add-ProcessStep -StepName "GetRequestData" -Status "Success" -Message "Retrieved request data for $($requestData.firstName) $($requestData.surname)"
return $requestData
}
catch {
Add-Error -StepName "GetRequestData" -ErrorMessage "Failed to retrieve request data: $_"
throw
}
}
function Convert-UnixTimeToDateTime {
param (
[Parameter(Mandatory = $true)]
[int64]$InputUnixTime
)
try {
$dateTime = (Get-Date "1970-01-01 00:00:00Z").AddSeconds($InputUnixTime / 1000)
$currentTime = Get-Date
# Check if the time is in the past
if ($dateTime -lt $currentTime) {
# If in the past, use current date and add a few minutes
$dateTime = $currentTime.AddMinutes(5) # Add 5 minutes (adjust as needed)
}
Add-ProcessStep -StepName "DateTimeConversion" -Status "Success" -Message "Converted Unix time to DateTime: $dateTime"
return $dateTime
}
catch {
Add-Error -StepName "DateTimeConversion" -ErrorMessage "Failed to convert Unix time to DateTime: $_"
throw
}
}
function Set-Expiry {
param(
[Parameter(Mandatory = $true)]
[int64]$Date,
[Parameter(Mandatory = $true)]
[string]$RequestId,
[Parameter(Mandatory = $true)]
[string]$FirstName,
[Parameter(Mandatory = $true)]
[string]$Surname,
[Parameter(Mandatory = $true)]
[string]$BusinessName,
[Parameter(Mandatory = $true)]
[string]$ADCredentialName
)
try {
# Ensure the Active Directory module is imported
Import-Module ActiveDirectory -ErrorAction Stop
$ADCredential = $secret:ADCredentialPRD #Get-Variable -Name $ADCredentialName -ValueOnly -Scope Script
# Function to find the SAMAccountName using FirstName and Surname
function Find-SAMAccountName {
param (
[Parameter(Mandatory = $true)]
[string]$FirstName,
[Parameter(Mandatory = $true)]
[string]$Surname,
[Parameter(Mandatory = $true)]
[PSCredential]$Credential
)
# Search for the user in Active Directory
$Filter = "GivenName -eq '$FirstName' -and Surname -eq '$Surname'"
$User = Get-ADUser -Filter $Filter -Properties SAMAccountName -Credential $Credential -ErrorAction Stop
if ($User) {
return $User.SAMAccountName
}
else {
$errorMsg = "Error: User not found with the name '$FirstName $Surname'."
Add-Error -StepName "ADUserLookup" -ErrorMessage $errorMsg
throw $errorMsg
}
}
# Function to set account expiration date using SAMAccountName
function Set-AccountExpiration {
param (
[Parameter(Mandatory = $true)]
[string]$SAMAccountName,
[Parameter(Mandatory = $true)]
[datetime]$ExpiryDate,
[Parameter(Mandatory = $true)]
[PSCredential]$Credential
)
# Set the account expiration date
Set-ADUser -Identity $SAMAccountName -AccountExpirationDate $ExpiryDate -Credential $Credential -ErrorAction Stop
Add-ProcessStep -StepName "SetExpiry" -Status "Success" -Message "Account expiration date for $SAMAccountName set to $ExpiryDate"
return $true
}
# Convert Unix time to DateTime
$ExpiryDate = Convert-UnixTimeToDateTime -InputUnixTime $Date
$resultObject.ExpiryDate = $ExpiryDate
# Find the SAMAccountName in Active Directory
$SAMAccountName = Find-SAMAccountName -FirstName $FirstName -Surname $Surname -Credential $ADCredential
# If SAMAccountName is found, set the account expiration date
if ($SAMAccountName) {
$success = Set-AccountExpiration -SAMAccountName $SAMAccountName -ExpiryDate $ExpiryDate -Credential $ADCredential
if ($success) {
return 0 # Success exit code
}
}
return 1 # Error exit code
}
catch {
Add-Error -StepName "SetExpiry" -ErrorMessage "Failed to set account expiry date: $_"
return 1 # Error exit code
}
}
}
process {
try {
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
# 0. Store the request data #
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
try {
$RequestData = Get-RequestData -RequestID $RequestID -AuthToken $AuthToken -ServiceDeskBaseUrl $ServiceDeskBaseUrl
$resultObject.UserInfo = [PSCustomObject]@{
FirstName = $RequestData.firstName
Surname = $RequestData.surname
LeaveDate = $RequestData.date
Business = $RequestData.business
}
}
catch {
$errorMsg = "Failed to retrieve request data: $_"
Add-Error -StepName "RequestDataRetrieval" -ErrorMessage $errorMsg
throw $errorMsg
}
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
# 1. Ensure user found in AD #
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
try {
$SAMAccountName = Get-SAMAccountName -FirstName $($RequestData.firstName) -Surname $($RequestData.surname) -RequestId $RequestId
if (-not $SAMAccountName) {
$errorMsg = "Unable to determine SAMAccountName for user $($RequestData.firstName) $($RequestData.surname)"
Add-Error -StepName "SAMAccountNameLookup" -ErrorMessage $errorMsg
throw $errorMsg
}
Add-ProcessStep -StepName "SAMAccountNameLookup" -Status "Success" -Message "Found SAMAccountName: $SAMAccountName"
}
catch {
if (-not ($resultObject.Errors | Where-Object { $_.Step -eq "ADUserLookup" })) {
Add-Error -StepName "SAMAccountNameLookup" -ErrorMessage "Failed to get SAMAccountName: $_"
}
throw
}
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
# 2. Set expiry of the user found #
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
$SEParams = @{
FirstName = $($RequestData.firstName)
Surname = $($RequestData.surname)
Date = $($RequestData.date)
BusinessName = $($RequestData.business)
RequestId = $RequestId
ADCredentialName = $ADCredentialName
}
Add-ProcessStep -StepName "SetExpiryStart" -Status "InProgress" -Message "Setting expiry date for user: $($RequestData.firstName) $($RequestData.surname)"
$exitCode = Set-Expiry @SEParams
if ($exitCode -eq 0) {
Add-ProcessStep -StepName "SetExpiryComplete" -Status "Success" -Message "Successfully set expiry date for user"
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
# 3. Schedule leaver processing #
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
# Prepare unique schedule name
$ScheduleName = "$RequestId-$($RequestData.firstName)-$($RequestData.surname)"
$resultObject.ScheduleName = $ScheduleName
# Convert UnixTime to Cron format for scheduling
$CronExpression = Convert-UnixTimeToCron $Date
#$OneTime = (convert-UnixTimeToUtcDateTime).ToUniversalTime()
$resultObject.CronExpression = $cronExpression
$OneTime = Convert-UnixTimeToDateTime -InputUnixTime $Date
# Define parameters for follow-up script
$FollowUpParams = @{
RequestId = $RequestId
SAMAccountName = $SAMAccountName
}
Add-ProcessStep -StepName "SchedulingStart" -Status "InProgress" -Message "Scheduling follow-up script for $OneTime"
try {
# This line is kept as per requirements
New-PSUSchedule -Name $ScheduleName -Script $ControllerScript -Parameters $FollowUpParams -Computer $ComputerName -Cron $CronExpression -TimeZone "GMT Standard Time" #-OneTime $OneTime
Add-ProcessStep -StepName "SchedulingComplete" -Status "Success" -Message "Successfully scheduled leaver processing for $OneTime"
$resultObject.Status = "Success"
}
catch {
Add-Error -StepName "Scheduling" -ErrorMessage "Failed to schedule follow-up script: $_"
$resultObject.Status = "Partial - Expiry Set but Scheduling Failed"
throw
}
}
else {
Add-Error -StepName "SetExpiryFailed" -ErrorMessage "Failed to set account expiry date. Follow-up script will not be scheduled."
$resultObject.Status = "Failed - Could Not Set Expiry Date"
throw "Failed to set account expiry date"
}
}
catch {
if (-not ($resultObject.Errors.Count -gt 0)) {
Add-Error -StepName "UnhandledException" -ErrorMessage "Unhandled exception occurred: $_"
}
$resultObject.Status = "Failed"
}
}
end {
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
# 4. End block - register outcome #
# ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
# Create worklog description and update original ticket
$worklogSummary = "Leaver Processing Summary:`n"
$worklogSummary += "Status: $($resultObject.Status)`n"
if ($resultObject.UserInfo) {
$worklogSummary += "User: $($resultObject.UserInfo.FirstName) $($resultObject.UserInfo.Surname)`n"
}
# Add specific note about user not found if applicable
if ($resultObject.UserNotFound) {
$userNotFoundNote = "ATTENTION: The user $($resultObject.AttemptedUser.FirstName) $($resultObject.AttemptedUser.Surname) was not found in Active Directory. Please check that the First Name and Surname match the request. If not, please notify the requestor and make the necessary changes."
$worklogSummary = "$userNotFoundNote`n`n$worklogSummary"
$worklogSummary += "ACTION REQUIRED: Verify user details in Active Directory`n"
}
if ($resultObject.ExpiryDate) {
$worklogSummary += "Expiry Date Set: $($resultObject.ExpiryDate)`n"
}
if ($resultObject.ScheduleName) {
$worklogSummary += "Processing Scheduled: Yes`n"
$worklogSummary += "Schedule Name: $($resultObject.ScheduleName)`n"
}
if ($resultObject.Errors.Count -gt 0) {
$worklogSummary += "`nErrors Encountered:`n"
foreach ($error in $resultObject.Errors) {
$worklogSummary += "- [$($error.Step)] $($error.Message)`n"
}
}
$worklogSummary += "`nProcessing Steps:`n"
foreach ($step in $resultObject.Steps) {
$worklogSummary += "- [$($step.Status)] $($step.Step): $($step.Message)`n"
}
try {
# Update the ticket with the results
$updateParams = @{
TicketID = $RequestId
WorklogDescription = $worklogSummary
ResolutionContent = $worklogSummary
TicketType = "UserProcessing"
}
# If process was successful and user was found, close the ticket
# if ($resultObject.Status -match "Success" -and -not $resultObject.UserNotFound) {
# $updateParams.CloseTicket = $true
# }
# Call Update-Ticket to update the ticket
& $UpdateTicketScript @updateParams
# Write out worklog on console
return $worklogSummary
# return $updateParams
}
catch {
Write-Error "Failed to update ticket with results: $_"
}
}
The output of a run looks like this:
Monday, June 9, 2025 1:44:47 PM
{
"RequestId": "112323"
}
Requested HTTP/1.1 POST with 29-byte payload
Received HTTP/1.1 response of content type application/json of unknown size
Content encoding: utf-8
Id : 3
Identity :
AppToken :
Cron : 49 13 9 6 1
NextExecution :
Description :
Script : apis\controller-leaver.ps1
TimeZoneString : GMT Standard Time
Continuous : False
Delay :
Credential :
OneTime :
Environment :
Parameters : {@{Id=5; Name=SAMAccountName; Value=<Objs
Version="1.1.0.1"
xmlns="http://schemas.microsoft.com/powershell/2004/04">
<S>Dhruvkumar.Vadkar</S>
</Objs>; Type=System.String; Variable=False;
DisplayValue=Dhruvkumar.Vadkar;
ObjectValue="Dhruvkumar.Vadkar"; BoolValue=False;
StringValue=Dhruvkumar.Vadkar; StringArrayValue=;
IntegerValue=0; DateValue=1/1/0001 12:00:00 AM;
SecureStringValue=; ValidValues=}, @{Id=6;
Name=RequestId; Value=<Objs Version="1.1.0.1"
xmlns="http://schemas.microsoft.com/powershell/2004/04">
<S>112323</S>
</Objs>; Type=System.String; Variable=False;
DisplayValue=112323; ObjectValue="112323";
BoolValue=False; StringValue=112323; StringArrayValue=;
IntegerValue=0; DateValue=1/1/0001 12:00:00 AM;
SecureStringValue=; ValidValues=}}
Name : 112323-Dhruvkumar-Vadkar
Paused : False
Timeout : 0
Condition :
RandomDelay : False
Computer : INFR-VL-AUT-02
ReadOnly : False
OneWayOneTime : False
RandomDelayMaximum : 60
AvailableInBranch : {}
AvailableInBranches : {}
Module :
EveryHour : False
Minute : 0
EveryDay : False
Hour : 0
DayOfWeek : False
Day :
EveryMonth : False
DayOfMonth : 0
SpecificMonth : False
Month :
ScheduleString :
Valid : True
Leaver Processing Summary:
Status: Success
User: Dhruvkumar Vadkar
Expiry Date Set: 06/09/2025 13:49:48
Processing Scheduled: Yes
Schedule Name: 112323-Dhruvkumar-Vadkar
Processing Steps:
- [Success] GetRequestData: Retrieved request data for Dhruvkumar Vadkar
- [Success] ADUserLookup: Found user Dhruvkumar Vadkar with SAMAccountName:
- [Success] SAMAccountNameLookup: Found SAMAccountName: Dhruvkumar.Vadkar
- [InProgress] SetExpiryStart: Setting expiry date for user: Dhruvkumar Vadkar
- [Success] DateTimeConversion: Converted Unix time to DateTime: 06/09/2025 13:49:48
- [Success] SetExpiry: Account expiration date for Dhruvkumar.Vadkar set to 06/09/2025 13:49:48
- [Success] SetExpiryComplete: Successfully set expiry date for user
- [Warning] TimeConversion: Specified time was in the past. Adjusted to current time + 5 minutes.
- [Success] DateTimeConversion: Converted Unix time to DateTime: 06/09/2025 13:49:48
- [InProgress] SchedulingStart: Scheduling follow-up script for 06/09/2025 13:49:48
- [Success] SchedulingComplete: Successfully scheduled leaver processing for 06/09/2025 13:49:48
Key points to note:
- currently we are in GMT+1 (British Summer time) and the time is 1:46.
# get-date
09 June 2025 13:45:36
# get-timezone
Id : GMT Standard Time
HasIanaId : False
DisplayName : (UTC+00:00) Dublin, Edinburgh, Lisbon, London
StandardName : GMT Standard Time
DaylightName : GMT Daylight Time
BaseUtcOffset : 00:00:00
SupportsDaylightSavingTime : True
-
as the ticket was for a past-leaver, logic was, schedule should have been current time + 5 min; script ran at 13:44 → so this should have been 13:49
-
the CRON matches this:
Cron : 49 13 9 6 1
-
(this also matches the output we see)
-
the subsequent PSU schedule’s text is correct, but the time is not:
-
the
schedules.ps1
is correct, but the scheduled job not ran at the given time:
cat C:\ProgramData\UniversalAutomation\Repository\.universal\schedules.ps1
New-PSUSchedule -Cron "49 13 9 6 1" -Script "apis\controller-leaver.ps1" -TimeZone "GMT Standard Time" -Parameters @{
RequestId = '112323'
SAMAccountName = 'Dhruvkumar.Vadkar'
} -Name "112323-Dhruvkumar-Vadkar" -Computer "INFR-VL-AUT-02"
(We observed this on the previous test last Friday too; at that time the schedule was set to 3:xx, the schedule was saying 3:xx but actually the job run at 4:xx)
What are we missing? It is a bit confusing…would assume PSU uses UTC, but the CRON/schedule is at odds with the GUI…
Thanks,
Fabrice