Skip to content
This repository was archived by the owner on Feb 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Set default behavior to automatically normalize line endings
* text=auto

# Force LF line endings for source code files
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.sh text eol=lf
*.bash text eol=lf
Makefile text eol=lf

# Force CRLF for Windows-specific files
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf

# Binary files (don't modify line endings)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.zip binary
*.tar binary
*.gz binary
*.exe binary
*.dll binary
*.so binary
*.dylib binary

# Git files
*.gitignore text eol=lf
*.gitattributes text eol=lf

10 changes: 4 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,18 @@
# Build variables
BINARY_NAME=patchmon-agent
BUILD_DIR=build
# Use hardcoded version instead of git tags
VERSION=1.3.6
# Strip debug info and set version variable
LDFLAGS=-ldflags "-s -w -X patchmon-agent/internal/version.Version=$(VERSION)"
# Strip debug info (version comes from internal/version/version.go)
LDFLAGS=-ldflags "-s -w"
# Disable VCS stamping
BUILD_FLAGS=-buildvcs=false

# Go variables
GOBASE=$(shell pwd)
GOBIN=$(GOBASE)/$(BUILD_DIR)
# Use full path to go binary to avoid PATH issues when running as root
GO_CMD=/usr/local/go/bin/go
GO_CMD=/usr/bin/go
# Use full path to golangci-lint binary to avoid PATH issues when running as root
GOLANGCI_LINT_CMD=/usr/local/go/bin/golangci-lint
GOLANGCI_LINT_CMD=/root/go/bin/golangci-lint

# Default target
.PHONY: all
Expand Down
43 changes: 36 additions & 7 deletions cmd/patchmon-agent/commands/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package commands

import (
"context"
"encoding/json"
"fmt"
"os"
"time"

"patchmon-agent/internal/client"
Expand All @@ -20,6 +22,8 @@ import (
"github.com/spf13/cobra"
)

var reportJson bool

// reportCmd represents the report command
var reportCmd = &cobra.Command{
Use: "report",
Expand All @@ -30,20 +34,26 @@ var reportCmd = &cobra.Command{
return err
}

return sendReport()
return sendReport(reportJson)
},
}

func sendReport() error {
func init() {
reportCmd.Flags().BoolVar(&reportJson, "json", false, "Output the JSON report payload to stdout instead of sending to server")
}

func sendReport(outputJson bool) error {
// Start tracking execution time
startTime := time.Now()
logger.Debug("Starting report process")

// Load API credentials to send report
logger.Debug("Loading API credentials")
if err := cfgManager.LoadCredentials(); err != nil {
logger.WithError(err).Debug("Failed to load credentials")
return err
// Load API credentials only if we're sending the report (not just outputting JSON)
if !outputJson {
logger.Debug("Loading API credentials")
if err := cfgManager.LoadCredentials(); err != nil {
logger.WithError(err).Debug("Failed to load credentials")
return err
}
}

// Initialise managers
Expand Down Expand Up @@ -189,6 +199,18 @@ func sendReport() error {
RebootReason: rebootReason,
}

// If --report-json flag is set, output JSON and exit
if outputJson {
jsonData, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
if _, err := fmt.Fprintf(os.Stdout, "%s\n", jsonData); err != nil {
return fmt.Errorf("failed to write JSON output: %w", err)
}
return nil
}

// Send report
logger.Info("Sending report to PatchMon server...")
httpClient := client.New(cfgManager, logger)
Expand Down Expand Up @@ -244,6 +266,13 @@ func sendReport() error {
logger.Info("PatchMon agent update completed successfully")
// updateAgent() will exit after restart, so this won't be reached
}
} else if versionInfo.AutoUpdateDisabled && versionInfo.LatestVersion != versionInfo.CurrentVersion {
// Update is available but auto-update is disabled
logger.WithFields(logrus.Fields{
"current": versionInfo.CurrentVersion,
"latest": versionInfo.LatestVersion,
"reason": versionInfo.AutoUpdateDisabledReason,
}).Info("New update available but auto-update is disabled")
} else {
logger.WithField("version", versionInfo.CurrentVersion).Info("Agent is up to date")
}
Expand Down
169 changes: 157 additions & 12 deletions cmd/patchmon-agent/commands/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"patchmon-agent/internal/client"
"patchmon-agent/internal/integrations"
"patchmon-agent/internal/integrations/docker"
"patchmon-agent/internal/utils"
"patchmon-agent/pkg/models"

"github.com/gorilla/websocket"
Expand Down Expand Up @@ -44,14 +45,106 @@ func runService() error {
httpClient := client.New(cfgManager, logger)
ctx := context.Background()

// obtain initial interval
intervalMinutes := 60
// Get api_id for offset calculation
apiId := cfgManager.GetCredentials().APIID

// Load interval from config.yml (with default fallback)
intervalMinutes := cfgManager.GetConfig().UpdateInterval
if intervalMinutes <= 0 {
// Default to 60 if not set or invalid
intervalMinutes = 60
logger.WithField("interval", intervalMinutes).Info("Using default interval (not set in config)")
} else {
logger.WithField("interval", intervalMinutes).Info("Loaded interval from config.yml")
}

// Fetch interval from server and update config if different
if resp, err := httpClient.GetUpdateInterval(ctx); err == nil && resp.UpdateInterval > 0 {
intervalMinutes = resp.UpdateInterval
if resp.UpdateInterval != intervalMinutes {
logger.WithFields(map[string]interface{}{
"config_interval": intervalMinutes,
"server_interval": resp.UpdateInterval,
}).Info("Server interval differs from config, updating config.yml")

if err := cfgManager.SetUpdateInterval(resp.UpdateInterval); err != nil {
logger.WithError(err).Warn("Failed to save interval to config.yml")
} else {
intervalMinutes = resp.UpdateInterval
logger.WithField("interval", intervalMinutes).Info("Updated interval in config.yml")
}
}
} else if err != nil {
logger.WithError(err).Warn("Failed to fetch interval from server, using config value")
}

ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute)
defer ticker.Stop()
// Fetch integration status from server and sync with config.yml
logger.Info("Syncing integration status from server...")
if integrationResp, err := httpClient.GetIntegrationStatus(ctx); err == nil && integrationResp.Success {
configUpdated := false
for integrationName, serverEnabled := range integrationResp.Integrations {
configEnabled := cfgManager.IsIntegrationEnabled(integrationName)
if serverEnabled != configEnabled {
logger.WithFields(map[string]interface{}{
"integration": integrationName,
"config_value": configEnabled,
"server_value": serverEnabled,
}).Info("Integration status differs, updating config.yml")

if err := cfgManager.SetIntegrationEnabled(integrationName, serverEnabled); err != nil {
logger.WithError(err).Warn("Failed to save integration status to config.yml")
} else {
configUpdated = true
logger.WithFields(map[string]interface{}{
"integration": integrationName,
"enabled": serverEnabled,
}).Info("Updated integration status in config.yml")
}
}
}

if configUpdated {
// Reload config so in-memory state matches the updated file
if err := cfgManager.LoadConfig(); err != nil {
logger.WithError(err).Warn("Failed to reload config after integration update")
} else {
logger.Info("Config reloaded, integration settings will be applied")
}
} else {
logger.Debug("Integration status matches config, no update needed")
}
} else if err != nil {
logger.WithError(err).Warn("Failed to fetch integration status from server, using config values")
}

// Load or calculate offset based on api_id to stagger reporting times
var offset time.Duration
configOffsetSeconds := cfgManager.GetConfig().ReportOffset

// Calculate what the offset should be based on current api_id and interval
calculatedOffset := utils.CalculateReportOffset(apiId, intervalMinutes)
calculatedOffsetSeconds := int(calculatedOffset.Seconds())

// Use config offset if it exists and matches calculated value, otherwise recalculate and save
if configOffsetSeconds > 0 && configOffsetSeconds == calculatedOffsetSeconds {
offset = time.Duration(configOffsetSeconds) * time.Second
logger.WithFields(map[string]interface{}{
"api_id": apiId,
"interval_minutes": intervalMinutes,
"offset_seconds": offset.Seconds(),
}).Info("Loaded report offset from config.yml")
} else {
// Offset not in config or doesn't match, calculate and save it
offset = calculatedOffset
if err := cfgManager.SetReportOffset(calculatedOffsetSeconds); err != nil {
logger.WithError(err).Warn("Failed to save offset to config.yml")
} else {
logger.WithFields(map[string]interface{}{
"api_id": apiId,
"interval_minutes": intervalMinutes,
"offset_seconds": offset.Seconds(),
}).Info("Calculated and saved report offset to config.yml")
}
}

// Send startup ping to notify server that agent has started
logger.Info("🚀 Agent starting up, notifying server...")
Expand All @@ -63,7 +156,7 @@ func runService() error {

// initial report on boot
logger.Info("Sending initial report on startup...")
if err := sendReport(); err != nil {
if err := sendReport(false); err != nil {
logger.WithError(err).Warn("initial report failed")
} else {
logger.Info("✅ Initial report sent successfully")
Expand All @@ -78,22 +171,74 @@ func runService() error {
// Start integration monitoring (Docker real-time events, etc.)
startIntegrationMonitoring(ctx, dockerEvents)

// Create ticker with initial interval
ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute)
defer ticker.Stop()

// Wait for offset before starting periodic reports
// This staggers the reporting times across different agents
offsetTimer := time.NewTimer(offset)
defer offsetTimer.Stop()

// Track whether offset period has passed
offsetPassed := false

// Track current interval for offset recalculation on updates
currentInterval := intervalMinutes

for {
select {
case <-offsetTimer.C:
// Offset period completed, start consuming from ticker normally
offsetPassed = true
logger.Debug("Offset period completed, periodic reports will now start")
case <-ticker.C:
if err := sendReport(); err != nil {
logger.WithError(err).Warn("periodic report failed")
// Only process ticker events after offset has passed
if offsetPassed {
if err := sendReport(false); err != nil {
logger.WithError(err).Warn("periodic report failed")
}
}
case m := <-messages:
switch m.kind {
case "settings_update":
if m.interval > 0 {
if m.interval > 0 && m.interval != currentInterval {
// Save new interval to config.yml
if err := cfgManager.SetUpdateInterval(m.interval); err != nil {
logger.WithError(err).Warn("Failed to save interval to config.yml")
} else {
logger.WithField("interval", m.interval).Info("Saved new interval to config.yml")
}

// Recalculate offset for new interval and save to config.yml
newOffset := utils.CalculateReportOffset(apiId, m.interval)
newOffsetSeconds := int(newOffset.Seconds())
if err := cfgManager.SetReportOffset(newOffsetSeconds); err != nil {
logger.WithError(err).Warn("Failed to save offset to config.yml")
}

logger.WithFields(map[string]interface{}{
"old_interval": currentInterval,
"new_interval": m.interval,
"new_offset_seconds": newOffset.Seconds(),
}).Info("Recalculated and saved offset for new interval")

// Stop old ticker
ticker.Stop()

// Create new ticker with updated interval
ticker = time.NewTicker(time.Duration(m.interval) * time.Minute)
currentInterval = m.interval

// Reset offset timer for new interval
offsetTimer.Stop()
offsetTimer = time.NewTimer(newOffset)
offsetPassed = false // Reset flag for new interval

logger.WithField("new_interval", m.interval).Info("interval updated, no report sent")
}
case "report_now":
if err := sendReport(); err != nil {
if err := sendReport(false); err != nil {
logger.WithError(err).Warn("report_now failed")
}
case "update_agent":
Expand Down Expand Up @@ -352,7 +497,7 @@ func toggleIntegration(integrationName string, enabled bool) error {
// Since we're running inside the service, we can't stop ourselves directly
// Instead, we'll create a helper script that runs after we exit
logger.Debug("Detected OpenRC, scheduling service restart via helper script")

// Create a helper script that will restart the service after we exit
helperScript := `#!/bin/sh
# Wait a moment for the current process to exit
Expand Down Expand Up @@ -385,7 +530,7 @@ rm -f "$0"
os.Exit(0)
}
}

// Fallback: If helper script approach failed, just exit and let OpenRC handle it
// OpenRC with command_background="yes" should restart on exit
logger.Info("Exiting to allow OpenRC to restart service with updated config...")
Expand Down
Loading