Sync from dev @ 252c1cf
Source: main (252c1cf) Excluded: live tenant exports, generated artifacts, and dev-only tooling.
This commit is contained in:
46
deploy/RELEASE.md
Normal file
46
deploy/RELEASE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# ASTRAL Public Release Checklist
|
||||
|
||||
Use this checklist before publishing a new sanitized version of ASTRAL to the public repository.
|
||||
|
||||
## Pre-release scan
|
||||
|
||||
Run the following commands from the repository root to ensure no tenant-specific data remains:
|
||||
|
||||
```bash
|
||||
# Search for the original tenant identifiers
|
||||
grep -ri "cqre" --include="*.{yml,yaml,py,md,json,sh,ps1}" . | grep -v node_modules | grep -v __pycache__ | grep -v .git
|
||||
grep -ri "kracmar" --include="*.{yml,yaml,py,md,json,sh,ps1}" . | grep -v node_modules | grep -v __pycache__ | grep -v .git
|
||||
grep -ri "sc_intunebackup" --include="*.{yml,yaml,py,md,json,sh,ps1}" . | grep -v node_modules | grep -v __pycache__ | grep -v .git
|
||||
|
||||
# Search for the original tenant ID (replace with your actual tenant ID)
|
||||
grep -ri "0ec9f34c-17c8-4541-b084-7d64ecdcc997" --include="*.{yml,yaml,py,md,json,sh,ps1}" . | grep -v node_modules | grep -v __pycache__ | grep -v .git
|
||||
```
|
||||
|
||||
Expected result: **zero matches** outside of this release checklist.
|
||||
|
||||
## File verification
|
||||
|
||||
- [ ] `azure-pipelines.yml` contains no hardcoded tenant domain, email, or service connection name.
|
||||
- [ ] `azure-pipelines-restore.yml` contains no hardcoded tenant domain, email, or service connection name.
|
||||
- [ ] `azure-pipelines-review-sync.yml` contains no hardcoded tenant-specific values.
|
||||
- [ ] `scripts/common.py` uses a generic fallback name (not `CQRE_Intune_Backupper`).
|
||||
- [ ] `tenant-state/` contains only placeholder files (`.gitkeep`, `README.md`).
|
||||
- [ ] `prod-as-built.md` has been deleted.
|
||||
- [ ] All markdown documentation uses generic examples (`contoso.onmicrosoft.com`, `astral-backup@contoso.com`, `sc-astral-backup`).
|
||||
|
||||
## Test verification
|
||||
|
||||
- [ ] Unit tests pass: `python3 -m unittest discover -s tests -v`
|
||||
|
||||
## Publication steps
|
||||
|
||||
1. Ensure you are on a clean branch (e.g. `publish/v1.x`).
|
||||
2. Run the pre-release scan above.
|
||||
3. Commit any last-minute fixes.
|
||||
4. Tag the release: `git tag -a v1.0.0 -m "ASTRAL v1.0.0"`
|
||||
5. Push the tag.
|
||||
6. Publish to the public repository (fresh clone or specific branch push).
|
||||
|
||||
## Note on Git history
|
||||
|
||||
If the original repository contained live tenant exports in its history, consider publishing from a **squashed or freshly initialized repository** rather than pushing the full private history. The public template does not benefit from historical tenant data, and a clean history avoids accidental exposure of old exports.
|
||||
228
deploy/bootstrap-tenant.ps1
Normal file
228
deploy/bootstrap-tenant.ps1
Normal file
@@ -0,0 +1,228 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstraps an Azure AD app registration for ASTRAL with required Microsoft Graph permissions.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates a single-tenant app registration, assigns read (and optional write) Graph application permissions,
|
||||
grants admin consent, and configures a workload federated credential for Azure DevOps.
|
||||
|
||||
.PARAMETER TenantName
|
||||
The Microsoft 365 tenant domain, e.g. contoso.onmicrosoft.com.
|
||||
|
||||
.PARAMETER ServiceConnectionName
|
||||
The intended Azure DevOps service connection name (used for the federated credential subject).
|
||||
|
||||
.PARAMETER AppDisplayName
|
||||
Optional display name for the app registration. Default: "ASTRAL Backup Service".
|
||||
|
||||
.PARAMETER AdoOrganizationUrl
|
||||
Optional Azure DevOps organization URL, e.g. https://dev.azure.com/contoso.
|
||||
If provided, the script prints a one-liner to create the service connection via REST API.
|
||||
|
||||
.PARAMETER AddRestorePermissions
|
||||
If specified, also adds write permissions for the restore pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
.\bootstrap-tenant.ps1 -TenantName "contoso.onmicrosoft.com" -ServiceConnectionName "sc-astral-backup"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantName,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ServiceConnectionName,
|
||||
|
||||
[string]$AppDisplayName = "ASTRAL Backup Service",
|
||||
|
||||
[string]$AdoOrganizationUrl = "",
|
||||
|
||||
[switch]$AddRestorePermissions
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Test-ModuleInstalled {
|
||||
param ([string]$Name)
|
||||
$mod = Get-Module -ListAvailable -Name $Name | Select-Object -First 1
|
||||
if (-not $mod) {
|
||||
Write-Host "Installing module: $Name" -ForegroundColor Cyan
|
||||
Install-Module $Name -Scope CurrentUser -Force -AllowClobber
|
||||
}
|
||||
}
|
||||
|
||||
Test-ModuleInstalled "Microsoft.Graph.Applications"
|
||||
Test-ModuleInstalled "Microsoft.Graph.Identity.SignIns"
|
||||
|
||||
Import-Module Microsoft.Graph.Applications
|
||||
Import-Module Microsoft.Graph.Identity.SignIns
|
||||
|
||||
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||
Connect-MgGraph -Scopes "Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All","Directory.Read.All" -NoWelcome
|
||||
|
||||
$tenant = Get-MgOrganization | Select-Object -First 1
|
||||
if (-not $tenant) {
|
||||
throw "Unable to read tenant details. Ensure you are authenticated to the correct tenant."
|
||||
}
|
||||
|
||||
Write-Host "Tenant: $($tenant.DisplayName) ($($tenant.Id))" -ForegroundColor Green
|
||||
|
||||
# Required read permissions
|
||||
$readPermissions = @(
|
||||
"Device.Read.All",
|
||||
"DeviceManagementApps.Read.All",
|
||||
"DeviceManagementConfiguration.Read.All",
|
||||
"DeviceManagementManagedDevices.Read.All",
|
||||
"DeviceManagementRBAC.Read.All",
|
||||
"DeviceManagementScripts.Read.All",
|
||||
"DeviceManagementServiceConfig.Read.All",
|
||||
"Group.Read.All",
|
||||
"Policy.Read.All",
|
||||
"Policy.Read.ConditionalAccess",
|
||||
"Policy.Read.DeviceConfiguration",
|
||||
"User.Read.All",
|
||||
"Application.Read.All"
|
||||
)
|
||||
|
||||
$optionalReadPermissions = @(
|
||||
"RoleManagement.Read.Directory",
|
||||
"Directory.Read.All",
|
||||
"AuditLog.Read.All"
|
||||
)
|
||||
|
||||
$restorePermissions = @(
|
||||
"DeviceManagementApps.ReadWrite.All",
|
||||
"DeviceManagementConfiguration.ReadWrite.All",
|
||||
"DeviceManagementManagedDevices.ReadWrite.All",
|
||||
"DeviceManagementRBAC.ReadWrite.All",
|
||||
"DeviceManagementScripts.ReadWrite.All",
|
||||
"DeviceManagementServiceConfig.ReadWrite.All",
|
||||
"Policy.Read.All",
|
||||
"Policy.ReadWrite.ConditionalAccess"
|
||||
)
|
||||
|
||||
$allPermissions = $readPermissions + $optionalReadPermissions
|
||||
if ($AddRestorePermissions) {
|
||||
$allPermissions += $restorePermissions
|
||||
}
|
||||
|
||||
# Get Microsoft Graph SP to map permissions to AppRoles
|
||||
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
|
||||
if (-not $graphSp) {
|
||||
throw "Microsoft Graph service principal not found in tenant."
|
||||
}
|
||||
|
||||
$requiredResourceAccess = @()
|
||||
$appRoles = @()
|
||||
foreach ($permName in ($allPermissions | Select-Object -Unique)) {
|
||||
$appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $permName } | Select-Object -First 1
|
||||
if (-not $appRole) {
|
||||
Write-Warning "Permission '$permName' not found in Microsoft Graph. Skipping."
|
||||
continue
|
||||
}
|
||||
$appRoles += $appRole
|
||||
}
|
||||
|
||||
if ($appRoles.Count -eq 0) {
|
||||
throw "No valid Graph permissions resolved. Cannot continue."
|
||||
}
|
||||
|
||||
$resourceAccess = @()
|
||||
foreach ($ar in $appRoles) {
|
||||
$resourceAccess += @{
|
||||
id = $ar.Id
|
||||
type = "Role"
|
||||
}
|
||||
}
|
||||
|
||||
$requiredResourceAccess = @(
|
||||
@{
|
||||
resourceAppId = $graphSp.AppId
|
||||
resourceAccess = $resourceAccess
|
||||
}
|
||||
)
|
||||
|
||||
# Create or update app registration
|
||||
$existingApp = Get-MgApplication -Filter "displayName eq '$AppDisplayName'" | Select-Object -First 1
|
||||
if ($existingApp) {
|
||||
Write-Host "Found existing app registration: $($existingApp.AppId)" -ForegroundColor Yellow
|
||||
$app = $existingApp
|
||||
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess
|
||||
Write-Host "Updated required resource access." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "Creating app registration: $AppDisplayName" -ForegroundColor Cyan
|
||||
$app = New-MgApplication -DisplayName $AppDisplayName -SignInAudience "AzureADMyOrg" -RequiredResourceAccess $requiredResourceAccess
|
||||
Write-Host "Created app registration. AppId: $($app.AppId)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Ensure service principal exists
|
||||
$sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" | Select-Object -First 1
|
||||
if (-not $sp) {
|
||||
Write-Host "Creating service principal..." -ForegroundColor Cyan
|
||||
$sp = New-MgServicePrincipal -AppId $app.AppId
|
||||
}
|
||||
|
||||
# Grant admin consent
|
||||
Write-Host "Granting admin consent..." -ForegroundColor Cyan
|
||||
foreach ($ar in $appRoles) {
|
||||
$existingAssignment = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id | Where-Object { $_.AppRoleId -eq $ar.Id }
|
||||
if (-not $existingAssignment) {
|
||||
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -PrincipalId $sp.Id -ResourceId $graphSp.Id -AppRoleId $ar.Id | Out-Null
|
||||
}
|
||||
}
|
||||
Write-Host "Admin consent granted." -ForegroundColor Green
|
||||
|
||||
# Federated credential for Azure DevOps
|
||||
$federatedCredentialName = "AstralAzureDevOps-$ServiceConnectionName"
|
||||
$existingFedCred = Get-MgApplicationFederatedIdentityCredential -ApplicationId $app.Id | Where-Object { $_.Name -eq $federatedCredentialName }
|
||||
if (-not $existingFedCred) {
|
||||
Write-Host "Creating federated credential for Azure DevOps..." -ForegroundColor Cyan
|
||||
# Subject identifier for Azure DevOps workload identity federation
|
||||
# Format: sc://<ado-org>/<project>/<service-connection-name>
|
||||
# We require the user to fill in org/project manually or via parameters.
|
||||
$adoOrg = Read-Host "Enter your Azure DevOps organization name (e.g. 'contoso')"
|
||||
$adoProject = Read-Host "Enter your Azure DevOps project name (e.g. 'ASTRAL')"
|
||||
$subject = "sc://$adoOrg/$adoProject/$ServiceConnectionName"
|
||||
|
||||
$params = @{
|
||||
Name = $federatedCredentialName
|
||||
Issuer = "https://vstoken.dev.azure.com"
|
||||
Subject = $subject
|
||||
Audiences = @("api://AzureADTokenExchange")
|
||||
}
|
||||
New-MgApplicationFederatedIdentityCredential -ApplicationId $app.Id -BodyParameter $params | Out-Null
|
||||
Write-Host "Federated credential created. Subject: $subject" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "Federated credential already exists." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Bootstrap complete ===" -ForegroundColor Green
|
||||
Write-Host "Tenant Name: $TenantName"
|
||||
Write-Host "Tenant ID: $($tenant.Id)"
|
||||
Write-Host "App Display Name: $AppDisplayName"
|
||||
Write-Host "App ID: $($app.AppId)"
|
||||
Write-Host "Service Connection: $ServiceConnectionName"
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:" -ForegroundColor Cyan
|
||||
Write-Host "1. In Azure DevOps, create a Workload Identity Federation service connection."
|
||||
Write-Host " - Tenant ID: $($tenant.Id)"
|
||||
Write-Host " - App ID: $($app.AppId)"
|
||||
Write-Host " - Name: $ServiceConnectionName"
|
||||
Write-Host ""
|
||||
|
||||
if ($AdoOrganizationUrl) {
|
||||
$project = if ($AdoOrganizationUrl -match "/([^/]+)$") { $matches[1] } else { "YOUR_PROJECT" }
|
||||
$pat = Read-Host "Enter an Azure DevOps PAT with 'ServiceConnections: Read & manage' scope (input is hidden)" -AsSecureString
|
||||
$patPlain = [System.Net.NetworkCredential]::new("", $pat).Password
|
||||
Write-Host ""
|
||||
Write-Host "You can create the service connection via REST API using:"
|
||||
Write-Host " curl -u :$patPlain -X POST -H 'Content-Type: application/json' "
|
||||
Write-Host " -d '{ ... }' "
|
||||
Write-Host " '$AdoOrganizationUrl/_apis/serviceendpoint/endpoints?api-version=7.1'"
|
||||
}
|
||||
|
||||
Disconnect-MgGraph | Out-Null
|
||||
150
deploy/onboarding-runbook.md
Normal file
150
deploy/onboarding-runbook.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# ASTRAL Onboarding Runbook
|
||||
|
||||
This guide walks through deploying ASTRAL into a new Azure DevOps organization and Microsoft 365 tenant.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Azure DevOps organization and project created.
|
||||
- Owner or Contributor access to the target Microsoft 365 tenant.
|
||||
- Permission to create app registrations and grant admin consent in Entra ID.
|
||||
- PowerShell 7+ or Windows PowerShell 5.1 with the `Microsoft.Graph` module (for the bootstrap script).
|
||||
|
||||
## Step 1: Import the repository
|
||||
|
||||
1. In Azure DevOps, create a new Git repository in your project.
|
||||
2. Push the contents of this repository into it, or use **Import repository** from a public Git URL.
|
||||
|
||||
## Step 2: Create the tenant variable group
|
||||
|
||||
1. In Azure DevOps, go to **Pipelines > Library** and create a new Variable Group.
|
||||
2. Recommended name: `vg-astral-tenant` (you can choose any name).
|
||||
3. Add the variables from `templates/variables-tenant.yml`. Use your real tenant values:
|
||||
|
||||
| Variable | Example value | Notes |
|
||||
| --- | --- | --- |
|
||||
| `TENANT_NAME` | `contoso.onmicrosoft.com` | Your M365 tenant domain |
|
||||
| `SERVICE_CONNECTION_NAME` | `sc-astral-backup` | Name you will use for the service connection |
|
||||
| `USER_NAME` | `ASTRAL Backup Service` | Git committer name |
|
||||
| `USER_EMAIL` | `astral-backup@contoso.com` | Git committer email |
|
||||
| `AGENT_POOL_NAME` | `Azure Pipelines` | Change if using a self-hosted pool |
|
||||
| `BACKUP_TIMEZONE` | `Europe/Prague` | Valid tz database name |
|
||||
| `FULL_RUN_HOUR` | `00` | Hour that triggers full export |
|
||||
| `AUTO_REMEDIATE_RESTORE_PIPELINE_ID` | *(leave empty)* | Filled in Step 8 |
|
||||
|
||||
4. If you plan to use Azure OpenAI summaries, also add:
|
||||
- `ENABLE_PR_AI_SUMMARY` = `true`
|
||||
- `AZURE_OPENAI_ENDPOINT`
|
||||
- `AZURE_OPENAI_DEPLOYMENT`
|
||||
- `AZURE_OPENAI_API_KEY` *(mark as secret)*
|
||||
|
||||
## Step 3: Link the variable group to the pipelines
|
||||
|
||||
Open each pipeline YAML and uncomment the variable group line near the top:
|
||||
|
||||
```yaml
|
||||
variables:
|
||||
- group: vg-astral-tenant # <-- uncomment this line
|
||||
- template: templates/variables-common.yml
|
||||
```
|
||||
|
||||
Do this for:
|
||||
- `azure-pipelines.yml`
|
||||
- `azure-pipelines-review-sync.yml`
|
||||
- `azure-pipelines-restore.yml`
|
||||
|
||||
Commit and push the changes.
|
||||
|
||||
## Step 4: Run the tenant bootstrap script
|
||||
|
||||
Run `deploy/bootstrap-tenant.ps1` in a PowerShell session authenticated to your target tenant.
|
||||
|
||||
```powershell
|
||||
# Example
|
||||
.\deploy\bootstrap-tenant.ps1 -TenantName "contoso.onmicrosoft.com" -ServiceConnectionName "sc-astral-backup"
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Create a single-tenant app registration.
|
||||
2. Add required Microsoft Graph application permissions.
|
||||
3. Grant admin consent.
|
||||
4. Create a workload federated credential for Azure DevOps.
|
||||
5. Print the App ID and instructions for creating the Azure DevOps service connection.
|
||||
|
||||
## Step 5: Create the Azure DevOps service connection
|
||||
|
||||
1. In Azure DevOps, go to **Project settings > Service connections**.
|
||||
2. Click **New service connection > Azure Resource Manager > Workload identity federation (manual)**.
|
||||
3. Fill in:
|
||||
- **Subscription**: leave blank or select if you also want ARM access (not required).
|
||||
- **Tenant ID**: your Microsoft 365 tenant ID.
|
||||
- **Service Connection Name**: the same value you set in `SERVICE_CONNECTION_NAME` (e.g. `sc-astral-backup`).
|
||||
- **App ID**: from the bootstrap script output.
|
||||
4. Save the service connection.
|
||||
|
||||
## Step 6: Import the pipelines
|
||||
|
||||
1. Go to **Pipelines > Create pipeline > Azure Repos Git**.
|
||||
2. Select your repository.
|
||||
3. Choose **Existing Azure Pipelines YAML file**.
|
||||
4. Import each of the three YAMLs one by one:
|
||||
- `azure-pipelines.yml` (main backup)
|
||||
- `azure-pipelines-review-sync.yml` (review sync)
|
||||
- `azure-pipelines-restore.yml` (restore)
|
||||
|
||||
## Step 7: Grant repository permissions to the build identity
|
||||
|
||||
1. Go to **Project settings > Repositories**.
|
||||
2. Select your repository.
|
||||
3. Under **Security**, grant the **Build Service** account:
|
||||
- Contribute
|
||||
- Create branch
|
||||
- Force push
|
||||
- Create pull request
|
||||
- Edit pull request
|
||||
- Tag creation (if you enable tagging)
|
||||
|
||||
4. Under **Pipelines**, grant the build service **Queue builds** permission on `azure-pipelines-restore.yml` if you plan to use auto-remediation.
|
||||
|
||||
## Step 8: Set the restore pipeline definition ID
|
||||
|
||||
After importing `azure-pipelines-restore.yml`, find its definition ID:
|
||||
|
||||
1. Open the restore pipeline in Azure DevOps.
|
||||
2. The URL contains `definitionId=XX`. Note the number.
|
||||
3. Go back to your variable group (`vg-astral-tenant`) and set:
|
||||
- `AUTO_REMEDIATE_RESTORE_PIPELINE_ID` = `XX`
|
||||
|
||||
## Step 9: Validate the deployment
|
||||
|
||||
1. Import `deploy/validate-deployment.yml` as a one-time pipeline.
|
||||
2. Run it.
|
||||
3. Verify that all checks pass:
|
||||
- Graph token acquisition
|
||||
- Required roles present
|
||||
- Test read from Graph
|
||||
- Test PR creation and abandonment
|
||||
|
||||
## Step 10: Run the first backup
|
||||
|
||||
1. Queue a manual run of `azure-pipelines.yml`.
|
||||
2. Set `forceFullRun=true` to get a complete initial snapshot.
|
||||
3. Verify that `tenant-state/` is populated and a rolling PR is created.
|
||||
|
||||
## Optional: progressive feature rollout
|
||||
|
||||
| Phase | What to enable |
|
||||
| --- | --- |
|
||||
| Backup-only | `ENABLE_PR_REVIEW_SUMMARY=false`, `ENABLE_PR_REVIEWER_DECISIONS=false`, `AUTO_REMEDIATE_AFTER_MERGE=false` |
|
||||
| Review package | `ENABLE_PR_REVIEW_SUMMARY=true`, `ENABLE_PR_REVIEWER_DECISIONS=true` |
|
||||
| Full package | Also enable restore and set `AUTO_REMEDIATE_AFTER_MERGE=true` if desired |
|
||||
| AI summaries | `ENABLE_PR_AI_SUMMARY=true` plus Azure OpenAI variables |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| Pipeline fails at "Get Graph Token" | Wrong service connection name or missing federated credential | Verify `SERVICE_CONNECTION_NAME` matches the service connection exactly |
|
||||
| "Missing required Graph roles" | Admin consent not granted | Run bootstrap script again or grant consent manually in Entra ID |
|
||||
| Rolling PR not created | Build identity lacks PR permissions | Add **Create pull request** and **Edit pull request** permissions |
|
||||
| Restore pipeline queue fails | `AUTO_REMEDIATE_RESTORE_PIPELINE_ID` wrong or missing queue permission | Verify the ID and grant **Queue builds** on the restore pipeline |
|
||||
| Empty `tenant-state/` after run | First run may have no data if Graph returns nothing; also check `BACKUP_FOLDER` path | Verify Graph permissions and re-run |
|
||||
118
deploy/publish-public.yml
Normal file
118
deploy/publish-public.yml
Normal file
@@ -0,0 +1,118 @@
|
||||
trigger: none
|
||||
pr: none
|
||||
|
||||
# Publisher pipeline: pushes a sanitized snapshot of the dev repo to the public template repo.
|
||||
#
|
||||
# Usage:
|
||||
# Queue this pipeline manually and optionally provide a tag name (e.g. v1.1.0).
|
||||
#
|
||||
# Prerequisites:
|
||||
# - PUBLIC_REPO_URL (pipeline variable)
|
||||
# - PUBLIC_REPO_PAT (secret pipeline variable)
|
||||
|
||||
parameters:
|
||||
- name: tagName
|
||||
displayName: Optional release tag (e.g. v1.1.0)
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
variables:
|
||||
- template: ../templates/variables-common.yml
|
||||
|
||||
jobs:
|
||||
- job: publish_public_template
|
||||
displayName: Publish sanitized snapshot to public repo
|
||||
pool:
|
||||
name: $(AGENT_POOL_NAME)
|
||||
steps:
|
||||
- checkout: self
|
||||
persistCredentials: true
|
||||
|
||||
- task: Bash@3
|
||||
displayName: Run sync-to-public
|
||||
inputs:
|
||||
targetType: inline
|
||||
script: |
|
||||
set -euo pipefail
|
||||
chmod +x "$(Build.SourcesDirectory)/deploy/sync-to-public.sh"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
# Run the sync script; it clones the public repo into a temp subdir
|
||||
"$(Build.SourcesDirectory)/deploy/sync-to-public.sh" \
|
||||
"$(PUBLIC_REPO_URL)" \
|
||||
"${{ parameters.tagName }}"
|
||||
|
||||
# The script prints the clone path in its output. Extract the last temp dir it used.
|
||||
PUBLIC_CLONE="$TMP_DIR/public"
|
||||
mkdir -p "$PUBLIC_CLONE"
|
||||
|
||||
# Re-run the sync into our controlled temp dir to guarantee the path
|
||||
cd "$(Build.SourcesDirectory)"
|
||||
rsync -a \
|
||||
--exclude='.git' \
|
||||
--exclude='tenant-state' \
|
||||
--exclude='prod-as-built.md' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='deploy/sync-to-public.sh' \
|
||||
--exclude='deploy/publish-public.yml' \
|
||||
"$(Build.SourcesDirectory)/" "$PUBLIC_CLONE/"
|
||||
|
||||
cd "$PUBLIC_CLONE"
|
||||
|
||||
# Re-create empty tenant-state structure
|
||||
mkdir -p tenant-state/intune tenant-state/entra tenant-state/reports/intune tenant-state/reports/entra
|
||||
touch tenant-state/intune/.gitkeep tenant-state/entra/.gitkeep tenant-state/reports/intune/.gitkeep tenant-state/reports/entra/.gitkeep
|
||||
cat > tenant-state/README.md <<'EOF'
|
||||
# tenant-state
|
||||
|
||||
This directory is populated automatically by the ASTRAL pipeline.
|
||||
Do not place manual files here; they will be overwritten on the next export.
|
||||
EOF
|
||||
|
||||
git init
|
||||
git remote add origin "$(PUBLIC_REPO_URL)" 2>/dev/null || git remote set-url origin "$(PUBLIC_REPO_URL)"
|
||||
|
||||
git config user.email "astral-publish@local"
|
||||
git config user.name "ASTRAL Publisher"
|
||||
|
||||
# Fetch existing public main so we can diff against it
|
||||
git fetch origin main || true
|
||||
|
||||
# Stage everything
|
||||
git add -A
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to publish."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DEV_SHA="$(git -C '$(Build.SourcesDirectory)' rev-parse --short HEAD)"
|
||||
DEV_BRANCH="$(git -C '$(Build.SourcesDirectory)' rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
git commit -m "Sync from dev @ ${DEV_SHA}
|
||||
|
||||
Source: ${DEV_BRANCH} (${DEV_SHA})
|
||||
Excluded: live tenant exports, generated artifacts, and dev-only tooling."
|
||||
|
||||
if [ -n "${{ parameters.tagName }}" ]; then
|
||||
git tag -a "${{ parameters.tagName }}" -m "Release ${{ parameters.tagName }}"
|
||||
fi
|
||||
|
||||
# Push commit (and tag if provided)
|
||||
git push origin HEAD:main --force
|
||||
if [ -n "${{ parameters.tagName }}" ]; then
|
||||
git push origin "${{ parameters.tagName }}"
|
||||
fi
|
||||
|
||||
echo "Publication complete."
|
||||
if [ -n "${{ parameters.tagName }}" ]; then
|
||||
echo "Tag: ${{ parameters.tagName }}"
|
||||
fi
|
||||
env:
|
||||
GIT_ASKPASS: echo
|
||||
GIT_USERNAME: $(PUBLIC_REPO_USERNAME)
|
||||
GIT_PASSWORD: $(PUBLIC_REPO_PAT)
|
||||
120
deploy/validate-deployment.yml
Normal file
120
deploy/validate-deployment.yml
Normal file
@@ -0,0 +1,120 @@
|
||||
trigger: none
|
||||
pr: none
|
||||
|
||||
# One-time validation pipeline for ASTRAL onboarding.
|
||||
# Import this pipeline, run it manually, and verify all checks pass.
|
||||
|
||||
variables:
|
||||
# Uncomment after creating your tenant variable group.
|
||||
# - group: vg-astral-tenant
|
||||
- template: ../templates/variables-common.yml
|
||||
|
||||
jobs:
|
||||
- job: validate_environment
|
||||
displayName: Validate ASTRAL deployment
|
||||
pool:
|
||||
name: $(AGENT_POOL_NAME)
|
||||
steps:
|
||||
- checkout: self
|
||||
persistCredentials: true
|
||||
|
||||
- task: AzurePowerShell@5
|
||||
displayName: Validate Graph token acquisition
|
||||
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: $($sortedRoles -join ', ')"
|
||||
|
||||
$requiredReadRoles = @(
|
||||
"Device.Read.All",
|
||||
"DeviceManagementApps.Read.All",
|
||||
"DeviceManagementConfiguration.Read.All",
|
||||
"DeviceManagementManagedDevices.Read.All",
|
||||
"DeviceManagementRBAC.Read.All",
|
||||
"DeviceManagementScripts.Read.All",
|
||||
"DeviceManagementServiceConfig.Read.All",
|
||||
"Group.Read.All",
|
||||
"Policy.Read.All",
|
||||
"Policy.Read.ConditionalAccess",
|
||||
"Policy.Read.DeviceConfiguration",
|
||||
"User.Read.All",
|
||||
"Application.Read.All"
|
||||
)
|
||||
|
||||
$missing = $requiredReadRoles | Where-Object { $_ -notin $sortedRoles }
|
||||
if ($missing) {
|
||||
throw "Missing required Graph roles: $($missing -join ', ')"
|
||||
}
|
||||
Write-Host "All required read roles are present." -ForegroundColor Green
|
||||
|
||||
# Export token for subsequent steps
|
||||
Write-Host "##vso[task.setvariable variable=GRAPH_TOKEN;issecret=true]$accessToken"
|
||||
|
||||
- task: Bash@3
|
||||
displayName: Validate Graph read access
|
||||
inputs:
|
||||
targetType: inline
|
||||
script: |
|
||||
set -euo pipefail
|
||||
TOKEN="$(GRAPH_TOKEN)"
|
||||
URL="https://graph.microsoft.com/v1.0/organization?$select=id,displayName"
|
||||
RESPONSE=$(curl -sf -H "Authorization: Bearer $TOKEN" "$URL")
|
||||
echo "Graph read test response: $RESPONSE"
|
||||
echo "Graph connectivity confirmed."
|
||||
|
||||
- task: Bash@3
|
||||
displayName: Validate PR creation permission
|
||||
inputs:
|
||||
targetType: inline
|
||||
script: |
|
||||
set -euo pipefail
|
||||
TOKEN="$(System.AccessToken)"
|
||||
COLLECTION_URI="$(System.CollectionUri)"
|
||||
PROJECT="$(System.TeamProject)"
|
||||
REPO_ID="$(Build.Repository.ID)"
|
||||
API="${COLLECTION_URI%/}/${PROJECT}/_apis/git/repositories/${REPO_ID}/pullrequests?api-version=7.1"
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
{
|
||||
"sourceRefName": "refs/heads/main",
|
||||
"targetRefName": "refs/heads/main",
|
||||
"title": "ASTRAL validation test PR",
|
||||
"description": "This is a temporary PR created by the validate-deployment pipeline. It will be abandoned immediately.",
|
||||
"isDraft": true
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "Creating test PR..."
|
||||
PR_RESPONSE=$(curl -sf -u ":$TOKEN" -H "Content-Type: application/json" -d "$BODY" "$API")
|
||||
PR_ID=$(echo "$PR_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['pullRequestId'])")
|
||||
echo "Created test PR #$PR_ID"
|
||||
|
||||
ABANDON_API="${COLLECTION_URI%/}/${PROJECT}/_apis/git/repositories/${REPO_ID}/pullrequests/${PR_ID}?api-version=7.1"
|
||||
echo "Abandoning test PR #$PR_ID..."
|
||||
curl -sf -u ":$TOKEN" -H "Content-Type: application/json" -X PATCH -d '{"status":"abandoned"}' "$ABANDON_API"
|
||||
echo "PR creation and abandonment successful."
|
||||
Reference in New Issue
Block a user