Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
01b9e2d
Add CI with unit and integration tests for the install and management…
fivetran-paulharper Oct 30, 2026
6a932cd
Fix first CI run: Pester layout, registry URL override, lint
fivetran-paulharper Oct 30, 2026
6a7d876
Fix second CI run: stub docker as functions, lint findings, UTF-8 BOM
fivetran-paulharper Oct 30, 2026
541ab70
Keep Pester stub state in a global
fivetran-paulharper Oct 30, 2026
73edf72
Fix Pester helpers that flattened recorded docker calls
fivetran-paulharper Oct 30, 2026
a93604e
Return docker call arguments unwrapped in the Pester helper
fivetran-paulharper Oct 30, 2026
71dff03
Address review: stubs as files, spell out that tests never hit the re…
fivetran-paulharper Oct 30, 2026
0f0addf
Generalize the curl stub and wait for Docker on Windows runners
fivetran-paulharper Oct 30, 2026
ec1257e
Harden CI workflow and tests after review
fivetran-paulharper Oct 30, 2026
20a9a3e
Apply delta review: liveness check, Docker wait error mode, cancel on…
fivetran-paulharper Oct 30, 2026
336e9c6
Make the stub agent require the real agent's invocation
fivetran-paulharper Oct 30, 2026
dc29918
Cover the TOKEN and WebSocket migration install paths
fivetran-paulharper Oct 30, 2026
e0e66ad
Record the HTTP method in the Invoke-RestMethod stub
fivetran-paulharper Oct 30, 2026
fe4d44b
Cover the Windows installer and manager failure paths
fivetran-paulharper Nov 2, 2026
e37f16c
Fail failed upgrades, time out registry queries, validate settings
fivetran-paulharper Nov 2, 2026
83b4ea3
Run the PowerShell tests under PowerShell 7 too
fivetran-paulharper Nov 3, 2026
11c8cde
Set the config file ACL with Set-Acl
fivetran-paulharper Nov 3, 2026
731a129
Exit install.ps1 with 0 after a successful install
fivetran-paulharper Nov 3, 2026
9ae7fb6
Keep Windows PowerShell 5.1 from wrapping test output
fivetran-paulharper Nov 3, 2026
442a6c3
Check the memory setting only for commands that start the agent
fivetran-paulharper Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

# A new push to a pull request cancels its previous run, which otherwise keeps
# two Windows runners busy for up to 45 minutes. Runs on main are never
# cancelled so every merged commit gets a full result.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
shellcheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: shellcheck --severity=warning install.sh proxy-agent-manager.sh tests/integration/linux.sh tests/stub/heartbeat.sh tests/bash/stubs/docker tests/bash/stubs/curl

psscriptanalyzer:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Run PSScriptAnalyzer
shell: pwsh
run: |
if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) {
Install-Module PSScriptAnalyzer -Force -Scope CurrentUser
}
$results = Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1
$results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
if ($results) { exit 1 }

bats:
name: Unit tests (bash)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install -y bats
- run: bats tests/bash

pester:
name: Unit tests (${{ matrix.shell }}, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
# Customers may run the scripts with Windows PowerShell 5.1
# (powershell) or PowerShell 7 (pwsh); the scripts under test run in
# the same host as Pester.
shell: [powershell, pwsh]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
defaults:
run:
shell: ${{ matrix.shell }}
steps:
- uses: actions/checkout@v4
- name: Run Pester
# Pester is pinned to 5.x since the tests use its syntax.
run: |
$pester = Get-Module -ListAvailable Pester | Where-Object { $_.Version -ge '5.0' -and $_.Version -lt '6.0' }
if (-not $pester) {
Install-Module Pester -MinimumVersion 5.0 -MaximumVersion 5.99.99 -Force -SkipPublisherCheck -Scope CurrentUser
}
Import-Module Pester -MinimumVersion 5.0 -MaximumVersion 5.99.99
Invoke-Pester -Path tests/pwsh -CI

integration-linux:
name: Integration (Linux containers, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
# The published Linux image is multi-architecture (amd64 and arm64).
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- run: tests/integration/linux.sh

integration-windows:
name: Integration (Windows containers, ${{ matrix.shell }}, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
shell: [powershell]
# PowerShell 7 runs on one OS only, since these jobs are slow.
include:
- os: windows-2025
shell: pwsh
runs-on: ${{ matrix.os }}
timeout-minutes: 45
defaults:
run:
# The installer and manager run in the same host as the test.
shell: ${{ matrix.shell }}
steps:
- uses: actions/checkout@v4
- name: Wait for the Docker daemon
# The Docker service is not always running when a Windows job starts.
shell: powershell
run: |
# The default 'Stop' preference would turn docker's stderr into a
# terminating error on the first failed attempt.
$ErrorActionPreference = 'Continue'
$service = Get-Service docker -ErrorAction SilentlyContinue
if ($service -and $service.Status -ne 'Running') { Start-Service docker }
foreach ($attempt in 1..30) {
docker info *> $null
if ($LASTEXITCODE -eq 0) { exit 0 }
Start-Sleep -Seconds 2
}
docker info
exit 1
- name: Run integration test
run: tests/integration/windows.ps1
15 changes: 15 additions & 0 deletions PSScriptAnalyzerSettings.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@{
Severity = @('Error', 'Warning')
ExcludeRules = @(
# The scripts are interactive installers; Write-Host is the intended output channel.
'PSAvoidUsingWriteHost',
# Start/Stop-ProxyAgent are internal helpers of a CLI that already takes an explicit command.
'PSUseShouldProcessForStateChangingFunctions',
# `docker inspect` is used as an existence probe; its failure is the expected outcome.
'PSAvoidUsingEmptyCatchBlock',
# Naming style only.
'PSUseSingularNouns',
# Write-Log is a local helper; the rule flags it against a cmdlet of the same name in a PowerShell Core build.
'PSAvoidOverwritingBuiltInCmdlets'
)
}
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,34 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

**Volume mount issues** — Docker Desktop for Windows translates Windows paths in volume mounts automatically. If you see an empty config inside the container, ensure the install directory path does not contain special characters.

## Development

CI (`.github/workflows/ci.yml`) runs on every pull request:

- `shellcheck` on the shell scripts and PSScriptAnalyzer (settings in `PSScriptAnalyzerSettings.psd1`) on the PowerShell scripts.
- Unit tests for the management scripts and the Windows installer: `tests/bash/manager.bats` (bats), and `tests/pwsh/Manager.Tests.ps1` and `tests/pwsh/Install.Tests.ps1` (Pester 5, under both Windows PowerShell 5.1 and PowerShell 7). They run the scripts with stubbed `docker`, registry and host calls, so they need no Docker daemon.
- Integration tests that run the installer and management script against a real Docker daemon: `tests/integration/linux.sh` on Ubuntu (amd64 and arm64), and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers (on 2025 also under PowerShell 7). They use a stub agent image (`tests/stub/`) that only writes the heartbeat file, and a local stand-in for the registry, the script download and the configure endpoint (`tests/integration/registry_server.py`), so they need no Fivetran credentials. They cover installing from a config file, from a `TOKEN`, and from a legacy WebSocket config, then rollback, upgrade, restart and stop.

To run them locally you need Docker for the integration tests, `python3` (`python` on Windows) for the registry stand-in, [bats-core](https://github.com/bats-core/bats-core) for the bash tests and [Pester 5](https://pester.dev) for the PowerShell tests. The stub images pull `alpine` and `servercore` base images, so the integration tests need network access for that.

```bash
bats tests/bash
tests/integration/linux.sh # needs Docker
```

```powershell
Invoke-Pester -Path tests/pwsh
tests\integration\windows.ps1 # needs Docker in Windows-containers mode
```

The scripts read these environment variables so the tests can point them at the stub image and local server. They are not meant for production use:

| Variable | Read by | Replaces |
|----------|---------|----------|
| `PROXY_AGENT_IMAGE` | management scripts | the agent image repository |
| `PROXY_AGENT_REGISTRY_TAGS_URL` | installers and management scripts | the registry tag listing URL |
| `PROXY_AGENT_SCRIPT_URL` | installers | the management script download URL |

## License

This project is licensed under the [Apache License 2.0](./LICENSE).
14 changes: 10 additions & 4 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#Requires -Version 5.1
#Requires -Version 5.1
#
# Installer for Fivetran Proxy Agent on Windows using Docker
#
Expand Down Expand Up @@ -29,8 +29,10 @@ $MIN_RECOMMENDED_DISK_SPACE_MB = 2048
# for a new base image on upgrade. Sized from the 1.2.9 images (fivetran/engineering#330090); revisit when the image changes.
$MIN_RECOMMENDED_WINDOWS_DISK_SPACE_MB = 12288
$AGENT_SCRIPT = 'proxy-agent-manager.ps1'
$AGENT_SCRIPT_URL = 'https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.ps1'
$REGISTRY_TAGS_URL = 'https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list'
# PROXY_AGENT_SCRIPT_URL and PROXY_AGENT_REGISTRY_TAGS_URL are overridable so the
# test suite can install the checked-out management script against a stub registry.
$AGENT_SCRIPT_URL = if ($env:PROXY_AGENT_SCRIPT_URL) { $env:PROXY_AGENT_SCRIPT_URL } else { 'https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.ps1' }
$REGISTRY_TAGS_URL = if ($env:PROXY_AGENT_REGISTRY_TAGS_URL) { $env:PROXY_AGENT_REGISTRY_TAGS_URL } else { 'https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list' }
$DEFAULT_FIVETRAN_API_URL = 'https://api.fivetran.com'
$SETTINGS_FILE = 'settings.ps1'

Expand Down Expand Up @@ -408,7 +410,8 @@ try {
'NT AUTHORITY\SYSTEM', 'Read', 'Allow')))
$configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule(
(New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544')), 'Read', 'Allow')))
[System.IO.File]::SetAccessControl($configDest, $configAcl)
# Set-Acl rather than [System.IO.File]::SetAccessControl, which PowerShell 7 does not have.
Set-Acl -LiteralPath $configDest -AclObject $configAcl

if ($ConfigPath) {
[System.IO.File]::WriteAllText($configDest, $configFileContent, [System.Text.UTF8Encoding]::new($false))
Expand Down Expand Up @@ -471,6 +474,9 @@ try {

Write-Host "`nInstallation complete."
Write-Host "Install directory: $InstallDir"
# Otherwise the script would exit with whatever $LASTEXITCODE a tolerated
# failure left, such as an unknown Docker data root.
exit 0
} finally {
Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue
}
13 changes: 8 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ MIN_RECOMMENDED_CONTAINER_MEMORY_MB=5120 # proxy agent container should get at
HOST_RESERVED_MEMORY_MB=1024 # leave at least this much RAM for the host on Linux
MIN_RECOMMENDED_DISK_SPACE_MB=2048
AGENT_SCRIPT="proxy-agent-manager.sh"
AGENT_SCRIPT_URL="https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.sh"
REGISTRY_TAGS_URL="https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list"
# PROXY_AGENT_SCRIPT_URL and PROXY_AGENT_REGISTRY_TAGS_URL are overridable so the
# test suite can install the checked-out management script against a stub registry.
AGENT_SCRIPT_URL="${PROXY_AGENT_SCRIPT_URL:-https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.sh}"
REGISTRY_TAGS_URL="${PROXY_AGENT_REGISTRY_TAGS_URL:-https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list}"
SETTINGS_FILE="settings.sh"

WARNINGS=()
Expand Down Expand Up @@ -222,7 +224,7 @@ report_warnings_and_errors() {

get_latest_version() {
local tags_json
tags_json=$(curl -s -f "$REGISTRY_TAGS_URL") || die "Unable to query image registry for latest version"
tags_json=$(curl -s -f --max-time 30 "$REGISTRY_TAGS_URL") || die "Unable to query image registry for latest version"

local version
version=$(echo "$tags_json" \
Expand Down Expand Up @@ -275,8 +277,9 @@ main() {
local agent_id auth_token
agent_id=$(extract_json_string "agent_id" "$config_path")
auth_token=$(extract_json_string "auth_token" "$config_path")
[ -n "$agent_id" ] && [ -n "$auth_token" ] \
|| die "Could not extract credentials from WebSocket config"
if [ -z "$agent_id" ] || [ -z "$auth_token" ]; then
die "Could not extract credentials from WebSocket config"
fi
TOKEN=$(printf '%s:%s' "$agent_id" "$auth_token" | base64 | tr -d '\n')
config_path=""
echo "Detected WebSocket agent configuration. Fetching updated config from Fivetran..."
Expand Down
38 changes: 30 additions & 8 deletions proxy-agent-manager.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#Requires -Version 5.1
#Requires -Version 5.1
param(
[Parameter(Position = 0)]
[string]$Command
Expand All @@ -7,7 +7,9 @@ param(
$ErrorActionPreference = 'Stop'

$BASE_DIR = $PSScriptRoot
$IMAGE = 'us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent'
# PROXY_AGENT_IMAGE and PROXY_AGENT_REGISTRY_TAGS_URL are overridable so the
# test suite can run against a stub image and registry.
$IMAGE = if ($env:PROXY_AGENT_IMAGE) { $env:PROXY_AGENT_IMAGE } else { 'us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent' }
$CONFIG_FILE = Join-Path $BASE_DIR 'config\config.json'
$VERSION_FILE = Join-Path $BASE_DIR 'version'
$LOGFILE = Join-Path $BASE_DIR 'logs\proxy-agent-manager.log'
Expand All @@ -17,7 +19,8 @@ $SETTINGS_FILE = Join-Path $BASE_DIR 'settings.ps1'
if (Test-Path -LiteralPath $SETTINGS_FILE) {
. $SETTINGS_FILE
}
if (-not $MEMORY_ALLOCATION_MB) { $MEMORY_ALLOCATION_MB = 5120 }
# Unset or empty only, so 0 is rejected like any other invalid setting.
if ("$MEMORY_ALLOCATION_MB" -eq '') { $MEMORY_ALLOCATION_MB = 5120 }

# ── Startup validation ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -169,12 +172,16 @@ function Get-LatestVersion {
[string]$WindowsVersion
)

$registryHost = $IMAGE.Split('/')[0]
$repositoryPath = $IMAGE.Substring($registryHost.Length + 1)
$registryUrl = "https://$registryHost/v2/$repositoryPath/tags/list"
if ($env:PROXY_AGENT_REGISTRY_TAGS_URL) {
$registryUrl = $env:PROXY_AGENT_REGISTRY_TAGS_URL
} else {
$registryHost = $IMAGE.Split('/')[0]
$repositoryPath = $IMAGE.Substring($registryHost.Length + 1)
$registryUrl = "https://$registryHost/v2/$repositoryPath/tags/list"
}

try {
$tagsJson = Invoke-RestMethod -Uri $registryUrl -Method Get
$tagsJson = Invoke-RestMethod -Uri $registryUrl -Method Get -TimeoutSec 30
} catch {
Write-Host "ERROR: Unable to query image registry for latest version: $_" -ForegroundColor Red
exit 1
Expand Down Expand Up @@ -208,6 +215,15 @@ function Compare-SemVer {
return $aVer.CompareTo($bVer)
}

# Only the commands that start a container check the setting, so stop, status
# and logs still work with an invalid one.
function Assert-MemoryAllocation {
if ("$MEMORY_ALLOCATION_MB" -notmatch '^\d+$' -or [int64]"$MEMORY_ALLOCATION_MB" -lt 1) {
Write-Host "ERROR: Invalid `$MEMORY_ALLOCATION_MB in ${SETTINGS_FILE}: $MEMORY_ALLOCATION_MB (must be a positive integer, in MB)" -ForegroundColor Red
exit 1
}
}

function Stop-ProxyAgent {
try { docker inspect $CONTAINER_NAME 2>&1 | Out-Null } catch {}
if ($LASTEXITCODE -eq 0) {
Expand Down Expand Up @@ -379,25 +395,31 @@ function Invoke-Upgrade {
if (-not (Start-ProxyAgent -Version $latestVersion -ContainerOsType $containerOsType)) {
Write-Log "Upgrade failed, rolling back to $currentVersion..."
Set-Content -Path $VERSION_FILE -Value $currentVersion -Encoding ascii
Start-ProxyAgent -Version $currentVersion -ContainerOsType $containerOsType | Out-Null
if (-not (Start-ProxyAgent -Version $currentVersion -ContainerOsType $containerOsType)) {
Write-Log "Rollback to $currentVersion failed."
}
exit 1
}
}

# ── Commands ─────────────────────────────────────────────────────────────────

switch ($Command) {
'start' {
Assert-MemoryAllocation
if (-not (Start-CurrentProxyAgent)) { exit 1 }
}
'stop' {
Write-Log "Stopping $CONTAINER_NAME..."
Stop-ProxyAgent
}
'restart' {
Assert-MemoryAllocation
Write-Log "Restarting $CONTAINER_NAME..."
if (-not (Start-CurrentProxyAgent)) { exit 1 }
}
'upgrade' {
Assert-MemoryAllocation
Invoke-Upgrade
}
'status' {
Expand Down
Loading
Loading