Skip to content

LEV-1803: Move the Linux Tentacle image from Debian 11 to Ubuntu 22.04 +semver: major - #1292

Closed
todthomson wants to merge 6 commits into
mainfrom
lev/tod/LEV-1803-move-linux-tentacle-Dockerfile-from-debian-to-ubuntu
Closed

LEV-1803: Move the Linux Tentacle image from Debian 11 to Ubuntu 22.04 +semver: major#1292
todthomson wants to merge 6 commits into
mainfrom
lev/tod/LEV-1803-move-linux-tentacle-Dockerfile-from-debian-to-ubuntu

Conversation

@todthomson

@todthomson todthomson commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary (Tod-written)

Moves docker/linux/Dockerfile from debian:11-slim to ubuntu:22.04.

This fixes the broken Build: Linux Docker image and anything else that depends on that Dockerfile.

This (IMHO) is a breaking change (i.e. who knows who is depending on Tentacle having something Debian 11 specific that isn't included in Ubuntu 22.04 e.g. libssl1.1 so we need to rev the major version, which I will do as part of the merge commit).

Tests still broken in this PR are fixed in the next (stacked) PR: #1293.

Why Ubuntu 22.04 specifically

22.04 is deliberate, for two reasons:

  1. It is the smallest possible move off Debian 11. Jammy is the closest still-supported distro to bullseye, and the whole change came to three ABI-versioned package names (libgcc1 -> libgcc-s1, libicu67 -> libicu70, libssl1.1 -> libssl3) plus repointing Docker's apt repo at /linux/ubuntu. Nothing in the .deb, the entrypoint scripts or the image's behaviour had to change with it.
  2. Staying on Debian isn't an option. The Developer Experience team will not be providing Debian 12 or Debian 13 build agents, so a Debian 12 base - which is where the earlier EFT-3311 spike went, and what docker/kubernetes-agent-tentacle/Dockerfile already uses via runtime-deps:8.0-bookworm-slim - would leave the Linux image on a distro we can't build on.

Ubuntu 24.04 is the eventual destination (it costs libicu70 -> libicu74), but there's no 24.04 build agent yet either, so that's a follow-up rather than part of this.

Why the runtime dependency list shrank

libc6, libgcc-s1, libgssapi-krb5-2, libstdc++6 and zlib1g were dropped from the first apt-get install. ubuntu:22.04 already ships all five at the newest version jammy offers, so naming them installed nothing.

ca-certificates, libicu70 and libssl3 stay, and the reason differs for each: the first two are genuinely absent from the base image, while libssl3 is in the base but at 3.0.2-0ubuntu1.26 when jammy-security has 3.0.2-0ubuntu1.29. Naming libssl3 is precisely what pulls that security update in, so dropping it as "already present" would have shipped an unpatched OpenSSL. That distinction, and the warning that a no-op today stops being one as soon as the base image lags behind jammy-security, is now written into the Dockerfile so the list doesn't get "tidied" again by mistake.

The smoke tests still assert all eight packages are present in the built image regardless of what installed them, so a future base that stops shipping one of them fails there rather than at runtime.

Details (Claude-authored)

Moves docker/linux/Dockerfile to ubuntu:22.04. The base image swap alone doesn't build — two Debian-specific things had to change with it.

Not a one-line change

  1. Package names were bullseye-specific. libicu67 and libssl1.1 don't exist on jammy at all — the build failed with E: Couldn't find any package by regex 'libssl1.1'. Now libicu70 and libssl3, plus libgcc1libgcc-s1 (libgcc1 survives on jammy only as a transitional dummy). The .deb's own dependency line already accepts libssl3, so no packaging change was needed.
  2. install-docker.sh pointed at the Debian apt repo. lsb_release -cs now returns jammy, and there's no such suite under download.docker.com/linux/debian — confirmed 404 Not FoundE: The repository ... does not have a Release file. Since that script runs set -eux, docker-in-docker would have failed to install. Repointed at /linux/ubuntu.

Both failures would have gone red in Build: Linux Docker image, so CI would have caught them — but only at build time, which is the gap the script below closes.

Verification

The Linux image is built in TeamCity, not by NUKE here, so there was no local way to check a Dockerfile change short of pushing. testing/docker-linux/build-and-test-linux-docker-image.sh (added here) reproduces that chain and tests the result — 50/50 passing:

Stage What
deb PackDebianPackage, linux-x64 only
image the same docker-compose.build.yml command TeamCity runs
smoke 40 assertions: base image, runtime deps, dind, apt source and keyring, entrypoint guards, labels
e2e SQL Server + Octopus Server + a listening and a polling Tentacle from the fresh image

The e2e stage registers both Tentacles, asserts TentaclePassive/TentacleActive, waits for health checks, and confirms the server reads the built version off the wire. Registration in 15s, health in 5s.

Confirmed in the image: Ubuntu 22.04.5 LTS, OpenSSL 3.0.2, tentacle and dockerd both working.

Worth knowing: there is a Test: Windows Docker Image config in TeamCity but no Linux equivalentBuild: Linux Docker image feeds straight into Build: Docker manifest with nothing starting a container. So build-time regressions are covered today; runtime ones are not. Wiring this script in is a sensible follow-up.

Secret handling in the test script

  • An unlicensed Octopus Server allows 0 targets, so the e2e stage needs a licence. It reads OCTOPUS_SERVER_BASE64_LICENSE, else the same 1Password item ./environment.sh setup uses — and only when stdin is a TTY, since op can't prompt in CI or under an agent. --no-1password is a hard override. Without a licence the stage falls back to asserting configuration and connectivity up to the licence refusal.
  • The SQL SA and Octopus admin passwords are generated per run (20 chars, [A-Za-z0-9]), not hardcoded. Alphanumeric because the value lands in a SQL connection string, a sqlcmd -P argument and a JSON body; one upper, one lower and one digit are guaranteed rather than left to chance, since SQL Server's SA policy needs three of its four categories.
  • Every secret reaches its container via a 0600 --env-file, never -e, so none appear in ps. One file per consumer. The licence is additionally scrubbed from dumped container logs, because the server image logs its own --licenseBase64 invocation.

Notes for review

  • Running stage 1 dirties Product.wxs and .nuke/build.schema.json — NUKE side-effects, noted in the script header.
  • Docker repository setup brought in line with Docker's Ubuntu docs, and three packages dropped. The armoured key now lives at /etc/apt/keyrings/docker.asc and is referenced from a deb822 .sources file rather than a one-line .list. That let apt-transport-https go (a transitional stub on jammy; apt 2.4 ships the https method itself), gnupg go (only ever needed for gpg --dearmor — apt reads the armoured key directly and verifies it with gpgv, which apt depends on), and lsb-release go (replaced by /etc/os-release). Verified against a bare ubuntu:22.04 with only ca-certificates and curl added: InRelease fetched and verified, docker-ce resolvable, zero apt warnings. Image 1.17 GB → 1.16 GB, 213 packages. Note Python 3.10 stays regardless — it arrives via networkd-dispatcher in docker-ce's apparmor chain, not via lsb-release.
  • iproute2 added. dockerd does not need it — confirmed by running the image --privileged, where the daemon starts, reports no missing binaries and runs a nested container without it. But dockerd-entrypoint.sh uses ip in _tls_san, and without it a dind TLS certificate is issued with DNS SANs only (measured: DNS:docker,DNS:<hostname>,DNS:localhost, against …,IP:127.0.0.1,IP:172.17.0.2,IP:::1 with it), so a client connecting to the daemon by IP fails hostname verification. Upstream docker:dind ships it for the same reason.
  • The buildx and compose plugins still arrive via docker-ce-cli Recommends (verified present: buildx v0.37.0, compose v5.5.1).
  • Dead code removed: install-docker.sh had if [ iptables -nL > /dev/null 2>&1 ], which is test comparing two literal strings rather than running the command — it fails with "unary operator expected" (exit 2), so the iptables-legacy branch was unreachable and nft has always been selected. The conditional and its dead branch are now deleted and nft is set unconditionally, which is a no-op at runtime and gets shellcheck to parse the file again (it was erroring with SC1073/SC1072 and bailing out mid-file). Actually selecting legacy would flip dind's firewall backend and still needs its own testing, so that is untouched.
  • apt install ./tentacle_*.deb is now apt-get install -y --no-install-recommends ./tentacle_*.deb. Without -y, apt aborts (Do you want to continue? [Y/n] Abort., exit 1) the moment the .deb needs a dependency the earlier layers didn't already install — the exact invariant this base-image change moves. It also drops apt's "does not have a stable CLI interface" warning from the build log.

Follow-ups raised

  • LEV-1833 — add a TeamCity Test: Linux Docker Image configuration that runs this script, so runtime regressions in the image are caught rather than shipped. Probing the image for this PR also disproved the script's own assumption that privileged dind cannot run under emulation: dockerd came up (29.8.0, overlayfs) and ran a nested container, so asserting "the daemon starts and can run a container" is both feasible and the highest-value assertion missing today. The iproute2 gap above went unnoticed precisely because nothing exercises dind.
  • LEV-1834 — stop hand-maintaining ABI-pinned runtime dependency names, by moving to mcr.microsoft.com/dotnet/runtime-deps as docker/kubernetes-agent-tentacle/Dockerfile already does. Deliberately not done here: it needs the whole dind payload re-verified on a new base, and it depends on LEV-1833 for anything better than hand-verification.

CI status

The three red integration-test checks are not from this changeBuild: Linux Docker image passes:

🤖 Generated with Claude Code

@CLAassistant

CLAassistant commented Sep 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@todthomson
todthomson force-pushed the lev/tod/LEV-1803-move-linux-tentacle-Dockerfile-from-debian-to-ubuntu branch from 58edddc to be3248d Compare September 8, 2026 07:12
@OctopusDeploy OctopusDeploy deleted a comment from gitguardian Bot Sep 8, 2026
@todthomson todthomson changed the title LEV-1803: Move the Linux Tentacle image from Debian 11 to Ubuntu 22.04 LEV-1803: Move the Linux Tentacle image from Debian 11 to Ubuntu 22.04 +semver: major Sep 8, 2026
@todthomson
todthomson marked this pull request as ready for review September 8, 2026 09:10
@todthomson
todthomson requested a review from a team as a code owner September 8, 2026 09:10
@todthomson

Copy link
Copy Markdown
Member Author

@claude review once

@OctopusDeploy OctopusDeploy deleted a comment from gitguardian Bot Sep 8, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread build-and-test-linux-docker-image.sh Outdated
Comment thread testing/docker-linux/build-and-test-linux-docker-image.sh
Comment thread testing/docker-linux/build-and-test-linux-docker-image.sh
@todthomson
todthomson force-pushed the lev/tod/LEV-1803-move-linux-tentacle-Dockerfile-from-debian-to-ubuntu branch from 46c13f7 to 383b878 Compare September 8, 2026 09:28
@gitguardian

gitguardian Bot commented Sep 8, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@todthomson

Copy link
Copy Markdown
Member Author

@claude review (and give me ALL your findings + split off your findings on build-and-test-linux-docker-image.sh into a separate section as it's a TEST not code that actually runs in production)

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread testing/docker-linux/build-and-test-linux-docker-image.sh Outdated
Comment thread testing/docker-linux/build-and-test-linux-docker-image.sh Outdated
@todthomson

todthomson commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Local Claude review

Reviewed the PR from a local checkout: built the image, ran the script, and verified each claim against a real container rather than reading the diff. Findings and what was done about them below. Everything actioned landed in 00304c6.

Verified sound (no change needed)

  • All eight Ubuntu 22.04 package names resolve (apt-get --dry-run on ubuntu:22.04). libgcc1 -> libgcc-s1, libicu67 -> libicu70, libssl1.1 -> libssl3 are the right jammy equivalents.
  • The image builds clean, and the .deb's only declared dependency (libssl1.0.0 | libssl1.0.2 | libssl1.1 | libssl3, linux-packages/packaging-scripts/package.sh:51) is satisfied by libssl3, so no packaging change was needed.
  • lsb_release -cs returns jammy and Docker's linux/ubuntu repo serves it.
  • No image size regression: 1.17 GB, same as before.
  • build/Build.Tests.cs:98 already tests the .deb on ubuntu:jammy, so package-level coverage exists.
  • ip (iproute2) is absent from the image, and dockerd-entrypoint.sh:13 uses it in _tls_san — but debian:11-slim didn't ship it either and that path only runs when DOCKER_TLS_CERTDIR is set, which the Dockerfile never does. Pre-existing, off the default path, left alone.

Findings

1. apt install without -y — fixed

docker/linux/Dockerfile:46 used bare apt install ./tentacle_*.deb. It only worked because the first RUN layer happened to pre-install everything the .deb needed — the exact invariant this base image change moves. Confirmed apt aborts the moment anything extra is required:

Do you want to continue? [Y/n] Abort.
EXIT=1

The build log also carried WARNING: apt does not have a stable CLI interface. Use with caution in scripts.

Now apt-get install -y --no-install-recommends. (The earlier eft/tod/fix/EFT-3311-upgrade-linux-dockerfile-to-debian-12 branch already had this fix; it got dropped on the way here.)

2. Dead iptables conditional — dead code deleted rather than documented

The comment's analysis was correct, confirmed two ways:

$ bash -c 'if [ iptables -nL > /dev/null 2>&1 ]; then echo legacy; else echo nft; fi'
+ '[' iptables -nL ']'
nft
$ bash -c '[ iptables -nL ]; echo $?'
bash: [: iptables: unary operator expected
2

Two problems with leaving it as a comment: shellcheck reported errors (SC1073/SC1027/SC1072) and bailed out mid-file, so nothing after line 75 was being checked; and "the fix is to drop the brackets" invites a future dev to silently flip dind's firewall backend.

The conditional and its unreachable legacy branch are now deleted, nft is set unconditionally — a no-op at runtime, since nft is what has always been selected — and the reasoning is kept in a shorter comment. shellcheck -S warning on that file is now clean.

3. Why Ubuntu 22.04 — answered in the PR description

Raised because three things sat oddly together: docker/kubernetes-agent-tentacle/Dockerfile uses runtime-deps:8.0-bookworm-slim (Debian 12), there's an existing EFT-3311 branch that took this file to Debian 12, and 22.04's standard support ends April 2027 against 24.04's April 2029.

Answered: 22.04 is the smallest possible move off bullseye, and Debian is out because DevEx will not be providing Debian 12 or 13 build agents. 24.04 is the eventual destination (it costs libicu70 -> libicu74; verified libssl3 is still a valid name on noble) but there's no 24.04 agent yet. Now written into the Summary.

4. Hand-maintained ABI-pinned package names — partly retracted, and the evidence changed the fix

My original suggestion was that the list was redundant and could be dropped in favour of Microsoft's runtime-deps. Checking installed-vs-candidate versions on a pristine base showed that's only half right:

Package In base Candidate Verdict
ca-certificates 20260601~22.04.1 must list
libicu70 70.1-2 must list
libssl3 3.0.2-0ubuntu1.26 3.0.2-0ubuntu1.29 listing is what patches it
libc6, libgcc-s1, libgssapi-krb5-2, libstdc++6, zlib1g current current genuine no-ops

So five were dropped and three kept. Dropping libssl3 as "already present" would have shipped OpenSSL at .26 instead of .29 — verified the built image still reports libssl3 3.0.2-0ubuntu1.29 after the trim. The distinction, and the warning that a no-op today stops being one as soon as the base lags behind jammy-security, is now in the Dockerfile.

Worth saying plainly: explicitly listing base-image packages is not pure noise, it's what pulls security updates. Trim this list with care.

5. Test script placement — moved and referenced

819 lines at the repo root, not referenced from any readme and not wired into CI, so nothing would ever run it. Now at testing/docker-linux/build-and-test-linux-docker-image.sh, with references added to README.md (under Build, Test and Delivery), testing/README.md (new Linux Docker Image section with the stage table and licence notes), and docker/readme.md (under Additional Information — that file is the customer-facing Docker Hub readme, so the reference is scoped to contributors).

The move needed a code fix: REPO_DIR resolved from the script's own directory, which would have pointed at testing/docker-linux and broken every relative path. It now resolves two levels up and asserts it found the repo root.

Still not wired into TeamCity. There is a Test: Windows Docker Image config but no Linux equivalent, so runtime regressions in this image remain uncovered by CI — worth a follow-up.

6. Smaller script issues — all fixed

  • grep -q across two files. grep -q "^dockremap:" /etc/subuid /etc/subgid exits 0 on the first match in either file, so it would have passed with only one of the two present. Confirmed with a two-file test. Now checked separately.
  • Weak version assertion. SHORT_VERSION="${BUILD_NUMBER%%-*}" reduced to 9.2.4324 and only asserted the machines JSON contained it. EXPECTED_VERSION is now hoisted out of the smoke stage and reused, so it compares the full FullSemVer.
  • Empty env values. OCTOPUS_SERVER_BASE64_LICENSE= and MASTER_KEY= were passed as empty strings; both are now omitted entirely when unset.
  • Work done under --skip-e2e. Passwords were minted and temp files written even when stage 4 was skipped. Now all of it lives in e2e_prepare, called from stage 4 only. Verified --skip-e2e creates no temp files.
  • Indentation. The licence branch sat at column 0 inside the stage 4 block, leaving a bare fi/fi pair. Reindented.

Verification after the changes

Rebuilt and re-ran the full script from its new location — 47/47 passing, same as before the review:

  • Smoke, 37/37. Notably the eight runtime-dependency assertions still pass with only three named in the Dockerfile, which is the safety net working as intended. The built image reports libssl3 3.0.2-0ubuntu1.29, so trimming the list cost no security patches.
  • e2e, 10/10, licensed. Both Tentacles registered in 20s, health-checked in 5s, TentaclePassive/TentacleActive as expected. This exercised every stage 4 change: dropping the empty MASTER_KEY did not upset the server container (up in 85s), the licence still reaches it through the env file, and the version assertion now matches the full 9.2.4324-lev-tod-...-ubuntu string off the wire rather than just the 9.2.4324 prefix.
  • Teardown leaves no containers, no network and no temp files behind.
  • bash -n and shellcheck -S warning clean on both shell files.

e2e_prepare was also exercised in isolation both ways: with OCTOPUS_SERVER_BASE64_LICENSE unset the server env file contains only ADMIN_PASSWORD and DB_CONNECTION_STRING, and with it set the licence key appears. Files are 0600 and removed by the trap.

🤖 Generated with Claude Code

@todthomson

Copy link
Copy Markdown
Member Author

@claude review (and give me ALL your findings + split off your findings on build-and-test-linux-docker-image.sh into a separate section as it's a TEST not code that actually runs in production)

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five findings from the earlier rounds of this review are fixed correctly. Verified in the code: resolve_license now strips \n\r before the value reaches the env-file; e2e_teardown is split into an unconditional e2e_cleanup_stale/e2e_remove_stack plus a --keep-gated e2e_teardown for the EXIT trap; wait_for now takes trailing container names and fails fast on an exited state; the previously-unguarded ENVIRONMENTS=$(...)/MACHINES=$(...) captures now fall back to || VAR=""; and all e2e container/network names carry a $$-based suffix (E2E_RUN_ID) to avoid collisions between concurrent runs.

Extended reasoning...

Overview

This PR moves the Linux Tentacle Docker base image from debian:11-slim to ubuntu:22.04, updates install-docker.sh's apt repository setup for Ubuntu/jammy (including fixing a genuinely broken if [ iptables ... ] conditional), and adds a large new local verification script (~979+ lines) covering deb build, image build, smoke tests, and an end-to-end SQL Server + Octopus Server + Tentacle registration test.

Security risks

No new risks beyond what was already reviewed in prior rounds: secrets (SQL SA password, Octopus admin password, license) are generated per-run and passed via 0600 --env-file, never -e, and the license is scrubbed from dumped logs. The license-newline fix in this round closes a correctness bug that could have corrupted the env-file, not a security hole per se.

Level of scrutiny

This is now the third review round on this PR. In the first two rounds I raised one 🔴 (license newline corruption) and four 🟡 findings (teardown guard, wait_for fail-fast, unguarded API captures, concurrent-run name collisions). This round I re-read the current diff and confirmed each of those five is fixed with a correct implementation (spot-checked the actual code, not just the commit message claims), plus a bonus fix (find_deb's || true for the "or empty" contract under pipefail) that was not part of my original findings but is consistent with the stated intent.

Other factors

No third-party CHANGES_REQUESTED or unresolved objections are indicated in the timeline beyond my own prior findings, which are now resolved by code, not just by author self-resolution of the threads. Given everything raised was substantively addressed and verified against the actual diff, and the change itself (base image swap plus a local-only test script) is well-scoped and thoroughly self-tested (50/50 per the author's verification), I'm approving.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five findings from the previous review round have been genuinely fixed in code (not just resolved as threads): the licence value is stripped of embedded newlines before being written to the env file, wait_for now takes container names and fails fast if one exits instead of burning the full timeout, teardown is split into an unconditional e2e_cleanup_stale and a --keep-gated e2e_teardown so repeated --keep runs no longer collide, every octopus_api/machines_json capture in the e2e and smoke stages is now guarded, and all e2e container/network names carry the script's PID so concurrent runs don't collide. I re-checked each of these directly against the current file and they hold up. No new issues found.

Extended reasoning...

Overview

This is a follow-up push on a PR I've already reviewed three times. The prior round produced 5 inline findings on testing/docker-linux/build-and-test-linux-docker-image.sh: one 🔴 (license value with embedded newlines corrupting the env file) and four 🟡 (teardown/pre-run cleanup sharing one --keep guard, wait_for not detecting an exited container the way wait_for_log does, several unguarded VAR=$(...) captures under set -euo pipefail that could abort the script mid-stage, and fixed e2e container/network names colliding across concurrent runs).

Verification of fixes

I did not rely on thread-resolution metadata (all threads were resolved by the author or this bot's own prior session, which the instructions correctly treat as non-independent). Instead I re-read the current file directly:

  • License: OCTOPUS_LICENSE=$(printf '%s' "$OCTOPUS_LICENSE" | tr -d '\n\r') at line 463, before it reaches new_env_file/server_env.
  • Teardown split: e2e_remove_stack (actual removal), e2e_cleanup_stale (unconditional, pre-run, filtered to this run's own names), and e2e_teardown (--keep-gated, used only by the EXIT trap).
  • wait_for now accepts trailing container names and checks docker inspect -f '{{.State.Status}}' for exited after each poll, failing fast with a clear message instead of sitting out the full timeout.
  • The ENVIRONMENTS=$(...) and MACHINES=$(...) captures are now guarded with || ENVIRONMENTS="" / || MACHINES="", matching the pattern already used in the smoke stage.
  • E2E names are suffixed with E2E_RUN_ID="$$", and pre-run cleanup only targets names matching this run's PID, avoiding both the previous collision and a new one that an unconditional pre-run cleanup would have introduced.

The commit message for 7c521347c6dc also documents a bonus fix (find_deb silently aborting under pipefail on a clean checkout with --skip-deb) that lines up with what the diff shows.

Security risks

No new security-relevant surface was introduced since the last review; the one security-relevant finding (license value corruption) is fixed. Secrets continue to be passed via 0600 --env-file rather than -e/argv, as previously described.

Level of scrutiny and other factors

The production-facing files (docker/linux/Dockerfile, docker/linux/install-scripts/install-docker.sh) are unchanged from what I reviewed previously and match the PR description precisely — base image swap, package name changes for jammy, deb822 Docker repo source, and the dead if [ iptables -nL ... ] conditional replaced with a fixed nft selection (correctly identified as a no-op behavior change, since the original test was always false). The large test script is where all substantive risk lived, and that risk has been addressed through three iterations of review and fix. Given the fixes are verified directly in code and no new issues were found, I'm approving rather than deferring again.

@xwipeoutx xwipeoutx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This all looks good to me.

todthomson and others added 6 commits September 9, 2026 12:41
Squashed from seven commits. Their descriptions follow, in order.

== Move the Linux Tentacle Dockerfile from Debian 11 to Ubuntu 22.04

Switching the base image alone is not enough: the package names in the
Dockerfile were Debian 11 (bullseye) specific and the Docker apt source
was hardcoded to the Debian repo, so the image failed to build.

- Use the Ubuntu 22.04 (jammy) names for the .NET runtime dependencies:
  libicu67 -> libicu70 and libssl1.1 -> libssl3, neither of which exists
  on jammy at all (the build died with "Couldn't find any package by
  regex 'libssl1.1'"). Also libgcc1 -> libgcc-s1; libgcc1 survives on
  jammy only as a transitional dummy package. The .deb's own dependency
  is already satisfied by libssl3 (libssl1.0.0 | libssl1.0.2 |
  libssl1.1 | libssl3), so no packaging change is needed.
- Point install-docker.sh at download.docker.com/linux/ubuntu. It used
  the debian repo, and since `lsb_release -cs` now returns "jammy" there
  is no such suite under the Debian path, so the docker-in-docker
  install would have failed.

Verified end to end with build-docker-linux.sh: Ubuntu 22.04.5 LTS,
OpenSSL 3.0.2, tentacle and dockerd both working, and a Tentacle from
the image configures itself and connects to a real Octopus Server.

== Add build-docker-linux.sh to build and verify the Linux image locally

The Linux Tentacle image is built in TeamCity, not by NUKE in this repo,
so there was no local way to check a change to docker/linux/Dockerfile
short of pushing. This reproduces that chain and then tests the result.

Stages, matching TeamFireAndMotion_OctopusTentacle_TentacleVLatest_net80:
  1. deb    - PackDebianPackage, restricted to linux-x64
  2. image  - the same docker-compose.build.yml command TeamCity runs,
              with BUILD_NUMBER derived from the .deb filename
  3. smoke  - 37 assertions over the image contents and behaviour
  4. e2e    - SQL Server + Octopus Server + a listening and a polling
              Tentacle built from the fresh image

Notes on why some of this is shaped the way it is:

- NUKE resolves every [ParameterFromPasswordStore] at start-up, which
  shells out to the 1Password CLI and hangs when `op` cannot prompt.
  Those secrets only feed the SBOM target, so the script opts out with
  OCTOPUS__Tests__SecretManagerEnabled=False.
- Everything is pinned to linux/amd64, since the .deb is amd64 and this
  needs to work from an arm64 (Apple Silicon) checkout.
- The existing Pester suite could not be reused: docker/Tests and
  docker-compose.yml are Windows-container based and assume a server
  with imported data, so stage 4 is new.
- An unlicensed Octopus Server enforces a Targets limit of 0 on
  2026.4.x, so no target can register. With no licence the stage
  asserts configuration and connectivity up to the licence refusal;
  set OCTOPUS_SERVER_BASE64_LICENSE to assert full registration,
  comms style and health status instead.

== Source a licence in build-docker-linux.sh so the e2e test can register

An unlicensed Octopus Server allows 0 targets, so stage 4 could never
prove the thing it exists to prove: that a Tentacle built from this
image registers and health-checks. Resolve a licence instead, from
OCTOPUS_SERVER_BASE64_LICENSE or, failing that, the same 1Password item
the Octopus Server repo's ./environment.sh setup uses:

    op://software licencing/octopus deploy ultimate license key base64

The 1Password lookup only runs when stdin is a TTY. `op` has to prompt
to authenticate, so with nothing to prompt it hangs or fails - the same
trap that stalls NUKE's own 1Password lookup in stage 1 - and gating on
a TTY keeps it out of CI runners, background shells and agents without
anyone having to remember a flag. --no-1password is a hard override.

Handling of the licence itself:
- Passed to the container via a 0600 --env-file, not -e, so it stays
  out of `ps` output.
- Never echoed; only its length is logged.
- dump_logs scrubs it, because the server image logs its own
  `license --licenseBase64 <value>` invocation and would otherwise
  print the licence verbatim if the server failed to start.

Verified: 47/47 with a licence, registration in 15s and health checks
in 5s, and no occurrence of the licence anywhere in the run log. The
TTY gate was unit-tested both ways - skipped with no TTY, reached under
a real pty. Without a licence the stage still falls back to asserting
configuration and connectivity up to the licence refusal (44/44).

== Rename build-docker-linux.sh to build-and-test-linux-docker-image.sh

Most of the script is assertions, not build steps, so a name leading with
"build" hid its main purpose: anyone editing docker/linux/Dockerfile had
no cue that this is the thing to run. The new name also matches TeamCity's
own vocabulary for the equivalent Windows job ("Test: Windows Docker
image") and says which of the repo's three Dockerfiles it covers.

== Generate the e2e container passwords instead of hardcoding them

The SQL Server SA and Octopus admin passwords were fixed literals in the
script. They now come from random_password(): 20 characters, fresh per
run, never written anywhere but the containers they are made for.

Alphanumeric only, because the value is interpolated into a SQL Server
connection string, a `sqlcmd -P` argument and a JSON request body, and
punctuation would need escaping differently in each of the three.

With punctuation excluded, at least one upper, one lower and one digit
is guaranteed rather than left to chance: SQL Server's SA policy requires
characters from three of its four categories, so a random draw missing
one would fail the container at start-up. /dev/urandom is read in fixed
chunks so `head` finishes before `tr` and the pipeline cannot be
SIGPIPE'd into tripping `set -o pipefail`.

Verified: generator gives 20 chars, correct charset and all three classes
over 1000 draws, 200/200 distinct; full run 47/47 green, so SQL Server
accepts a symbol-free SA password and Octopus accepts a random
alphanumeric admin password through both install and API login.

== Pass every e2e secret via --env-file instead of -e

The licence already went in through an env file; the generated SQL SA and
Octopus admin passwords did not, so they sat in `ps` output for the life
of each container. Covers all five places a secret previously reached a
command line:

- SQL Server container      -> E2E_ENV_SQL
- Octopus Server container  -> E2E_ENV_SERVER (licence, admin password and
                               the connection string that embeds the SA one)
- both Tentacle containers  -> E2E_ENV_AGENT
- the curl helper container -> E2E_ENV_API; the helper now reads
                               OCTO_USER/OCTO_PASS from its environment
                               rather than taking them as argv
- the SQL readiness probe   -> reads $MSSQL_SA_PASSWORD inside the
                               container, so it is off the `docker exec`
                               argv too

One file per consumer, so no container is handed a credential it has no
use for. All are 0600 and removed by e2e_rm_temp, which both EXIT traps
now share - including the --skip-e2e path, where previously only the
helper was cleaned up.

Docker takes env-file values literally, which suits the connection
string's ';' and '=' characters with no quoting needed.

Verified: no secret remains on any `-e` flag (remaining ones are
ACCEPT_EULA, MSSQL_PID, ADMIN_USERNAME, MASTER_KEY, ServerUrl/Port,
Target* and the API-FAKE placeholder); full run 47/47 green; temp files
gone afterwards.

== Document the dead iptables-legacy branch in install-docker.sh

`if [ iptables -nL > /dev/null 2>&1 ]` never succeeds: `[ ]` is the test
builtin, not a subshell, so iptables is never executed. Test just compares
the literal strings `iptables` and `-nL`, and with two arguments expects
the first to be a unary operator, which `iptables` is not - so it errors
and returns non-zero. The legacy branch is therefore dead code and nft is
always selected.

Comment only. Fixing it (drop the brackets) would flip docker-in-docker's
firewall backend from nft to legacy on most hosts, which needs its own
testing. The behaviour predates the move off Debian, so it is not a
regression from that change.

+semver: major

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dockerfile:

- `apt install ./tentacle_*.deb` -> `apt-get install -y
  --no-install-recommends`. Without `-y`, apt aborts as soon as the .deb
  needs a dependency the earlier layers did not already install, which is
  the exact invariant the base image change moves.
- Drop libc6, libgcc-s1, libgssapi-krb5-2, libstdc++6 and zlib1g from the
  runtime dependency list; ubuntu:22.04 already ships all five at the
  newest version jammy offers, so naming them installed nothing.
  ca-certificates, libicu70 and libssl3 stay - the first two are absent
  from the base image, and libssl3 is present but at 3.0.2-0ubuntu1.26
  when jammy-security has 3.0.2-0ubuntu1.29, so naming it is what pulls
  that security update in. Documented in the file so the list is not
  "tidied" again by mistake.

install-docker.sh:

- Delete the dead `if [ iptables -nL > /dev/null 2>&1 ]` conditional and
  its unreachable iptables-legacy branch, and set nft unconditionally.
  `[` is the test builtin, so iptables never ran and test failed with
  "unary operator expected" - nft has always been selected, making this a
  no-op at runtime. Also gets shellcheck parsing the file again; it was
  erroring with SC1073/SC1072 and bailing out mid-file.

Test script:

- Move to testing/docker-linux/, and resolve the repo root two levels up
  rather than from the script's own directory.
- Reference it from README.md, docker/readme.md and testing/README.md.
- Check /etc/subuid and /etc/subgid separately; `grep -q a b` exits 0 on
  the first match in either file, so the combined form passed with only
  one of the two.
- Hoist EXPECTED_VERSION out of the smoke stage and use it for the
  on-the-wire version assertion, instead of a bare numeric prefix that
  could match a coincidental substring.
- Only mint credentials, read 1Password and write temp files when stage 4
  is actually going to run.
- Omit OCTOPUS_SERVER_BASE64_LICENSE and MASTER_KEY entirely when empty,
  rather than passing them as empty values.
- Indent the licence branch to match the block it sits in.

Verified: image rebuilds clean and all 37 smoke tests still pass. The
smoke tests assert all eight runtime dependencies are present regardless
of what installed them, and libssl3 is still 3.0.2-0ubuntu1.29 in the
built image after the list was trimmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… layer

both_healthy counted occurrences of `"HealthStatus":"Healthy"|"HasWarnings"`
across the whole /api/machines/all payload and asserted there were at least
two, without tying either status to a machine. With only the two Tentacles
registered it could not really lie, but a stale machine left behind by an
earlier run would let one healthy record stand in for a Tentacle that was
still unhealthy.

machine_health now resolves the status of a named machine by splitting the
payload into one line per machine first, and both_healthy checks each
Tentacle by name. awk does the split because sed's replacement-side `\n` is
not portable to the BSD sed on macOS.

Also add the `apt-get clean` the first apt layer was missing; the other two
layers in the Dockerfile already had it.

Verified: 47/47 (37 smoke + 10 licensed e2e), including "both Tentacles
reported Healthy" through the new per-machine path. libssl3 in the built
image is still 3.0.2-0ubuntu1.29 and the image is still 1.17 GB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up on the four remaining review findings.

install-docker.sh - three packages dropped from the repository setup:

- apt-transport-https is a transitional stub on jammy ("transitional
  package for https support", depending only on apt >= 1.5~alpha4); apt
  2.4 ships /usr/lib/apt/methods/https itself.
- gnupg was only needed for `gpg --dearmor`. The key is now fetched
  ASCII-armoured and referenced directly from Signed-By, verified with
  gpgv, which apt itself depends on.
- lsb-release is replaced by /etc/os-release. It was also the largest of
  the three, though not for the reason first assumed: it does pull in
  Python 3.10, but so does networkd-dispatcher via docker-ce's apparmor
  chain, so Python stays in the image either way.

The repository is now declared the way Docker's own Ubuntu instructions
do it: the armoured key under /etc/apt/keyrings, referenced from a deb822
.sources file rather than a one-line .list. Verified against a bare
ubuntu:22.04 with only ca-certificates and curl added - InRelease fetched
and verified, docker-ce resolvable, no apt warnings.

install-docker.sh - one package added:

- iproute2, for `ip`, which dockerd-entrypoint.sh uses in _tls_san.
  dockerd does not need it (confirmed: the daemon starts, reports no
  missing binaries and runs a nested container without it), but without
  it a dind TLS certificate is issued with DNS SANs only - measured as
  DNS:docker,DNS:<hostname>,DNS:localhost against
  DNS:...,IP:127.0.0.1,IP:172.17.0.2,IP:::1 with it - so a client
  connecting to the daemon by IP fails hostname verification.

Test script:

- resolve_license now strips newlines from the licence. An embedded one
  split the --env-file entry in two, handing the server a truncated
  licence plus a garbage second variable, and broke the sed that scrubs
  the licence from dumped container logs ("sed: 1: s|AAAA", exit 1). Both
  confirmed against the old code.
- Smoke tests follow the repository move: they read docker.sources rather
  than docker.list, assert the Signed-By path, check the keyring is
  present, readable and armoured, and check `ip` is on PATH.

Verified: 50/50 (40 smoke + 10 licensed e2e), image 1.17 GB -> 1.16 GB,
213 packages. shellcheck -S warning clean on both shell files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four remaining bot findings; the fifth (licence newlines, the only 🔴)
was already fixed in 916b0c2.

e2e_teardown did two jobs behind one --keep guard: pre-run cleanup and
EXIT-trap teardown. After a --keep run, the next --keep run returned early
from the pre-run call and then died on a `docker run --name` conflict.
Split into e2e_remove_stack (the actual removal), e2e_cleanup_stale
(unconditional, pre-run, and says what it removed) and e2e_teardown
(--keep-gated, EXIT trap only).

wait_for now takes optional trailing container names and fails immediately
if one has exited, the way wait_for_log already did. The four call sites
have 420-900s timeouts, so a container that died on startup previously sat
out the whole wait and reported a timeout that hid the real cause. Measured
0s instead of 300s on a container that exits immediately. The condition is
still evaluated before the exit check, so a container that satisfies the
test and then exits still counts as ready.

Every `VAR=$(...)` capture in the smoke and e2e stages is now guarded.
Unguarded, a transient docker or API failure exited the shell at the point
of capture, losing dump_logs, the test tally and the pass/fail verdict -
which defeats the point of fail() returning 0 so a run always reports.
A dead Docker daemon is still caught up front by the `docker info` check,
so this cannot mass-fail for that reason.

Found while doing that, and not in the bot's list: find_deb documents
"or empty if there are none", but with no match the glob is passed through,
`ls` fails, and under pipefail `DEB_PATH=$(find_deb)` aborted the script
before reaching the `die` that explains what is missing - so --skip-deb on
a clean checkout exited silently. Fixed in find_deb itself.

Every Docker name in stage 4 now carries the script's PID as a suffix, so
concurrent runs on one host cannot collide. This matters for the CI work in
LEV-1833, where a PR build and a nightly can overlap on a shared agent.
Note that fixing the --keep issue above made this worse before fixing it
here: an unconditional pre-run cleanup would reliably destroy a concurrent
run's live stack. Pre-run cleanup is now scoped to this run's own names.

Verified: 50/50 (40 smoke + 10 licensed e2e), suffixed names registering
and health-checking end to end, clean teardown. shellcheck -S warning clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@todthomson
todthomson force-pushed the lev/tod/LEV-1803-move-linux-tentacle-Dockerfile-from-debian-to-ubuntu branch from bb3376b to 964a092 Compare September 9, 2026 02:41
@todthomson

Copy link
Copy Markdown
Member Author

Closing this (approved) PR, as this code will come in with the code in stacked PR: #1293.

@todthomson todthomson closed this Sep 9, 2026
@todthomson
todthomson deleted the lev/tod/LEV-1803-move-linux-tentacle-Dockerfile-from-debian-to-ubuntu branch September 9, 2026 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants