diff --git a/agent/agent.go b/agent/agent.go index 764bc23e..e181aac6 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -19,6 +19,8 @@ import ( gossh "golang.org/x/crypto/ssh" ) +const defaultDataCacheTimeMs uint16 = 60_000 + type Agent struct { sync.Mutex // Used to lock agent while collecting data debug bool // true if LOG_LEVEL is set to debug @@ -36,6 +38,7 @@ type Agent struct { sensorConfig *SensorConfig // Sensors config systemInfo system.Info // Host system info (dynamic) systemDetails system.Details // Host system details (static, once-per-connection) + detailsDirty bool // Whether system details have changed and need to be resent gpuManager *GPUManager // Manages GPU data cache *systemDataCache // Cache for system stats based on cache time connectionManager *ConnectionManager // Channel to signal connection events @@ -97,7 +100,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { slog.Debug(beszel.Version) // initialize docker manager - agent.dockerManager = newDockerManager() + agent.dockerManager = newDockerManager(agent) // initialize system info agent.refreshSystemDetails() @@ -142,7 +145,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { // if debugging, print stats if agent.debug { - slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true})) + slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true})) } return agent, nil @@ -164,11 +167,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD Info: a.systemInfo, } - // Include static system details only when requested - if options.IncludeDetails { - data.Details = &a.systemDetails - } - // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs) if a.dockerManager != nil { @@ -181,7 +179,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD } // skip updating systemd services if cache time is not the default 60sec interval - if a.systemdManager != nil && cacheTimeMs == 60_000 { + if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs { totalCount := uint16(a.systemdManager.getServiceStatsCount()) if totalCount > 0 { numFailed := a.systemdManager.getFailedServiceCount() @@ -212,7 +210,8 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD slog.Debug("Extra FS", "data", data.Stats.ExtraFs) a.cache.Set(data, cacheTimeMs) - return data + + return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails) } // Start initializes and starts the agent with optional WebSocket connection diff --git a/agent/docker.go b/agent/docker.go index 22216000..f35f7fc8 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -25,6 +25,7 @@ import ( "github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/container" + "github.com/henrygd/beszel/internal/entities/system" "github.com/blang/semver" ) @@ -52,20 +53,22 @@ const ( ) type dockerManager struct { - client *http.Client // Client to query Docker API - wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish - sem chan struct{} // Semaphore to limit concurrent container requests - containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap - apiContainerList []*container.ApiInfo // List of containers from Docker API - containerStatsMap map[string]*container.Stats // Keeps track of container stats - validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap - goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly) - isWindows bool // Whether the Docker Engine API is running on Windows - buf *bytes.Buffer // Buffer to store and read response bodies - decoder *json.Decoder // Reusable JSON decoder that reads from buf - apiStats *container.ApiStats // Reusable API stats object - excludeContainers []string // Patterns to exclude containers by name - usingPodman bool // Whether the Docker Engine API is running on Podman + agent *Agent // Used to propagate system detail changes back to the agent + client *http.Client // Client to query Docker API + wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish + sem chan struct{} // Semaphore to limit concurrent container requests + containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap + apiContainerList []*container.ApiInfo // List of containers from Docker API + containerStatsMap map[string]*container.Stats // Keeps track of container stats + validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap + goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly) + dockerVersionChecked bool // Whether a version probe has completed successfully + isWindows bool // Whether the Docker Engine API is running on Windows + buf *bytes.Buffer // Buffer to store and read response bodies + decoder *json.Decoder // Reusable JSON decoder that reads from buf + apiStats *container.ApiStats // Reusable API stats object + excludeContainers []string // Patterns to exclude containers by name + usingPodman bool // Whether the Docker Engine API is running on Podman // Cache-time-aware tracking for CPU stats (similar to cpu.go) // Maps cache time intervals to container-specific CPU usage tracking @@ -78,7 +81,6 @@ type dockerManager struct { networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time - retrySleep func(time.Duration) } // userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests @@ -87,6 +89,14 @@ type userAgentRoundTripper struct { userAgent string } +// dockerVersionResponse contains the /version fields used for engine checks. +type dockerVersionResponse struct { + Version string `json:"Version"` + Components []struct { + Name string `json:"Name"` + } `json:"Components"` +} + // RoundTrip implements the http.RoundTripper interface func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", u.userAgent) @@ -134,7 +144,14 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, return nil, err } - dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows") + // Detect Podman and Windows from Server header + serverHeader := resp.Header.Get("Server") + if !dm.usingPodman && detectPodmanFromHeader(serverHeader) { + dm.setIsPodman() + } + dm.isWindows = strings.Contains(serverHeader, "windows") + + dm.ensureDockerVersionChecked() containersLength := len(dm.apiContainerList) @@ -588,7 +605,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) { } // Creates a new http client for Docker or Podman API -func newDockerManager() *dockerManager { +func newDockerManager(agent *Agent) *dockerManager { dockerHost, exists := utils.GetEnv("DOCKER_HOST") if exists { // return nil if set to empty string @@ -654,6 +671,7 @@ func newDockerManager() *dockerManager { } manager := &dockerManager{ + agent: agent, client: &http.Client{ Timeout: timeout, Transport: userAgentTransport, @@ -671,51 +689,54 @@ func newDockerManager() *dockerManager { networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), lastNetworkReadTime: make(map[uint16]map[string]time.Time), - retrySleep: time.Sleep, } - // If using podman, return client - if strings.Contains(dockerHost, "podman") { - manager.usingPodman = true - manager.goodDockerVersion = true - return manager - } - - // run version check in goroutine to avoid blocking (server may not be ready and requires retries) - go manager.checkDockerVersion() - - // give version check a chance to complete before returning - time.Sleep(50 * time.Millisecond) + // Best-effort startup probe. If the engine is not ready yet, getDockerStats will + // retry after the first successful /containers/json request. + _, _ = manager.checkDockerVersion() return manager } // checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0. // Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch. -func (dm *dockerManager) checkDockerVersion() { - var err error - var resp *http.Response - var versionInfo struct { - Version string `json:"Version"` +func (dm *dockerManager) checkDockerVersion() (bool, error) { + resp, err := dm.client.Get("http://localhost/version") + if err != nil { + return false, err } - const versionMaxTries = 2 - for i := 1; i <= versionMaxTries; i++ { - resp, err = dm.client.Get("http://localhost/version") - if err == nil && resp.StatusCode == http.StatusOK { - break - } - if resp != nil { - resp.Body.Close() - } - if i < versionMaxTries { - slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp) - dm.retrySleep(5 * time.Second) - } + if resp.StatusCode != http.StatusOK { + status := resp.Status + resp.Body.Close() + return false, fmt.Errorf("docker version request failed: %s", status) } - if err != nil || resp.StatusCode != http.StatusOK { + + var versionInfo dockerVersionResponse + serverHeader := resp.Header.Get("Server") + if err := dm.decode(resp, &versionInfo); err != nil { + return false, err + } + + dm.applyDockerVersionInfo(serverHeader, &versionInfo) + dm.dockerVersionChecked = true + return true, nil +} + +// ensureDockerVersionChecked retries the version probe after a successful +// container list request. +func (dm *dockerManager) ensureDockerVersionChecked() { + if dm.dockerVersionChecked { return } - if err := dm.decode(resp, &versionInfo); err != nil { + if _, err := dm.checkDockerVersion(); err != nil { + slog.Debug("Failed to get Docker version", "err", err) + } +} + +// applyDockerVersionInfo updates version-dependent behavior from engine metadata. +func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) { + if detectPodmanEngine(serverHeader, versionInfo) { + dm.setIsPodman() return } // if version > 24, one-shot works correctly and we can limit concurrent operations @@ -941,3 +962,46 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) { func (dm *dockerManager) IsPodman() bool { return dm.usingPodman } + +// setIsPodman sets the manager to Podman mode and updates system details accordingly. +func (dm *dockerManager) setIsPodman() { + if dm.usingPodman { + return + } + dm.usingPodman = true + dm.goodDockerVersion = true + dm.dockerVersionChecked = true + // keep system details updated - this may be detected late if server isn't ready when + // agent starts, so make sure we notify the hub if this happens later. + if dm.agent != nil { + dm.agent.updateSystemDetails(func(details *system.Details) { + details.Podman = true + }) + } +} + +// detectPodmanFromHeader identifies Podman from the Docker API server header. +func detectPodmanFromHeader(server string) bool { + return strings.HasPrefix(server, "Libpod") +} + +// detectPodmanFromVersion identifies Podman from the version payload. +func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool { + if versionInfo == nil { + return false + } + for _, component := range versionInfo.Components { + if strings.HasPrefix(component.Name, "Podman") { + return true + } + } + return false +} + +// detectPodmanEngine checks both header and version metadata for Podman. +func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool { + if detectPodmanFromHeader(serverHeader) { + return true + } + return detectPodmanFromVersion(versionInfo) +} diff --git a/agent/docker_test.go b/agent/docker_test.go index 87ef4712..9186a756 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -539,59 +539,53 @@ func TestDockerManagerCreation(t *testing.T) { func TestCheckDockerVersion(t *testing.T) { tests := []struct { - name string - responses []struct { - statusCode int - body string - } - expectedGood bool - expectedRequests int + name string + statusCode int + body string + server string + expectSuccess bool + expectedGood bool + expectedPodman bool + expectError bool + expectedRequest string }{ { - name: "200 with good version on first try", - responses: []struct { - statusCode int - body string - }{ - {http.StatusOK, `{"Version":"25.0.1"}`}, - }, - expectedGood: true, - expectedRequests: 1, + name: "good docker version", + statusCode: http.StatusOK, + body: `{"Version":"25.0.1"}`, + expectSuccess: true, + expectedGood: true, + expectedPodman: false, + expectedRequest: "/version", }, { - name: "200 with old version on first try", - responses: []struct { - statusCode int - body string - }{ - {http.StatusOK, `{"Version":"24.0.7"}`}, - }, - expectedGood: false, - expectedRequests: 1, + name: "old docker version", + statusCode: http.StatusOK, + body: `{"Version":"24.0.7"}`, + expectSuccess: true, + expectedGood: false, + expectedPodman: false, + expectedRequest: "/version", }, { - name: "non-200 then 200 with good version", - responses: []struct { - statusCode int - body string - }{ - {http.StatusServiceUnavailable, `"not ready"`}, - {http.StatusOK, `{"Version":"25.1.0"}`}, - }, - expectedGood: true, - expectedRequests: 2, + name: "podman from server header", + statusCode: http.StatusOK, + body: `{"Version":"5.5.0"}`, + server: "Libpod/5.5.0", + expectSuccess: true, + expectedGood: true, + expectedPodman: true, + expectedRequest: "/version", }, { - name: "non-200 on all retries", - responses: []struct { - statusCode int - body string - }{ - {http.StatusInternalServerError, `"error"`}, - {http.StatusUnauthorized, `"error"`}, - }, - expectedGood: false, - expectedRequests: 2, + name: "non-200 response", + statusCode: http.StatusServiceUnavailable, + body: `"not ready"`, + expectSuccess: false, + expectedGood: false, + expectedPodman: false, + expectError: true, + expectedRequest: "/version", }, } @@ -599,13 +593,13 @@ func TestCheckDockerVersion(t *testing.T) { t.Run(tt.name, func(t *testing.T) { requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - idx := requestCount requestCount++ - if idx >= len(tt.responses) { - idx = len(tt.responses) - 1 + assert.Equal(t, tt.expectedRequest, r.URL.EscapedPath()) + if tt.server != "" { + w.Header().Set("Server", tt.server) } - w.WriteHeader(tt.responses[idx].statusCode) - fmt.Fprint(w, tt.responses[idx].body) + w.WriteHeader(tt.statusCode) + fmt.Fprint(w, tt.body) })) defer server.Close() @@ -617,17 +611,24 @@ func TestCheckDockerVersion(t *testing.T) { }, }, }, - retrySleep: func(time.Duration) {}, } - dm.checkDockerVersion() + success, err := dm.checkDockerVersion() + assert.Equal(t, tt.expectSuccess, success) + assert.Equal(t, tt.expectSuccess, dm.dockerVersionChecked) assert.Equal(t, tt.expectedGood, dm.goodDockerVersion) - assert.Equal(t, tt.expectedRequests, requestCount) + assert.Equal(t, tt.expectedPodman, dm.usingPodman) + assert.Equal(t, 1, requestCount) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } }) } - t.Run("request error on all retries", func(t *testing.T) { + t.Run("request error", func(t *testing.T) { requestCount := 0 dm := &dockerManager{ client: &http.Client{ @@ -638,16 +639,171 @@ func TestCheckDockerVersion(t *testing.T) { }, }, }, - retrySleep: func(time.Duration) {}, } - dm.checkDockerVersion() + success, err := dm.checkDockerVersion() + assert.False(t, success) + require.Error(t, err) + assert.False(t, dm.dockerVersionChecked) assert.False(t, dm.goodDockerVersion) - assert.Equal(t, 2, requestCount) + assert.False(t, dm.usingPodman) + assert.Equal(t, 1, requestCount) }) } +// newDockerManagerForVersionTest creates a dockerManager wired to a test server. +func newDockerManagerForVersionTest(server *httptest.Server) *dockerManager { + return &dockerManager{ + client: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, network, _ string) (net.Conn, error) { + return net.Dial(network, server.Listener.Addr().String()) + }, + }, + }, + containerStatsMap: make(map[string]*container.Stats), + lastCpuContainer: make(map[uint16]map[string]uint64), + lastCpuSystem: make(map[uint16]map[string]uint64), + lastCpuReadTime: make(map[uint16]map[string]time.Time), + networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), + networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), + lastNetworkReadTime: make(map[uint16]map[string]time.Time), + } +} + +func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) { + tests := []struct { + name string + containerServer string + versionServer string + versionBody string + expectedGood bool + expectedPodman bool + }{ + { + name: "200 with good version on first try", + versionBody: `{"Version":"25.0.1"}`, + expectedGood: true, + expectedPodman: false, + }, + { + name: "200 with old version on first try", + versionBody: `{"Version":"24.0.7"}`, + expectedGood: false, + expectedPodman: false, + }, + { + name: "podman detected from server header", + containerServer: "Libpod/5.5.0", + expectedGood: true, + expectedPodman: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requestCounts := map[string]int{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCounts[r.URL.EscapedPath()]++ + switch r.URL.EscapedPath() { + case "/containers/json": + if tt.containerServer != "" { + w.Header().Set("Server", tt.containerServer) + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `[]`) + case "/version": + if tt.versionServer != "" { + w.Header().Set("Server", tt.versionServer) + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, tt.versionBody) + default: + t.Fatalf("unexpected path: %s", r.URL.EscapedPath()) + } + })) + defer server.Close() + + dm := newDockerManagerForVersionTest(server) + + stats, err := dm.getDockerStats(defaultCacheTimeMs) + require.NoError(t, err) + assert.Empty(t, stats) + assert.True(t, dm.dockerVersionChecked) + assert.Equal(t, tt.expectedGood, dm.goodDockerVersion) + assert.Equal(t, tt.expectedPodman, dm.usingPodman) + assert.Equal(t, 1, requestCounts["/containers/json"]) + if tt.expectedPodman { + assert.Equal(t, 0, requestCounts["/version"]) + } else { + assert.Equal(t, 1, requestCounts["/version"]) + } + + stats, err = dm.getDockerStats(defaultCacheTimeMs) + require.NoError(t, err) + assert.Empty(t, stats) + assert.Equal(t, tt.expectedGood, dm.goodDockerVersion) + assert.Equal(t, tt.expectedPodman, dm.usingPodman) + assert.Equal(t, 2, requestCounts["/containers/json"]) + if tt.expectedPodman { + assert.Equal(t, 0, requestCounts["/version"]) + } else { + assert.Equal(t, 1, requestCounts["/version"]) + } + }) + } + +} + +func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) { + requestCounts := map[string]int{} + versionStatuses := []int{http.StatusServiceUnavailable, http.StatusOK} + versionBodies := []string{`"not ready"`, `{"Version":"25.1.0"}`} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCounts[r.URL.EscapedPath()]++ + switch r.URL.EscapedPath() { + case "/containers/json": + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `[]`) + case "/version": + idx := requestCounts["/version"] - 1 + if idx >= len(versionStatuses) { + idx = len(versionStatuses) - 1 + } + w.WriteHeader(versionStatuses[idx]) + fmt.Fprint(w, versionBodies[idx]) + default: + t.Fatalf("unexpected path: %s", r.URL.EscapedPath()) + } + })) + defer server.Close() + + dm := newDockerManagerForVersionTest(server) + + stats, err := dm.getDockerStats(defaultCacheTimeMs) + require.NoError(t, err) + assert.Empty(t, stats) + assert.False(t, dm.dockerVersionChecked) + assert.False(t, dm.goodDockerVersion) + assert.Equal(t, 1, requestCounts["/version"]) + + stats, err = dm.getDockerStats(defaultCacheTimeMs) + require.NoError(t, err) + assert.Empty(t, stats) + assert.True(t, dm.dockerVersionChecked) + assert.True(t, dm.goodDockerVersion) + assert.Equal(t, 2, requestCounts["/containers/json"]) + assert.Equal(t, 2, requestCounts["/version"]) + + stats, err = dm.getDockerStats(defaultCacheTimeMs) + require.NoError(t, err) + assert.Empty(t, stats) + assert.Equal(t, 3, requestCounts["/containers/json"]) + assert.Equal(t, 2, requestCounts["/version"]) +} + func TestCycleCpuDeltas(t *testing.T) { dm := &dockerManager{ lastCpuContainer: map[uint16]map[string]uint64{ diff --git a/agent/server.go b/agent/server.go index 5f42bbb4..0b584d75 100644 --- a/agent/server.go +++ b/agent/server.go @@ -193,7 +193,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes // handleLegacyStats serves the legacy one-shot stats payload for older hubs func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error { - stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000}) + stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs}) return a.writeToSession(w, stats, hubVersion) } diff --git a/agent/system.go b/agent/system.go index d15245a2..bed0eb25 100644 --- a/agent/system.go +++ b/agent/system.go @@ -115,6 +115,26 @@ func (a *Agent) refreshSystemDetails() { } } +// attachSystemDetails returns details only for fresh default-interval responses. +func (a *Agent) attachSystemDetails(data *system.CombinedData, cacheTimeMs uint16, includeRequested bool) *system.CombinedData { + if cacheTimeMs != defaultDataCacheTimeMs || (!includeRequested && !a.detailsDirty) { + return data + } + + // copy data to avoid adding details to the original cached struct + response := *data + response.Details = &a.systemDetails + a.detailsDirty = false + return &response +} + +// updateSystemDetails applies a mutation to the static details payload and marks +// it for inclusion on the next fresh default-interval response. +func (a *Agent) updateSystemDetails(updateFunc func(details *system.Details)) { + updateFunc(&a.systemDetails) + a.detailsDirty = true +} + // Returns current info, stats about the host system func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { var systemStats system.Stats diff --git a/agent/system_test.go b/agent/system_test.go new file mode 100644 index 00000000..ff74696d --- /dev/null +++ b/agent/system_test.go @@ -0,0 +1,61 @@ +package agent + +import ( + "testing" + + "github.com/henrygd/beszel/internal/common" + "github.com/henrygd/beszel/internal/entities/system" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) { + agent := &Agent{ + cache: NewSystemDataCache(), + systemDetails: system.Details{Hostname: "updated-host", Podman: true}, + detailsDirty: true, + } + cached := &system.CombinedData{ + Info: system.Info{Hostname: "cached-host"}, + } + agent.cache.Set(cached, defaultDataCacheTimeMs) + + response := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs}) + + assert.Same(t, cached, response) + assert.Nil(t, response.Details) + assert.True(t, agent.detailsDirty) + assert.Equal(t, "cached-host", response.Info.Hostname) + assert.Nil(t, cached.Details) + + secondResponse := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs}) + assert.Same(t, cached, secondResponse) + assert.Nil(t, secondResponse.Details) +} + +func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) { + agent := &Agent{} + + agent.updateSystemDetails(func(details *system.Details) { + details.Hostname = "updated-host" + details.Podman = true + }) + + assert.True(t, agent.detailsDirty) + assert.Equal(t, "updated-host", agent.systemDetails.Hostname) + assert.True(t, agent.systemDetails.Podman) + + original := &system.CombinedData{} + realTimeResponse := agent.attachSystemDetails(original, 1000, true) + assert.Same(t, original, realTimeResponse) + assert.Nil(t, realTimeResponse.Details) + assert.True(t, agent.detailsDirty) + + response := agent.attachSystemDetails(original, defaultDataCacheTimeMs, false) + require.NotNil(t, response.Details) + assert.NotSame(t, original, response) + assert.Equal(t, "updated-host", response.Details.Hostname) + assert.True(t, response.Details.Podman) + assert.False(t, agent.detailsDirty) + assert.Nil(t, original.Details) +}