Keep Windows agent update signatures addressable

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-01 16:01:48 +01:00
parent e7d38380c6
commit 9a5af6ff79
9 changed files with 162 additions and 37 deletions
@@ -98,6 +98,15 @@ jobs:
set -euo pipefail
test "$(readlink /usr/local/bin/pulse-agent)" = "/opt/pulse/bin/pulse-agent-linux-amd64"
test -x /usr/local/bin/pulse-agent
for arch in amd64 arm64 386; do
for suffix in "" .sig .sshsig; do
alias="/opt/pulse/bin/pulse-agent-windows-${arch}${suffix}"
target="pulse-agent-windows-${arch}.exe${suffix}"
test -L "${alias}"
test "$(readlink "${alias}")" = "${target}"
test -s "${alias}"
done
done
for sidecar in /opt/pulse/bin/*.sig /opt/pulse/bin/*.sshsig; do
test ! -x "$sidecar"
done
+14 -1
View File
@@ -369,10 +369,17 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \
# Unified agent binaries (all platforms and architectures) plus detached signatures
COPY --from=release-assets-builder /app/pulse-agent-* /opt/pulse/bin/
# Create symlinks for Windows without .exe extension
# Create complete binary/signature aliases for Windows without .exe extension.
# The download handler resolves this name and reads detached sidecars beside it.
RUN ln -s pulse-agent-windows-amd64.exe /opt/pulse/bin/pulse-agent-windows-amd64 && \
ln -s pulse-agent-windows-amd64.exe.sig /opt/pulse/bin/pulse-agent-windows-amd64.sig && \
ln -s pulse-agent-windows-amd64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-amd64.sshsig && \
ln -s pulse-agent-windows-arm64.exe /opt/pulse/bin/pulse-agent-windows-arm64 && \
ln -s pulse-agent-windows-arm64.exe.sig /opt/pulse/bin/pulse-agent-windows-arm64.sig && \
ln -s pulse-agent-windows-arm64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-arm64.sshsig && \
ln -s pulse-agent-windows-386.exe /opt/pulse/bin/pulse-agent-windows-386 && \
ln -s pulse-agent-windows-386.exe.sig /opt/pulse/bin/pulse-agent-windows-386.sig && \
ln -s pulse-agent-windows-386.exe.sshsig /opt/pulse/bin/pulse-agent-windows-386.sshsig && \
chown -R pulse:pulse /opt/pulse
# Arch-resolved /usr/local/bin/pulse-agent so the helm chart's agent workload
@@ -411,8 +418,14 @@ RUN chmod 755 /opt/pulse/scripts/*.sh /opt/pulse/scripts/*.ps1 && \
ln -sf /opt/pulse/bin/pulse-agent-linux-amd64 /usr/local/bin/pulse-agent; \
fi && \
ln -sf pulse-agent-windows-amd64.exe /opt/pulse/bin/pulse-agent-windows-amd64 && \
ln -sf pulse-agent-windows-amd64.exe.sig /opt/pulse/bin/pulse-agent-windows-amd64.sig && \
ln -sf pulse-agent-windows-amd64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-amd64.sshsig && \
ln -sf pulse-agent-windows-arm64.exe /opt/pulse/bin/pulse-agent-windows-arm64 && \
ln -sf pulse-agent-windows-arm64.exe.sig /opt/pulse/bin/pulse-agent-windows-arm64.sig && \
ln -sf pulse-agent-windows-arm64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-arm64.sshsig && \
ln -sf pulse-agent-windows-386.exe /opt/pulse/bin/pulse-agent-windows-386 && \
ln -sf pulse-agent-windows-386.exe.sig /opt/pulse/bin/pulse-agent-windows-386.sig && \
ln -sf pulse-agent-windows-386.exe.sshsig /opt/pulse/bin/pulse-agent-windows-386.sshsig && \
chown -R pulse:pulse /opt/pulse
# Unified Agent image assembled from the same immutable candidate payload.
@@ -987,6 +987,13 @@ artifact-selection behaviour.
against their exact `.exe` targets and then omit them, because the image
recreates those aliases deterministically and the immutable payload
manifest remains regular-file-only.
Each alias is a three-member delivery contract: the extensionless binary,
`.sig`, and `.sshsig` paths must resolve respectively to the signed `.exe`,
`.exe.sig`, and `.exe.sshsig` files. Server archives must create those
aliases only after signing the complete cross-platform payload, and both
source-built and exact-candidate images must recreate and qualify all three;
otherwise the download handler rejects the local Windows agent and falls
through to a release asset with the wrong filename identity.
Helm Pages convergence must promote the immutable chart artifact produced
and qualified by the exact create-release run. It must bind that artifact
to the activated source run, tag, commit, and activation marker, and must
+26
View File
@@ -206,6 +206,32 @@ func TestDownloadUnifiedAgent_LocalReleaseBinaryIncludesSignatureHeader(t *testi
assert.Equal(t, encodedTestSSHSignature("signed-local-agent-ssh"), w.Header().Get(sshSignatureHeaderName))
}
func TestDownloadUnifiedAgent_WindowsReleaseAliasIncludesSignatureHeaders(t *testing.T) {
router, tempDir := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.4.2"
router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "", 0, "", errors.New("GitHub fallback must not be used"))
binDir := filepath.Join(tempDir, "bin")
baseName := "pulse-agent-windows-amd64"
exePath := filepath.Join(binDir, baseName+".exe")
binContent := validTestUnifiedAgentBinary("windows-amd64")
require.NoError(t, os.WriteFile(exePath, binContent, 0755))
require.NoError(t, os.WriteFile(exePath+".sig", []byte("signed-windows-agent"), 0644))
require.NoError(t, os.WriteFile(exePath+".sshsig", []byte("signed-windows-agent-ssh"), 0644))
for _, suffix := range []string{"", ".sig", ".sshsig"} {
require.NoError(t, os.Symlink(baseName+".exe"+suffix, filepath.Join(binDir, baseName+suffix)))
}
req := httptest.NewRequest(http.MethodGet, "/download/pulse-agent?arch=windows-amd64", nil)
w := httptest.NewRecorder()
router.handleDownloadUnifiedAgent(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, string(binContent), w.Body.String())
assert.Equal(t, "signed-windows-agent", w.Header().Get(signatureHeaderName))
assert.Equal(t, encodedTestSSHSignature("signed-windows-agent-ssh"), w.Header().Get(sshSignatureHeaderName))
}
func TestDownloadUnifiedAgent_SkipsStaleLocalBinaryAndProxies(t *testing.T) {
router, tempDir := setupUnifiedAgentRouter(t)
router.serverVersion = "v6.0.0-rc.1"
+8 -8
View File
@@ -396,11 +396,9 @@ esac
EOF
chmod +x "$universal_dir/bin/pulse-agent"
# Add VERSION file
# Add VERSION file. Sign the completed universal payload only after every
# cross-platform agent has been staged below.
echo "$VERSION" > "$universal_dir/VERSION"
pulse_release_sign_directory_assets "$universal_dir/bin"
pulse_release_sign_directory_assets "$universal_dir/scripts"
pulse_release_sign_file "$universal_dir/VERSION"
# Package standalone unified agent binaries (all platforms)
# Linux
@@ -500,10 +498,12 @@ cp "$BUILD_DIR/pulse-agent-windows-amd64.exe" "$universal_dir/bin/"
cp "$BUILD_DIR/pulse-agent-windows-arm64.exe" "$universal_dir/bin/"
cp "$BUILD_DIR/pulse-agent-windows-386.exe" "$universal_dir/bin/"
# Create symlinks for Windows binaries without .exe extension (required for download endpoint)
ln -s pulse-agent-windows-amd64.exe "$universal_dir/bin/pulse-agent-windows-amd64"
ln -s pulse-agent-windows-arm64.exe "$universal_dir/bin/pulse-agent-windows-arm64"
ln -s pulse-agent-windows-386.exe "$universal_dir/bin/pulse-agent-windows-386"
# Sign all regular payload files, then create the extensionless Windows aliases
# as complete binary/signature triplets required by the download endpoint.
pulse_release_sign_directory_assets "$universal_dir/bin"
pulse_release_sign_directory_assets "$universal_dir/scripts"
pulse_release_sign_file "$universal_dir/VERSION"
pulse_release_link_windows_agent_aliases "$universal_dir/bin"
# Create universal tarball
cd "$universal_dir"
@@ -509,6 +509,9 @@ func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) {
`test "${actual_embedded_agent}" = "${expected_agent}"`,
`test "$(readlink /usr/local/bin/pulse-agent)" = "/opt/pulse/bin/pulse-agent-linux-amd64"`,
`test -x /usr/local/bin/pulse-agent`,
`for arch in amd64 arm64 386; do`,
`target="pulse-agent-windows-${arch}.exe${suffix}"`,
`test -s "${alias}"`,
`test ! -x "$sidecar"`,
} {
if !strings.Contains(qualifier, needle) {
@@ -517,6 +520,54 @@ func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) {
}
}
func TestWindowsAgentAliasesCarryDetachedSignatureSidecars(t *testing.T) {
commonPath := repoFile("scripts", "release_asset_common.sh")
tempDir := t.TempDir()
for _, arch := range []string{"amd64", "arm64", "386"} {
base := filepath.Join(tempDir, "pulse-agent-windows-"+arch+".exe")
for _, suffix := range []string{"", ".sig", ".sshsig"} {
if err := os.WriteFile(base+suffix, []byte("packet-"+arch+suffix), 0o644); err != nil {
t.Fatalf("write Windows packet fixture: %v", err)
}
}
}
cmd := exec.Command("bash", "-c", `source "$1"; pulse_release_link_windows_agent_aliases "$2"`, "windows-agent-alias-test", commonPath, tempDir)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("create Windows agent packet aliases: %v\n%s", err, output)
}
for _, arch := range []string{"amd64", "arm64", "386"} {
for _, suffix := range []string{"", ".sig", ".sshsig"} {
alias := filepath.Join(tempDir, "pulse-agent-windows-"+arch+suffix)
want := "pulse-agent-windows-" + arch + ".exe" + suffix
got, err := os.Readlink(alias)
if err != nil {
t.Fatalf("read Windows agent alias %s: %v", alias, err)
}
if got != want {
t.Fatalf("Windows agent alias %s targets %q, want %q", alias, got, want)
}
if info, err := os.Stat(alias); err != nil || info.Size() == 0 {
t.Fatalf("Windows agent alias %s does not resolve to a non-empty packet member: info=%v err=%v", alias, info, err)
}
}
}
assertFileContainsAll(t, repoFile("Dockerfile"),
`ln -s pulse-agent-windows-amd64.exe.sig /opt/pulse/bin/pulse-agent-windows-amd64.sig`,
`ln -s pulse-agent-windows-amd64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-amd64.sshsig`,
`ln -sf pulse-agent-windows-amd64.exe.sig /opt/pulse/bin/pulse-agent-windows-amd64.sig`,
`ln -sf pulse-agent-windows-amd64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-amd64.sshsig`,
)
assertFileContainsAll(t, repoFile("scripts", "build-release.sh"),
`pulse_release_sign_directory_assets "$universal_dir/bin"`,
`pulse_release_link_windows_agent_aliases "$universal_dir/bin"`,
)
assertFileContainsAll(t, repoFile("scripts", "prepare-release-container-context.sh"),
`for suffix in "" .sig .sshsig; do`,
`expected_target="pulse-agent-windows-${windows_arch}.exe${suffix}"`,
)
}
func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *testing.T) {
version := "6.3.0-rc.test"
releaseDir := t.TempDir()
@@ -548,6 +599,11 @@ func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *tes
"scripts/install.sh.sshsig": "shared-installer-sshsig",
"VERSION": version,
}
for _, windowsArch := range []string{"amd64", "arm64", "386"} {
name := "bin/pulse-agent-windows-" + windowsArch + ".exe"
files[name+".sig"] = "shared-windows-" + windowsArch + "-signature"
files[name+".sshsig"] = "shared-windows-" + windowsArch + "-ssh-signature"
}
for _, helperTarget := range []string{"linux-amd64", "linux-arm64", "linux-armv7", "linux-armv6", "linux-386"} {
name := "bin/pulse-agent-helper-" + helperTarget
files[name] = "shared-helper-" + helperTarget
@@ -576,11 +632,13 @@ func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *tes
}
}
for _, windowsArch := range []string{"amd64", "arm64", "386"} {
name := "bin/pulse-agent-windows-" + windowsArch
target := "pulse-agent-windows-" + windowsArch + ".exe"
header := &tar.Header{Name: name, Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: target}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatalf("write %s alias to %s archive: %v", name, arch, err)
for _, suffix := range []string{"", ".sig", ".sshsig"} {
name := "bin/pulse-agent-windows-" + windowsArch + suffix
target := "pulse-agent-windows-" + windowsArch + ".exe" + suffix
header := &tar.Header{Name: name, Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: target}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatalf("write %s alias to %s archive: %v", name, arch, err)
}
}
}
if err := tarWriter.Close(); err != nil {
@@ -614,9 +672,11 @@ func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *tes
t.Fatalf("prepared context retained duplicate universal payload: %v", err)
}
for _, windowsArch := range []string{"amd64", "arm64", "386"} {
aliasPath := filepath.Join(outputDir, "amd64", "bin", "pulse-agent-windows-"+windowsArch)
if _, err := os.Lstat(aliasPath); !os.IsNotExist(err) {
t.Fatalf("prepared context retained recreated Windows alias %s: %v", aliasPath, err)
for _, suffix := range []string{"", ".sig", ".sshsig"} {
aliasPath := filepath.Join(outputDir, "amd64", "bin", "pulse-agent-windows-"+windowsArch+suffix)
if _, err := os.Lstat(aliasPath); !os.IsNotExist(err) {
t.Fatalf("prepared context retained recreated Windows alias %s: %v", aliasPath, err)
}
}
}
+14 -12
View File
@@ -86,18 +86,20 @@ for arch in amd64 arm64; do
validate_archive_entries "${archive}"
tar --no-same-owner --no-same-permissions -xzf "${archive}" -C "${output_dir}/${arch}"
for windows_arch in amd64 arm64 386; do
alias_path="${output_dir}/${arch}/bin/pulse-agent-windows-${windows_arch}"
expected_target="pulse-agent-windows-${windows_arch}.exe"
if [[ ! -L "${alias_path}" ]] || \
[[ "$(readlink "${alias_path}")" != "${expected_target}" ]] || \
[[ ! -f "${output_dir}/${arch}/bin/${expected_target}" ]]; then
echo "Error: ${archive} has an invalid Windows agent alias ${alias_path}." >&2
exit 1
fi
# runtime_prebuilt recreates these aliases after copying the immutable
# .exe payloads. Keep the manifest-covered context symlink-free rather
# than weakening its canonical-file rule.
rm -f "${alias_path}"
for suffix in "" .sig .sshsig; do
alias_path="${output_dir}/${arch}/bin/pulse-agent-windows-${windows_arch}${suffix}"
expected_target="pulse-agent-windows-${windows_arch}.exe${suffix}"
if [[ ! -L "${alias_path}" ]] || \
[[ "$(readlink "${alias_path}")" != "${expected_target}" ]] || \
[[ ! -f "${output_dir}/${arch}/bin/${expected_target}" ]]; then
echo "Error: ${archive} has an invalid Windows agent alias ${alias_path}." >&2
exit 1
fi
# runtime_prebuilt recreates these aliases after copying the
# immutable .exe payload and sidecars. Keep the manifest-covered
# context symlink-free rather than weakening its canonical-file rule.
rm -f "${alias_path}"
done
done
for required in \
bin/pulse \
+15 -7
View File
@@ -124,6 +124,20 @@ pulse_release_sign_directory_assets() {
done < <(find "${dir}" -maxdepth 1 -type f ! -name '*.sig' ! -name '*.sshsig' -print0)
}
pulse_release_link_windows_agent_aliases() {
local dir="$1"
local target=""
local suffix=""
for target in windows-amd64 windows-arm64 windows-386; do
for suffix in "" .sig .sshsig; do
ln -sf \
"pulse-agent-${target}.exe${suffix}" \
"${dir}/pulse-agent-${target}${suffix}"
done
done
}
pulse_release_stage_server_archive() {
local archive_path="$1"
local staging_dir="$2"
@@ -171,13 +185,6 @@ pulse_release_stage_server_archive() {
dest="${staging_dir}/bin/pulse-agent-runner-${target}"
install -m 0755 "${src}" "${dest}"
done
(
cd "${staging_dir}/bin"
ln -sf pulse-agent-windows-amd64.exe pulse-agent-windows-amd64
ln -sf pulse-agent-windows-arm64.exe pulse-agent-windows-arm64
ln -sf pulse-agent-windows-386.exe pulse-agent-windows-386
)
install -m 0755 "${PULSE_SCRIPTS_DIR}/install-container-agent.sh" "${staging_dir}/scripts/install-container-agent.sh"
install -m 0755 "${PULSE_SCRIPTS_DIR}/install-docker.sh" "${staging_dir}/scripts/install-docker.sh"
install -m 0755 "${rendered_installers_dir}/install.sh" "${staging_dir}/scripts/install.sh"
@@ -189,6 +196,7 @@ pulse_release_stage_server_archive() {
pulse_release_sign_directory_assets "${staging_dir}/bin"
pulse_release_sign_directory_assets "${staging_dir}/scripts"
pulse_release_sign_file "${staging_dir}/VERSION"
pulse_release_link_windows_agent_aliases "${staging_dir}/bin"
mkdir -p "$(dirname "${archive_path}")"
(
+1 -1
View File
@@ -254,7 +254,7 @@ if [ "$SKIP_DOCKER" = false ]; then
# Validate all required binaries exist and are non-empty
info "Checking downloadable binaries in /opt/pulse/bin/..."
docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-agent-linux-amd64 pulse-agent-linux-arm64 pulse-agent-linux-armv7 pulse-agent-linux-armv6 pulse-agent-linux-386 pulse-agent-helper-linux-amd64 pulse-agent-helper-linux-arm64 pulse-agent-helper-linux-armv7 pulse-agent-helper-linux-armv6 pulse-agent-helper-linux-386 pulse-agent-runner-linux-amd64 pulse-agent-runner-linux-arm64 pulse-agent-runner-linux-armv7 pulse-agent-runner-linux-armv6 pulse-agent-runner-linux-386 pulse-agent-darwin-amd64 pulse-agent-darwin-arm64 pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 pulse-agent-windows-386.exe pulse-agent-windows-386 pulse-agent-freebsd-amd64 pulse-agent-freebsd-arm64"; for f in $required; do [ -e "$f" ] || { echo "missing binary $f" >&2; exit 1; }; [ -s "$f" ] || { echo "empty binary $f" >&2; exit 1; }; done; [ "$(readlink pulse-agent-windows-amd64)" = "pulse-agent-windows-amd64.exe" ] || { echo "unified agent windows amd64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-arm64)" = "pulse-agent-windows-arm64.exe" ] || { echo "unified agent windows arm64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-386)" = "pulse-agent-windows-386.exe" ] || { echo "unified agent windows 386 symlink broken" >&2; exit 1; }; echo "All binaries present"' || { error "Binary validation failed"; exit 1; }
docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-agent-linux-amd64 pulse-agent-linux-arm64 pulse-agent-linux-armv7 pulse-agent-linux-armv6 pulse-agent-linux-386 pulse-agent-helper-linux-amd64 pulse-agent-helper-linux-arm64 pulse-agent-helper-linux-armv7 pulse-agent-helper-linux-armv6 pulse-agent-helper-linux-386 pulse-agent-runner-linux-amd64 pulse-agent-runner-linux-arm64 pulse-agent-runner-linux-armv7 pulse-agent-runner-linux-armv6 pulse-agent-runner-linux-386 pulse-agent-darwin-amd64 pulse-agent-darwin-arm64 pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 pulse-agent-windows-386.exe pulse-agent-windows-386 pulse-agent-freebsd-amd64 pulse-agent-freebsd-arm64"; for f in $required; do [ -e "$f" ] || { echo "missing binary $f" >&2; exit 1; }; [ -s "$f" ] || { echo "empty binary $f" >&2; exit 1; }; done; for arch in amd64 arm64 386; do for suffix in "" .sig .sshsig; do alias="pulse-agent-windows-${arch}${suffix}"; target="pulse-agent-windows-${arch}.exe${suffix}"; [ -L "$alias" ] || { echo "missing Windows agent alias $alias" >&2; exit 1; }; [ "$(readlink "$alias")" = "$target" ] || { echo "Windows agent alias $alias does not target $target" >&2; exit 1; }; [ -s "$alias" ] || { echo "empty Windows agent alias $alias" >&2; exit 1; }; done; done; echo "All binaries present"' || { error "Binary validation failed"; exit 1; }
success "All downloadable binaries present"
# Validate the arch-resolved /usr/local/bin/pulse-agent symlink. The helm