diff --git a/.changelog/226.txt b/.changelog/226.txt new file mode 100644 index 00000000..280aa01a --- /dev/null +++ b/.changelog/226.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +The sysinfo APIs (e.g. `Host()`, `Process()`) now accept an optional argument to force reading from an alternative filesystem root. This can be useful inside of containers to read data from the Linux host. +``` \ No newline at end of file diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 071e2d63..00a9d2c7 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -23,22 +23,50 @@ import ( "github.com/elastic/go-sysinfo/types" ) -var ( - hostProvider HostProvider - processProvider ProcessProvider +type ( + HostOptsCreator = func(ProviderOptions) HostProvider + ProcessOptsCreator = func(ProviderOptions) ProcessProvider ) +// HostProvider defines interfaces that provide host-specific metrics type HostProvider interface { Host() (types.Host, error) } +// ProcessProvider defines interfaces that provide process-specific metrics type ProcessProvider interface { Processes() ([]types.Process, error) Process(pid int) (types.Process, error) Self() (types.Process, error) } +type ProviderOptions struct { + Hostfs string +} + +var ( + hostProvider HostProvider + processProvider ProcessProvider + processProviderWithOpts ProcessOptsCreator + hostProviderWithOpts HostOptsCreator +) + +// Register a metrics provider. `provider` should implement one or more of `ProcessProvider` or `HostProvider` func Register(provider interface{}) { + if h, ok := provider.(ProcessOptsCreator); ok { + if processProviderWithOpts != nil { + panic(fmt.Sprintf("ProcessOptsCreator already registered: %T", processProviderWithOpts)) + } + processProviderWithOpts = h + } + + if h, ok := provider.(HostOptsCreator); ok { + if hostProviderWithOpts != nil { + panic(fmt.Sprintf("HostOptsCreator already registered: %T", hostProviderWithOpts)) + } + hostProviderWithOpts = h + } + if h, ok := provider.(HostProvider); ok { if hostProvider != nil { panic(fmt.Sprintf("HostProvider already registered: %v", hostProvider)) @@ -54,5 +82,18 @@ func Register(provider interface{}) { } } -func GetHostProvider() HostProvider { return hostProvider } -func GetProcessProvider() ProcessProvider { return processProvider } +// GetHostProvider returns the HostProvider registered for the system. May return nil. +func GetHostProvider(opts ProviderOptions) HostProvider { + if hostProviderWithOpts != nil { + return hostProviderWithOpts(opts) + } + return hostProvider +} + +// GetProcessProvider returns the ProcessProvider registered on the system. May return nil. +func GetProcessProvider(opts ProviderOptions) ProcessProvider { + if processProviderWithOpts != nil { + return processProviderWithOpts(opts) + } + return processProvider +} diff --git a/providers/linux/host_linux.go b/providers/linux/host_linux.go index 6e48928d..3a09f631 100644 --- a/providers/linux/host_linux.go +++ b/providers/linux/host_linux.go @@ -34,7 +34,9 @@ import ( ) func init() { - registry.Register(newLinuxSystem("")) + // register wrappers that implement the HostFS versions of the ProcessProvider and HostProvider + registry.Register(func(opts registry.ProviderOptions) registry.HostProvider { return newLinuxSystem(opts.Hostfs) }) + registry.Register(func(opts registry.ProviderOptions) registry.ProcessProvider { return newLinuxSystem(opts.Hostfs) }) } type linuxSystem struct { @@ -45,7 +47,7 @@ func newLinuxSystem(hostFS string) linuxSystem { mountPoint := filepath.Join(hostFS, procfs.DefaultMountPoint) fs, _ := procfs.NewFS(mountPoint) return linuxSystem{ - procFS: procFS{FS: fs, mountPoint: mountPoint}, + procFS: procFS{FS: fs, mountPoint: mountPoint, baseMount: hostFS}, } } @@ -59,14 +61,17 @@ type host struct { info types.HostInfo } +// Info returns host info func (h *host) Info() types.HostInfo { return h.info } +// Memory returns memory info func (h *host) Memory() (*types.HostMemoryInfo, error) { - content, err := os.ReadFile(h.procFS.path("meminfo")) + path := h.procFS.path("meminfo") + content, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, fmt.Errorf("error reading meminfo file %s: %w", path, err) } return parseMemInfo(content) @@ -82,9 +87,10 @@ func (h *host) FQDN() (string, error) { // VMStat reports data from /proc/vmstat on linux. func (h *host) VMStat() (*types.VMStatInfo, error) { - content, err := os.ReadFile(h.procFS.path("vmstat")) + path := h.procFS.path("vmstat") + content, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, fmt.Errorf("error reading vmstat file %s: %w", path, err) } return parseVMStat(content) @@ -94,7 +100,7 @@ func (h *host) VMStat() (*types.VMStatInfo, error) { func (h *host) LoadAverage() (*types.LoadAverageInfo, error) { loadAvg, err := h.procFS.LoadAvg() if err != nil { - return nil, err + return nil, fmt.Errorf("error fetching load averages: %w", err) } return &types.LoadAverageInfo{ @@ -106,31 +112,34 @@ func (h *host) LoadAverage() (*types.LoadAverageInfo, error) { // NetworkCounters reports data from /proc/net on linux func (h *host) NetworkCounters() (*types.NetworkCountersInfo, error) { - snmpRaw, err := os.ReadFile(h.procFS.path("net/snmp")) + snmpFile := h.procFS.path("net/snmp") + snmpRaw, err := os.ReadFile(snmpFile) if err != nil { - return nil, err + return nil, fmt.Errorf("error fetching net/snmp file %s: %w", snmpFile, err) } snmp, err := getNetSnmpStats(snmpRaw) if err != nil { - return nil, err + return nil, fmt.Errorf("error parsing SNMP stats: %w", err) } - netstatRaw, err := os.ReadFile(h.procFS.path("net/netstat")) + netstatFile := h.procFS.path("net/netstat") + netstatRaw, err := os.ReadFile(netstatFile) if err != nil { - return nil, err + return nil, fmt.Errorf("error fetching net/netstat file %s: %w", netstatFile, err) } netstat, err := getNetstatStats(netstatRaw) if err != nil { - return nil, err + return nil, fmt.Errorf("error parsing netstat file: %w", err) } return &types.NetworkCountersInfo{SNMP: snmp, Netstat: netstat}, nil } +// CPUTime returns host CPU usage metrics func (h *host) CPUTime() (types.CPUTimes, error) { stat, err := h.procFS.Stat() if err != nil { - return types.CPUTimes{}, err + return types.CPUTimes{}, fmt.Errorf("error fetching CPU stats: %w", err) } return types.CPUTimes{ @@ -246,7 +255,7 @@ func (r *reader) kernelVersion(h *host) { } func (r *reader) os(h *host) { - v, err := OperatingSystem() + v, err := getOSInfo(h.procFS.baseMount) if r.addErr(err) { return } @@ -258,7 +267,7 @@ func (r *reader) time(h *host) { } func (r *reader) uniqueID(h *host) { - v, err := MachineID() + v, err := MachineIDHostfs(h.procFS.baseMount) if r.addErr(err) { return } @@ -268,6 +277,7 @@ func (r *reader) uniqueID(h *host) { type procFS struct { procfs.FS mountPoint string + baseMount string } func (fs *procFS) path(p ...string) string { diff --git a/providers/linux/machineid.go b/providers/linux/machineid.go index 3252eb24..a5e8afaa 100644 --- a/providers/linux/machineid.go +++ b/providers/linux/machineid.go @@ -21,6 +21,7 @@ import ( "bytes" "fmt" "os" + "path/filepath" "github.com/elastic/go-sysinfo/types" ) @@ -29,12 +30,12 @@ import ( // These will be searched in order. var machineIDFiles = []string{"/etc/machine-id", "/var/lib/dbus/machine-id", "/var/db/dbus/machine-id"} -func MachineID() (string, error) { +func machineID(hostfs string) (string, error) { var contents []byte var err error for _, file := range machineIDFiles { - contents, err = os.ReadFile(file) + contents, err = os.ReadFile(filepath.Join(hostfs, file)) if err != nil { if os.IsNotExist(err) { // Try next location @@ -57,3 +58,11 @@ func MachineID() (string, error) { contents = bytes.TrimSpace(contents) return string(contents), nil } + +func MachineIDHostfs(hostfs string) (string, error) { + return machineID(hostfs) +} + +func MachineID() (string, error) { + return machineID("") +} diff --git a/providers/linux/machineid_test.go b/providers/linux/machineid_test.go new file mode 100644 index 00000000..3dee6ec0 --- /dev/null +++ b/providers/linux/machineid_test.go @@ -0,0 +1,32 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package linux + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMachineIDLookup(t *testing.T) { + path := "testdata/fedora30" + known := "144d62edb0f142458f320852f495b72c" + id, err := MachineIDHostfs(path) + require.NoError(t, err) + require.Equal(t, known, id) +} diff --git a/providers/linux/os.go b/providers/linux/os.go index fb220289..6a98892c 100644 --- a/providers/linux/os.go +++ b/providers/linux/os.go @@ -68,6 +68,8 @@ func init() { } } +// OperatingSystem returns OS info. This does not take an alternate hostfs. +// to get OS info from an alternate root path, use reader.os() func OperatingSystem() (*types.OSInfo, error) { return getOSInfo("") } diff --git a/providers/linux/process_linux.go b/providers/linux/process_linux.go index dfbf5ca7..fc3c25be 100644 --- a/providers/linux/process_linux.go +++ b/providers/linux/process_linux.go @@ -19,6 +19,7 @@ package linux import ( "bytes" + "fmt" "os" "strconv" "strings" @@ -31,10 +32,11 @@ import ( const userHz = 100 +// Processes returns a list of processes on the system func (s linuxSystem) Processes() ([]types.Process, error) { procs, err := s.procFS.AllProcs() if err != nil { - return nil, err + return nil, fmt.Errorf("error fetching all processes: %w", err) } processes := make([]types.Process, 0, len(procs)) @@ -44,19 +46,21 @@ func (s linuxSystem) Processes() ([]types.Process, error) { return processes, nil } +// Process returns the given process func (s linuxSystem) Process(pid int) (types.Process, error) { - proc, err := s.procFS.NewProc(pid) + proc, err := s.procFS.Proc(pid) if err != nil { - return nil, err + return nil, fmt.Errorf("error fetching process: %w", err) } return &process{Proc: proc, fs: s.procFS}, nil } +// Self returns process info for the caller's own PID func (s linuxSystem) Self() (types.Process, error) { proc, err := s.procFS.Self() if err != nil { - return nil, err + return nil, fmt.Errorf("error fetching self process info: %w", err) } return &process{Proc: proc, fs: s.procFS}, nil @@ -68,19 +72,21 @@ type process struct { info *types.ProcessInfo } +// PID returns the PID of the process func (p *process) PID() int { return p.Proc.PID } +// Parent returns the parent process func (p *process) Parent() (types.Process, error) { info, err := p.Info() if err != nil { - return nil, err + return nil, fmt.Errorf("error fetching process info: %w", err) } - proc, err := p.fs.NewProc(info.PPID) + proc, err := p.fs.Proc(info.PPID) if err != nil { - return nil, err + return nil, fmt.Errorf("error fetching data for parent process: %w", err) } return &process{Proc: proc, fs: p.fs}, nil @@ -90,8 +96,8 @@ func (p *process) path(pa ...string) string { return p.fs.path(append([]string{strconv.Itoa(p.PID())}, pa...)...) } +// CWD returns the current working directory func (p *process) CWD() (string, error) { - // TODO: add CWD to procfs cwd, err := os.Readlink(p.path("cwd")) if os.IsNotExist(err) { return "", nil @@ -100,34 +106,35 @@ func (p *process) CWD() (string, error) { return cwd, err } +// Info returns basic process info func (p *process) Info() (types.ProcessInfo, error) { if p.info != nil { return *p.info, nil } - stat, err := p.NewStat() + stat, err := p.Stat() if err != nil { - return types.ProcessInfo{}, err + return types.ProcessInfo{}, fmt.Errorf("error fetching process stats: %w", err) } exe, err := p.Executable() if err != nil { - return types.ProcessInfo{}, err + return types.ProcessInfo{}, fmt.Errorf("error fetching process executable info: %w", err) } args, err := p.CmdLine() if err != nil { - return types.ProcessInfo{}, err + return types.ProcessInfo{}, fmt.Errorf("error fetching process cmdline: %w", err) } cwd, err := p.CWD() if err != nil { - return types.ProcessInfo{}, err + return types.ProcessInfo{}, fmt.Errorf("error fetching process CWD: %w", err) } bootTime, err := bootTime(p.fs.FS) if err != nil { - return types.ProcessInfo{}, err + return types.ProcessInfo{}, fmt.Errorf("error fetching boot time: %w", err) } p.info = &types.ProcessInfo{ @@ -143,8 +150,9 @@ func (p *process) Info() (types.ProcessInfo, error) { return *p.info, nil } +// Memory returns memory stats for the process func (p *process) Memory() (types.MemoryInfo, error) { - stat, err := p.NewStat() + stat, err := p.Stat() if err != nil { return types.MemoryInfo{}, err } @@ -155,8 +163,9 @@ func (p *process) Memory() (types.MemoryInfo, error) { }, nil } +// CPUTime returns CPU usage time for the process func (p *process) CPUTime() (types.CPUTimes, error) { - stat, err := p.NewStat() + stat, err := p.Stat() if err != nil { return types.CPUTimes{}, err } @@ -177,6 +186,7 @@ func (p *process) OpenHandleCount() (int, error) { return p.Proc.FileDescriptorsLen() } +// Environment returns a list of environment variables for the process func (p *process) Environment() (map[string]string, error) { // TODO: add Environment to procfs content, err := os.ReadFile(p.path("environ")) @@ -203,6 +213,7 @@ func (p *process) Environment() (map[string]string, error) { return env, nil } +// Seccomp returns seccomp info for the process func (p *process) Seccomp() (*types.SeccompInfo, error) { content, err := os.ReadFile(p.path("status")) if err != nil { @@ -212,6 +223,7 @@ func (p *process) Seccomp() (*types.SeccompInfo, error) { return readSeccompFields(content) } +// Capabilities returns capability info for the process func (p *process) Capabilities() (*types.CapabilityInfo, error) { content, err := os.ReadFile(p.path("status")) if err != nil { @@ -221,6 +233,7 @@ func (p *process) Capabilities() (*types.CapabilityInfo, error) { return readCapabilities(content) } +// User returns user info for the process func (p *process) User() (types.UserInfo, error) { content, err := os.ReadFile(p.path("status")) if err != nil { @@ -248,6 +261,9 @@ func (p *process) User() (types.UserInfo, error) { } return nil }) + if err != nil { + return user, fmt.Errorf("error partsing key-values in user data: %w", err) + } return user, nil } @@ -256,20 +272,20 @@ func (p *process) User() (types.UserInfo, error) { func (p *process) NetworkCounters() (*types.NetworkCountersInfo, error) { snmpRaw, err := os.ReadFile(p.path("net/snmp")) if err != nil { - return nil, err + return nil, fmt.Errorf("error reading net/snmp file: %w", err) } snmp, err := getNetSnmpStats(snmpRaw) if err != nil { - return nil, err + return nil, fmt.Errorf("error parsing SNMP network data: %w", err) } netstatRaw, err := os.ReadFile(p.path("net/netstat")) if err != nil { - return nil, err + return nil, fmt.Errorf("error reading net/netstat file: %w", err) } netstat, err := getNetstatStats(netstatRaw) if err != nil { - return nil, err + return nil, fmt.Errorf("error parsing netstat file: %w", err) } return &types.NetworkCountersInfo{SNMP: snmp, Netstat: netstat}, nil diff --git a/providers/linux/testdata/fedora30/etc/machine-id b/providers/linux/testdata/fedora30/etc/machine-id new file mode 100644 index 00000000..b20597a5 --- /dev/null +++ b/providers/linux/testdata/fedora30/etc/machine-id @@ -0,0 +1 @@ +144d62edb0f142458f320852f495b72c diff --git a/providers/linux/testdata/fedora40/proc/33925/stat b/providers/linux/testdata/fedora40/proc/33925/stat new file mode 100644 index 00000000..08066eb3 --- /dev/null +++ b/providers/linux/testdata/fedora40/proc/33925/stat @@ -0,0 +1 @@ +33925 (rpc.statd) S 1 33925 33925 0 -1 4194624 104 0 0 0 1 0 0 0 20 0 1 0 840035 10326016 664 18446744073709551615 1 1 0 0 0 0 0 69632 18947 0 0 0 17 3 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/providers/linux/util.go b/providers/linux/util.go index f97f1059..1c1d0584 100644 --- a/providers/linux/util.go +++ b/providers/linux/util.go @@ -93,6 +93,7 @@ func decodeBitMap(s string, lookupName func(int) string) ([]string, error) { return names, nil } +// parses a meminfo field, returning either a raw numerical value, or the kB value converted to bytes func parseBytesOrNumber(data []byte) (uint64, error) { parts := bytes.Fields(data) diff --git a/system.go b/system.go index b9a33607..e2edfe22 100644 --- a/system.go +++ b/system.go @@ -30,6 +30,21 @@ import ( _ "github.com/elastic/go-sysinfo/providers/windows" ) +type ProviderOption func(*registry.ProviderOptions) + +// WithHostFS returns a provider with a custom HostFS root path, +// enabling use of the library from within a container, or an alternate root path on linux. +// For example, WithHostFS("/hostfs") can be used when /hostfs points to the root filesystem of the container host. +// For full functionality, the alternate hostfs should have: +// - /proc +// - /var +// - /etc +func WithHostFS(hostfs string) ProviderOption { + return func(po *registry.ProviderOptions) { + po.Hostfs = hostfs + } +} + // Go returns information about the Go runtime. func Go() types.GoInfo { return types.GoInfo{ @@ -40,12 +55,31 @@ func Go() types.GoInfo { } } +func applyOptsAndReturnProvider(opts ...ProviderOption) registry.ProviderOptions { + options := registry.ProviderOptions{} + for _, opt := range opts { + opt(&options) + } + return options +} + +// setupProcessProvider returns a ProcessProvider. +// Most of the exported functions here deal with processes, +// so this just gets wrapped by all the external functions +func setupProcessProvider(opts ...ProviderOption) (registry.ProcessProvider, error) { + provider := registry.GetProcessProvider(applyOptsAndReturnProvider(opts...)) + if provider == nil { + return nil, types.ErrNotImplemented + } + return provider, nil +} + // Host returns information about host on which this process is running. If // host information collection is not implemented for this platform then // types.ErrNotImplemented is returned. // On Darwin (macOS) a types.ErrNotImplemented is returned with cgo disabled. -func Host() (types.Host, error) { - provider := registry.GetHostProvider() +func Host(opts ...ProviderOption) (types.Host, error) { + provider := registry.GetHostProvider(applyOptsAndReturnProvider(opts...)) if provider == nil { return nil, types.ErrNotImplemented } @@ -56,10 +90,10 @@ func Host() (types.Host, error) { // with the given PID. The types.Process object can be used to query information // about the process. If process information collection is not implemented for // this platform then types.ErrNotImplemented is returned. -func Process(pid int) (types.Process, error) { - provider := registry.GetProcessProvider() - if provider == nil { - return nil, types.ErrNotImplemented +func Process(pid int, opts ...ProviderOption) (types.Process, error) { + provider, err := setupProcessProvider(opts...) + if err != nil { + return nil, err } return provider.Process(pid) } @@ -67,10 +101,10 @@ func Process(pid int) (types.Process, error) { // Processes return a list of all processes. If process information collection // is not implemented for this platform then types.ErrNotImplemented is // returned. -func Processes() ([]types.Process, error) { - provider := registry.GetProcessProvider() - if provider == nil { - return nil, types.ErrNotImplemented +func Processes(opts ...ProviderOption) ([]types.Process, error) { + provider, err := setupProcessProvider(opts...) + if err != nil { + return nil, err } return provider.Processes() } @@ -78,10 +112,10 @@ func Processes() ([]types.Process, error) { // Self return a types.Process object representing this process. If process // information collection is not implemented for this platform then // types.ErrNotImplemented is returned. -func Self() (types.Process, error) { - provider := registry.GetProcessProvider() - if provider == nil { - return nil, types.ErrNotImplemented +func Self(opts ...ProviderOption) (types.Process, error) { + provider, err := setupProcessProvider(opts...) + if err != nil { + return nil, err } return provider.Self() } diff --git a/system_test.go b/system_test.go index efb872e3..e84357f1 100644 --- a/system_test.go +++ b/system_test.go @@ -74,6 +74,32 @@ var expectedProcessFeatures = map[string]*ProcessFeatures{ }, } +func TestSystemHostFS(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("test is linux-only") + } + + handler, err := Host(WithHostFS("providers/linux/testdata/ubuntu1710")) + require.NoError(t, err) + memInfo, err := handler.Memory() + require.NoError(t, err) + // make sure we read the testdata file + require.Equal(t, memInfo.Free, uint64(2612703232)) +} + +func TestSystemProcessHostFS(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("test is linux-only") + } + + handler, err := Process(33925, WithHostFS("providers/linux/testdata/fedora40")) + require.NoError(t, err) + + _, err = handler.Memory() + require.NoError(t, err) + +} + func TestProcessFeaturesMatrix(t *testing.T) { const GOOS = runtime.GOOS var features ProcessFeatures