Source: main (726ecd2) Excluded: live tenant exports, generated artifacts, and dev-only tooling.
612 lines
25 KiB
YAML
612 lines
25 KiB
YAML
trigger: none
|
|
pr: none
|
|
|
|
parameters:
|
|
- name: dryRun
|
|
displayName: Dry run only (report, no changes pushed)
|
|
type: boolean
|
|
default: true
|
|
- name: updateAssignments
|
|
displayName: Update assignments
|
|
type: boolean
|
|
default: true
|
|
- name: removeObjectsNotInBaseline
|
|
displayName: Remove objects not present in baseline
|
|
type: boolean
|
|
default: false
|
|
- name: includeEntraUpdate
|
|
displayName: Include Entra updates
|
|
type: boolean
|
|
default: false
|
|
- name: baselineBranch
|
|
displayName: Baseline branch to restore from
|
|
type: string
|
|
default: main
|
|
- name: baselineRef
|
|
displayName: Optional historical git ref (branch/tag/commit) to restore from
|
|
type: string
|
|
default: ""
|
|
- name: restoreMode
|
|
displayName: Restore mode (`full` or `selective`)
|
|
type: string
|
|
default: full
|
|
- name: restorePathsCsv
|
|
displayName: Selective restore file paths (CSV; repo-relative or intune-relative)
|
|
type: string
|
|
default: ""
|
|
- name: maxWorkers
|
|
displayName: IntuneCD max workers
|
|
type: number
|
|
default: 10
|
|
- name: excludeCsv
|
|
displayName: Exclude object categories (comma-separated IntuneCD keys)
|
|
type: string
|
|
default: ""
|
|
|
|
variables:
|
|
# Tenant-specific values are expected in a variable group (see templates/variables-tenant.yml).
|
|
# Uncomment the line below after creating the group in your Azure DevOps project.
|
|
- group: vg-astral-cqre
|
|
- template: templates/variables-common.yml
|
|
- name: BACKUP_FOLDER
|
|
value: tenant-state
|
|
- name: INTUNE_BACKUP_SUBDIR
|
|
value: intune
|
|
- name: INTUNECD_VERSION
|
|
value: 2.5.0
|
|
|
|
jobs:
|
|
- job: restore_from_baseline
|
|
displayName: Restore tenant from approved baseline
|
|
pool:
|
|
name: $(AGENT_POOL_NAME)
|
|
steps:
|
|
- checkout: self
|
|
persistCredentials: true
|
|
|
|
- task: Bash@3
|
|
displayName: Checkout approved baseline snapshot
|
|
inputs:
|
|
targetType: inline
|
|
script: |
|
|
set -euo pipefail
|
|
|
|
TARGET_REF_RAW="${{ parameters.baselineRef }}"
|
|
TARGET_REF="$(echo "$TARGET_REF_RAW" | xargs)"
|
|
TARGET_REF_LOWER="$(echo "$TARGET_REF" | tr '[:upper:]' '[:lower:]')"
|
|
if echo "$TARGET_REF" | grep -Eq '^\$\([^)]+\)$'; then
|
|
TARGET_REF=""
|
|
elif [ "$TARGET_REF_LOWER" = "none" ] || [ "$TARGET_REF_LOWER" = "null" ] || [ "$TARGET_REF_LOWER" = "n/a" ] || [ "$TARGET_REF_LOWER" = "-" ] || [ "$TARGET_REF_LOWER" = "_none_" ]; then
|
|
TARGET_REF=""
|
|
fi
|
|
if [ -z "$TARGET_REF" ]; then
|
|
TARGET_REF="${{ parameters.baselineBranch }}"
|
|
fi
|
|
|
|
git fetch --quiet --tags origin
|
|
git fetch --quiet origin "${{ parameters.baselineBranch }}"
|
|
|
|
RESOLVED_REF=""
|
|
if git rev-parse --verify --quiet "origin/$TARGET_REF^{commit}" >/dev/null; then
|
|
RESOLVED_REF="origin/$TARGET_REF"
|
|
elif git rev-parse --verify --quiet "$TARGET_REF^{commit}" >/dev/null; then
|
|
RESOLVED_REF="$TARGET_REF"
|
|
elif git fetch --quiet origin "$TARGET_REF" >/dev/null 2>&1; then
|
|
RESOLVED_REF="FETCH_HEAD"
|
|
fi
|
|
|
|
if [ -z "$RESOLVED_REF" ]; then
|
|
echo "##vso[task.logissue type=error]Unable to resolve baseline ref '$TARGET_REF'."
|
|
echo "Checked local ref, origin/<ref>, and direct fetch origin <ref>."
|
|
exit 1
|
|
fi
|
|
|
|
git checkout --force --detach "$RESOLVED_REF"
|
|
RESOLVED_COMMIT="$(git rev-parse HEAD)"
|
|
echo "Restore baseline snapshot selected: requested=$TARGET_REF resolved=$RESOLVED_REF commit=$RESOLVED_COMMIT"
|
|
echo "##vso[task.setvariable variable=RESTORE_BASELINE_REF]$TARGET_REF"
|
|
echo "##vso[task.setvariable variable=RESTORE_BASELINE_COMMIT]$RESOLVED_COMMIT"
|
|
test -d "$(Build.SourcesDirectory)/$(BACKUP_FOLDER)/$(INTUNE_BACKUP_SUBDIR)"
|
|
|
|
- task: Bash@3
|
|
displayName: Install IntuneCD
|
|
inputs:
|
|
targetType: inline
|
|
script: |
|
|
set -euo pipefail
|
|
pip3 install "IntuneCD==$(INTUNECD_VERSION)"
|
|
|
|
- task: Bash@3
|
|
displayName: Prepare restore payload scope
|
|
inputs:
|
|
targetType: inline
|
|
script: |
|
|
set -euo pipefail
|
|
python3 - <<'PY'
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import sys
|
|
|
|
repo_root = pathlib.Path(os.environ["BUILD_SOURCESDIRECTORY"]).resolve()
|
|
backup_folder = os.environ["BACKUP_FOLDER"]
|
|
intune_subdir = os.environ["INTUNE_BACKUP_SUBDIR"]
|
|
restore_mode = os.environ.get("RESTORE_MODE", "").strip().lower() or "full"
|
|
restore_paths_csv = os.environ.get("RESTORE_PATHS_CSV", "").strip()
|
|
temp_root = pathlib.Path(os.environ["AGENT_TEMPDIRECTORY"]).resolve() / "restore-scope-intune"
|
|
intune_root = repo_root / backup_folder / intune_subdir
|
|
|
|
if not intune_root.is_dir():
|
|
print(f"##vso[task.logissue type=error]Intune restore source root not found: {intune_root}")
|
|
raise SystemExit(1)
|
|
|
|
if restore_mode == "full":
|
|
print(f"Restore mode: full (source={intune_root})")
|
|
print(f"##vso[task.setvariable variable=RESTORE_SOURCE_PATH]{intune_root}")
|
|
raise SystemExit(0)
|
|
|
|
if restore_mode not in {"selective", "paths"}:
|
|
print(f"##vso[task.logissue type=error]Unsupported restoreMode '{restore_mode}'. Use 'full' or 'selective'.")
|
|
raise SystemExit(1)
|
|
|
|
raw_items = [item.strip() for item in restore_paths_csv.replace("\n", ",").split(",")]
|
|
raw_items = [item for item in raw_items if item]
|
|
if not raw_items:
|
|
print("##vso[task.logissue type=error]restoreMode=selective requires restorePathsCsv with at least one path.")
|
|
raise SystemExit(1)
|
|
|
|
backup_prefix = f"{backup_folder}/{intune_subdir}/"
|
|
copied = []
|
|
errors = []
|
|
|
|
if temp_root.exists():
|
|
shutil.rmtree(temp_root)
|
|
temp_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
def normalize_to_intune_relative(path_text):
|
|
p = path_text.replace("\\", "/").lstrip("./")
|
|
if p.startswith("/"):
|
|
return None
|
|
if p.startswith(backup_prefix):
|
|
p = p[len(backup_prefix):]
|
|
elif p.startswith(f"{intune_subdir}/"):
|
|
p = p[len(intune_subdir) + 1 :]
|
|
return p.strip("/")
|
|
|
|
for item in raw_items:
|
|
rel = normalize_to_intune_relative(item)
|
|
if not rel:
|
|
errors.append(f"Invalid path '{item}'")
|
|
continue
|
|
|
|
parts = pathlib.PurePosixPath(rel).parts
|
|
if any(part in {"..", ""} for part in parts):
|
|
errors.append(f"Path traversal is not allowed: '{item}'")
|
|
continue
|
|
|
|
src = intune_root.joinpath(*parts)
|
|
if not src.is_file():
|
|
errors.append(f"Path not found in selected baseline snapshot: '{item}' -> '{src}'")
|
|
continue
|
|
|
|
dst = temp_root.joinpath(*parts)
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
copied.append(rel)
|
|
|
|
if errors:
|
|
for message in errors:
|
|
print(f"##vso[task.logissue type=error]{message}")
|
|
raise SystemExit(1)
|
|
|
|
if not copied:
|
|
print("##vso[task.logissue type=error]No files were prepared for selective restore.")
|
|
raise SystemExit(1)
|
|
|
|
print(f"Restore mode: selective (files={len(copied)})")
|
|
for rel in copied:
|
|
print(f" - {backup_folder}/{intune_subdir}/{rel}")
|
|
print(f"##vso[task.setvariable variable=RESTORE_SOURCE_PATH]{temp_root}")
|
|
PY
|
|
env:
|
|
RESTORE_MODE: ${{ parameters.restoreMode }}
|
|
RESTORE_PATHS_CSV: ${{ parameters.restorePathsCsv }}
|
|
|
|
- task: AzurePowerShell@5
|
|
displayName: Get Graph token for restore
|
|
inputs:
|
|
azureSubscription: $(SERVICE_CONNECTION_NAME)
|
|
azurePowerShellVersion: LatestVersion
|
|
ScriptType: inlineScript
|
|
Inline: |
|
|
$getTokenParams = @{
|
|
ResourceTypeName = 'MSGraph'
|
|
AsSecureString = $true
|
|
ErrorAction = 'Stop'
|
|
}
|
|
$tokenCommand = Get-Command Get-AzAccessToken -ErrorAction Stop
|
|
if ($tokenCommand.Parameters.ContainsKey('ForceRefresh')) {
|
|
$getTokenParams['ForceRefresh'] = $true
|
|
}
|
|
$accessToken = ([PSCredential]::New('dummy', (Get-AzAccessToken @getTokenParams).Token).GetNetworkCredential().Password)
|
|
|
|
$tokenParts = $accessToken.Split('.')
|
|
if ($tokenParts.Length -lt 2) { throw "Invalid Graph access token format." }
|
|
$payload = $tokenParts[1].Replace('-', '+').Replace('_', '/')
|
|
switch ($payload.Length % 4) {
|
|
2 { $payload += '==' }
|
|
3 { $payload += '=' }
|
|
}
|
|
$payloadJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload))
|
|
$claims = $payloadJson | ConvertFrom-Json
|
|
$roles = @($claims.roles)
|
|
$sortedRoles = $roles | Sort-Object
|
|
Write-Host "Graph token roles for restore: $($sortedRoles -join ', ')"
|
|
|
|
$missingRoles = @()
|
|
$requiredIntuneWriteRoles = @(
|
|
'DeviceManagementApps.ReadWrite.All',
|
|
'DeviceManagementConfiguration.ReadWrite.All',
|
|
'DeviceManagementManagedDevices.ReadWrite.All',
|
|
'DeviceManagementRBAC.ReadWrite.All',
|
|
'DeviceManagementScripts.ReadWrite.All',
|
|
'DeviceManagementServiceConfig.ReadWrite.All',
|
|
'Group.Read.All'
|
|
)
|
|
foreach ($role in $requiredIntuneWriteRoles) {
|
|
if (-not ($roles -contains $role)) { $missingRoles += $role }
|
|
}
|
|
|
|
if ("${{ parameters.includeEntraUpdate }}" -eq "true") {
|
|
$requiredEntraWriteRoles = @(
|
|
'Policy.Read.All',
|
|
'Policy.ReadWrite.ConditionalAccess'
|
|
)
|
|
foreach ($role in $requiredEntraWriteRoles) {
|
|
if (-not ($roles -contains $role)) { $missingRoles += $role }
|
|
}
|
|
}
|
|
|
|
if ($missingRoles.Count -gt 0) {
|
|
$missingRoles = $missingRoles | Select-Object -Unique | Sort-Object
|
|
Write-Host "##vso[task.logissue type=error]Graph token is missing restore permissions: $($missingRoles -join ', ')"
|
|
throw "Service connection token is missing required Graph application permissions for restore."
|
|
}
|
|
|
|
Write-Host "##vso[task.setvariable variable=accessToken;issecret=true]$accessToken"
|
|
|
|
- task: Bash@3
|
|
displayName: Run IntuneCD restore/update
|
|
inputs:
|
|
targetType: inline
|
|
script: |
|
|
set -euo pipefail
|
|
echo "RESTORE_SCRIPT_VERSION=2026-03-12.8"
|
|
|
|
to_lower() {
|
|
echo "$1" | tr '[:upper:]' '[:lower:]'
|
|
}
|
|
|
|
DRY_RUN="$(to_lower "$DRY_RUN")"
|
|
UPDATE_ASSIGNMENTS="$(to_lower "$UPDATE_ASSIGNMENTS")"
|
|
REMOVE_UNMANAGED="$(to_lower "$REMOVE_UNMANAGED")"
|
|
ENTRA_UPDATE="$(to_lower "$ENTRA_UPDATE")"
|
|
|
|
if [ -z "$(RESTORE_SOURCE_PATH)" ]; then
|
|
RESTORE_PATH="$(Build.SourcesDirectory)/$(BACKUP_FOLDER)/$(INTUNE_BACKUP_SUBDIR)"
|
|
else
|
|
RESTORE_PATH="$(RESTORE_SOURCE_PATH)"
|
|
fi
|
|
export RESTORE_PATH_ENV="$RESTORE_PATH"
|
|
|
|
python3 - <<'PY'
|
|
import base64
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
root = pathlib.Path(os.environ["RESTORE_PATH_ENV"]).resolve()
|
|
if not root.exists():
|
|
print(f"Restore source folder not found; payload normalization skipped: {root}")
|
|
raise SystemExit(0)
|
|
|
|
graph_token = os.environ.get("GRAPH_TOKEN", "").strip()
|
|
guid_re = re.compile(
|
|
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
|
|
)
|
|
group_id_cache = {}
|
|
|
|
def is_guid(value):
|
|
return bool(guid_re.match(str(value or "").strip()))
|
|
|
|
def resolve_group_id(group_name):
|
|
name = str(group_name or "").strip()
|
|
if not name or not graph_token:
|
|
return None
|
|
|
|
cache_key = name.lower()
|
|
if cache_key in group_id_cache:
|
|
return group_id_cache[cache_key]
|
|
|
|
filter_value = name.replace("'", "''")
|
|
query = urllib.parse.urlencode(
|
|
{
|
|
"$select": "id,displayName",
|
|
"$filter": f"displayName eq '{filter_value}'",
|
|
}
|
|
)
|
|
url = f"https://graph.microsoft.com/v1.0/groups?{query}"
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={
|
|
"Authorization": f"Bearer {graph_token}",
|
|
"Accept": "application/json",
|
|
},
|
|
method="GET",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
except Exception:
|
|
group_id_cache[cache_key] = None
|
|
return None
|
|
|
|
values = payload.get("value", []) if isinstance(payload, dict) else []
|
|
exact = [
|
|
item for item in values
|
|
if str(item.get("displayName", "")) == name and is_guid(item.get("id"))
|
|
]
|
|
if len(exact) == 1:
|
|
resolved = exact[0]["id"]
|
|
group_id_cache[cache_key] = resolved
|
|
return resolved
|
|
|
|
ids = [item.get("id") for item in values if is_guid(item.get("id"))]
|
|
resolved = ids[0] if len(ids) == 1 else None
|
|
group_id_cache[cache_key] = resolved
|
|
return resolved
|
|
|
|
def strip_assignment_display_labels(node):
|
|
removed = 0
|
|
allowed_assignment_target_keys = {
|
|
"@odata.type",
|
|
"groupId",
|
|
"collectionId",
|
|
"deviceAndAppManagementAssignmentFilterId",
|
|
"deviceAndAppManagementAssignmentFilterType",
|
|
}
|
|
if isinstance(node, dict):
|
|
odata_type = str(node.get("@odata.type", "") or "").lower()
|
|
is_assignment_target = "assignmenttarget" in odata_type
|
|
|
|
if is_assignment_target:
|
|
group_name = (
|
|
node.get("groupName")
|
|
or node.get("groupDisplayName")
|
|
or node.get("displayName")
|
|
)
|
|
group_id = str(node.get("groupId", "") or "").strip()
|
|
is_group_target = "groupassignmenttarget" in odata_type
|
|
if is_group_target and not is_guid(group_id):
|
|
resolved_group_id = resolve_group_id(group_name)
|
|
if resolved_group_id:
|
|
node["groupId"] = resolved_group_id
|
|
group_id = resolved_group_id
|
|
|
|
for key in list(node.keys()):
|
|
if key.startswith("@odata."):
|
|
continue
|
|
if key not in allowed_assignment_target_keys:
|
|
if key in node:
|
|
node.pop(key, None)
|
|
removed += 1
|
|
|
|
# Keep only valid group assignment targets for update payload.
|
|
if is_group_target and not is_guid(node.get("groupId")):
|
|
node["__drop_assignment_target__"] = True
|
|
elif "groupId" in node:
|
|
for key in ("groupDisplayName", "groupName", "displayName", "groupType"):
|
|
if key in node:
|
|
node.pop(key, None)
|
|
removed += 1
|
|
|
|
if "targetDisplayName" in node and isinstance(node.get("target"), dict):
|
|
node.pop("targetDisplayName", None)
|
|
removed += 1
|
|
|
|
for value in node.values():
|
|
removed += strip_assignment_display_labels(value)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
removed += strip_assignment_display_labels(item)
|
|
return removed
|
|
|
|
def prune_invalid_assignment_targets(node):
|
|
removed = 0
|
|
if isinstance(node, dict):
|
|
assignments = node.get("assignments")
|
|
if isinstance(assignments, list):
|
|
filtered = []
|
|
for assignment in assignments:
|
|
target = assignment.get("target") if isinstance(assignment, dict) else None
|
|
if isinstance(target, dict) and target.get("__drop_assignment_target__") is True:
|
|
removed += 1
|
|
continue
|
|
filtered.append(assignment)
|
|
if len(filtered) != len(assignments):
|
|
node["assignments"] = filtered
|
|
for value in node.values():
|
|
removed += prune_invalid_assignment_targets(value)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
removed += prune_invalid_assignment_targets(item)
|
|
return removed
|
|
|
|
def remove_internal_markers(node):
|
|
removed = 0
|
|
if isinstance(node, dict):
|
|
if "__drop_assignment_target__" in node:
|
|
node.pop("__drop_assignment_target__", None)
|
|
removed += 1
|
|
for value in node.values():
|
|
removed += remove_internal_markers(value)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
removed += remove_internal_markers(item)
|
|
return removed
|
|
|
|
normalized_payload_json = 0
|
|
sanitized_assignment_labels = 0
|
|
invalid_assignment_targets_removed = 0
|
|
files_changed = 0
|
|
|
|
for path in sorted(root.rglob("*.json")):
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
|
|
file_changed = False
|
|
|
|
# IntuneCD expects payloadJson as base64 string; backup may store dict/list.
|
|
# Some exports can be list-root JSON, so only access payloadJson on dict roots.
|
|
if isinstance(data, dict):
|
|
payload = data.get("payloadJson")
|
|
if isinstance(payload, (dict, list)):
|
|
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
data["payloadJson"] = base64.b64encode(payload_json).decode("ascii")
|
|
normalized_payload_json += 1
|
|
file_changed = True
|
|
|
|
removed = strip_assignment_display_labels(data)
|
|
if removed > 0:
|
|
sanitized_assignment_labels += removed
|
|
file_changed = True
|
|
|
|
dropped = prune_invalid_assignment_targets(data)
|
|
if dropped > 0:
|
|
invalid_assignment_targets_removed += dropped
|
|
file_changed = True
|
|
|
|
# Clean up internal markers used by prune flow.
|
|
if remove_internal_markers(data) > 0:
|
|
file_changed = True
|
|
|
|
if file_changed:
|
|
path.write_text(json.dumps(data, indent=4, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
files_changed += 1
|
|
|
|
print(
|
|
"Restore payload normalization complete: "
|
|
f"filesChanged={files_changed}, "
|
|
f"appConfigPayloadJsonNormalized={normalized_payload_json}, "
|
|
f"assignmentDisplayLabelsRemoved={sanitized_assignment_labels}, "
|
|
f"invalidAssignmentTargetsRemoved={invalid_assignment_targets_removed}"
|
|
)
|
|
PY
|
|
|
|
cmd=(
|
|
IntuneCD-startupdate
|
|
--token "$(accessToken)"
|
|
--mode=1
|
|
--path "$RESTORE_PATH"
|
|
--exit-on-error
|
|
)
|
|
|
|
if [ "$DRY_RUN" = "true" ]; then
|
|
cmd+=(--report)
|
|
fi
|
|
if [ "$UPDATE_ASSIGNMENTS" = "true" ]; then
|
|
cmd+=(--update-assignments)
|
|
fi
|
|
if [ "$REMOVE_UNMANAGED" = "true" ]; then
|
|
cmd+=(--remove)
|
|
fi
|
|
if [ "$ENTRA_UPDATE" = "true" ]; then
|
|
cmd+=(--entraupdate)
|
|
fi
|
|
|
|
EXCLUDE_CSV_TRIMMED="$(echo "$EXCLUDE_CSV" | xargs)"
|
|
EXCLUDE_CSV_NORMALIZED="$(echo "$EXCLUDE_CSV_TRIMMED" | tr '[:upper:]' '[:lower:]')"
|
|
if [ "$EXCLUDE_CSV_NORMALIZED" = "none" ] || [ "$EXCLUDE_CSV_NORMALIZED" = "null" ] || [ "$EXCLUDE_CSV_NORMALIZED" = "n/a" ] || [ "$EXCLUDE_CSV_NORMALIZED" = "-" ] || [ "$EXCLUDE_CSV_NORMALIZED" = "_none_" ]; then
|
|
EXCLUDE_CSV_TRIMMED=""
|
|
fi
|
|
|
|
exclude_items=()
|
|
if [ -n "$EXCLUDE_CSV_TRIMMED" ]; then
|
|
IFS=',' read -r -a raw_items <<< "$EXCLUDE_CSV_TRIMMED"
|
|
for item in "${raw_items[@]}"; do
|
|
trimmed="$(echo "$item" | xargs)"
|
|
if [ -n "$trimmed" ]; then
|
|
exclude_items+=("$trimmed")
|
|
fi
|
|
done
|
|
fi
|
|
|
|
has_dms_exclude=0
|
|
for item in "${exclude_items[@]}"; do
|
|
if [ "$(echo "$item" | tr '[:upper:]' '[:lower:]')" = "devicemanagementsettings" ]; then
|
|
has_dms_exclude=1
|
|
break
|
|
fi
|
|
done
|
|
if [ "$has_dms_exclude" -eq 0 ]; then
|
|
exclude_items+=("DeviceManagementSettings")
|
|
echo "Auto-excluding DeviceManagementSettings (IntuneCD update requires interactive auth for this category)."
|
|
fi
|
|
|
|
if [ "${#exclude_items[@]}" -gt 0 ]; then
|
|
cmd+=(--exclude)
|
|
cmd+=("${exclude_items[@]}")
|
|
fi
|
|
|
|
echo "Restore command mode: dryRun=$DRY_RUN updateAssignments=$UPDATE_ASSIGNMENTS remove=$REMOVE_UNMANAGED entraupdate=$ENTRA_UPDATE maxWorkers=$MAX_WORKERS sourcePath=$RESTORE_PATH"
|
|
if [ "${#exclude_items[@]}" -gt 0 ]; then
|
|
joined_excludes="$(IFS=,; echo "${exclude_items[*]}")"
|
|
echo "Excluding categories: $joined_excludes"
|
|
fi
|
|
|
|
intunecd_log="${AGENT_TEMPDIRECTORY:-/tmp}/intunecd-restore.log"
|
|
rm -f "$intunecd_log"
|
|
set +e
|
|
"${cmd[@]}" >"$intunecd_log" 2>&1
|
|
intunecd_rc=$?
|
|
set -e
|
|
echo "IntuneCD exit code captured: $intunecd_rc"
|
|
|
|
if [ "$intunecd_rc" -ne 0 ]; then
|
|
echo "IntuneCD restore/update failed with exit code: $intunecd_rc"
|
|
marker_pattern="error|\\[ERROR\\]|\\[CRITICAL\\]|request failed|failed with status|modelvalidationfailure|traceback|exception|error updating|failed after|unable to|forbidden|unauthorized"
|
|
marker_count="$(grep -Eic "$marker_pattern" "$intunecd_log" || true)"
|
|
echo "Detected error-marker lines: $marker_count"
|
|
echo "Relevant markers from full output (line:number:text):"
|
|
grep -Ein "$marker_pattern" "$intunecd_log" | tail -n 200 || true
|
|
echo "First 80 lines of IntuneCD output:"
|
|
head -n 80 "$intunecd_log" || true
|
|
echo "Last 120 lines of IntuneCD output:"
|
|
tail -n 120 "$intunecd_log" || true
|
|
if [ "${marker_count:-0}" -eq 0 ]; then
|
|
echo "##vso[task.logissue type=warning]IntuneCD returned non-zero without explicit error markers; treating as successful no-op."
|
|
intunecd_rc=0
|
|
fi
|
|
else
|
|
echo "Last 60 lines of IntuneCD output:"
|
|
tail -n 60 "$intunecd_log" || true
|
|
fi
|
|
|
|
if [ "$intunecd_rc" -ne 0 ]; then
|
|
exit "$intunecd_rc"
|
|
fi
|
|
failOnStderr: false
|
|
env:
|
|
DRY_RUN: ${{ parameters.dryRun }}
|
|
UPDATE_ASSIGNMENTS: ${{ parameters.updateAssignments }}
|
|
REMOVE_UNMANAGED: ${{ parameters.removeObjectsNotInBaseline }}
|
|
ENTRA_UPDATE: ${{ parameters.includeEntraUpdate }}
|
|
MAX_WORKERS: ${{ parameters.maxWorkers }}
|
|
EXCLUDE_CSV: ${{ parameters.excludeCsv }}
|
|
GRAPH_TOKEN: $(accessToken)
|