From f08084b43261adfb72f10d203b75618879b6c190 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Thu, 7 May 2026 15:17:10 +1000 Subject: [PATCH 01/27] fix: upgrade linux dockerfile to debian 12 --- docker/linux/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index 75525462a..1fc87fb20 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -1,4 +1,4 @@ -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 \ From d42b9923ae961afeb1f14d447e3d5635ea4c1c5a Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Thu, 7 May 2026 15:54:52 +1000 Subject: [PATCH 02/27] fix: upgrade apt deps libicu72 and libssl3 --- docker/linux/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index 1fc87fb20..659cce5d4 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -6,8 +6,8 @@ RUN apt-get update && \ libc6 \ libgcc1 \ libgssapi-krb5-2 \ - libicu67 \ - libssl1.1 \ + libicu72 \ + libssl3 \ libstdc++6 \ zlib1g && \ rm -rf /var/lib/apt/lists/* From da9a313fb742211cc7eab5be42e6294883daf6a7 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Thu, 7 May 2026 16:12:13 +1000 Subject: [PATCH 03/27] fix: add apt-utils to deps to avoid warning => debconf: delaying package configuration, since apt-utils is not installed --- docker/linux/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index 659cce5d4..0b789a1c5 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -2,6 +2,7 @@ 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 \ + apt-utils \ ca-certificates \ libc6 \ libgcc1 \ From c327063eb2602765b3179ac86f89779b86b6295c Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Thu, 7 May 2026 16:28:41 +1000 Subject: [PATCH 04/27] tidy: remove explicit install of things already installed by default --- docker/linux/Dockerfile | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index 0b789a1c5..01cff5cca 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -1,16 +1,14 @@ FROM debian:12-slim +ENV DEBIAN_FRONTEND=noninteractive ENV ASPNETCORE_URLS=http://+:80 DOTNET_RUNNING_IN_CONTAINER=true RUN apt-get update && \ apt-get install -y --no-install-recommends \ apt-utils \ ca-certificates \ - libc6 \ libgcc1 \ libgssapi-krb5-2 \ libicu72 \ - libssl3 \ - libstdc++6 \ - zlib1g && \ + libssl3 && \ rm -rf /var/lib/apt/lists/* ARG BUILD_NUMBER From dedd1b81068da0b06fb0dab194d7bc66a30e5463 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Sat, 9 May 2026 11:55:18 +1000 Subject: [PATCH 05/27] fix: suppress stderr warning of dpkg interactive config --- docker/linux/Dockerfile | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index 01cff5cca..d4f791760 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -1,11 +1,9 @@ FROM debian:12-slim -ENV DEBIAN_FRONTEND=noninteractive ENV ASPNETCORE_URLS=http://+:80 DOTNET_RUNNING_IN_CONTAINER=true RUN apt-get update && \ - apt-get install -y --no-install-recommends \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ apt-utils \ ca-certificates \ - libgcc1 \ libgssapi-krb5-2 \ libicu72 \ libssl3 && \ @@ -15,7 +13,7 @@ ARG BUILD_NUMBER ARG BUILD_DATE RUN apt-get update && \ - apt-get install -y \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ curl \ dos2unix \ jq \ @@ -42,7 +40,7 @@ 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 && \ + DEBIAN_FRONTEND=noninteractive apt-get install ./tentacle_${BUILD_NUMBER}_amd64.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ ln -s /opt/octopus/tentacle/Tentacle /usr/bin/tentacle From 4b72ab190e9b5e4c9e06b2635765fa53594651ae Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Mon, 11 May 2026 11:39:30 +1000 Subject: [PATCH 06/27] revert: doesn't help --- docker/linux/Dockerfile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index d4f791760..f8c998261 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -1,8 +1,7 @@ FROM debian:12-slim ENV ASPNETCORE_URLS=http://+:80 DOTNET_RUNNING_IN_CONTAINER=true RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - apt-utils \ + apt-get install -y --no-install-recommends \ ca-certificates \ libgssapi-krb5-2 \ libicu72 \ @@ -13,7 +12,7 @@ ARG BUILD_NUMBER ARG BUILD_DATE RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y \ + apt-get install -y \ curl \ dos2unix \ jq \ @@ -40,7 +39,7 @@ RUN /install-scripts/install-docker.sh # Install Tentacle COPY _artifacts/deb/tentacle_${BUILD_NUMBER}_amd64.deb /tmp/ RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install ./tentacle_${BUILD_NUMBER}_amd64.deb && \ + apt-get install ./tentacle_${BUILD_NUMBER}_amd64.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ ln -s /opt/octopus/tentacle/Tentacle /usr/bin/tentacle From bd60a056f488be01e44a07a32ff09e1c78c77f17 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Tue, 12 May 2026 11:14:05 +1000 Subject: [PATCH 07/27] test: add E2E smoke test for Linux Tentacle Docker image Builds the image from the local .deb, brings up a local Octopus Server, registers the Tentacle as a worker, and runs a hello-world AdHocScript via the REST API to verify the Debian 12 upgrade end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 244 +++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100755 scripts/smoke-test-linux-tentacle.sh diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh new file mode 100755 index 000000000..c9ed56588 --- /dev/null +++ b/scripts/smoke-test-linux-tentacle.sh @@ -0,0 +1,244 @@ +#!/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 local Octopus +# Server in the sibling OctopusDeploy repo, registers the Tentacle as a worker, +# runs a hello-world AdHocScript on it, and asserts success. +# +# Required tools: docker, op (signed in), curl, jq. +# Required state: a built .deb in ../_artifacts/deb/tentacle_*_amd64.deb and the +# OctopusDeploy repo checked out alongside OctopusTentacle. + +set -euo pipefail + +TENTACLE_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SERVER_REPO="${SERVER_REPO:-$(cd "$TENTACLE_REPO/../OctopusDeploy" && pwd)}" +ENV_FILE="$SERVER_REPO/.env" +ENV_BACKUP="$ENV_FILE.smoke-test.bak" +# Transient compose override: disables Docker-in-Docker on the Tentacle. The +# default tentacle entrypoint launches a dockerd daemon, which requires the +# container to run with `--privileged`; without that the daemon fails and its +# wrapper script kills the Tentacle agent. Setting DISABLE_DIND=Y skips it. +OVERRIDE_COMPOSE="$SERVER_REPO/docker-compose.smoke.yml" + +API="http://localhost:8065/api" +API_KEY="API-APIKEY01" +H="X-Octopus-ApiKey: $API_KEY" +IMAGE_TAG="smoke-debian12" +ONEPASSWORD_LICENSE_REF="op://software licencing/octopus deploy ultimate license key base64/value" + +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 op +require curl +require jq + +teardown() { + local exit_code=$? + log "--- teardown ---" + if [[ -f "$OVERRIDE_COMPOSE" ]]; then + (cd "$SERVER_REPO" && docker compose -f docker-compose.yml -f docker-compose.smoke.yml --profile tentacle down 2>/dev/null) || true + rm -f "$OVERRIDE_COMPOSE" + fi + (cd "$SERVER_REPO" && docker compose down 2>/dev/null) || true + if [[ -f "$ENV_BACKUP" ]]; then + mv "$ENV_BACKUP" "$ENV_FILE" + log "Restored $ENV_FILE" + fi + 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="octopusdeploy/tentacle:${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: Fetch license from 1Password & patch .env +############################################################################### +log "--- Step 2: fetch license and patch .env ---" +[[ -f "$ENV_FILE" ]] || die "Expected $ENV_FILE to exist." + +if ! op account list >/dev/null 2>&1; then + die "1Password CLI is not signed in. Run: eval \$(op signin)" +fi + +LICENSE_BASE64="$(op read "$ONEPASSWORD_LICENSE_REF" 2>/dev/null || true)" +[[ -n "$LICENSE_BASE64" ]] || die "Could not read license from 1Password at: $ONEPASSWORD_LICENSE_REF" +log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" + +cp "$ENV_FILE" "$ENV_BACKUP" + +upsert_env_var() { + local key="$1" value="$2" + if grep -q "^${key}=" "$ENV_FILE"; then + # Use a sed delimiter unlikely to appear in base64 + python3 - "$ENV_FILE" "$key" "$value" <<'PY' +import sys, re +path, key, value = sys.argv[1], sys.argv[2], sys.argv[3] +with open(path) as f: txt = f.read() +txt = re.sub(rf'^{re.escape(key)}=.*$', f'{key}={value}', txt, count=1, flags=re.M) +with open(path, 'w') as f: f.write(txt) +PY + else + printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE" + fi +} + +upsert_env_var TENTACLE_TAG "$IMAGE_TAG" +upsert_env_var OCTOPUS_SERVER_BASE64_LICENSE "$LICENSE_BASE64" + +############################################################################### +# Step 3: Bring up Octopus Server and wait for /api to respond +############################################################################### +log "--- Step 3: start octopus-server ---" +cd "$SERVER_REPO" +docker compose up -d octopus-server + +log "Waiting for $API/octopusservernodes/ping ..." +for i in {1..120}; do + if curl -fsS -H "$H" "$API/octopusservernodes/ping" >/dev/null 2>&1; then + log "Server is up after ${i}s" + break + fi + [[ $i -eq 120 ]] && die "Server did not become ready in 120s" + sleep 1 +done + +############################################################################### +# Step 4: Bring up the Tentacle (Worker, polling mode, DIND disabled) +############################################################################### +log "--- Step 4: start tentacle ---" +cat > "$OVERRIDE_COMPOSE" <<'YAML' +services: + tentacle: + environment: + DISABLE_DIND: "Y" +YAML + +COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.smoke.yml --profile tentacle) + +# --no-deps because octopus-server may lack a 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 ..." +for i in {1..60}; do + if "${COMPOSE[@]}" logs --no-color tentacle 2>/dev/null | grep -q "Configuration successful."; then + log "Tentacle registered after ${i}s" + break + fi + [[ $i -eq 60 ]] && die "Tentacle did not register in 60s. 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 5: Verify worker is registered & run hello-world AdHocScript +############################################################################### +log "--- Step 5: verify registration and run hello-world ---" + +# Find the worker we just registered. The Tentacle picks its container hostname +# as the default name, so we can't filter by name reliably. Instead, take the +# worker whose Id is the largest "Workers-N" — i.e. the most recent registration. +WORKER_ID="" +WORKER_NAME="" +for i in {1..60}; do + WORKERS_JSON="$(curl -fsS -H "$H" "$API/workers?take=1000" 2>/dev/null || echo '{"Items":[]}')" + WORKER_ID="$(echo "$WORKERS_JSON" \ + | jq -r '[.Items[] | select(.Id | startswith("Workers-"))] | sort_by(.Id | ltrimstr("Workers-") | tonumber) | last | .Id // empty')" + WORKER_NAME="$(echo "$WORKERS_JSON" | jq -r --arg id "$WORKER_ID" '.Items[] | select(.Id == $id) | .Name // empty')" + [[ -n "$WORKER_ID" ]] && break + sleep 1 +done +if [[ -z "$WORKER_ID" ]]; then + warn "No worker appeared. Diagnostic dump of $API/workers:" + curl -fsS -H "$H" "$API/workers" || true + warn "Tentacle container logs (tail 80):" + docker compose --profile tentacle logs --no-color --tail=80 tentacle || true + die "Worker did not appear after 60s" +fi +log "Registered worker: $WORKER_ID (name='$WORKER_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 + +# Sanity: the printed log should mention Debian 12. +if ! curl -fsS -H "$H" "$API/tasks/$TASK_ID/raw" | grep -q 'Debian GNU/Linux 12'; then + warn "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." From 131f043eb53df27148b42b60417221d1f18a7a46 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Tue, 12 May 2026 11:18:59 +1000 Subject: [PATCH 08/27] +semver: major From e6fb8e2948ca30778e056ec7cd6d975b9a417627 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Tue, 12 May 2026 13:56:50 +1000 Subject: [PATCH 09/27] include `-y --no-install-recommends` even though it likely doesn't matter Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docker/linux/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index f8c998261..f1109c7c7 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -39,7 +39,7 @@ RUN /install-scripts/install-docker.sh # Install Tentacle COPY _artifacts/deb/tentacle_${BUILD_NUMBER}_amd64.deb /tmp/ RUN apt-get update && \ - apt-get 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 From c80962f0c1b53a1cb6f221b373c9b5a5da19a98f Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Tue, 12 May 2026 14:01:27 +1000 Subject: [PATCH 10/27] fix: drop undeclared python3 dep from smoke test Replace upsert_env_var's Python heredoc with a pure-bash implementation. The previous version invoked python3 without listing it in the script's required-tools check, so the failure mode on a machine without Python was a confusing "python3: command not found" mid-run instead of an upfront "Missing required tool" message. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index c9ed56588..03e65b613 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -103,19 +103,22 @@ log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" cp "$ENV_FILE" "$ENV_BACKUP" upsert_env_var() { + # Pure-bash: avoids sed/awk escape headaches with a base64 value (which + # contains '/' and '=' but not '\' or '&'). Matches the line by literal + # "KEY=" prefix, not regex, so unusual keys won't bite us. local key="$1" value="$2" - if grep -q "^${key}=" "$ENV_FILE"; then - # Use a sed delimiter unlikely to appear in base64 - python3 - "$ENV_FILE" "$key" "$value" <<'PY' -import sys, re -path, key, value = sys.argv[1], sys.argv[2], sys.argv[3] -with open(path) as f: txt = f.read() -txt = re.sub(rf'^{re.escape(key)}=.*$', f'{key}={value}', txt, count=1, flags=re.M) -with open(path, 'w') as f: f.write(txt) -PY - else - printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE" - fi + local tmp="$ENV_FILE.tmp" line found= + : > "$tmp" + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "${key}="* ]]; then + printf '%s=%s\n' "$key" "$value" >> "$tmp" + found=1 + else + printf '%s\n' "$line" >> "$tmp" + fi + done < "$ENV_FILE" + [[ -z "$found" ]] && printf '%s=%s\n' "$key" "$value" >> "$tmp" + mv "$tmp" "$ENV_FILE" } upsert_env_var TENTACLE_TAG "$IMAGE_TAG" From 6f577d3d3986ae35d7f03a08ea9b50deebc9927a Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Tue, 12 May 2026 14:35:04 +1000 Subject: [PATCH 11/27] fix: use mktemp for transient compose override The previous version wrote and deleted a fixed-name `docker-compose.smoke.yml` in the sibling OctopusDeploy repo. If a user already had a file by that name (now or in the future), this script would silently clobber it and remove it on teardown. Switch to `mktemp` in $TMPDIR so the path is unique-per-run, and only delete it in teardown if this run actually created one. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index 03e65b613..bc9a4201c 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -20,7 +20,9 @@ ENV_BACKUP="$ENV_FILE.smoke-test.bak" # default tentacle entrypoint launches a dockerd daemon, which requires the # container to run with `--privileged`; without that the daemon fails and its # wrapper script kills the Tentacle agent. Setting DISABLE_DIND=Y skips it. -OVERRIDE_COMPOSE="$SERVER_REPO/docker-compose.smoke.yml" +# Created via mktemp in Step 4 so we never clobber an unrelated file the user +# may already have in the sibling repo. +OVERRIDE_COMPOSE="" API="http://localhost:8065/api" API_KEY="API-APIKEY01" @@ -41,8 +43,8 @@ require jq teardown() { local exit_code=$? log "--- teardown ---" - if [[ -f "$OVERRIDE_COMPOSE" ]]; then - (cd "$SERVER_REPO" && docker compose -f docker-compose.yml -f docker-compose.smoke.yml --profile tentacle down 2>/dev/null) || true + if [[ -n "$OVERRIDE_COMPOSE" && -f "$OVERRIDE_COMPOSE" ]]; then + (cd "$SERVER_REPO" && docker compose -f docker-compose.yml -f "$OVERRIDE_COMPOSE" --profile tentacle down 2>/dev/null) || true rm -f "$OVERRIDE_COMPOSE" fi (cd "$SERVER_REPO" && docker compose down 2>/dev/null) || true @@ -145,6 +147,7 @@ done # Step 4: Bring up the Tentacle (Worker, polling mode, DIND disabled) ############################################################################### log "--- Step 4: start tentacle ---" +OVERRIDE_COMPOSE="$(mktemp "${TMPDIR:-/tmp}/docker-compose.smoke-tentacle.XXXXXX.yml")" cat > "$OVERRIDE_COMPOSE" <<'YAML' services: tentacle: @@ -152,7 +155,7 @@ services: DISABLE_DIND: "Y" YAML -COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.smoke.yml --profile tentacle) +COMPOSE=(docker compose -f docker-compose.yml -f "$OVERRIDE_COMPOSE" --profile tentacle) # --no-deps because octopus-server may lack a healthcheck; we already polled # its API ping above and know it's ready. From f22c0c24745c44f7d6f7adeee23fe99c4e5b2dc9 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Tue, 12 May 2026 14:38:13 +1000 Subject: [PATCH 12/27] fix: use mktemp for .env backup path The previous fixed `.env.smoke-test.bak` path would silently overwrite a stale backup from a previously-crashed run, losing the original .env. Switch to `mktemp` in $TMPDIR so the backup path is unique-per-run, log the path so it's discoverable from script output if anything goes wrong, and guard the teardown restore on the variable being set so an early failure (before the backup is made) doesn't try to mv an empty path. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index bc9a4201c..b16a98fa1 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -15,7 +15,10 @@ set -euo pipefail TENTACLE_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SERVER_REPO="${SERVER_REPO:-$(cd "$TENTACLE_REPO/../OctopusDeploy" && pwd)}" ENV_FILE="$SERVER_REPO/.env" -ENV_BACKUP="$ENV_FILE.smoke-test.bak" +# .env backup path is assigned via mktemp in Step 2 (after we know $ENV_FILE +# exists). Using a unique per-run path avoids clobbering a stale backup from +# a previously-crashed run. +ENV_BACKUP="" # Transient compose override: disables Docker-in-Docker on the Tentacle. The # default tentacle entrypoint launches a dockerd daemon, which requires the # container to run with `--privileged`; without that the daemon fails and its @@ -48,7 +51,7 @@ teardown() { rm -f "$OVERRIDE_COMPOSE" fi (cd "$SERVER_REPO" && docker compose down 2>/dev/null) || true - if [[ -f "$ENV_BACKUP" ]]; then + if [[ -n "$ENV_BACKUP" && -f "$ENV_BACKUP" ]]; then mv "$ENV_BACKUP" "$ENV_FILE" log "Restored $ENV_FILE" fi @@ -102,7 +105,9 @@ LICENSE_BASE64="$(op read "$ONEPASSWORD_LICENSE_REF" 2>/dev/null || true)" [[ -n "$LICENSE_BASE64" ]] || die "Could not read license from 1Password at: $ONEPASSWORD_LICENSE_REF" log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" +ENV_BACKUP="$(mktemp "${TMPDIR:-/tmp}/octopus-server.env.smoke-tentacle.XXXXXX.bak")" cp "$ENV_FILE" "$ENV_BACKUP" +log "Backed up .env to $ENV_BACKUP (will be restored on exit)" upsert_env_var() { # Pure-bash: avoids sed/awk escape headaches with a base64 value (which From 14f4c75242178aaec1c71a50fd0cabb32ab4c742 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Tue, 12 May 2026 15:43:45 +1000 Subject: [PATCH 13/27] fix: put X's at end of mktemp templates for BSD portability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BSD mktemp (macOS) silently leaves Xs unsubstituted when they aren't the final characters of the template — so the previous templates ("...XXXXXX.bak" and "...XXXXXX.yml") actually produced fixed-name files with literal Xs, defeating the unique-per-run guarantee. Verified by inspecting the logged backup path. Move the Xs to the end and drop the .bak/.yml suffixes (docker compose parses by content not extension, and the suffix was cosmetic for the backup). Verified end-to-end against a local Octopus Server. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index b16a98fa1..58d4a3fc8 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -105,7 +105,7 @@ LICENSE_BASE64="$(op read "$ONEPASSWORD_LICENSE_REF" 2>/dev/null || true)" [[ -n "$LICENSE_BASE64" ]] || die "Could not read license from 1Password at: $ONEPASSWORD_LICENSE_REF" log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" -ENV_BACKUP="$(mktemp "${TMPDIR:-/tmp}/octopus-server.env.smoke-tentacle.XXXXXX.bak")" +ENV_BACKUP="$(mktemp "${TMPDIR:-/tmp}/octopus-server-env-smoke-tentacle-XXXXXX")" cp "$ENV_FILE" "$ENV_BACKUP" log "Backed up .env to $ENV_BACKUP (will be restored on exit)" @@ -152,7 +152,7 @@ done # Step 4: Bring up the Tentacle (Worker, polling mode, DIND disabled) ############################################################################### log "--- Step 4: start tentacle ---" -OVERRIDE_COMPOSE="$(mktemp "${TMPDIR:-/tmp}/docker-compose.smoke-tentacle.XXXXXX.yml")" +OVERRIDE_COMPOSE="$(mktemp "${TMPDIR:-/tmp}/docker-compose-smoke-tentacle-XXXXXX")" cat > "$OVERRIDE_COMPOSE" <<'YAML' services: tentacle: From ebbb878c15b9a5aae448c32919dbbdb90a89c077 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 20 May 2026 14:28:04 +1000 Subject: [PATCH 14/27] fix: use grep -qF for literal "Configuration successful." match The period in the search string is a regex wildcard with plain grep -q; -F (--fixed-strings) makes the match literal, which is what's intended. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index 58d4a3fc8..f6d1edd98 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -168,7 +168,7 @@ COMPOSE=(docker compose -f docker-compose.yml -f "$OVERRIDE_COMPOSE" --profile t log "Waiting for Tentacle 'Configuration successful.' in logs ..." for i in {1..60}; do - if "${COMPOSE[@]}" logs --no-color tentacle 2>/dev/null | grep -q "Configuration successful."; then + if "${COMPOSE[@]}" logs --no-color tentacle 2>/dev/null | grep -qF "Configuration successful."; then log "Tentacle registered after ${i}s" break fi From de069c62a08a6713930337f9482217dac934f3c8 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 20 May 2026 14:29:35 +1000 Subject: [PATCH 15/27] docs: clarify that API_KEY is the sibling repo's dev-only sentinel Adds a sentence to the header comment explaining that API-APIKEY01 is the well-known dev key provisioned by the sibling OctopusDeploy repo's compose stack for its local Server, not a real secret. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index f6d1edd98..a835bbbce 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -9,6 +9,10 @@ # Required tools: docker, op (signed in), curl, jq. # Required state: a built .deb in ../_artifacts/deb/tentacle_*_amd64.deb and the # OctopusDeploy repo checked out alongside OctopusTentacle. +# +# Note on $API_KEY below: "API-APIKEY01" is the well-known dev sentinel API key +# provisioned by the sibling OctopusDeploy repo's docker-compose stack for its +# local-only Server instance. It is not a real secret and is safe to commit. set -euo pipefail From 84550c7f6e7ef82a880e0e651d83dc7eb6f38779 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 20 May 2026 17:10:34 +1000 Subject: [PATCH 16/27] fix: hard-fail when Debian 12 is missing from AdHocScript output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the load-bearing assertion of the smoke test — if the Tentacle isn't actually running on Debian 12, the test should fail, not warn. Also switches the grep to -qF so the period is treated literally. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index a835bbbce..ef722fc4f 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -251,9 +251,11 @@ if [[ "$STATE" != "Success" ]]; then die "Task finished in state '$STATE' (expected Success)" fi -# Sanity: the printed log should mention Debian 12. -if ! curl -fsS -H "$H" "$API/tasks/$TASK_ID/raw" | grep -q 'Debian GNU/Linux 12'; then - warn "Task succeeded but the log does NOT mention 'Debian GNU/Linux 12'. Inspect output above." +# 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." From abea049234d374258ad9a4515b3e264626dbbcb2 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 20 May 2026 17:11:25 +1000 Subject: [PATCH 17/27] fix: use mktemp for upsert_env_var temp file Avoids writing a sibling \$ENV_FILE.tmp that two concurrent runs could race on. Uses the same XXXXXX template convention as the other mktemp calls in this script. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index ef722fc4f..24d3b0004 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -118,8 +118,8 @@ upsert_env_var() { # contains '/' and '=' but not '\' or '&'). Matches the line by literal # "KEY=" prefix, not regex, so unusual keys won't bite us. local key="$1" value="$2" - local tmp="$ENV_FILE.tmp" line found= - : > "$tmp" + local tmp line found= + tmp="$(mktemp "${TMPDIR:-/tmp}/octopus-server-env-smoke-upsert-XXXXXX")" while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" == "${key}="* ]]; then printf '%s=%s\n' "$key" "$value" >> "$tmp" From 4d4cb439904b1e5c1ed64e757d1b3dc45d873c25 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 20 May 2026 17:12:07 +1000 Subject: [PATCH 18/27] feat: allow OCTOPUS_LICENSE_BASE64 to bypass 1Password lookup Lets CI runners (which can't use op) inject the license via env var, while keeping op read as the local-dev default. The op tool is only required when the env var is unset. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index 24d3b0004..9279b4f52 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -6,10 +6,15 @@ # Server in the sibling OctopusDeploy repo, registers the Tentacle as a worker, # runs a hello-world AdHocScript on it, and asserts success. # -# Required tools: docker, op (signed in), curl, jq. +# Required tools: docker, curl, jq. # Required state: a built .deb in ../_artifacts/deb/tentacle_*_amd64.deb and the # OctopusDeploy repo checked out alongside OctopusTentacle. # +# License source: set $OCTOPUS_LICENSE_BASE64 to a base64-encoded Octopus license +# to skip the 1Password lookup (this is the path CI runners should use). When +# the env var is unset, the script falls back to `op read` against 1Password +# for local-dev use, in which case `op` must be installed and signed in. +# # Note on $API_KEY below: "API-APIKEY01" is the well-known dev sentinel API key # provisioned by the sibling OctopusDeploy repo's docker-compose stack for its # local-only Server instance. It is not a real secret and is safe to commit. @@ -43,9 +48,10 @@ 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 op require curl require jq +# `op` is only required when OCTOPUS_LICENSE_BASE64 is not pre-set (local-dev path). +[[ -n "${OCTOPUS_LICENSE_BASE64:-}" ]] || require op teardown() { local exit_code=$? @@ -96,19 +102,23 @@ docker build \ log "Built $DST_IMAGE" ############################################################################### -# Step 2: Fetch license from 1Password & patch .env +# Step 2: Resolve license & patch .env ############################################################################### -log "--- Step 2: fetch license and patch .env ---" +log "--- Step 2: resolve license and patch .env ---" [[ -f "$ENV_FILE" ]] || die "Expected $ENV_FILE to exist." -if ! op account list >/dev/null 2>&1; then - die "1Password CLI is not signed in. Run: eval \$(op signin)" +if [[ -n "${OCTOPUS_LICENSE_BASE64:-}" ]]; then + LICENSE_BASE64="$OCTOPUS_LICENSE_BASE64" + log "Using license from \$OCTOPUS_LICENSE_BASE64 (${#LICENSE_BASE64} bytes)" +else + if ! op account list >/dev/null 2>&1; then + die "1Password CLI is not signed in. Run: eval \$(op signin) — or pre-set \$OCTOPUS_LICENSE_BASE64." + fi + LICENSE_BASE64="$(op read "$ONEPASSWORD_LICENSE_REF" 2>/dev/null || true)" + [[ -n "$LICENSE_BASE64" ]] || die "Could not read license from 1Password at: $ONEPASSWORD_LICENSE_REF" + log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" fi -LICENSE_BASE64="$(op read "$ONEPASSWORD_LICENSE_REF" 2>/dev/null || true)" -[[ -n "$LICENSE_BASE64" ]] || die "Could not read license from 1Password at: $ONEPASSWORD_LICENSE_REF" -log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" - ENV_BACKUP="$(mktemp "${TMPDIR:-/tmp}/octopus-server-env-smoke-tentacle-XXXXXX")" cp "$ENV_FILE" "$ENV_BACKUP" log "Backed up .env to $ENV_BACKUP (will be restored on exit)" From 98a263708459a0b6ae35a50accc7e5867bdcf447 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 20 May 2026 17:13:01 +1000 Subject: [PATCH 19/27] fix: look up worker by per-run TargetName instead of "highest Workers-N" Generates a unique per-run worker name (smoke-tentacle--) and passes it to the Tentacle as TargetName via the override compose. The Server-side lookup then filters by Name == $WORKER_TARGET_NAME, which is robust against reused DB volumes growing the workers list and against any future Workers-N renumbering. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index 9279b4f52..ef2f94746 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -42,6 +42,12 @@ H="X-Octopus-ApiKey: $API_KEY" IMAGE_TAG="smoke-debian12" ONEPASSWORD_LICENSE_REF="op://software licencing/octopus deploy ultimate license key base64/value" +# Per-run worker name. Tagging the worker with a unique name (rather than +# relying on the container hostname / a "highest Workers-N" heuristic) keeps +# the test idempotent across reused Server DB volumes and lets teardown find +# the exact worker this run registered. +WORKER_TARGET_NAME="smoke-tentacle-$(date +%Y%m%d-%H%M%S)-$$" + 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; } @@ -167,11 +173,12 @@ done ############################################################################### log "--- Step 4: start tentacle ---" OVERRIDE_COMPOSE="$(mktemp "${TMPDIR:-/tmp}/docker-compose-smoke-tentacle-XXXXXX")" -cat > "$OVERRIDE_COMPOSE" <<'YAML' +cat > "$OVERRIDE_COMPOSE" </dev/null || echo '{"Items":[]}')" + 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 '[.Items[] | select(.Id | startswith("Workers-"))] | sort_by(.Id | ltrimstr("Workers-") | tonumber) | last | .Id // empty')" - WORKER_NAME="$(echo "$WORKERS_JSON" | jq -r --arg id "$WORKER_ID" '.Items[] | select(.Id == $id) | .Name // empty')" + | 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 appeared. Diagnostic dump of $API/workers:" + 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):" docker compose --profile tentacle logs --no-color --tail=80 tentacle || true - die "Worker did not appear after 60s" + die "Worker '$WORKER_TARGET_NAME' did not appear after 60s" fi -log "Registered worker: $WORKER_ID (name='$WORKER_NAME')" +log "Registered worker: $WORKER_ID (name='$WORKER_TARGET_NAME')" ADHOC_BODY="$(jq -nc \ --arg id "$WORKER_ID" \ From c6dc67e801395cf9a45e0be6f50abd5c184492cd Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 20 May 2026 17:13:48 +1000 Subject: [PATCH 20/27] fix: deregister worker in teardown to keep test idempotent When the Server's DB volume is reused across runs (CI especially), the workers list otherwise grows monotonically. Teardown now issues a best-effort DELETE /api/workers/\$WORKER_ID before bringing the stack down, guarded on \$WORKER_ID being populated (so an early failure before Step 5 is a no-op). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index ef2f94746..f5bf087ed 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -47,6 +47,10 @@ ONEPASSWORD_LICENSE_REF="op://software licencing/octopus deploy ultimate license # the test idempotent across reused Server DB volumes and lets teardown find # the exact worker this run registered. WORKER_TARGET_NAME="smoke-tentacle-$(date +%Y%m%d-%H%M%S)-$$" +# Populated in Step 5 once the Server confirms registration; used by teardown +# to deregister the worker via DELETE so the workers list doesn't grow +# monotonically across runs that share a Server DB volume. +WORKER_ID="" log() { printf '\033[1;34m[smoke]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[smoke]\033[0m %s\n' "$*" >&2; } @@ -62,6 +66,13 @@ require jq teardown() { local exit_code=$? log "--- teardown ---" + # Deregister the worker first, while the Server is still up. Best-effort: + # if the Server is already dead or the worker never registered, we just + # move on — the goal is to keep the workers list clean across runs. + 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 if [[ -n "$OVERRIDE_COMPOSE" && -f "$OVERRIDE_COMPOSE" ]]; then (cd "$SERVER_REPO" && docker compose -f docker-compose.yml -f "$OVERRIDE_COMPOSE" --profile tentacle down 2>/dev/null) || true rm -f "$OVERRIDE_COMPOSE" @@ -213,7 +224,6 @@ log "--- Step 5: verify registration and run hello-world ---" # Find the worker we just registered by its per-run TargetName. This is # robust against reused Server DB volumes (where workers list grows across # runs) and avoids the previous "highest Workers-N" heuristic. -WORKER_ID="" 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" \ From c24274431b1145d98eff2d2bc0aa83a46a7acdc1 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Thu, 21 May 2026 09:59:42 +1000 Subject: [PATCH 21/27] cleanup: combine two apt-get steps into one --- docker/linux/Dockerfile | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/docker/linux/Dockerfile b/docker/linux/Dockerfile index f1109c7c7..1d98a7c6e 100644 --- a/docker/linux/Dockerfile +++ b/docker/linux/Dockerfile @@ -5,23 +5,14 @@ RUN apt-get update && \ ca-certificates \ libgssapi-krb5-2 \ libicu72 \ - libssl3 && \ + 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,7 +34,7 @@ RUN apt-get update && \ 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. From 09ba661eb48fcfb8b2fc4d1daaa3e655bec8aa42 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 27 May 2026 16:05:58 +1000 Subject: [PATCH 22/27] feat: make Linux Tentacle smoke test self-contained Drops the sibling OctopusDeploy repo dependency. The smoke test now brings up its own MSSQL + Octopus Server via a new compose file at scripts/smoke-test-linux-tentacle.compose.yml, allowing it to run in TeamCity. Verified end-to-end against octopusdeploy/octopusdeploy:latest. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.compose.yml | 62 ++++++ scripts/smoke-test-linux-tentacle.sh | 184 +++++++----------- 2 files changed, 128 insertions(+), 118 deletions(-) create mode 100644 scripts/smoke-test-linux-tentacle.compose.yml diff --git a/scripts/smoke-test-linux-tentacle.compose.yml b/scripts/smoke-test-linux-tentacle.compose.yml new file mode 100644 index 000000000..125b72194 --- /dev/null +++ b/scripts/smoke-test-linux-tentacle.compose.yml @@ -0,0 +1,62 @@ +# 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_TAG, OCTOPUS_SERVER_TAG, SA_PASSWORD, ADMIN_PASSWORD, ADMIN_API_KEY, +# OCTOPUS_SERVER_BASE64_LICENSE, WORKER_TARGET_NAME) are interpolated below. + +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: octopusdeploy/tentacle:${TENTACLE_TAG} + 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 index f5bf087ed..2d99b22fe 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -2,56 +2,44 @@ # # 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 local Octopus -# Server in the sibling OctopusDeploy repo, registers the Tentacle as a worker, -# runs a hello-world AdHocScript on it, and asserts success. +# 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. -# Required state: a built .deb in ../_artifacts/deb/tentacle_*_amd64.deb and the -# OctopusDeploy repo checked out alongside OctopusTentacle. +# Required tools: docker, curl, jq, openssl. +# Required state: a built .deb in _artifacts/deb/tentacle_*_amd64.deb. # # License source: set $OCTOPUS_LICENSE_BASE64 to a base64-encoded Octopus license # to skip the 1Password lookup (this is the path CI runners should use). When # the env var is unset, the script falls back to `op read` against 1Password # for local-dev use, in which case `op` must be installed and signed in. -# -# Note on $API_KEY below: "API-APIKEY01" is the well-known dev sentinel API key -# provisioned by the sibling OctopusDeploy repo's docker-compose stack for its -# local-only Server instance. It is not a real secret and is safe to commit. set -euo pipefail TENTACLE_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SERVER_REPO="${SERVER_REPO:-$(cd "$TENTACLE_REPO/../OctopusDeploy" && pwd)}" -ENV_FILE="$SERVER_REPO/.env" -# .env backup path is assigned via mktemp in Step 2 (after we know $ENV_FILE -# exists). Using a unique per-run path avoids clobbering a stale backup from -# a previously-crashed run. -ENV_BACKUP="" -# Transient compose override: disables Docker-in-Docker on the Tentacle. The -# default tentacle entrypoint launches a dockerd daemon, which requires the -# container to run with `--privileged`; without that the daemon fails and its -# wrapper script kills the Tentacle agent. Setting DISABLE_DIND=Y skips it. -# Created via mktemp in Step 4 so we never clobber an unrelated file the user -# may already have in the sibling repo. -OVERRIDE_COMPOSE="" +COMPOSE_FILE="$TENTACLE_REPO/scripts/smoke-test-linux-tentacle.compose.yml" API="http://localhost:8065/api" -API_KEY="API-APIKEY01" -H="X-Octopus-ApiKey: $API_KEY" +# 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" IMAGE_TAG="smoke-debian12" ONEPASSWORD_LICENSE_REF="op://software licencing/octopus deploy ultimate license key base64/value" -# Per-run worker name. Tagging the worker with a unique name (rather than -# relying on the container hostname / a "highest Workers-N" heuristic) keeps -# the test idempotent across reused Server DB volumes and lets teardown find -# the exact worker this run registered. +# 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)-$$" -# Populated in Step 5 once the Server confirms registration; used by teardown -# to deregister the worker via DELETE so the workers list doesn't grow -# monotonically across runs that share a Server DB volume. WORKER_ID="" +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; } @@ -60,36 +48,43 @@ require() { command -v "$1" >/dev/null || die "Missing required tool: $1"; } require docker require curl require jq -# `op` is only required when OCTOPUS_LICENSE_BASE64 is not pre-set (local-dev path). +require openssl [[ -n "${OCTOPUS_LICENSE_BASE64:-}" ]] || require op +compose() { docker compose -f "$COMPOSE_FILE" "$@"; } + teardown() { local exit_code=$? log "--- teardown ---" - # Deregister the worker first, while the Server is still up. Best-effort: - # if the Server is already dead or the worker never registered, we just - # move on — the goal is to keep the workers list clean across runs. 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 - if [[ -n "$OVERRIDE_COMPOSE" && -f "$OVERRIDE_COMPOSE" ]]; then - (cd "$SERVER_REPO" && docker compose -f docker-compose.yml -f "$OVERRIDE_COMPOSE" --profile tentacle down 2>/dev/null) || true - rm -f "$OVERRIDE_COMPOSE" - fi - (cd "$SERVER_REPO" && docker compose down 2>/dev/null) || true - if [[ -n "$ENV_BACKUP" && -f "$ENV_BACKUP" ]]; then - mv "$ENV_BACKUP" "$ENV_FILE" - log "Restored $ENV_FILE" - 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 +# Step 1: Resolve license ############################################################################### -log "--- Step 1: build Tentacle image ---" +log "--- Step 1: resolve license ---" +if [[ -n "${OCTOPUS_LICENSE_BASE64:-}" ]]; then + LICENSE_BASE64="$OCTOPUS_LICENSE_BASE64" + log "Using license from \$OCTOPUS_LICENSE_BASE64 (${#LICENSE_BASE64} bytes)" +else + if ! op account list >/dev/null 2>&1; then + die "1Password CLI is not signed in. Run: eval \$(op signin) — or pre-set \$OCTOPUS_LICENSE_BASE64." + fi + LICENSE_BASE64="$(op read "$ONEPASSWORD_LICENSE_REF" 2>/dev/null || true)" + [[ -n "$LICENSE_BASE64" ]] || die "Could not read license from 1Password at: $ONEPASSWORD_LICENSE_REF" + log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" +fi + +############################################################################### +# Step 2: Build the Linux Tentacle image from the local .deb +############################################################################### +log "--- Step 2: build Tentacle image ---" cd "$TENTACLE_REPO" shopt -s nullglob @@ -119,63 +114,29 @@ docker build \ log "Built $DST_IMAGE" ############################################################################### -# Step 2: Resolve license & patch .env +# Step 3: Bring up MSSQL + Octopus Server and wait for /api to respond ############################################################################### -log "--- Step 2: resolve license and patch .env ---" -[[ -f "$ENV_FILE" ]] || die "Expected $ENV_FILE to exist." +log "--- Step 3: 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. +export TENTACLE_TAG OCTOPUS_SERVER_TAG SA_PASSWORD ADMIN_PASSWORD ADMIN_API_KEY \ + WORKER_TARGET_NAME +export OCTOPUS_SERVER_BASE64_LICENSE="$LICENSE_BASE64" -if [[ -n "${OCTOPUS_LICENSE_BASE64:-}" ]]; then - LICENSE_BASE64="$OCTOPUS_LICENSE_BASE64" - log "Using license from \$OCTOPUS_LICENSE_BASE64 (${#LICENSE_BASE64} bytes)" -else - if ! op account list >/dev/null 2>&1; then - die "1Password CLI is not signed in. Run: eval \$(op signin) — or pre-set \$OCTOPUS_LICENSE_BASE64." - fi - LICENSE_BASE64="$(op read "$ONEPASSWORD_LICENSE_REF" 2>/dev/null || true)" - [[ -n "$LICENSE_BASE64" ]] || die "Could not read license from 1Password at: $ONEPASSWORD_LICENSE_REF" - log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" -fi - -ENV_BACKUP="$(mktemp "${TMPDIR:-/tmp}/octopus-server-env-smoke-tentacle-XXXXXX")" -cp "$ENV_FILE" "$ENV_BACKUP" -log "Backed up .env to $ENV_BACKUP (will be restored on exit)" - -upsert_env_var() { - # Pure-bash: avoids sed/awk escape headaches with a base64 value (which - # contains '/' and '=' but not '\' or '&'). Matches the line by literal - # "KEY=" prefix, not regex, so unusual keys won't bite us. - local key="$1" value="$2" - local tmp line found= - tmp="$(mktemp "${TMPDIR:-/tmp}/octopus-server-env-smoke-upsert-XXXXXX")" - while IFS= read -r line || [[ -n "$line" ]]; do - if [[ "$line" == "${key}="* ]]; then - printf '%s=%s\n' "$key" "$value" >> "$tmp" - found=1 - else - printf '%s\n' "$line" >> "$tmp" - fi - done < "$ENV_FILE" - [[ -z "$found" ]] && printf '%s=%s\n' "$key" "$value" >> "$tmp" - mv "$tmp" "$ENV_FILE" -} - -upsert_env_var TENTACLE_TAG "$IMAGE_TAG" -upsert_env_var OCTOPUS_SERVER_BASE64_LICENSE "$LICENSE_BASE64" - -############################################################################### -# Step 3: Bring up Octopus Server and wait for /api to respond -############################################################################### -log "--- Step 3: start octopus-server ---" -cd "$SERVER_REPO" -docker compose up -d octopus-server +compose up -d mssql octopus-server log "Waiting for $API/octopusservernodes/ping ..." -for i in {1..120}; do +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 - [[ $i -eq 120 ]] && die "Server did not become ready in 120s" + 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 @@ -183,37 +144,26 @@ done # Step 4: Bring up the Tentacle (Worker, polling mode, DIND disabled) ############################################################################### log "--- Step 4: start tentacle ---" -OVERRIDE_COMPOSE="$(mktemp "${TMPDIR:-/tmp}/docker-compose-smoke-tentacle-XXXXXX")" -cat > "$OVERRIDE_COMPOSE" </dev/null | grep -qF "Configuration successful."; then + if compose logs --no-color tentacle 2>/dev/null | grep -qF "Configuration successful."; then log "Tentacle registered after ${i}s" break fi [[ $i -eq 60 ]] && die "Tentacle did not register in 60s. Logs: -$("${COMPOSE[@]}" logs --no-color --tail=80 tentacle)" +$(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 +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)" +$(compose logs --no-color --tail=80 tentacle)" fi ############################################################################### @@ -221,9 +171,7 @@ fi ############################################################################### log "--- Step 5: verify registration and run hello-world ---" -# Find the worker we just registered by its per-run TargetName. This is -# robust against reused Server DB volumes (where workers list grows across -# runs) and avoids the previous "highest Workers-N" heuristic. +# 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" \ @@ -236,7 +184,7 @@ 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):" - docker compose --profile tentacle logs --no-color --tail=80 tentacle || true + 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')" From 974557704bfda14e2fe284dbc80885f245be0ce4 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 27 May 2026 16:22:04 +1000 Subject: [PATCH 23/27] feat: drop license requirement; default to Community Edition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Octopus Server provisions a free Community Edition license on first boot when OCTOPUS_SERVER_BASE64_LICENSE is empty — well within limits for this smoke test (1 space, 1 worker, 0 projects, IsCompliant=true). Drops the 1Password lookup and the `op` tool requirement. The env var is still pass-through, so a paid license can be supplied if anyone wants to test against an Enterprise edition. Verified end-to-end with no license set. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.compose.yml | 6 ++- scripts/smoke-test-linux-tentacle.sh | 48 +++++++------------ 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.compose.yml b/scripts/smoke-test-linux-tentacle.compose.yml index 125b72194..3b63afe2c 100644 --- a/scripts/smoke-test-linux-tentacle.compose.yml +++ b/scripts/smoke-test-linux-tentacle.compose.yml @@ -4,7 +4,9 @@ # # Driven by scripts/smoke-test-linux-tentacle.sh — env vars exported there # (TENTACLE_TAG, OCTOPUS_SERVER_TAG, SA_PASSWORD, ADMIN_PASSWORD, ADMIN_API_KEY, -# OCTOPUS_SERVER_BASE64_LICENSE, WORKER_TARGET_NAME) are interpolated below. +# 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 @@ -37,7 +39,7 @@ services: ADMIN_USERNAME: "admin" ADMIN_PASSWORD: "${ADMIN_PASSWORD}" ADMIN_API_KEY: "${ADMIN_API_KEY}" - OCTOPUS_SERVER_BASE64_LICENSE: "${OCTOPUS_SERVER_BASE64_LICENSE}" + OCTOPUS_SERVER_BASE64_LICENSE: "${OCTOPUS_SERVER_BASE64_LICENSE:-}" ports: - "8065:8080" diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index 2d99b22fe..b77dadc2d 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -9,10 +9,10 @@ # Required tools: docker, curl, jq, openssl. # Required state: a built .deb in _artifacts/deb/tentacle_*_amd64.deb. # -# License source: set $OCTOPUS_LICENSE_BASE64 to a base64-encoded Octopus license -# to skip the 1Password lookup (this is the path CI runners should use). When -# the env var is unset, the script falls back to `op read` against 1Password -# for local-dev use, in which case `op` must be installed and signed in. +# 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 @@ -30,7 +30,6 @@ ADMIN_PASSWORD="Smoke-$(openssl rand -hex 16)!" SA_PASSWORD="Sa$(openssl rand -hex 12)!" H="X-Octopus-ApiKey: $ADMIN_API_KEY" IMAGE_TAG="smoke-debian12" -ONEPASSWORD_LICENSE_REF="op://software licencing/octopus deploy ultimate license key base64/value" # 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. @@ -49,7 +48,6 @@ require docker require curl require jq require openssl -[[ -n "${OCTOPUS_LICENSE_BASE64:-}" ]] || require op compose() { docker compose -f "$COMPOSE_FILE" "$@"; } @@ -66,25 +64,9 @@ teardown() { trap teardown EXIT ############################################################################### -# Step 1: Resolve license +# Step 1: Build the Linux Tentacle image from the local .deb ############################################################################### -log "--- Step 1: resolve license ---" -if [[ -n "${OCTOPUS_LICENSE_BASE64:-}" ]]; then - LICENSE_BASE64="$OCTOPUS_LICENSE_BASE64" - log "Using license from \$OCTOPUS_LICENSE_BASE64 (${#LICENSE_BASE64} bytes)" -else - if ! op account list >/dev/null 2>&1; then - die "1Password CLI is not signed in. Run: eval \$(op signin) — or pre-set \$OCTOPUS_LICENSE_BASE64." - fi - LICENSE_BASE64="$(op read "$ONEPASSWORD_LICENSE_REF" 2>/dev/null || true)" - [[ -n "$LICENSE_BASE64" ]] || die "Could not read license from 1Password at: $ONEPASSWORD_LICENSE_REF" - log "Fetched license from 1Password (${#LICENSE_BASE64} bytes)" -fi - -############################################################################### -# Step 2: Build the Linux Tentacle image from the local .deb -############################################################################### -log "--- Step 2: build Tentacle image ---" +log "--- Step 1: build Tentacle image ---" cd "$TENTACLE_REPO" shopt -s nullglob @@ -114,15 +96,17 @@ docker build \ log "Built $DST_IMAGE" ############################################################################### -# Step 3: Bring up MSSQL + Octopus Server and wait for /api to respond +# Step 2: Bring up MSSQL + Octopus Server and wait for /api to respond ############################################################################### -log "--- Step 3: start mssql and octopus-server ---" +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. +# 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_TAG OCTOPUS_SERVER_TAG SA_PASSWORD ADMIN_PASSWORD ADMIN_API_KEY \ WORKER_TARGET_NAME -export OCTOPUS_SERVER_BASE64_LICENSE="$LICENSE_BASE64" +export OCTOPUS_SERVER_BASE64_LICENSE="${OCTOPUS_SERVER_BASE64_LICENSE:-}" compose up -d mssql octopus-server @@ -141,9 +125,9 @@ for i in {1..300}; do done ############################################################################### -# Step 4: Bring up the Tentacle (Worker, polling mode, DIND disabled) +# Step 3: Bring up the Tentacle (Worker, polling mode, DIND disabled) ############################################################################### -log "--- Step 4: start tentacle ---" +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 @@ -167,9 +151,9 @@ $(compose logs --no-color --tail=80 tentacle)" fi ############################################################################### -# Step 5: Verify worker is registered & run hello-world AdHocScript +# Step 4: Verify worker is registered & run hello-world AdHocScript ############################################################################### -log "--- Step 5: verify registration and run hello-world ---" +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 From 27f5b3f21302eeb32a7e44350b91be15e86cc2a1 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 27 May 2026 16:41:21 +1000 Subject: [PATCH 24/27] fix: loosen Tentacle registration wait from 60 to 180 iterations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each iteration runs `docker compose logs` (~1-2s on a busy host) plus a 1s sleep, so the old 60-iteration cap was ~2-3 min wall — and on Apple Silicon under amd64 emulation, with several stacks recycling, it can expire before the grep catches "Configuration successful." even though the Tentacle has fully registered with the Server. Also rename the units in the log/error from seconds to iterations since that's what the loop counter actually measures. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index b77dadc2d..7755d95b0 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -133,12 +133,16 @@ log "--- Step 3: start tentacle ---" compose up -d --no-deps tentacle log "Waiting for Tentacle 'Configuration successful.' in logs ..." -for i in {1..60}; do +# 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}s" + log "Tentacle registered after ${i} iterations" break fi - [[ $i -eq 60 ]] && die "Tentacle did not register in 60s. Logs: + [[ $i -eq 180 ]] && die "Tentacle did not register in 180 iterations. Logs: $(compose logs --no-color --tail=80 tentacle)" sleep 1 done From 8f39c712dab06befdc6e011801b2ebc6ec2127f9 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 27 May 2026 17:32:52 +1000 Subject: [PATCH 25/27] refactor: rename smoke-test image to local-only tentacle-smoke:debian12 Previously the smoke test built and ran octopusdeploy/tentacle:smoke-debian12, a tag in the public Docker Hub namespace. Using a local-only image name (no octopusdeploy/ prefix) removes any risk of compose silently pulling a public image if one ever appears under that tag, and makes the "this image is only ever built locally" intent obvious. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.compose.yml | 11 ++++++----- scripts/smoke-test-linux-tentacle.sh | 10 +++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/scripts/smoke-test-linux-tentacle.compose.yml b/scripts/smoke-test-linux-tentacle.compose.yml index 3b63afe2c..3a56f5f16 100644 --- a/scripts/smoke-test-linux-tentacle.compose.yml +++ b/scripts/smoke-test-linux-tentacle.compose.yml @@ -3,10 +3,11 @@ # 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_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. +# (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 @@ -44,7 +45,7 @@ services: - "8065:8080" tentacle: - image: octopusdeploy/tentacle:${TENTACLE_TAG} + image: ${TENTACLE_IMAGE}:${TENTACLE_TAG} platform: linux/amd64 depends_on: - octopus-server diff --git a/scripts/smoke-test-linux-tentacle.sh b/scripts/smoke-test-linux-tentacle.sh index 7755d95b0..59c543437 100755 --- a/scripts/smoke-test-linux-tentacle.sh +++ b/scripts/smoke-test-linux-tentacle.sh @@ -29,13 +29,17 @@ 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" -IMAGE_TAG="smoke-debian12" +# 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}" @@ -85,7 +89,7 @@ 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="octopusdeploy/tentacle:${IMAGE_TAG}" +DST_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" docker build \ --platform linux/amd64 \ --build-arg BUILD_NUMBER="$BUILD_NUMBER" \ @@ -104,7 +108,7 @@ log "--- Step 2: start mssql and octopus-server ---" # 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_TAG OCTOPUS_SERVER_TAG SA_PASSWORD ADMIN_PASSWORD ADMIN_API_KEY \ +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:-}" From 39a016ea0fb9d7802078d5265c964ad874060f33 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 27 May 2026 17:33:11 +1000 Subject: [PATCH 26/27] fix: pin tentacle service to pull_policy: never MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Belt-and-braces against accidental registry pulls. The image is built locally in step 1 of the smoke test under a local-only name, so any attempted pull would be a bug — make compose fail loudly instead of silently pulling. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle.compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/smoke-test-linux-tentacle.compose.yml b/scripts/smoke-test-linux-tentacle.compose.yml index 3a56f5f16..bd5766922 100644 --- a/scripts/smoke-test-linux-tentacle.compose.yml +++ b/scripts/smoke-test-linux-tentacle.compose.yml @@ -46,6 +46,10 @@ services: 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 From 0aa45438989a23bf0f9c3215915e55599cc650d8 Mon Sep 17 00:00:00 2001 From: Tod Thomson Date: Wed, 27 May 2026 18:09:08 +1000 Subject: [PATCH 27/27] feat: add TeamCity wrapper for Linux Tentacle smoke test Wraps scripts/smoke-test-linux-tentacle.sh in TeamCity service messages so the smoke test can be run as an integration test on TeamCity: a single named test with timing, progress blocks per step, and buildProblem on failure. Inner stdout/stderr is forwarded unchanged so the wrapper is safe to run locally too. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke-test-linux-tentacle-teamcity.sh | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100755 scripts/smoke-test-linux-tentacle-teamcity.sh 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'"