Sync from dev @ 252c1cf
Source: main (252c1cf) Excluded: live tenant exports, generated artifacts, and dev-only tooling.
This commit is contained in:
611
azure-pipelines-restore.yml
Normal file
611
azure-pipelines-restore.yml
Normal file
@@ -0,0 +1,611 @@
|
||||
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-tenant
|
||||
- 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)
|
||||
Reference in New Issue
Block a user