diff --git a/server/channels/app/platform/service.go b/server/channels/app/platform/service.go index 9993b4b1b69..112fb8f8b8a 100644 --- a/server/channels/app/platform/service.go +++ b/server/channels/app/platform/service.go @@ -120,6 +120,8 @@ type PlatformService struct { pdpService einterfaces.PolicyDecisionPointInterface + startTime time.Time + // installTypeOverride overrides MM_INSTALL_TYPE in support packet diagnostics. installTypeOverride string @@ -150,6 +152,7 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { Store: sc.Store, clusterIFace: sc.Cluster, hashSeed: maphash.MakeSeed(), + startTime: time.Now(), goroutineExitSignal: make(chan struct{}, 1), goroutineBuffered: make(chan struct{}, runtime.NumCPU()), WebSocketRouter: &WebSocketRouter{ diff --git a/server/channels/app/platform/support_packet.go b/server/channels/app/platform/support_packet.go index 917bbd87bcd..45a304b9c0a 100644 --- a/server/channels/app/platform/support_packet.go +++ b/server/channels/app/platform/support_packet.go @@ -107,6 +107,11 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model if err != nil { rErr = multierror.Append(errors.Wrap(err, "error while getting hostname")) } + d.Server.ProcessID = os.Getpid() + d.Server.StartedAt = ps.startTime.UTC() + if hostUptimeSeconds, hostUptimeErr := getHostUptimeSeconds(); hostUptimeErr == nil { + d.Server.HostStartedAt = time.Now().Add(-time.Duration(hostUptimeSeconds) * time.Second).UTC() + } d.Server.Version = model.CurrentVersion d.Server.BuildHash = model.BuildHash d.Server.GoVersion = runtime.Version() @@ -126,7 +131,6 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model if err != nil { rErr = multierror.Append(rErr, errors.Wrap(err, "error while getting max file descriptor limit")) } - d.Server.ProcessID = os.Getpid() /* Config */ d.Config.Source = ps.DescribeConfig() diff --git a/server/channels/app/platform/support_packet_test.go b/server/channels/app/platform/support_packet_test.go index 7050c113265..78f98145c19 100644 --- a/server/channels/app/platform/support_packet_test.go +++ b/server/channels/app/platform/support_packet_test.go @@ -9,6 +9,7 @@ import ( "errors" "os" "path" + "runtime" "testing" "github.com/goccy/go-yaml" @@ -213,6 +214,13 @@ func TestGetSupportPacketDiagnostics(t *testing.T) { assert.True(t, d.Server.OpenFileDescriptors == -1 || d.Server.OpenFileDescriptors > 0, "OpenFileDescriptors should be -1 (unsupported) or positive, got %d", d.Server.OpenFileDescriptors) assert.True(t, d.Server.MaxFileDescriptors == -1 || d.Server.MaxFileDescriptors > 0, "MaxFileDescriptors should be -1 (unsupported) or positive, got %d", d.Server.MaxFileDescriptors) assert.Positive(t, d.Server.ProcessID) + assert.False(t, d.Server.StartedAt.IsZero()) + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + assert.False(t, d.Server.HostStartedAt.IsZero()) + assert.True(t, !d.Server.HostStartedAt.After(d.Server.StartedAt)) + } else { + assert.True(t, d.Server.HostStartedAt.IsZero(), "HostStartedAt should be zero on unsupported platforms") + } /* Config */ assert.Equal(t, "memory://", d.Config.Source) diff --git a/server/channels/app/platform/uptime_darwin.go b/server/channels/app/platform/uptime_darwin.go new file mode 100644 index 00000000000..1bff75d5834 --- /dev/null +++ b/server/channels/app/platform/uptime_darwin.go @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build darwin + +package platform + +import ( + "fmt" + "time" + + "golang.org/x/sys/unix" +) + +// getHostUptimeSeconds uses kern.boottime sysctl to return the number of seconds +// the host OS has been running. +func getHostUptimeSeconds() (int64, error) { + tv, err := unix.SysctlTimeval("kern.boottime") + if err != nil { + return 0, fmt.Errorf("failed to get kern.boottime: %w", err) + } + bootTime := time.Unix(tv.Sec, int64(tv.Usec)*int64(time.Microsecond)) + return int64(time.Since(bootTime).Seconds()), nil +} diff --git a/server/channels/app/platform/uptime_darwin_test.go b/server/channels/app/platform/uptime_darwin_test.go new file mode 100644 index 00000000000..9d6c9edaa90 --- /dev/null +++ b/server/channels/app/platform/uptime_darwin_test.go @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build darwin + +package platform + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetHostUptimeSeconds(t *testing.T) { + t.Run("returns a positive value from kern.boottime", func(t *testing.T) { + seconds, err := getHostUptimeSeconds() + require.NoError(t, err) + assert.Positive(t, seconds) + }) +} diff --git a/server/channels/app/platform/uptime_linux.go b/server/channels/app/platform/uptime_linux.go new file mode 100644 index 00000000000..8baaa1687de --- /dev/null +++ b/server/channels/app/platform/uptime_linux.go @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build linux + +package platform + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// getHostUptimeSeconds reads /proc/uptime and returns the number of seconds +// the host OS has been running. +func getHostUptimeSeconds() (int64, error) { + return parseUptimeFile("/proc/uptime") +} + +// parseUptimeFile reads an uptime file in the /proc/uptime format and returns +// the number of seconds as an int64. It is a separate function to allow unit +// tests to supply a synthetic file path. +func parseUptimeFile(path string) (int64, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, fmt.Errorf("failed to read /proc/uptime: %w", err) + } + + fields := strings.Fields(string(data)) + if len(fields) == 0 { + return 0, fmt.Errorf("unexpected /proc/uptime format") + } + + f, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return 0, fmt.Errorf("failed to parse /proc/uptime value: %w", err) + } + + return int64(f), nil +} diff --git a/server/channels/app/platform/uptime_linux_test.go b/server/channels/app/platform/uptime_linux_test.go new file mode 100644 index 00000000000..c356fbb5f5c --- /dev/null +++ b/server/channels/app/platform/uptime_linux_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build linux + +package platform + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetHostUptimeSeconds(t *testing.T) { + t.Run("returns a positive value from /proc/uptime", func(t *testing.T) { + seconds, err := getHostUptimeSeconds() + require.NoError(t, err) + assert.Positive(t, seconds) + }) + + t.Run("error on unreadable file", func(t *testing.T) { + missingPath := t.TempDir() + "/uptime" + _, err := parseUptimeFile(missingPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read") + }) + + t.Run("error on empty file", func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "uptime") + require.NoError(t, err) + require.NoError(t, f.Close()) + + _, err = parseUptimeFile(f.Name()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected /proc/uptime format") + }) + + t.Run("error on non-numeric first field", func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "uptime") + require.NoError(t, err) + _, err = f.WriteString("notanumber 0.00\n") + require.NoError(t, err) + require.NoError(t, f.Close()) + + _, err = parseUptimeFile(f.Name()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse /proc/uptime value") + }) + + t.Run("parses valid uptime correctly", func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "uptime") + require.NoError(t, err) + _, err = f.WriteString("12345.67 890.12\n") + require.NoError(t, err) + require.NoError(t, f.Close()) + + seconds, err := parseUptimeFile(f.Name()) + require.NoError(t, err) + assert.Equal(t, int64(12345), seconds) + }) +} diff --git a/server/channels/app/platform/uptime_other.go b/server/channels/app/platform/uptime_other.go new file mode 100644 index 00000000000..2e3e171ad5f --- /dev/null +++ b/server/channels/app/platform/uptime_other.go @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build !linux && !darwin + +package platform + +import "errors" + +// ErrHostUptimeUnsupportedPlatform is returned when host uptime detection is not supported on the current platform. +var ErrHostUptimeUnsupportedPlatform = errors.New("host uptime detection not supported on this platform") + +// getHostUptimeSeconds returns an error on non-Linux platforms. +func getHostUptimeSeconds() (int64, error) { + return 0, ErrHostUptimeUnsupportedPlatform +} diff --git a/server/channels/app/platform/uptime_other_test.go b/server/channels/app/platform/uptime_other_test.go new file mode 100644 index 00000000000..d56232d1a00 --- /dev/null +++ b/server/channels/app/platform/uptime_other_test.go @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build !linux && !darwin + +package platform + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetHostUptimeSeconds(t *testing.T) { + seconds, err := getHostUptimeSeconds() + require.Error(t, err) + assert.ErrorIs(t, err, ErrHostUptimeUnsupportedPlatform) + assert.Equal(t, int64(0), seconds) +} diff --git a/server/public/model/support_packet.go b/server/public/model/support_packet.go index 37bcdb25289..b8c598bdb4f 100644 --- a/server/public/model/support_packet.go +++ b/server/public/model/support_packet.go @@ -6,6 +6,7 @@ package model import ( "encoding/json" "io" + "time" ) const ( @@ -25,18 +26,20 @@ type SupportPacketDiagnostics struct { } `yaml:"license"` Server struct { - OS string `yaml:"os"` - Architecture string `yaml:"architecture"` - CPUCores int `yaml:"cpu_cores"` - TotalMemoryMB uint64 `yaml:"total_memory_mb"` - OpenFileDescriptors int64 `yaml:"open_file_descriptors"` - MaxFileDescriptors int64 `yaml:"max_file_descriptors"` - Hostname string `yaml:"hostname"` - ProcessID int `yaml:"process_id"` - Version string `yaml:"version"` - BuildHash string `yaml:"build_hash"` - GoVersion string `yaml:"go_version"` - InstallationType string `yaml:"installation_type"` + OS string `yaml:"os"` + Architecture string `yaml:"architecture"` + CPUCores int `yaml:"cpu_cores"` + TotalMemoryMB uint64 `yaml:"total_memory_mb"` + OpenFileDescriptors int64 `yaml:"open_file_descriptors"` + MaxFileDescriptors int64 `yaml:"max_file_descriptors"` + Hostname string `yaml:"hostname"` + ProcessID int `yaml:"process_id"` + StartedAt time.Time `yaml:"started_at"` + HostStartedAt time.Time `yaml:"host_started_at,omitempty"` + Version string `yaml:"version"` + BuildHash string `yaml:"build_hash"` + GoVersion string `yaml:"go_version"` + InstallationType string `yaml:"installation_type"` } `yaml:"server"` Config struct {