//go:build testing package systems_test import ( "encoding/json" "testing" "time" "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/tests" "github.com/pocketbase/dbx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCreateRecordsRejectsNullSystemdService(t *testing.T) { hub, user := tests.GetHubWithUser(t) defer hub.Cleanup() records, err := tests.CreateSystems(hub, 1, user.Id, "paused") require.NoError(t, err) sys, err := hub.GetSystemManager().GetSystem(records[0].Id) require.NoError(t, err) require.NoError(t, systems.CreateSystemdStatsRecords(hub, []*systemd.Service{ {Name: "existing.service", State: systemd.StatusFailed}, }, records[0].Id)) for _, services := range []string{`[null]`, `[{"name":"new.service"},null]`, `[null,{"name":"new.service"}]`} { for _, encoding := range []string{"json", "cbor"} { t.Run(encoding+"/"+services, func(t *testing.T) { var data system.CombinedData require.NoError(t, json.Unmarshal([]byte(`{"systemd":`+services+`}`), &data)) if encoding == "cbor" { encoded, err := cbor.Marshal(data) require.NoError(t, err) data = system.CombinedData{} require.NoError(t, cbor.Unmarshal(encoded, &data)) } _, err := sys.CreateRecords(&data) require.ErrorContains(t, err, "null systemd service") var names []string require.NoError(t, hub.DB().Select("name").From("systemd_services"). Where(dbx.HashExp{"system": records[0].Id}).Column(&names)) assert.Equal(t, []string{"existing.service"}, names) count, err := hub.CountRecords("system_stats", dbx.HashExp{"system": records[0].Id}) require.NoError(t, err) assert.Zero(t, count, "invalid snapshot must roll back system stats") }) } } } func TestCreateRecordsHandlesSystemdAlertLifecycle(t *testing.T) { hub, user := tests.GetHubWithUser(t) defer hub.Cleanup() settings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id}) require.NoError(t, err) settings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) require.NoError(t, hub.Save(settings)) systemRecords, err := tests.CreateSystems(hub, 1, user.Id, "paused") require.NoError(t, err) systemRecord := systemRecords[0] alert, err := tests.CreateRecord(hub, "alerts", map[string]any{ "name": "SystemdFailed", "system": systemRecord.Id, "user": user.Id, }) require.NoError(t, err) monitoredSystem, err := hub.GetSystemManager().GetSystem(systemRecord.Id) require.NoError(t, err) initialEmailCount := hub.TestMailer.TotalSend() // Exercise the production path: persist the snapshot transactionally, save the // system record, and let its update hook evaluate and deliver the alert. _, err = monitoredSystem.CreateRecords(&system.CombinedData{ Info: system.Info{Services: []uint16{1, 1}}, SystemdServicesUpdated: true, SystemdServices: []*systemd.Service{ {Name: "failed.service", State: systemd.StatusFailed}, }, }) require.NoError(t, err) alert, err = hub.FindRecordById("alerts", alert.Id) require.NoError(t, err) assert.True(t, alert.GetBool("triggered")) assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend()) serviceCount, err := hub.CountRecords("systemd_services", dbx.HashExp{"system": systemRecord.Id}) require.NoError(t, err) assert.EqualValues(t, 1, serviceCount) unresolvedCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"alert_id": alert.Id, "resolved": ""}) require.NoError(t, err) assert.EqualValues(t, 1, unresolvedCount) // A fresh empty snapshot must delete the old failed row and resolve the alert. _, err = monitoredSystem.CreateRecords(&system.CombinedData{ Info: system.Info{Services: []uint16{0, 0}}, SystemdServicesUpdated: true, }) require.NoError(t, err) alert, err = hub.FindRecordById("alerts", alert.Id) require.NoError(t, err) assert.False(t, alert.GetBool("triggered")) assert.Equal(t, initialEmailCount+2, hub.TestMailer.TotalSend()) serviceCount, err = hub.CountRecords("systemd_services", dbx.HashExp{"system": systemRecord.Id}) require.NoError(t, err) assert.Zero(t, serviceCount) unresolvedCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"alert_id": alert.Id, "resolved": ""}) require.NoError(t, err) assert.Zero(t, unresolvedCount) } // createSystemdStatsRecords upserts the reported services and must drop rows for // services the agent has stopped reporting, so a unit removed from the host doesn't // linger with its last known state until the retention sweep. func TestCreateSystemdStatsRecordsRemovesUnreportedServices(t *testing.T) { hub, err := tests.NewTestHub(t.TempDir()) require.NoError(t, err) defer hub.Cleanup() user, err := tests.CreateUser(hub, "test@example.com", "password") require.NoError(t, err) system, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "host": "127.0.0.1", "users": []string{user.Id}, }) require.NoError(t, err) serviceNames := func() []string { var out []string require.NoError(t, hub.DB().Select("name").From("systemd_services"). Where(dbx.NewExp("system={:s}", dbx.Params{"s": system.Id})). OrderBy("name").Column(&out)) return out } require.NoError(t, systems.CreateSystemdStatsRecords(hub, []*systemd.Service{ {Name: "a.service", State: systemd.StatusActive}, {Name: "gone.service", State: systemd.StatusFailed}, }, system.Id)) assert.Equal(t, []string{"a.service", "gone.service"}, serviceNames()) // Batches are stamped with millisecond precision and update cycles are a minute // apart in practice; ensure the next batch gets a distinct timestamp. time.Sleep(2 * time.Millisecond) // gone.service is no longer reported, so its row must not survive. require.NoError(t, systems.CreateSystemdStatsRecords(hub, []*systemd.Service{ {Name: "a.service", State: systemd.StatusActive}, }, system.Id)) assert.Equal(t, []string{"a.service"}, serviceNames()) // A fresh empty snapshot means the agent no longer reports any services. require.NoError(t, systems.CreateSystemdStatsRecords(hub, nil, system.Id)) assert.Empty(t, serviceNames()) }