Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
109 changes: 109 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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 (PowerShell 5.1, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Run Pester
# Windows PowerShell 5.1 is what customers run the scripts with, so the
# tests run there rather than under pwsh. Pester is pinned to 5.x since
# the tests use its syntax.
shell: powershell
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)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- run: tests/integration/linux.sh

integration-windows:
name: Integration (Windows containers, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
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
shell: powershell
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 @@ -136,6 +136,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: `tests/bash/manager.bats` (bats) and `tests/pwsh/Manager.Tests.ps1` (Pester 5 under Windows PowerShell 5.1). Both run the script with stubbed `docker` and registry 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, and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers. 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).
8 changes: 5 additions & 3 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 @@ -26,8 +26,10 @@ $MIN_RECOMMENDED_CONTAINER_MEMORY_MB = 5120 # proxy agent container should g
$HOST_RESERVED_MEMORY_MB = 3072 # leave at least this much RAM for the host on Windows
$MIN_RECOMMENDED_DISK_SPACE_MB = 2048
$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
11 changes: 7 additions & 4 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 @@ -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
16 changes: 11 additions & 5 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 Down Expand Up @@ -169,9 +171,13 @@ 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
Expand Down
6 changes: 4 additions & 2 deletions proxy-agent-manager.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ set -euo pipefail

BASE_DIR="$(cd "$(dirname "$0")" && pwd)"

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="${PROXY_AGENT_IMAGE:-us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent}"
CONFIG_FILE="$BASE_DIR/config/config.json"
VERSION_FILE="$BASE_DIR/version"
SETTINGS_FILE="$BASE_DIR/settings.sh"
Expand Down Expand Up @@ -62,7 +64,7 @@ MEMORY_ALLOCATION_MB="${MEMORY_ALLOCATION_MB:-5120}"
get_latest_version() {
local registry_host="${IMAGE%%/*}"
local repository_path="${IMAGE#*/}"
local registry_url="https://${registry_host}/v2/${repository_path}/tags/list"
local registry_url="${PROXY_AGENT_REGISTRY_TAGS_URL:-https://${registry_host}/v2/${repository_path}/tags/list}"
local tags_json
tags_json=$(curl -s -f "$registry_url") || {
echo "ERROR: Unable to query image registry for latest version" >&2
Expand Down
134 changes: 134 additions & 0 deletions tests/bash/manager.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env bats
#
# Unit tests for proxy-agent-manager.sh. The script is run as a black box with
# the stub `docker` and `curl` commands from ./stubs on PATH, so no Docker
# daemon or network access is needed. The versions below are fixtures and do
# not depend on what is published in the real registry.
#

REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
# The repository the manager hardcodes; only used to build expected image
# strings, the docker stub never pulls anything.
IMAGE_REPO="us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent"

setup() {
AGENT_DIR="$(mktemp -d)"
mkdir -p "$AGENT_DIR/config"
cp "$REPO_ROOT/proxy-agent-manager.sh" "$AGENT_DIR/"
echo '{"agent_id": "abc123"}' > "$AGENT_DIR/config/config.json"
echo "1.2.9-ubuntu-26.04" > "$AGENT_DIR/version"

# State shared with the stubs in ./stubs; see the comments there.
export DOCKER_LOG="$AGENT_DIR/docker.log"
export CURL_LOG="$AGENT_DIR/curl.log"
export DOCKER_LAST_IMAGE="$AGENT_DIR/docker.image"
export DOCKER_UNHEALTHY_TAG=""
# Registry tag listing returned by the curl stub.
export STUB_CURL_RESPONSE='{"tags": ["1.2.9-ubuntu-26.04","1.2.10-ubuntu-26.04","1.2.11","1.2.10-windows-ltsc2022"]}'
export PATH="$REPO_ROOT/tests/bash/stubs:$PATH"
}

teardown() {
rm -rf "$AGENT_DIR"
}

manager() {
"$AGENT_DIR/proxy-agent-manager.sh" "$@"
}

last_image() {
cat "$DOCKER_LAST_IMAGE"
}

@test "start runs the pinned Ubuntu image with the default memory limit" {
run manager start
[ "$status" -eq 0 ]
[ "$(last_image)" = "$IMAGE_REPO:1.2.9-ubuntu-26.04" ]
grep -q -- "--memory=5120m" "$DOCKER_LOG"
}

@test "start honors MEMORY_ALLOCATION_MB from settings.sh" {
echo "MEMORY_ALLOCATION_MB=2048" > "$AGENT_DIR/settings.sh"
run manager start
[ "$status" -eq 0 ]
grep -q -- "--memory=2048m" "$DOCKER_LOG"
}

@test "start mounts the config read-only and labels the container with the agent id" {
run manager start
[ "$status" -eq 0 ]
grep -q -- "$AGENT_DIR/config/config.json:/config/config.json:ro" "$DOCKER_LOG"
grep -q -- "--name proxy-agent-abc123" "$DOCKER_LOG"
grep -q -- "proxy_agent_id=abc123" "$DOCKER_LOG"
}

@test "start fails when the config file is missing" {
rm "$AGENT_DIR/config/config.json"
run manager start
[ "$status" -eq 1 ]
[[ "$output" == *"Config file not found"* ]]
}

@test "start fails when the version file is empty" {
: > "$AGENT_DIR/version"
run manager start
[ "$status" -eq 1 ]
[[ "$output" == *"is empty"* ]]
}

@test "upgrade picks the newest Ubuntu tag and ignores bare and Windows tags" {
run manager upgrade
[ "$status" -eq 0 ]
[ "$(last_image)" = "$IMAGE_REPO:1.2.10-ubuntu-26.04" ]
[[ "$(cat "$AGENT_DIR/version")" == 1.2.10* ]]
}

@test "upgrade queries the registry tag listing, or its override" {
run manager upgrade
[ "$status" -eq 0 ]
grep -q "https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list" "$CURL_LOG"

export PROXY_AGENT_REGISTRY_TAGS_URL="http://127.0.0.1:1/v2/stub/tags/list"
run manager upgrade
[ "$status" -eq 0 ]
grep -q "http://127.0.0.1:1/v2/stub/tags/list" "$CURL_LOG"
}

@test "upgrade is a no-op when already on the latest version" {
export STUB_CURL_RESPONSE='{"tags": ["1.2.9-ubuntu-26.04","1.2.11"]}'
run manager upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"Already running the latest version"* ]]
! grep -q "^run" "$DOCKER_LOG"
}

@test "upgrade rolls back when the new container is unhealthy" {
export DOCKER_UNHEALTHY_TAG="1.2.10"
run manager upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"rolling back"* ]]
[ "$(last_image)" = "$IMAGE_REPO:1.2.9-ubuntu-26.04" ]
[[ "$(cat "$AGENT_DIR/version")" == 1.2.9* ]]
}

@test "upgrade rejects an invalid version file entry" {
echo "2.0" > "$AGENT_DIR/version"
run manager upgrade
[ "$status" -eq 1 ]
[[ "$output" == *"Invalid"* ]]
}

@test "stop removes the running container" {
manager start
run manager stop
[ "$status" -eq 0 ]
[ ! -f "$DOCKER_LAST_IMAGE" ]
grep -q "^stop proxy-agent-abc123" "$DOCKER_LOG"
grep -q "^rm proxy-agent-abc123" "$DOCKER_LOG"
}

@test "unknown command prints usage" {
run manager bogus
[ "$status" -eq 1 ]
[[ "$output" == *"Usage:"* ]]
}
Loading
Loading