Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b41cc6b6c | |||
| 8512201ccc | |||
| c8aa711f8c | |||
| 821ff9b9ef | |||
| 4954e94d08 | |||
| 10341c63cb | |||
| 10b15fc17c | |||
| 1229c52ecf | |||
| 14d1bdc461 | |||
| 7c164ab3b3 | |||
| d89290aedb | |||
| ddd36a9116 | |||
| b66dd245c0 | |||
| 1ea724cd1f | |||
| a2bd338839 |
@@ -0,0 +1,100 @@
|
|||||||
|
name: Release (Gitea)
|
||||||
|
|
||||||
|
# Lives in .gitea/workflows/ so it runs on Gitea Actions only. The GitHub-side
|
||||||
|
# release lives in .github/workflows/release.yml.
|
||||||
|
#
|
||||||
|
# Triggered automatically on v* tag pushes; can also be invoked manually via
|
||||||
|
# workflow_dispatch with a version override (useful for testing the runner
|
||||||
|
# without bumping the project version).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to build (e.g. 0.1.4). Defaults to Directory.Build.props.'
|
||||||
|
required: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-installer:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: ver
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if ('${{ github.event_name }}' -eq 'push') {
|
||||||
|
$v = '${{ github.ref_name }}'.TrimStart('v')
|
||||||
|
} elseif ('${{ inputs.version }}') {
|
||||||
|
$v = '${{ inputs.version }}'
|
||||||
|
} else {
|
||||||
|
[xml]$p = Get-Content Directory.Build.props
|
||||||
|
$v = $p.Project.PropertyGroup.Version
|
||||||
|
}
|
||||||
|
"version=$v" | Out-File $env:GITHUB_OUTPUT -Append
|
||||||
|
Write-Host "Building version $v"
|
||||||
|
|
||||||
|
- name: Restore + test
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
dotnet restore WebhookServer.sln
|
||||||
|
dotnet test WebhookServer.sln -c Release
|
||||||
|
|
||||||
|
- name: Ensure Inno Setup is installed
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if (-not (Get-Command iscc -ErrorAction SilentlyContinue) -and `
|
||||||
|
-not (Test-Path 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe') -and `
|
||||||
|
-not (Test-Path 'C:\Program Files\Inno Setup 6\ISCC.exe')) {
|
||||||
|
choco install innosetup --no-progress -y
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Build installer
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }}
|
||||||
|
|
||||||
|
# actions/upload-artifact@v4 is GitHub-only ("GHESNotSupportedError" on
|
||||||
|
# Gitea). The release-creation step below attaches the .exe via Gitea's
|
||||||
|
# API directly, which is the only place we actually need to surface it.
|
||||||
|
|
||||||
|
- name: Create Gitea release with installer attached
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
$version = '${{ steps.ver.outputs.version }}'
|
||||||
|
$tag = '${{ github.ref_name }}'
|
||||||
|
$repo = '${{ github.repository }}'
|
||||||
|
$serverUrl = '${{ github.server_url }}'
|
||||||
|
$apiBase = "$serverUrl/api/v1/repos/$repo"
|
||||||
|
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||||
|
|
||||||
|
# 1. Create the release.
|
||||||
|
$isPre = $version.StartsWith('0.')
|
||||||
|
$createBody = @{
|
||||||
|
tag_name = $tag
|
||||||
|
name = "Webhook Server $version"
|
||||||
|
body = "Automated build via Gitea Actions runner."
|
||||||
|
draft = $false
|
||||||
|
prerelease = $isPre
|
||||||
|
} | ConvertTo-Json
|
||||||
|
$rel = Invoke-RestMethod -Uri "$apiBase/releases" -Method Post `
|
||||||
|
-Headers $headers -ContentType 'application/json' -Body $createBody
|
||||||
|
Write-Host "Created release id=$($rel.id) tag=$tag"
|
||||||
|
|
||||||
|
# 2. Attach the installer.
|
||||||
|
$file = Get-Item "dist/WebhookServer-Setup-$version.exe"
|
||||||
|
$uploadUri = "$apiBase/releases/$($rel.id)/assets?name=$($file.Name)"
|
||||||
|
Invoke-RestMethod -Uri $uploadUri -Method Post -Headers $headers `
|
||||||
|
-ContentType 'application/octet-stream' -InFile $file.FullName | Out-Null
|
||||||
|
Write-Host "Uploaded $($file.Name) ($([math]::Round($file.Length / 1MB, 2)) MB) to $tag"
|
||||||
@@ -5,6 +5,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-installer:
|
build-installer:
|
||||||
|
# Gitea reads .github/workflows/ for compatibility, but the create-release
|
||||||
|
# step uses a GitHub-only action. Skip the whole job on non-GitHub runners.
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # needed to create releases / upload assets
|
contents: write # needed to create releases / upload assets
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
|
# Gitea reads .github/workflows/ for compatibility, but this workflow
|
||||||
|
# pushes to a GitHub-hosted wiki. Skip on non-GitHub runners; the Gitea
|
||||||
|
# wiki is synced separately via scripts/sync-wiki.ps1.
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.2</Version>
|
<Version>0.1.5</Version>
|
||||||
<Authors>Justin Paul</Authors>
|
<Authors>Justin Paul</Authors>
|
||||||
<Company>Justin Paul</Company>
|
<Company>Justin Paul</Company>
|
||||||
<Product>Webhook Server</Product>
|
<Product>Webhook Server</Product>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Designed for sysadmins who want to wire up tools like **Zerto pre/post scripts**
|
|||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
1. **Download** the latest installer: <https://github.com/recklessop/webhook-server/releases/latest>
|
1. **Download** the latest installer: <https://github.com/recklessop/webhook-server/releases/latest>
|
||||||
2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service.
|
2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service. The installer also downloads + installs the **.NET 8 runtimes** (ASP.NET Core + Desktop) if they're missing — fresh Windows Server installs need this.
|
||||||
3. **Open Webhook Server** from the Start Menu (auto-elevates).
|
3. **Open Webhook Server** from the Start Menu (auto-elevates).
|
||||||
4. **File → New endpoint**, configure a slug + script, save, hit the URL.
|
4. **File → New endpoint**, configure a slug + script, save, hit the URL.
|
||||||
|
|
||||||
@@ -61,11 +61,12 @@ Everything you need to operate the server:
|
|||||||
|
|
||||||
Recipes:
|
Recipes:
|
||||||
|
|
||||||
- [Zerto failover post-script → DNS + service checks](docs/recipes/zerto-pre-post-scripts.md) ← **canonical use case**
|
- [Zerto failover post-script → DNS + service checks](docs/recipes/zerto-pre-post-scripts.md) ← **canonical use case** (Windows ZVM)
|
||||||
|
- [Zerto ZVMA (Kubernetes) pre/post → notify + VM health check](docs/recipes/zerto-zvma-pre-post.md) — same pattern for the in-cluster scripts-service
|
||||||
- [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md)
|
- [GitHub-style HMAC-signed webhook](docs/recipes/github-style-hmac.md)
|
||||||
- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md)
|
- [Pop UI on the user's desktop](docs/recipes/ui-on-desktop.md)
|
||||||
|
|
||||||
A ready-to-drop-in Zerto-side script is included at [`scripts/examples/zerto-post-failover.ps1`](scripts/examples/zerto-post-failover.ps1).
|
Ready-to-drop-in Zerto-side scripts are included at [`scripts/examples/zerto-post-failover.ps1`](scripts/examples/zerto-post-failover.ps1) (Windows ZVM) and [`scripts/examples/zerto-zvma-send.ps1`](scripts/examples/zerto-zvma-send.ps1) (ZVMA / Kubernetes); receiver examples for the ZVMA recipe ship as [`zerto-receiver-notify.ps1`](scripts/examples/zerto-receiver-notify.ps1) and [`zerto-receiver-vm-healthcheck.ps1`](scripts/examples/zerto-receiver-vm-healthcheck.ps1).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -19,11 +19,12 @@ Webhook Server is a Windows service that runs a script (PowerShell, cmd, or any
|
|||||||
|
|
||||||
## Recipes (cookbook style)
|
## Recipes (cookbook style)
|
||||||
|
|
||||||
- [Zerto failover post-script → DNS + service checks](recipes/zerto-pre-post-scripts.md) ← canonical use case
|
- [Zerto failover post-script → DNS + service checks](recipes/zerto-pre-post-scripts.md) ← canonical use case (Windows ZVM)
|
||||||
|
- [Zerto ZVMA (Kubernetes) pre/post → notify + VM health check](recipes/zerto-zvma-pre-post.md) — same pattern for the in-cluster scripts-service
|
||||||
- [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md)
|
- [GitHub-style HMAC-signed webhook](recipes/github-style-hmac.md)
|
||||||
- [Pop UI on the user's desktop](recipes/ui-on-desktop.md)
|
- [Pop UI on the user's desktop](recipes/ui-on-desktop.md)
|
||||||
|
|
||||||
The flagship Zerto recipe also ships with a **ready-to-use Zerto-side post-script** at [`scripts/examples/zerto-post-failover.ps1`](../scripts/examples/zerto-post-failover.ps1).
|
The flagship Zerto recipe ships with a ready-to-use Zerto-side post-script at [`scripts/examples/zerto-post-failover.ps1`](../scripts/examples/zerto-post-failover.ps1). The ZVMA recipe ships with [`zerto-zvma-send.ps1`](../scripts/examples/zerto-zvma-send.ps1) (sender) plus [`zerto-receiver-notify.ps1`](../scripts/examples/zerto-receiver-notify.ps1) and [`zerto-receiver-vm-healthcheck.ps1`](../scripts/examples/zerto-receiver-vm-healthcheck.ps1) (receivers).
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,34 @@ This page covers a fresh install. If you already have Webhook Server installed,
|
|||||||
|
|
||||||
- Windows 10, Windows 11, or Windows Server 2019 / 2022 / 2025
|
- Windows 10, Windows 11, or Windows Server 2019 / 2022 / 2025
|
||||||
- Administrator rights to install the service and to run the GUI
|
- Administrator rights to install the service and to run the GUI
|
||||||
|
- **.NET 8 runtimes** (the installer downloads + installs them automatically if missing — see below)
|
||||||
- (Optional, only if you publish from source) .NET 8 SDK
|
- (Optional, only if you publish from source) .NET 8 SDK
|
||||||
|
|
||||||
The installer is **x64 only**. There is no x86 build.
|
The installer is **x64 only**. There is no x86 build.
|
||||||
|
|
||||||
|
### .NET 8 runtimes
|
||||||
|
|
||||||
|
Webhook Server is published as framework-dependent (so the installer stays small) and needs two .NET 8 runtimes on the target machine:
|
||||||
|
|
||||||
|
| Runtime | Used by | Auto-installed by setup |
|
||||||
|
|---|---|---|
|
||||||
|
| ASP.NET Core 8 Runtime (`Microsoft.AspNetCore.App` 8.x) | the Service / Kestrel | Yes |
|
||||||
|
| .NET Desktop Runtime 8 (`Microsoft.WindowsDesktop.App` 8.x) | the WPF GUI | Yes |
|
||||||
|
|
||||||
|
A clean Windows Server install has neither. The installer detects what's missing and downloads + installs each one silently before copying our files. If the machine has no internet access, install them manually first:
|
||||||
|
|
||||||
|
- ASP.NET Core 8 Runtime — <https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe>
|
||||||
|
- .NET Desktop Runtime 8 — <https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe>
|
||||||
|
|
||||||
|
Run each with `/install /quiet /norestart` for unattended installs, or just double-click. A reboot is rarely required.
|
||||||
|
|
||||||
|
To check what's already installed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet --list-runtimes
|
||||||
|
# expect to see Microsoft.AspNetCore.App 8.x.y and Microsoft.WindowsDesktop.App 8.x.y
|
||||||
|
```
|
||||||
|
|
||||||
## 1. Download
|
## 1. Download
|
||||||
|
|
||||||
Grab the latest installer from the GitHub Releases page:
|
Grab the latest installer from the GitHub Releases page:
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Recipe: Zerto ZVMA (Kubernetes) pre/post scripts → notify + VM health check
|
||||||
|
|
||||||
|
> Companion to [Zerto failover post-script → DNS + service checks](zerto-pre-post-scripts.md).
|
||||||
|
> That recipe targets the **Windows ZVM** (the older deployment, where the
|
||||||
|
> Zerto-side script is a `.ps1` calling `curl.exe`). **This** recipe targets
|
||||||
|
> the **ZVMA on Kubernetes** — the newer deployment, where pre/post scripts
|
||||||
|
> run inside the in-cluster `scripts-service` container (Linux + pwsh 7).
|
||||||
|
> The webhook-server side is the same Windows service in both cases; only
|
||||||
|
> the Zerto-side runtime differs.
|
||||||
|
|
||||||
|
## What we're building
|
||||||
|
|
||||||
|
ZVMA's `scripts-service` pod runs your VPG pre/post scripts inside a Linux
|
||||||
|
container. It exposes a small set of `Zerto*` environment variables, and we
|
||||||
|
want to:
|
||||||
|
|
||||||
|
1. POST those variables to a Webhook Server endpoint at the start (pre) and
|
||||||
|
end (post) of every VPG operation, and
|
||||||
|
2. On the receiving Windows host, do something useful with them — at minimum
|
||||||
|
a chat notification, and on `post` a quick health check of the VMs that
|
||||||
|
just powered on.
|
||||||
|
|
||||||
|
The endpoints are **Async**, so the Zerto VPG sequence is never blocked by
|
||||||
|
slow downstream actions (notifications, port probes, etc.).
|
||||||
|
|
||||||
|
```
|
||||||
|
Zerto VPG operation starts
|
||||||
|
|
|
||||||
|
+-- ZVMA scripts-service container runs:
|
||||||
|
| /app/scripts-files/zerto-zvma-send.ps1 -Phase pre
|
||||||
|
| -> POST http://webhook.dr/hook/zerto-pre (async, returns 202)
|
||||||
|
|
|
||||||
|
+-- VMs come up at recovery site
|
||||||
|
|
|
||||||
|
+-- ZVMA scripts-service container runs:
|
||||||
|
/app/scripts-files/zerto-zvma-send.ps1 -Phase post
|
||||||
|
-> POST http://webhook.dr/hook/zerto-post (async, returns 202)
|
||||||
|
|
||||||
|
(meanwhile, on the webhook server)
|
||||||
|
/hook/zerto-pre -> Slack/Teams notification ("Test failover starting...")
|
||||||
|
/hook/zerto-post -> Slack/Teams notification + ping/port probe each VM,
|
||||||
|
write a JSON report to disk, exit non-zero on failure.
|
||||||
|
```
|
||||||
|
|
||||||
|
## What ZVMA exposes
|
||||||
|
|
||||||
|
Captured from a real Test failover; same set is present in pre and post:
|
||||||
|
|
||||||
|
| Variable | Example | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `ZertoVPGName` | `ubuntu-2404-local` | The VPG that fired the script |
|
||||||
|
| `ZertoInternalVpgName` | `ubuntu-2404-local` | Usually identical to `ZertoVPGName` |
|
||||||
|
| `ZertoOperation` | `Test` | `Test` / `Failover` / `Move` / `FailoverBeforeCommit` / `FailoverDuringCommit` |
|
||||||
|
| `ZertoForce` | `Yes` (pre) / `No` (post) | Set to `Yes` only during the pre phase when force mode is on; reset to `No` by post |
|
||||||
|
| `VmDisplayNames` | `ubuntu-2404(1)(1)(1)` | Comma-separated for multi-VM VPGs; Test failovers add `(N)` suffixes |
|
||||||
|
| `ZertoHypervisorManagerIP` | `192.168.50.20` | The vCenter / Hyper-V manager ZVMA is talking to |
|
||||||
|
| `ZertoHypervisorManagerPort` | `443` | |
|
||||||
|
| `ZertoOutputDir` | `/app/scripts-output` | Container-side output dir (written back to ZVMA via PVC) |
|
||||||
|
| `ZertoWorkingDir` | `/app/scripts-files` | Where script files live in-container |
|
||||||
|
|
||||||
|
Branch on `ZertoOperation` to differentiate Test runs from real failovers.
|
||||||
|
**`ZertoForce` is only meaningful during the pre phase** — capture it there
|
||||||
|
if you need it later, because by post it's been reset.
|
||||||
|
|
||||||
|
## 1. The Zerto-side script (sender)
|
||||||
|
|
||||||
|
A ready-to-use script ships in this repo at
|
||||||
|
[`scripts/examples/zerto-zvma-send.ps1`](../../scripts/examples/zerto-zvma-send.ps1).
|
||||||
|
Place it where the `scripts-service` pod can read it — typically the
|
||||||
|
`scripts-service-scripts-files-pvc`, mounted at `/app/scripts-files/` — and
|
||||||
|
wire it into the VPG twice:
|
||||||
|
|
||||||
|
> **VPG settings → Recovery → Scripts → Pre-Recovery Script**
|
||||||
|
> Path: `/app/scripts-files/zerto-zvma-send.ps1`
|
||||||
|
> Parameters: `-Phase pre`
|
||||||
|
>
|
||||||
|
> **VPG settings → Recovery → Scripts → Post-Recovery Script**
|
||||||
|
> Path: `/app/scripts-files/zerto-zvma-send.ps1`
|
||||||
|
> Parameters: `-Phase post`
|
||||||
|
|
||||||
|
The default `$WebhookUrl` includes `{phase}` so one script + one URL config
|
||||||
|
serves both phases — `http://webhook.dr/hook/zerto-{phase}` becomes
|
||||||
|
`/hook/zerto-pre` and `/hook/zerto-post` automatically. Override with
|
||||||
|
`-WebhookUrl` and `-Bearer` if you'd rather pass them per-VPG.
|
||||||
|
|
||||||
|
The script POSTs a single JSON object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phase": "pre",
|
||||||
|
"capturedAt": "2026-05-08T17:45:54Z",
|
||||||
|
"host": "scripts-service-f9b6cb7-4xbxq",
|
||||||
|
"zerto": {
|
||||||
|
"vpgName": "ubuntu-2404-local",
|
||||||
|
"internalVpgName": "ubuntu-2404-local",
|
||||||
|
"operation": "Test",
|
||||||
|
"force": "Yes",
|
||||||
|
"vmDisplayNames": "ubuntu-2404(1)(1)(1)",
|
||||||
|
"hypervisorManagerIP": "192.168.50.20",
|
||||||
|
"hypervisorManagerPort": "443",
|
||||||
|
"outputDir": "/app/scripts-output",
|
||||||
|
"workingDir": "/app/scripts-files"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A webhook outage **does not fail the VPG** — the script catches and exits 0.
|
||||||
|
Comment in the file shows how to flip that to strict mode if you'd rather a
|
||||||
|
webhook outage abort the failover.
|
||||||
|
|
||||||
|
## 2. The webhook-server-side scripts (receivers)
|
||||||
|
|
||||||
|
Two examples ship in the repo. Both read the JSON body from stdin (the
|
||||||
|
webhook server delivers the body to the script's stdin when **JSON body to
|
||||||
|
stdin** is ticked on the endpoint).
|
||||||
|
|
||||||
|
### a. Slack/Teams notification — both phases
|
||||||
|
|
||||||
|
[`scripts/examples/zerto-receiver-notify.ps1`](../../scripts/examples/zerto-receiver-notify.ps1)
|
||||||
|
posts a single-line summary to a Slack or Teams Incoming Webhook URL. It
|
||||||
|
picks an icon based on `ZertoOperation`:
|
||||||
|
|
||||||
|
- `Test` → 🧪 — benign, expected
|
||||||
|
- `Failover` → 🚨 — real production event
|
||||||
|
- `Move` → 🚚 — planned migration
|
||||||
|
|
||||||
|
…and highlights `ZertoForce=Yes` on the **pre** message so you can see at
|
||||||
|
a glance whether the operation was force-flagged.
|
||||||
|
|
||||||
|
Set the destination via `NOTIFY_URL` env var on the webhook host, or
|
||||||
|
hardcode at the top of the script.
|
||||||
|
|
||||||
|
### b. Post-recovery VM health check — post phase only
|
||||||
|
|
||||||
|
[`scripts/examples/zerto-receiver-vm-healthcheck.ps1`](../../scripts/examples/zerto-receiver-vm-healthcheck.ps1)
|
||||||
|
runs only on `phase=post` for operations that bring VMs up
|
||||||
|
(`Test`/`Failover`/`Move`/`FailoverBeforeCommit`/`FailoverDuringCommit`).
|
||||||
|
For each name in `VmDisplayNames` it:
|
||||||
|
|
||||||
|
1. Strips the trailing `(1)(1)(1)` suffix Zerto adds on Test failovers, so
|
||||||
|
DNS resolution targets the actual hostname.
|
||||||
|
2. Pings (`Test-Connection`).
|
||||||
|
3. Probes a configurable TCP port (`-ProbePort`, default `3389` for RDP;
|
||||||
|
use `22` for SSH or `443` for the web tier).
|
||||||
|
4. Writes a JSON report to
|
||||||
|
`C:\ProgramData\WebhookServer\zerto-healthchecks\<vpg>-<op>-<utcstamp>.json`.
|
||||||
|
5. Exits non-zero if any VM failed either probe — which surfaces in the
|
||||||
|
webhook server's run history (and outbound callback, if configured).
|
||||||
|
|
||||||
|
Bump the endpoint's **Timeout (sec)** to `120` when wiring this in, since
|
||||||
|
network probes can take a while.
|
||||||
|
|
||||||
|
## 3. Configure the endpoints in the GUI
|
||||||
|
|
||||||
|
Two endpoints. Identical except for the slug, the script, and (for the
|
||||||
|
healthcheck) the timeout.
|
||||||
|
|
||||||
|
### `zerto-pre`
|
||||||
|
|
||||||
|
| Section | Setting | Value |
|
||||||
|
|---|---|---|
|
||||||
|
| Identity | Slug | `zerto-pre` |
|
||||||
|
| Identity | Description | "Zerto pre-recovery: chat notification" |
|
||||||
|
| Auth | Mode | **Bearer** |
|
||||||
|
| Auth | Bearer secret | generate a 32-byte random string; reuse for `zerto-post` |
|
||||||
|
| Allowed clients | (one per line) | the IP of the K8s node running `scripts-service` (e.g. `192.168.50.30`) |
|
||||||
|
| Executor | Type | **Windows PowerShell** (or PowerShell 7) |
|
||||||
|
| Executor | Script path | `C:\scripts\zerto-receiver-notify.ps1` |
|
||||||
|
| Data passing | JSON body to stdin | ✓ |
|
||||||
|
| Run as | Identity | **Service** |
|
||||||
|
| Response | Mode | **Async** |
|
||||||
|
| Response | Timeout (sec) | `30` |
|
||||||
|
| Response | Fail on non-zero exit | unticked *(async hooks have no caller to receive a 502)* |
|
||||||
|
|
||||||
|
### `zerto-post`
|
||||||
|
|
||||||
|
Same as above, except:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Slug | `zerto-post` |
|
||||||
|
| Description | "Zerto post-recovery: notify + VM health check" |
|
||||||
|
| Script path | a **wrapper** that calls both receiver scripts in turn (see below) |
|
||||||
|
| Timeout (sec) | `120` |
|
||||||
|
|
||||||
|
Two receivers on one endpoint is easiest with a tiny wrapper that fans
|
||||||
|
stdin out to both scripts:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# C:\scripts\zerto-post-fanout.ps1
|
||||||
|
$body = [Console]::In.ReadToEnd()
|
||||||
|
$body | & 'C:\scripts\zerto-receiver-notify.ps1'
|
||||||
|
$body | & 'C:\scripts\zerto-receiver-vm-healthcheck.ps1'
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run the two as separate endpoints (`zerto-post-notify` and
|
||||||
|
`zerto-post-healthcheck`) and have the Zerto-side script POST to both —
|
||||||
|
either pattern is fine. The fanout wrapper keeps the Zerto config simpler.
|
||||||
|
|
||||||
|
## 4. Wire up the bearer token
|
||||||
|
|
||||||
|
On the ZVMA / scripts-service side, the easiest place to put the token is
|
||||||
|
a Kubernetes Secret mounted into the pod, but the simplest approach for
|
||||||
|
testing is to pass it as a parameter to the Zerto-side script:
|
||||||
|
|
||||||
|
> VPG settings → Pre-Recovery Script → Parameters:
|
||||||
|
> `-Phase pre -Bearer <paste-token>`
|
||||||
|
>
|
||||||
|
> VPG settings → Post-Recovery Script → Parameters:
|
||||||
|
> `-Phase post -Bearer <paste-token>`
|
||||||
|
|
||||||
|
For production, mount a Secret at a known path in the pod and have the
|
||||||
|
sender script read from it (`Get-Content /run/secrets/webhook-token`).
|
||||||
|
|
||||||
|
## 5. Test before going live
|
||||||
|
|
||||||
|
Run a Test failover on a non-critical VPG. Watch:
|
||||||
|
|
||||||
|
- **Slack/Teams**: a `:test_tube: Zerto Test - phase: pre` message arrives,
|
||||||
|
followed ~30s–several minutes later by a `:test_tube: Zerto Test - phase:
|
||||||
|
post` message.
|
||||||
|
- **Webhook Server GUI** → run history: two runs for `zerto-pre` /
|
||||||
|
`zerto-post`, both green.
|
||||||
|
- **`C:\ProgramData\WebhookServer\zerto-healthchecks\`**: a fresh JSON
|
||||||
|
report named `<vpg>-Test-<utcstamp>.json` containing per-VM ping and port
|
||||||
|
probe results.
|
||||||
|
- **ZVMA**: the VPG operation completes successfully; nothing in the
|
||||||
|
pre/post logs blocked on the webhook.
|
||||||
|
|
||||||
|
## Variations
|
||||||
|
|
||||||
|
### Branch on Test vs. real failover in the receivers
|
||||||
|
|
||||||
|
The notifier already styles the message differently. To do something only
|
||||||
|
on a real failover (e.g. update DNS), guard with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
if ($p.zerto.operation -ne 'Test') {
|
||||||
|
# do the destructive thing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A `ZertoOperation` of `Test` means "exercise — don't touch production
|
||||||
|
dependencies." Always check it before doing anything that mutates real
|
||||||
|
state.
|
||||||
|
|
||||||
|
### Capture `ZertoForce` from pre for use in post
|
||||||
|
|
||||||
|
`ZertoForce` is `Yes` only during the **pre** phase when force mode is on
|
||||||
|
and is reset to `No` by the **post** phase. If your post-side logic needs
|
||||||
|
to know the operation was force-flagged, save it during pre (e.g. write a
|
||||||
|
small marker to the shared `ZertoOutputDir`) and read it back during post.
|
||||||
|
|
||||||
|
### Per-VPG endpoints
|
||||||
|
|
||||||
|
For fine-grained access control or different actions per VPG, create one
|
||||||
|
endpoint per VPG (`zerto-pre-app01`, `zerto-post-app01`, …) with its own
|
||||||
|
bearer token. Override `-WebhookUrl` and `-Bearer` on the Zerto side per
|
||||||
|
VPG.
|
||||||
|
|
||||||
|
### Audit trail
|
||||||
|
|
||||||
|
Every endpoint can have an outbound **Callback** URL. Configure with your
|
||||||
|
SIEM's HTTP collector + an HMAC secret, and every run produces a JSON
|
||||||
|
record with runId, exit code, duration, stdout, and stderr — convenient
|
||||||
|
for compliance.
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
|
||||||
|
The ZVMA `scripts-service` pod runs your scripts inside a Linux container
|
||||||
|
with broad reach into the management cluster — anything your script does
|
||||||
|
runs with whatever ServiceAccount that pod uses. Treat the script content
|
||||||
|
as privileged and make sure pre/post script edit rights are restricted to
|
||||||
|
trusted operators. If you're unfamiliar with the pod's RBAC posture, check
|
||||||
|
`Get-ChildItem Env:` from inside the container and look at
|
||||||
|
`/var/run/secrets/kubernetes.io/serviceaccount/` — that token is what your
|
||||||
|
scripts (and a malicious script) can use to talk to the K8s API.
|
||||||
@@ -38,6 +38,28 @@ You launched the GUI without elevation. The admin pipe ACL is `SYSTEM` + `Admini
|
|||||||
|
|
||||||
**Fix in v0.1.0**: right-click the Start Menu shortcut → **Run as administrator**, or upgrade.
|
**Fix in v0.1.0**: right-click the Start Menu shortcut → **Run as administrator**, or upgrade.
|
||||||
|
|
||||||
|
### Service won't start after install / GUI says "Disconnected" with no obvious error
|
||||||
|
|
||||||
|
If `Get-Service WebhookServer` shows it stopped and `Start-Service WebhookServer` fails, or the GUI itself won't even launch, you're probably missing a .NET 8 runtime. The v0.1.4+ installer auto-fetches them, but a clean Windows Server box might still hit this if the install was offline or used an older installer.
|
||||||
|
|
||||||
|
Check what's installed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet --list-runtimes
|
||||||
|
```
|
||||||
|
|
||||||
|
You need both:
|
||||||
|
|
||||||
|
- `Microsoft.AspNetCore.App 8.x.y` — for the Service
|
||||||
|
- `Microsoft.WindowsDesktop.App 8.x.y` — for the GUI
|
||||||
|
|
||||||
|
If either is missing, install from:
|
||||||
|
|
||||||
|
- ASP.NET Core 8 Runtime — <https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe>
|
||||||
|
- .NET Desktop Runtime 8 — <https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe>
|
||||||
|
|
||||||
|
Re-run with `/install /quiet /norestart` for unattended installs. Then `Start-Service WebhookServer`.
|
||||||
|
|
||||||
### "Connection refused" hitting the hook URL
|
### "Connection refused" hitting the hook URL
|
||||||
|
|
||||||
Three possibilities, in order of probability:
|
Three possibilities, in order of probability:
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ Filename: "powershell.exe"; \
|
|||||||
RunOnceId: "RemoveWebhookService"
|
RunOnceId: "RemoveWebhookService"
|
||||||
|
|
||||||
[Code]
|
[Code]
|
||||||
|
const
|
||||||
|
// aka.ms redirects to the latest 8.0.x patch. Inno Setup's downloader
|
||||||
|
// follows redirects via the Windows HTTP stack.
|
||||||
|
AspNetCore8Url = 'https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe';
|
||||||
|
WinDesktop8Url = 'https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe';
|
||||||
|
AspNetCore8File = 'aspnetcore-runtime-8.0-win-x64.exe';
|
||||||
|
WinDesktop8File = 'windowsdesktop-runtime-8.0-win-x64.exe';
|
||||||
|
|
||||||
|
var
|
||||||
|
DownloadPage: TDownloadWizardPage;
|
||||||
|
|
||||||
function ServiceExists(): Boolean;
|
function ServiceExists(): Boolean;
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
@@ -96,6 +107,119 @@ begin
|
|||||||
Result := (ResultCode = 0);
|
Result := (ResultCode = 0);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
// True if a Microsoft.* shared-framework directory under
|
||||||
|
// %ProgramFiles%\dotnet\shared contains at least one 8.x.y subfolder.
|
||||||
|
function HasDotNet8(const RuntimeName: String): Boolean;
|
||||||
|
var
|
||||||
|
rec: TFindRec;
|
||||||
|
base: String;
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
base := ExpandConstant('{commonpf}\dotnet\shared\') + RuntimeName;
|
||||||
|
if not DirExists(base) then Exit;
|
||||||
|
if FindFirst(base + '\8.*', rec) then
|
||||||
|
try
|
||||||
|
repeat
|
||||||
|
if (rec.Name <> '.') and (rec.Name <> '..') and
|
||||||
|
DirExists(base + '\' + rec.Name) then begin
|
||||||
|
Result := True;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
until not FindNext(rec);
|
||||||
|
finally
|
||||||
|
FindClose(rec);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function NeedsAspNet8(): Boolean;
|
||||||
|
begin
|
||||||
|
Result := not HasDotNet8('Microsoft.AspNetCore.App');
|
||||||
|
end;
|
||||||
|
|
||||||
|
function NeedsWinDesktop8(): Boolean;
|
||||||
|
begin
|
||||||
|
Result := not HasDotNet8('Microsoft.WindowsDesktop.App');
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure InitializeWizard;
|
||||||
|
begin
|
||||||
|
DownloadPage := CreateDownloadPage(
|
||||||
|
'Downloading prerequisites',
|
||||||
|
'Webhook Server needs the .NET 8 runtimes. Setup is fetching them now.',
|
||||||
|
nil);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Runs a downloaded runtime installer silently. Treats Microsoft's
|
||||||
|
// "success but reboot pending" / "newer already installed" exit codes
|
||||||
|
// as successes so we don't fail the whole install over a benign result.
|
||||||
|
function RunRuntimeInstaller(const FileName, DisplayName: String): String;
|
||||||
|
var
|
||||||
|
resultCode: Integer;
|
||||||
|
fullPath: String;
|
||||||
|
begin
|
||||||
|
Result := '';
|
||||||
|
fullPath := ExpandConstant('{tmp}\') + FileName;
|
||||||
|
if not Exec(fullPath, '/install /quiet /norestart', '', SW_HIDE,
|
||||||
|
ewWaitUntilTerminated, resultCode) then begin
|
||||||
|
Result := 'Could not launch the ' + DisplayName + ' installer.';
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
case resultCode of
|
||||||
|
0, 1638, 3010, 1641: ;
|
||||||
|
else
|
||||||
|
Result := DisplayName + ' installer failed (exit code ' +
|
||||||
|
IntToStr(resultCode) + ').';
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||||
|
var
|
||||||
|
errMsg: String;
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
if CurPageID <> wpReady then Exit;
|
||||||
|
if not (NeedsAspNet8 or NeedsWinDesktop8) then Exit;
|
||||||
|
|
||||||
|
DownloadPage.Clear;
|
||||||
|
if NeedsAspNet8 then
|
||||||
|
DownloadPage.Add(AspNetCore8Url, AspNetCore8File, '');
|
||||||
|
if NeedsWinDesktop8 then
|
||||||
|
DownloadPage.Add(WinDesktop8Url, WinDesktop8File, '');
|
||||||
|
DownloadPage.Show;
|
||||||
|
try
|
||||||
|
try
|
||||||
|
DownloadPage.Download;
|
||||||
|
except
|
||||||
|
if MsgBox('Failed to download the .NET 8 runtimes:' + #13#10#13#10 +
|
||||||
|
GetExceptionMessage + #13#10#13#10 +
|
||||||
|
'Continue installing anyway? Webhook Server will not start ' +
|
||||||
|
'until the runtimes are installed manually.',
|
||||||
|
mbError, MB_YESNO) = IDNO then
|
||||||
|
Result := False;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DownloadPage.Hide;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if NeedsAspNet8 then begin
|
||||||
|
errMsg := RunRuntimeInstaller(AspNetCore8File, 'ASP.NET Core 8 Runtime');
|
||||||
|
if errMsg <> '' then begin
|
||||||
|
MsgBox(errMsg, mbError, MB_OK);
|
||||||
|
Result := False;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
if NeedsWinDesktop8 then begin
|
||||||
|
errMsg := RunRuntimeInstaller(WinDesktop8File, '.NET Desktop Runtime 8');
|
||||||
|
if errMsg <> '' then begin
|
||||||
|
MsgBox(errMsg, mbError, MB_OK);
|
||||||
|
Result := False;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
|
|||||||
+138
-3
@@ -53,15 +53,150 @@ if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
|
|||||||
-c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
|
-c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
|
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
|
||||||
|
|
||||||
# 2. Compile installer.
|
# 2. Pre-flight: confirm every source path the .iss references exists, and
|
||||||
|
# surface the longest path so MAX_PATH issues are obvious in the log.
|
||||||
|
function Show-SourcePath($label, $path, [switch]$Recursive) {
|
||||||
|
if (-not (Test-Path $path)) { Write-Warning "MISSING $label : $path"; return }
|
||||||
|
$items = if ($Recursive) {
|
||||||
|
Get-ChildItem $path -Recurse -File -ErrorAction SilentlyContinue
|
||||||
|
} else {
|
||||||
|
Get-ChildItem $path -File -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
$count = ($items | Measure-Object).Count
|
||||||
|
$longest = ($items | Measure-Object -Maximum -Property { $_.FullName.Length }).Maximum
|
||||||
|
Write-Host (" {0,-30} files={1,-5} longestPath={2,-5} root={3}" -f $label, $count, $longest, $path)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "--- pre-flight: source paths the .iss will read ---" -ForegroundColor Cyan
|
||||||
|
Show-SourcePath 'publish\service' $publishSvc -Recursive
|
||||||
|
Show-SourcePath 'publish\gui' $publishGui -Recursive
|
||||||
|
Show-SourcePath 'scripts' (Join-Path $repoRoot 'scripts')
|
||||||
|
Show-SourcePath 'scripts\examples' (Join-Path $repoRoot 'scripts\examples') -Recursive
|
||||||
|
Show-SourcePath 'docs' (Join-Path $repoRoot 'docs') -Recursive
|
||||||
|
Show-SourcePath 'resources' (Join-Path $repoRoot 'resources')
|
||||||
|
Show-SourcePath 'README.md (file)' (Join-Path $repoRoot 'README.md')
|
||||||
|
|
||||||
|
$lpe = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
|
||||||
|
-Name LongPathsEnabled -ErrorAction SilentlyContinue).LongPathsEnabled
|
||||||
|
Write-Host " LongPathsEnabled (HKLM): $lpe"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 3. Compile installer.
|
||||||
$iscc = Find-InnoCompiler
|
$iscc = Find-InnoCompiler
|
||||||
$iss = Join-Path $repoRoot 'installer\webhook-server.iss'
|
$iss = Join-Path $repoRoot 'installer\webhook-server.iss'
|
||||||
$dist = Join-Path $repoRoot 'dist'
|
$dist = Join-Path $repoRoot 'dist'
|
||||||
New-Item -ItemType Directory -Path $dist -Force | Out-Null
|
New-Item -ItemType Directory -Path $dist -Force | Out-Null
|
||||||
|
|
||||||
Write-Host "Compiling installer with $iscc"
|
Write-Host "Compiling installer with $iscc"
|
||||||
& $iscc "/DAppVersion=$version" $iss
|
# Run ISCC from the .iss directory with just the bare filename. When invoked
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compile failed' }
|
# with a deeply-nested absolute path on the act-runner host (under
|
||||||
|
# %SystemRoot%\System32\config\systemprofile\...), ISCC sometimes prints a
|
||||||
|
# generic "The system cannot find the path specified." before it touches any
|
||||||
|
# source files. cd-ing first sidesteps it.
|
||||||
|
$issDir = Split-Path $iss -Parent
|
||||||
|
$issName = Split-Path $iss -Leaf
|
||||||
|
|
||||||
|
# Extra pre-flight: confirm the specific files our .iss references that a
|
||||||
|
# trivial test .iss wouldn't (icon, README, scripts) actually exist relative
|
||||||
|
# to the .iss directory the way ISCC will resolve them (RepoRoot = ..\).
|
||||||
|
Write-Host "--- pre-flight: paths the .iss references via {#RepoRoot} ---" -ForegroundColor Cyan
|
||||||
|
$issRefs = @(
|
||||||
|
'resources\webhook-server.ico',
|
||||||
|
'README.md',
|
||||||
|
'scripts\install-service.ps1',
|
||||||
|
'scripts\uninstall-service.ps1',
|
||||||
|
'publish\service',
|
||||||
|
'publish\gui',
|
||||||
|
'docs',
|
||||||
|
'scripts\examples'
|
||||||
|
)
|
||||||
|
foreach ($ref in $issRefs) {
|
||||||
|
$abs = Join-Path $repoRoot $ref
|
||||||
|
$exists = Test-Path $abs
|
||||||
|
Write-Host (" {0,-40} exists={1} ({2})" -f $ref, $exists, $abs)
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "--- runtime context ---" -ForegroundColor Cyan
|
||||||
|
Write-Host " identity: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
|
||||||
|
Write-Host " USERPROFILE: $env:USERPROFILE"
|
||||||
|
Write-Host " APPDATA: $env:APPDATA"
|
||||||
|
Write-Host " LOCALAPPDATA: $env:LOCALAPPDATA"
|
||||||
|
Write-Host " TEMP: $env:TEMP"
|
||||||
|
$isccDir = Split-Path $iscc -Parent
|
||||||
|
Write-Host " ISCC dir: $isccDir"
|
||||||
|
foreach ($f in @('ISCC.exe','ISCmplr.dll','ISPP.dll','Default.isl','Compil32.exe')) {
|
||||||
|
$p = Join-Path $isccDir $f
|
||||||
|
Write-Host (" {0,-15} exists={1}" -f $f, (Test-Path $p))
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host " PS location (pre): $((Get-Location).Path)"
|
||||||
|
Write-Host " .NET cwd (pre): $([System.IO.Directory]::GetCurrentDirectory())"
|
||||||
|
|
||||||
|
Push-Location $issDir
|
||||||
|
$savedDotNetCwd = [System.IO.Directory]::GetCurrentDirectory()
|
||||||
|
[System.IO.Directory]::SetCurrentDirectory($issDir)
|
||||||
|
try {
|
||||||
|
Write-Host " PS location (post): $((Get-Location).Path)"
|
||||||
|
Write-Host " .NET cwd (post): $([System.IO.Directory]::GetCurrentDirectory())"
|
||||||
|
|
||||||
|
# Sanity: compile a minimal .iss right next to ours BEFORE attempting the
|
||||||
|
# real one. Minimal has no #defines, no [Code], no [Files], no compression
|
||||||
|
# tweak - just the absolute floor of what ISCC will accept. If THIS fails
|
||||||
|
# under the same SYSTEM context with the same identical exit/error, the
|
||||||
|
# problem is environmental, not in our .iss content.
|
||||||
|
$minIss = Join-Path $issDir "min-test.iss"
|
||||||
|
@"
|
||||||
|
[Setup]
|
||||||
|
AppName=MinTest
|
||||||
|
AppVersion=1.0
|
||||||
|
AppId={{12345678-1234-1234-1234-123456789ABC}
|
||||||
|
DefaultDirName={pf}\MinTest
|
||||||
|
CreateAppDir=no
|
||||||
|
Uninstallable=no
|
||||||
|
OutputBaseFilename=mintest
|
||||||
|
OutputDir=$dist
|
||||||
|
"@ | Set-Content -Path $minIss -Encoding ascii
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "--- bisect step 1: minimal .iss ---" -ForegroundColor Cyan
|
||||||
|
& $iscc (Split-Path $minIss -Leaf) *>&1 | ForEach-Object { Write-Host " $_" }
|
||||||
|
$minExit = $LASTEXITCODE
|
||||||
|
Write-Host " minimal exit: $minExit"
|
||||||
|
Remove-Item $minIss -ErrorAction SilentlyContinue
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Bake the version into a temp .iss and override OutputDir to an absolute
|
||||||
|
# path so nothing in the build depends on cwd resolution.
|
||||||
|
$tempIss = Join-Path $issDir "webhook-server.gen.iss"
|
||||||
|
$issBody = Get-Content $issName -Raw
|
||||||
|
$pattern = '(?s)#ifndef AppVersion\s+#define AppVersion "[^"]*"\s+#endif'
|
||||||
|
if ($issBody -notmatch $pattern) { throw "Could not find #ifndef AppVersion block in $issName" }
|
||||||
|
$issBody = $issBody -replace $pattern, "#define AppVersion `"$version`""
|
||||||
|
Set-Content -Path $tempIss -Value $issBody -Encoding ascii
|
||||||
|
Write-Host " using $tempIss"
|
||||||
|
|
||||||
|
# Capture stdout+stderr together so any error line ISCC emits is visible
|
||||||
|
# in the runner log even if the runner's console capture drops one stream.
|
||||||
|
# /O<absolute> overrides OutputDir so ..\dist isn't resolved relative to
|
||||||
|
# whatever cwd ISCC actually inherits.
|
||||||
|
$logPath = Join-Path $env:TEMP "iscc-$version.log"
|
||||||
|
& $iscc "/O$dist" (Split-Path $tempIss -Leaf) *>&1 | Tee-Object -FilePath $logPath | ForEach-Object { Write-Host $_ }
|
||||||
|
$exit = $LASTEXITCODE
|
||||||
|
Write-Host " ISCC exit code: $exit"
|
||||||
|
Write-Host " ISCC log path: $logPath"
|
||||||
|
if (Test-Path $logPath) {
|
||||||
|
Write-Host " --- iscc log file contents ---"
|
||||||
|
Get-Content $logPath | ForEach-Object { Write-Host " $_" }
|
||||||
|
Write-Host " --- end iscc log ---"
|
||||||
|
}
|
||||||
|
Remove-Item $tempIss -ErrorAction SilentlyContinue
|
||||||
|
} finally {
|
||||||
|
[System.IO.Directory]::SetCurrentDirectory($savedDotNetCwd)
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
if ($exit -ne 0) { throw "Inno Setup compile failed (exit $exit)" }
|
||||||
|
|
||||||
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
|
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@
|
|||||||
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
|
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
|
||||||
<MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
|
<MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
|
<MenuItem Header="_Minimize to tray"
|
||||||
|
IsCheckable="True"
|
||||||
|
IsChecked="{Binding MinimizeToTrayEnabled, Mode=TwoWay}"
|
||||||
|
ToolTip="When ticked, closing or minimizing the window hides it to the tray and keeps the GUI process alive. Untick to make the X button quit the app."/>
|
||||||
|
<Separator/>
|
||||||
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem Header="_Server">
|
<MenuItem Header="_Server">
|
||||||
@@ -63,17 +68,26 @@
|
|||||||
<DataGrid.RowStyle>
|
<DataGrid.RowStyle>
|
||||||
<Style TargetType="DataGridRow">
|
<Style TargetType="DataGridRow">
|
||||||
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
|
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
|
||||||
|
<!-- The ContextMenu lives in its own visual tree (a popup), so
|
||||||
|
AncestorType=Window doesn't resolve from inside menu items.
|
||||||
|
Stash MainViewModel on the row's Tag here (still in the
|
||||||
|
Window's tree), then reach it from the menu via
|
||||||
|
PlacementTarget.Tag. -->
|
||||||
|
<Setter Property="Tag" Value="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
<Setter Property="ContextMenu">
|
<Setter Property="ContextMenu">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<MenuItem Header="_Edit…" Command="{Binding DataContext.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="_Edit…"
|
||||||
<MenuItem Header="_Copy URL" Command="{Binding DataContext.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
Command="{Binding PlacementTarget.Tag.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||||
|
<MenuItem Header="_Copy URL"
|
||||||
|
Command="{Binding PlacementTarget.Tag.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Toggle _enabled"
|
<MenuItem Header="Toggle _enabled"
|
||||||
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Command="{Binding PlacementTarget.Tag.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="_Delete…" Command="{Binding DataContext.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="_Delete…"
|
||||||
|
Command="{Binding PlacementTarget.Tag.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
@@ -11,26 +12,56 @@ public partial class MainWindow : Window
|
|||||||
private readonly TrayIcon _tray;
|
private readonly TrayIcon _tray;
|
||||||
private readonly MainViewModel _vm;
|
private readonly MainViewModel _vm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set to true when the user has explicitly asked to quit (File -> Exit or
|
||||||
|
/// Tray -> Exit). The OnClosing handler reads this to decide whether to
|
||||||
|
/// actually let the window close or hide it to the tray.
|
||||||
|
/// </summary>
|
||||||
|
public bool ExitForReal { get; set; }
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_vm = new MainViewModel(new AdminPipeClient());
|
_vm = new MainViewModel(new AdminPipeClient());
|
||||||
DataContext = _vm;
|
DataContext = _vm;
|
||||||
|
_vm.RealExitRequested += OnRealExitRequested;
|
||||||
|
|
||||||
_tray = new TrayIcon(
|
_tray = new TrayIcon(
|
||||||
resolveMainWindow: () => Application.Current.MainWindow,
|
resolveMainWindow: () => Application.Current.MainWindow,
|
||||||
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync());
|
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync(),
|
||||||
|
onExit: OnRealExitRequested);
|
||||||
|
|
||||||
Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
|
Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
|
||||||
StateChanged += OnStateChanged;
|
StateChanged += OnStateChanged;
|
||||||
Closed += (_, _) => _tray.Dispose();
|
Closing += OnClosing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClosing(object? sender, CancelEventArgs e)
|
||||||
|
{
|
||||||
|
if (ExitForReal || !_vm.MinimizeToTrayEnabled)
|
||||||
|
{
|
||||||
|
_tray.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Treat the X button / Alt+F4 like a minimize: hide to tray, keep the
|
||||||
|
// process alive so the tray icon persists.
|
||||||
|
e.Cancel = true;
|
||||||
|
Hide();
|
||||||
|
ShowInTaskbar = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRealExitRequested()
|
||||||
|
{
|
||||||
|
ExitForReal = true;
|
||||||
|
Application.Current.Shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnStateChanged(object? sender, EventArgs e)
|
private void OnStateChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
// Minimize-to-tray: hide the window when the user minimizes; restoring is
|
// Minimize-to-tray: hide the window when the user minimizes IF they've
|
||||||
// via the tray icon's double-click or context menu.
|
// opted in via File -> Minimize to tray. Otherwise behave like a normal
|
||||||
if (WindowState == WindowState.Minimized)
|
// Windows minimize.
|
||||||
|
if (WindowState == WindowState.Minimized && _vm.MinimizeToTrayEnabled)
|
||||||
{
|
{
|
||||||
Hide();
|
Hide();
|
||||||
ShowInTaskbar = false;
|
ShowInTaskbar = false;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace WebhookServer.Gui.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-user GUI preferences that don't belong in the service-side ServerConfig.
|
||||||
|
/// Persisted to %APPDATA%\WebhookServer\gui.json. Best-effort: failures to read
|
||||||
|
/// or write fall back silently to defaults.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GuiSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When true, the X / Alt+F4 / minimize buttons hide the window to the tray
|
||||||
|
/// and keep the GUI process alive. When false, X exits the app and minimize
|
||||||
|
/// behaves like a normal Windows minimize.
|
||||||
|
/// </summary>
|
||||||
|
public bool MinimizeToTrayEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
private static string FilePath => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"WebhookServer",
|
||||||
|
"gui.json");
|
||||||
|
|
||||||
|
public static GuiSettings Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(FilePath))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(FilePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(json))
|
||||||
|
return JsonSerializer.Deserialize<GuiSettings>(json) ?? new GuiSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* fall through to defaults */ }
|
||||||
|
return new GuiSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(FilePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
}
|
||||||
|
catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,13 @@ public sealed class TrayIcon : IDisposable
|
|||||||
private readonly NotifyIcon _icon;
|
private readonly NotifyIcon _icon;
|
||||||
private readonly Func<Window?> _resolveMainWindow;
|
private readonly Func<Window?> _resolveMainWindow;
|
||||||
private readonly Func<Task> _restartServiceAsync;
|
private readonly Func<Task> _restartServiceAsync;
|
||||||
|
private readonly Action _onExit;
|
||||||
|
|
||||||
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync)
|
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync, Action onExit)
|
||||||
{
|
{
|
||||||
_resolveMainWindow = resolveMainWindow;
|
_resolveMainWindow = resolveMainWindow;
|
||||||
_restartServiceAsync = restartServiceAsync;
|
_restartServiceAsync = restartServiceAsync;
|
||||||
|
_onExit = onExit;
|
||||||
|
|
||||||
_icon = new NotifyIcon
|
_icon = new NotifyIcon
|
||||||
{
|
{
|
||||||
@@ -39,7 +41,7 @@ public sealed class TrayIcon : IDisposable
|
|||||||
menu.Items.Add(new ToolStripSeparator());
|
menu.Items.Add(new ToolStripSeparator());
|
||||||
menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false));
|
menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false));
|
||||||
menu.Items.Add(new ToolStripSeparator());
|
menu.Items.Add(new ToolStripSeparator());
|
||||||
menu.Items.Add("E&xit", null, (_, _) => Application.Current.Shutdown());
|
menu.Items.Add("E&xit", null, (_, _) => _onExit());
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,14 @@ public sealed partial class ConfigCheckpointsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (Selected is null) return;
|
if (Selected is null) return;
|
||||||
|
|
||||||
|
// Capture before the refresh; the ObservableCollection.Clear() in
|
||||||
|
// RefreshAsync nulls Selected (the original instance is gone from the
|
||||||
|
// collection so the SelectedItem binding clears).
|
||||||
|
var fileName = Selected.FileName;
|
||||||
|
var savedAt = Selected.SavedAt;
|
||||||
|
|
||||||
var ok = MessageBox.Show(
|
var ok = MessageBox.Show(
|
||||||
$"Roll the configuration back to the checkpoint from {Selected.SavedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
|
$"Roll the configuration back to the checkpoint from {savedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.",
|
||||||
"Confirm rollback",
|
"Confirm rollback",
|
||||||
MessageBoxButton.OKCancel,
|
MessageBoxButton.OKCancel,
|
||||||
MessageBoxImage.Warning);
|
MessageBoxImage.Warning);
|
||||||
@@ -91,10 +97,10 @@ public sealed partial class ConfigCheckpointsViewModel : ObservableObject
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _client.RestoreBackupAsync(Selected.FileName).ConfigureAwait(false);
|
await _client.RestoreBackupAsync(fileName).ConfigureAwait(false);
|
||||||
await RefreshAsync().ConfigureAwait(false);
|
await RefreshAsync().ConfigureAwait(false);
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
StatusMessage = $"Rolled back to {Selected!.FileName}.");
|
StatusMessage = $"Rolled back to {fileName}.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,17 +29,28 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[ObservableProperty] private ServerConfig _serverConfig = new();
|
[ObservableProperty] private ServerConfig _serverConfig = new();
|
||||||
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
|
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
|
||||||
[ObservableProperty] private string? _httpsBaseUrl;
|
[ObservableProperty] private string? _httpsBaseUrl;
|
||||||
|
[ObservableProperty] private bool _minimizeToTrayEnabled;
|
||||||
|
|
||||||
private readonly DispatcherTimer _logTimer;
|
private readonly DispatcherTimer _logTimer;
|
||||||
|
private readonly GuiSettings _settings;
|
||||||
|
|
||||||
public MainViewModel(AdminPipeClient client)
|
public MainViewModel(AdminPipeClient client)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
|
_settings = GuiSettings.Load();
|
||||||
|
_minimizeToTrayEnabled = _settings.MinimizeToTrayEnabled;
|
||||||
|
|
||||||
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
|
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
|
||||||
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
|
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
|
||||||
_logTimer.Start();
|
_logTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnMinimizeToTrayEnabledChanged(bool value)
|
||||||
|
{
|
||||||
|
_settings.MinimizeToTrayEnabled = value;
|
||||||
|
_settings.Save();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task RefreshAsync()
|
private async Task RefreshAsync()
|
||||||
{
|
{
|
||||||
@@ -286,10 +297,14 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised when the user picks File -> Exit. MainWindow flips its
|
||||||
|
/// ExitForReal flag and shuts down, bypassing the X-hides-to-tray logic.</summary>
|
||||||
|
public event Action? RealExitRequested;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Exit()
|
private void Exit()
|
||||||
{
|
{
|
||||||
Application.Current.Shutdown();
|
RealExitRequested?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
Reference in New Issue
Block a user