Skip to content
This repository was archived by the owner on Feb 23, 2026. It is now read-only.

Commit c753095

Browse files
Merge pull request #35 from PatchMon/1-3-7
Version 1.3.7 main
2 parents 36aec47 + 2276c9e commit c753095

12 files changed

Lines changed: 639 additions & 172 deletions

File tree

.gitattributes

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Set default behavior to automatically normalize line endings
2+
* text=auto
3+
4+
# Force LF line endings for source code files
5+
*.go text eol=lf
6+
*.mod text eol=lf
7+
*.sum text eol=lf
8+
*.yml text eol=lf
9+
*.yaml text eol=lf
10+
*.json text eol=lf
11+
*.md text eol=lf
12+
*.txt text eol=lf
13+
*.sh text eol=lf
14+
*.bash text eol=lf
15+
Makefile text eol=lf
16+
17+
# Force CRLF for Windows-specific files
18+
*.bat text eol=crlf
19+
*.cmd text eol=crlf
20+
*.ps1 text eol=crlf
21+
22+
# Binary files (don't modify line endings)
23+
*.png binary
24+
*.jpg binary
25+
*.jpeg binary
26+
*.gif binary
27+
*.ico binary
28+
*.pdf binary
29+
*.zip binary
30+
*.tar binary
31+
*.gz binary
32+
*.exe binary
33+
*.dll binary
34+
*.so binary
35+
*.dylib binary
36+
37+
# Git files
38+
*.gitignore text eol=lf
39+
*.gitattributes text eol=lf
40+

Makefile

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,18 @@
33
# Build variables
44
BINARY_NAME=patchmon-agent
55
BUILD_DIR=build
6-
# Use hardcoded version instead of git tags
7-
VERSION=1.3.6
8-
# Strip debug info and set version variable
9-
LDFLAGS=-ldflags "-s -w -X patchmon-agent/internal/version.Version=$(VERSION)"
6+
# Strip debug info (version comes from internal/version/version.go)
7+
LDFLAGS=-ldflags "-s -w"
108
# Disable VCS stamping
119
BUILD_FLAGS=-buildvcs=false
1210

1311
# Go variables
1412
GOBASE=$(shell pwd)
1513
GOBIN=$(GOBASE)/$(BUILD_DIR)
1614
# Use full path to go binary to avoid PATH issues when running as root
17-
GO_CMD=/usr/local/go/bin/go
15+
GO_CMD=/usr/bin/go
1816
# Use full path to golangci-lint binary to avoid PATH issues when running as root
19-
GOLANGCI_LINT_CMD=/usr/local/go/bin/golangci-lint
17+
GOLANGCI_LINT_CMD=/root/go/bin/golangci-lint
2018

2119
# Default target
2220
.PHONY: all

cmd/patchmon-agent/commands/report.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package commands
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
7+
"os"
68
"time"
79

810
"patchmon-agent/internal/client"
@@ -20,6 +22,8 @@ import (
2022
"github.com/spf13/cobra"
2123
)
2224

25+
var reportJson bool
26+
2327
// reportCmd represents the report command
2428
var reportCmd = &cobra.Command{
2529
Use: "report",
@@ -30,20 +34,26 @@ var reportCmd = &cobra.Command{
3034
return err
3135
}
3236

33-
return sendReport()
37+
return sendReport(reportJson)
3438
},
3539
}
3640

37-
func sendReport() error {
41+
func init() {
42+
reportCmd.Flags().BoolVar(&reportJson, "json", false, "Output the JSON report payload to stdout instead of sending to server")
43+
}
44+
45+
func sendReport(outputJson bool) error {
3846
// Start tracking execution time
3947
startTime := time.Now()
4048
logger.Debug("Starting report process")
4149

42-
// Load API credentials to send report
43-
logger.Debug("Loading API credentials")
44-
if err := cfgManager.LoadCredentials(); err != nil {
45-
logger.WithError(err).Debug("Failed to load credentials")
46-
return err
50+
// Load API credentials only if we're sending the report (not just outputting JSON)
51+
if !outputJson {
52+
logger.Debug("Loading API credentials")
53+
if err := cfgManager.LoadCredentials(); err != nil {
54+
logger.WithError(err).Debug("Failed to load credentials")
55+
return err
56+
}
4757
}
4858

4959
// Initialise managers
@@ -189,6 +199,18 @@ func sendReport() error {
189199
RebootReason: rebootReason,
190200
}
191201

202+
// If --report-json flag is set, output JSON and exit
203+
if outputJson {
204+
jsonData, err := json.MarshalIndent(payload, "", " ")
205+
if err != nil {
206+
return fmt.Errorf("failed to marshal JSON: %w", err)
207+
}
208+
if _, err := fmt.Fprintf(os.Stdout, "%s\n", jsonData); err != nil {
209+
return fmt.Errorf("failed to write JSON output: %w", err)
210+
}
211+
return nil
212+
}
213+
192214
// Send report
193215
logger.Info("Sending report to PatchMon server...")
194216
httpClient := client.New(cfgManager, logger)
@@ -244,6 +266,13 @@ func sendReport() error {
244266
logger.Info("PatchMon agent update completed successfully")
245267
// updateAgent() will exit after restart, so this won't be reached
246268
}
269+
} else if versionInfo.AutoUpdateDisabled && versionInfo.LatestVersion != versionInfo.CurrentVersion {
270+
// Update is available but auto-update is disabled
271+
logger.WithFields(logrus.Fields{
272+
"current": versionInfo.CurrentVersion,
273+
"latest": versionInfo.LatestVersion,
274+
"reason": versionInfo.AutoUpdateDisabledReason,
275+
}).Info("New update available but auto-update is disabled")
247276
} else {
248277
logger.WithField("version", versionInfo.CurrentVersion).Info("Agent is up to date")
249278
}

cmd/patchmon-agent/commands/serve.go

Lines changed: 157 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"patchmon-agent/internal/client"
1515
"patchmon-agent/internal/integrations"
1616
"patchmon-agent/internal/integrations/docker"
17+
"patchmon-agent/internal/utils"
1718
"patchmon-agent/pkg/models"
1819

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

47-
// obtain initial interval
48-
intervalMinutes := 60
48+
// Get api_id for offset calculation
49+
apiId := cfgManager.GetCredentials().APIID
50+
51+
// Load interval from config.yml (with default fallback)
52+
intervalMinutes := cfgManager.GetConfig().UpdateInterval
53+
if intervalMinutes <= 0 {
54+
// Default to 60 if not set or invalid
55+
intervalMinutes = 60
56+
logger.WithField("interval", intervalMinutes).Info("Using default interval (not set in config)")
57+
} else {
58+
logger.WithField("interval", intervalMinutes).Info("Loaded interval from config.yml")
59+
}
60+
61+
// Fetch interval from server and update config if different
4962
if resp, err := httpClient.GetUpdateInterval(ctx); err == nil && resp.UpdateInterval > 0 {
50-
intervalMinutes = resp.UpdateInterval
63+
if resp.UpdateInterval != intervalMinutes {
64+
logger.WithFields(map[string]interface{}{
65+
"config_interval": intervalMinutes,
66+
"server_interval": resp.UpdateInterval,
67+
}).Info("Server interval differs from config, updating config.yml")
68+
69+
if err := cfgManager.SetUpdateInterval(resp.UpdateInterval); err != nil {
70+
logger.WithError(err).Warn("Failed to save interval to config.yml")
71+
} else {
72+
intervalMinutes = resp.UpdateInterval
73+
logger.WithField("interval", intervalMinutes).Info("Updated interval in config.yml")
74+
}
75+
}
76+
} else if err != nil {
77+
logger.WithError(err).Warn("Failed to fetch interval from server, using config value")
5178
}
5279

53-
ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute)
54-
defer ticker.Stop()
80+
// Fetch integration status from server and sync with config.yml
81+
logger.Info("Syncing integration status from server...")
82+
if integrationResp, err := httpClient.GetIntegrationStatus(ctx); err == nil && integrationResp.Success {
83+
configUpdated := false
84+
for integrationName, serverEnabled := range integrationResp.Integrations {
85+
configEnabled := cfgManager.IsIntegrationEnabled(integrationName)
86+
if serverEnabled != configEnabled {
87+
logger.WithFields(map[string]interface{}{
88+
"integration": integrationName,
89+
"config_value": configEnabled,
90+
"server_value": serverEnabled,
91+
}).Info("Integration status differs, updating config.yml")
92+
93+
if err := cfgManager.SetIntegrationEnabled(integrationName, serverEnabled); err != nil {
94+
logger.WithError(err).Warn("Failed to save integration status to config.yml")
95+
} else {
96+
configUpdated = true
97+
logger.WithFields(map[string]interface{}{
98+
"integration": integrationName,
99+
"enabled": serverEnabled,
100+
}).Info("Updated integration status in config.yml")
101+
}
102+
}
103+
}
104+
105+
if configUpdated {
106+
// Reload config so in-memory state matches the updated file
107+
if err := cfgManager.LoadConfig(); err != nil {
108+
logger.WithError(err).Warn("Failed to reload config after integration update")
109+
} else {
110+
logger.Info("Config reloaded, integration settings will be applied")
111+
}
112+
} else {
113+
logger.Debug("Integration status matches config, no update needed")
114+
}
115+
} else if err != nil {
116+
logger.WithError(err).Warn("Failed to fetch integration status from server, using config values")
117+
}
118+
119+
// Load or calculate offset based on api_id to stagger reporting times
120+
var offset time.Duration
121+
configOffsetSeconds := cfgManager.GetConfig().ReportOffset
122+
123+
// Calculate what the offset should be based on current api_id and interval
124+
calculatedOffset := utils.CalculateReportOffset(apiId, intervalMinutes)
125+
calculatedOffsetSeconds := int(calculatedOffset.Seconds())
126+
127+
// Use config offset if it exists and matches calculated value, otherwise recalculate and save
128+
if configOffsetSeconds > 0 && configOffsetSeconds == calculatedOffsetSeconds {
129+
offset = time.Duration(configOffsetSeconds) * time.Second
130+
logger.WithFields(map[string]interface{}{
131+
"api_id": apiId,
132+
"interval_minutes": intervalMinutes,
133+
"offset_seconds": offset.Seconds(),
134+
}).Info("Loaded report offset from config.yml")
135+
} else {
136+
// Offset not in config or doesn't match, calculate and save it
137+
offset = calculatedOffset
138+
if err := cfgManager.SetReportOffset(calculatedOffsetSeconds); err != nil {
139+
logger.WithError(err).Warn("Failed to save offset to config.yml")
140+
} else {
141+
logger.WithFields(map[string]interface{}{
142+
"api_id": apiId,
143+
"interval_minutes": intervalMinutes,
144+
"offset_seconds": offset.Seconds(),
145+
}).Info("Calculated and saved report offset to config.yml")
146+
}
147+
}
55148

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

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

174+
// Create ticker with initial interval
175+
ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute)
176+
defer ticker.Stop()
177+
178+
// Wait for offset before starting periodic reports
179+
// This staggers the reporting times across different agents
180+
offsetTimer := time.NewTimer(offset)
181+
defer offsetTimer.Stop()
182+
183+
// Track whether offset period has passed
184+
offsetPassed := false
185+
186+
// Track current interval for offset recalculation on updates
187+
currentInterval := intervalMinutes
188+
81189
for {
82190
select {
191+
case <-offsetTimer.C:
192+
// Offset period completed, start consuming from ticker normally
193+
offsetPassed = true
194+
logger.Debug("Offset period completed, periodic reports will now start")
83195
case <-ticker.C:
84-
if err := sendReport(); err != nil {
85-
logger.WithError(err).Warn("periodic report failed")
196+
// Only process ticker events after offset has passed
197+
if offsetPassed {
198+
if err := sendReport(false); err != nil {
199+
logger.WithError(err).Warn("periodic report failed")
200+
}
86201
}
87202
case m := <-messages:
88203
switch m.kind {
89204
case "settings_update":
90-
if m.interval > 0 {
205+
if m.interval > 0 && m.interval != currentInterval {
206+
// Save new interval to config.yml
207+
if err := cfgManager.SetUpdateInterval(m.interval); err != nil {
208+
logger.WithError(err).Warn("Failed to save interval to config.yml")
209+
} else {
210+
logger.WithField("interval", m.interval).Info("Saved new interval to config.yml")
211+
}
212+
213+
// Recalculate offset for new interval and save to config.yml
214+
newOffset := utils.CalculateReportOffset(apiId, m.interval)
215+
newOffsetSeconds := int(newOffset.Seconds())
216+
if err := cfgManager.SetReportOffset(newOffsetSeconds); err != nil {
217+
logger.WithError(err).Warn("Failed to save offset to config.yml")
218+
}
219+
220+
logger.WithFields(map[string]interface{}{
221+
"old_interval": currentInterval,
222+
"new_interval": m.interval,
223+
"new_offset_seconds": newOffset.Seconds(),
224+
}).Info("Recalculated and saved offset for new interval")
225+
226+
// Stop old ticker
91227
ticker.Stop()
228+
229+
// Create new ticker with updated interval
92230
ticker = time.NewTicker(time.Duration(m.interval) * time.Minute)
231+
currentInterval = m.interval
232+
233+
// Reset offset timer for new interval
234+
offsetTimer.Stop()
235+
offsetTimer = time.NewTimer(newOffset)
236+
offsetPassed = false // Reset flag for new interval
237+
93238
logger.WithField("new_interval", m.interval).Info("interval updated, no report sent")
94239
}
95240
case "report_now":
96-
if err := sendReport(); err != nil {
241+
if err := sendReport(false); err != nil {
97242
logger.WithError(err).Warn("report_now failed")
98243
}
99244
case "update_agent":
@@ -352,7 +497,7 @@ func toggleIntegration(integrationName string, enabled bool) error {
352497
// Since we're running inside the service, we can't stop ourselves directly
353498
// Instead, we'll create a helper script that runs after we exit
354499
logger.Debug("Detected OpenRC, scheduling service restart via helper script")
355-
500+
356501
// Create a helper script that will restart the service after we exit
357502
helperScript := `#!/bin/sh
358503
# Wait a moment for the current process to exit
@@ -385,7 +530,7 @@ rm -f "$0"
385530
os.Exit(0)
386531
}
387532
}
388-
533+
389534
// Fallback: If helper script approach failed, just exit and let OpenRC handle it
390535
// OpenRC with command_background="yes" should restart on exit
391536
logger.Info("Exiting to allow OpenRC to restart service with updated config...")

0 commit comments

Comments
 (0)