Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5fe69a813 | ||
|
|
2fa9eea274 | ||
|
|
8d0e6dca71 |
@@ -5,14 +5,15 @@ Proof of concept backup dashboard, intended to display backup jobs from multiple
|
||||
```
|
||||
collectors ──POST /api/v1/ingest (API key)──▶ ┌──────────────┐
|
||||
│ FastAPI + │
|
||||
browser ──nginx (basic auth, TLS)──▶ /api/* ─▶│ SQLite │
|
||||
└─ static files: public/ └──────────────┘
|
||||
browser ──nginx (TLS)──▶ /api/* ─────────────▶│ SQLite │
|
||||
│ └─ static files: public/ └──────────────┘
|
||||
└─ auth_request ─▶ oauth2-proxy ─▶ Microsoft Entra ID (Microsoft 365 sign-in)
|
||||
```
|
||||
|
||||
- `public/`: static frontend (no build step). Add `?mock=1` to the URL to use built-in sample data.
|
||||
- `backend/`: FastAPI API backed by a single SQLite file.
|
||||
- `deploy/`: nginx site and systemd unit.
|
||||
- `collectors/`: scripts that report results from backup products ([Veeam B&R](collectors/veeam/README.md), [Azure Backup / MARS](collectors/mars/README.md), [Windows Server Backup](collectors/wsb/README.md), [Veeam Backup for M365](collectors/vb365/README.md)).
|
||||
- `collectors/`: PowerShell scripts that report results from backup products ([Veeam B&R](collectors/veeam/README.md), [Azure Backup / MARS](collectors/mars/README.md), [Windows Server Backup](collectors/wsb/README.md), [Veeam Backup for M365](collectors/vb365/README.md)). Code they share (config, logging, posting, state, installing) is in `collectors/common/BackupDashboard.Collector.psm1`.
|
||||
|
||||
## Data model
|
||||
|
||||
@@ -72,12 +73,58 @@ The OpenAPI schema is at `/api/v1/openapi.json`.
|
||||
|
||||
## Authentication
|
||||
|
||||
- **Dashboard users:** nginx basic auth over HTTPS (`deploy/nginx.conf`). Every user can see every tenant.
|
||||
*Planned:* Microsoft 365 sign-in through [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/)
|
||||
with the Entra ID provider and nginx `auth_request`. That's an nginx-only change. The frontend
|
||||
already tells the user to reload when a session expires (HTTP 401).
|
||||
- **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with
|
||||
[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/), which signs users in with Microsoft
|
||||
Entra ID. Who may sign in is controlled in Entra (users or a group assigned to the app). Every
|
||||
signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent.
|
||||
The dashboard shows who's signed in; **Sign out** also signs out of Microsoft in that browser,
|
||||
otherwise the next visit would sign straight back in.
|
||||
- **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored.
|
||||
To revoke one, run `python manage.py revoke-key NAME`.
|
||||
To revoke one, run `python manage.py revoke-key NAME`. `/api/v1/ingest` is the only path that
|
||||
doesn't need Microsoft sign-in.
|
||||
|
||||
### Microsoft 365 sign-in setup
|
||||
|
||||
**In the Microsoft Entra admin center** (entra.microsoft.com), as an admin of your tenant:
|
||||
|
||||
1. *App registrations → New registration*. Name: `Backup Dashboard`. Supported account types:
|
||||
**Accounts in this organizational directory only** (single tenant). Redirect URI: platform
|
||||
**Web**, `https://<your dashboard host>/oauth2/callback`. Register.
|
||||
2. On its *Overview*, note the **Application (client) ID** and **Directory (tenant) ID**.
|
||||
3. *Certificates & secrets → New client secret*. Copy the secret's **Value** now (it's only shown
|
||||
once). Note its expiry date: sign-in stops working when it expires, so set a reminder to
|
||||
create a new one before then.
|
||||
4. *Enterprise applications → Backup Dashboard → Properties*: set **Assignment required** to
|
||||
**Yes**. Then *Users and groups → Add user/group* and add the people (or a group) who should
|
||||
see the dashboard. Everyone else in the tenant is refused by Microsoft before reaching it.
|
||||
|
||||
No API permissions beyond the default `User.Read` are needed.
|
||||
|
||||
**On the dashboard server:**
|
||||
|
||||
1. Install oauth2-proxy 7.8 or newer from its
|
||||
[releases](https://github.com/oauth2-proxy/oauth2-proxy/releases) as `/usr/local/bin/oauth2-proxy`,
|
||||
and create its user: `sudo useradd --system --no-create-home oauth2-proxy`.
|
||||
2. Install the config and the client secret, readable only by root and oauth2-proxy:
|
||||
```bash
|
||||
sudo install -d -m 750 -g oauth2-proxy /etc/oauth2-proxy
|
||||
sudo install -m 640 -g oauth2-proxy deploy/oauth2-proxy.cfg /etc/oauth2-proxy/backup-dashboard.cfg
|
||||
sudo sh -c 'umask 027; printf %s "<client secret value>" > /etc/oauth2-proxy/client-secret' && sudo chgrp oauth2-proxy /etc/oauth2-proxy/client-secret
|
||||
```
|
||||
In `/etc/oauth2-proxy/backup-dashboard.cfg`, fill in `<TENANT_ID>`, `<APPLICATION_CLIENT_ID>`,
|
||||
`<DASHBOARD_HOST>`, and `<COOKIE_SECRET>`, generated with:
|
||||
`openssl rand -base64 32 | tr -- '+/' '-_'`
|
||||
3. Install `deploy/oauth2-proxy.service` into `/etc/systemd/system/`, then
|
||||
`sudo systemctl daemon-reload && sudo systemctl enable --now oauth2-proxy`.
|
||||
Check it with `journalctl -u oauth2-proxy -n 20`: it should say "OAuthProxy configured for
|
||||
Microsoft Entra ID".
|
||||
4. Install the new `deploy/nginx.conf` (keeping your server name and certificate paths), then
|
||||
`sudo nginx -t && sudo systemctl reload nginx`. Basic auth and its htpasswd file are no longer
|
||||
used.
|
||||
|
||||
**Troubleshooting:** sign-in problems show in `journalctl -u oauth2-proxy`. Microsoft's own
|
||||
errors (wrong redirect URI, user not assigned, expired secret) appear on its sign-in page with an
|
||||
`AADSTS…` code.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -91,12 +138,18 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload
|
||||
# → http://localhost:8000
|
||||
```
|
||||
|
||||
Collector tests (PowerShell 7 + Pester; see [collectors/tests](collectors/tests/README.md)):
|
||||
|
||||
```powershell
|
||||
./collectors/tests/Invoke-CollectorTests.ps1
|
||||
```
|
||||
|
||||
## Deployment (Linux, nginx)
|
||||
|
||||
1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`.
|
||||
2. Copy `deploy/backup-dashboard.env` to `/etc/backup-dashboard/backup-dashboard.env`. Both the service and `manage.py` read their settings from it.
|
||||
3. Create a `backupdash` system user, then install `deploy/backup-dashboard-api.service` into `/etc/systemd/system/` and enable it. The database is created on first start.
|
||||
4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and create users with `htpasswd -B`.
|
||||
4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and set up Microsoft 365 sign-in (see [Microsoft 365 sign-in setup](#microsoft-365-sign-in-setup)).
|
||||
5. Run `manage.py` as the service user. It finds the database through the env file:
|
||||
```bash
|
||||
cd /opt/backup-dashboard/backend
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"""Collector (machine) authentication.
|
||||
|
||||
Dashboard *users* are authenticated by nginx in front of the app (basic auth today,
|
||||
oauth2-proxy + Microsoft Entra ID later), so nothing here deals with people.
|
||||
Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via
|
||||
oauth2-proxy), so nothing here deals with people.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
@@ -25,6 +25,16 @@ def health(conn: sqlite3.Connection = Depends(db.get_db)):
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(request: Request):
|
||||
# Set by nginx from oauth2-proxy's sign-in check (nginx overwrites anything the browser sends).
|
||||
# Display only: access control happens in nginx. Empty in dev or without Microsoft sign-in.
|
||||
return {
|
||||
"email": request.headers.get("x-forwarded-email") or None,
|
||||
"name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)):
|
||||
return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)}
|
||||
|
||||
@@ -122,3 +122,9 @@ def test_tenant_name_is_case_insensitive(client):
|
||||
body["tenant"] = "ACME CORP"
|
||||
client.post("/api/v1/ingest", json=body)
|
||||
assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1
|
||||
|
||||
|
||||
def test_me_reports_signed_in_user_from_proxy_headers(client):
|
||||
assert client.get("/api/v1/me").json() == {"email": None, "name": None}
|
||||
r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]", "X-Forwarded-User": "abc-123"})
|
||||
assert r.json() == {"email": "[email protected]", "name": "abc-123"}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<#
|
||||
Shared code for the Backup Dashboard collectors: config and state files, logging, posting to
|
||||
the ingest API, and installing a collector as a scheduled task.
|
||||
|
||||
Each collector imports this from next to itself (installed: C:\Program Files\BackupDashboard)
|
||||
or from ..\common (in the repo). Collectors on the same server share one installed copy, so
|
||||
keep these functions backward compatible.
|
||||
|
||||
Works on Windows PowerShell 5.1 and PowerShell 7.
|
||||
#>
|
||||
|
||||
$script:Config = $null
|
||||
$script:DryRun = $false
|
||||
$script:LogPath = $null
|
||||
$script:StatePath = $null
|
||||
$script:LookbackHours = 48
|
||||
$script:Sent = @{}
|
||||
$script:RunsSent = 0
|
||||
$script:MaxLogBytes = 5MB
|
||||
$script:Invariant = [Globalization.CultureInfo]::InvariantCulture
|
||||
|
||||
# ---------- Logging and values ----------
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = '{0} [{1}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Write-Host $line
|
||||
if ($script:DryRun -or -not $script:LogPath) { return }
|
||||
if ((Test-Path $script:LogPath) -and (Get-Item $script:LogPath).Length -gt $script:MaxLogBytes) {
|
||||
Move-Item $script:LogPath "$($script:LogPath).1" -Force
|
||||
}
|
||||
Add-Content -Path $script:LogPath -Value $line -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-ErrorText($ErrorRecord) {
|
||||
# Windows PowerShell puts the HTTP response body (e.g. the API's 422 explanation) here
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
return "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)"
|
||||
}
|
||||
return $ErrorRecord.Exception.Message
|
||||
}
|
||||
|
||||
# The API needs an explicit UTC offset. DateTimeOffset keeps a Local/Unspecified time as local and a
|
||||
# Utc time as +00:00, so convert UTC-valued times to Kind Utc before calling this.
|
||||
function ConvertTo-IsoTime($Value) {
|
||||
if ($null -eq $Value) { return $null }
|
||||
$dt = [datetime]$Value
|
||||
if ($dt.Year -le 1900) { return $null } # "not set" (DateTime.MinValue, MARS's 1601-01-01, ...)
|
||||
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $script:Invariant)
|
||||
}
|
||||
|
||||
# First property of $Object, out of $Names, that exists and isn't empty. Backup products' object
|
||||
# models aren't fully documented, so reads go through this rather than assuming one spelling.
|
||||
function Get-FirstValue($Object, [string[]]$Names) {
|
||||
if ($null -eq $Object) { return $null }
|
||||
foreach ($name in $Names) {
|
||||
try {
|
||||
$prop = $Object.PSObject.Properties[$name]
|
||||
if (-not $prop) { continue }
|
||||
$value = $prop.Value
|
||||
# Check emptiness by type: casting to [string] would make a one-item list of objects ''
|
||||
if ($null -eq $value) { continue }
|
||||
if ($value -is [string] -and $value -eq '') { continue }
|
||||
if ($value -is [Collections.ICollection] -and $value.Count -eq 0) { continue }
|
||||
return $value
|
||||
} catch { }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# ---------- Collector run ----------
|
||||
|
||||
<#
|
||||
Loads the config ($ConfigPath, JSON) and the record of runs already sent, and sets up logging
|
||||
and TLS. Log and state files sit next to the config: <Name>-collector.log / .state.json.
|
||||
Returns the config object; its LookbackHours is resolved (the parameter, else the config, else
|
||||
48). Throws if the config can't be read or lacks one of $RequiredKeys.
|
||||
#>
|
||||
function Initialize-Collector {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$ConfigPath,
|
||||
[int]$LookbackHours = 0,
|
||||
[switch]$DryRun,
|
||||
[string[]]$RequiredKeys = @('ApiUrl', 'ApiKey', 'Tenant')
|
||||
)
|
||||
# Logging first, so a bad config can still be logged
|
||||
$dataDir = Split-Path -Parent $ConfigPath
|
||||
$script:LogPath = Join-Path $dataDir "$Name-collector.log"
|
||||
$script:StatePath = Join-Path $dataDir "$Name-collector.state.json"
|
||||
$script:DryRun = [bool]$DryRun
|
||||
$script:RunsSent = 0
|
||||
$script:Sent = @{}
|
||||
|
||||
$config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
|
||||
foreach ($key in $RequiredKeys) {
|
||||
if (-not $config.$key) { throw "Config $ConfigPath is missing '$key'" }
|
||||
}
|
||||
if ($LookbackHours -le 0) {
|
||||
$LookbackHours = if ($config.LookbackHours) { [int]$config.LookbackHours } else { 48 }
|
||||
}
|
||||
$config | Add-Member -NotePropertyName LookbackHours -NotePropertyValue $LookbackHours -Force
|
||||
$script:LookbackHours = $LookbackHours
|
||||
$script:Config = $config
|
||||
|
||||
# Windows PowerShell 5.1 may default to TLS 1.0
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
# Run key -> start time (Unix seconds), for runs already delivered in their final state.
|
||||
# Numbers rather than ISO strings: PowerShell 7's ConvertFrom-Json turns date strings into
|
||||
# DateTimes and loses the offset.
|
||||
if (Test-Path $script:StatePath) {
|
||||
$saved = Get-Content -Path $script:StatePath -Raw | ConvertFrom-Json
|
||||
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $script:Sent[$p.Name] = [long]$p.Value } }
|
||||
}
|
||||
return $config
|
||||
}
|
||||
|
||||
function Test-RunSent([string]$Key) {
|
||||
return $script:Sent.ContainsKey($Key)
|
||||
}
|
||||
|
||||
<#
|
||||
Posts one report to the ingest API: the job alone (to refresh next_run_at) or the job with a
|
||||
run. A run that has finished is then recorded as sent under $SentKey, so later collector runs
|
||||
skip it; a running one isn't, so it's re-sent until it finishes. Throws on an HTTP error.
|
||||
#>
|
||||
function Send-JobReport {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Tenant,
|
||||
[Parameter(Mandatory)][string]$SourceSystem,
|
||||
[Parameter(Mandatory)]$Job,
|
||||
$Run,
|
||||
[string]$SentKey,
|
||||
$StartedAt
|
||||
)
|
||||
$body = [ordered]@{ tenant = $Tenant; source_system = $SourceSystem; job = $Job }
|
||||
if ($Run) { $body.run = $Run }
|
||||
$json = $body | ConvertTo-Json -Depth 6 -Compress
|
||||
if ($script:DryRun) {
|
||||
Write-Host $json
|
||||
} else {
|
||||
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1,
|
||||
# mangling non-ASCII job names (e.g. en dashes)
|
||||
$params = @{
|
||||
Uri = "$($script:Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
|
||||
Method = 'Post'
|
||||
Headers = @{ Authorization = "Bearer $($script:Config.ApiKey)" }
|
||||
ContentType = 'application/json; charset=utf-8'
|
||||
Body = [Text.Encoding]::UTF8.GetBytes($json)
|
||||
TimeoutSec = 30
|
||||
}
|
||||
Invoke-RestMethod @params | Out-Null
|
||||
}
|
||||
if ($Run) {
|
||||
$script:RunsSent++
|
||||
if ($Run.status -ne 'running' -and $SentKey) {
|
||||
$script:Sent[$SentKey] = ([DateTimeOffset][datetime]$StartedAt).ToUnixTimeSeconds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# True when a failed Send-JobReport was the API rejecting the key: no point trying more jobs
|
||||
function Test-AuthRejected($ErrorRecord) {
|
||||
$status = 0
|
||||
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { }
|
||||
return $status -eq 401
|
||||
}
|
||||
|
||||
<#
|
||||
Saves the sent-runs record (dropping runs older than the lookback window plus a day) and logs
|
||||
the summary line. Doesn't exit: the collector does that with the returned exit code.
|
||||
#>
|
||||
function Complete-Collector {
|
||||
param([int]$JobCount = -1, [int]$Failures = 0)
|
||||
if (-not $script:DryRun) {
|
||||
$cutoff = [DateTimeOffset]::Now.AddHours(-$script:LookbackHours - 24).ToUnixTimeSeconds()
|
||||
$keep = [ordered]@{}
|
||||
foreach ($key in $script:Sent.Keys) {
|
||||
if ($script:Sent[$key] -ge $cutoff) { $keep[$key] = $script:Sent[$key] }
|
||||
}
|
||||
try {
|
||||
$keep | ConvertTo-Json -Compress | Set-Content -Path $script:StatePath -Encoding UTF8
|
||||
} catch {
|
||||
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
|
||||
}
|
||||
}
|
||||
if ($JobCount -ge 0) {
|
||||
Write-Log ("Reported {0} jobs, {1} runs, {2} failures" -f $JobCount, $script:RunsSent, $Failures)
|
||||
} else {
|
||||
Write-Log ("Reported {0} runs, {1} failures" -f $script:RunsSent, $Failures)
|
||||
}
|
||||
if ($Failures) { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------- Installing ----------
|
||||
|
||||
<#
|
||||
The part of every collector installer after its own checks: prompt for the API key, write the
|
||||
config to ProgramData\BackupDashboard (locked to SYSTEM and Administrators, since it holds the
|
||||
key), copy the collector script and this module to $InstallDir (Program Files: only admins can
|
||||
modify what runs as SYSTEM), and register a scheduled task running it as SYSTEM.
|
||||
#>
|
||||
function Install-CollectorTask {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Name, # 'veeam' -> veeam-collector.json / .log
|
||||
[Parameter(Mandatory)][string]$ScriptPath, # the collector script to install
|
||||
[Parameter(Mandatory)][string]$TaskName,
|
||||
[Parameter(Mandatory)][string]$TaskDescription,
|
||||
[Parameter(Mandatory)][string]$Shell, # powershell.exe or pwsh.exe, full path
|
||||
[Parameter(Mandatory)][string]$ApiUrl,
|
||||
[Parameter(Mandatory)][int]$IntervalMinutes,
|
||||
[Parameter(Mandatory)][string]$InstallDir,
|
||||
[System.Collections.IDictionary]$Settings = @{} # extra config: Tenant, LookbackHours, ...
|
||||
)
|
||||
$dataDir = Join-Path $env:ProgramData 'BackupDashboard'
|
||||
$configPath = Join-Path $dataDir "$Name-collector.json"
|
||||
|
||||
if ($ApiUrl -notmatch '^https://') {
|
||||
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
|
||||
}
|
||||
|
||||
# Read the key as a SecureString so it isn't echoed to the console or kept in shell history
|
||||
$secure = Read-Host -AsSecureString 'API key (from: python manage.py create-key <name>)'
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try { $apiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||||
if (-not $apiKey.StartsWith('bdk_')) { throw "That doesn't look like a dashboard API key (expected 'bdk_...')." }
|
||||
|
||||
# Config folder: SYSTEM + Administrators only (SIDs so this works on non-English Windows)
|
||||
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
|
||||
& icacls.exe $dataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
if ($LASTEXITCODE) { throw "icacls failed on $dataDir" }
|
||||
|
||||
$config = [ordered]@{ ApiUrl = $ApiUrl.TrimEnd('/'); ApiKey = $apiKey }
|
||||
foreach ($key in $Settings.Keys) { $config[$key] = $Settings[$key] }
|
||||
$config | ConvertTo-Json -Depth 4 | Set-Content -Path $configPath -Encoding UTF8
|
||||
|
||||
# The collector and this module, side by side (the collector looks for the module next to itself)
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$installedScript = Join-Path $InstallDir (Split-Path -Leaf $ScriptPath)
|
||||
foreach ($file in $ScriptPath, $PSCommandPath) {
|
||||
$target = Join-Path $InstallDir (Split-Path -Leaf $file)
|
||||
Copy-Item -Path $file -Destination $target -Force
|
||||
Unblock-File -Path $target
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $Shell `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$installedScript`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
|
||||
$taskSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
|
||||
-MultipleInstances IgnoreNew -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
|
||||
-Settings $taskSettings -Description $TaskDescription -Force | Out-Null
|
||||
|
||||
Write-Host "Installed. Config: $configPath"
|
||||
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using $Shell."
|
||||
Write-Host ''
|
||||
Write-Host 'Next steps:'
|
||||
Write-Host " 1. Preview what will be sent: & '$Shell' -File '$installedScript' -DryRun"
|
||||
Write-Host " 2. Backfill 30 days of history: & '$Shell' -File '$installedScript' -LookbackHours 720"
|
||||
Write-Host " 3. Check the log: Get-Content '$dataDir\$Name-collector.log' -Tail 20"
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Write-Log, Get-ErrorText, ConvertTo-IsoTime, Get-FirstValue,
|
||||
Initialize-Collector, Test-RunSent, Send-JobReport, Test-AuthRejected, Complete-Collector,
|
||||
Install-CollectorTask
|
||||
@@ -29,9 +29,6 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$TaskName = 'Backup Dashboard - MARS collector'
|
||||
$DataDir = Join-Path $env:ProgramData 'BackupDashboard'
|
||||
$ConfigPath = Join-Path $DataDir 'mars-collector.json'
|
||||
$WindowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
|
||||
# Check the agent is here before changing anything
|
||||
@@ -40,51 +37,19 @@ if (-not (Get-Module -ListAvailable -Name MSOnlineBackup) -and -not (Test-Path $
|
||||
throw 'The MARS agent (MSOnlineBackup PowerShell module) is not installed on this server.'
|
||||
}
|
||||
|
||||
if ($ApiUrl -notmatch '^https://') {
|
||||
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
|
||||
# Shared installer code: in the repo it's in ..\common, so copy the whole collectors folder
|
||||
$sharedModule = @(
|
||||
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
||||
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sharedModule) {
|
||||
throw "BackupDashboard.Collector.psm1 not found. Copy the whole collectors folder, including common."
|
||||
}
|
||||
Import-Module $sharedModule -Force
|
||||
|
||||
# Read the key as a SecureString so it isn't echoed to the console or kept in shell history
|
||||
$secure = Read-Host -AsSecureString 'API key (from: python manage.py create-key <name>)'
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try { $apiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||||
if (-not $apiKey.StartsWith('bdk_')) { throw "That doesn't look like a dashboard API key (expected 'bdk_...')." }
|
||||
|
||||
# Config folder: SYSTEM + Administrators only (SIDs so this works on non-English Windows)
|
||||
New-Item -ItemType Directory -Path $DataDir -Force | Out-Null
|
||||
& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
if ($LASTEXITCODE) { throw "icacls failed on $DataDir" }
|
||||
|
||||
$config = [ordered]@{
|
||||
ApiUrl = $ApiUrl.TrimEnd('/')
|
||||
ApiKey = $apiKey
|
||||
Tenant = $Tenant
|
||||
SourceSystem = $SourceSystem
|
||||
LookbackHours = $LookbackHours
|
||||
}
|
||||
if ($JobName) { $config.JobName = $JobName }
|
||||
$config | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$scriptPath = Join-Path $InstallDir 'Send-MarsBackupStatus.ps1'
|
||||
Copy-Item -Path (Join-Path $PSScriptRoot 'Send-MarsBackupStatus.ps1') -Destination $scriptPath -Force
|
||||
Unblock-File -Path $scriptPath
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $WindowsPowerShell `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
|
||||
-MultipleInstances IgnoreNew -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
|
||||
-Settings $settings -Description 'Sends Azure Backup (MARS) results to the Backup Dashboard' -Force | Out-Null
|
||||
|
||||
Write-Host "Installed. Config: $ConfigPath"
|
||||
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using Windows PowerShell."
|
||||
Write-Host ''
|
||||
Write-Host 'Next steps:'
|
||||
Write-Host " 1. Preview what will be sent: & '$WindowsPowerShell' -File '$scriptPath' -DryRun"
|
||||
Write-Host " 2. Backfill 30 days of history: & '$WindowsPowerShell' -File '$scriptPath' -LookbackHours 720"
|
||||
Write-Host " 3. Check the log: Get-Content '$DataDir\mars-collector.log' -Tail 20"
|
||||
$settings = [ordered]@{ Tenant = $Tenant; SourceSystem = $SourceSystem; LookbackHours = $LookbackHours }
|
||||
if ($JobName) { $settings.JobName = $JobName }
|
||||
Install-CollectorTask -Name 'mars' -ScriptPath (Join-Path $PSScriptRoot 'Send-MarsBackupStatus.ps1') `
|
||||
-TaskName 'Backup Dashboard - MARS collector' `
|
||||
-TaskDescription 'Sends Azure Backup (MARS) results to the Backup Dashboard' `
|
||||
-Shell $WindowsPowerShell -ApiUrl $ApiUrl -IntervalMinutes $IntervalMinutes -InstallDir $InstallDir -Settings $settings
|
||||
|
||||
@@ -18,7 +18,8 @@ targets and which every supported Windows Server has.
|
||||
```bash
|
||||
python manage.py create-key mars-acme-fs01
|
||||
```
|
||||
2. Copy this folder to the server and run:
|
||||
2. Copy the whole `collectors` folder to the server (the installer needs `common`
|
||||
as well as this folder), then from `collectors\mars` run:
|
||||
```powershell
|
||||
.\Install-MarsCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
|
||||
```
|
||||
@@ -33,8 +34,9 @@ targets and which every supported Windows Server has.
|
||||
powershell.exe -File 'C:\Program Files\BackupDashboard\Send-MarsBackupStatus.ps1' -LookbackHours 720
|
||||
```
|
||||
|
||||
It can be installed alongside the Veeam collector: it uses its own config, state, log and task.
|
||||
Re-run the installer to change settings or update the script.
|
||||
It can be installed alongside the other collectors: it uses its own config, state, log and task.
|
||||
Re-run the installer to change settings or update the script. It also installs
|
||||
`BackupDashboard.Collector.psm1`, the code shared by all collectors, next to the script.
|
||||
|
||||
## What it sends
|
||||
|
||||
|
||||
@@ -38,63 +38,22 @@ param(
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$DataDir = Split-Path -Parent $ConfigPath
|
||||
$LogPath = Join-Path $DataDir 'mars-collector.log'
|
||||
$StatePath = Join-Path $DataDir 'mars-collector.state.json'
|
||||
$MaxLogBytes = 5MB
|
||||
# Shared collector code: installed next to this script; in the repo, in ..\common
|
||||
$sharedModule = @(
|
||||
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
||||
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sharedModule) {
|
||||
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
|
||||
exit 2
|
||||
}
|
||||
Import-Module $sharedModule -Force
|
||||
|
||||
# MARS keeps a limited job history; this comfortably covers 30 days at 3 backups a day
|
||||
$MaxPreviousJobs = 200
|
||||
$Invariant = [Globalization.CultureInfo]::InvariantCulture
|
||||
|
||||
# ---------- Helpers ----------
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = '{0} [{1}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Write-Host $line
|
||||
if ($DryRun) { return }
|
||||
if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) {
|
||||
Move-Item $LogPath "$LogPath.1" -Force
|
||||
}
|
||||
Add-Content -Path $LogPath -Value $line -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-ErrorText($ErrorRecord) {
|
||||
# Windows PowerShell puts the HTTP response body (e.g. the API's 422 explanation) here
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
return "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)"
|
||||
}
|
||||
return $ErrorRecord.Exception.Message
|
||||
}
|
||||
|
||||
# The API needs an explicit UTC offset. DateTimeOffset keeps a Local/Unspecified time as local and a
|
||||
# Utc time as +00:00.
|
||||
function ConvertTo-IsoTime($Value) {
|
||||
if ($null -eq $Value) { return $null }
|
||||
$dt = [datetime]$Value
|
||||
if ($dt.Year -le 1900) { return $null } # "not set" (MARS uses 1601-01-01)
|
||||
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
|
||||
}
|
||||
|
||||
# First property of $Object, out of $Names, that exists and isn't empty. MARS object property names
|
||||
# aren't documented, so reads go through this rather than assuming one spelling.
|
||||
function Get-FirstValue($Object, [string[]]$Names) {
|
||||
if ($null -eq $Object) { return $null }
|
||||
foreach ($name in $Names) {
|
||||
try {
|
||||
$prop = $Object.PSObject.Properties[$name]
|
||||
if (-not $prop) { continue }
|
||||
$value = $prop.Value
|
||||
# Check emptiness by type: casting to [string] would make a one-item list of objects ''
|
||||
if ($null -eq $value) { continue }
|
||||
if ($value -is [string] -and $value -eq '') { continue }
|
||||
if ($value -is [Collections.ICollection] -and $value.Count -eq 0) { continue }
|
||||
return $value
|
||||
} catch { }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Import-Mars {
|
||||
if (Get-Command Get-OBJob -ErrorAction SilentlyContinue) { return } # already loaded
|
||||
if (Get-Module -ListAvailable -Name MSOnlineBackup) {
|
||||
@@ -305,49 +264,16 @@ function Test-IsBackupJob($Job) {
|
||||
return (-not $type) -or $type -match 'Backup'
|
||||
}
|
||||
|
||||
function Send-Report($Body) {
|
||||
$json = $Body | ConvertTo-Json -Depth 6 -Compress
|
||||
if ($DryRun) {
|
||||
Write-Host $json
|
||||
return
|
||||
}
|
||||
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1
|
||||
$params = @{
|
||||
Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
|
||||
Method = 'Post'
|
||||
Headers = @{ Authorization = "Bearer $($Config.ApiKey)" }
|
||||
ContentType = 'application/json; charset=utf-8'
|
||||
Body = [Text.Encoding]::UTF8.GetBytes($json)
|
||||
TimeoutSec = 30
|
||||
}
|
||||
Invoke-RestMethod @params | Out-Null
|
||||
}
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
try {
|
||||
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
|
||||
foreach ($key in 'ApiUrl', 'ApiKey', 'Tenant') {
|
||||
if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" }
|
||||
}
|
||||
$Config = Initialize-Collector -Name 'mars' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun
|
||||
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Azure Backup (MARS)' }
|
||||
$JobName = if ($Config.JobName) { [string]$Config.JobName } else { "$env:COMPUTERNAME - Azure Backup" }
|
||||
if ($LookbackHours -le 0) {
|
||||
$LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 }
|
||||
}
|
||||
|
||||
# Windows PowerShell 5.1 may default to TLS 1.0
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$LookbackHours = $Config.LookbackHours
|
||||
|
||||
Import-Mars
|
||||
|
||||
# Job ID -> start time (Unix seconds), for jobs already delivered in their final state
|
||||
$sent = @{}
|
||||
if (Test-Path $StatePath) {
|
||||
$saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
|
||||
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } }
|
||||
}
|
||||
|
||||
$sinceUtc = (Get-Date).ToUniversalTime().AddHours(-$LookbackHours)
|
||||
# Get-OBJob with no arguments lists jobs in progress; -Previous lists finished ones
|
||||
$jobs = @(@(Get-OBJob) + @(Get-OBJob -Previous $MaxPreviousJobs) | Where-Object {
|
||||
@@ -360,7 +286,7 @@ try {
|
||||
$byId[[string]$job.JobId] = $job # a job in both lists: the finished entry (later) wins
|
||||
}
|
||||
$pending = @($byId.Values |
|
||||
Where-Object { -not $sent.ContainsKey([string]$_.JobId) } |
|
||||
Where-Object { -not (Test-RunSent ([string]$_.JobId)) } |
|
||||
Sort-Object { Get-JobTimeUtc $_ 'StartTime' })
|
||||
|
||||
$jobInfo = [ordered]@{
|
||||
@@ -376,38 +302,19 @@ try {
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
$runsSent = 0
|
||||
try {
|
||||
if ($pending.Count -eq 0) {
|
||||
# Keep next_run_at fresh even when nothing new ran
|
||||
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo })
|
||||
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
|
||||
}
|
||||
foreach ($job in $pending) {
|
||||
$run = ConvertTo-Run $job
|
||||
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo; run = $run })
|
||||
$runsSent++
|
||||
if ($run.status -ne 'running') {
|
||||
$sent[$run.external_id] = ([DateTimeOffset](Get-JobTimeUtc $job 'StartTime')).ToUnixTimeSeconds()
|
||||
}
|
||||
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ((Get-JobTimeUtc $job 'StartTime'))
|
||||
}
|
||||
} catch {
|
||||
$failures++
|
||||
Write-Log "Failed to report: $(Get-ErrorText $_)" 'ERROR'
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
# Forget jobs that have aged out of the lookback window (plus a day of slack)
|
||||
$cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds()
|
||||
$keep = [ordered]@{}
|
||||
foreach ($id in $sent.Keys) {
|
||||
if ($sent[$id] -ge $cutoff) { $keep[$id] = $sent[$id] }
|
||||
}
|
||||
try {
|
||||
$keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8
|
||||
} catch {
|
||||
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log ("Reported {0} runs, {1} failures" -f $runsSent, $failures)
|
||||
if ($failures) { exit 1 }
|
||||
$exitCode = Complete-Collector -Failures $failures
|
||||
if ($exitCode) { exit $exitCode }
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
# ./Send-MarsBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
||||
|
||||
# Fixed to the top of the hour, and IDs derived from times, so repeated runs see the same jobs
|
||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
||||
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
|
||||
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
|
||||
$notSet = [datetime]::new(1601, 1, 1)
|
||||
function New-StableGuid([string]$Seed) {
|
||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# The collector installers, with stand-ins for the Windows-only commands they call (icacls, Task
|
||||
# Scheduler, Unblock-File, the API key prompt). Checks what each would write and register.
|
||||
|
||||
BeforeAll {
|
||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||
$collectorsDir = Split-Path -Parent $PSScriptRoot
|
||||
$shell = if ($env:COLLECTOR_TEST_SHELL) { $env:COLLECTOR_TEST_SHELL } else { (Get-Process -Id $PID).Path }
|
||||
$pwshPath = (Get-Process -Id $PID).Path # stands in for an installed PowerShell 7
|
||||
|
||||
# Runs one installer in a fresh process and sandbox; returns what it wrote and registered
|
||||
function Invoke-Installer {
|
||||
param([string]$Collector, [string]$Arguments, [switch]$NoProductModule)
|
||||
$sandbox = New-TestDir 'installer'
|
||||
$src = Join-Path $sandbox 'collectors'
|
||||
Copy-Item -Path $collectorsDir -Destination $src -Recurse
|
||||
$installer = (Get-ChildItem (Join-Path $src $Collector) -Filter 'Install-*.ps1').FullName
|
||||
# The test runner isn't elevated
|
||||
(Get-Content $installer) -notmatch '^#Requires -RunAsAdministrator' | Set-Content $installer
|
||||
foreach ($d in 'ProgramData', 'ProgramFiles', 'SystemRoot') { New-Item -ItemType Directory -Path (Join-Path $sandbox $d) | Out-Null }
|
||||
$record = Join-Path $sandbox 'record.txt'
|
||||
|
||||
$stubs = @'
|
||||
function global:Record($what) { Add-Content $env:REC_FILE $what }
|
||||
function global:Read-Host { param([switch]$AsSecureString, $Prompt) ConvertTo-SecureString 'bdk_testkey' -AsPlainText -Force }
|
||||
function global:icacls.exe { Record "icacls $($args -join ' ')"; $global:LASTEXITCODE = 0 }
|
||||
function global:Unblock-File { param($Path) Record "unblock $(Split-Path -Leaf $Path)" }
|
||||
function global:Get-Module { param([switch]$ListAvailable, $Name) if (-not $env:STUB_NO_MODULE) { [pscustomobject]@{ Name = $Name } } }
|
||||
function global:New-ScheduledTaskAction { param($Execute, $Argument) Record "exe $Execute"; Record "args $Argument" }
|
||||
function global:New-ScheduledTaskTrigger { param([switch]$Once, $At, $RepetitionInterval) Record "every $([int]$RepetitionInterval.TotalMinutes)" }
|
||||
function global:New-ScheduledTaskPrincipal { param($UserId, $LogonType, $RunLevel) Record "principal $UserId $LogonType $RunLevel" }
|
||||
function global:New-ScheduledTaskSettingsSet { param($ExecutionTimeLimit, $MultipleInstances, [switch]$StartWhenAvailable) Record "settings $MultipleInstances" }
|
||||
function global:Register-ScheduledTask { param($TaskName, $Action, $Trigger, $Principal, $Settings, $Description, [switch]$Force) Record "task $TaskName" }
|
||||
'@
|
||||
$env:REC_FILE = $record
|
||||
$env:STUB_NO_MODULE = if ($NoProductModule) { '1' } else { '' }
|
||||
$saved = @{ ProgramData = $env:ProgramData; ProgramFiles = $env:ProgramFiles; SystemRoot = $env:SystemRoot }
|
||||
try {
|
||||
foreach ($d in $saved.Keys) { Set-Item "env:$d" (Join-Path $sandbox $d) }
|
||||
Push-Location (Split-Path $installer)
|
||||
$output = @(& $shell -NoProfile -NonInteractive -Command "$stubs; & '$installer' $Arguments" 2>&1 | ForEach-Object { "$_" })
|
||||
} finally {
|
||||
Pop-Location
|
||||
foreach ($d in $saved.Keys) { Set-Item "env:$d" $saved[$d] }
|
||||
}
|
||||
|
||||
$configFile = Get-ChildItem (Join-Path (Join-Path $sandbox 'ProgramData') 'BackupDashboard') -Filter '*-collector.json' -ErrorAction SilentlyContinue
|
||||
$installDir = Join-Path (Join-Path $sandbox 'ProgramFiles') 'BackupDashboard'
|
||||
return [pscustomobject]@{
|
||||
Output = $output -join "`n"
|
||||
Config = if ($configFile) { Get-Content $configFile.FullName -Raw | ConvertFrom-Json } else { $null }
|
||||
Files = @(Get-ChildItem $installDir -ErrorAction SilentlyContinue | ForEach-Object Name | Sort-Object)
|
||||
Record = @(if (Test-Path $record) { Get-Content $record })
|
||||
Sandbox = $sandbox
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Collector installers' {
|
||||
Context 'Veeam' {
|
||||
BeforeAll { $i = Invoke-Installer veeam "-ApiUrl https://dash.example.com/ -Tenant 'Acme Corp' -PwshPath '$pwshPath'" }
|
||||
|
||||
It 'writes the config, including the API key from the prompt' {
|
||||
$i.Config.ApiUrl | Should -Be 'https://dash.example.com'
|
||||
$i.Config.ApiKey | Should -Be 'bdk_testkey'
|
||||
$i.Config.Tenant | Should -Be 'Acme Corp'
|
||||
$i.Config.SourceSystem | Should -Be 'Veeam B&R'
|
||||
$i.Config.LookbackHours | Should -Be 48
|
||||
}
|
||||
It 'locks the config folder to SYSTEM and Administrators' {
|
||||
$i.Record | Where-Object { $_ -like 'icacls*' } | Should -BeLike '*/inheritance:r /grant:r `*S-1-5-18:(OI)(CI)F `*S-1-5-32-544:(OI)(CI)F'
|
||||
}
|
||||
It 'installs the collector and the shared module side by side, unblocked' {
|
||||
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-VeeamBackupStatus.ps1')
|
||||
$i.Record | Should -Contain 'unblock BackupDashboard.Collector.psm1'
|
||||
$i.Record | Should -Contain 'unblock Send-VeeamBackupStatus.ps1'
|
||||
}
|
||||
It 'registers an hourly SYSTEM task running the collector under PowerShell 7' {
|
||||
$i.Record | Should -Contain "exe $pwshPath"
|
||||
$i.Record | Where-Object { $_ -like 'args*' } | Should -BeLike '*-File "*Send-VeeamBackupStatus.ps1"'
|
||||
$i.Record | Should -Contain 'every 60'
|
||||
$i.Record | Should -Contain 'principal S-1-5-18 ServiceAccount Highest'
|
||||
$i.Record | Should -Contain 'settings IgnoreNew'
|
||||
$i.Record | Should -Contain 'task Backup Dashboard - Veeam collector'
|
||||
}
|
||||
It 'refuses a PowerShell 7 path that does not exist, before changing anything' {
|
||||
$bad = Invoke-Installer veeam "-ApiUrl https://dash.example.com -Tenant Acme -PwshPath '/no/such/pwsh.exe'"
|
||||
$bad.Output | Should -BeLike '*PowerShell 7 not found*'
|
||||
$bad.Config | Should -BeNullOrEmpty
|
||||
$bad.Files | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context 'MARS' {
|
||||
It 'writes JobName, runs hourly under Windows PowerShell, and honours -IntervalMinutes' {
|
||||
$i = Invoke-Installer mars "-ApiUrl https://dash.example.com -Tenant 'Acme Corp' -JobName 'File Server' -IntervalMinutes 30"
|
||||
$i.Config.JobName | Should -Be 'File Server'
|
||||
$i.Config.SourceSystem | Should -Be 'Azure Backup (MARS)'
|
||||
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-MarsBackupStatus.ps1')
|
||||
$i.Record | Where-Object { $_ -like 'exe*' } | Should -BeLike '*WindowsPowerShell*powershell.exe'
|
||||
$i.Record | Should -Contain 'every 30'
|
||||
$i.Record | Should -Contain 'task Backup Dashboard - MARS collector'
|
||||
}
|
||||
It 'refuses to install where the MARS agent is missing, before changing anything' {
|
||||
$i = Invoke-Installer mars "-ApiUrl https://dash.example.com -Tenant Acme" -NoProductModule
|
||||
$i.Output | Should -BeLike '*MARS agent*not installed*'
|
||||
$i.Config | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context 'Windows Server Backup' {
|
||||
It 'warns when the dashboard URL is not HTTPS' {
|
||||
$i = Invoke-Installer wsb "-ApiUrl http://dash.example.com -Tenant 'Acme Corp'"
|
||||
$i.Output | Should -BeLike '*not HTTPS*'
|
||||
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-WsbBackupStatus.ps1')
|
||||
$i.Record | Should -Contain 'task Backup Dashboard - WSB collector'
|
||||
}
|
||||
It 'refuses to install without the Windows Server Backup feature' {
|
||||
$i = Invoke-Installer wsb "-ApiUrl https://dash.example.com -Tenant Acme" -NoProductModule
|
||||
# (PowerShell wraps long error messages, so match a short part)
|
||||
$i.Output | Should -BeLike '*WindowsServerBackup PowerShell module is not available*'
|
||||
$i.Config | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context 'VB365' {
|
||||
It 'writes the organization-to-company map, with no Tenant unless given' {
|
||||
$i = Invoke-Installer vb365 "-ApiUrl https://dash.example.com -OrganizationTenants @{ 'acme.onmicrosoft.com' = 'Acme Corp'; 'blue.onmicrosoft.com' = 'Bluewater' }"
|
||||
$i.Config.OrganizationTenants.'acme.onmicrosoft.com' | Should -Be 'Acme Corp'
|
||||
$i.Config.OrganizationTenants.'blue.onmicrosoft.com' | Should -Be 'Bluewater'
|
||||
$i.Config.PSObject.Properties.Name | Should -Not -Contain 'Tenant'
|
||||
$i.Record | Where-Object { $_ -like 'exe*' } | Should -BeLike '*powershell.exe'
|
||||
}
|
||||
It 'uses PowerShell 7 when given -PwshPath' {
|
||||
$i = Invoke-Installer vb365 "-ApiUrl https://dash.example.com -Tenant 'Solo Co' -PwshPath '$pwshPath'"
|
||||
$i.Config.Tenant | Should -Be 'Solo Co'
|
||||
$i.Record | Should -Contain "exe $pwshPath"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs the collector test suite (Pester): the shared module, each collector against its mock,
|
||||
and the installers.
|
||||
|
||||
.DESCRIPTION
|
||||
Needs PowerShell 7.2+ and Pester 5.5+:
|
||||
Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser
|
||||
|
||||
Each collector runs in its own process, by default under the same PowerShell running the
|
||||
tests. On Windows, use -CollectorShell to run them under Windows PowerShell 5.1 instead, as
|
||||
the MARS, WSB and VB365 servers do.
|
||||
|
||||
Exits non-zero if any test fails, so it can gate a commit or CI job.
|
||||
|
||||
.EXAMPLE
|
||||
./collectors/tests/Invoke-CollectorTests.ps1
|
||||
.EXAMPLE
|
||||
./collectors/tests/Invoke-CollectorTests.ps1 -CollectorShell powershell.exe
|
||||
.EXAMPLE
|
||||
./collectors/tests/Invoke-CollectorTests.ps1 -Path ./collectors/tests/Wsb.Tests.ps1
|
||||
#>
|
||||
#Requires -Version 7.2
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# powershell.exe / pwsh / a full path: the shell the collectors and installers run under
|
||||
[string]$CollectorShell,
|
||||
# Test files or folders to run; defaults to every *.Tests.ps1 here
|
||||
[string[]]$Path = @($PSScriptRoot),
|
||||
[ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')][string]$Output = 'Detailed'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$pester = Get-Module -ListAvailable -Name Pester | Where-Object Version -GE '5.5' |
|
||||
Sort-Object Version -Descending | Select-Object -First 1
|
||||
if (-not $pester) {
|
||||
throw 'Pester 5.5 or newer is needed: Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser'
|
||||
}
|
||||
Import-Module $pester
|
||||
|
||||
if ($CollectorShell) {
|
||||
$resolved = Get-Command $CollectorShell -ErrorAction SilentlyContinue
|
||||
if (-not $resolved) { throw "Shell not found: $CollectorShell" }
|
||||
$env:COLLECTOR_TEST_SHELL = $resolved.Source
|
||||
}
|
||||
$shellName = if ($env:COLLECTOR_TEST_SHELL) { $env:COLLECTOR_TEST_SHELL } else { "this PowerShell ($($PSVersionTable.PSVersion))" }
|
||||
Write-Host "Collectors run under: $shellName"
|
||||
|
||||
# All temp folders for this run, removed at the end
|
||||
$env:COLLECTOR_TEST_TEMP = Join-Path ([IO.Path]::GetTempPath()) ("backup-dashboard-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
try {
|
||||
$config = New-PesterConfiguration
|
||||
$config.Run.Path = $Path
|
||||
$config.Run.PassThru = $true
|
||||
$config.Output.Verbosity = $Output
|
||||
$result = Invoke-Pester -Configuration $config
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force $env:COLLECTOR_TEST_TEMP -ErrorAction SilentlyContinue
|
||||
Remove-Item env:COLLECTOR_TEST_TEMP
|
||||
}
|
||||
exit $(if ($result.Result -eq 'Passed') { 0 } else { 1 })
|
||||
@@ -0,0 +1,95 @@
|
||||
# Azure Backup (MARS agent) collector, against collectors/mars/tests/MockMars.ps1 (shapes from a
|
||||
# real agent, 2.0.9274: job times are UTC values with no UTC marking, ErrorInfo is always present).
|
||||
|
||||
BeforeAll {
|
||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||
$now = Set-MockNow
|
||||
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
|
||||
# MARS reports job times in UTC; the API should get them with +00:00
|
||||
function Utc([datetime]$Local) { ConvertTo-ApiTime $Local.ToUniversalTime() }
|
||||
}
|
||||
|
||||
Describe 'MARS collector' {
|
||||
Context 'what it reports (dry run)' {
|
||||
BeforeAll {
|
||||
$r = Invoke-Collector mars -Config $config -DryRun
|
||||
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
|
||||
$byStart = @{}
|
||||
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
|
||||
}
|
||||
|
||||
It 'runs cleanly' {
|
||||
$r.ExitCode | Should -Be 0
|
||||
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
|
||||
}
|
||||
It 'reports the server as one job, named after it' {
|
||||
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'mars:srv01'
|
||||
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Azure Backup'
|
||||
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Azure Backup (MARS)'
|
||||
}
|
||||
It 'computes the next run from the schedule (daily at 21:00)' {
|
||||
$next = (Get-Date).Date.AddHours(21)
|
||||
if ((Get-Date) -ge $next) { $next = $next.AddDays(1) }
|
||||
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
|
||||
}
|
||||
It 'treats job times as UTC (MARS gives UTC values without marking them)' {
|
||||
$byStart.Keys | Should -Contain (Utc $now.AddHours(-12))
|
||||
$byStart[(Utc $now.AddHours(-12))].finished_at | Should -Be (Utc $now.AddHours(-11.5))
|
||||
}
|
||||
It 'reports a completed backup as success, despite its (always present) ErrorInfo with code 0' {
|
||||
$run = $byStart[(Utc $now.AddHours(-12))]
|
||||
$run.status | Should -Be 'success'
|
||||
$run.message | Should -Be 'Backup completed successfully.'
|
||||
$run.size_bytes | Should -Be 523407872
|
||||
$run.details.datasources | Should -Be @('E:\')
|
||||
}
|
||||
It 'reports a completed backup with failed files as a warning, with the count and log' {
|
||||
$run = $byStart[(Utc $now.AddHours(-36))]
|
||||
$run.status | Should -Be 'warning'
|
||||
$run.message | Should -BeLike '*E:\: 12 file(s) failed to back up*'
|
||||
$run.message | Should -BeLike '*Failed file log: C:\Program Files\Microsoft Azure Recovery Services Agent\Temp\Errors.log*'
|
||||
$run.details.failed_files | Should -Be 12
|
||||
$run.details.failed_bytes | Should -Be 40MB
|
||||
}
|
||||
It 'reports a failed backup with its error code and parameters' {
|
||||
$run = $byStart[(Utc $now.AddHours(-30))]
|
||||
$run.status | Should -Be 'failed'
|
||||
$run.message | Should -BeLike '*Error code 100050 (Volume=E:)*'
|
||||
}
|
||||
It 'reports a backup in progress as running, without a finish time' {
|
||||
$run = $byStart[(Utc $now.AddMinutes(-10))]
|
||||
$run.status | Should -Be 'running'
|
||||
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
|
||||
}
|
||||
It 'ignores restores and backups outside the lookback window' {
|
||||
$runs.Count | Should -Be 4
|
||||
}
|
||||
It 'uses JobName from the config when set' {
|
||||
$named = Invoke-Collector mars -Config ($config + @{ JobName = 'File Server' }) -DryRun
|
||||
$named.Reports.job.name | Select-Object -Unique | Should -Be 'File Server'
|
||||
$named.Reports.job.external_id | Select-Object -Unique | Should -Be 'mars:srv01'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'sending to the API' {
|
||||
BeforeAll {
|
||||
$stub = Start-ApiStub
|
||||
$live = $config.Clone(); $live.ApiUrl = $stub.Url
|
||||
$first = Invoke-Collector mars -Config $live
|
||||
$firstRequests = Get-StubRequests $stub
|
||||
$second = Invoke-Collector mars -Config $live -WorkDir $first.WorkDir
|
||||
$secondRequests = Get-StubRequests $stub
|
||||
}
|
||||
AfterAll { Stop-ApiStub $stub }
|
||||
|
||||
It 'sends every run on the first run' {
|
||||
$first.ExitCode | Should -Be 0
|
||||
$firstRequests.Count | Should -Be 4
|
||||
}
|
||||
It 'on the next run, resends only the backup in progress' {
|
||||
$first.State.Count | Should -Be 3
|
||||
$secondRequests.Count | Should -Be 1
|
||||
$secondRequests[0].Payload.run.status | Should -Be 'running'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
# Tests for collectors/common/BackupDashboard.Collector.psm1, the code all collectors share.
|
||||
|
||||
BeforeAll {
|
||||
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'common/BackupDashboard.Collector.psm1') -Force
|
||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||
|
||||
function New-TempConfig([hashtable]$Values, [hashtable]$State) {
|
||||
$dir = New-TestDir 'module'
|
||||
$path = Join-Path $dir 'test-collector.json'
|
||||
$Values | ConvertTo-Json | Set-Content -Path $path
|
||||
if ($State) { $State | ConvertTo-Json | Set-Content -Path (Join-Path $dir 'test-collector.state.json') }
|
||||
return $path
|
||||
}
|
||||
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
|
||||
}
|
||||
|
||||
Describe 'Get-FirstValue' {
|
||||
It 'returns the first property that has a value' {
|
||||
Get-FirstValue ([pscustomobject]@{ A = $null; B = ''; C = 'yes' }) @('A', 'B', 'C') | Should -Be 'yes'
|
||||
}
|
||||
It 'treats an empty collection as no value' {
|
||||
Get-FirstValue ([pscustomobject]@{ A = @(); B = 'yes' }) @('A', 'B') | Should -Be 'yes'
|
||||
}
|
||||
It 'returns a one-item list of objects (which [string] would turn into an empty string)' {
|
||||
$item = [pscustomobject]@{ Name = 'E:\' }
|
||||
$result = @(Get-FirstValue ([pscustomobject]@{ List = @($item) }) @('List'))
|
||||
$result.Count | Should -Be 1
|
||||
$result[0].Name | Should -Be 'E:\'
|
||||
}
|
||||
It 'returns $null for a missing property or object' {
|
||||
Get-FirstValue ([pscustomobject]@{ A = 1 }) @('Nope') | Should -BeNullOrEmpty
|
||||
Get-FirstValue $null @('A') | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'keeps $false (a real value, not "missing")' {
|
||||
Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled') | Should -BeFalse
|
||||
$null -eq (Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled')) | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'ConvertTo-IsoTime' {
|
||||
It 'gives local times their offset' {
|
||||
$local = [datetime]::new(2026, 10, 8, 21, 0, 0, [DateTimeKind]::Local)
|
||||
ConvertTo-IsoTime $local | Should -Be ([DateTimeOffset]$local).ToString("yyyy-MM-dd'T'HH:mm:sszzz")
|
||||
}
|
||||
It 'gives UTC times +00:00' {
|
||||
ConvertTo-IsoTime ([datetime]::new(2026, 10, 8, 3, 0, 5, [DateTimeKind]::Utc)) | Should -Be '2026-10-08T03:00:05+00:00'
|
||||
}
|
||||
It 'treats "not set" values as no time' {
|
||||
ConvertTo-IsoTime ([datetime]::MinValue) | Should -BeNullOrEmpty
|
||||
ConvertTo-IsoTime ([datetime]::new(1601, 1, 1)) | Should -BeNullOrEmpty # MARS
|
||||
ConvertTo-IsoTime ([datetime]::new(1900, 1, 1)) | Should -BeNullOrEmpty # Veeam
|
||||
ConvertTo-IsoTime $null | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Initialize-Collector' {
|
||||
It 'requires ApiUrl, ApiKey and Tenant by default' {
|
||||
$path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' }
|
||||
{ Initialize-Collector -Name 'test' -ConfigPath $path } | Should -Throw "*missing 'Tenant'*"
|
||||
}
|
||||
It 'accepts a custom set of required keys' {
|
||||
$path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' }
|
||||
{ Initialize-Collector -Name 'test' -ConfigPath $path -RequiredKeys 'ApiUrl', 'ApiKey' } | Should -Not -Throw
|
||||
}
|
||||
It 'resolves LookbackHours: parameter, then config, then 48' {
|
||||
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig $config)).LookbackHours | Should -Be 48
|
||||
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 }))).LookbackHours | Should -Be 720
|
||||
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 })) -LookbackHours 12).LookbackHours | Should -Be 12
|
||||
}
|
||||
It 'loads runs already sent from the state file' {
|
||||
$path = New-TempConfig $config @{ 'run-1' = [DateTimeOffset]::Now.ToUnixTimeSeconds() }
|
||||
$null = Initialize-Collector -Name 'test' -ConfigPath $path
|
||||
Test-RunSent 'run-1' | Should -BeTrue
|
||||
Test-RunSent 'run-2' | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Send-JobReport and Complete-Collector' {
|
||||
BeforeEach {
|
||||
$path = New-TempConfig $config @{
|
||||
'recent' = [DateTimeOffset]::Now.AddHours(-10).ToUnixTimeSeconds()
|
||||
'stale' = [DateTimeOffset]::Now.AddHours(-100).ToUnixTimeSeconds() # > 48h + 1 day ago
|
||||
}
|
||||
$statePath = Join-Path (Split-Path $path) 'test-collector.state.json'
|
||||
$job = [ordered]@{ external_id = 'j1'; name = 'Job'; next_run_at = $null }
|
||||
}
|
||||
It 'records a finished run as sent, but not a running one' {
|
||||
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
|
||||
$null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'done' -StartedAt (Get-Date) 6>&1
|
||||
$null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'running' }) -SentKey 'busy' -StartedAt (Get-Date) 6>&1
|
||||
Test-RunSent 'done' | Should -BeTrue
|
||||
Test-RunSent 'busy' | Should -BeFalse
|
||||
}
|
||||
It 'prints the payload in a dry run, with the job and run' {
|
||||
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
|
||||
$printed = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'x' -StartedAt (Get-Date) 6>&1
|
||||
$payload = "$printed" | ConvertFrom-Json
|
||||
$payload.tenant | Should -Be 'Acme'
|
||||
$payload.source_system | Should -Be 'Test'
|
||||
$payload.job.external_id | Should -Be 'j1'
|
||||
$payload.run.status | Should -Be 'success'
|
||||
}
|
||||
It 'saves the state, dropping runs older than the lookback window plus a day' {
|
||||
$null = Initialize-Collector -Name 'test' -ConfigPath $path
|
||||
$null = Complete-Collector -Failures 0 6>&1
|
||||
$saved = Get-Content $statePath -Raw | ConvertFrom-Json
|
||||
$saved.PSObject.Properties.Name | Should -Be @('recent')
|
||||
}
|
||||
It "doesn't touch the state in a dry run" {
|
||||
$before = Get-Content $statePath -Raw
|
||||
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
|
||||
$null = Complete-Collector -Failures 0 6>&1
|
||||
Get-Content $statePath -Raw | Should -Be $before
|
||||
}
|
||||
It 'returns exit code 1 when anything failed, 0 otherwise' {
|
||||
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
|
||||
Complete-Collector -Failures 0 6>$null | Should -Be 0
|
||||
Complete-Collector -Failures 2 6>$null | Should -Be 1
|
||||
}
|
||||
It 'logs a summary with or without a job count' {
|
||||
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
|
||||
"$(Complete-Collector -JobCount 3 -Failures 0 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 3 jobs, 0 runs, 0 failures$'
|
||||
"$(Complete-Collector -Failures 1 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 0 runs, 1 failures$'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-AuthRejected' {
|
||||
It 'is true only for HTTP 401' {
|
||||
$error401 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 401 } } }
|
||||
$error500 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 500 } } }
|
||||
$noResponse = [pscustomobject]@{ Exception = [pscustomobject]@{ Message = 'timeout' } }
|
||||
Test-AuthRejected $error401 | Should -BeTrue
|
||||
Test-AuthRejected $error500 | Should -BeFalse
|
||||
Test-AuthRejected $noResponse | Should -BeFalse
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Collector tests
|
||||
|
||||
Pester tests for the collectors, the shared module and the installers. Each collector runs against
|
||||
its mock (`collectors/<name>/tests/Mock*.ps1`, built from real servers' diagnostic output) and a
|
||||
stand-in for the ingest API, so no backup product or dashboard is needed.
|
||||
|
||||
```powershell
|
||||
Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser # once
|
||||
./collectors/tests/Invoke-CollectorTests.ps1 # everything, ~15 s
|
||||
./collectors/tests/Invoke-CollectorTests.ps1 -Path ./collectors/tests/Wsb.Tests.ps1
|
||||
```
|
||||
|
||||
Needs PowerShell 7.2+ to run the tests. Collectors run in their own process, under the same
|
||||
PowerShell by default. On Windows, run them under Windows PowerShell 5.1, as the MARS, WSB and
|
||||
VB365 servers do:
|
||||
|
||||
```powershell
|
||||
./collectors/tests/Invoke-CollectorTests.ps1 -CollectorShell powershell.exe
|
||||
```
|
||||
|
||||
The runner exits non-zero if anything fails.
|
||||
|
||||
## What's covered
|
||||
|
||||
| File | Checks |
|
||||
|---|---|
|
||||
| `Module.Tests.ps1` | `BackupDashboard.Collector.psm1`: property lookup, time format, config and lookback, state (recording sent runs, pruning), dry runs, exit codes, 401 detection |
|
||||
| `Veeam.Tests.ps1` | Next run from the live schedule (VBR 13), none for copy/disabled jobs; copy-job worker sessions; statuses and messages; state and resending; rejected key; missing config |
|
||||
| `Mars.Tests.ps1` | UTC job times; ErrorCode 0 isn't an error; failed files → warning; next run from the schedule; JobName; state |
|
||||
| `Wsb.Tests.ps1` | Culture-formatted times (en-US and en-GB); VMs nested in VirtualMachineList; HRESULTs; failure log only when relevant; running → finished merging on start time |
|
||||
| `Vb365.Tests.ps1` | Organization → company mapping; `[Warning]`/`[Failed]` log prefixes; retries; "187.2 MB" sizes; state |
|
||||
| `Installers.Tests.ps1` | Config written (incl. the VB365 map), files copied (collector + module), task registered (shell, interval, SYSTEM), refusals before changing anything |
|
||||
|
||||
Each of these was a real bug or a real-server finding. To check the suite still catches them,
|
||||
put one back (e.g. read `$Job.ScheduleOptions.NextRun` instead of `GetScheduleOptions()` in the
|
||||
Veeam collector) and run the tests: they should fail.
|
||||
|
||||
## Adding a collector
|
||||
|
||||
1. Write `collectors/<name>/tests/Mock<Name>.ps1` from the product's diagnostic output, taking
|
||||
its time from `$env:MOCK_NOW` like the others.
|
||||
2. Add `<name>` to `Invoke-Collector`'s `ValidateSet` in `TestHelpers.psm1`.
|
||||
3. Add `<Name>.Tests.ps1`, one `It` per behaviour confirmed on a real server.
|
||||
@@ -0,0 +1,166 @@
|
||||
<#
|
||||
Helpers for the collector tests: run a collector against its mock in a separate PowerShell
|
||||
process, and stand in for the dashboard's ingest API.
|
||||
|
||||
Each collector run gets its own process so the mocks' global functions and the collector's
|
||||
`exit` don't leak between tests. The shell is the one running the tests, or
|
||||
$env:COLLECTOR_TEST_SHELL (e.g. powershell.exe, to run the collectors under Windows
|
||||
PowerShell 5.1 like most servers do).
|
||||
#>
|
||||
|
||||
$script:CollectorsDir = Split-Path -Parent $PSScriptRoot
|
||||
$script:Invariant = [Globalization.CultureInfo]::InvariantCulture
|
||||
|
||||
# Temp folders for this test run live under one root, which Invoke-CollectorTests.ps1 removes
|
||||
function New-TestDir([string]$Prefix = 'test') {
|
||||
$root = if ($env:COLLECTOR_TEST_TEMP) { $env:COLLECTOR_TEST_TEMP } else { Join-Path ([IO.Path]::GetTempPath()) 'backup-dashboard-tests' }
|
||||
$dir = Join-Path $root ("$Prefix-" + [guid]::NewGuid().ToString('N').Substring(0, 12))
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
return $dir
|
||||
}
|
||||
|
||||
function Get-TestShell {
|
||||
if ($env:COLLECTOR_TEST_SHELL) { return $env:COLLECTOR_TEST_SHELL }
|
||||
return (Get-Process -Id $PID).Path
|
||||
}
|
||||
|
||||
<#
|
||||
Pins the time the mocks are relative to (the top of the current hour) for this test session,
|
||||
so a test can't fail because the hour turned over between the run and the assertions.
|
||||
Returns it as a local DateTime.
|
||||
#>
|
||||
function Set-MockNow {
|
||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
||||
$env:MOCK_NOW = $now.ToString('o', $script:Invariant)
|
||||
return [datetime]$env:MOCK_NOW
|
||||
}
|
||||
|
||||
# The API's time format: "2026-10-08T21:00:00-06:00"
|
||||
function ConvertTo-ApiTime([datetime]$Value) {
|
||||
return ([DateTimeOffset]$Value).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $script:Invariant)
|
||||
}
|
||||
|
||||
# JSON with date-looking strings left as strings (PowerShell 7.5+ would otherwise make them DateTimes)
|
||||
function ConvertFrom-JsonText([string]$Json) {
|
||||
if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) {
|
||||
return $Json | ConvertFrom-Json -DateKind String
|
||||
}
|
||||
return $Json | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function ConvertTo-PSLiteral([string]$Value) { return "'" + $Value.Replace("'", "''") + "'" }
|
||||
|
||||
<#
|
||||
Runs one collector against its mock. Returns:
|
||||
ExitCode the collector's exit code (0 when it ends normally)
|
||||
Reports in -DryRun, the payloads it would have sent
|
||||
Log its log lines (timestamp stripped), from the console
|
||||
State the sent-runs state file, as a hashtable (empty if none)
|
||||
WorkDir pass back in to run again with the same config and state
|
||||
#>
|
||||
function Invoke-Collector {
|
||||
param(
|
||||
[Parameter(Mandatory, Position = 0)][ValidateSet('veeam', 'mars', 'wsb', 'vb365')][string]$Name,
|
||||
[Parameter(Mandatory)][hashtable]$Config,
|
||||
[string]$WorkDir,
|
||||
[switch]$DryRun,
|
||||
[string]$MockArgs = '',
|
||||
[string]$Culture = 'en-US',
|
||||
[string]$ExtraArgs = '',
|
||||
[string]$ComputerName = 'SRV01'
|
||||
)
|
||||
$dir = Join-Path $script:CollectorsDir $Name
|
||||
$script = (Get-ChildItem (Join-Path $dir 'Send-*.ps1')).FullName
|
||||
$mock = (Get-ChildItem (Join-Path (Join-Path $dir 'tests') 'Mock*.ps1')).FullName
|
||||
if (-not $WorkDir) {
|
||||
$WorkDir = New-TestDir $Name
|
||||
}
|
||||
$configPath = Join-Path $WorkDir "$Name-collector.json"
|
||||
$Config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8
|
||||
|
||||
$command = @(
|
||||
"[Globalization.CultureInfo]::CurrentCulture = '$Culture'"
|
||||
". $(ConvertTo-PSLiteral $mock) $MockArgs"
|
||||
"& $(ConvertTo-PSLiteral $script) -ConfigPath $(ConvertTo-PSLiteral $configPath) $(if ($DryRun) { '-DryRun' }) $ExtraArgs"
|
||||
'exit $LASTEXITCODE'
|
||||
) -join '; '
|
||||
|
||||
$savedComputer = $env:COMPUTERNAME
|
||||
$env:COMPUTERNAME = $ComputerName
|
||||
try {
|
||||
$output = @(& (Get-TestShell) -NoProfile -NonInteractive -Command $command 2>&1 | ForEach-Object { "$_" })
|
||||
$exitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$env:COMPUTERNAME = $savedComputer
|
||||
}
|
||||
|
||||
$state = @{}
|
||||
$statePath = Join-Path $WorkDir "$Name-collector.state.json"
|
||||
if (Test-Path $statePath) {
|
||||
$saved = ConvertFrom-JsonText (Get-Content $statePath -Raw)
|
||||
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $state[$p.Name] = $p.Value } }
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $exitCode
|
||||
# Payloads are single-line JSON; a logged API error body spans several lines starting with '{'
|
||||
Reports = @($output | Where-Object { $_ -match '^\{".*\}$' } | ForEach-Object { ConvertFrom-JsonText $_ })
|
||||
Log = @($output | Where-Object { $_ -match '^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \[' } | ForEach-Object { $_.Substring(20) })
|
||||
Output = $output
|
||||
State = $state
|
||||
WorkDir = $WorkDir
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
A stand-in for the ingest API on a free localhost port. Records every request; answers 401
|
||||
for API keys containing "bad", 200 otherwise. Stop it with Stop-ApiStub.
|
||||
#>
|
||||
function Start-ApiStub {
|
||||
$probe = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
$probe.Start(); $port = $probe.LocalEndpoint.Port; $probe.Stop()
|
||||
|
||||
$listener = [Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add("http://localhost:$port/")
|
||||
$listener.Start()
|
||||
$requests = [Collections.Concurrent.ConcurrentQueue[object]]::new()
|
||||
$job = Start-ThreadJob -ArgumentList $listener, $requests -ScriptBlock {
|
||||
param($listener, $requests)
|
||||
while ($listener.IsListening) {
|
||||
try { $context = $listener.GetContext() } catch { break }
|
||||
$body = [IO.StreamReader]::new($context.Request.InputStream, [Text.Encoding]::UTF8).ReadToEnd()
|
||||
$auth = $context.Request.Headers['Authorization']
|
||||
$requests.Enqueue([pscustomobject]@{
|
||||
Path = $context.Request.Url.AbsolutePath; Auth = $auth
|
||||
ContentType = $context.Request.ContentType; Body = $body
|
||||
})
|
||||
$rejected = $auth -match 'bad'
|
||||
$reply = if ($rejected) { '{"detail":"Invalid or revoked API key"}' } else { '{"job_id":1,"run_id":1}' }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($reply)
|
||||
$context.Response.StatusCode = $(if ($rejected) { 401 } else { 200 })
|
||||
$context.Response.ContentType = 'application/json'
|
||||
$context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
$context.Response.Close()
|
||||
}
|
||||
}
|
||||
return [pscustomobject]@{ Url = "http://localhost:$port"; Listener = $listener; Requests = $requests; Job = $job }
|
||||
}
|
||||
|
||||
# Takes the requests recorded since the last call; Body is the parsed JSON payload
|
||||
function Get-StubRequests($Stub) {
|
||||
$taken = @()
|
||||
$item = $null
|
||||
while ($Stub.Requests.TryDequeue([ref]$item)) {
|
||||
$item | Add-Member -NotePropertyName Payload -NotePropertyValue (ConvertFrom-JsonText $item.Body) -Force
|
||||
$taken += $item
|
||||
}
|
||||
return , $taken
|
||||
}
|
||||
|
||||
function Stop-ApiStub($Stub) {
|
||||
$Stub.Listener.Stop()
|
||||
$Stub.Listener.Close()
|
||||
$Stub.Job | Stop-Job -PassThru | Remove-Job -Force
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function New-TestDir, Set-MockNow, ConvertTo-ApiTime, ConvertFrom-JsonText, Invoke-Collector,
|
||||
Start-ApiStub, Get-StubRequests, Stop-ApiStub
|
||||
@@ -0,0 +1,116 @@
|
||||
# Veeam Backup for Microsoft 365 collector, against collectors/vb365/tests/MockVb365.ps1 (shapes from
|
||||
# a VB365 8.4 server: log items carry their status as a Title prefix, retries are separate sessions).
|
||||
|
||||
BeforeAll {
|
||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||
$now = Set-MockNow
|
||||
$base = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' }
|
||||
|
||||
function Get-JobRuns($Reports, [string]$JobName) {
|
||||
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
|
||||
}
|
||||
function Get-Tenants($Reports) {
|
||||
$map = @{}
|
||||
foreach ($report in $Reports) { $map[$report.job.name] = $report.tenant }
|
||||
return $map
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'VB365 collector' {
|
||||
Context 'what it reports (dry run)' {
|
||||
BeforeAll {
|
||||
$r = Invoke-Collector vb365 -Config ($base + @{ Tenant = 'Default Co' }) -DryRun
|
||||
$acme = Get-JobRuns $r.Reports 'Acme 365 Backup'
|
||||
$blue = Get-JobRuns $r.Reports 'Bluewater 365 Backup'
|
||||
}
|
||||
|
||||
It 'runs cleanly, reporting every job' {
|
||||
$r.ExitCode | Should -Be 0
|
||||
$r.Log[-1] | Should -Be '[INFO] Reported 3 jobs, 5 runs, 0 failures'
|
||||
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam Backup for M365'
|
||||
}
|
||||
It "uses each job's NextRun, and none for a disabled job" {
|
||||
$next = $now.Date.AddHours(17).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))
|
||||
($r.Reports | Where-Object { $_.job.name -eq 'Acme 365 Backup' })[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
|
||||
($r.Reports | Where-Object { $_.job.name -eq 'Bluewater Old Mailboxes' })[0].job.next_run_at | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'reports a successful session with its size ("187.2 MB" in bytes) and objects' {
|
||||
$run = $acme | Where-Object status -EQ 'success'
|
||||
$run.started_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(-1).AddHours(17))
|
||||
$run.size_bytes | Should -Be ([long](187.2 * 1MB))
|
||||
$run.details.processed_objects | Should -Be 568
|
||||
$run.details.organization | Should -Be 'acme.onmicrosoft.com'
|
||||
$run.message | Should -Be 'Job completed successfully.'
|
||||
}
|
||||
It 'reports a session in progress as running, without a finish time' {
|
||||
$run = $acme | Where-Object status -EQ 'running'
|
||||
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
|
||||
$run.message | Should -Be ''
|
||||
}
|
||||
It 'builds the message from [Warning]/[Failed] log items, without the prefix' {
|
||||
$run = $blue | Where-Object status -EQ 'warning'
|
||||
$run.message | Should -BeLike 'Processing mailbox [email protected] finished with warning: Item "Q3.pst" exceeds the maximum size*'
|
||||
# Literal check: in -like patterns, [Warning] is a character set
|
||||
$run.message.Contains('[Warning]') | Should -BeFalse
|
||||
}
|
||||
It 'ignores [Success] log items, even ones mentioning "error"' {
|
||||
($blue | Where-Object status -EQ 'warning').message | Should -Not -BeLike '*error-reports*'
|
||||
}
|
||||
It 'flags a session VB365 will retry, and links the retry back to it' {
|
||||
$warning = $blue | Where-Object status -EQ 'warning'
|
||||
$retry = $blue | Where-Object { $_.details.retry_of }
|
||||
$warning.message | Should -BeLike '*Veeam will retry this job.'
|
||||
$warning.details.will_retry | Should -BeTrue
|
||||
$retry.status | Should -Be 'success'
|
||||
$retry.details.retry_of | Should -Be $warning.external_id
|
||||
}
|
||||
It 'reports a failed session with its error' {
|
||||
$run = $blue | Where-Object status -EQ 'failed'
|
||||
$run.message | Should -BeLike '*(401) Unauthorized*'
|
||||
}
|
||||
It 'skips sessions outside the lookback window' {
|
||||
Get-JobRuns $r.Reports 'Bluewater Old Mailboxes' | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context 'which company a job appears under' {
|
||||
It 'uses OrganizationTenants (not case-sensitive), then Tenant for unmapped organizations' {
|
||||
$r = Invoke-Collector vb365 -DryRun -Config ($base + @{
|
||||
Tenant = 'Default Co'; OrganizationTenants = @{ 'ACME.onmicrosoft.com' = 'Acme Corp' } })
|
||||
$tenants = Get-Tenants $r.Reports
|
||||
$tenants['Acme 365 Backup'] | Should -Be 'Acme Corp'
|
||||
$tenants['Bluewater 365 Backup'] | Should -Be 'Default Co'
|
||||
}
|
||||
It "falls back to the organization's name with no mapping and no Tenant" {
|
||||
$r = Invoke-Collector vb365 -Config $base -DryRun
|
||||
$r.ExitCode | Should -Be 0
|
||||
$tenants = Get-Tenants $r.Reports
|
||||
$tenants['Acme 365 Backup'] | Should -Be 'acme.onmicrosoft.com'
|
||||
$tenants['Bluewater 365 Backup'] | Should -Be 'bluewater.onmicrosoft.com'
|
||||
}
|
||||
}
|
||||
|
||||
Context 'sending to the API' {
|
||||
BeforeAll {
|
||||
$stub = Start-ApiStub
|
||||
$live = $base + @{ Tenant = 'Default Co' }; $live.ApiUrl = $stub.Url
|
||||
$first = Invoke-Collector vb365 -Config $live
|
||||
$firstRequests = Get-StubRequests $stub
|
||||
$second = Invoke-Collector vb365 -Config $live -WorkDir $first.WorkDir
|
||||
$secondRequests = Get-StubRequests $stub
|
||||
}
|
||||
AfterAll { Stop-ApiStub $stub }
|
||||
|
||||
It 'sends every session, remembering the finished ones' {
|
||||
$first.ExitCode | Should -Be 0
|
||||
@($firstRequests | Where-Object { $_.Payload.run }).Count | Should -Be 5
|
||||
$first.State.Count | Should -Be 4
|
||||
}
|
||||
It 'on the next run, resends only the running session (and refreshes every job)' {
|
||||
$resent = @($secondRequests | Where-Object { $_.Payload.run })
|
||||
$resent.Count | Should -Be 1
|
||||
$resent[0].Payload.run.status | Should -Be 'running'
|
||||
$secondRequests.Count | Should -Be 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# Veeam B&R collector, against collectors/veeam/tests/MockVeeam.ps1 (shapes from a VBR 13 server).
|
||||
|
||||
BeforeAll {
|
||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||
$now = Set-MockNow
|
||||
$dash = [char]0x2013 # job names use an en dash, as real ones often do
|
||||
$fileServer = "File Server $dash Nightly"
|
||||
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
|
||||
|
||||
function Get-Runs($Reports, [string]$JobName) {
|
||||
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Veeam collector' {
|
||||
Context 'what it reports (dry run)' {
|
||||
BeforeAll {
|
||||
$r = Invoke-Collector veeam -Config $config -DryRun
|
||||
$jobs = @{}
|
||||
foreach ($report in $r.Reports) { $jobs[$report.job.name] = $report.job }
|
||||
}
|
||||
|
||||
It 'runs cleanly' {
|
||||
$r.ExitCode | Should -Be 0
|
||||
$r.Log[-1] | Should -Be '[INFO] Reported 5 jobs, 6 runs, 0 failures'
|
||||
}
|
||||
It 'reports every job, under the configured tenant' {
|
||||
$jobs.Keys | Sort-Object | Should -Be @('Cloud Copy', $fileServer, 'Offsite Copy', 'Old Exchange', "SQL01 $dash Logs")
|
||||
$r.Reports.tenant | Select-Object -Unique | Should -Be 'Acme'
|
||||
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam B&R'
|
||||
}
|
||||
It "reads the next run from the live schedule (VBR 13's ScheduleOptions property is empty)" {
|
||||
$jobs[$fileServer].next_run_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(1).AddMinutes(15))
|
||||
$jobs["SQL01 $dash Logs"].next_run_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(40))
|
||||
}
|
||||
It 'gives no next run to immediate-mode copy jobs (their NextRun is stale), disabled or unscheduled jobs' {
|
||||
$jobs['Cloud Copy'].next_run_at | Should -BeNullOrEmpty
|
||||
$jobs['Old Exchange'].next_run_at | Should -BeNullOrEmpty
|
||||
$jobs['Offsite Copy'].next_run_at | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'reports a successful session with its times and size' {
|
||||
$run = Get-Runs $r.Reports $fileServer | Where-Object status -EQ 'success'
|
||||
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddDays(-1).AddHours(-2))
|
||||
$run.finished_at | Should -Be (ConvertTo-ApiTime $now.AddDays(-1).AddHours(-1))
|
||||
$run.size_bytes | Should -Be 128GB
|
||||
$run.message | Should -Be 'Job completed successfully.'
|
||||
$run.details.objects | Should -Be 2
|
||||
}
|
||||
It 'reports a session in progress as running, without a finish time' {
|
||||
$run = Get-Runs $r.Reports $fileServer | Where-Object status -EQ 'running'
|
||||
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-20))
|
||||
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
|
||||
$run.message | Should -Be ''
|
||||
}
|
||||
It 'names the object behind a warning' {
|
||||
$run = Get-Runs $r.Reports "SQL01 $dash Logs"
|
||||
$run.status | Should -Be 'warning'
|
||||
$run.message | Should -BeLike 'SQL01: Warning - Failed to truncate transaction logs*'
|
||||
$run.details.objects_warning | Should -Be 1
|
||||
}
|
||||
It "includes a failure's reason and failed log lines, but not successful ones" {
|
||||
$run = Get-Runs $r.Reports 'Offsite Copy'
|
||||
$run.status | Should -Be 'failed'
|
||||
$run.message | Should -BeLike '*Unable to connect to repository NAS02*'
|
||||
$run.message | Should -BeLike '*Network path not found (0x80070035)*'
|
||||
$run.message | Should -Not -BeLike '*Preparing backup chain*'
|
||||
}
|
||||
It "files copy jobs' worker sessions under the copy job, naming the source" {
|
||||
$runs = Get-Runs $r.Reports 'Cloud Copy'
|
||||
$runs.status | Sort-Object | Should -Be @('success', 'warning')
|
||||
$runs.details.copied_from | Select-Object -Unique | Should -Be $fileServer
|
||||
}
|
||||
It "skips sessions outside the lookback window and from jobs it doesn't know" {
|
||||
@($r.Reports | Where-Object run).Count | Should -Be 6
|
||||
Get-Runs $r.Reports 'Old Exchange' | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Context 'sending to the API' {
|
||||
BeforeAll {
|
||||
$stub = Start-ApiStub
|
||||
$live = $config.Clone(); $live.ApiUrl = $stub.Url; $live.ApiKey = 'bdk_good'
|
||||
$first = Invoke-Collector veeam -Config $live
|
||||
$firstRequests = Get-StubRequests $stub
|
||||
$second = Invoke-Collector veeam -Config $live -WorkDir $first.WorkDir
|
||||
$secondRequests = Get-StubRequests $stub
|
||||
}
|
||||
AfterAll { Stop-ApiStub $stub }
|
||||
|
||||
It 'posts each report to /api/v1/ingest with the API key, as UTF-8 JSON' {
|
||||
$first.ExitCode | Should -Be 0
|
||||
$firstRequests.Count | Should -Be 7
|
||||
$firstRequests.Path | Select-Object -Unique | Should -Be '/api/v1/ingest'
|
||||
$firstRequests.Auth | Select-Object -Unique | Should -Be 'Bearer bdk_good'
|
||||
$firstRequests.ContentType | Select-Object -Unique | Should -Be 'application/json; charset=utf-8'
|
||||
}
|
||||
It 'keeps non-ASCII job names intact' {
|
||||
$firstRequests.Payload.job.name | Should -Contain $fileServer
|
||||
}
|
||||
It 'remembers finished sessions, but not running ones' {
|
||||
$first.State.Count | Should -Be 5
|
||||
}
|
||||
It 'on the next run, resends only the running session (and refreshes every job)' {
|
||||
$resent = @($secondRequests | Where-Object { $_.Payload.run })
|
||||
$resent.Count | Should -Be 1
|
||||
$resent[0].Payload.run.status | Should -Be 'running'
|
||||
$secondRequests.Count | Should -Be 5
|
||||
}
|
||||
}
|
||||
|
||||
Context 'problems' {
|
||||
It 'stops after the first job when the API key is rejected, exiting 1' {
|
||||
$stub = Start-ApiStub
|
||||
try {
|
||||
$r = Invoke-Collector veeam -Config @{ ApiUrl = $stub.Url; ApiKey = 'bdk_bad'; Tenant = 'Acme' }
|
||||
(Get-StubRequests $stub).Count | Should -Be 1
|
||||
$r.ExitCode | Should -Be 1
|
||||
$r.Log | Should -Contain '[ERROR] API key rejected; check ApiKey in the config. Stopping.'
|
||||
} finally { Stop-ApiStub $stub }
|
||||
}
|
||||
It 'exits 2 when the config lacks a tenant' {
|
||||
$r = Invoke-Collector veeam -Config @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' } -DryRun
|
||||
$r.ExitCode | Should -Be 2
|
||||
$r.Log[0] | Should -BeLike "*is missing 'Tenant'*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# Windows Server Backup collector, against collectors/wsb/tests/MockWsb.ps1 (shapes from a Server
|
||||
# 2019 Hyper-V host: job times are culture-formatted local-time strings, HRESULTs are strings,
|
||||
# VMs are nested under a VirtualMachineList item).
|
||||
|
||||
BeforeAll {
|
||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||
$now = Set-MockNow
|
||||
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
|
||||
$vms = @('AAOG-EDM-AD1', 'Host Component')
|
||||
$hostComponentBytes = 447729
|
||||
}
|
||||
|
||||
Describe 'Windows Server Backup collector' {
|
||||
Context 'what it reports (dry run)' {
|
||||
BeforeAll {
|
||||
$r = Invoke-Collector wsb -Config $config -DryRun
|
||||
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
|
||||
$byStart = @{}
|
||||
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
|
||||
function At([double]$HoursAgo) { $byStart[(ConvertTo-ApiTime $now.AddHours(-$HoursAgo))] }
|
||||
}
|
||||
|
||||
It 'runs cleanly' {
|
||||
$r.ExitCode | Should -Be 0
|
||||
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
|
||||
}
|
||||
It 'reports the server as one job, with the next backup from Get-WBSummary' {
|
||||
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'wsb:srv01'
|
||||
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Windows Server Backup'
|
||||
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Windows Server Backup'
|
||||
$next = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
|
||||
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
|
||||
}
|
||||
It 'reads job times as local time, and sends no run ID (WSB jobs have none; the API keys on start time)' {
|
||||
(At 12).finished_at | Should -Be (ConvertTo-ApiTime $now.AddHours(-11.6))
|
||||
$runs | ForEach-Object { $_.PSObject.Properties.Name | Should -Not -Contain 'external_id' }
|
||||
}
|
||||
It 'reports a Hyper-V backup with its VMs (nested in VirtualMachineList) and size' {
|
||||
$run = At 12
|
||||
$run.status | Should -Be 'success'
|
||||
$run.message | Should -Be 'Backup completed successfully.'
|
||||
$run.details.items | Should -Be $vms
|
||||
$run.size_bytes | Should -Be (124756706036 + $hostComponentBytes)
|
||||
}
|
||||
It "doesn't cite the failure log for a success (WSB fills it in regardless)" {
|
||||
(At 12).message | Should -Not -BeLike '*Failure log*'
|
||||
}
|
||||
It 'reports a backup where one VM failed as a warning, naming the VM' {
|
||||
$run = At 36
|
||||
$run.status | Should -Be 'warning'
|
||||
$run.message | Should -BeLike '*AAOG-EDM-AD1 - Access is denied.*'
|
||||
$run.message | Should -BeLike '*Failure log: C:\Windows\Logs\WindowsServerBackup\Backup_Error.log*'
|
||||
}
|
||||
It 'reports a backup with an error HRESULT as failed, with the code in 0x8 form' {
|
||||
$run = At 30
|
||||
$run.status | Should -Be 'failed'
|
||||
$run.message | Should -BeLike '*The backup storage location could not be found.*'
|
||||
$run.message | Should -BeLike '*error 0x80042302*'
|
||||
}
|
||||
It 'reports a volume backup with its volumes and size' {
|
||||
$run = At 20
|
||||
$run.status | Should -Be 'success'
|
||||
$run.details.items | Should -Be @('C:', 'D:')
|
||||
$run.size_bytes | Should -Be (240GB)
|
||||
}
|
||||
It "ignores restores, old backups, and Get-WBJob's empty job when nothing is running" {
|
||||
$runs.Count | Should -Be 4
|
||||
}
|
||||
It 'reads dd/MM dates correctly on servers with other regional settings' {
|
||||
$gb = Invoke-Collector wsb -Config $config -DryRun -Culture 'en-GB'
|
||||
($gb.Reports | ConvertTo-Json -Depth 6) | Should -Be ($r.Reports | ConvertTo-Json -Depth 6)
|
||||
}
|
||||
It 'reports a backup in progress as running, with no message' {
|
||||
$running = Invoke-Collector wsb -Config $config -DryRun -MockArgs '-WithRunningJob'
|
||||
$run = $running.Reports | Where-Object { $_.run.status -eq 'running' } | ForEach-Object run
|
||||
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-10))
|
||||
$run.message | Should -Be ''
|
||||
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
|
||||
$run.details.items | Should -Be $vms
|
||||
}
|
||||
}
|
||||
|
||||
Context 'sending to the API' {
|
||||
BeforeAll {
|
||||
$stub = Start-ApiStub
|
||||
$live = $config.Clone(); $live.ApiUrl = $stub.Url
|
||||
$first = Invoke-Collector wsb -Config $live -MockArgs '-WithRunningJob'
|
||||
$firstRequests = Get-StubRequests $stub
|
||||
$second = Invoke-Collector wsb -Config $live -WorkDir $first.WorkDir -MockArgs '-RunningJobFinished'
|
||||
$secondRequests = Get-StubRequests $stub
|
||||
}
|
||||
AfterAll { Stop-ApiStub $stub }
|
||||
|
||||
It 'sends every run, remembering the finished ones' {
|
||||
$first.ExitCode | Should -Be 0
|
||||
$firstRequests.Count | Should -Be 5
|
||||
$first.State.Count | Should -Be 4
|
||||
}
|
||||
It 'sends the finished backup once it ends, with the same start time, so the API merges it into the running one' {
|
||||
$running = $firstRequests.Payload.run | Where-Object status -EQ 'running'
|
||||
$secondRequests.Count | Should -Be 1
|
||||
$finished = $secondRequests[0].Payload.run
|
||||
$finished.status | Should -Be 'success'
|
||||
$finished.started_at | Should -Be $running.started_at
|
||||
$second.State.Count | Should -Be 5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,6 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$TaskName = 'Backup Dashboard - VB365 collector'
|
||||
$DataDir = Join-Path $env:ProgramData 'BackupDashboard'
|
||||
$ConfigPath = Join-Path $DataDir 'vb365-collector.json'
|
||||
$Shell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
if ($PwshPath) {
|
||||
if (-not (Test-Path $PwshPath)) { throw "PowerShell 7 not found at '$PwshPath'." }
|
||||
@@ -55,51 +52,23 @@ if (-not $Tenant -and $OrganizationTenants.Count -eq 0) {
|
||||
Write-Warning 'No -Tenant or -OrganizationTenants: jobs will be listed under their Microsoft 365 organization name.'
|
||||
}
|
||||
|
||||
if ($ApiUrl -notmatch '^https://') {
|
||||
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
|
||||
# Shared installer code: in the repo it's in ..\common, so copy the whole collectors folder
|
||||
$sharedModule = @(
|
||||
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
||||
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sharedModule) {
|
||||
throw "BackupDashboard.Collector.psm1 not found. Copy the whole collectors folder, including common."
|
||||
}
|
||||
Import-Module $sharedModule -Force
|
||||
|
||||
# Read the key as a SecureString so it isn't echoed to the console or kept in shell history
|
||||
$secure = Read-Host -AsSecureString 'API key (from: python manage.py create-key <name>)'
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try { $apiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||||
if (-not $apiKey.StartsWith('bdk_')) { throw "That doesn't look like a dashboard API key (expected 'bdk_...')." }
|
||||
|
||||
# Config folder: SYSTEM + Administrators only (SIDs so this works on non-English Windows)
|
||||
New-Item -ItemType Directory -Path $DataDir -Force | Out-Null
|
||||
& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
if ($LASTEXITCODE) { throw "icacls failed on $DataDir" }
|
||||
|
||||
$config = [ordered]@{
|
||||
ApiUrl = $ApiUrl.TrimEnd('/')
|
||||
ApiKey = $apiKey
|
||||
$settings = [ordered]@{
|
||||
SourceSystem = $SourceSystem
|
||||
LookbackHours = $LookbackHours
|
||||
OrganizationTenants = $OrganizationTenants
|
||||
}
|
||||
if ($Tenant) { $config.Tenant = $Tenant }
|
||||
$config | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$scriptPath = Join-Path $InstallDir 'Send-Vb365BackupStatus.ps1'
|
||||
Copy-Item -Path (Join-Path $PSScriptRoot 'Send-Vb365BackupStatus.ps1') -Destination $scriptPath -Force
|
||||
Unblock-File -Path $scriptPath
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $Shell `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
|
||||
-MultipleInstances IgnoreNew -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
|
||||
-Settings $settings -Description 'Sends Veeam Backup for Microsoft 365 results to the Backup Dashboard' -Force | Out-Null
|
||||
|
||||
Write-Host "Installed. Config: $ConfigPath"
|
||||
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using $Shell."
|
||||
Write-Host ''
|
||||
Write-Host 'Next steps:'
|
||||
Write-Host " 1. Preview what will be sent: & '$Shell' -File '$scriptPath' -DryRun"
|
||||
Write-Host " 2. Backfill 30 days of history: & '$Shell' -File '$scriptPath' -LookbackHours 720"
|
||||
Write-Host " 3. Check the log: Get-Content '$DataDir\vb365-collector.log' -Tail 20"
|
||||
if ($Tenant) { $settings.Tenant = $Tenant }
|
||||
Install-CollectorTask -Name 'vb365' -ScriptPath (Join-Path $PSScriptRoot 'Send-Vb365BackupStatus.ps1') `
|
||||
-TaskName 'Backup Dashboard - VB365 collector' `
|
||||
-TaskDescription 'Sends Veeam Backup for Microsoft 365 results to the Backup Dashboard' `
|
||||
-Shell $Shell -ApiUrl $ApiUrl -IntervalMinutes $IntervalMinutes -InstallDir $InstallDir -Settings $settings
|
||||
|
||||
@@ -27,7 +27,8 @@ one with `python manage.py delete-job <id>`.
|
||||
```bash
|
||||
python manage.py create-key vb365-01
|
||||
```
|
||||
2. Copy this folder to the server and run one of:
|
||||
2. Copy the whole `collectors` folder to the server (the installer needs `common`
|
||||
as well as this folder), then from `collectors\vb365` run one of:
|
||||
```powershell
|
||||
# VB365 server for one client
|
||||
.\Install-Vb365Collector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
|
||||
@@ -52,7 +53,8 @@ one with `python manage.py delete-job <id>`.
|
||||
```
|
||||
|
||||
It can be installed alongside the other collectors: it uses its own config, state, log and task.
|
||||
Re-run the installer to change settings (including the organization mapping) or update the script.
|
||||
Re-run the installer to change settings (including the organization mapping) or update the script. It also installs
|
||||
`BackupDashboard.Collector.psm1`, the code shared by all collectors, next to the script.
|
||||
|
||||
## What it sends
|
||||
|
||||
|
||||
@@ -41,61 +41,21 @@ param(
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$DataDir = Split-Path -Parent $ConfigPath
|
||||
$LogPath = Join-Path $DataDir 'vb365-collector.log'
|
||||
$StatePath = Join-Path $DataDir 'vb365-collector.state.json'
|
||||
$MaxLogBytes = 5MB
|
||||
# Shared collector code: installed next to this script; in the repo, in ..\common
|
||||
$sharedModule = @(
|
||||
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
||||
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sharedModule) {
|
||||
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
|
||||
exit 2
|
||||
}
|
||||
Import-Module $sharedModule -Force
|
||||
|
||||
$Invariant = [Globalization.CultureInfo]::InvariantCulture
|
||||
|
||||
# ---------- Helpers ----------
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = '{0} [{1}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Write-Host $line
|
||||
if ($DryRun) { return }
|
||||
if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) {
|
||||
Move-Item $LogPath "$LogPath.1" -Force
|
||||
}
|
||||
Add-Content -Path $LogPath -Value $line -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-ErrorText($ErrorRecord) {
|
||||
# Windows PowerShell puts the HTTP response body (e.g. the API's 422 explanation) here
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
return "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)"
|
||||
}
|
||||
return $ErrorRecord.Exception.Message
|
||||
}
|
||||
|
||||
# The API needs an explicit UTC offset. DateTimeOffset keeps a Local/Unspecified time as local and a
|
||||
# Utc time as +00:00.
|
||||
function ConvertTo-IsoTime($Value) {
|
||||
if ($null -eq $Value) { return $null }
|
||||
$dt = [datetime]$Value
|
||||
if ($dt.Year -le 1900) { return $null } # "not set" (DateTime.MinValue and friends)
|
||||
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
|
||||
}
|
||||
|
||||
# First property of $Object, out of $Names, that exists and isn't empty. The VB365 object model isn't
|
||||
# fully documented, so reads go through this rather than assuming one spelling.
|
||||
function Get-FirstValue($Object, [string[]]$Names) {
|
||||
if ($null -eq $Object) { return $null }
|
||||
foreach ($name in $Names) {
|
||||
try {
|
||||
$prop = $Object.PSObject.Properties[$name]
|
||||
if (-not $prop) { continue }
|
||||
$value = $prop.Value
|
||||
# Check emptiness by type: casting to [string] would make a one-item list of objects ''
|
||||
if ($null -eq $value) { continue }
|
||||
if ($value -is [string] -and $value -eq '') { continue }
|
||||
if ($value -is [Collections.ICollection] -and $value.Count -eq 0) { continue }
|
||||
return $value
|
||||
} catch { }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Import-Vb365 {
|
||||
if (Get-Command Get-VBOJob -ErrorAction SilentlyContinue) { return } # already loaded
|
||||
if (Get-Module -ListAvailable -Name Veeam.Archiver.PowerShell) {
|
||||
@@ -245,48 +205,15 @@ function ConvertTo-Run($Session, $Job, [string]$JobKind) {
|
||||
return $run
|
||||
}
|
||||
|
||||
function Send-Report($Body) {
|
||||
$json = $Body | ConvertTo-Json -Depth 6 -Compress
|
||||
if ($DryRun) {
|
||||
Write-Host $json
|
||||
return
|
||||
}
|
||||
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1
|
||||
$params = @{
|
||||
Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
|
||||
Method = 'Post'
|
||||
Headers = @{ Authorization = "Bearer $($Config.ApiKey)" }
|
||||
ContentType = 'application/json; charset=utf-8'
|
||||
Body = [Text.Encoding]::UTF8.GetBytes($json)
|
||||
TimeoutSec = 30
|
||||
}
|
||||
Invoke-RestMethod @params | Out-Null
|
||||
}
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
try {
|
||||
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
|
||||
foreach ($key in 'ApiUrl', 'ApiKey') {
|
||||
if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" }
|
||||
}
|
||||
$Config = Initialize-Collector -Name 'vb365' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun -RequiredKeys 'ApiUrl', 'ApiKey'
|
||||
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam Backup for M365' }
|
||||
if ($LookbackHours -le 0) {
|
||||
$LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 }
|
||||
}
|
||||
|
||||
# Windows PowerShell 5.1 may default to TLS 1.0
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$LookbackHours = $Config.LookbackHours
|
||||
|
||||
Import-Vb365
|
||||
|
||||
# Session ID -> start time (Unix seconds), for sessions already delivered in their final state
|
||||
$sent = @{}
|
||||
if (Test-Path $StatePath) {
|
||||
$saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
|
||||
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } }
|
||||
}
|
||||
|
||||
$since = (Get-Date).AddHours(-$LookbackHours)
|
||||
|
||||
# (job, kind, sessions) for backup jobs, plus backup copy jobs where the cmdlets exist
|
||||
@@ -315,7 +242,6 @@ try {
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
$runsSent = 0
|
||||
foreach ($item in $work) {
|
||||
$job = $item.Job
|
||||
$tenant = Get-TenantForJob $job
|
||||
@@ -326,47 +252,27 @@ foreach ($item in $work) {
|
||||
next_run_at = Get-NextRun $job
|
||||
}
|
||||
$pending = @($item.Sessions |
|
||||
Where-Object { $_ -and -not $sent.ContainsKey([string](Get-FirstValue $_ @('Id', 'SessionId'))) } |
|
||||
Where-Object { $_ -and -not (Test-RunSent ([string](Get-FirstValue $_ @('Id', 'SessionId')))) } |
|
||||
Sort-Object { Get-Time $_ 'CreationTime' })
|
||||
|
||||
try {
|
||||
if ($pending.Count -eq 0) {
|
||||
# Keep next_run_at fresh even when nothing new ran
|
||||
Send-Report ([ordered]@{ tenant = $tenant; source_system = $SourceSystem; job = $jobInfo })
|
||||
Send-JobReport -Tenant $tenant -SourceSystem $SourceSystem -Job $jobInfo
|
||||
}
|
||||
foreach ($session in $pending) {
|
||||
$run = ConvertTo-Run $session $job $item.Kind
|
||||
Send-Report ([ordered]@{ tenant = $tenant; source_system = $SourceSystem; job = $jobInfo; run = $run })
|
||||
$runsSent++
|
||||
if ($run.status -ne 'running') {
|
||||
$sent[$run.external_id] = ([DateTimeOffset](Get-Time $session 'CreationTime')).ToUnixTimeSeconds()
|
||||
}
|
||||
Send-JobReport -Tenant $tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ((Get-Time $session 'CreationTime'))
|
||||
}
|
||||
} catch {
|
||||
$failures++
|
||||
Write-Log "Failed to report job '$($job.Name)': $(Get-ErrorText $_)" 'ERROR'
|
||||
$httpStatus = 0
|
||||
try { $httpStatus = [int]$_.Exception.Response.StatusCode } catch { }
|
||||
if ($httpStatus -eq 401) {
|
||||
if (Test-AuthRejected $_) {
|
||||
Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
# Forget sessions that have aged out of the lookback window (plus a day of slack)
|
||||
$cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds()
|
||||
$keep = [ordered]@{}
|
||||
foreach ($id in $sent.Keys) {
|
||||
if ($sent[$id] -ge $cutoff) { $keep[$id] = $sent[$id] }
|
||||
}
|
||||
try {
|
||||
$keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8
|
||||
} catch {
|
||||
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log ("Reported {0} jobs, {1} runs, {2} failures" -f $work.Count, $runsSent, $failures)
|
||||
if ($failures) { exit 1 }
|
||||
$exitCode = Complete-Collector -JobCount $work.Count -Failures $failures
|
||||
if ($exitCode) { exit $exitCode }
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
# ./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
||||
|
||||
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same data
|
||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
||||
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
|
||||
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
|
||||
function New-StableGuid([string]$Seed) {
|
||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||
}
|
||||
|
||||
@@ -30,9 +30,6 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$TaskName = 'Backup Dashboard - Veeam collector'
|
||||
$DataDir = Join-Path $env:ProgramData 'BackupDashboard'
|
||||
$ConfigPath = Join-Path $DataDir 'veeam-collector.json'
|
||||
|
||||
# Find PowerShell 7 before changing anything, so a missing install fails cleanly
|
||||
if (-not $PwshPath) {
|
||||
@@ -54,49 +51,18 @@ if ($LASTEXITCODE -or ($pwshMajor -as [int]) -lt 7) {
|
||||
throw "'$PwshPath' isn't a working PowerShell 7 (reported '$pwshVersion')."
|
||||
}
|
||||
|
||||
if ($ApiUrl -notmatch '^https://') {
|
||||
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
|
||||
# Shared installer code: in the repo it's in ..\common, so copy the whole collectors folder
|
||||
$sharedModule = @(
|
||||
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
||||
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sharedModule) {
|
||||
throw "BackupDashboard.Collector.psm1 not found. Copy the whole collectors folder, including common."
|
||||
}
|
||||
Import-Module $sharedModule -Force
|
||||
|
||||
# Read the key as a SecureString so it isn't echoed to the console or kept in shell history
|
||||
$secure = Read-Host -AsSecureString 'API key (from: python manage.py create-key <name>)'
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try { $apiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||||
if (-not $apiKey.StartsWith('bdk_')) { throw "That doesn't look like a dashboard API key (expected 'bdk_...')." }
|
||||
|
||||
# Config folder: SYSTEM + Administrators only (SIDs so this works on non-English Windows)
|
||||
New-Item -ItemType Directory -Path $DataDir -Force | Out-Null
|
||||
& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
if ($LASTEXITCODE) { throw "icacls failed on $DataDir" }
|
||||
|
||||
[ordered]@{
|
||||
ApiUrl = $ApiUrl.TrimEnd('/')
|
||||
ApiKey = $apiKey
|
||||
Tenant = $Tenant
|
||||
SourceSystem = $SourceSystem
|
||||
LookbackHours = $LookbackHours
|
||||
} | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$scriptPath = Join-Path $InstallDir 'Send-VeeamBackupStatus.ps1'
|
||||
Copy-Item -Path (Join-Path $PSScriptRoot 'Send-VeeamBackupStatus.ps1') -Destination $scriptPath -Force
|
||||
Unblock-File -Path $scriptPath
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $PwshPath `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
|
||||
-MultipleInstances IgnoreNew -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
|
||||
-Settings $settings -Description 'Sends Veeam job results to the Backup Dashboard' -Force | Out-Null
|
||||
|
||||
Write-Host "Installed. Config: $ConfigPath"
|
||||
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using PowerShell $pwshVersion ($PwshPath)."
|
||||
Write-Host ''
|
||||
Write-Host 'Next steps:'
|
||||
Write-Host " 1. Preview what will be sent: & '$PwshPath' -File '$scriptPath' -DryRun"
|
||||
Write-Host " 2. Backfill 30 days of history: & '$PwshPath' -File '$scriptPath' -LookbackHours 720"
|
||||
Write-Host " 3. Check the log: Get-Content '$DataDir\veeam-collector.log' -Tail 20"
|
||||
$settings = [ordered]@{ Tenant = $Tenant; SourceSystem = $SourceSystem; LookbackHours = $LookbackHours }
|
||||
Install-CollectorTask -Name 'veeam' -ScriptPath (Join-Path $PSScriptRoot 'Send-VeeamBackupStatus.ps1') `
|
||||
-TaskName 'Backup Dashboard - Veeam collector' `
|
||||
-TaskDescription 'Sends Veeam job results to the Backup Dashboard' `
|
||||
-Shell $PwshPath -ApiUrl $ApiUrl -IntervalMinutes $IntervalMinutes -InstallDir $InstallDir -Settings $settings
|
||||
|
||||
@@ -13,7 +13,8 @@ account can't run it. The installer checks for this before changing anything.
|
||||
```bash
|
||||
python manage.py create-key veeam-acme
|
||||
```
|
||||
2. Copy this folder to the VBR server and run, from PowerShell 7 (`pwsh`):
|
||||
2. Copy the whole `collectors` folder to the VBR server (the installer needs `common`
|
||||
as well as this folder), then from `collectors\veeam` run, from PowerShell 7 (`pwsh`):
|
||||
```powershell
|
||||
.\Install-VeeamCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
|
||||
```
|
||||
@@ -29,7 +30,8 @@ account can't run it. The installer checks for this before changing anything.
|
||||
& 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -LookbackHours 720
|
||||
```
|
||||
|
||||
Re-run the installer to change settings or update the script. To remove the collector,
|
||||
Re-run the installer to change settings or update the script. It also installs
|
||||
`BackupDashboard.Collector.psm1`, the code shared by all collectors, next to the script. To remove the collector,
|
||||
delete the scheduled task and both `BackupDashboard` folders.
|
||||
|
||||
## What it sends
|
||||
|
||||
@@ -37,42 +37,19 @@ param(
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$DataDir = Split-Path -Parent $ConfigPath
|
||||
$LogPath = Join-Path $DataDir 'veeam-collector.log'
|
||||
$StatePath = Join-Path $DataDir 'veeam-collector.state.json'
|
||||
$MaxLogBytes = 5MB
|
||||
$Invariant = [Globalization.CultureInfo]::InvariantCulture
|
||||
# Shared collector code: installed next to this script; in the repo, in ..\common
|
||||
$sharedModule = @(
|
||||
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
||||
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sharedModule) {
|
||||
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
|
||||
exit 2
|
||||
}
|
||||
Import-Module $sharedModule -Force
|
||||
|
||||
# ---------- Helpers ----------
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = '{0} [{1}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Write-Host $line
|
||||
if ($DryRun) { return }
|
||||
if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) {
|
||||
Move-Item $LogPath "$LogPath.1" -Force
|
||||
}
|
||||
Add-Content -Path $LogPath -Value $line -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-ErrorText($ErrorRecord) {
|
||||
# Windows PowerShell puts the HTTP response body (e.g. the API's 422 explanation) here
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
return "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)"
|
||||
}
|
||||
return $ErrorRecord.Exception.Message
|
||||
}
|
||||
|
||||
# The API needs an explicit UTC offset. Veeam returns local times, often with Kind=Unspecified,
|
||||
# which DateTimeOffset treats as local, exactly what we want.
|
||||
function ConvertTo-IsoTime($Value) {
|
||||
if ($null -eq $Value) { return $null }
|
||||
$dt = [datetime]$Value
|
||||
if ($dt.Year -le 1900) { return $null } # Veeam's "not set" value (e.g. EndTime while running)
|
||||
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
|
||||
}
|
||||
|
||||
function Import-Veeam {
|
||||
if (Get-Command Get-VBRJob -ErrorAction SilentlyContinue) { return } # already loaded
|
||||
if (Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) {
|
||||
@@ -217,51 +194,15 @@ function ConvertTo-Run($Session, $Job) {
|
||||
return $run
|
||||
}
|
||||
|
||||
function Send-Report($Body) {
|
||||
$json = $Body | ConvertTo-Json -Depth 6 -Compress
|
||||
if ($DryRun) {
|
||||
Write-Host $json
|
||||
return
|
||||
}
|
||||
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1,
|
||||
# mangling non-ASCII job names (e.g. en dashes).
|
||||
$params = @{
|
||||
Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
|
||||
Method = 'Post'
|
||||
Headers = @{ Authorization = "Bearer $($Config.ApiKey)" }
|
||||
ContentType = 'application/json; charset=utf-8'
|
||||
Body = [Text.Encoding]::UTF8.GetBytes($json)
|
||||
TimeoutSec = 30
|
||||
}
|
||||
Invoke-RestMethod @params | Out-Null
|
||||
}
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
try {
|
||||
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
|
||||
foreach ($key in 'ApiUrl', 'ApiKey', 'Tenant') {
|
||||
if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" }
|
||||
}
|
||||
$Config = Initialize-Collector -Name 'veeam' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun
|
||||
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam B&R' }
|
||||
if ($LookbackHours -le 0) {
|
||||
$LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 }
|
||||
}
|
||||
|
||||
# Windows PowerShell 5.1 may default to TLS 1.0
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$LookbackHours = $Config.LookbackHours
|
||||
|
||||
Import-Veeam
|
||||
|
||||
# Session ID -> start time (Unix seconds), for sessions already delivered in their final state.
|
||||
# Numbers rather than ISO strings: PowerShell 7's ConvertFrom-Json turns date strings into
|
||||
# DateTimes and loses the offset.
|
||||
$sent = @{}
|
||||
if (Test-Path $StatePath) {
|
||||
$saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
|
||||
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } }
|
||||
}
|
||||
|
||||
$since = (Get-Date).AddHours(-$LookbackHours)
|
||||
$jobs = @(Get-VBRJob -WarningAction SilentlyContinue)
|
||||
|
||||
@@ -288,7 +229,6 @@ try {
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
$runsSent = 0
|
||||
foreach ($job in $jobs) {
|
||||
$jobInfo = [ordered]@{
|
||||
external_id = $job.Id.ToString()
|
||||
@@ -299,48 +239,28 @@ foreach ($job in $jobs) {
|
||||
$pending = @()
|
||||
if ($sessionsByJob.ContainsKey($jobInfo.external_id)) {
|
||||
$pending = @($sessionsByJob[$jobInfo.external_id] |
|
||||
Where-Object { -not $sent.ContainsKey($_.Id.ToString()) } |
|
||||
Where-Object { -not (Test-RunSent ($_.Id.ToString())) } |
|
||||
Sort-Object CreationTime)
|
||||
}
|
||||
|
||||
try {
|
||||
if ($pending.Count -eq 0) {
|
||||
# Keep next_run_at fresh even when nothing new ran
|
||||
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo })
|
||||
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
|
||||
}
|
||||
foreach ($session in $pending) {
|
||||
$run = ConvertTo-Run $session $job
|
||||
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo; run = $run })
|
||||
$runsSent++
|
||||
if ($run.status -ne 'running') {
|
||||
$sent[$run.external_id] = ([DateTimeOffset][datetime]$session.CreationTime).ToUnixTimeSeconds()
|
||||
}
|
||||
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ([datetime]$session.CreationTime)
|
||||
}
|
||||
} catch {
|
||||
$failures++
|
||||
Write-Log "Failed to report job '$($job.Name)': $(Get-ErrorText $_)" 'ERROR'
|
||||
$httpStatus = 0
|
||||
try { $httpStatus = [int]$_.Exception.Response.StatusCode } catch { }
|
||||
if ($httpStatus -eq 401) {
|
||||
if (Test-AuthRejected $_) {
|
||||
Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
# Forget sessions that have aged out of the lookback window (plus a day of slack)
|
||||
$cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds()
|
||||
$keep = [ordered]@{}
|
||||
foreach ($id in $sent.Keys) {
|
||||
if ($sent[$id] -ge $cutoff) { $keep[$id] = $sent[$id] }
|
||||
}
|
||||
try {
|
||||
$keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8
|
||||
} catch {
|
||||
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log ("Reported {0} jobs, {1} runs, {2} failures" -f $jobs.Count, $runsSent, $failures)
|
||||
if ($failures) { exit 1 }
|
||||
$exitCode = Complete-Collector -JobCount $jobs.Count -Failures $failures
|
||||
if ($exitCode) { exit $exitCode }
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same
|
||||
# jobs and sessions, like a real server
|
||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
||||
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
|
||||
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
|
||||
function New-StableGuid([string]$Seed) {
|
||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||
}
|
||||
|
||||
@@ -29,9 +29,6 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$TaskName = 'Backup Dashboard - WSB collector'
|
||||
$DataDir = Join-Path $env:ProgramData 'BackupDashboard'
|
||||
$ConfigPath = Join-Path $DataDir 'wsb-collector.json'
|
||||
$WindowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
|
||||
# Check Windows Server Backup is here before changing anything
|
||||
@@ -39,51 +36,19 @@ if (-not (Get-Module -ListAvailable -Name WindowsServerBackup)) {
|
||||
throw 'The WindowsServerBackup PowerShell module is not available. Install the Windows Server Backup feature (Install-WindowsFeature Windows-Server-Backup).'
|
||||
}
|
||||
|
||||
if ($ApiUrl -notmatch '^https://') {
|
||||
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
|
||||
# Shared installer code: in the repo it's in ..\common, so copy the whole collectors folder
|
||||
$sharedModule = @(
|
||||
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
||||
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sharedModule) {
|
||||
throw "BackupDashboard.Collector.psm1 not found. Copy the whole collectors folder, including common."
|
||||
}
|
||||
Import-Module $sharedModule -Force
|
||||
|
||||
# Read the key as a SecureString so it isn't echoed to the console or kept in shell history
|
||||
$secure = Read-Host -AsSecureString 'API key (from: python manage.py create-key <name>)'
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try { $apiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||||
if (-not $apiKey.StartsWith('bdk_')) { throw "That doesn't look like a dashboard API key (expected 'bdk_...')." }
|
||||
|
||||
# Config folder: SYSTEM + Administrators only (SIDs so this works on non-English Windows)
|
||||
New-Item -ItemType Directory -Path $DataDir -Force | Out-Null
|
||||
& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
if ($LASTEXITCODE) { throw "icacls failed on $DataDir" }
|
||||
|
||||
$config = [ordered]@{
|
||||
ApiUrl = $ApiUrl.TrimEnd('/')
|
||||
ApiKey = $apiKey
|
||||
Tenant = $Tenant
|
||||
SourceSystem = $SourceSystem
|
||||
LookbackHours = $LookbackHours
|
||||
}
|
||||
if ($JobName) { $config.JobName = $JobName }
|
||||
$config | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$scriptPath = Join-Path $InstallDir 'Send-WsbBackupStatus.ps1'
|
||||
Copy-Item -Path (Join-Path $PSScriptRoot 'Send-WsbBackupStatus.ps1') -Destination $scriptPath -Force
|
||||
Unblock-File -Path $scriptPath
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $WindowsPowerShell `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
|
||||
-MultipleInstances IgnoreNew -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
|
||||
-Settings $settings -Description 'Sends Windows Server Backup results to the Backup Dashboard' -Force | Out-Null
|
||||
|
||||
Write-Host "Installed. Config: $ConfigPath"
|
||||
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using Windows PowerShell."
|
||||
Write-Host ''
|
||||
Write-Host 'Next steps:'
|
||||
Write-Host " 1. Preview what will be sent: & '$WindowsPowerShell' -File '$scriptPath' -DryRun"
|
||||
Write-Host " 2. Backfill 30 days of history: & '$WindowsPowerShell' -File '$scriptPath' -LookbackHours 720"
|
||||
Write-Host " 3. Check the log: Get-Content '$DataDir\wsb-collector.log' -Tail 20"
|
||||
$settings = [ordered]@{ Tenant = $Tenant; SourceSystem = $SourceSystem; LookbackHours = $LookbackHours }
|
||||
if ($JobName) { $settings.JobName = $JobName }
|
||||
Install-CollectorTask -Name 'wsb' -ScriptPath (Join-Path $PSScriptRoot 'Send-WsbBackupStatus.ps1') `
|
||||
-TaskName 'Backup Dashboard - WSB collector' `
|
||||
-TaskDescription 'Sends Windows Server Backup results to the Backup Dashboard' `
|
||||
-Shell $WindowsPowerShell -ApiUrl $ApiUrl -IntervalMinutes $IntervalMinutes -InstallDir $InstallDir -Settings $settings
|
||||
|
||||
@@ -19,7 +19,8 @@ Requires the Windows Server Backup feature with its PowerShell module
|
||||
```bash
|
||||
python manage.py create-key wsb-acme-dc01
|
||||
```
|
||||
2. Copy this folder to the server and run:
|
||||
2. Copy the whole `collectors` folder to the server (the installer needs `common`
|
||||
as well as this folder), then from `collectors\wsb` run:
|
||||
```powershell
|
||||
.\Install-WsbCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
|
||||
```
|
||||
@@ -35,7 +36,8 @@ Requires the Windows Server Backup feature with its PowerShell module
|
||||
```
|
||||
|
||||
It can be installed alongside the other collectors: it uses its own config, state, log and task.
|
||||
Re-run the installer to change settings or update the script.
|
||||
Re-run the installer to change settings or update the script. It also installs
|
||||
`BackupDashboard.Collector.psm1`, the code shared by all collectors, next to the script.
|
||||
|
||||
## What it sends
|
||||
|
||||
|
||||
@@ -40,64 +40,23 @@ param(
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$DataDir = Split-Path -Parent $ConfigPath
|
||||
$LogPath = Join-Path $DataDir 'wsb-collector.log'
|
||||
$StatePath = Join-Path $DataDir 'wsb-collector.state.json'
|
||||
$MaxLogBytes = 5MB
|
||||
# Shared collector code: installed next to this script; in the repo, in ..\common
|
||||
$sharedModule = @(
|
||||
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
|
||||
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sharedModule) {
|
||||
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
|
||||
exit 2
|
||||
}
|
||||
Import-Module $sharedModule -Force
|
||||
|
||||
# Get-WBJob -Previous accepts at most 100. That covers a 30-day backfill at up to 3 backups a day;
|
||||
# servers backing up more often only get their latest 100 runs backfilled.
|
||||
$MaxPreviousJobs = 100
|
||||
$Invariant = [Globalization.CultureInfo]::InvariantCulture
|
||||
|
||||
# ---------- Helpers ----------
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = '{0} [{1}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Write-Host $line
|
||||
if ($DryRun) { return }
|
||||
if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) {
|
||||
Move-Item $LogPath "$LogPath.1" -Force
|
||||
}
|
||||
Add-Content -Path $LogPath -Value $line -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-ErrorText($ErrorRecord) {
|
||||
# Windows PowerShell puts the HTTP response body (e.g. the API's 422 explanation) here
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
return "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)"
|
||||
}
|
||||
return $ErrorRecord.Exception.Message
|
||||
}
|
||||
|
||||
# The API needs an explicit UTC offset. DateTimeOffset keeps a Local/Unspecified time as local and a
|
||||
# Utc time as +00:00.
|
||||
function ConvertTo-IsoTime($Value) {
|
||||
if ($null -eq $Value) { return $null }
|
||||
$dt = [datetime]$Value
|
||||
if ($dt.Year -le 1900) { return $null } # "not set" (DateTime.MinValue and friends)
|
||||
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
|
||||
}
|
||||
|
||||
# First property of $Object, out of $Names, that exists and isn't empty. The WSB object model isn't
|
||||
# fully documented, so reads go through this rather than assuming one spelling.
|
||||
function Get-FirstValue($Object, [string[]]$Names) {
|
||||
if ($null -eq $Object) { return $null }
|
||||
foreach ($name in $Names) {
|
||||
try {
|
||||
$prop = $Object.PSObject.Properties[$name]
|
||||
if (-not $prop) { continue }
|
||||
$value = $prop.Value
|
||||
# Check emptiness by type: casting to [string] would make a one-item list of objects ''
|
||||
if ($null -eq $value) { continue }
|
||||
if ($value -is [string] -and $value -eq '') { continue }
|
||||
if ($value -is [Collections.ICollection] -and $value.Count -eq 0) { continue }
|
||||
return $value
|
||||
} catch { }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Import-Wsb {
|
||||
if (Get-Command Get-WBJob -ErrorAction SilentlyContinue) { return } # already loaded
|
||||
# Present only when the Windows Server Backup feature (with its command-line tools) is installed
|
||||
@@ -288,49 +247,16 @@ function Get-NextRun {
|
||||
}
|
||||
}
|
||||
|
||||
function Send-Report($Body) {
|
||||
$json = $Body | ConvertTo-Json -Depth 6 -Compress
|
||||
if ($DryRun) {
|
||||
Write-Host $json
|
||||
return
|
||||
}
|
||||
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1
|
||||
$params = @{
|
||||
Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
|
||||
Method = 'Post'
|
||||
Headers = @{ Authorization = "Bearer $($Config.ApiKey)" }
|
||||
ContentType = 'application/json; charset=utf-8'
|
||||
Body = [Text.Encoding]::UTF8.GetBytes($json)
|
||||
TimeoutSec = 30
|
||||
}
|
||||
Invoke-RestMethod @params | Out-Null
|
||||
}
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
try {
|
||||
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
|
||||
foreach ($key in 'ApiUrl', 'ApiKey', 'Tenant') {
|
||||
if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" }
|
||||
}
|
||||
$Config = Initialize-Collector -Name 'wsb' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun
|
||||
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Windows Server Backup' }
|
||||
$JobName = if ($Config.JobName) { [string]$Config.JobName } else { "$env:COMPUTERNAME - Windows Server Backup" }
|
||||
if ($LookbackHours -le 0) {
|
||||
$LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 }
|
||||
}
|
||||
|
||||
# Windows PowerShell 5.1 may default to TLS 1.0
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$LookbackHours = $Config.LookbackHours
|
||||
|
||||
Import-Wsb
|
||||
|
||||
# Run start time (ISO, as sent) -> Unix seconds, for runs already delivered in their final state
|
||||
$sent = @{}
|
||||
if (Test-Path $StatePath) {
|
||||
$saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
|
||||
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } }
|
||||
}
|
||||
|
||||
$since = (Get-Date).AddHours(-$LookbackHours)
|
||||
# Get-WBJob with no arguments returns the job in progress (if any); -Previous lists finished ones
|
||||
$jobs = @(@(Get-WBJob) + @(Get-WBJob -Previous $MaxPreviousJobs) | Where-Object {
|
||||
@@ -342,7 +268,7 @@ try {
|
||||
if ($start -lt $since) { continue }
|
||||
$byStart[(ConvertTo-IsoTime $start)] = $job # a job in both lists: the finished entry (later) wins
|
||||
}
|
||||
$pending = @($byStart.Keys | Sort-Object | Where-Object { -not $sent.ContainsKey($_) } |
|
||||
$pending = @($byStart.Keys | Sort-Object | Where-Object { -not (Test-RunSent ($_)) } |
|
||||
ForEach-Object { $byStart[$_] })
|
||||
|
||||
$jobInfo = [ordered]@{
|
||||
@@ -358,38 +284,19 @@ try {
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
$runsSent = 0
|
||||
try {
|
||||
if ($pending.Count -eq 0) {
|
||||
# Keep next_run_at fresh even when nothing new ran
|
||||
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo })
|
||||
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
|
||||
}
|
||||
foreach ($job in $pending) {
|
||||
$run = ConvertTo-Run $job
|
||||
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo; run = $run })
|
||||
$runsSent++
|
||||
if ($run.status -ne 'running') {
|
||||
$sent[$run.started_at] = ([DateTimeOffset](Get-JobTime $job 'StartTime')).ToUnixTimeSeconds()
|
||||
}
|
||||
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.started_at -StartedAt ((Get-JobTime $job 'StartTime'))
|
||||
}
|
||||
} catch {
|
||||
$failures++
|
||||
Write-Log "Failed to report: $(Get-ErrorText $_)" 'ERROR'
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
# Forget runs that have aged out of the lookback window (plus a day of slack)
|
||||
$cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds()
|
||||
$keep = [ordered]@{}
|
||||
foreach ($key in $sent.Keys) {
|
||||
if ($sent[$key] -ge $cutoff) { $keep[$key] = $sent[$key] }
|
||||
}
|
||||
try {
|
||||
$keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8
|
||||
} catch {
|
||||
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log ("Reported {0} runs, {1} failures" -f $runsSent, $failures)
|
||||
if ($failures) { exit 1 }
|
||||
$exitCode = Complete-Collector -Failures $failures
|
||||
if ($exitCode) { exit $exitCode }
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
#
|
||||
# . ./tests/MockWsb.ps1 # nothing running
|
||||
# . ./tests/MockWsb.ps1 -WithRunningJob # a backup in progress
|
||||
# . ./tests/MockWsb.ps1 -RunningJobFinished # that same backup, now finished
|
||||
# ./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
||||
param([switch]$WithRunningJob)
|
||||
param([switch]$WithRunningJob, [switch]$RunningJobFinished)
|
||||
|
||||
# Fixed to the top of the hour so repeated runs see the same jobs
|
||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
||||
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
|
||||
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
|
||||
|
||||
# Same format as the real cmdlet: short date + short time in the current culture
|
||||
function Format-WbTime($Value) {
|
||||
@@ -83,6 +85,9 @@ $global:MockWsbPrevious = @(
|
||||
(New-MockJob 'Recovery' $now.AddHours(-5) $now.AddHours(-4) 'Completed'), # a restore: must be ignored
|
||||
(New-MockJob 'Backup' $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Completed') # outside 48h lookback
|
||||
)
|
||||
if ($RunningJobFinished) {
|
||||
$global:MockWsbPrevious += New-MockJob 'Backup' $now.AddMinutes(-10) $now.AddMinutes(-2) 'Completed' -Items @(New-VmList 10GB)
|
||||
}
|
||||
|
||||
$global:MockWsbSummary = [pscustomobject]@{
|
||||
NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
|
||||
|
||||
+54
-8
@@ -1,7 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name backups.example.com;
|
||||
# Basic auth sends the password with every request — never serve this over plain HTTP
|
||||
# Session cookies and API keys must never cross plain HTTP
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -23,26 +23,72 @@ server {
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
# Dashboard users. Create with: htpasswd -B -c /etc/nginx/backup-dashboard.htpasswd <user>
|
||||
# To move to Microsoft 365 sign-in later, replace these two lines with an auth_request to
|
||||
# oauth2-proxy (provider=entra-id); the app itself doesn't need to change.
|
||||
auth_basic "Backup Monitor";
|
||||
auth_basic_user_file /etc/nginx/backup-dashboard.htpasswd;
|
||||
# ---------- Microsoft 365 sign-in ----------
|
||||
# oauth2-proxy (deploy/oauth2-proxy.cfg) runs on 127.0.0.1:4180 and does the sign-in with
|
||||
# Entra ID. Every request below asks it "is this browser signed in?" via auth_request.
|
||||
|
||||
# Its own pages: /oauth2/start (go to Microsoft), /oauth2/callback, /oauth2/sign_out
|
||||
location /oauth2/ {
|
||||
proxy_pass http://127.0.0.1:4180;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Where to return after signing in: the page that was asked for
|
||||
proxy_set_header X-Auth-Request-Redirect $request_uri;
|
||||
# Entra ID session cookies are large
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
}
|
||||
|
||||
# The sign-in check itself, only reachable as an auth_request subrequest
|
||||
location = /oauth2/auth {
|
||||
internal;
|
||||
proxy_pass http://127.0.0.1:4180;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_pass_request_body off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
}
|
||||
|
||||
# ---------- Dashboard ----------
|
||||
|
||||
location / {
|
||||
auth_request /oauth2/auth;
|
||||
# Not signed in: straight to Microsoft's sign-in, then back to this page
|
||||
error_page 401 = /oauth2/start;
|
||||
# Make browsers check for a newer page/script on every load (a cheap 304 when unchanged),
|
||||
# so an update shows up at once. (expires, not add_header: an add_header here would drop
|
||||
# the security headers above.)
|
||||
expires -1;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
auth_request /oauth2/auth;
|
||||
# No redirect here: API calls from the page get a plain 401, and the page asks the user to
|
||||
# reload, which goes through sign-in above.
|
||||
|
||||
# Who's signed in, for the dashboard's "signed in as" (proxy_set_header replaces any value
|
||||
# the browser sent, so these can't be spoofed)
|
||||
auth_request_set $auth_email $upstream_http_x_auth_request_email;
|
||||
auth_request_set $auth_user $upstream_http_x_auth_request_user;
|
||||
auth_request_set $auth_name $upstream_http_x_auth_request_preferred_username;
|
||||
proxy_set_header X-Forwarded-Email $auth_email;
|
||||
proxy_set_header X-Forwarded-User $auth_user;
|
||||
proxy_set_header X-Forwarded-Preferred-Username $auth_name;
|
||||
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app
|
||||
# Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app,
|
||||
# so no Microsoft sign-in here
|
||||
location = /api/v1/ingest {
|
||||
auth_basic off;
|
||||
client_max_body_size 256k;
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# oauth2-proxy config for Backup Dashboard's Microsoft 365 (Entra ID) sign-in.
|
||||
# Install as /etc/oauth2-proxy/backup-dashboard.cfg, readable only by root and the oauth2-proxy
|
||||
# user (it holds the cookie secret). Needs oauth2-proxy 7.8 or newer for the entra-id provider.
|
||||
# Replace the four <...> values; see the README's "Microsoft 365 sign-in" section.
|
||||
|
||||
## Where it listens: only nginx talks to it
|
||||
http_address = "127.0.0.1:4180"
|
||||
reverse_proxy = true
|
||||
# Only nginx (on this machine) may supply X-Forwarded-* headers
|
||||
trusted_proxy_ips = ["127.0.0.1/32", "::1/128"]
|
||||
|
||||
## Entra ID app registration (single tenant)
|
||||
provider = "entra-id"
|
||||
oidc_issuer_url = "https://login.microsoftonline.com/<TENANT_ID>/v2.0"
|
||||
client_id = "<APPLICATION_CLIENT_ID>"
|
||||
# The client secret's *value*, in a file of its own, root:oauth2-proxy, mode 640
|
||||
client_secret_file = "/etc/oauth2-proxy/client-secret"
|
||||
redirect_url = "https://<DASHBOARD_HOST>/oauth2/callback"
|
||||
|
||||
## Who may sign in
|
||||
# Anyone in the tenant that Entra lets through. Limit it in Entra itself: Enterprise
|
||||
# applications -> this app -> Properties -> "Assignment required" = Yes, then assign users or a
|
||||
# group. Entra then refuses everyone else before they ever reach the dashboard.
|
||||
email_domains = ["*"]
|
||||
# The user's sign-in name (UPN), which every work account has. The "email" claim is only present
|
||||
# when the account has a mailbox address.
|
||||
oidc_email_claim = "preferred_username"
|
||||
|
||||
## Session cookie
|
||||
# Generate with: openssl rand -base64 32 | tr -- '+/' '-_'
|
||||
cookie_secret = "<COOKIE_SECRET>"
|
||||
cookie_name = "_backup_dashboard"
|
||||
cookie_secure = true
|
||||
cookie_samesite = "lax"
|
||||
# Sign in again (usually silently, via the Microsoft session) after this long
|
||||
cookie_expire = "12h"
|
||||
|
||||
## Behaviour with nginx's auth_request
|
||||
# Auth-only: nginx serves the dashboard; oauth2-proxy just answers "signed in or not"
|
||||
upstreams = ["static://202"]
|
||||
# Put the user in X-Auth-Request-* headers on /oauth2/auth responses, for nginx to pass on
|
||||
set_xauthrequest = true
|
||||
# Go straight to Microsoft instead of showing a "Sign in with..." page first
|
||||
skip_provider_button = true
|
||||
# Allow "Sign out" to continue to Microsoft's sign-out page
|
||||
whitelist_domains = ["login.microsoftonline.com"]
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=oauth2-proxy (Microsoft 365 sign-in for Backup Dashboard)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=oauth2-proxy
|
||||
Group=oauth2-proxy
|
||||
ExecStart=/usr/local/bin/oauth2-proxy --config=/etc/oauth2-proxy/backup-dashboard.cfg
|
||||
Restart=on-failure
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -71,6 +71,12 @@ body {
|
||||
}
|
||||
.topbar__title { margin: 0; font-size: 1.25rem; }
|
||||
.topbar__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; }
|
||||
/* Only when shown: display:flex on the bare class would override the hidden attribute */
|
||||
.topbar__user:not([hidden]) {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding-left: 0.75rem; border-left: 1px solid var(--border);
|
||||
}
|
||||
a.btn { text-decoration: none; }
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
<div class="topbar__meta">
|
||||
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
|
||||
<button id="refresh-btn" class="btn" type="button">Refresh</button>
|
||||
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
|
||||
<span id="signed-in" class="topbar__user" hidden>
|
||||
<span id="signed-in-email" class="muted"></span>
|
||||
<a id="sign-out" class="btn" href="#">Sign out</a>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ const httpApi = {
|
||||
getRun(id) {
|
||||
return request(`/runs/${encodeURIComponent(id)}`);
|
||||
},
|
||||
/** GET /me → { email, name } of the Microsoft 365 user, both null without sign-in */
|
||||
getMe() {
|
||||
return request('/me');
|
||||
},
|
||||
};
|
||||
|
||||
// Mock is loaded lazily so it never ships in the production request path.
|
||||
|
||||
@@ -56,6 +56,9 @@ const dom = {
|
||||
empty: $('#empty-state'),
|
||||
search: $('#search'),
|
||||
refreshBtn: $('#refresh-btn'),
|
||||
signedIn: $('#signed-in'),
|
||||
signedInEmail: $('#signed-in-email'),
|
||||
signOut: $('#sign-out'),
|
||||
lastUpdated: $('#last-updated'),
|
||||
errorBanner: $('#error-banner'),
|
||||
dialog: $('#detail-dialog'),
|
||||
@@ -524,5 +527,20 @@ dom.search.addEventListener('input', () => {
|
||||
|
||||
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
||||
|
||||
// ---------- Signed-in user ----------
|
||||
async function showSignedInUser() {
|
||||
try {
|
||||
const me = await api.getMe();
|
||||
const who = me?.email || me?.name;
|
||||
if (!who) return; // no Microsoft 365 sign-in in front of the dashboard (dev, mock)
|
||||
dom.signedInEmail.textContent = who;
|
||||
dom.signOut.href = CONFIG.signOutUrl;
|
||||
dom.signedIn.hidden = false;
|
||||
} catch (err) {
|
||||
console.error(err); // not worth a banner: the jobs request will surface real problems
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Boot ----------
|
||||
showSignedInUser();
|
||||
refresh().then(schedulePoll);
|
||||
@@ -12,4 +12,8 @@ export const CONFIG = Object.freeze({
|
||||
historyDays: 30,
|
||||
// ?mock=1 → use in-browser sample data, no backend needed
|
||||
useMock: params.has('mock'),
|
||||
// oauth2-proxy clears the dashboard session, then hands over to Microsoft's sign-out page.
|
||||
// Signing out of the dashboard alone would be undone at once: the browser's Microsoft session
|
||||
// would sign it straight back in.
|
||||
signOutUrl: '/oauth2/sign_out?rd=' + encodeURIComponent('https://login.microsoftonline.com/common/oauth2/v2.0/logout'),
|
||||
});
|
||||
@@ -126,4 +126,7 @@ export const mockApi = {
|
||||
}
|
||||
throw new Error('Run not found');
|
||||
},
|
||||
async getMe() {
|
||||
return { email: null, name: null }; // no sign-in in mock mode
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user