diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index 75525462a..1d98a7c6e 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -1,31 +1,18 @@ -FROM debian:11-slim +FROM debian:12-slim ENV ASPNETCORE_URLS=http://+:80 DOTNET_RUNNING_IN_CONTAINER=true RUN apt-get update && \ apt-get install -y --no-install-recommends \ ca-certificates \ - libc6 \ - libgcc1 \ libgssapi-krb5-2 \ - libicu67 \ - libssl1.1 \ - libstdc++6 \ - zlib1g && \ + libicu72 \ + libssl3 \ + xxd && \ + apt-get clean && \ rm -rf /var/lib/apt/lists/* ARG BUILD_NUMBER ARG BUILD_DATE -RUN apt-get update && \ - apt-get install -y \ - curl \ - dos2unix \ - jq \ - sudo \ - xxd \ - && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - EXPOSE 10933 WORKDIR /tmp @@ -43,11 +30,11 @@ RUN /install-scripts/install-docker.sh # Install Tentacle COPY _artifacts/deb/tentacle_${BUILD_NUMBER}_amd64.deb /tmp/ RUN apt-get update && \ - apt install ./tentacle_${BUILD_NUMBER}_amd64.deb && \ + apt-get install -y --no-install-recommends ./tentacle_${BUILD_NUMBER}_amd64.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ ln -s /opt/octopus/tentacle/Tentacle /usr/bin/tentacle - + WORKDIR / # We know this won't reduce the image size at all. It's just to make the filesystem a little tidier. diff --git a/scripts/smoke-test-linux-tentacle-teamcity.sh b/scripts/smoke-test-linux-tentacle-teamcity.sh new file mode 100755 index 000000000..585446204 --- /dev/null +++ b/scripts/smoke-test-linux-tentacle-teamcity.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# TeamCity wrapper for scripts/smoke-test-linux-tentacle.sh. +# +# Emits TeamCity service messages so the smoke test surfaces as a single +# named integration test on a TeamCity build agent, with progress blocks +# per step (parsed from the inner script's `--- Step N: ... ---` markers) +# and a buildProblem on failure. The inner script's stdout/stderr is +# forwarded verbatim, so the TC messages are harmless noise when this +# wrapper is run locally. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INNER_SCRIPT="$SCRIPT_DIR/smoke-test-linux-tentacle.sh" +[[ -x "$INNER_SCRIPT" ]] || { echo "Missing executable: $INNER_SCRIPT" >&2; exit 1; } + +SUITE_NAME="${TEAMCITY_SMOKE_SUITE_NAME:-LinuxTentacleSmoke}" +TEST_NAME="${TEAMCITY_SMOKE_TEST_NAME:-LinuxTentacleSmokeTest}" + +# Service-message escaping per TeamCity's Build Script Interaction spec: +# |→|| '→|' newline→|n CR→|r [→|[ ]→|] +tc_escape() { + local s=$1 + s=${s//|/||} + s=${s//\'/|\'} + s=${s//$'\n'/|n} + s=${s//$'\r'/|r} + s=${s//[/|[} + s=${s//]/|]} + printf '%s' "$s" +} + +tc() { printf '##teamcity[%s]\n' "$*"; } + +open_block="" +close_open_block() { + if [[ -n "$open_block" ]]; then + tc "blockClosed name='$open_block'" + open_block="" + fi +} + +# The while-read loop consumes every line the inner script prints, so we +# stash its exit code in a temp file to recover it after the pipe drains. +exit_file=$(mktemp) +trap 'rm -f "$exit_file"' EXIT + +ESC_SUITE=$(tc_escape "$SUITE_NAME") +ESC_TEST=$(tc_escape "$TEST_NAME") + +start_epoch=$(date +%s) +tc "testSuiteStarted name='$ESC_SUITE'" +tc "testStarted name='$ESC_TEST' captureStandardOutput='true'" + +# Process substitution keeps the loop in the current shell so $open_block +# survives across iterations. The `rc=0; … || rc=$?; echo "$rc" > …` form +# captures the inner script's exit code while keeping `set -e` happy — a +# bare `; echo $? > …` would never run, because `set -e` is inherited into +# the subshell and aborts it the moment the inner script exits non-zero. +while IFS= read -r line; do + printf '%s\n' "$line" + # Match the inner script's `[smoke] --- title ---` markers; the [^-]* + # gap tolerates ANSI colour bytes wrapped around `[smoke]` by log(). + if [[ "$line" =~ \[smoke\][^-]*---\ (.+)\ ---$ ]]; then + title=$(tc_escape "${BASH_REMATCH[1]}") + close_open_block + tc "blockOpened name='$title'" + open_block="$title" + fi +done < <(rc=0; "$INNER_SCRIPT" 2>&1 || rc=$?; echo "$rc" > "$exit_file") + +close_open_block + +inner_exit=$(<"$exit_file") +inner_exit=${inner_exit:-1} +duration_ms=$(( ($(date +%s) - start_epoch) * 1000 )) + +if [[ "$inner_exit" -ne 0 ]]; then + fail_msg=$(tc_escape "smoke-test-linux-tentacle.sh exited with code $inner_exit") + tc "testFailed name='$ESC_TEST' message='$fail_msg'" + tc "testFinished name='$ESC_TEST' duration='$duration_ms'" + tc "testSuiteFinished name='$ESC_SUITE'" + tc "buildProblem description='$fail_msg' identity='linux-tentacle-smoke'" + exit "$inner_exit" +fi + +tc "testFinished name='$ESC_TEST' duration='$duration_ms'" +tc "testSuiteFinished name='$ESC_SUITE'" diff --git a/scripts/smoke-test-linux-tentacle.compose.yml b/scripts/smoke-test-linux-tentacle.compose.yml new file mode 100644 index 000000000..bd5766922 --- /dev/null +++ b/scripts/smoke-test-linux-tentacle.compose.yml @@ -0,0 +1,69 @@ +# Self-contained docker compose for the Linux Tentacle smoke test (EFT-3311). +# Brings up MSSQL + Octopus Server + the Tentacle image under test on an +# internal network so the test does not need a sibling OctopusDeploy checkout. +# +# Driven by scripts/smoke-test-linux-tentacle.sh — env vars exported there +# (TENTACLE_IMAGE, TENTACLE_TAG, OCTOPUS_SERVER_TAG, SA_PASSWORD, ADMIN_PASSWORD, +# ADMIN_API_KEY, WORKER_TARGET_NAME) are interpolated below. +# OCTOPUS_SERVER_BASE64_LICENSE is optional: when empty, Octopus Server starts +# on the free Community Edition license (1 space, 1 worker, 5 projects), which +# is enough for this test. + +name: octopustentacle-smoke + +services: + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "${SA_PASSWORD}" + MSSQL_PID: "Developer" + healthcheck: + test: + - "CMD-SHELL" + - '/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -C -No -Q "SELECT 1" >/dev/null 2>&1 || exit 1' + interval: 5s + timeout: 5s + retries: 60 + start_period: 30s + + octopus-server: + image: octopusdeploy/octopusdeploy:${OCTOPUS_SERVER_TAG:-latest} + platform: linux/amd64 + depends_on: + mssql: + condition: service_healthy + environment: + ACCEPT_EULA: "Y" + DB_CONNECTION_STRING: "Server=mssql,1433;Database=Octopus;User Id=sa;Password=${SA_PASSWORD};TrustServerCertificate=true;" + ADMIN_USERNAME: "admin" + ADMIN_PASSWORD: "${ADMIN_PASSWORD}" + ADMIN_API_KEY: "${ADMIN_API_KEY}" + OCTOPUS_SERVER_BASE64_LICENSE: "${OCTOPUS_SERVER_BASE64_LICENSE:-}" + ports: + - "8065:8080" + + tentacle: + image: ${TENTACLE_IMAGE}:${TENTACLE_TAG} + # Fail loudly if the local build step was skipped — never silently pull + # from a registry. The script tags this image with a local-only name + # (no `octopusdeploy/` prefix), so a pull attempt would always be wrong. + pull_policy: never + platform: linux/amd64 + depends_on: + - octopus-server + environment: + ACCEPT_EULA: "Y" + ServerApiKey: "${ADMIN_API_KEY}" + ServerUrl: "http://octopus-server:8080" + # Setting ServerPort puts the Tentacle into polling (TentacleActive) mode, + # which is what we want for a worker — no inbound listener required. + ServerPort: "10943" + TargetWorkerPool: "Default Worker Pool" + TargetName: "${WORKER_TARGET_NAME}" + Space: "Default" + # The Tentacle image's default entrypoint launches a dockerd sidecar, + # which requires --privileged. We don't need DinD for this smoke test; + # skipping it keeps the container running without elevated privileges. + DISABLE_DIND: "Y" diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh new file mode 100755 index 000000000..59c543437 --- /dev/null +++ b/scripts/smoke-test-linux-tentacle.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# +# End-to-end smoke test for the Linux Tentacle Docker image (EFT-3311). +# +# Builds the image from the .deb in _artifacts/deb, brings up a self-contained +# Octopus Server + MSSQL stack via docker compose, registers the Tentacle as a +# worker, runs a hello-world AdHocScript on it, and asserts success. +# +# Required tools: docker, curl, jq, openssl. +# Required state: a built .deb in _artifacts/deb/tentacle_*_amd64.deb. +# +# License: not required. Octopus Server provisions a free Community Edition +# license on first boot, which is well within limits for this test (1 space, +# 1 worker, 0 projects). Set $OCTOPUS_SERVER_BASE64_LICENSE to override — +# useful if you want to test against a paid edition — otherwise leave unset. + +set -euo pipefail + +TENTACLE_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="$TENTACLE_REPO/scripts/smoke-test-linux-tentacle.compose.yml" + +API="http://localhost:8065/api" +# Ephemeral credentials — the Server DB is recreated from scratch on every run +# (compose down -v in teardown), so a fixed sentinel is safe and keeps the +# Authorization header below trivial. The SA password must satisfy SQL Server's +# complexity policy (upper + lower + digit + special); openssl supplies entropy +# for the digits/lowercase portion. +ADMIN_API_KEY="API-SMOKETEST0000000000000" +ADMIN_PASSWORD="Smoke-$(openssl rand -hex 16)!" +SA_PASSWORD="Sa$(openssl rand -hex 12)!" +H="X-Octopus-ApiKey: $ADMIN_API_KEY" +# Local-only image name (not octopusdeploy/tentacle) so there is no chance of +# colliding with — or accidentally pulling — a public Docker Hub tag. +IMAGE_NAME="tentacle-smoke" +IMAGE_TAG="debian12" + +# Per-run worker name. Mostly cosmetic since the DB is fresh every run, but it +# makes container logs easier to trace and lets teardown deregister by ID. +WORKER_TARGET_NAME="smoke-tentacle-$(date +%Y%m%d-%H%M%S)-$$" +WORKER_ID="" + +TENTACLE_IMAGE="$IMAGE_NAME" +TENTACLE_TAG="$IMAGE_TAG" +OCTOPUS_SERVER_TAG="${OCTOPUS_SERVER_TAG:-latest}" + +log() { printf '\033[1;34m[smoke]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[smoke]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[smoke]\033[0m %s\n' "$*" >&2; exit 1; } + +require() { command -v "$1" >/dev/null || die "Missing required tool: $1"; } +require docker +require curl +require jq +require openssl + +compose() { docker compose -f "$COMPOSE_FILE" "$@"; } + +teardown() { + local exit_code=$? + log "--- teardown ---" + if [[ -n "$WORKER_ID" ]]; then + log "Deregistering worker $WORKER_ID" + curl -fsS -X DELETE -H "$H" "$API/workers/$WORKER_ID" >/dev/null 2>&1 || true + fi + compose down -v 2>/dev/null || true + exit "$exit_code" +} +trap teardown EXIT + +############################################################################### +# Step 1: Build the Linux Tentacle image from the local .deb +############################################################################### +log "--- Step 1: build Tentacle image ---" +cd "$TENTACLE_REPO" + +shopt -s nullglob +DEBS=(_artifacts/deb/tentacle_*_amd64.deb) +shopt -u nullglob +[[ ${#DEBS[@]} -ge 1 ]] || die "No .deb found in _artifacts/deb/. Build it first." +[[ ${#DEBS[@]} -eq 1 ]] || die "Multiple .debs in _artifacts/deb/; expected one: ${DEBS[*]}" +DEB_FILE="${DEBS[0]}" +DEB_BASENAME="$(basename "$DEB_FILE")" +BUILD_NUMBER="${DEB_BASENAME#tentacle_}" +BUILD_NUMBER="${BUILD_NUMBER%_amd64.deb}" +export BUILD_NUMBER +export BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +log "BUILD_NUMBER=$BUILD_NUMBER" +# Use `docker build` directly rather than `docker compose -f docker-compose.build.yml` +# because that compose file also defines kubernetes/windows tentacle services which +# require extra env vars (BUILD_ARCH, BUILD_VARIANT) we don't care about here. +DST_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" +docker build \ + --platform linux/amd64 \ + --build-arg BUILD_NUMBER="$BUILD_NUMBER" \ + --build-arg BUILD_DATE="$BUILD_DATE" \ + -f docker/linux/Dockerfile \ + -t "$DST_IMAGE" \ + . +log "Built $DST_IMAGE" + +############################################################################### +# Step 2: Bring up MSSQL + Octopus Server and wait for /api to respond +############################################################################### +log "--- Step 2: start mssql and octopus-server ---" +# Export every var the compose file interpolates. octopus-server depends on +# mssql with condition: service_healthy, so compose will block until MSSQL is +# accepting queries before starting the Server. OCTOPUS_SERVER_BASE64_LICENSE +# is pass-through: empty (the default) gives Community Edition, a real value +# is honoured for testing against a paid edition. +export TENTACLE_IMAGE TENTACLE_TAG OCTOPUS_SERVER_TAG SA_PASSWORD ADMIN_PASSWORD ADMIN_API_KEY \ + WORKER_TARGET_NAME +export OCTOPUS_SERVER_BASE64_LICENSE="${OCTOPUS_SERVER_BASE64_LICENSE:-}" + +compose up -d mssql octopus-server + +log "Waiting for $API/octopusservernodes/ping ..." +for i in {1..300}; do + if curl -fsS -H "$H" "$API/octopusservernodes/ping" >/dev/null 2>&1; then + log "Server is up after ${i}s" + break + fi + if [[ $i -eq 300 ]]; then + warn "Server did not become ready in 300s. Recent logs:" + compose logs --no-color --tail=120 octopus-server mssql || true + die "Octopus Server did not become ready" + fi + sleep 1 +done + +############################################################################### +# Step 3: Bring up the Tentacle (Worker, polling mode, DIND disabled) +############################################################################### +log "--- Step 3: start tentacle ---" +# --no-deps because octopus-server has no compose-level healthcheck; we already +# polled its API ping above and know it's ready. +compose up -d --no-deps tentacle + +log "Waiting for Tentacle 'Configuration successful.' in logs ..." +# Loop count is iterations, not seconds: each iteration is one `docker compose +# logs` invocation plus a 1s sleep, so wall-clock per iteration is ~1.5-3s on +# a loaded host. 180 iterations comfortably covers a slow Apple-Silicon / +# amd64-emulation case while still failing fast on a real regression. +for i in {1..180}; do + if compose logs --no-color tentacle 2>/dev/null | grep -qF "Configuration successful."; then + log "Tentacle registered after ${i} iterations" + break + fi + [[ $i -eq 180 ]] && die "Tentacle did not register in 180 iterations. Logs: +$(compose logs --no-color --tail=80 tentacle)" + sleep 1 +done + +# Make sure the agent is still running (the wrapper script can exit shortly +# after registration if a sidecar like dockerd dies). +if ! compose ps --status running --services 2>/dev/null | grep -qx tentacle; then + die "Tentacle container exited shortly after registration. Logs: +$(compose logs --no-color --tail=80 tentacle)" +fi + +############################################################################### +# Step 4: Verify worker is registered & run hello-world AdHocScript +############################################################################### +log "--- Step 4: verify registration and run hello-world ---" + +# Find the worker we just registered by its per-run TargetName. +for i in {1..60}; do + WORKERS_JSON="$(curl -fsS -H "$H" --data-urlencode "name=$WORKER_TARGET_NAME" -G "$API/workers" 2>/dev/null || echo '{"Items":[]}')" + WORKER_ID="$(echo "$WORKERS_JSON" \ + | jq -r --arg name "$WORKER_TARGET_NAME" '.Items[] | select(.Name == $name) | .Id' \ + | head -n1)" + [[ -n "$WORKER_ID" ]] && break + sleep 1 +done +if [[ -z "$WORKER_ID" ]]; then + warn "No worker named '$WORKER_TARGET_NAME' appeared. Diagnostic dump of $API/workers:" + curl -fsS -H "$H" "$API/workers" || true + warn "Tentacle container logs (tail 80):" + compose logs --no-color --tail=80 tentacle || true + die "Worker '$WORKER_TARGET_NAME' did not appear after 60s" +fi +log "Registered worker: $WORKER_ID (name='$WORKER_TARGET_NAME')" + +ADHOC_BODY="$(jq -nc \ + --arg id "$WORKER_ID" \ + '{ + Name: "AdHocScript", + Description: "EFT-3311 Debian 12 smoke test", + Arguments: { + ScriptBody: "echo Hello from $(hostname); cat /etc/os-release | head -2", + Syntax: "Bash", + WorkerIds: [$id] + } + }')" + +TASK_RESP="$(curl -fsS -X POST -H "$H" -H "Content-Type: application/json" \ + "$API/tasks" -d "$ADHOC_BODY")" +TASK_ID="$(echo "$TASK_RESP" | jq -r '.Id')" +[[ -n "$TASK_ID" && "$TASK_ID" != "null" ]] || die "Could not submit AdHocScript task. Response: $TASK_RESP" +log "Submitted task: $TASK_ID" + +STATE="" +for i in {1..120}; do + STATE="$(curl -fsS -H "$H" "$API/tasks/$TASK_ID" | jq -r '.State')" + echo " task=$TASK_ID state=$STATE" + case "$STATE" in + Success|Failed|Canceled|TimedOut) break ;; + esac + sleep 2 +done + +log "--- Task log ---" +curl -fsS -H "$H" "$API/tasks/$TASK_ID/raw" || true +log "--- end task log ---" + +if [[ "$STATE" != "Success" ]]; then + die "Task finished in state '$STATE' (expected Success)" +fi + +# Load-bearing assertion: the whole point of this smoke test is to prove the +# Debian 12 base image is what's actually running on the Tentacle, so a missing +# os-release line is a hard failure, not a warning. +if ! curl -fsS -H "$H" "$API/tasks/$TASK_ID/raw" | grep -qF 'Debian GNU/Linux 12'; then + die "Task succeeded but the log does NOT mention 'Debian GNU/Linux 12'. Inspect output above." +fi + +log "PASS — Tentacle (Debian 12) registered and executed hello-world."