Add ZVMA pre/post script recipe + env-dump examples
Adds a Kubernetes-ZVMA companion to the existing Windows-ZVM recipe:
- scripts/examples/zerto-zvma-send.ps1 - Zerto-side sender for both
pre and post phases, packages the Zerto* env vars into a structured
JSON body and POSTs to a {phase}-templated webhook URL.
- scripts/examples/zerto-receiver-notify.ps1 - server-side receiver
that posts a Slack/Teams notification, with phase-aware formatting
and ZertoForce highlighted on pre.
- scripts/examples/zerto-receiver-vm-healthcheck.ps1 - server-side
receiver that pings + port-probes each VM in VmDisplayNames after
failover and writes a per-run JSON report.
- scripts/examples/send-env-vars.ps1 + save-env-vars.ps1 - generic
env-dump client/receiver pair (the diagnostic that surfaced what
the ZVMA scripts-service container exposes).
- docs/recipes/zerto-zvma-pre-post.md - full walkthrough mirroring
the existing Windows-ZVM recipe's structure.
- README.md and docs/README.md - link the new recipe and examples.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Server-side receiver for the env-dump webhook. Reads the JSON body from
|
||||
stdin and writes it to a timestamped file on disk.
|
||||
|
||||
.DESCRIPTION
|
||||
Configure a webhook endpoint like this:
|
||||
Executable: powershell.exe (or pwsh.exe)
|
||||
Arguments: -NoProfile -ExecutionPolicy Bypass -File C:\path\to\save-env-vars.ps1
|
||||
Data passing: [x] Stdin JSON
|
||||
Run As: Service (or any account that can write to $OutDir)
|
||||
|
||||
Output goes to C:\ProgramData\WebhookServer\env-dumps\<host>-<utcstamp>.json
|
||||
by default; override with -OutDir.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $OutDir = 'C:\ProgramData\WebhookServer\env-dumps'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $OutDir)) {
|
||||
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$body = [Console]::In.ReadToEnd()
|
||||
if ([string]::IsNullOrWhiteSpace($body)) {
|
||||
Write-Error 'Empty request body on stdin.'
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Parse so we can pull the host name for the filename, and to fail fast on
|
||||
# malformed JSON before writing it.
|
||||
$parsed = $body | ConvertFrom-Json
|
||||
$hostName = if ($parsed.host) { $parsed.host } else { 'unknown' }
|
||||
$safeHost = ($hostName -replace '[^A-Za-z0-9_.-]', '_')
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
|
||||
$path = Join-Path $OutDir "$safeHost-$stamp.json"
|
||||
|
||||
# Persist the original body verbatim - keeps key ordering and avoids any
|
||||
# round-trip surprises from ConvertTo-Json.
|
||||
Set-Content -Path $path -Value $body -Encoding utf8
|
||||
|
||||
Write-Host "Saved $($body.Length) bytes to $path"
|
||||
@@ -0,0 +1,68 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Collects env vars from PowerShell and bash, packages them into a single
|
||||
JSON object, and POSTs the result to a Webhook Server endpoint.
|
||||
|
||||
.DESCRIPTION
|
||||
Output JSON shape:
|
||||
{
|
||||
"host": "<computername>",
|
||||
"capturedAt":"2026-05-08T12:34:56Z",
|
||||
"pwsh": { "VAR": "value", ... },
|
||||
"bash": { "VAR": "value", ... }
|
||||
}
|
||||
|
||||
Pair this with `save-env-vars.ps1` on the server side - configure an
|
||||
endpoint with StdinJson enabled and that script as the executable.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $WebhookUrl = 'http://localhost:8080/hook/env-dump',
|
||||
[string] $Bearer = '',
|
||||
[string] $BashExe = 'bash'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- pwsh env vars --------------------------------------------------------
|
||||
$pwshVars = [ordered]@{}
|
||||
Get-ChildItem Env: | Sort-Object Name | ForEach-Object {
|
||||
$pwshVars[$_.Name] = $_.Value
|
||||
}
|
||||
|
||||
# --- bash env vars --------------------------------------------------------
|
||||
$bashVars = [ordered]@{}
|
||||
$bashCmd = Get-Command $BashExe -ErrorAction SilentlyContinue
|
||||
if ($null -ne $bashCmd) {
|
||||
# `env -0` separates entries with NUL so values containing newlines stay intact.
|
||||
$raw = & $bashCmd.Source -c 'env -0' 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $raw) {
|
||||
foreach ($entry in ($raw -split "`0")) {
|
||||
if ([string]::IsNullOrEmpty($entry)) { continue }
|
||||
$eq = $entry.IndexOf('=')
|
||||
if ($eq -lt 1) { continue }
|
||||
$bashVars[$entry.Substring(0, $eq)] = $entry.Substring($eq + 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warning "bash not found on PATH (looked for '$BashExe'); 'bash' section will be empty."
|
||||
}
|
||||
|
||||
# --- assemble payload -----------------------------------------------------
|
||||
$payload = [ordered]@{
|
||||
host = $env:COMPUTERNAME
|
||||
capturedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
pwsh = $pwshVars
|
||||
bash = $bashVars
|
||||
}
|
||||
|
||||
$json = $payload | ConvertTo-Json -Depth 5 -Compress
|
||||
|
||||
# --- POST -----------------------------------------------------------------
|
||||
$headers = @{ 'Content-Type' = 'application/json' }
|
||||
if ($Bearer) { $headers['Authorization'] = "Bearer $Bearer" }
|
||||
|
||||
Write-Host "POST $WebhookUrl ($($json.Length) bytes; pwsh=$($pwshVars.Count), bash=$($bashVars.Count))"
|
||||
$response = Invoke-RestMethod -Method Post -Uri $WebhookUrl -Headers $headers -Body $json
|
||||
$response | ConvertTo-Json -Depth 5
|
||||
@@ -0,0 +1,90 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Webhook-server-side receiver: posts a Slack/Teams notification when a VPG
|
||||
fires its pre or post recovery script.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads the JSON body from stdin (the payload sent by zerto-zvma-send.ps1),
|
||||
builds a phase-aware message, and posts it to an Incoming Webhook URL.
|
||||
|
||||
The message highlights:
|
||||
- VPG name + operation type (Test / Failover / Move / ...)
|
||||
- Whether ZertoForce was set (only relevant pre)
|
||||
- VM display names included in the run
|
||||
- Phase (pre vs post) so you can see the bracketing in chat
|
||||
|
||||
Wire up two endpoints:
|
||||
/hook/zerto-pre -> this script with -Phase pre (pass via args)
|
||||
/hook/zerto-post -> this script with -Phase post
|
||||
|
||||
Or one endpoint per phase, each pointing at this script. The script reads
|
||||
`phase` from the JSON body, so the -Phase param is optional.
|
||||
|
||||
.NOTES
|
||||
Compatible with:
|
||||
- Slack Incoming Webhooks (posts {"text": "..."})
|
||||
- Teams legacy connector "Incoming Webhook" (same body shape)
|
||||
- Discord webhooks (use ?wait=true for body, but text is "content" not
|
||||
"text" - tweak below)
|
||||
|
||||
Endpoint config:
|
||||
ExecutorType: WindowsPowerShell or PowerShell 7
|
||||
ScriptPath: C:\scripts\zerto-receiver-notify.ps1
|
||||
DataPassing: [x] Stdin JSON
|
||||
ResponseMode: async (we don't need to block the VPG on a chat post)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $NotifyUrl = $env:NOTIFY_URL # set on the Webhook Server host, or hardcode below
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $NotifyUrl) {
|
||||
# Fall back to a hardcoded URL if NOTIFY_URL env var isn't set.
|
||||
# Replace with your Slack/Teams Incoming Webhook URL.
|
||||
$NotifyUrl = 'https://hooks.slack.com/services/REPLACE/ME/HERE'
|
||||
}
|
||||
|
||||
$body = [Console]::In.ReadToEnd()
|
||||
if ([string]::IsNullOrWhiteSpace($body)) {
|
||||
Write-Error 'Empty stdin - expected JSON body from the webhook server.'
|
||||
exit 2
|
||||
}
|
||||
$p = $body | ConvertFrom-Json
|
||||
|
||||
$z = $p.zerto
|
||||
$phase = if ($p.phase) { $p.phase } else { 'unknown' }
|
||||
$op = if ($z.operation) { $z.operation } else { 'unknown' }
|
||||
|
||||
# Pick an icon based on operation. Test is benign; Failover/Move are real.
|
||||
$icon = switch ($op) {
|
||||
'Test' { ':test_tube:' }
|
||||
'Failover' { ':rotating_light:' }
|
||||
'Move' { ':truck:' }
|
||||
default { ':information_source:' }
|
||||
}
|
||||
|
||||
$forceTag = if ($phase -eq 'pre' -and $z.force -eq 'Yes') { ' *(FORCE)*' } else { '' }
|
||||
|
||||
$lines = @(
|
||||
"$icon *Zerto $op* - phase: ``$phase``$forceTag"
|
||||
"VPG: ``$($z.vpgName)``"
|
||||
"VMs: ``$($z.vmDisplayNames)``"
|
||||
"Hypervisor mgr: ``$($z.hypervisorManagerIP):$($z.hypervisorManagerPort)``"
|
||||
"Captured: $($p.capturedAt) (from $($p.host))"
|
||||
)
|
||||
$text = $lines -join "`n"
|
||||
|
||||
$payload = @{ text = $text } | ConvertTo-Json -Compress
|
||||
|
||||
try {
|
||||
Invoke-RestMethod -Method Post -Uri $NotifyUrl `
|
||||
-ContentType 'application/json' -Body $payload -TimeoutSec 10 | Out-Null
|
||||
Write-Host "[$phase] notified $op for VPG '$($z.vpgName)'"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Notification post failed: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Webhook-server-side receiver: post-failover VM health check. Pings each
|
||||
VM in the VPG and probes a configurable TCP port; writes a per-run
|
||||
report to disk.
|
||||
|
||||
.DESCRIPTION
|
||||
Intended for the POST-recovery webhook only - on a Test or real Failover,
|
||||
once the VMs are powered on at the recovery site, we can spot-check that
|
||||
they responded to ICMP and that a known port is listening (RDP, SSH,
|
||||
HTTP, etc).
|
||||
|
||||
Skips itself entirely on the pre-recovery phase (nothing's running yet)
|
||||
and on $z.operation values that don't bring VMs up.
|
||||
|
||||
Wire up one endpoint:
|
||||
/hook/zerto-post -> this script
|
||||
DataPassing: [x] Stdin JSON
|
||||
ResponseMode: async
|
||||
|
||||
.NOTES
|
||||
VmDisplayNames is a comma-separated list for multi-VM VPGs; some Zerto
|
||||
versions wrap each name in parentheses (e.g. "vm1(1)(1)(1)") to disambig
|
||||
after Test failover. We strip the trailing parenthesised suffixes when
|
||||
resolving DNS so the recovered hostname is what we ping.
|
||||
|
||||
Endpoint config:
|
||||
ExecutorType: WindowsPowerShell or PowerShell 7
|
||||
ScriptPath: C:\scripts\zerto-receiver-vm-healthcheck.ps1
|
||||
DataPassing: [x] Stdin JSON
|
||||
ResponseMode: async
|
||||
TimeoutSeconds: 120 (this script does network I/O - bump from default)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int] $ProbePort = 3389, # RDP. Use 22 for Linux, 80/443 for web tier.
|
||||
[int] $PingTimeout = 2000, # ms
|
||||
[string] $ReportDir = 'C:\ProgramData\WebhookServer\zerto-healthchecks'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- read + parse payload -------------------------------------------------
|
||||
$body = [Console]::In.ReadToEnd()
|
||||
if ([string]::IsNullOrWhiteSpace($body)) {
|
||||
Write-Error 'Empty stdin.'
|
||||
exit 2
|
||||
}
|
||||
$p = $body | ConvertFrom-Json
|
||||
|
||||
$z = $p.zerto
|
||||
$phase = $p.phase
|
||||
$op = $z.operation
|
||||
|
||||
# Skip if this isn't a post-phase run for an op that powers VMs on.
|
||||
if ($phase -ne 'post') {
|
||||
Write-Host "Phase '$phase' - nothing to check yet, skipping."
|
||||
exit 0
|
||||
}
|
||||
if ($op -notin @('Test','Failover','Move','FailoverBeforeCommit','FailoverDuringCommit')) {
|
||||
Write-Host "Operation '$op' doesn't bring VMs up; skipping."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- parse VM list --------------------------------------------------------
|
||||
function Strip-ZertoSuffix {
|
||||
param([string] $name)
|
||||
# "ubuntu-2404(1)(1)(1)" -> "ubuntu-2404"
|
||||
return ($name -replace '(\([^)]*\))+\s*$','').Trim()
|
||||
}
|
||||
|
||||
$rawNames = ($z.vmDisplayNames -split '[,;]') | ForEach-Object { $_.Trim() } |
|
||||
Where-Object { $_ }
|
||||
if (-not $rawNames) {
|
||||
Write-Warning 'No VM display names in payload - nothing to check.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- run checks -----------------------------------------------------------
|
||||
$results = foreach ($raw in $rawNames) {
|
||||
$clean = Strip-ZertoSuffix $raw
|
||||
$pingOk = $false
|
||||
$portOk = $false
|
||||
$err = $null
|
||||
|
||||
try {
|
||||
$pingOk = (Test-Connection -ComputerName $clean -Count 1 -Quiet `
|
||||
-TimeoutSeconds ([math]::Max(1, [int]($PingTimeout / 1000))) `
|
||||
-ErrorAction Stop)
|
||||
} catch { $err = "ping: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$portOk = (Test-NetConnection -ComputerName $clean -Port $ProbePort `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue)
|
||||
} catch { $err = ($err, "port: $($_.Exception.Message)") -ne $null -join '; ' }
|
||||
|
||||
[pscustomobject]@{
|
||||
DisplayName = $raw
|
||||
Resolved = $clean
|
||||
PingOk = $pingOk
|
||||
PortOk = $portOk
|
||||
ProbePort = $ProbePort
|
||||
Error = $err
|
||||
}
|
||||
}
|
||||
|
||||
# --- write report ---------------------------------------------------------
|
||||
if (-not (Test-Path $ReportDir)) {
|
||||
New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$safeVpg = ($z.vpgName -replace '[^A-Za-z0-9_.-]','_')
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
|
||||
$file = Join-Path $ReportDir "$safeVpg-$op-$stamp.json"
|
||||
|
||||
$report = [ordered]@{
|
||||
vpgName = $z.vpgName
|
||||
operation = $op
|
||||
phase = $phase
|
||||
capturedAt = $p.capturedAt
|
||||
completedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
probePort = $ProbePort
|
||||
vms = $results
|
||||
summary = @{
|
||||
total = $results.Count
|
||||
pingFailures = ($results | Where-Object { -not $_.PingOk }).Count
|
||||
portFailures = ($results | Where-Object { -not $_.PortOk }).Count
|
||||
}
|
||||
}
|
||||
$report | ConvertTo-Json -Depth 5 | Set-Content -Path $file -Encoding utf8
|
||||
|
||||
# Console output goes back via the webhook callback (if configured) so the
|
||||
# Zerto-side script log shows a quick summary even though the call is async.
|
||||
$bad = $report.summary.pingFailures + $report.summary.portFailures
|
||||
Write-Host "[$op/$phase] $($z.vpgName): $($results.Count) VM(s), $bad issue(s). Report: $file"
|
||||
|
||||
# Exit non-zero if anything failed, so the webhook server's failOnNonZeroExit
|
||||
# turns this into a 502 for the caller (and shows up in the run history).
|
||||
if ($bad -gt 0) { exit 1 }
|
||||
@@ -0,0 +1,74 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Zerto pre/post script (ZVMA / Linux scripts-service edition). Reads the
|
||||
Zerto-injected environment variables and POSTs them to a Webhook Server
|
||||
endpoint as a structured JSON payload.
|
||||
|
||||
.DESCRIPTION
|
||||
Drop into a VPG's Recovery Scripts in the ZVM UI:
|
||||
VPG settings -> Recovery -> Scripts -> Pre / Post Recovery Script
|
||||
Path: /app/scripts-files/zerto-zvma-send.ps1
|
||||
Parameters: -Phase pre (or -Phase post on the post-recovery slot)
|
||||
|
||||
Configure $WebhookUrl + $Bearer (or use the -WebhookUrl / -Bearer params
|
||||
so one script file can serve multiple VPGs / endpoints).
|
||||
|
||||
Async by default - the call returns 202 in milliseconds and the actual
|
||||
work runs in the webhook server's background, so the VPG sequence is
|
||||
never blocked by slow downstream actions (DNS, notifications, etc.).
|
||||
|
||||
.NOTES
|
||||
The scripts-service container has pwsh 7 and curl available. This script
|
||||
uses Invoke-RestMethod to keep things native to PowerShell.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('pre', 'post')]
|
||||
[string] $Phase,
|
||||
|
||||
[string] $WebhookUrl = 'http://192.168.50.250:8080/hook/zerto-{phase}',
|
||||
[string] $Bearer = '',
|
||||
[int] $TimeoutSec = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Resolve {phase} placeholder so one URL template can route to /hook/zerto-pre
|
||||
# and /hook/zerto-post. Plain URLs without the token work too.
|
||||
$url = $WebhookUrl.Replace('{phase}', $Phase)
|
||||
|
||||
$payload = [ordered]@{
|
||||
phase = $Phase
|
||||
capturedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
host = $env:HOSTNAME # scripts-service pod name
|
||||
zerto = [ordered]@{
|
||||
vpgName = $env:ZertoVPGName
|
||||
internalVpgName = $env:ZertoInternalVpgName
|
||||
operation = $env:ZertoOperation # Test / Failover / Move / ...
|
||||
force = $env:ZertoForce # only meaningful pre
|
||||
vmDisplayNames = $env:VmDisplayNames # comma-separated for multi-VM VPGs
|
||||
hypervisorManagerIP = $env:ZertoHypervisorManagerIP
|
||||
hypervisorManagerPort = $env:ZertoHypervisorManagerPort
|
||||
outputDir = $env:ZertoOutputDir
|
||||
workingDir = $env:ZertoWorkingDir
|
||||
}
|
||||
}
|
||||
|
||||
$body = $payload | ConvertTo-Json -Depth 4 -Compress
|
||||
|
||||
$headers = @{ 'Content-Type' = 'application/json' }
|
||||
if ($Bearer) { $headers['Authorization'] = "Bearer $Bearer" }
|
||||
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Method Post -Uri $url -Headers $headers `
|
||||
-Body $body -TimeoutSec $TimeoutSec
|
||||
Write-Host "[$Phase] webhook accepted: $($resp | ConvertTo-Json -Compress)"
|
||||
}
|
||||
catch {
|
||||
# Pre/post failures should not block the VPG operation. Log loudly and exit 0
|
||||
# so Zerto's recovery sequence continues. Flip to `exit 1` if you want a
|
||||
# webhook outage to fail the failover.
|
||||
Write-Warning "[$Phase] webhook call failed: $($_.Exception.Message)"
|
||||
}
|
||||
Reference in New Issue
Block a user