Files
astral/azure-pipelines-restore.yml
Tomas Kracmar 17d745bdac Sync from dev @ 252c1cf
Source: main (252c1cf)
Excluded: live tenant exports, generated artifacts, and dev-only tooling.
2026-04-17 15:57:35 +02:00

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-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)