diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3260d4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..810bfe5 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -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' + ) +} diff --git a/README.md b/README.md index 0fbbd50..288f73f 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/install.ps1 b/install.ps1 index 92523c7..554bd30 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 5.1 +#Requires -Version 5.1 # # Installer for Fivetran Proxy Agent on Windows using Docker # @@ -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' diff --git a/install.sh b/install.sh index a275e18..c140b37 100755 --- a/install.sh +++ b/install.sh @@ -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=() @@ -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..." diff --git a/proxy-agent-manager.ps1 b/proxy-agent-manager.ps1 index 93474a1..d0c0445 100644 --- a/proxy-agent-manager.ps1 +++ b/proxy-agent-manager.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 5.1 +#Requires -Version 5.1 param( [Parameter(Position = 0)] [string]$Command @@ -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' @@ -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 diff --git a/proxy-agent-manager.sh b/proxy-agent-manager.sh index 5a703f1..8439eb1 100755 --- a/proxy-agent-manager.sh +++ b/proxy-agent-manager.sh @@ -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" @@ -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 diff --git a/tests/bash/manager.bats b/tests/bash/manager.bats new file mode 100644 index 0000000..7fe924c --- /dev/null +++ b/tests/bash/manager.bats @@ -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:"* ]] +} diff --git a/tests/bash/stubs/curl b/tests/bash/stubs/curl new file mode 100755 index 0000000..fddcf6e --- /dev/null +++ b/tests/bash/stubs/curl @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# +# Stub curl for tests/bash/manager.bats. Records its arguments to $CURL_LOG, +# answers every call with $STUB_CURL_RESPONSE and never touches the network. +# +echo "$*" >> "$CURL_LOG" +echo "$STUB_CURL_RESPONSE" diff --git a/tests/bash/stubs/docker b/tests/bash/stubs/docker new file mode 100755 index 0000000..8c49f18 --- /dev/null +++ b/tests/bash/stubs/docker @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# Stub docker CLI for tests/bash/manager.bats. Records every call to +# $DOCKER_LOG and keeps one container's worth of state: $DOCKER_LAST_IMAGE +# exists while a container "runs" and holds the image it was started from. +# Nothing is pulled or run. If $DOCKER_UNHEALTHY_TAG is set and the running +# image contains it, the health check reports unhealthy. +# +echo "$*" >> "$DOCKER_LOG" +case "$1" in + run) + for arg in "$@"; do + case "$arg" in *proxy-agent:*) echo "$arg" > "$DOCKER_LAST_IMAGE" ;; esac + done + ;; + inspect) + [ -f "$DOCKER_LAST_IMAGE" ] || exit 1 + if [ "$2" = "-f" ]; then + case "$(cat "$DOCKER_LAST_IMAGE")" in + *"${DOCKER_UNHEALTHY_TAG:-__none__}"*) echo unhealthy ;; + *) echo healthy ;; + esac + fi + ;; + stop|rm) + rm -f "$DOCKER_LAST_IMAGE" + ;; + ps) + echo "NAMES IMAGE STATUS" + ;; +esac diff --git a/tests/integration/fixtures/config-websocket.json b/tests/integration/fixtures/config-websocket.json new file mode 100644 index 0000000..d85f52a --- /dev/null +++ b/tests/integration/fixtures/config-websocket.json @@ -0,0 +1,5 @@ +{ + "agent_id": "test-agent", + "auth_token": "secret", + "proxy_server_uri": "wss://old-proxy.invalid" +} diff --git a/tests/integration/fixtures/config.json b/tests/integration/fixtures/config.json new file mode 100644 index 0000000..0ed8e98 --- /dev/null +++ b/tests/integration/fixtures/config.json @@ -0,0 +1,5 @@ +{ + "agent_id": "test-agent", + "orchestrator_host": "orchestrator.invalid", + "orchestrator_port": 443 +} diff --git a/tests/integration/linux.sh b/tests/integration/linux.sh new file mode 100755 index 0000000..c3d0e24 --- /dev/null +++ b/tests/integration/linux.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# +# Integration test for install.sh and proxy-agent-manager.sh on a real Docker +# daemon. Uses a stub agent image built here (tests/stub) and a local stand-in +# for the registry and the script download, so it needs no Fivetran +# credentials and never contacts the real registry; the versions below are +# fixtures. The only network access is pulling the stub's alpine base image. +# +# Usage: tests/integration/linux.sh +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PORT="${PORT:-8181}" +STUB_IMAGE="proxy-agent-stub" +SUFFIX="-ubuntu-26.04" +OLD_VERSION="1.2.3" +NEW_VERSION="1.2.4" +AGENT_ID="test-agent" # matches the fixtures +CONTAINER="proxy-agent-${AGENT_ID}" +TEMP_ROOT="$(mktemp -d)" +INSTALL_DIR="$TEMP_ROOT/fivetran-proxy-agent" +FIXTURES="$REPO_ROOT/tests/integration/fixtures" +# Credential the stub configure endpoint accepts: agent_id:auth_token from +# fixtures/config-websocket.json, base64 encoded as the dashboard does. +TOKEN_VALUE="$(printf 'test-agent:secret' | base64 | tr -d '\n')" +SERVER_PID="" + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + rm -rf "$TEMP_ROOT" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +current_image() { + docker inspect -f '{{.Config.Image}}' "$CONTAINER" +} + +assert_image() { + local actual + actual=$(current_image) + [ "$actual" = "$1" ] || fail "expected image $1, got $actual" +} + +assert_healthy() { + local status + status=$(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER") + [ "$status" = "healthy" ] || fail "expected healthy container, got $status" +} + +assert_version_file() { + local content + content=$(cat "$INSTALL_DIR/version") + [[ "$content" == "$1"* ]] || fail "expected version file to start with $1, got '$content'" +} + +echo "== Building stub image" +docker build -q -f "$REPO_ROOT/tests/stub/Dockerfile.linux" -t "$STUB_IMAGE:$OLD_VERSION$SUFFIX" "$REPO_ROOT/tests/stub" +# The bare tags let the rollback step work whether the management script pins +# the plain version or the full image tag. +for tag in "$OLD_VERSION" "$NEW_VERSION" "$NEW_VERSION$SUFFIX"; do + docker tag "$STUB_IMAGE:$OLD_VERSION$SUFFIX" "$STUB_IMAGE:$tag" +done + +echo "== Starting stub registry on port $PORT" +python3 "$REPO_ROOT/tests/integration/registry_server.py" --port "$PORT" --root "$REPO_ROOT" \ + --tags "$OLD_VERSION,$OLD_VERSION$SUFFIX,$NEW_VERSION,$NEW_VERSION$SUFFIX,$NEW_VERSION-windows-ltsc2022" \ + --token "$TOKEN_VALUE" --config "$FIXTURES/config.json" & +SERVER_PID=$! +for _ in $(seq 1 40); do + # The liveness check catches a server that died on bind (port in use), so + # the readiness check below cannot succeed against some other listener. + kill -0 "$SERVER_PID" 2>/dev/null || fail "stub registry exited; is port $PORT in use?" + curl -sf "http://127.0.0.1:$PORT/proxy-agent-manager.sh" >/dev/null && break + sleep 0.25 +done +curl -sf "http://127.0.0.1:$PORT/proxy-agent-manager.sh" >/dev/null || fail "stub registry did not start" + +export RUNTIME=docker +export MEMORY_ALLOCATION_MB=256 +export PROXY_AGENT_IMAGE="$STUB_IMAGE" +export PROXY_AGENT_REGISTRY_TAGS_URL="http://127.0.0.1:$PORT/v2/stub/tags/list" +export PROXY_AGENT_SCRIPT_URL="http://127.0.0.1:$PORT/proxy-agent-manager.sh" +export FIVETRAN_API_URL="http://127.0.0.1:$PORT" + +echo "== Installing from a config file" +"$REPO_ROOT/install.sh" "$FIXTURES/config.json" --install-dir "$INSTALL_DIR" +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" +assert_healthy +assert_version_file "$NEW_VERSION" +grep -qx "MEMORY_ALLOCATION_MB=256" "$INSTALL_DIR/settings.sh" || fail "settings.sh does not record the memory allocation" +[ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((256 * 1024 * 1024))" ] || fail "container memory limit not applied" +config_mode=$(stat -c %a "$INSTALL_DIR/config/config.json" 2>/dev/null || stat -f %Lp "$INSTALL_DIR/config/config.json") +[ "$config_mode" = "600" ] || fail "config.json is not mode 600 (got $config_mode)" +cmp -s "$FIXTURES/config.json" "$INSTALL_DIR/config/config.json" || fail "config.json was not copied verbatim" + +MANAGER="$INSTALL_DIR/proxy-agent-manager.sh" + +echo "== status" +status_output=$("$MANAGER" status) +[[ "$status_output" == *"$CONTAINER"* ]] || fail "status does not list the container" + +echo "== Rolling back to $OLD_VERSION via the version file" +echo "$OLD_VERSION" > "$INSTALL_DIR/version" +"$MANAGER" start +case "$(current_image)" in + "$STUB_IMAGE:$OLD_VERSION"|"$STUB_IMAGE:$OLD_VERSION$SUFFIX") ;; + *) fail "expected rollback to $OLD_VERSION, got $(current_image)" ;; +esac +assert_healthy + +echo "== Upgrading" +"$MANAGER" upgrade +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" +assert_healthy +assert_version_file "$NEW_VERSION" + +echo "== Upgrading again is a no-op" +upgrade_output=$("$MANAGER" upgrade) +[[ "$upgrade_output" == *"Already running the latest version"* ]] || fail "second upgrade did not report already latest" +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" + +echo "== restart" +"$MANAGER" restart +assert_healthy + +echo "== stop" +"$MANAGER" stop +if docker inspect "$CONTAINER" >/dev/null 2>&1; then + fail "container still exists after stop" +fi + +echo "== Installing with TOKEN fetches the config from the configure endpoint" +TOKEN_INSTALL_DIR="$TEMP_ROOT/fivetran-proxy-agent-token" +TOKEN="$TOKEN_VALUE" "$REPO_ROOT/install.sh" --install-dir "$TOKEN_INSTALL_DIR" +cmp -s "$FIXTURES/config.json" "$TOKEN_INSTALL_DIR/config/config.json" || fail "config.json does not match the configure endpoint's response" +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" +assert_healthy +"$TOKEN_INSTALL_DIR/proxy-agent-manager.sh" stop + +echo "== Installing with a wrong TOKEN fails" +BAD_TOKEN_INSTALL_DIR="$TEMP_ROOT/fivetran-proxy-agent-bad-token" +if bad_token_output=$(TOKEN="$(printf 'test-agent:wrong' | base64 | tr -d '\n')" "$REPO_ROOT/install.sh" --install-dir "$BAD_TOKEN_INSTALL_DIR" 2>&1); then + fail "install.sh succeeded with a wrong TOKEN" +fi +[[ "$bad_token_output" == *"HTTP 401"* ]] || fail "expected an HTTP 401 error, got: $bad_token_output" +[ ! -s "$BAD_TOKEN_INSTALL_DIR/config/config.json" ] || fail "config.json was written despite the failed configure call" + +echo "== Installing from a WebSocket config migrates it through the configure endpoint" +WS_INSTALL_DIR="$TEMP_ROOT/fivetran-proxy-agent-websocket" +"$REPO_ROOT/install.sh" "$FIXTURES/config-websocket.json" --install-dir "$WS_INSTALL_DIR" +cmp -s "$FIXTURES/config.json" "$WS_INSTALL_DIR/config/config.json" || fail "WebSocket config was not replaced by the configure endpoint's response" +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" +assert_healthy +"$WS_INSTALL_DIR/proxy-agent-manager.sh" stop + +echo "PASS" diff --git a/tests/integration/registry_server.py b/tests/integration/registry_server.py new file mode 100644 index 0000000..636b4a6 --- /dev/null +++ b/tests/integration/registry_server.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Stand-in for the remote endpoints the installers call. + +- ``GET /v2//tags/list``: a container registry tag listing with the + tags given on the command line. +- ``POST /proxy-agent-configure``: see below; the Fivetran configure endpoint. + Returns the ``--config`` file as the agent configuration when the request + carries ``Authorization: Basic <--token>``, and 401 otherwise. +- Anything else: a static file from ``--root`` (the repository checkout), which + is how the installers download the management script under test instead of + the one on GitHub's main branch. +""" +import argparse +import json +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +CONFIGURE_PATH = "/proxy-agent/configure" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=8181) + parser.add_argument("--root", required=True, help="directory to serve static files from") + parser.add_argument("--tags", required=True, help="comma-separated image tags to list") + parser.add_argument("--token", required=True, help="base64 credential the configure endpoint accepts") + parser.add_argument("--config", required=True, help="config.json the configure endpoint returns") + args = parser.parse_args() + tags = args.tags.split(",") + config_body = Path(args.config).read_bytes() + + class Handler(SimpleHTTPRequestHandler): + def __init__(self, *handler_args, **handler_kwargs): + super().__init__(*handler_args, directory=args.root, **handler_kwargs) + + def send_json(self, status, body): + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path.startswith("/v2/") and self.path.endswith("/tags/list"): + self.send_json(200, json.dumps({"name": "stub", "tags": tags}).encode()) + return + super().do_GET() + + def do_POST(self): + if self.path != CONFIGURE_PATH: + self.send_error(404) + return + if self.headers.get("Authorization") == f"Basic {args.token}": + self.send_json(200, config_body) + else: + self.send_json(401, json.dumps({"message": "unauthorized"}).encode()) + + def log_message(self, fmt, *log_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + print(f"stub registry listening on http://127.0.0.1:{args.port}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 new file mode 100644 index 0000000..15df7a5 --- /dev/null +++ b/tests/integration/windows.ps1 @@ -0,0 +1,208 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS +Integration test for install.ps1 and proxy-agent-manager.ps1 with Windows +containers on a real Docker daemon. + +.DESCRIPTION +Uses a stub agent image built here (tests\stub) for the host's LTSC version and +a local stand-in for the registry and the script download, so it needs no +Fivetran credentials and never contacts the real registry; the versions below +are fixtures. The only network access is pulling the stub's servercore base +image. +#> +param( + [int]$Port = 8181 +) + +$ErrorActionPreference = 'Stop' + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$StubImage = 'proxy-agent-stub' +$OldVersion = '1.2.3' +$NewVersion = '1.2.4' +$AgentId = 'test-agent' # matches the fixtures +$Container = "proxy-agent-$AgentId" +$TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "proxy-agent-test-$PID" +$InstallDir = Join-Path $TempRoot 'fivetran-proxy-agent' +$Fixtures = Join-Path $RepoRoot 'tests\integration\fixtures' +# Credential the stub configure endpoint accepts: agent_id:auth_token from +# fixtures\config-websocket.json, base64 encoded as the dashboard does. +$TokenValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('test-agent:secret')) +$Server = $null + +function Fail { + param([string]$Message) + throw "FAIL: $Message" +} + +function Get-LtscVersion { + $build = [int](Get-CimInstance -ClassName Win32_OperatingSystem).BuildNumber + switch ($build) { + 17763 { return 'ltsc2019' } + 20348 { return 'ltsc2022' } + 26100 { return 'ltsc2025' } + } + Fail "Unsupported Windows build $build; the stub image needs a matching servercore tag" +} + +function Get-CurrentImage { + return (docker inspect -f '{{.Config.Image}}' $Container).Trim() +} + +function Assert-Image { + param([string]$Expected) + $actual = Get-CurrentImage + if ($actual -ne $Expected) { Fail "expected image $Expected, got $actual" } +} + +function Assert-Healthy { + $status = (docker inspect -f '{{.State.Health.Status}}' $Container).Trim() + if ($status -ne 'healthy') { Fail "expected healthy container, got $status" } +} + +function Assert-VersionFile { + param([string]$Prefix) + $content = (Get-Content -LiteralPath (Join-Path $InstallDir 'version') -Raw).Trim() + if (-not $content.StartsWith($Prefix)) { Fail "expected version file to start with $Prefix, got '$content'" } +} + +function Invoke-Manager { + param([string]$Command, [string]$Dir = $InstallDir) + $output = & (Join-Path $Dir 'proxy-agent-manager.ps1') $Command *>&1 | Out-String + Write-Host $output + if ($LASTEXITCODE -ne 0) { Fail "manager $Command exited with $LASTEXITCODE" } + return $output +} + +function Assert-ConfigMatches { + param([string]$Dir, [string]$Fixture) + $expected = Get-Content -LiteralPath (Join-Path $Fixtures $Fixture) -Raw + $actual = Get-Content -LiteralPath (Join-Path $Dir 'config\config.json') -Raw + if ($actual -ne $expected) { Fail "config.json in $Dir does not match $Fixture" } +} + +try { + $null = New-Item -ItemType Directory -Force -Path $TempRoot + $ltsc = Get-LtscVersion + $oldTag = "$OldVersion-windows-$ltsc" + $newTag = "$NewVersion-windows-$ltsc" + + Write-Host "== Building stub image for $ltsc" + docker build -q --build-arg "BASE_TAG=$ltsc" -f (Join-Path $RepoRoot 'tests\stub\Dockerfile.windows') -t "${StubImage}:$oldTag" (Join-Path $RepoRoot 'tests\stub') | Out-Host + if ($LASTEXITCODE -ne 0) { Fail 'stub image build failed' } + docker tag "${StubImage}:$oldTag" "${StubImage}:$newTag" + + Write-Host "== Starting stub registry on port $Port" + $tags = "$oldTag,$newTag,$NewVersion-ubuntu-26.04,$NewVersion,$NewVersion-windows-ltsc2016" + # Start-Process does not quote arguments, so paths are quoted here. + $Server = Start-Process -FilePath python -PassThru -NoNewWindow -ArgumentList @( + "`"$(Join-Path $RepoRoot 'tests\integration\registry_server.py')`"", + '--port', $Port, '--root', "`"$RepoRoot`"", '--tags', $tags, + '--token', $TokenValue, '--config', "`"$(Join-Path $Fixtures 'config.json')`"" + ) + $ready = $false + foreach ($attempt in 1..40) { + # A server that died on bind (port in use) must not let the readiness + # check succeed against some other listener. + if ($Server.HasExited) { Fail "stub registry exited; is port $Port in use?" } + try { + $null = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/proxy-agent-manager.ps1" -UseBasicParsing -TimeoutSec 2 + $ready = $true + break + } catch { + Start-Sleep -Milliseconds 250 + } + } + if (-not $ready) { Fail 'stub registry did not start' } + + $env:RUNTIME = 'docker' + $env:MEMORY_ALLOCATION_MB = '512' + $env:PROXY_AGENT_IMAGE = $StubImage + $env:PROXY_AGENT_REGISTRY_TAGS_URL = "http://127.0.0.1:$Port/v2/stub/tags/list" + $env:PROXY_AGENT_SCRIPT_URL = "http://127.0.0.1:$Port/proxy-agent-manager.ps1" + $env:FIVETRAN_API_URL = "http://127.0.0.1:$Port" + + Write-Host '== Installing from a config file' + & (Join-Path $RepoRoot 'install.ps1') (Join-Path $Fixtures 'config.json') -InstallDir $InstallDir + if ($LASTEXITCODE -ne 0) { Fail "install.ps1 exited with $LASTEXITCODE" } + Assert-Image "${StubImage}:$newTag" + Assert-Healthy + Assert-VersionFile $NewVersion + $settings = Get-Content -LiteralPath (Join-Path $InstallDir 'settings.ps1') -Raw + if ($settings -notmatch '\$MEMORY_ALLOCATION_MB = 512') { Fail 'settings.ps1 does not record the memory allocation' } + $memory = [int64](docker inspect -f '{{.HostConfig.Memory}}' $Container).Trim() + if ($memory -ne 512MB) { Fail "container memory limit not applied (got $memory)" } + Assert-ConfigMatches $InstallDir 'config.json' + + Write-Host '== status' + $status = Invoke-Manager status + if ($status -notmatch [regex]::Escape($Container)) { Fail 'status does not list the container' } + + Write-Host "== Rolling back to $OldVersion via the version file" + Set-Content -LiteralPath (Join-Path $InstallDir 'version') -Value $OldVersion -Encoding ascii + $null = Invoke-Manager start + Assert-Image "${StubImage}:$oldTag" + Assert-Healthy + + Write-Host '== Upgrading' + $null = Invoke-Manager upgrade + Assert-Image "${StubImage}:$newTag" + Assert-Healthy + Assert-VersionFile $NewVersion + + Write-Host '== Upgrading again is a no-op' + $output = Invoke-Manager upgrade + if ($output -notmatch 'Already running the latest version') { Fail 'second upgrade did not report already latest' } + Assert-Image "${StubImage}:$newTag" + + Write-Host '== restart' + $null = Invoke-Manager restart + Assert-Healthy + + Write-Host '== stop' + $null = Invoke-Manager stop + $remaining = docker ps -a --filter "name=$Container" --format '{{.Names}}' + if ($remaining) { Fail "container still exists after stop: $remaining" } + + Write-Host '== Installing with TOKEN fetches the config from the configure endpoint' + $tokenInstallDir = Join-Path $TempRoot 'fivetran-proxy-agent-token' + $env:TOKEN = $TokenValue + & (Join-Path $RepoRoot 'install.ps1') -InstallDir $tokenInstallDir + if ($LASTEXITCODE -ne 0) { Fail "install.ps1 with TOKEN exited with $LASTEXITCODE" } + Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue + Assert-ConfigMatches $tokenInstallDir 'config.json' + Assert-Image "${StubImage}:$newTag" + Assert-Healthy + $null = Invoke-Manager stop -Dir $tokenInstallDir + + Write-Host '== Installing with a wrong TOKEN fails' + $badTokenInstallDir = Join-Path $TempRoot 'fivetran-proxy-agent-bad-token' + $env:TOKEN = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('test-agent:wrong')) + $badTokenOutput = & (Join-Path $RepoRoot 'install.ps1') -InstallDir $badTokenInstallDir *>&1 | Out-String + $badTokenExit = $LASTEXITCODE + Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue + if ($badTokenExit -eq 0) { Fail 'install.ps1 succeeded with a wrong TOKEN' } + if ($badTokenOutput -notmatch 'HTTP 401') { Fail "expected an HTTP 401 error, got: $badTokenOutput" } + $badConfig = Join-Path $badTokenInstallDir 'config\config.json' + if ((Test-Path -LiteralPath $badConfig) -and (Get-Item -LiteralPath $badConfig).Length -gt 0) { + Fail 'config.json was written despite the failed configure call' + } + + Write-Host '== Installing from a WebSocket config migrates it through the configure endpoint' + $wsInstallDir = Join-Path $TempRoot 'fivetran-proxy-agent-websocket' + & (Join-Path $RepoRoot 'install.ps1') (Join-Path $Fixtures 'config-websocket.json') -InstallDir $wsInstallDir + if ($LASTEXITCODE -ne 0) { Fail "install.ps1 with a WebSocket config exited with $LASTEXITCODE" } + Assert-ConfigMatches $wsInstallDir 'config.json' + Assert-Image "${StubImage}:$newTag" + Assert-Healthy + $null = Invoke-Manager stop -Dir $wsInstallDir + + Write-Host 'PASS' +} finally { + # Best-effort cleanup; a failure here must not hide the test's own error. + $ErrorActionPreference = 'Continue' + if ($Server -and -not $Server.HasExited) { Stop-Process -Id $Server.Id -Force } + docker rm -f $Container 2>&1 | Out-Null + if (Test-Path -LiteralPath $TempRoot) { Remove-Item -LiteralPath $TempRoot -Recurse -Force } +} diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 new file mode 100644 index 0000000..07c8b6f --- /dev/null +++ b/tests/pwsh/Manager.Tests.ps1 @@ -0,0 +1,274 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } +<# +.SYNOPSIS +Unit tests for proxy-agent-manager.ps1. + +.DESCRIPTION +The script is run as a black box with docker, Invoke-RestMethod and +Get-CimInstance replaced by functions, so no Docker daemon is needed. Functions +defined in BeforeAll shadow the real commands for the invoked script because it +runs in a child scope. + +The stubs keep their state in $global:ProxyAgentStub. PowerShell scopes +dynamically, so inside a stub called from the manager script $script: refers to +the manager's scope, not this file's; only a global is visible to both. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification = 'Stub state must be global; see the description above.')] +param() + +BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + # The repository the manager hardcodes; only used to build expected image + # strings. The docker stub never pulls anything and the tag list below is + # a fixture, so these tests do not depend on what is published. + $script:ImageRepo = 'us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent' + + # Creates an installation directory with the management script, a config + # file and the given version file content, and returns the manager's path. + function New-AgentDir { + param( + [string]$Version = '1.2.9-ubuntu-26.04', + [string]$Settings + ) + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ("proxy-agent-test-" + [guid]::NewGuid()) + $null = New-Item -ItemType Directory -Force -Path (Join-Path $dir 'config') + Copy-Item (Join-Path $script:RepoRoot 'proxy-agent-manager.ps1') $dir + Set-Content -LiteralPath (Join-Path $dir 'config\config.json') -Value '{"agent_id": "abc123"}' -Encoding ascii + Set-Content -LiteralPath (Join-Path $dir 'version') -Value $Version -Encoding ascii + if ($Settings) { + Set-Content -LiteralPath (Join-Path $dir 'settings.ps1') -Value $Settings -Encoding ascii + } + $script:AgentDir = $dir + return Join-Path $dir 'proxy-agent-manager.ps1' + } + + function Get-VersionFile { + return (Get-Content -LiteralPath (Join-Path $script:AgentDir 'version') -Raw).Trim() + } + + # Runs the manager, capturing every output stream as one string. + function Invoke-Manager { + param([string]$Manager, [string]$Command) + return (& $Manager $Command *>&1 | Out-String) + } + + # Returns the arguments of the first recorded docker call for the given + # subcommand, or nothing. A foreach loop is used because piping the + # recorded calls through Where-Object would flatten them into strings. + function Get-DockerCall { + param([string]$Subcommand) + foreach ($call in $global:ProxyAgentStub.DockerCalls) { + if ($call[0] -eq $Subcommand) { return $call } + } + } + + function Get-DockerRunArgs { + return Get-DockerCall 'run' + } + + # Stand-in for the docker CLI. Records every call and keeps one container's + # worth of state: LastImage is $null while no container "runs" and + # otherwise holds the image it was started from. + function docker { + $stub = $global:ProxyAgentStub + $stub.DockerCalls += ,@($args) + $global:LASTEXITCODE = 0 + switch ($args[0]) { + 'info' { return $stub.OsType } + 'run' { + $stub.LastImage = $args | Where-Object { $_ -like "*proxy-agent:*" } | Select-Object -First 1 + return 'container-id' + } + 'inspect' { + if (-not $stub.LastImage) { + $global:LASTEXITCODE = 1 + return + } + if ($args[1] -eq '-f') { + if ($stub.UnhealthyTag -and $stub.LastImage -like "*$($stub.UnhealthyTag)*") { return 'unhealthy' } + return 'healthy' + } + } + { $_ -in 'stop', 'rm' } { $stub.LastImage = $null } + 'ps' { return 'NAMES IMAGE STATUS' } + } + } + + # Stand-in for the registry tag listing. Records the request. + function Invoke-RestMethod { + param([string]$Uri, [string]$Method) + $global:ProxyAgentStub.RegistryUrl = $Uri + $global:ProxyAgentStub.RegistryMethod = $Method + return [pscustomobject]@{ tags = $global:ProxyAgentStub.Tags } + } + + # Stand-in for the host OS lookup, reporting Windows Server 2022. + function Get-CimInstance { + return [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } + } +} + +AfterAll { + Remove-Variable -Name ProxyAgentStub -Scope Global -ErrorAction SilentlyContinue +} + +Describe 'proxy-agent-manager.ps1' { + BeforeEach { + # A developer's environment must not leak into the default-URL assertion. + Remove-Item Env:\PROXY_AGENT_REGISTRY_TAGS_URL -ErrorAction SilentlyContinue + $global:ProxyAgentStub = @{ + OsType = 'linux' + LastImage = $null + UnhealthyTag = $null + DockerCalls = @() + RegistryUrl = $null + RegistryMethod = $null + Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') + } + } + + AfterEach { + Remove-Item Env:\PROXY_AGENT_REGISTRY_TAGS_URL -ErrorAction SilentlyContinue + if ($script:AgentDir -and (Test-Path -LiteralPath $script:AgentDir)) { + Remove-Item -LiteralPath $script:AgentDir -Recurse -Force + } + } + + Context 'with Linux containers' { + It 'starts the pinned Ubuntu image with the default memory limit' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-ubuntu-26.04" + Get-DockerRunArgs | Should -Contain '--memory=5120m' + } + + It 'honors MEMORY_ALLOCATION_MB from settings.ps1' { + $manager = New-AgentDir -Settings '$MEMORY_ALLOCATION_MB = 2048' + $null = Invoke-Manager $manager start + Get-DockerRunArgs | Should -Contain '--memory=2048m' + } + + It 'labels the container with the agent id' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $runArgs = Get-DockerRunArgs + $runArgs | Should -Contain 'proxy-agent-abc123' + $runArgs | Should -Contain 'proxy_agent_id=abc123' + } + + It 'fails when the config file is missing' { + $manager = New-AgentDir + Remove-Item -LiteralPath (Join-Path $script:AgentDir 'config\config.json') + $output = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Config file not found' + } + + It 'fails when the version file is empty' { + $manager = New-AgentDir -Version '' + $output = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'is empty' + } + + It 'refuses a Windows image tag' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' + { & $manager start } | Should -Throw '*Windows containers*' + } + + It 'upgrades to the newest Ubuntu tag and ignores bare and Windows tags' { + $manager = New-AgentDir + $null = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.10-ubuntu-26.04" + Get-VersionFile | Should -BeLike '1.2.10*' + } + + It 'queries the registry tag listing, or its override, on upgrade' { + $manager = New-AgentDir + $null = Invoke-Manager $manager upgrade + $global:ProxyAgentStub.RegistryUrl | Should -Be "https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list" + + $env:PROXY_AGENT_REGISTRY_TAGS_URL = 'http://127.0.0.1:1/v2/stub/tags/list' + $null = Invoke-Manager $manager upgrade + $global:ProxyAgentStub.RegistryUrl | Should -Be 'http://127.0.0.1:1/v2/stub/tags/list' + } + + It 'does nothing on upgrade when already on the latest version' { + $global:ProxyAgentStub.Tags = @('1.2.9-ubuntu-26.04', '1.2.11') + $manager = New-AgentDir + $output = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'Already running the latest version' + Get-DockerRunArgs | Should -BeNullOrEmpty + } + + It 'rolls back when the upgraded container is unhealthy' { + $global:ProxyAgentStub.UnhealthyTag = '1.2.10' + $manager = New-AgentDir + $output = Invoke-Manager $manager upgrade + $output | Should -Match 'rolling back' + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-ubuntu-26.04" + Get-VersionFile | Should -BeLike '1.2.9*' + } + + It 'rejects an invalid version file entry on upgrade' { + $manager = New-AgentDir -Version '2.0' + { & $manager upgrade } | Should -Throw '*Invalid*' + } + + It 'removes the running container on stop' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $null = Invoke-Manager $manager stop + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.LastImage | Should -BeNullOrEmpty + Get-DockerCall 'rm' | Should -Contain 'proxy-agent-abc123' + } + } + + Context 'with Windows containers' { + BeforeEach { + $global:ProxyAgentStub.OsType = 'windows' + } + + It 'starts the image for the detected LTSC version from a plain version' { + $manager = New-AgentDir -Version '1.2.9' + $null = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-windows-ltsc2022" + } + + It 'starts a pinned Windows image tag as is' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2025' + $null = Invoke-Manager $manager start + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-windows-ltsc2025" + } + + It 'refuses an Ubuntu image tag' { + $manager = New-AgentDir -Version '1.2.9-ubuntu-26.04' + { & $manager start } | Should -Throw '*Linux containers*' + } + + It 'upgrades to the newest tag for the same LTSC version only' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' + $null = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.10-windows-ltsc2022" + Get-VersionFile | Should -BeLike '1.2.10*' + } + + It 'mounts the config directory and uses the Windows health check' { + $manager = New-AgentDir -Version '1.2.9' + $null = Invoke-Manager $manager start + $runArgs = Get-DockerRunArgs + ($runArgs | Where-Object { $_ -like '*:C:/config:ro' }) | Should -Not -BeNullOrEmpty + $healthCmd = $runArgs | Where-Object { $_ -like 'powershell.exe -NoProfile -EncodedCommand *' } + $healthCmd | Should -Not -BeNullOrEmpty + $encoded = ($healthCmd -split ' ')[-1] + $decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + $decoded | Should -Match 'HEARTBEAT_EXPIRE_AT' + } + } +} diff --git a/tests/stub/Dockerfile.linux b/tests/stub/Dockerfile.linux new file mode 100644 index 0000000..f66774a --- /dev/null +++ b/tests/stub/Dockerfile.linux @@ -0,0 +1,7 @@ +# Stub proxy agent image for the integration tests. It only writes the heartbeat +# file the management script's health check reads, so the tests never need +# Fivetran credentials or network access. +FROM alpine:3.20 +COPY heartbeat.sh /heartbeat.sh +RUN chmod +x /heartbeat.sh +ENTRYPOINT ["/heartbeat.sh"] diff --git a/tests/stub/Dockerfile.windows b/tests/stub/Dockerfile.windows new file mode 100644 index 0000000..53e23a6 --- /dev/null +++ b/tests/stub/Dockerfile.windows @@ -0,0 +1,8 @@ +# Stub proxy agent image for the Windows-container integration tests. It only +# writes the heartbeat file the management script's health check reads. +# servercore is required because the health check runs powershell.exe inside +# the container. BASE_TAG must match the host's LTSC version. +ARG BASE_TAG=ltsc2022 +FROM mcr.microsoft.com/windows/servercore:${BASE_TAG} +COPY heartbeat.ps1 C:/heartbeat.ps1 +ENTRYPOINT ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:/heartbeat.ps1"] diff --git a/tests/stub/heartbeat.ps1 b/tests/stub/heartbeat.ps1 new file mode 100644 index 0000000..0748558 --- /dev/null +++ b/tests/stub/heartbeat.ps1 @@ -0,0 +1,24 @@ +# Stub proxy agent: writes the heartbeat file the management script's health +# check reads, then idles. It expects to be invoked like the real agent, with +# `-i ` pointing at a readable file, and exits otherwise so the +# container never becomes healthy and the test fails. +if ($args.Count -lt 2 -or $args[0] -ne '-i') { + Write-Error "stub proxy agent: expected '-i ', got: $args" + exit 1 +} +if (-not (Test-Path -LiteralPath $args[1] -PathType Leaf)) { + Write-Error "stub proxy agent: config file not readable: $($args[1])" + exit 1 +} + +$path = $env:HEARTBEAT_PATH +if (-not $path) { throw 'HEARTBEAT_PATH must be set' } +$expiry = if ($env:HEARTBEAT_EXPIRY_SECONDS) { [int]$env:HEARTBEAT_EXPIRY_SECONDS } else { 30 } + +Write-Host "stub proxy agent started with config $($args[1])" +$null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) +while ($true) { + $expireAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $expiry + Set-Content -LiteralPath $path -Value "HEARTBEAT_EXPIRE_AT=$expireAt" -Encoding ascii + Start-Sleep -Seconds 5 +} diff --git a/tests/stub/heartbeat.sh b/tests/stub/heartbeat.sh new file mode 100755 index 0000000..44dfced --- /dev/null +++ b/tests/stub/heartbeat.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# +# Stub proxy agent: writes the heartbeat file the management script's health +# check reads, then idles. It expects to be invoked like the real agent, with +# `-i ` pointing at a readable file, and exits otherwise so the +# container never becomes healthy and the test fails. +# +set -eu + +if [ "${1:-}" != "-i" ] || [ -z "${2:-}" ]; then + echo "stub proxy agent: expected '-i ', got: $*" >&2 + exit 1 +fi +if [ ! -r "$2" ]; then + echo "stub proxy agent: config file not readable: $2" >&2 + exit 1 +fi + +path="${HEARTBEAT_PATH:?HEARTBEAT_PATH must be set}" +expiry="${HEARTBEAT_EXPIRY_SECONDS:-30}" + +echo "stub proxy agent started with config $2" +while true; do + echo "HEARTBEAT_EXPIRE_AT=$(( $(date +%s) + expiry ))" > "$path" + sleep 5 +done