From 548c65c1a3004b3526c88d81fcd60768e0ce9a70 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 21 Nov 2025 10:47:53 +0000 Subject: [PATCH] Harden release validation for host agent downloads (related to #735) --- install.sh | 88 ++++++++---- scripts/build-release.sh | 189 +++++++++++++++++-------- scripts/validate-release.sh | 270 +++++++++++++++++++++++++++++++++--- 3 files changed, 445 insertions(+), 102 deletions(-) diff --git a/install.sh b/install.sh index 61830c2..2edb174 100755 --- a/install.sh +++ b/install.sh @@ -2438,7 +2438,7 @@ download_pulse() { print_warn "Host agent binary not found in archive; skipping installation" fi - install_additional_agent_binaries "$LATEST_RELEASE" + install_additional_agent_binaries "$LATEST_RELEASE" "$TEMP_EXTRACT" # Install all agent scripts deploy_agent_scripts "$TEMP_EXTRACT" @@ -2487,7 +2487,7 @@ download_pulse() { ln -sf "$INSTALL_DIR/bin/pulse-docker-agent" /usr/local/bin/pulse-docker-agent fi - install_additional_agent_binaries "$LATEST_RELEASE" + install_additional_agent_binaries "$LATEST_RELEASE" "$TEMP_EXTRACT2" deploy_agent_scripts "$TEMP_EXTRACT2" @@ -2512,26 +2512,74 @@ download_pulse() { fi # End of SKIP_DOWNLOAD check } +copy_host_agent_binaries_from_dir() { + local source_dir="$1" + + if [[ -z "$source_dir" ]] || [[ ! -d "$source_dir/bin" ]]; then + return 1 + fi + + local copied=0 + shopt -s nullglob + for agent_file in "$source_dir"/bin/pulse-host-agent-*; do + [[ -e "$agent_file" ]] || continue + + local base + base=$(basename "$agent_file") + if [[ "$base" == "pulse-host-agent" ]]; then + continue + fi + + cp -a "$agent_file" "$INSTALL_DIR/bin/$base" + if [[ ! -L "$INSTALL_DIR/bin/$base" ]]; then + chmod +x "$INSTALL_DIR/bin/$base" + fi + chown -h pulse:pulse "$INSTALL_DIR/bin/$base" || true + copied=1 + done + shopt -u nullglob + + if [[ $copied -eq 0 ]]; then + return 1 + fi + return 0 +} + install_additional_agent_binaries() { local version="$1" + local source_dir="${2:-}" if [[ -z "$version" ]]; then return fi - local targets=("linux-amd64" "linux-arm64" "linux-armv7") - local docker_missing=0 - local host_missing=0 - for target in "${targets[@]}"; do + local docker_targets=("linux-amd64" "linux-arm64" "linux-armv7") + local host_targets=("linux-amd64" "linux-arm64" "linux-armv7" "linux-armv6" "linux-386" "darwin-amd64" "darwin-arm64" "windows-amd64" "windows-arm64" "windows-386") + + # Prefer locally available host agents from the extracted archive to avoid network reliance + copy_host_agent_binaries_from_dir "$source_dir" || true + + local docker_missing_targets=() + for target in "${docker_targets[@]}"; do if [[ ! -f "$INSTALL_DIR/bin/pulse-docker-agent-$target" ]]; then - docker_missing=1 - fi - if [[ ! -f "$INSTALL_DIR/bin/pulse-host-agent-$target" ]]; then - host_missing=1 + docker_missing_targets+=("$target") fi done - if [[ $docker_missing -eq 0 ]] && [[ $host_missing -eq 0 ]]; then + local host_missing_targets=() + for target in "${host_targets[@]}"; do + if [[ "$target" == windows-* ]]; then + if [[ ! -e "$INSTALL_DIR/bin/pulse-host-agent-$target" && ! -e "$INSTALL_DIR/bin/pulse-host-agent-$target.exe" ]]; then + host_missing_targets+=("$target") + fi + else + if [[ ! -e "$INSTALL_DIR/bin/pulse-host-agent-$target" ]]; then + host_missing_targets+=("$target") + fi + fi + done + + if [[ ${#docker_missing_targets[@]} -eq 0 ]] && [[ ${#host_missing_targets[@]} -eq 0 ]]; then return fi @@ -2582,20 +2630,10 @@ install_additional_agent_binaries() { fi done - # Install host agent binaries - for agent_file in "$temp_dir"/bin/pulse-host-agent-*; do - if [[ -f "$agent_file" ]]; then - local base - base=$(basename "$agent_file") - # Don't copy the wrapper script, only platform-specific binaries - if [[ "$base" == pulse-host-agent-linux-* ]] || [[ "$base" == pulse-host-agent-darwin-* ]] || [[ "$base" == pulse-host-agent-windows-* ]]; then - cp -f "$agent_file" "$INSTALL_DIR/bin/$base" - chmod +x "$INSTALL_DIR/bin/$base" - chown pulse:pulse "$INSTALL_DIR/bin/$base" - host_installed=1 - fi - fi - done + # Install host agent binaries (preserve symlinks for Windows targets) + if copy_host_agent_binaries_from_dir "$temp_dir"; then + host_installed=1 + fi if [[ $docker_installed -eq 1 ]]; then print_success "Additional Docker agent binaries installed" diff --git a/scripts/build-release.sh b/scripts/build-release.sh index ee4f5f0..9161110 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -34,7 +34,37 @@ rm -rf internal/api/frontend-modern mkdir -p internal/api/frontend-modern cp -r frontend-modern/dist internal/api/frontend-modern/ -# Build for different architectures +# Build host agents for every supported platform/architecture so download endpoints work offline +echo "Building host agents for all platforms..." +declare -A host_agent_builds=( + ["linux-amd64"]="GOOS=linux GOARCH=amd64" + ["linux-arm64"]="GOOS=linux GOARCH=arm64" + ["linux-armv7"]="GOOS=linux GOARCH=arm GOARM=7" + ["linux-armv6"]="GOOS=linux GOARCH=arm GOARM=6" + ["linux-386"]="GOOS=linux GOARCH=386" + ["darwin-amd64"]="GOOS=darwin GOARCH=amd64" + ["darwin-arm64"]="GOOS=darwin GOARCH=arm64" + ["windows-amd64"]="GOOS=windows GOARCH=amd64" + ["windows-arm64"]="GOOS=windows GOARCH=arm64" + ["windows-386"]="GOOS=windows GOARCH=386" +) +host_agent_order=(linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-386 darwin-amd64 darwin-arm64 windows-amd64 windows-arm64 windows-386) + +for target in "${host_agent_order[@]}"; do + build_env="${host_agent_builds[$target]}" + output_path="$BUILD_DIR/pulse-host-agent-$target" + if [[ "$target" == windows-* ]]; then + output_path="${output_path}.exe" + fi + + env $build_env go build \ + -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \ + -trimpath \ + -o "$output_path" \ + ./cmd/pulse-host-agent +done + +# Build for different architectures (server + docker agent + sensor proxy) declare -A builds=( ["linux-amd64"]="GOOS=linux GOARCH=amd64" ["linux-arm64"]="GOOS=linux GOARCH=arm64" @@ -42,11 +72,11 @@ declare -A builds=( ["linux-armv6"]="GOOS=linux GOARCH=arm GOARM=6" ["linux-386"]="GOOS=linux GOARCH=386" ) +build_order=(linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-386) -for build_name in "${!builds[@]}"; do +for build_name in "${build_order[@]}"; do echo "Building for $build_name..." - # Get build environment build_env="${builds[$build_name]}" build_time=$(date -u '+%Y-%m-%d_%H:%M:%S') @@ -66,34 +96,43 @@ for build_name in "${!builds[@]}"; do -o "$BUILD_DIR/pulse-docker-agent-$build_name" \ ./cmd/pulse-docker-agent - # Build host agent binary - env $build_env go build \ - -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \ - -trimpath \ - -o "$BUILD_DIR/pulse-host-agent-$build_name" \ - ./cmd/pulse-host-agent - # Build temperature proxy binary env $build_env go build \ -ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit}" \ -trimpath \ -o "$BUILD_DIR/pulse-sensor-proxy-$build_name" \ ./cmd/pulse-sensor-proxy - - # Create release archive with proper structure +done + +# Create platform-specific tarballs that include all host agent binaries for download endpoints +for build_name in "${build_order[@]}"; do + echo "Packaging release for $build_name..." + tar_name="pulse-v${VERSION}-${build_name}.tar.gz" - - # Create staging directory staging_dir="$BUILD_DIR/staging-$build_name" rm -rf "$staging_dir" mkdir -p "$staging_dir/bin" mkdir -p "$staging_dir/scripts" - - # Copy binaries and VERSION file + + # Copy architecture-specific runtime binaries cp "$BUILD_DIR/pulse-$build_name" "$staging_dir/bin/pulse" cp "$BUILD_DIR/pulse-docker-agent-$build_name" "$staging_dir/bin/pulse-docker-agent" cp "$BUILD_DIR/pulse-host-agent-$build_name" "$staging_dir/bin/pulse-host-agent" cp "$BUILD_DIR/pulse-sensor-proxy-$build_name" "$staging_dir/bin/pulse-sensor-proxy" + + # Copy host agent binaries for every supported platform/architecture + for target in "${host_agent_order[@]}"; do + src="$BUILD_DIR/pulse-host-agent-$target" + dest="$staging_dir/bin/pulse-host-agent-$target" + if [[ "$target" == windows-* ]]; then + src="${src}.exe" + dest="${dest}.exe" + fi + cp "$src" "$dest" + done + ( cd "$staging_dir/bin" && ln -sf pulse-host-agent-windows-amd64.exe pulse-host-agent-windows-amd64 && ln -sf pulse-host-agent-windows-arm64.exe pulse-host-agent-windows-arm64 && ln -sf pulse-host-agent-windows-386.exe pulse-host-agent-windows-386 ) + + # Copy scripts and VERSION metadata cp "scripts/install-docker-agent.sh" "$staging_dir/scripts/install-docker-agent.sh" cp "scripts/install-container-agent.sh" "$staging_dir/scripts/install-container-agent.sh" cp "scripts/install-host-agent.sh" "$staging_dir/scripts/install-host-agent.sh" @@ -104,15 +143,13 @@ for build_name in "${!builds[@]}"; do cp "scripts/install-docker.sh" "$staging_dir/scripts/install-docker.sh" chmod 755 "$staging_dir/scripts/"*.sh "$staging_dir/scripts/"*.ps1 echo "$VERSION" > "$staging_dir/VERSION" - + # Create tarball from staging directory cd "$staging_dir" tar -czf "../../$RELEASE_DIR/$tar_name" . cd ../.. - - # Cleanup staging + rm -rf "$staging_dir" - echo "Created $RELEASE_DIR/$tar_name" done @@ -124,7 +161,7 @@ mkdir -p "$universal_dir/bin" mkdir -p "$universal_dir/scripts" # Copy all binaries to bin/ directory to maintain consistent structure -for build_name in "${!builds[@]}"; do +for build_name in "${build_order[@]}"; do cp "$BUILD_DIR/pulse-$build_name" "$universal_dir/bin/pulse-${build_name}" cp "$BUILD_DIR/pulse-docker-agent-$build_name" "$universal_dir/bin/pulse-docker-agent-${build_name}" cp "$BUILD_DIR/pulse-host-agent-$build_name" "$universal_dir/bin/pulse-host-agent-${build_name}" @@ -237,43 +274,6 @@ chmod +x "$universal_dir/bin/pulse-host-agent" # Add VERSION file echo "$VERSION" > "$universal_dir/VERSION" -# Build host agent for macOS -echo "Building host agent for macOS amd64..." -env GOOS=darwin GOARCH=amd64 go build \ - -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \ - -trimpath \ - -o "$BUILD_DIR/pulse-host-agent-darwin-amd64" \ - ./cmd/pulse-host-agent - -echo "Building host agent for macOS arm64..." -env GOOS=darwin GOARCH=arm64 go build \ - -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \ - -trimpath \ - -o "$BUILD_DIR/pulse-host-agent-darwin-arm64" \ - ./cmd/pulse-host-agent - -# Build host agent for Windows -echo "Building host agent for Windows amd64..." -env GOOS=windows GOARCH=amd64 go build \ - -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \ - -trimpath \ - -o "$BUILD_DIR/pulse-host-agent-windows-amd64.exe" \ - ./cmd/pulse-host-agent - -echo "Building host agent for Windows arm64..." -env GOOS=windows GOARCH=arm64 go build \ - -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \ - -trimpath \ - -o "$BUILD_DIR/pulse-host-agent-windows-arm64.exe" \ - ./cmd/pulse-host-agent - -echo "Building host agent for Windows 386..." -env GOOS=windows GOARCH=386 go build \ - -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=v${VERSION}" \ - -trimpath \ - -o "$BUILD_DIR/pulse-host-agent-windows-386.exe" \ - ./cmd/pulse-host-agent - # Package standalone host agent binaries tar -czf "$RELEASE_DIR/pulse-host-agent-v${VERSION}-darwin-amd64.tar.gz" -C "$BUILD_DIR" pulse-host-agent-darwin-amd64 tar -czf "$RELEASE_DIR/pulse-host-agent-v${VERSION}-darwin-arm64.tar.gz" -C "$BUILD_DIR" pulse-host-agent-darwin-arm64 @@ -358,3 +358,76 @@ echo echo "Release build complete!" echo "Archives created in $RELEASE_DIR/" ls -lh $RELEASE_DIR/ + +# Create host-agent manifest (per tarball) for validation/debugging +manifest_path="$RELEASE_DIR/host-agent-manifest.json" +echo "Generating host-agent manifest at $manifest_path..." +python3 - <<'EOF' "$RELEASE_DIR" "$VERSION" "$manifest_path" +import json +import os +import sys +import tarfile + +release_dir = sys.argv[1] +version = sys.argv[2] +manifest_path = sys.argv[3] + +tar_arches = [ + "linux-amd64", + "linux-arm64", + "linux-armv7", + "linux-armv6", + "linux-386", +] + +host_agents = [ + "pulse-host-agent-linux-amd64", + "pulse-host-agent-linux-arm64", + "pulse-host-agent-linux-armv7", + "pulse-host-agent-linux-armv6", + "pulse-host-agent-linux-386", + "pulse-host-agent-darwin-amd64", + "pulse-host-agent-darwin-arm64", + "pulse-host-agent-windows-amd64.exe", + "pulse-host-agent-windows-arm64.exe", + "pulse-host-agent-windows-386.exe", + "pulse-host-agent-windows-amd64", + "pulse-host-agent-windows-arm64", + "pulse-host-agent-windows-386", +] + +manifest = { + "version": version, + "tarballs": {}, + "universal": [], +} + +def collect_agents(tar_path): + found = [] + try: + with tarfile.open(tar_path, "r:gz") as tf: + names = set(m.name for m in tf.getmembers() if (m.isfile() or m.issym())) + for agent in host_agents: + target = f"./bin/{agent}" + if target in names: + found.append(agent) + except Exception as exc: + print(f"Failed to read {tar_path}: {exc}", file=sys.stderr) + return sorted(found) + +# Platform tarballs +for arch in tar_arches: + tarball = os.path.join(release_dir, f"pulse-v{version}-{arch}.tar.gz") + if os.path.exists(tarball): + manifest["tarballs"][arch] = collect_agents(tarball) + +# Universal tarball +universal_tar = os.path.join(release_dir, f"pulse-v{version}.tar.gz") +if os.path.exists(universal_tar): + manifest["universal"] = collect_agents(universal_tar) + +with open(manifest_path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2) + +print(json.dumps(manifest, indent=2)) +EOF diff --git a/scripts/validate-release.sh b/scripts/validate-release.sh index 421514a..a393c95 100755 --- a/scripts/validate-release.sh +++ b/scripts/validate-release.sh @@ -21,6 +21,11 @@ error() { echo -e "${RED}[ERROR]${NC} $*" >&2 } +section() { + echo "" + echo -e "${BLUE}=== ${1} ===${NC}" +} + success() { echo -e "${GREEN}[✓]${NC} $*" } @@ -33,6 +38,37 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*" } +with_network_blocked() { + # Drop outbound traffic inside container by adding a reject route; avoids needing elevated host perms. + # Caller supplies: container name, command... + local container="$1" + shift + docker exec "$container" sh -c "ip route add blackhole 0.0.0.0/0 || true" && "$@" +} + +check_tar_entries_nonempty() { + local tarball="$1" + shift + for entry in "$@"; do + if ! tar -tzf "$tarball" "$entry" >/dev/null 2>&1; then + error "$(basename "$tarball") missing entry: $entry" + exit 1 + fi + # Examine type; skip size enforcement for symlinks + local type + type=$(tar -tvf "$tarball" "$entry" 2>/dev/null | awk 'NR==1 {print substr($0,1,1)}') + if [ "$type" = "l" ]; then + continue + fi + local size + size=$(tar -xOf "$tarball" "$entry" 2>/dev/null | wc -c | tr -d '[:space:]') + if [ -z "$size" ] || [ "$size" -le 0 ]; then + error "$(basename "$tarball") missing or empty entry: $entry" + exit 1 + fi + done +} + if [ $# -lt 1 ]; then error "Usage: $0 [image] [release-dir] [--skip-docker]" exit 1 @@ -73,7 +109,8 @@ fi # Create temp directory for extractions tmp_root=$(mktemp -d) -trap 'rm -rf "$tmp_root"' EXIT +smoke_container="" +trap 'rm -rf "$tmp_root"; if [ -n "$smoke_container" ]; then docker rm -f "$smoke_container" >/dev/null 2>&1 || true; fi' EXIT info "Validating Pulse $PULSE_TAG release artifacts" info "Image: $IMAGE" @@ -88,7 +125,7 @@ if [ "$SKIP_DOCKER" = false ]; then # Validate VERSION file in container info "Checking VERSION file in Docker image..." - docker run --rm --entrypoint /bin/sh -e EXPECTED_VERSION="$PULSE_VERSION" "$IMAGE" -c 'set -euo pipefail; actual=$(cat /VERSION | tr -d "\r\n"); [ "$actual" = "$EXPECTED_VERSION" ] || { echo "VERSION mismatch: expected=$EXPECTED_VERSION actual=$actual" >&2; exit 1; }' || { error "VERSION file mismatch in Docker image"; exit 1; } + docker run --rm --entrypoint /bin/sh -e EXPECTED_VERSION="$PULSE_VERSION" "$IMAGE" -c 'set -euo pipefail; for path in /VERSION /app/VERSION; do if [ -f "$path" ]; then actual=$(cat "$path" | tr -d "\r\n"); [ "$actual" = "$EXPECTED_VERSION" ] && exit 0 || { echo "VERSION mismatch at $path: expected=$EXPECTED_VERSION actual=$actual" >&2; exit 1; }; fi; done; echo "VERSION file not found in image" >&2; exit 1' || { error "VERSION file mismatch in Docker image"; exit 1; } success "VERSION file correct: $PULSE_VERSION" # Validate all required scripts exist and are executable @@ -120,6 +157,124 @@ if [ "$SKIP_DOCKER" = false ]; then docker run --rm --entrypoint /bin/sh -e EXPECTED_TAG="$PULSE_TAG" "$IMAGE" -c 'set -euo pipefail; grep -aF "$EXPECTED_TAG" /opt/pulse/bin/pulse-docker-agent-linux-amd64 >/dev/null' || { error "Docker agent version string not found"; exit 1; } success "Docker agent version embedded: $PULSE_TAG" + # Smoke test download endpoints from a running container + info "Running download endpoint smoke tests..." + HOST_PORT=8765 + SMOKE_CONTAINER="pulse-download-smoke-$$" + smoke_container="$SMOKE_CONTAINER" + + docker run -d --rm \ + --name "$SMOKE_CONTAINER" \ + -p "$HOST_PORT:7655" \ + -e PULSE_MOCK_MODE=true \ + -e PULSE_ALLOW_DOCKER_UPDATES=true \ + -e PULSE_AUTH_USER=admin \ + -e PULSE_AUTH_PASS=admin \ + "$IMAGE" >/dev/null + + for i in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:${HOST_PORT}/api/health" >/dev/null 2>&1; then + break + fi + sleep 2 + if [ "$i" -eq 30 ]; then + docker logs "$SMOKE_CONTAINER" || true + error "Pulse container did not become healthy for download smoke tests" + exit 1 + fi + done + + download_matrix=( + "linux amd64" + "linux arm64" + "linux armv7" + "linux armv6" + "linux 386" + "darwin amd64" + "darwin arm64" + "windows amd64" + "windows arm64" + "windows 386" + ) + + for entry in "${download_matrix[@]}"; do + set -- $entry + platform=$1 + arch=$2 + url="http://127.0.0.1:${HOST_PORT}/download/pulse-host-agent?platform=${platform}&arch=${arch}" + tmp_file=$(mktemp) + if ! curl -fsS -o "$tmp_file" "$url"; then + docker logs "$SMOKE_CONTAINER" || true + error "Download failed for $platform/$arch" + exit 1 + fi + if [ ! -s "$tmp_file" ]; then + error "Downloaded empty binary for $platform/$arch" + exit 1 + fi + rm -f "$tmp_file" + done + success "Download endpoints returned binaries for all platforms/architectures" + + checksum_url="http://127.0.0.1:${HOST_PORT}/download/pulse-host-agent.sha256?platform=linux&arch=amd64" + checksum_tmp=$(mktemp) + if curl -fsS -o "$checksum_tmp" "$checksum_url"; then + if ! grep -Eq '^[0-9a-f]{64}$' "$checksum_tmp"; then + error "Invalid checksum response from $checksum_url" + exit 1 + fi + success "Checksum endpoint responded with SHA256" + else + warn "Checksum endpoint unavailable (non-blocking): $checksum_url" + fi + rm -f "$checksum_tmp" + + docker rm -f "$SMOKE_CONTAINER" >/dev/null 2>&1 || true + smoke_container="" + + echo "" + + # Offline self-heal check: run with no outbound network and confirm download endpoint still serves binaries + section "Offline self-heal smoke test" + SMOKE_CONTAINER="pulse-offline-smoke-$$" + smoke_container="$SMOKE_CONTAINER" + docker run -d --rm \ + --name "$SMOKE_CONTAINER" \ + --network none \ + -e PULSE_MOCK_MODE=true \ + -e PULSE_ALLOW_DOCKER_UPDATES=true \ + -e PULSE_AUTH_USER=admin \ + -e PULSE_AUTH_PASS=admin \ + "$IMAGE" >/dev/null + + for i in $(seq 1 30); do + if docker exec "$SMOKE_CONTAINER" wget -qO- http://127.0.0.1:7655/api/health >/dev/null 2>&1; then + break + fi + sleep 2 + if [ "$i" -eq 30 ]; then + docker logs "$SMOKE_CONTAINER" || true + error "Pulse container did not become healthy for offline smoke tests" + exit 1 + fi + done + + offline_tmp=$(mktemp) + if ! docker exec "$SMOKE_CONTAINER" wget -qO- "http://127.0.0.1:7655/download/pulse-host-agent?platform=linux&arch=amd64" > "$offline_tmp"; then + docker logs "$SMOKE_CONTAINER" || true + error "Offline self-heal failed: download endpoint returned error with no outbound network" + exit 1 + fi + if [ ! -s "$offline_tmp" ]; then + error "Offline self-heal failed: downloaded binary is empty" + exit 1 + fi + rm -f "$offline_tmp" + success "Offline self-heal: download endpoint works without outbound network" + + docker rm -f "$SMOKE_CONTAINER" >/dev/null 2>&1 || true + smoke_container="" + echo "" else warn "=== Skipping Docker Image Validation (--skip-docker flag provided) ===" @@ -141,6 +296,7 @@ info "Checking required release assets..." required_assets=( "install.sh" "checksums.txt" + "host-agent-manifest.json" "pulse-v${PULSE_VERSION}.tar.gz" "pulse-v${PULSE_VERSION}-linux-amd64.tar.gz" "pulse-v${PULSE_VERSION}-linux-arm64.tar.gz" @@ -168,9 +324,92 @@ if [ $missing_count -gt 0 ]; then fi success "All ${#required_assets[@]} required release assets present" +# Validate host-agent manifest matches expected set +section "Validating host-agent manifest" +host_agent_manifest="host-agent-manifest.json" +python3 - "$host_agent_manifest" "$PULSE_VERSION" <<'EOF' || { error "Host-agent manifest validation failed"; exit 1; } +import json +import sys +import os + +manifest_path = sys.argv[1] +version = sys.argv[2] + +with open(manifest_path, "r", encoding="utf-8") as handle: + manifest = json.load(handle) + +expected_agents = { + "pulse-host-agent-linux-amd64", + "pulse-host-agent-linux-arm64", + "pulse-host-agent-linux-armv7", + "pulse-host-agent-linux-armv6", + "pulse-host-agent-linux-386", + "pulse-host-agent-darwin-amd64", + "pulse-host-agent-darwin-arm64", + "pulse-host-agent-windows-amd64.exe", + "pulse-host-agent-windows-arm64.exe", + "pulse-host-agent-windows-386.exe", + "pulse-host-agent-windows-amd64", + "pulse-host-agent-windows-arm64", + "pulse-host-agent-windows-386", +} + +def check_set(name, found): + missing = expected_agents - set(found) + extra = set(found) - expected_agents + if missing or extra: + msg = [] + if missing: + msg.append(f"{name} missing: {sorted(missing)}") + if extra: + msg.append(f"{name} unexpected: {sorted(extra)}") + print(" ; ".join(msg)) + return False + return True + +ok = True + +if manifest.get("version") != version: + print(f"Manifest version mismatch: expected {version}, got {manifest.get('version')}") + ok = False + +universal = manifest.get("universal", []) +if not check_set("universal", universal): + ok = False + +for arch in ["linux-amd64","linux-arm64","linux-armv7","linux-armv6","linux-386"]: + found = manifest.get("tarballs", {}).get(arch) + if found is None: + print(f"Missing tarball entry in manifest for {arch}") + ok = False + continue + if not check_set(arch, found): + ok = False + +if not ok: + sys.exit(1) +EOF +success "Host-agent manifest matches expected platform/arch matrix" + # Validate tarball contents -info "Validating tarball contents..." -for arch in linux-amd64 linux-arm64 linux-armv7; do +section "Validating tarball contents" +tar_arches=(linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-386) +host_agent_entries=( + ./bin/pulse-host-agent-linux-amd64 + ./bin/pulse-host-agent-linux-arm64 + ./bin/pulse-host-agent-linux-armv7 + ./bin/pulse-host-agent-linux-armv6 + ./bin/pulse-host-agent-linux-386 + ./bin/pulse-host-agent-darwin-amd64 + ./bin/pulse-host-agent-darwin-arm64 + ./bin/pulse-host-agent-windows-amd64.exe + ./bin/pulse-host-agent-windows-arm64.exe + ./bin/pulse-host-agent-windows-386.exe + ./bin/pulse-host-agent-windows-amd64 + ./bin/pulse-host-agent-windows-arm64 + ./bin/pulse-host-agent-windows-386 +) +for arch in "${tar_arches[@]}"; do tarball="pulse-v${PULSE_VERSION}-${arch}.tar.gz" # Check binaries (note: tarballs use ./ prefix) @@ -179,31 +418,24 @@ for arch in linux-amd64 linux-arm64 linux-armv7; do exit 1 fi + check_tar_entries_nonempty "$tarball" "${host_agent_entries[@]}" + # Check scripts tar -tzf "$tarball" ./scripts/install-docker-agent.sh ./scripts/install-container-agent.sh ./scripts/install-host-agent.sh ./scripts/install-host-agent.ps1 ./scripts/uninstall-host-agent.sh ./scripts/uninstall-host-agent.ps1 ./scripts/install-sensor-proxy.sh ./scripts/install-docker.sh >/dev/null 2>&1 || { error "$(basename $tarball) missing scripts"; exit 1; } # Check VERSION file tar -tzf "$tarball" ./VERSION >/dev/null 2>&1 || { error "$(basename $tarball) missing VERSION file"; exit 1; } done -success "Platform-specific tarballs contain all required files" +success "Platform-specific tarballs contain all required files (including cross-platform host agents)" # Validate universal tarball +section "Validating universal tarball" tar -tzf "pulse-v${PULSE_VERSION}.tar.gz" ./VERSION >/dev/null 2>&1 || { error "Universal tarball missing VERSION file"; exit 1; } -# Validate universal tarball contains Windows/macOS binaries for download endpoint -info "Validating universal tarball contains Windows/macOS binaries..." -for binary in \ - ./bin/pulse-host-agent-darwin-amd64 \ - ./bin/pulse-host-agent-darwin-arm64 \ - ./bin/pulse-host-agent-windows-amd64.exe \ - ./bin/pulse-host-agent-windows-arm64.exe \ - ./bin/pulse-host-agent-windows-386.exe \ - ./bin/pulse-host-agent-windows-amd64 \ - ./bin/pulse-host-agent-windows-arm64 \ - ./bin/pulse-host-agent-windows-386; do - tar -tzf "pulse-v${PULSE_VERSION}.tar.gz" "$binary" >/dev/null 2>&1 || { error "Universal tarball missing $binary"; exit 1; } -done -success "Universal tarball validated (includes Windows/macOS binaries)" +# Validate universal tarball contains all host agent binaries for download endpoint +info "Validating universal tarball contains all host agent binaries..." +check_tar_entries_nonempty "pulse-v${PULSE_VERSION}.tar.gz" "${host_agent_entries[@]}" +success "Universal tarball validated (includes cross-platform host agents)" # Validate macOS tarball tar -tzf "pulse-host-agent-v${PULSE_VERSION}-darwin-arm64.tar.gz" pulse-host-agent-darwin-arm64 >/dev/null 2>&1 || { error "macOS tarball validation failed"; exit 1; }