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:
2026-05-08 14:16:07 -04:00
parent 4954e94d08
commit 821ff9b9ef
8 changed files with 701 additions and 4 deletions
+46
View File
@@ -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"
+68
View File
@@ -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 }
+74
View File
@@ -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)"
}