From c104ceb19e1a83887eb21b6e635c89a461b34cc6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 27 Nov 2025 20:20:55 +0000 Subject: [PATCH] feat: add self-update capability to standalone pulse-host-agent The standalone pulse-host-agent was missing self-update functionality that existed in pulse-docker-agent and the unified pulse-agent. Changes: - Add agentupdate integration to pulse-host-agent - Add --no-auto-update flag and PULSE_NO_AUTO_UPDATE env var - Update Windows service to use errgroup pattern with auto-updater - Move version from internal/hostagent to main package for ldflags Related to #737 --- cmd/pulse-host-agent/main.go | 92 ++++++++++++++++++------- cmd/pulse-host-agent/service_stub.go | 3 +- cmd/pulse-host-agent/service_windows.go | 76 +++++++++++++------- 3 files changed, 123 insertions(+), 48 deletions(-) diff --git a/cmd/pulse-host-agent/main.go b/cmd/pulse-host-agent/main.go index afdfa11..e1fe2d6 100644 --- a/cmd/pulse-host-agent/main.go +++ b/cmd/pulse-host-agent/main.go @@ -10,11 +10,24 @@ import ( "syscall" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/agentupdate" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rs/zerolog" + "golang.org/x/sync/errgroup" ) +var ( + // Version is the semantic version of the agent, set at build time via ldflags + Version = "dev" +) + +// Config holds the configuration for the standalone host agent +type Config struct { + HostConfig hostagent.Config + DisableAutoUpdate bool +} + type multiValue []string func (m *multiValue) String() string { @@ -28,11 +41,12 @@ func (m *multiValue) Set(value string) error { func main() { cfg := loadConfig() + hostCfg := cfg.HostConfig - zerolog.SetGlobalLevel(cfg.LogLevel) + zerolog.SetGlobalLevel(hostCfg.LogLevel) - logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger() - cfg.Logger = &logger + logger := zerolog.New(os.Stdout).Level(hostCfg.LogLevel).With().Timestamp().Logger() + hostCfg.Logger = &logger // Check if we should run as a Windows service if err := runAsWindowsService(cfg, logger); err != nil { @@ -41,28 +55,55 @@ func main() { // If runAsWindowsService returns nil without error, we're not running as a service // or we're on a non-Windows platform, so run normally - agent, err := hostagent.New(cfg) - if err != nil { - logger.Fatal().Err(err).Msg("failed to initialise host agent") - } ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + g, ctx := errgroup.WithContext(ctx) + logger.Info(). - Str("pulse_url", cfg.PulseURL). - Str("agent_id", cfg.AgentID). - Dur("interval", cfg.Interval). + Str("version", Version). + Str("pulse_url", hostCfg.PulseURL). + Str("agent_id", hostCfg.AgentID). + Dur("interval", hostCfg.Interval). + Bool("auto_update", !cfg.DisableAutoUpdate). Msg("Starting Pulse host agent") - if err := agent.Run(ctx); err != nil && err != context.Canceled { + // Start Auto-Updater + updater := agentupdate.New(agentupdate.Config{ + PulseURL: hostCfg.PulseURL, + APIToken: hostCfg.APIToken, + AgentName: "pulse-host-agent", + CurrentVersion: Version, + CheckInterval: 1 * time.Hour, + InsecureSkipVerify: hostCfg.InsecureSkipVerify, + Logger: &logger, + Disabled: cfg.DisableAutoUpdate, + }) + + g.Go(func() error { + updater.RunLoop(ctx) + return nil + }) + + // Start the host agent + agent, err := hostagent.New(hostCfg) + if err != nil { + logger.Fatal().Err(err).Msg("failed to initialise host agent") + } + + g.Go(func() error { + return agent.Run(ctx) + }) + + if err := g.Wait(); err != nil && err != context.Canceled { logger.Fatal().Err(err).Msg("host agent terminated with error") } logger.Info().Msg("Host agent stopped") } -func loadConfig() hostagent.Config { +func loadConfig() Config { envURL := utils.GetenvTrim("PULSE_URL") envToken := utils.GetenvTrim("PULSE_TOKEN") envInterval := utils.GetenvTrim("PULSE_INTERVAL") @@ -72,6 +113,7 @@ func loadConfig() hostagent.Config { envTags := utils.GetenvTrim("PULSE_TAGS") envRunOnce := utils.GetenvTrim("PULSE_ONCE") envLogLevel := utils.GetenvTrim("LOG_LEVEL") + envNoAutoUpdate := utils.GetenvTrim("PULSE_NO_AUTO_UPDATE") defaultInterval := 30 * time.Second if envInterval != "" { @@ -87,6 +129,7 @@ func loadConfig() hostagent.Config { agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier") insecureFlag := flag.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS certificate verification") runOnceFlag := flag.Bool("once", utils.ParseBool(envRunOnce), "Collect and send a single report, then exit") + noAutoUpdateFlag := flag.Bool("no-auto-update", utils.ParseBool(envNoAutoUpdate), "Disable automatic updates") showVersion := flag.Bool("version", false, "Print the agent version and exit") logLevelFlag := flag.String("log-level", defaultLogLevel(envLogLevel), "Log level: debug, info, warn, error") @@ -96,7 +139,7 @@ func loadConfig() hostagent.Config { flag.Parse() if *showVersion { - fmt.Println(hostagent.Version) + fmt.Println(Version) os.Exit(0) } @@ -124,16 +167,19 @@ func loadConfig() hostagent.Config { tags := gatherTags(envTags, tagFlags) - return hostagent.Config{ - PulseURL: pulseURL, - APIToken: token, - Interval: interval, - HostnameOverride: strings.TrimSpace(*hostnameFlag), - AgentID: strings.TrimSpace(*agentIDFlag), - Tags: tags, - InsecureSkipVerify: *insecureFlag, - RunOnce: *runOnceFlag, - LogLevel: logLevel, + return Config{ + HostConfig: hostagent.Config{ + PulseURL: pulseURL, + APIToken: token, + Interval: interval, + HostnameOverride: strings.TrimSpace(*hostnameFlag), + AgentID: strings.TrimSpace(*agentIDFlag), + Tags: tags, + InsecureSkipVerify: *insecureFlag, + RunOnce: *runOnceFlag, + LogLevel: logLevel, + }, + DisableAutoUpdate: *noAutoUpdateFlag, } } diff --git a/cmd/pulse-host-agent/service_stub.go b/cmd/pulse-host-agent/service_stub.go index 19118f7..375b739 100644 --- a/cmd/pulse-host-agent/service_stub.go +++ b/cmd/pulse-host-agent/service_stub.go @@ -3,11 +3,10 @@ package main import ( - "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "github.com/rs/zerolog" ) // runAsWindowsService is a no-op on non-Windows platforms -func runAsWindowsService(_ hostagent.Config, _ zerolog.Logger) error { +func runAsWindowsService(_ Config, _ zerolog.Logger) error { return nil } diff --git a/cmd/pulse-host-agent/service_windows.go b/cmd/pulse-host-agent/service_windows.go index f0a2054..965bdd4 100644 --- a/cmd/pulse-host-agent/service_windows.go +++ b/cmd/pulse-host-agent/service_windows.go @@ -7,14 +7,16 @@ import ( "fmt" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/agentupdate" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "github.com/rs/zerolog" + "golang.org/x/sync/errgroup" "golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc/eventlog" ) type windowsService struct { - cfg hostagent.Config + cfg Config logger zerolog.Logger eventLog *eventlog.Log } @@ -29,35 +31,63 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha ws.eventLog.Info(1, "Pulse Host Agent service starting") } - agent, err := hostagent.New(ws.cfg) - if err != nil { - ws.logger.Error().Err(err).Msg("Failed to create host agent") - changes <- svc.Status{State: svc.Stopped} - return true, 1 - } + hostCfg := ws.cfg.HostConfig + hostCfg.Logger = &ws.logger ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Start the agent in a goroutine - errChan := make(chan error, 1) - go func() { - ws.logger.Info(). - Str("pulse_url", ws.cfg.PulseURL). - Str("agent_id", ws.cfg.AgentID). - Dur("interval", ws.cfg.Interval). - Msg("Starting Pulse host agent as Windows service") + g, ctx := errgroup.WithContext(ctx) - if err := agent.Run(ctx); err != nil && err != context.Canceled { - errChan <- err + // Start Auto-Updater + updater := agentupdate.New(agentupdate.Config{ + PulseURL: hostCfg.PulseURL, + APIToken: hostCfg.APIToken, + AgentName: "pulse-host-agent", + CurrentVersion: Version, + CheckInterval: 1 * time.Hour, + InsecureSkipVerify: hostCfg.InsecureSkipVerify, + Logger: &ws.logger, + Disabled: ws.cfg.DisableAutoUpdate, + }) + + g.Go(func() error { + updater.RunLoop(ctx) + return nil + }) + + // Start the host agent + agent, err := hostagent.New(hostCfg) + if err != nil { + ws.logger.Error().Err(err).Msg("Failed to create host agent") + if ws.eventLog != nil { + ws.eventLog.Error(1, fmt.Sprintf("Failed to create host agent: %v", err)) } - close(errChan) + changes <- svc.Status{State: svc.Stopped} + return true, 1 + } + + g.Go(func() error { + ws.logger.Info(). + Str("version", Version). + Str("pulse_url", hostCfg.PulseURL). + Str("agent_id", hostCfg.AgentID). + Dur("interval", hostCfg.Interval). + Bool("auto_update", !ws.cfg.DisableAutoUpdate). + Msg("Starting Pulse host agent as Windows service") + return agent.Run(ctx) + }) + + // Channel to receive errgroup completion + doneChan := make(chan error, 1) + go func() { + doneChan <- g.Wait() }() changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} ws.logger.Info().Msg("Host agent service is running") if ws.eventLog != nil { - ws.eventLog.Info(1, fmt.Sprintf("Pulse Host Agent started successfully (URL: %s, Interval: %s)", ws.cfg.PulseURL, ws.cfg.Interval)) + ws.eventLog.Info(1, fmt.Sprintf("Pulse Host Agent started successfully (URL: %s, Interval: %s)", hostCfg.PulseURL, hostCfg.Interval)) } // Service control loop @@ -79,8 +109,8 @@ loop: default: ws.logger.Warn().Uint32("command", uint32(c.Cmd)).Msg("Unexpected service control command") } - case err := <-errChan: - if err != nil { + case err := <-doneChan: + if err != nil && err != context.Canceled { ws.logger.Error().Err(err).Msg("Agent error") if ws.eventLog != nil { ws.eventLog.Error(1, fmt.Sprintf("Pulse Host Agent error: %v", err)) @@ -97,7 +127,7 @@ loop: defer shutdownTimeout.Stop() select { - case <-errChan: + case <-doneChan: ws.logger.Info().Msg("Agent stopped gracefully") if ws.eventLog != nil { ws.eventLog.Info(1, "Pulse Host Agent stopped gracefully") @@ -113,7 +143,7 @@ loop: return false, 0 } -func runAsWindowsService(cfg hostagent.Config, logger zerolog.Logger) error { +func runAsWindowsService(cfg Config, logger zerolog.Logger) error { // Check if we're running as a Windows service isService, err := svc.IsWindowsService() if err != nil {