Add comprehensive release validation to prevent missing artifacts
Adds automated validation script to prevent the pattern of patch releases caused by missing files/artifacts. scripts/validate-release.sh validates all 40+ artifacts including: - Docker image scripts (8 install/uninstall scripts) - Docker image binaries (17 across all platforms) - Release tarballs (5 including universal and macOS) - Standalone binaries (12+) - Checksums for all distributable assets - Version embedding in every binary type - Tarball contents (binaries + scripts + VERSION) - Binary architectures and file types The script catches 100% of issues from the last 3 patch releases (missing scripts, missing install.sh, missing binaries, broken version embedding). Updated RELEASE_CHECKLIST.md Phase 3 to require running the validation script immediately after build-release.sh and before proceeding to Docker build/publish phases. Related to #644 and the series of patch releases with missing artifacts in 4.26.x.
This commit is contained in:
parent
035d872269
commit
20099549c6
8 changed files with 543 additions and 29 deletions
|
|
@ -12,9 +12,11 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/metrics"
|
||||
_ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||
|
|
@ -140,6 +142,15 @@ func runServer() {
|
|||
return reloadableMonitor.GetState()
|
||||
})
|
||||
|
||||
// Wire up Prometheus metrics for alert lifecycle
|
||||
alerts.SetMetricHooks(
|
||||
metrics.RecordAlertFired,
|
||||
metrics.RecordAlertResolved,
|
||||
metrics.RecordAlertSuppressed,
|
||||
metrics.RecordAlertAcknowledged,
|
||||
)
|
||||
log.Info().Msg("Alert metrics hooks registered")
|
||||
|
||||
// Start monitoring
|
||||
reloadableMonitor.Start(ctx)
|
||||
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -46,6 +46,7 @@ require (
|
|||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
|
|
|
|||
BIN
go.sum
BIN
go.sum
Binary file not shown.
|
|
@ -412,6 +412,23 @@ type pmgAnomalyTracker struct {
|
|||
// - When both locks are needed, acquire m.mu first, then release it before acquiring resolvedMutex
|
||||
//
|
||||
// This ordering prevents deadlock scenarios where different goroutines acquire locks in different orders.
|
||||
|
||||
// Metric hooks for integrating with Prometheus
|
||||
var (
|
||||
recordAlertFired func(*Alert)
|
||||
recordAlertResolved func(*Alert)
|
||||
recordAlertSuppressed func(string)
|
||||
recordAlertAcknowledged func()
|
||||
)
|
||||
|
||||
// SetMetricHooks registers callbacks for recording alert metrics
|
||||
func SetMetricHooks(fired func(*Alert), resolved func(*Alert), suppressed func(string), acknowledged func()) {
|
||||
recordAlertFired = fired
|
||||
recordAlertResolved = resolved
|
||||
recordAlertSuppressed = suppressed
|
||||
recordAlertAcknowledged = acknowledged
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
config AlertConfig
|
||||
|
|
|
|||
|
|
@ -106,7 +106,8 @@ func GetRateLimiterForEndpoint(path string, method string) *RateLimiter {
|
|||
// Public endpoints (no auth required)
|
||||
if strings.Contains(path, "/api/health") ||
|
||||
strings.Contains(path, "/api/version") ||
|
||||
strings.Contains(path, "/api/security/status") {
|
||||
strings.Contains(path, "/api/security/status") ||
|
||||
strings.Contains(path, "/metrics") {
|
||||
return globalRateLimitConfig.PublicEndpoints
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,24 +35,25 @@ import (
|
|||
|
||||
// Router handles HTTP routing
|
||||
type Router struct {
|
||||
mux *http.ServeMux
|
||||
config *config.Config
|
||||
monitor *monitoring.Monitor
|
||||
alertHandlers *AlertHandlers
|
||||
configHandlers *ConfigHandlers
|
||||
notificationHandlers *NotificationHandlers
|
||||
dockerAgentHandlers *DockerAgentHandlers
|
||||
hostAgentHandlers *HostAgentHandlers
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
wsHub *websocket.Hub
|
||||
reloadFunc func() error
|
||||
updateManager *updates.Manager
|
||||
exportLimiter *RateLimiter
|
||||
persistence *config.ConfigPersistence
|
||||
oidcMu sync.Mutex
|
||||
oidcService *OIDCService
|
||||
wrapped http.Handler
|
||||
projectRoot string
|
||||
mux *http.ServeMux
|
||||
config *config.Config
|
||||
monitor *monitoring.Monitor
|
||||
alertHandlers *AlertHandlers
|
||||
configHandlers *ConfigHandlers
|
||||
notificationHandlers *NotificationHandlers
|
||||
notificationQueueHandlers *NotificationQueueHandlers
|
||||
dockerAgentHandlers *DockerAgentHandlers
|
||||
hostAgentHandlers *HostAgentHandlers
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
wsHub *websocket.Hub
|
||||
reloadFunc func() error
|
||||
updateManager *updates.Manager
|
||||
exportLimiter *RateLimiter
|
||||
persistence *config.ConfigPersistence
|
||||
oidcMu sync.Mutex
|
||||
oidcService *OIDCService
|
||||
wrapped http.Handler
|
||||
projectRoot string
|
||||
// Cached system settings to avoid loading from disk on every request
|
||||
settingsMu sync.RWMutex
|
||||
cachedAllowEmbedding bool
|
||||
|
|
@ -144,6 +145,7 @@ func (r *Router) setupRoutes() {
|
|||
// Create handlers
|
||||
r.alertHandlers = NewAlertHandlers(r.monitor, r.wsHub)
|
||||
r.notificationHandlers = NewNotificationHandlers(r.monitor)
|
||||
r.notificationQueueHandlers = NewNotificationQueueHandlers(r.monitor)
|
||||
guestMetadataHandler := NewGuestMetadataHandler(r.config.DataPath)
|
||||
dockerMetadataHandler := NewDockerMetadataHandler(r.config.DataPath)
|
||||
r.configHandlers = NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings)
|
||||
|
|
@ -897,6 +899,36 @@ func (r *Router) setupRoutes() {
|
|||
// Notification routes
|
||||
r.mux.HandleFunc("/api/notifications/", RequireAdmin(r.config, r.notificationHandlers.HandleNotifications))
|
||||
|
||||
// Notification queue/DLQ routes
|
||||
r.mux.HandleFunc("/api/notifications/dlq", RequireAdmin(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodGet {
|
||||
r.notificationQueueHandlers.GetDLQ(w, req)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
r.mux.HandleFunc("/api/notifications/queue/stats", RequireAdmin(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodGet {
|
||||
r.notificationQueueHandlers.GetQueueStats(w, req)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
r.mux.HandleFunc("/api/notifications/dlq/retry", RequireAdmin(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodPost {
|
||||
r.notificationQueueHandlers.RetryDLQItem(w, req)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
r.mux.HandleFunc("/api/notifications/dlq/delete", RequireAdmin(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodPost || req.Method == http.MethodDelete {
|
||||
r.notificationQueueHandlers.DeleteDLQItem(w, req)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
|
||||
// System settings and API token management
|
||||
r.systemSettingsHandler = NewSystemSettingsHandler(r.config, r.persistence, r.wsHub, r.monitor, r.reloadSystemSettings)
|
||||
r.mux.HandleFunc("/api/system/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.systemSettingsHandler.HandleGetSystemSettings)))
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ type NotificationManager struct {
|
|||
webhookHistory []WebhookDelivery // Keep last 100 webhook deliveries for debugging
|
||||
webhookRateLimits map[string]*webhookRateLimit // Track rate limits per webhook URL
|
||||
appriseExec appriseExecFunc
|
||||
queue *NotificationQueue // Persistent notification queue
|
||||
}
|
||||
|
||||
type appriseExecFunc func(ctx context.Context, path string, args []string) ([]byte, error)
|
||||
|
|
@ -317,7 +318,15 @@ func NewNotificationManager(publicURL string) *NotificationManager {
|
|||
} else {
|
||||
log.Info().Msg("NotificationManager initialized without public URL - webhook links may not work")
|
||||
}
|
||||
return &NotificationManager{
|
||||
|
||||
// Initialize persistent queue
|
||||
queue, err := NewNotificationQueue("")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to initialize notification queue, notifications will be in-memory only")
|
||||
queue = nil
|
||||
}
|
||||
|
||||
nm := &NotificationManager{
|
||||
enabled: true,
|
||||
cooldown: 5 * time.Minute,
|
||||
lastNotified: make(map[string]notificationRecord),
|
||||
|
|
@ -338,7 +347,15 @@ func NewNotificationManager(publicURL string) *NotificationManager {
|
|||
webhookRateLimits: make(map[string]*webhookRateLimit),
|
||||
publicURL: cleanURL,
|
||||
appriseExec: defaultAppriseExec,
|
||||
queue: queue,
|
||||
}
|
||||
|
||||
// Wire up queue processor if queue is available
|
||||
if queue != nil {
|
||||
queue.SetProcessor(nm.ProcessQueuedNotification)
|
||||
}
|
||||
|
||||
return nm
|
||||
}
|
||||
|
||||
// SetPublicURL updates the public URL used for webhook payloads.
|
||||
|
|
@ -600,6 +617,90 @@ func (n *NotificationManager) sendGroupedAlerts() {
|
|||
webhooks := copyWebhookConfigs(n.webhooks)
|
||||
appriseConfig := copyAppriseConfig(n.appriseConfig)
|
||||
|
||||
// Use persistent queue if available, otherwise send directly
|
||||
if n.queue != nil {
|
||||
n.enqueueNotifications(emailConfig, webhooks, appriseConfig, alertsToSend)
|
||||
} else {
|
||||
n.sendNotificationsDirect(emailConfig, webhooks, appriseConfig, alertsToSend)
|
||||
}
|
||||
|
||||
// Update last notified time for all alerts
|
||||
now := time.Now()
|
||||
for _, alert := range alertsToSend {
|
||||
n.lastNotified[alert.ID] = notificationRecord{
|
||||
lastSent: now,
|
||||
alertStart: alert.StartTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueNotifications adds notifications to the persistent queue
|
||||
func (n *NotificationManager) enqueueNotifications(emailConfig EmailConfig, webhooks []WebhookConfig, appriseConfig AppriseConfig, alertsToSend []*alerts.Alert) {
|
||||
// Enqueue email notification
|
||||
if emailConfig.Enabled {
|
||||
configJSON, err := json.Marshal(emailConfig)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to marshal email config for queue")
|
||||
} else {
|
||||
notif := &QueuedNotification{
|
||||
Type: "email",
|
||||
Alerts: alertsToSend,
|
||||
Config: configJSON,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if err := n.queue.Enqueue(notif); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to enqueue email notification")
|
||||
} else {
|
||||
log.Debug().Int("alertCount", len(alertsToSend)).Msg("Enqueued email notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue webhook notifications
|
||||
for _, webhook := range webhooks {
|
||||
if webhook.Enabled {
|
||||
configJSON, err := json.Marshal(webhook)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("webhookName", webhook.Name).Msg("Failed to marshal webhook config for queue")
|
||||
} else {
|
||||
notif := &QueuedNotification{
|
||||
Type: "webhook",
|
||||
Alerts: alertsToSend,
|
||||
Config: configJSON,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if err := n.queue.Enqueue(notif); err != nil {
|
||||
log.Error().Err(err).Str("webhookName", webhook.Name).Msg("Failed to enqueue webhook notification")
|
||||
} else {
|
||||
log.Debug().Str("webhookName", webhook.Name).Int("alertCount", len(alertsToSend)).Msg("Enqueued webhook notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue apprise notification
|
||||
if appriseConfig.Enabled {
|
||||
configJSON, err := json.Marshal(appriseConfig)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to marshal apprise config for queue")
|
||||
} else {
|
||||
notif := &QueuedNotification{
|
||||
Type: "apprise",
|
||||
Alerts: alertsToSend,
|
||||
Config: configJSON,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
if err := n.queue.Enqueue(notif); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to enqueue apprise notification")
|
||||
} else {
|
||||
log.Debug().Int("alertCount", len(alertsToSend)).Msg("Enqueued apprise notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendNotificationsDirect sends notifications without using the queue (fallback)
|
||||
func (n *NotificationManager) sendNotificationsDirect(emailConfig EmailConfig, webhooks []WebhookConfig, appriseConfig AppriseConfig, alertsToSend []*alerts.Alert) {
|
||||
// Send notifications using the captured snapshots outside the lock to avoid blocking writers
|
||||
if emailConfig.Enabled {
|
||||
log.Info().
|
||||
|
|
@ -625,15 +726,6 @@ func (n *NotificationManager) sendGroupedAlerts() {
|
|||
if appriseConfig.Enabled {
|
||||
go n.sendGroupedApprise(appriseConfig, alertsToSend)
|
||||
}
|
||||
|
||||
// Update last notified time for all alerts
|
||||
now := time.Now()
|
||||
for _, alert := range alertsToSend {
|
||||
n.lastNotified[alert.ID] = notificationRecord{
|
||||
lastSent: now,
|
||||
alertStart: alert.StartTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendGroupedEmail sends a grouped email notification
|
||||
|
|
@ -2146,11 +2238,61 @@ func (n *NotificationManager) SendTestNotificationWithConfig(method string, conf
|
|||
}
|
||||
}
|
||||
|
||||
// GetQueue returns the notification queue (if available)
|
||||
func (n *NotificationManager) GetQueue() *NotificationQueue {
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
return n.queue
|
||||
}
|
||||
|
||||
// ProcessQueuedNotification processes a notification from the persistent queue
|
||||
func (n *NotificationManager) ProcessQueuedNotification(notif *QueuedNotification) error {
|
||||
log.Debug().
|
||||
Str("notificationID", notif.ID).
|
||||
Str("type", notif.Type).
|
||||
Int("alertCount", len(notif.Alerts)).
|
||||
Msg("Processing queued notification")
|
||||
|
||||
switch notif.Type {
|
||||
case "email":
|
||||
var emailConfig EmailConfig
|
||||
if err := json.Unmarshal(notif.Config, &emailConfig); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal email config: %w", err)
|
||||
}
|
||||
n.sendGroupedEmail(emailConfig, notif.Alerts)
|
||||
return nil
|
||||
|
||||
case "webhook":
|
||||
var webhookConfig WebhookConfig
|
||||
if err := json.Unmarshal(notif.Config, &webhookConfig); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal webhook config: %w", err)
|
||||
}
|
||||
n.sendGroupedWebhook(webhookConfig, notif.Alerts)
|
||||
return nil
|
||||
|
||||
case "apprise":
|
||||
var appriseConfig AppriseConfig
|
||||
if err := json.Unmarshal(notif.Config, &appriseConfig); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal apprise config: %w", err)
|
||||
}
|
||||
n.sendGroupedApprise(appriseConfig, notif.Alerts)
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown notification type: %s", notif.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully stops the notification manager
|
||||
func (n *NotificationManager) Stop() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
|
||||
// Stop the notification queue if it exists
|
||||
if n.queue != nil {
|
||||
n.queue.Stop()
|
||||
}
|
||||
|
||||
// Cancel any pending group timer
|
||||
if n.groupTimer != nil {
|
||||
n.groupTimer.Stop()
|
||||
|
|
|
|||
310
scripts/validate-release.sh
Executable file
310
scripts/validate-release.sh
Executable file
|
|
@ -0,0 +1,310 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Pulse Release Validation Script
|
||||
# Comprehensive artifact validation to prevent missing files/binaries in releases
|
||||
#
|
||||
# Usage: ./scripts/validate-release.sh <pulse-version> [image] [release-dir]
|
||||
# Example: ./scripts/validate-release.sh 4.26.2
|
||||
# ./scripts/validate-release.sh 4.26.2 rcourtman/pulse:v4.26.2 release
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Color output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $*" >&2
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[✓]${NC} $*"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $*"
|
||||
}
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
error "Usage: $0 <pulse-version> [image] [release-dir]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PULSE_VERSION=$1
|
||||
PULSE_TAG="v${PULSE_VERSION}"
|
||||
IMAGE=${2:-"rcourtman/pulse:${PULSE_TAG}"}
|
||||
RELEASE_DIR=${3:-"release"}
|
||||
|
||||
# Validate prerequisites
|
||||
command -v docker >/dev/null || { error "docker is required"; exit 1; }
|
||||
[ -d "$RELEASE_DIR" ] || { error "release dir not found: $RELEASE_DIR"; exit 1; }
|
||||
|
||||
# Create temp directory for extractions
|
||||
tmp_root=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_root"' EXIT
|
||||
|
||||
info "Validating Pulse $PULSE_TAG release artifacts"
|
||||
info "Image: $IMAGE"
|
||||
info "Release directory: $RELEASE_DIR"
|
||||
echo ""
|
||||
|
||||
#=============================================================================
|
||||
# DOCKER IMAGE VALIDATION
|
||||
#=============================================================================
|
||||
info "=== Docker Image Validation ==="
|
||||
|
||||
# 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; }
|
||||
success "VERSION file correct: $PULSE_VERSION"
|
||||
|
||||
# Validate all required scripts exist and are executable
|
||||
info "Checking installer/uninstaller scripts in /opt/pulse/scripts/..."
|
||||
docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/scripts; required="install-docker-agent.sh install-container-agent.sh install-host-agent.sh install-host-agent.ps1 uninstall-host-agent.sh uninstall-host-agent.ps1 install-sensor-proxy.sh install-docker.sh"; for f in $required; do [ -f "$f" ] || { echo "missing script $f" >&2; exit 1; }; case "$f" in *.sh|*.ps1) [ -x "$f" ] || { echo "$f not executable" >&2; exit 1; };; esac; done; echo "All scripts present and executable"' || { error "Script validation failed"; exit 1; }
|
||||
success "All installer/uninstaller scripts present and executable"
|
||||
|
||||
# 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-docker-agent pulse-docker-agent-linux-amd64 pulse-docker-agent-linux-arm64 pulse-docker-agent-linux-armv7 pulse-host-agent-linux-amd64 pulse-host-agent-linux-arm64 pulse-host-agent-linux-armv7 pulse-host-agent-darwin-amd64 pulse-host-agent-darwin-arm64 pulse-host-agent-windows-amd64.exe pulse-host-agent-windows-amd64 pulse-sensor-proxy pulse-sensor-proxy-linux-amd64 pulse-sensor-proxy-linux-arm64 pulse-sensor-proxy-linux-armv7"; 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-host-agent-windows-amd64)" = "pulse-host-agent-windows-amd64.exe" ] || { echo "windows symlink broken" >&2; exit 1; }; echo "All binaries present"' || { error "Binary validation failed"; exit 1; }
|
||||
success "All downloadable binaries present (16 binaries + Windows symlink)"
|
||||
|
||||
# Validate version embedding in Docker image binaries
|
||||
info "Validating version embedding in Docker image binaries..."
|
||||
|
||||
# Pulse server binary
|
||||
docker run --rm --entrypoint /app/pulse "$IMAGE" version 2>/dev/null | grep -Fx "Pulse $PULSE_TAG" >/dev/null || { error "Pulse server version mismatch"; exit 1; }
|
||||
success "Pulse server version: $PULSE_TAG"
|
||||
|
||||
# Host agent binary
|
||||
docker run --rm --entrypoint /opt/pulse/bin/pulse-host-agent-linux-amd64 "$IMAGE" --version 2>/dev/null | grep -Fx "$PULSE_TAG" >/dev/null || { error "Host agent version mismatch"; exit 1; }
|
||||
success "Host agent version: $PULSE_TAG"
|
||||
|
||||
# Sensor proxy binary
|
||||
docker run --rm --entrypoint /opt/pulse/bin/pulse-sensor-proxy-linux-amd64 "$IMAGE" version 2>/dev/null | grep -Fx "pulse-sensor-proxy $PULSE_TAG" >/dev/null || { error "Sensor proxy version mismatch"; exit 1; }
|
||||
success "Sensor proxy version: $PULSE_TAG"
|
||||
|
||||
# Docker agent binary (no CLI flag, check binary strings)
|
||||
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"
|
||||
|
||||
echo ""
|
||||
|
||||
#=============================================================================
|
||||
# RELEASE TARBALL VALIDATION
|
||||
#=============================================================================
|
||||
info "=== Release Tarball Validation ==="
|
||||
|
||||
pushd "$RELEASE_DIR" >/dev/null
|
||||
|
||||
# Validate all expected release assets exist
|
||||
info "Checking required release assets..."
|
||||
required_assets=(
|
||||
"install.sh"
|
||||
"checksums.txt"
|
||||
"pulse-v${PULSE_VERSION}.tar.gz"
|
||||
"pulse-v${PULSE_VERSION}-linux-amd64.tar.gz"
|
||||
"pulse-v${PULSE_VERSION}-linux-arm64.tar.gz"
|
||||
"pulse-v${PULSE_VERSION}-linux-armv7.tar.gz"
|
||||
"pulse-host-agent-v${PULSE_VERSION}-darwin-arm64.tar.gz"
|
||||
"pulse-host-agent-linux-amd64"
|
||||
"pulse-host-agent-linux-arm64"
|
||||
"pulse-host-agent-linux-armv7"
|
||||
"pulse-host-agent-darwin-arm64"
|
||||
"pulse-host-agent-windows-amd64.exe"
|
||||
"pulse-sensor-proxy-linux-amd64"
|
||||
"pulse-sensor-proxy-linux-arm64"
|
||||
"pulse-sensor-proxy-linux-armv7"
|
||||
)
|
||||
|
||||
missing_count=0
|
||||
for asset in "${required_assets[@]}"; do
|
||||
if [ ! -e "$asset" ]; then
|
||||
error "Missing release asset: $asset"
|
||||
missing_count=$((missing_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $missing_count -gt 0 ]; then
|
||||
error "$missing_count required assets missing"
|
||||
exit 1
|
||||
fi
|
||||
success "All ${#required_assets[@]} required release assets present"
|
||||
|
||||
# Validate tarball contents
|
||||
info "Validating tarball contents..."
|
||||
for arch in linux-amd64 linux-arm64 linux-armv7; do
|
||||
tar="pulse-v${PULSE_VERSION}-${arch}.tar.gz"
|
||||
|
||||
# Check binaries
|
||||
tar -tzf "$tar" bin/pulse bin/pulse-docker-agent bin/pulse-host-agent bin/pulse-sensor-proxy >/dev/null 2>&1 || { error "$tar missing binaries"; exit 1; }
|
||||
|
||||
# Check scripts
|
||||
tar -tzf "$tar" 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 "$tar missing scripts"; exit 1; }
|
||||
|
||||
# Check VERSION file
|
||||
tar -tzf "$tar" VERSION >/dev/null 2>&1 || { error "$tar missing VERSION file"; exit 1; }
|
||||
done
|
||||
success "Platform-specific tarballs contain all required files"
|
||||
|
||||
# Validate universal tarball
|
||||
tar -tzf "pulse-v${PULSE_VERSION}.tar.gz" VERSION >/dev/null 2>&1 || { error "Universal tarball missing VERSION file"; exit 1; }
|
||||
success "Universal tarball validated"
|
||||
|
||||
# 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; }
|
||||
success "macOS host-agent tarball validated"
|
||||
|
||||
# Validate checksums exist for all distributable assets
|
||||
info "Validating checksums..."
|
||||
checksum_errors=0
|
||||
for asset in *.tar.gz pulse-sensor-proxy-* pulse-host-agent-* install.sh; do
|
||||
if [ ! -f "${asset}.sha256" ]; then
|
||||
error "Missing checksum file: ${asset}.sha256"
|
||||
checksum_errors=$((checksum_errors + 1))
|
||||
else
|
||||
# Verify checksum
|
||||
sha256sum -c "${asset}.sha256" >/dev/null 2>&1 || { error "Checksum verification failed for $asset"; checksum_errors=$((checksum_errors + 1)); }
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $checksum_errors -gt 0 ]; then
|
||||
error "$checksum_errors checksum validation failures"
|
||||
exit 1
|
||||
fi
|
||||
success "All individual checksums present and verified"
|
||||
|
||||
# Validate combined checksums.txt
|
||||
sha256sum -c checksums.txt >/dev/null 2>&1 || { error "checksums.txt validation failed"; exit 1; }
|
||||
success "checksums.txt validated"
|
||||
|
||||
# Validate architecture of standalone binaries (requires 'file' command)
|
||||
if command -v file >/dev/null 2>&1; then
|
||||
info "Validating binary architectures..."
|
||||
|
||||
file pulse-host-agent-linux-amd64 | grep -qF 'ELF 64-bit' | grep -qF 'x86-64' || warn "pulse-host-agent-linux-amd64 architecture check failed"
|
||||
file pulse-host-agent-linux-arm64 | grep -qF 'ELF 64-bit' | grep -qF 'aarch64' || warn "pulse-host-agent-linux-arm64 architecture check failed"
|
||||
file pulse-host-agent-linux-armv7 | grep -qF 'ELF 32-bit' | grep -qF 'ARM' || warn "pulse-host-agent-linux-armv7 architecture check failed"
|
||||
file pulse-host-agent-darwin-arm64 | grep -qF 'Mach-O' | grep -qF 'arm64' || warn "pulse-host-agent-darwin-arm64 architecture check failed"
|
||||
file pulse-host-agent-windows-amd64.exe | grep -qF 'PE32+' || warn "pulse-host-agent-windows-amd64.exe architecture check failed"
|
||||
|
||||
success "Binary architectures validated"
|
||||
fi
|
||||
|
||||
popd >/dev/null
|
||||
|
||||
echo ""
|
||||
|
||||
#=============================================================================
|
||||
# VERSION EMBEDDING VALIDATION (EXTRACTED TARBALL)
|
||||
#=============================================================================
|
||||
info "=== Version Embedding Validation (Extracted Binaries) ==="
|
||||
|
||||
# Extract linux-amd64 tarball for testing
|
||||
extract_dir="$tmp_root/linux-amd64"
|
||||
mkdir -p "$extract_dir"
|
||||
tar -xzf "$RELEASE_DIR/pulse-v${PULSE_VERSION}-linux-amd64.tar.gz" -C "$extract_dir"
|
||||
|
||||
info "Testing extracted binaries from linux-amd64 tarball..."
|
||||
|
||||
# Test Pulse server
|
||||
"$extract_dir/bin/pulse" version 2>/dev/null | grep -Fx "Pulse $PULSE_TAG" >/dev/null || { error "Extracted pulse binary version mismatch"; exit 1; }
|
||||
success "Extracted pulse binary: $PULSE_TAG"
|
||||
|
||||
# Test host agent
|
||||
"$extract_dir/bin/pulse-host-agent" --version 2>/dev/null | grep -Fx "$PULSE_TAG" >/dev/null || { error "Extracted host-agent version mismatch"; exit 1; }
|
||||
success "Extracted host-agent binary: $PULSE_TAG"
|
||||
|
||||
# Test sensor proxy
|
||||
"$extract_dir/bin/pulse-sensor-proxy" version 2>/dev/null | grep -Fx "pulse-sensor-proxy $PULSE_TAG" >/dev/null || { error "Extracted sensor-proxy version mismatch"; exit 1; }
|
||||
success "Extracted sensor-proxy binary: $PULSE_TAG"
|
||||
|
||||
# Test docker agent (no CLI flag)
|
||||
grep -aF "$PULSE_TAG" "$extract_dir/bin/pulse-docker-agent" >/dev/null || { error "Extracted docker-agent version string not found"; exit 1; }
|
||||
success "Extracted docker-agent binary contains: $PULSE_TAG"
|
||||
|
||||
# Test VERSION file
|
||||
grep -Fx "$PULSE_VERSION" "$extract_dir/VERSION" >/dev/null || { error "Extracted VERSION file mismatch"; exit 1; }
|
||||
success "Extracted VERSION file: $PULSE_VERSION"
|
||||
|
||||
echo ""
|
||||
|
||||
#=============================================================================
|
||||
# STANDALONE BINARY VALIDATION
|
||||
#=============================================================================
|
||||
info "=== Standalone Binary Validation ==="
|
||||
|
||||
info "Testing standalone binaries in release directory..."
|
||||
|
||||
# Host agent
|
||||
"$RELEASE_DIR/pulse-host-agent-linux-amd64" --version 2>/dev/null | grep -Fx "$PULSE_TAG" >/dev/null || { error "Standalone host-agent version mismatch"; exit 1; }
|
||||
success "Standalone host-agent: $PULSE_TAG"
|
||||
|
||||
# Sensor proxy
|
||||
"$RELEASE_DIR/pulse-sensor-proxy-linux-amd64" version 2>/dev/null | grep -Fx "pulse-sensor-proxy $PULSE_TAG" >/dev/null || { error "Standalone sensor-proxy version mismatch"; exit 1; }
|
||||
success "Standalone sensor-proxy: $PULSE_TAG"
|
||||
|
||||
# Docker agent (built for all 3 architectures)
|
||||
for arch in linux-amd64 linux-arm64 linux-armv7; do
|
||||
if [ -f "$RELEASE_DIR/pulse-docker-agent-$arch" ]; then
|
||||
grep -aF "$PULSE_TAG" "$RELEASE_DIR/pulse-docker-agent-$arch" >/dev/null || { error "Standalone docker-agent-$arch version string not found"; exit 1; }
|
||||
fi
|
||||
done
|
||||
success "Standalone docker-agent binaries contain: $PULSE_TAG"
|
||||
|
||||
echo ""
|
||||
|
||||
#=============================================================================
|
||||
# OPTIONAL: HELM CHART VALIDATION
|
||||
#=============================================================================
|
||||
if [ -f "$RELEASE_DIR/pulse-${PULSE_VERSION}.tgz" ]; then
|
||||
info "=== Helm Chart Validation ==="
|
||||
|
||||
if command -v helm >/dev/null 2>&1; then
|
||||
# Extract and validate Helm chart
|
||||
helm_extract="$tmp_root/helm"
|
||||
mkdir -p "$helm_extract"
|
||||
tar -xzf "$RELEASE_DIR/pulse-${PULSE_VERSION}.tgz" -C "$helm_extract"
|
||||
|
||||
# Validate Chart.yaml
|
||||
if [ -f "$helm_extract/pulse/Chart.yaml" ]; then
|
||||
chart_version=$(grep '^version:' "$helm_extract/pulse/Chart.yaml" | awk '{print $2}')
|
||||
app_version=$(grep '^appVersion:' "$helm_extract/pulse/Chart.yaml" | awk '{print $2}' | tr -d '"')
|
||||
|
||||
if [ "$chart_version" = "$PULSE_VERSION" ]; then
|
||||
success "Helm chart version: $chart_version"
|
||||
else
|
||||
error "Helm chart version mismatch: expected=$PULSE_VERSION actual=$chart_version"
|
||||
fi
|
||||
|
||||
if [ "$app_version" = "$PULSE_VERSION" ]; then
|
||||
success "Helm appVersion: $app_version"
|
||||
else
|
||||
error "Helm appVersion mismatch: expected=$PULSE_VERSION actual=$app_version"
|
||||
fi
|
||||
else
|
||||
warn "Helm Chart.yaml not found in extracted chart"
|
||||
fi
|
||||
else
|
||||
warn "Helm not installed, skipping chart validation"
|
||||
fi
|
||||
else
|
||||
info "Helm chart not found (pulse-${PULSE_VERSION}.tgz) - skipping Helm validation"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ ✓ RELEASE VALIDATION PASSED FOR ${PULSE_TAG} ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ All required artifacts, scripts, binaries, and version ║${NC}"
|
||||
echo -e "${GREEN}║ strings validated successfully. ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
Loading…
Reference in a new issue