Skip to content

Commit 338b49a

Browse files
authored
Merge pull request #657 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents a4116bd + ab15097 commit 338b49a

19 files changed

+1070
-274
lines changed

Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ function Add-CIPPScheduledTask {
4646
return "Could not run task: $ErrorMessage"
4747
}
4848
} else {
49+
# Generate RowKey early to use in duplicate check
50+
if (!$Task.RowKey) {
51+
$RowKey = (New-Guid).Guid
52+
} else {
53+
$RowKey = $Task.RowKey
54+
}
55+
56+
# Check for duplicate RowKey (prevents race conditions)
57+
$Filter = "PartitionKey eq 'ScheduledTask' and RowKey eq '$RowKey'"
58+
$ExistingTaskByKey = (Get-CIPPAzDataTableEntity @Table -Filter $Filter)
59+
if ($ExistingTaskByKey) {
60+
return "Task with ID $RowKey already exists"
61+
}
62+
4963
if ($DisallowDuplicateName) {
5064
$Filter = "PartitionKey eq 'ScheduledTask' and Name eq '$($Task.Name)'"
5165
$ExistingTask = (Get-CIPPAzDataTableEntity @Table -Filter $Filter)
@@ -110,11 +124,8 @@ function Add-CIPPScheduledTask {
110124
}
111125
$AdditionalProperties = ([PSCustomObject]$AdditionalProperties | ConvertTo-Json -Compress)
112126
if ($Parameters -eq 'null') { $Parameters = '' }
113-
if (!$Task.RowKey) {
114-
$RowKey = (New-Guid).Guid
115-
} else {
116-
$RowKey = $Task.RowKey
117-
}
127+
128+
# RowKey already generated during duplicate check above
118129

119130
$Recurrence = if ([string]::IsNullOrEmpty($task.Recurrence.value)) {
120131
$task.Recurrence

Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,48 @@ function Push-ExecScheduledCommand {
3535
Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue
3636
return
3737
}
38+
# Task should be 'Pending' (queued by orchestrator) or 'Running' (retry/recovery)
39+
# We accept both to handle edge cases
40+
41+
# Check for rerun protection - prevent duplicate executions within the recurrence interval
42+
if ($task.Recurrence -and $task.Recurrence -ne '0') {
43+
# Calculate interval in seconds from recurrence string
44+
$IntervalSeconds = switch -Regex ($task.Recurrence) {
45+
'^(\d+)$' { [int64]$matches[1] * 86400 } # Plain number = days
46+
'(\d+)m$' { [int64]$matches[1] * 60 }
47+
'(\d+)h$' { [int64]$matches[1] * 3600 }
48+
'(\d+)d$' { [int64]$matches[1] * 86400 }
49+
default { 0 }
50+
}
51+
52+
if ($IntervalSeconds -gt 0) {
53+
# Round down to nearest 15-minute interval (900 seconds) since that's when orchestrator runs
54+
# This prevents rerun blocking issues due to slight timing variations
55+
$FifteenMinutes = 900
56+
$AdjustedInterval = [Math]::Floor($IntervalSeconds / $FifteenMinutes) * $FifteenMinutes
57+
58+
# Ensure we have at least one 15-minute interval
59+
if ($AdjustedInterval -lt $FifteenMinutes) {
60+
$AdjustedInterval = $FifteenMinutes
61+
}
62+
# Use task RowKey as API identifier for rerun cache
63+
$RerunParams = @{
64+
TenantFilter = $Tenant
65+
Type = 'ScheduledTask'
66+
API = $task.RowKey
67+
Interval = $AdjustedInterval
68+
BaseTime = [int64]$task.ScheduledTime
69+
Headers = $Headers
70+
}
71+
72+
$IsRerun = Test-CIPPRerun @RerunParams
73+
if ($IsRerun) {
74+
Write-Information "Scheduled task $($task.Name) for tenant $Tenant was recently executed. Skipping to prevent duplicate execution."
75+
Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue
76+
return
77+
}
78+
}
79+
}
3880

3981
if ($task.Trigger) {
4082
# Extract trigger data from the task and process

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAppInsightsQuery.ps1

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,12 @@ function Invoke-ExecAppInsightsQuery {
2424
try {
2525
$LogData = Get-ApplicationInsightsQuery -Query $Query
2626

27-
$Body = @{
27+
$Body = ConvertTo-Json -Depth 10 -Compress -InputObject @{
2828
Results = @($LogData)
2929
Metadata = @{
3030
Query = $Query
3131
}
32-
} | ConvertTo-Json -Depth 10 -Compress
33-
32+
}
3433
return [HttpResponseContext]@{
3534
StatusCode = [HttpStatusCode]::OK
3635
Body = $Body

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecListBackup.ps1

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,22 @@ function Invoke-ExecListBackup {
3131
Items = $properties.Name
3232
}
3333
} else {
34+
# Prefer stored indicator (BackupIsBlob) to avoid reading Backup field
35+
$isBlob = $false
36+
if ($null -ne $item.PSObject.Properties['BackupIsBlob']) {
37+
try { $isBlob = [bool]$item.BackupIsBlob } catch { $isBlob = $false }
38+
} else {
39+
# Fallback heuristic for legacy rows if property missing
40+
if ($null -ne $item.PSObject.Properties['Backup']) {
41+
$b = $item.Backup
42+
if ($b -is [string] -and ($b -like 'https://*' -or $b -like 'http://*')) { $isBlob = $true }
43+
}
44+
}
3445
[PSCustomObject]@{
3546
BackupName = $item.RowKey
3647
Timestamp = $item.Timestamp
48+
Source = if ($isBlob) { 'blob' } else { 'table' }
3749
}
38-
3950
}
4051
}
4152
$Result = $Processed | Sort-Object Timestamp -Descending

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function Invoke-AddScheduledItem {
3232
$ScheduledTask = @{
3333
Task = $Request.Body
3434
Headers = $Request.Headers
35-
hidden = $hidden
35+
Hidden = $hidden
3636
DisallowDuplicateName = $Request.Query.DisallowDuplicateName
3737
DesiredStartTime = $Request.Body.DesiredStartTime
3838
}

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ function Invoke-ListScheduledItems {
2222
$SearchTitle = $Request.query.SearchTitle ?? $Request.body.SearchTitle
2323

2424
if ($ShowHidden -eq $true) {
25-
$ScheduledItemFilter.Add('Hidden eq true')
25+
$ScheduledItemFilter.Add("(Hidden eq true or Hidden eq 'True')")
2626
} else {
27-
$ScheduledItemFilter.Add('Hidden eq false')
27+
$ScheduledItemFilter.Add("(Hidden eq false or Hidden eq 'False')")
2828
}
2929

3030
if ($Name) {
@@ -43,6 +43,7 @@ function Invoke-ListScheduledItems {
4343
$HiddenTasks = $true
4444
}
4545
$Tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter
46+
Write-Information "Retrieved $($Tasks.Count) scheduled tasks from storage."
4647
if ($Type) {
4748
$Tasks = $Tasks | Where-Object { $_.command -eq $Type }
4849
}
@@ -58,8 +59,12 @@ function Invoke-ListScheduledItems {
5859
$AllowedTenantDomains = $TenantList | Where-Object -Property customerId -In $AllowedTenants | Select-Object -ExpandProperty defaultDomainName
5960
$Tasks = $Tasks | Where-Object -Property Tenant -In $AllowedTenantDomains
6061
}
61-
$ScheduledTasks = foreach ($Task in $tasks) {
62+
63+
Write-Information "Found $($Tasks.Count) scheduled tasks after filtering and access check."
64+
65+
$ScheduledTasks = foreach ($Task in $Tasks) {
6266
if (!$Task.Tenant -or !$Task.Command) {
67+
Write-Information "Skipping invalid scheduled task entry: $($Task.RowKey)"
6368
continue
6469
}
6570

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRestoreBackup.ps1

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,26 @@ function Invoke-ExecRestoreBackup {
1212
try {
1313

1414
if ($Request.Body.BackupName -like 'CippBackup_*') {
15-
$Table = Get-CippTable -tablename 'CIPPBackup'
16-
$Backup = Get-CippAzDataTableEntity @Table -Filter "RowKey eq '$($Request.Body.BackupName)' or OriginalEntityId eq '$($Request.Body.BackupName)'"
15+
# Use Get-CIPPBackup which already handles fetching from blob storage
16+
$Backup = Get-CIPPBackup -Type 'CIPP' -Name $Request.Body.BackupName
1717
if ($Backup) {
18-
$BackupData = $Backup.Backup | ConvertFrom-Json -ErrorAction SilentlyContinue | Select-Object * -ExcludeProperty ETag, Timestamp
18+
$raw = $Backup.Backup
19+
$BackupData = $null
20+
21+
# Get-CIPPBackup already fetches blob content, so raw should be JSON string
22+
try {
23+
if ($raw -is [string]) {
24+
$BackupData = $raw | ConvertFrom-Json -ErrorAction Stop
25+
} else {
26+
$BackupData = $raw | Select-Object * -ExcludeProperty ETag, Timestamp
27+
}
28+
} catch {
29+
throw "Failed to parse backup JSON: $($_.Exception.Message)"
30+
}
31+
1932
$BackupData | ForEach-Object {
2033
$Table = Get-CippTable -tablename $_.table
21-
$ht2 = @{ }
34+
$ht2 = @{}
2235
$_.psobject.properties | ForEach-Object { $ht2[$_.Name] = [string]$_.Value }
2336
$Table.Entity = $ht2
2437
Add-CIPPAzDataTableEntity @Table -Force

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1

Lines changed: 39 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
@{
2626
id = 'ManagedAppPolicies'
2727
method = 'GET'
28-
url = '/deviceAppManagement/managedAppPolicies?$expand=assignments&$orderby=displayName'
28+
url = '/deviceAppManagement/managedAppPolicies?$orderby=displayName'
2929
}
3030
@{
3131
id = 'MobileAppConfigurations'
@@ -41,60 +41,58 @@
4141

4242
$GraphRequest = [System.Collections.Generic.List[object]]::new()
4343

44-
# Process Managed App Policies - these need separate assignment lookups
44+
# Process Managed App Policies - these need separate assignment lookups as the ManagedAppPolicies endpoint does not support $expand
4545
$ManagedAppPolicies = ($BulkResults | Where-Object { $_.id -eq 'ManagedAppPolicies' }).body.value
4646
if ($ManagedAppPolicies) {
47-
# Build bulk requests for assignments of policies that support them
48-
$AssignmentRequests = [System.Collections.Generic.List[object]]::new()
49-
foreach ($Policy in $ManagedAppPolicies) {
50-
# Only certain policy types support assignments endpoint
51-
$odataType = $Policy.'@odata.type'
52-
if ($odataType -match 'androidManagedAppProtection|iosManagedAppProtection|windowsManagedAppProtection|targetedManagedAppConfiguration') {
53-
$urlSegment = switch -Wildcard ($odataType) {
54-
'*androidManagedAppProtection*' { 'androidManagedAppProtections' }
55-
'*iosManagedAppProtection*' { 'iosManagedAppProtections' }
56-
'*windowsManagedAppProtection*' { 'windowsManagedAppProtections' }
57-
'*targetedManagedAppConfiguration*' { 'targetedManagedAppConfigurations' }
58-
}
59-
if ($urlSegment) {
60-
$AssignmentRequests.Add(@{
61-
id = $Policy.id
62-
method = 'GET'
63-
url = "/deviceAppManagement/$urlSegment('$($Policy.id)')/assignments"
64-
})
47+
# Get all @odata.type and deduplicate them
48+
$OdataTypes = ($ManagedAppPolicies | Select-Object -ExpandProperty '@odata.type' -Unique) -replace '#microsoft.graph.', ''
49+
$ManagedAppPoliciesBulkRequests = foreach ($type in $OdataTypes) {
50+
# Translate to URL segments
51+
$urlSegment = switch ($type) {
52+
'androidManagedAppProtection' { 'androidManagedAppProtections' }
53+
'iosManagedAppProtection' { 'iosManagedAppProtections' }
54+
'mdmWindowsInformationProtectionPolicy' { 'mdmWindowsInformationProtectionPolicies' }
55+
'windowsManagedAppProtection' { 'windowsManagedAppProtections' }
56+
'targetedManagedAppConfiguration' { 'targetedManagedAppConfigurations' }
57+
default { $null }
58+
}
59+
Write-Information "Type: $type => URL Segment: $urlSegment"
60+
if ($urlSegment) {
61+
@{
62+
id = $type
63+
method = 'GET'
64+
url = "/deviceAppManagement/${urlSegment}?`$expand=assignments&`$orderby=displayName"
6565
}
6666
}
6767
}
6868

69-
# Fetch assignments in bulk if we have any
70-
$AssignmentResults = @{}
71-
if ($AssignmentRequests.Count -gt 0) {
72-
$AssignmentBulkResults = New-GraphBulkRequest -Requests $AssignmentRequests -tenantid $TenantFilter
73-
foreach ($result in $AssignmentBulkResults) {
74-
if ($result.body.value) {
75-
$AssignmentResults[$result.id] = $result.body.value
76-
}
77-
}
69+
$ManagedAppPoliciesBulkResults = New-GraphBulkRequest -Requests $ManagedAppPoliciesBulkRequests -tenantid $TenantFilter
70+
# Do this horriblenes as a workaround, as the results dont return with a odata.type property
71+
$ManagedAppPolicies = $ManagedAppPoliciesBulkResults | ForEach-Object {
72+
$URLName = $_.id
73+
$_.body.value | Add-Member -NotePropertyName 'URLName' -NotePropertyValue $URLName -Force
74+
$_.body.value
7875
}
7976

77+
78+
8079
foreach ($Policy in $ManagedAppPolicies) {
81-
$policyType = switch -Wildcard ($Policy.'@odata.type') {
82-
'*androidManagedAppProtection*' { 'Android App Protection' }
83-
'*iosManagedAppProtection*' { 'iOS App Protection' }
84-
'*windowsManagedAppProtection*' { 'Windows App Protection' }
85-
'*mdmWindowsInformationProtectionPolicy*' { 'Windows Information Protection (MDM)' }
86-
'*windowsInformationProtectionPolicy*' { 'Windows Information Protection' }
87-
'*targetedManagedAppConfiguration*' { 'App Configuration (MAM)' }
88-
'*defaultManagedAppProtection*' { 'Default App Protection' }
80+
$policyType = switch ($Policy.'URLName') {
81+
'androidManagedAppProtection' { 'Android App Protection'; break }
82+
'iosManagedAppProtection' { 'iOS App Protection'; break }
83+
'windowsManagedAppProtection' { 'Windows App Protection'; break }
84+
'mdmWindowsInformationProtectionPolicy' { 'Windows Information Protection (MDM)'; break }
85+
'windowsInformationProtectionPolicy' { 'Windows Information Protection'; break }
86+
'targetedManagedAppConfiguration' { 'App Configuration (MAM)'; break }
87+
'defaultManagedAppProtection' { 'Default App Protection'; break }
8988
default { 'App Protection Policy' }
9089
}
9190

9291
# Process assignments
9392
$PolicyAssignment = [System.Collections.Generic.List[string]]::new()
9493
$PolicyExclude = [System.Collections.Generic.List[string]]::new()
95-
$Assignments = $AssignmentResults[$Policy.id]
96-
if ($Assignments) {
97-
foreach ($Assignment in $Assignments) {
94+
if ($Policy.assignments) {
95+
foreach ($Assignment in $Policy.assignments) {
9896
$target = $Assignment.target
9997
switch ($target.'@odata.type') {
10098
'#microsoft.graph.allDevicesAssignmentTarget' { $PolicyAssignment.Add('All Devices') }
@@ -112,7 +110,7 @@
112110
}
113111

114112
$Policy | Add-Member -NotePropertyName 'PolicyTypeName' -NotePropertyValue $policyType -Force
115-
$Policy | Add-Member -NotePropertyName 'URLName' -NotePropertyValue 'managedAppPolicies' -Force
113+
# $Policy | Add-Member -NotePropertyName 'URLName' -NotePropertyValue 'managedAppPolicies' -Force
116114
$Policy | Add-Member -NotePropertyName 'PolicySource' -NotePropertyValue 'AppProtection' -Force
117115
$Policy | Add-Member -NotePropertyName 'PolicyAssignment' -NotePropertyValue ($PolicyAssignment -join ', ') -Force
118116
$Policy | Add-Member -NotePropertyName 'PolicyExclude' -NotePropertyValue ($PolicyExclude -join ', ') -Force

Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ function Start-UserTasksOrchestrator {
1010
param()
1111

1212
$Table = Get-CippTable -tablename 'ScheduledTasks'
13-
$1HourAgo = (Get-Date).AddHours(-1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
14-
$Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Running' and Timestamp lt datetime'$1HourAgo'))"
13+
$30MinutesAgo = (Get-Date).AddMinutes(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
14+
$4HoursAgo = (Get-Date).AddHours(-4).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
15+
# Pending = orchestrator queued, Running = actively executing
16+
# Pick up: Planned, Failed-Planned, stuck Pending (>30min), or stuck Running (>4hr for large AllTenants tasks)
17+
$Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$30MinutesAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo'))"
1518
$tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter
1619

1720
$RateLimitTable = Get-CIPPTable -tablename 'SchedulerRateLimits'
@@ -49,11 +52,14 @@ function Start-UserTasksOrchestrator {
4952
$currentUnixTime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds
5053
if ($currentUnixTime -ge $task.ScheduledTime) {
5154
try {
55+
# Update task state to 'Pending' immediately to prevent concurrent orchestrator runs from picking it up
56+
# 'Pending' = orchestrator has picked it up and is queuing commands
57+
# 'Running' = actual execution is happening (set by Push-ExecScheduledCommand)
5258
$null = Update-AzDataTableEntity -Force @Table -Entity @{
5359
PartitionKey = $task.PartitionKey
5460
RowKey = $task.RowKey
5561
ExecutedTime = "$currentUnixTime"
56-
TaskState = 'Planned'
62+
TaskState = 'Pending'
5763
}
5864
$task.Parameters = $task.Parameters | ConvertFrom-Json -AsHashtable
5965
$task.AdditionalProperties = $task.AdditionalProperties | ConvertFrom-Json

0 commit comments

Comments
 (0)