-
Notifications
You must be signed in to change notification settings - Fork 3
/
intune-backup-pipeline.yml
504 lines (444 loc) · 22.4 KB
/
intune-backup-pipeline.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
trigger: none
schedules:
- cron: "0 1 * * *"
displayName: "1am every day"
branches:
include:
- main
always: true
variables:
- name: BACKUP_FOLDER
value: prod-backup
- name: TENANT_NAME
value: contoso.onmicrosoft.com
- name: SERVICE_CONNECTION_NAME
value: intune_backup_connection
- name: USER_EMAIL
value: [email protected]
- name: USER_NAME
value: IT_Intune_Backupper
jobs:
- job: backup_intune
displayName: Backup & commit Intune configuration
pool:
vmImage: ubuntu-latest
continueOnError: false
steps:
- checkout: self
persistCredentials: true
- task: Bash@3
displayName: Remove existing prod-backup directory
inputs:
targetType: "inline"
script: |
rm -rfv "$(Build.SourcesDirectory)/$(BACKUP_FOLDER)"
workingDirectory: "$(Build.SourcesDirectory)"
failOnStderr: false
- task: Bash@3
displayName: Install IntuneCD
inputs:
targetType: "inline"
script: |
pip3 install IntuneCD
workingDirectory: "$(Build.SourcesDirectory)"
failOnStderr: true
- task: AzurePowerShell@5
displayName: "Get Graph Token for Workload Federated Credential"
inputs:
azureSubscription: $(SERVICE_CONNECTION_NAME)
azurePowerShellVersion: "LatestVersion"
ScriptType: "inlineScript"
Inline: |
$accessToken = (Get-AzAccessToken -ResourceTypeName MSGraph -ErrorAction Stop).Token
Write-Host "##vso[task.setvariable variable=accessToken;issecret=true]$accessToken"
# Backup the latest configuration, using the current directory
- task: Bash@3
displayName: Create Intune backup
inputs:
targetType: "inline"
script: |
mkdir -p "$(Build.SourcesDirectory)/$(BACKUP_FOLDER)"
BACKUP_START=`date +%Y.%m.%d:%H.%M.%S`
# set BACKUP_START pipeline variable
echo "##vso[task.setVariable variable=BACKUP_START]$BACKUP_START"
IntuneCD-startbackup \
-t $(accessToken) \
--mode=1 \
--output=json \
--path="$(Build.SourcesDirectory)/$(BACKUP_FOLDER)" \
--exclude CompliancePartnerHeartbeat ManagedGooglePlay VPPusedLicenseCount\
--append-id \
--ignore-omasettings
workingDirectory: "$(Build.SourcesDirectory)"
failOnStderr: true
# Commit changes and push to repo
- task: PowerShell@2
displayName: Find change author & commit the backup
name: commitAndSetVariable
inputs:
targetType: "inline"
script: |
# $verbosePreference = 'continue'
$root = "$(Build.SourcesDirectory)"
Set-Location $root
# configure GIT defaults
git config --global user.name 'unknown'
git config --global user.email '[email protected]'
# to avoid 256 limit on Windows
git config --global core.longpaths true
# to support UNICODE
git config --global core.quotepath off
# to avoid 'CRLF will be replaced by LF the next time Git touches it'
git config --global core.eol lf
git config --global core.autocrlf false
# get changed config files
$untrackedFile = git ls-files --others --exclude-standard --full-name
$trackedFile = git ls-files --modified --full-name
$changedFile = $untrackedFile, $trackedFile | % { $_ } | ? { $_ }
# "status"
# git --no-pager status
# "diff"
# git --no-pager diff
if ($changedFile) {
# set CHANGE_DETECTED pipeline variable
echo "##vso[task.setVariable variable=CHANGE_DETECTED;isOutput=true;]1"
# install required Graph modules (for authentication and getting audit logs)
if (!(Get-Module "Microsoft.Graph.DeviceManagement.Administration" -ListAvailable)) {
Install-Module Microsoft.Graph.DeviceManagement.Administration -AllowClobber -Force -AcceptLicense
}
#region authenticate to Graph API using service principal secret
Write-Host "Authenticating to Graph API"
$secureToken = ConvertTo-SecureString -String $(accessToken) -AsPlainText -Force
Connect-MgGraph -AccessToken $secureToken -NoWelcome
#endregion authenticate to Graph API using service principal secret
#region helper functions
# function to be able to catch errors and all outputs
function _startProcess {
[CmdletBinding()]
param (
[string] $filePath = ''
,
[string] $argumentList = ''
,
[string] $workingDirectory = (Get-Location)
,
[switch] $dontWait
,
# lot of git commands output verbose output to error stream
[switch] $outputErr2Std
)
$p = New-Object System.Diagnostics.Process
$p.StartInfo.UseShellExecute = $false
$p.StartInfo.RedirectStandardOutput = $true
$p.StartInfo.RedirectStandardError = $true
$p.StartInfo.WorkingDirectory = $workingDirectory
$p.StartInfo.FileName = $filePath
$p.StartInfo.Arguments = $argumentList
[void]$p.Start()
if (!$dontWait) {
$p.WaitForExit()
}
$result = $p.StandardOutput.ReadToEnd()
if ($result) {
# to avoid returning of null
$result
}
if ($outputErr2Std) {
$p.StandardError.ReadToEnd()
} else {
if ($err = $p.StandardError.ReadToEnd()) {
Write-Error $err
}
}
}
function _getResourceId {
[CmdletBinding()]
param (
[string] $filePath
)
$fileName = [System.IO.Path]::GetFileNameWithoutExtension($filePath)
# some files are just additional content for an existing config JSON and IntuneCD author decided to not put ResourceId in their name (a.k.a. resourceId needs to be retrieved from the "parent" file name)
# some files just don't have ResourceId in their name because of the IntunceCD author decision
if ($filePath -like "*Device Configurations/mobileconfig/*") {
$parentFolderPath = Split-Path (Split-Path $filePath -Parent) -Parent
$fileName = Get-ChildItem $parentFolderPath -File | ? {
(ConvertFrom-Json -InputObject (Get-Content $_.FullName -Raw)).payloadFileName -eq [System.IO.Path]::GetFileName($filePath)
} | select -expand BaseName
if (!$fileName) {
#FIXME throw az budu umet vytahnout parent file i pri DELETE operaci
Write-Warning "Unable to find 'parent' config file for $filePath"
return
}
} elseif ($filePath -like "*/Managed Google Play/*") {
return ($modificationEvent | ? { $_.Category -eq 'Enrollment' -and $_.ActivityType -eq "Patch AndroidForWorkSettings" }).Resources.ResourceId
}
# parse resource ID from the file name
# file name is in format <policyname>__<ID>
# beware that it doesn't have to be GUID! For example ESP profile, Apple configurator profile etc uses as ID <guid>_guid>, <guid>_string
$delimiter = "__"
if ($fileName -like "*$delimiter*") {
$resourceId = ($fileName -split $delimiter)[-1]
# just in case file name contains more than two following underscores in a row which would lead to ID starting with underscore(s)
$resourceId = $resourceId -replace "^_*"
} else {
$resourceId = $null
}
return $resourceId
}
#endregion helper functions
# get date of the last config backup commit, to have the starting point for searching the audit log
# because of shallow clones, I need to fetch more data before calling git log
$gitCommitDepth = 30
git fetch --depth=$gitCommitDepth
$commitList = _startProcess git "--no-pager log --no-show-signature -$gitCommitDepth --format=%s%%%%%%%cI" -outputErr2Std -dontWait
$lastCommitDate = $commitList -split "`n" | ? {$_} | % {
$commitName, $commitDate = $_ -split "%%%"
if ($commitName -match "^\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2} -- ") {
# config backup commit name is in a format '2023.10.08_01.01 -- ...'
$commitDate
}
}
if ($lastCommitDate) {
# pick the newest and convert it to datetime object
$lastCommitDate = Get-Date @($lastCommitDate)[0]
} else {
Write-Warning "Unable to obtain date of the last backup config commit. ALL Intune audit events will be gathered."
}
# array where objects representing each changed file will be saved with information like who made the change etc
$modificationData = New-Object System.Collections.ArrayList
#region get all Intune audit events since the last commit
# it is much faster to get all events at once then retrieve them one by one using resourceId
#region create search filter
$filter = "activityResult eq 'Success'", "ActivityOperationType ne 'Get'"
if ($lastCommitDate) {
# Intune logs use UTC time
$lastCommitDate = $lastCommitDate.ToUniversalTime()
$filterDateTimeFrom = Get-Date -Date $lastCommitDate -Format "yyyy-MM-ddTHH:mm:ss"
$filter += "ActivityDateTime ge $filterDateTimeFrom`Z"
}
$backupStart = [DateTime]::ParseExact('$(BACKUP_START)', 'yyyy.MM.dd:HH.mm.ss', $null)
$backupStart = $backupStart.ToUniversalTime()
$filterDateTimeTo = Get-Date -Date $backupStart -Format "yyyy-MM-ddTHH:mm:ss"
$filter += "ActivityDateTime le $filterDateTimeTo`Z"
$eventFilter = $filter -join " and "
#endregion create search filter
"`nGetting Intune event logs"
"`t- from: '$lastCommitDate' (UTC) to: '$backupStart' (UTC)"
"`t- filter: $eventFilter"
# Get-MgDeviceManagementAuditEvent requires DeviceManagementApps.Read.All scope
$modificationEvent = Get-MgDeviceManagementAuditEvent -Filter $eventFilter -All
#endregion get all Intune audit events since the last commit
"`nProcessing changed files"
# try to find out who made the change
foreach ($file in $changedFile) {
$resourceId = _getResourceId $file
# get author of the resource change
if ($resourceId) {
"`t- $resourceId ($file)"
$resourceModificationEvent = $modificationEvent | ? { $_.Resources.ResourceId -eq $resourceId }
# list of change actors
$modificationAuthorUPN = @()
$resourceModificationEvent.Actor | % {
$actor = $_
if ($actor.UserPrincipalName) {
# modified by user
$modificationAuthorUPN += $actor.UserPrincipalName
} elseif ($actor.ApplicationDisplayName) {
# modified by service principal
$modificationAuthorUPN += ($actor.ApplicationDisplayName + " (SP)")
}
}
$modificationAuthorUPN = $modificationAuthorUPN | select -Unique | Sort-Object
} else {
if ($file -like "*/Assignment Report/report.json") {
# assignment report has no ID because it is generated by IntuneCD
} elseif ($file -like "*/Managed Google Play/*" -or $file -like "*Device Management Settings/settings.json" -or $file -like "*/Apple Push Notification/*") {
# IntuneCD don't gather those resources ID
} elseif ($file -like "*Device Configurations/mobileconfig/*") {
# IntuneCD gather those resources ID in their "parent" JSON, but when DELETE operation occurs, there is no "parent" to gather such data (at least easily)
#FIXME zrusit az budu umet tahat ID i pri DELETE operaci
} else {
throw "Unable to find resourceId in '$file' file name. Pipeline code modification needed, because some changes in IntuneCD were made probably."
}
$modificationAuthorUPN = $null
}
if ($modificationAuthorUPN) {
"`t`t- changed by: $($modificationAuthorUPN -join ', ')"
} else {
"`t`t- unable to find out who made the change"
$modificationAuthorUPN = '[email protected]'
}
$null = $modificationData.Add(
[PSCustomObject]@{
resourceId = $resourceId
file = Join-Path $root $file
modificationAuthorUPN = $modificationAuthorUPN
}
)
}
#region commit changes by author(s) who made them
"`nCommit changes"
# tip: grouping by created string, otherwise doesn't work correctly (probably because modificationAuthorUPN can contains multiple values)!
$modificationData | Group-Object { $_.modificationAuthorUPN -join '&'} | % {
$modificationAuthorUPN = $_.Group.ModificationAuthorUPN | Select-Object -Unique
$modificationAuthorName = $modificationAuthorUPN | % { $_.split('@')[0] }
$modifiedFile = $_.Group.File
$modifiedFile | % {
"`t- Adding $_"
$gitResult = _startProcess git -ArgumentList "add `"$_`"" -dontWait -outputErr2Std
if ($gitResult -match "^fatal:") {
throw $gitResult
}
}
"`t- Setting commit author(s): $($modificationAuthorName -join ', ')"
git config user.name ($modificationAuthorName -join ', ')
git config user.email ($modificationAuthorUPN -join ', ')
# in case of any change in commit name, you have to modify retrieval of the $lastCommitDate too!!!
$DATEF = "$(Get-Date $backupStart -f yyyy.MM.dd_HH.mm)"
$commitName = "$DATEF` -- $($modificationAuthorName -join ', ')"
"`t- Creating commit '$commitName'"
$null = _startProcess git -ArgumentList "commit -m `"$commitName`"" -dontWait
$unpushedCommit = _startProcess git -ArgumentList "cherry -v origin/main"
if ([string]::IsNullOrEmpty($unpushedCommit)) {
# no change detected
# this shouldn't happen, it means that detection of the changed files isn't working correctly
Write-Warning "Nothing to commit?! This shouldn't happen."
# set CHANGE_DETECTED pipeline variable
echo "##vso[task.setVariable variable=CHANGE_DETECTED;isOutput=true;]0"
} else {
"`t`t- Commit was created"
# save commit date to pipeline variable to use it when creating TAG
echo "##vso[task.setVariable variable=COMMIT_DATE;isOutput=true;]$DATEF"
# save modification author(s) to use when creating TAG
echo "##vso[task.setVariable variable=MODIFICATION_AUTHOR;isOutput=true;]$(($modificationData.modificationAuthorUPN | select -Unique | Sort-Object) -join ', ')"
}
}
#endregion commit changes by author(s) who made them
"`nPush changes to upstream"
$result = _startProcess git -argumentList "push origin HEAD:main" -dontWait -outputErr2Std
} else {
"No change detected"
# set CHANGE_DETECTED pipeline variable
echo "##vso[task.setVariable variable=CHANGE_DETECTED;isOutput=true;]0"
}
# Create markdown documentation & commit
# - task: Bash@3
# displayName: Generate markdown document & commit
# inputs:
# targetType: 'inline'
# script: |
# if [ "$(commitAndsetVariable.CHANGE_DETECTED)" -eq 1 ]
# then
# INTRO="Intune backup and documentation generated at $(Build.Repository.Uri) <img align=\"right\" width=\"96\" height=\"96\" src=\"./logo.png\">"
# IntuneCD-startdocumentation \
# --path="$(Build.SourcesDirectory)/prod-backup" \
# --outpath="$(Build.SourcesDirectory)/prod-as-built.md" \
# --tenantname=$TENANT_NAME \
# --intro="$INTRO" \
# #--split=Y
# # Commit changes and push to repo
# DATEF=`date +%Y.%m.%d`
# git config user.name $(USER_NAME)
# git config user.email $(USER_EMAIL)
# git add --all
# git commit -m "Intune config as-built $DATEF"
# git pull origin main
# git push origin HEAD:main
# else
# echo "no configuration backup change detected in the last commit, documentation will not be created"
# fi
# workingDirectory: '$(Build.SourcesDirectory)'
# failOnStderr: false
# env:
# TENANT_NAME: $(TENANT_NAME)
- job: tag
displayName: Tag repo
dependsOn: backup_intune
condition: and(succeeded(), eq(dependencies.backup_intune.outputs['commitAndsetVariable.CHANGE_DETECTED'], 1))
pool:
vmImage: ubuntu-latest
continueOnError: false
variables:
COMMIT_DATE: $[ dependencies.backup_intune.outputs['commitAndSetVariable.COMMIT_DATE'] ]
MODIFICATION_AUTHOR: $[ dependencies.backup_intune.outputs['commitAndSetVariable.MODIFICATION_AUTHOR'] ]
steps:
- checkout: self
persistCredentials: true
# Set git global settings
- task: Bash@3
displayName: Configure Git
inputs:
targetType: "inline"
script: |
git config --global user.name $(USER_NAME)
git config --global user.email $(USER_EMAIL)
workingDirectory: "$(Build.SourcesDirectory)"
failOnStderr: true
- task: Bash@3
displayName: Pull origin
inputs:
targetType: "inline"
script: |
git pull origin main
workingDirectory: "$(Build.SourcesDirectory)"
failOnStderr: false
- task: PowerShell@2
displayName: Git tag
inputs:
targetType: "inline"
script: |
# change in configuration backup folder detected, create TAG
$DATEF= "$(COMMIT_DATE)"
"Creating TAG '$DATEF'"
git tag -a "$DATEF" -m "$DATEF -- Intune configuration snapshot (changes made by: $(MODIFICATION_AUTHOR))"
git push origin "$DATEF" *> $null # even status information goes to stderr :(
failOnStderr: true
pwsh: false
workingDirectory: "$(Build.SourcesDirectory)"
# Publish PDF & HTML documents as an artifacts
# - job: publish
# displayName: Publish as-built artifacts
# dependsOn: tag
# condition: and(succeeded(), eq(dependencies.backup_intune.outputs['commitAndsetVariable.CHANGE_DETECTED'], 1))
# pool:
# vmImage: ubuntu-latest
# continueOnError: false
# steps:
# - checkout: self
# persistCredentials: true
# # Install md-to-pdf
# # https://github.com/simonhaenisch/md-to-pdf
# - task: Bash@3
# displayName: Install md-to-pdf
# inputs:
# targetType: 'inline'
# script: |
# npm i --location=global md-to-pdf
# workingDirectory: '$(Build.SourcesDirectory)'
# failOnStderr: true
# # Convert markdown document to HTML
# - task: Bash@3
# displayName: Convert markdown to HTML
# inputs:
# targetType: 'inline'
# script: |
# cat "$(Build.SourcesDirectory)/prod-as-built.md" | md-to-pdf --config-file "$(Build.SourcesDirectory)/md2pdf/htmlconfig.json" --as-html > "$(Build.SourcesDirectory)/prod-as-built.html"
# workingDirectory: '$(Build.SourcesDirectory)'
# failOnStderr: false
# - task: PublishBuildArtifacts@1
# inputs:
# pathToPublish: "$(Build.SourcesDirectory)/prod-as-built.html"
# artifactName: "prod-as-built.html"
# # Convert markdown document to PDF
# - task: Bash@3
# displayName: Convert markdown to PDF
# inputs:
# targetType: 'inline'
# script: |
# cat "$(Build.SourcesDirectory)/prod-as-built.md" | md-to-pdf --config-file "$(Build.SourcesDirectory)/md2pdf/pdfconfig.json" > "$(Build.SourcesDirectory)/prod-as-built.pdf"
# workingDirectory: '$(Build.SourcesDirectory)'
# failOnStderr: false
# - task: PublishBuildArtifacts@1
# inputs:
# pathToPublish: "$(Build.SourcesDirectory)/prod-as-built.pdf"
# artifactName: "prod-as-built.pdf"