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
This commit is contained in:
rcourtman 2025-11-27 20:20:55 +00:00
parent b0ff539fcc
commit c104ceb19e
3 changed files with 123 additions and 48 deletions

View file

@ -10,11 +10,24 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog" "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 type multiValue []string
func (m *multiValue) String() string { func (m *multiValue) String() string {
@ -28,11 +41,12 @@ func (m *multiValue) Set(value string) error {
func main() { func main() {
cfg := loadConfig() cfg := loadConfig()
hostCfg := cfg.HostConfig
zerolog.SetGlobalLevel(cfg.LogLevel) zerolog.SetGlobalLevel(hostCfg.LogLevel)
logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger() logger := zerolog.New(os.Stdout).Level(hostCfg.LogLevel).With().Timestamp().Logger()
cfg.Logger = &logger hostCfg.Logger = &logger
// Check if we should run as a Windows service // Check if we should run as a Windows service
if err := runAsWindowsService(cfg, logger); err != nil { 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 // If runAsWindowsService returns nil without error, we're not running as a service
// or we're on a non-Windows platform, so run normally // 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) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
g, ctx := errgroup.WithContext(ctx)
logger.Info(). logger.Info().
Str("pulse_url", cfg.PulseURL). Str("version", Version).
Str("agent_id", cfg.AgentID). Str("pulse_url", hostCfg.PulseURL).
Dur("interval", cfg.Interval). Str("agent_id", hostCfg.AgentID).
Dur("interval", hostCfg.Interval).
Bool("auto_update", !cfg.DisableAutoUpdate).
Msg("Starting Pulse host agent") 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.Fatal().Err(err).Msg("host agent terminated with error")
} }
logger.Info().Msg("Host agent stopped") logger.Info().Msg("Host agent stopped")
} }
func loadConfig() hostagent.Config { func loadConfig() Config {
envURL := utils.GetenvTrim("PULSE_URL") envURL := utils.GetenvTrim("PULSE_URL")
envToken := utils.GetenvTrim("PULSE_TOKEN") envToken := utils.GetenvTrim("PULSE_TOKEN")
envInterval := utils.GetenvTrim("PULSE_INTERVAL") envInterval := utils.GetenvTrim("PULSE_INTERVAL")
@ -72,6 +113,7 @@ func loadConfig() hostagent.Config {
envTags := utils.GetenvTrim("PULSE_TAGS") envTags := utils.GetenvTrim("PULSE_TAGS")
envRunOnce := utils.GetenvTrim("PULSE_ONCE") envRunOnce := utils.GetenvTrim("PULSE_ONCE")
envLogLevel := utils.GetenvTrim("LOG_LEVEL") envLogLevel := utils.GetenvTrim("LOG_LEVEL")
envNoAutoUpdate := utils.GetenvTrim("PULSE_NO_AUTO_UPDATE")
defaultInterval := 30 * time.Second defaultInterval := 30 * time.Second
if envInterval != "" { if envInterval != "" {
@ -87,6 +129,7 @@ func loadConfig() hostagent.Config {
agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier") agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier")
insecureFlag := flag.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS certificate verification") 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") 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") showVersion := flag.Bool("version", false, "Print the agent version and exit")
logLevelFlag := flag.String("log-level", defaultLogLevel(envLogLevel), "Log level: debug, info, warn, error") logLevelFlag := flag.String("log-level", defaultLogLevel(envLogLevel), "Log level: debug, info, warn, error")
@ -96,7 +139,7 @@ func loadConfig() hostagent.Config {
flag.Parse() flag.Parse()
if *showVersion { if *showVersion {
fmt.Println(hostagent.Version) fmt.Println(Version)
os.Exit(0) os.Exit(0)
} }
@ -124,16 +167,19 @@ func loadConfig() hostagent.Config {
tags := gatherTags(envTags, tagFlags) tags := gatherTags(envTags, tagFlags)
return hostagent.Config{ return Config{
PulseURL: pulseURL, HostConfig: hostagent.Config{
APIToken: token, PulseURL: pulseURL,
Interval: interval, APIToken: token,
HostnameOverride: strings.TrimSpace(*hostnameFlag), Interval: interval,
AgentID: strings.TrimSpace(*agentIDFlag), HostnameOverride: strings.TrimSpace(*hostnameFlag),
Tags: tags, AgentID: strings.TrimSpace(*agentIDFlag),
InsecureSkipVerify: *insecureFlag, Tags: tags,
RunOnce: *runOnceFlag, InsecureSkipVerify: *insecureFlag,
LogLevel: logLevel, RunOnce: *runOnceFlag,
LogLevel: logLevel,
},
DisableAutoUpdate: *noAutoUpdateFlag,
} }
} }

View file

@ -3,11 +3,10 @@
package main package main
import ( import (
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
// runAsWindowsService is a no-op on non-Windows platforms // runAsWindowsService is a no-op on non-Windows platforms
func runAsWindowsService(_ hostagent.Config, _ zerolog.Logger) error { func runAsWindowsService(_ Config, _ zerolog.Logger) error {
return nil return nil
} }

View file

@ -7,14 +7,16 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"golang.org/x/sync/errgroup"
"golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/eventlog" "golang.org/x/sys/windows/svc/eventlog"
) )
type windowsService struct { type windowsService struct {
cfg hostagent.Config cfg Config
logger zerolog.Logger logger zerolog.Logger
eventLog *eventlog.Log 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") ws.eventLog.Info(1, "Pulse Host Agent service starting")
} }
agent, err := hostagent.New(ws.cfg) hostCfg := ws.cfg.HostConfig
if err != nil { hostCfg.Logger = &ws.logger
ws.logger.Error().Err(err).Msg("Failed to create host agent")
changes <- svc.Status{State: svc.Stopped}
return true, 1
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Start the agent in a goroutine g, ctx := errgroup.WithContext(ctx)
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")
if err := agent.Run(ctx); err != nil && err != context.Canceled { // Start Auto-Updater
errChan <- err 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} changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
ws.logger.Info().Msg("Host agent service is running") ws.logger.Info().Msg("Host agent service is running")
if ws.eventLog != nil { 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 // Service control loop
@ -79,8 +109,8 @@ loop:
default: default:
ws.logger.Warn().Uint32("command", uint32(c.Cmd)).Msg("Unexpected service control command") ws.logger.Warn().Uint32("command", uint32(c.Cmd)).Msg("Unexpected service control command")
} }
case err := <-errChan: case err := <-doneChan:
if err != nil { if err != nil && err != context.Canceled {
ws.logger.Error().Err(err).Msg("Agent error") ws.logger.Error().Err(err).Msg("Agent error")
if ws.eventLog != nil { if ws.eventLog != nil {
ws.eventLog.Error(1, fmt.Sprintf("Pulse Host Agent error: %v", err)) ws.eventLog.Error(1, fmt.Sprintf("Pulse Host Agent error: %v", err))
@ -97,7 +127,7 @@ loop:
defer shutdownTimeout.Stop() defer shutdownTimeout.Stop()
select { select {
case <-errChan: case <-doneChan:
ws.logger.Info().Msg("Agent stopped gracefully") ws.logger.Info().Msg("Agent stopped gracefully")
if ws.eventLog != nil { if ws.eventLog != nil {
ws.eventLog.Info(1, "Pulse Host Agent stopped gracefully") ws.eventLog.Info(1, "Pulse Host Agent stopped gracefully")
@ -113,7 +143,7 @@ loop:
return false, 0 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 // Check if we're running as a Windows service
isService, err := svc.IsWindowsService() isService, err := svc.IsWindowsService()
if err != nil { if err != nil {