diff --git a/.changelog/126.txt b/.changelog/126.txt new file mode 100644 index 00000000..ffba75e1 --- /dev/null +++ b/.changelog/126.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +freebsd: Implement a new provider for FreeBSD that requires CGO. +``` diff --git a/.ci/scripts/check-cross-compile.sh b/.ci/scripts/check-cross-compile.sh index 82de7c22..021e3faf 100755 --- a/.ci/scripts/check-cross-compile.sh +++ b/.ci/scripts/check-cross-compile.sh @@ -9,6 +9,7 @@ export CGO_ENABLED=0 GOOS=aix GOARCH=ppc64 go build ./... GOOS=darwin GOARCH=amd64 go build ./... GOOS=darwin GOARCH=arm64 go build ./... +GOOS=freebsd GOARCH=amd64 go build ./... GOOS=linux GOARCH=386 go build ./... GOOS=linux GOARCH=amd64 go build ./... GOOS=linux GOARCH=arm go build ./... diff --git a/Makefile b/Makefile index 9d4e6b17..4cc3108f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.phony: update +.PHONY: update update: fmt lic imports .PHONY: lic diff --git a/README.md b/README.md index cd2bd514..7e603282 100644 --- a/README.md +++ b/README.md @@ -30,28 +30,28 @@ if handleCounter, ok := process.(types.OpenHandleCounter); ok { These tables show what methods are implemented as well as the extra interfaces that are implemented. -| `Host` Features | Darwin | Linux | Windows | AIX | -|------------------|--------|-------|---------|-----| -| `Info()` | x | x | x | x | -| `Memory()` | x | x | x | x | -| `CPUTimer` | x | x | x | x | -| `LoadAverage` | x | x | | | -| `VMStat` | | x | | | -| `NetworkCounters`| | x | | | - -| `Process` Features | Darwin | Linux | Windows | AIX | -|------------------------|--------|-------|---------|-----| -| `Info()` | x | x | x | x | -| `Memory()` | x | x | x | x | -| `User()` | x | x | x | x | -| `Parent()` | x | x | x | x | -| `CPUTimer` | x | x | x | x | -| `Environment` | x | x | | x | -| `OpenHandleEnumerator` | | x | | | -| `OpenHandleCounter` | | x | | | -| `Seccomp` | | x | | | -| `Capabilities` | | x | | | -| `NetworkCounters` | | x | | | +| `Host` Features | Darwin | Linux | Windows | AIX | FreeBSD | +|-------------------|--------|-------|---------|-----|---------| +| `Info()` | x | x | x | x | x | +| `Memory()` | x | x | x | x | x | +| `CPUTimer` | x | x | x | x | x | +| `LoadAverage` | x | x | | | | +| `VMStat` | | x | | | | +| `NetworkCounters` | | x | | | | + +| `Process` Features | Darwin | Linux | Windows | AIX | FreeBSD | +|------------------------|--------|-------|---------|-----|---------| +| `Info()` | x | x | x | x | x | +| `Memory()` | x | x | x | x | x | +| `User()` | x | x | x | x | x | +| `Parent()` | x | x | x | x | x | +| `CPUTimer` | x | x | x | x | x | +| `Environment` | x | x | | x | x | +| `OpenHandleEnumerator` | | x | | | x | +| `OpenHandleCounter` | | x | | | x | +| `Seccomp` | | x | | | | +| `Capabilities` | | x | | | | +| `NetworkCounters` | | x | | | | ### GOOS / GOARCH Pairs @@ -62,6 +62,7 @@ This table lists the OS and architectures for which a "provider" is implemented. | aix/ppc64 | x | | | darwin/amd64 | optional * | x | | darwin/arm64 | optional * | x | +| freebsd/amd64 | x | x | | linux/386 | | | | linux/amd64 | | x | | linux/arm | | | diff --git a/internal/cgo/disabled.go b/internal/cgo/disabled.go new file mode 100644 index 00000000..b1a5879d --- /dev/null +++ b/internal/cgo/disabled.go @@ -0,0 +1,23 @@ +// 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. + +//go:build !cgo + +package cgo + +// Enabled is true if cgo was enabled at compile-time. +const Enabled = false diff --git a/internal/cgo/enabled.go b/internal/cgo/enabled.go new file mode 100644 index 00000000..036f1524 --- /dev/null +++ b/internal/cgo/enabled.go @@ -0,0 +1,23 @@ +// 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. + +//go:build cgo + +package cgo + +// Enabled is true if cgo was enabled at compile-time. +const Enabled = true diff --git a/providers/freebsd/defs_freebsd.go b/providers/freebsd/defs_freebsd.go new file mode 100644 index 00000000..34490a4b --- /dev/null +++ b/providers/freebsd/defs_freebsd.go @@ -0,0 +1,33 @@ +// 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. + +//go:build ignore +// +build ignore + +package freebsd + +/* +#include +#include +#include +#include +*/ +import "C" + +type kvmSwap C.struct_kvm_swap + +type clockInfo C.struct_clockinfo diff --git a/providers/freebsd/doc.go b/providers/freebsd/doc.go new file mode 100644 index 00000000..afdc9c1d --- /dev/null +++ b/providers/freebsd/doc.go @@ -0,0 +1,22 @@ +// 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 freebsd implements the HostProvider and ProcessProvider interfaces +// for providing information about FreeBSD. +package freebsd + +//go:generate sh -c "go tool cgo -godefs defs_freebsd.go > ztypes_freebsd.go" diff --git a/providers/freebsd/host_freebsd_cgo.go b/providers/freebsd/host_freebsd_cgo.go new file mode 100644 index 00000000..52c9a285 --- /dev/null +++ b/providers/freebsd/host_freebsd_cgo.go @@ -0,0 +1,238 @@ +// 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. + +//go:build freebsd && cgo + +package freebsd + +import ( + "context" + "errors" + "os" + "path/filepath" + "time" + + "github.com/prometheus/procfs" + + "github.com/elastic/go-sysinfo/internal/registry" + "github.com/elastic/go-sysinfo/providers/shared" + "github.com/elastic/go-sysinfo/types" +) + +func init() { + registry.Register(newFreeBSDSystem()) +} + +type freebsdSystem struct{} + +func newFreeBSDSystem() freebsdSystem { + return freebsdSystem{} +} + +func (s freebsdSystem) Host() (types.Host, error) { + return newHost() +} + +type host struct { + procFS procFS + info types.HostInfo +} + +func (h *host) Info() types.HostInfo { + return h.info +} + +func (h *host) CPUTime() (types.CPUTimes, error) { + cpu := types.CPUTimes{} + r := &reader{} + r.cpuTime(&cpu) + return cpu, r.Err() +} + +func (h *host) Memory() (*types.HostMemoryInfo, error) { + m := &types.HostMemoryInfo{} + r := &reader{} + r.memInfo(m) + return m, r.Err() +} + +func (h *host) FQDNWithContext(ctx context.Context) (string, error) { + return shared.FQDNWithContext(ctx) +} + +func (h *host) FQDN() (string, error) { + return h.FQDNWithContext(context.Background()) +} + +func newHost() (*host, error) { + h := &host{} + r := &reader{} + r.architecture(h) + r.bootTime(h) + r.hostname(h) + r.network(h) + r.kernelVersion(h) + r.os(h) + r.time(h) + r.uniqueID(h) + return h, r.Err() +} + +type reader struct { + errs []error +} + +func (r *reader) addErr(err error) bool { + if err != nil { + if !errors.Is(err, types.ErrNotImplemented) { + r.errs = append(r.errs, err) + } + return true + } + return false +} + +func (r *reader) Err() error { + return errors.Join(r.errs...) +} + +func (r *reader) cpuTime(cpu *types.CPUTimes) { + times, err := cpuStateTimes() + if r.addErr(err) { + return + } + *cpu = *times +} + +func (r *reader) memInfo(m *types.HostMemoryInfo) { + // Memory counter calculations: + // total = physical memory + // used = active + wired + // free = free + // available = buffers + inactive + cache + free + + pageSize, err := pageSizeBytes() + if r.addErr(err) { + return + } + + m.Total = totalPhysicalMem(r) + activePages := activePageCount(r) + + m.Metrics = make(map[string]uint64, 6) + m.Metrics["active_bytes"] = activePages * pageSize + + wirePages := wirePageCount(r) + m.Metrics["wired_bytes"] = wirePages * pageSize + + inactivePages := inactivePageCount(r) + m.Metrics["inactive_bytes"] = inactivePages * pageSize + + cachePages := cachePageCount(r) + m.Metrics["cache_bytes"] = cachePages * pageSize + + freePages := freePageCount(r) + m.Metrics["free_bytes"] = freePages * pageSize + + buffers := buffersUsedBytes(r) + m.Metrics["buffer_bytes"] = buffers + + m.Used = (activePages + wirePages) * pageSize + m.Free = freePages * pageSize + m.Available = (inactivePages+cachePages+freePages)*pageSize + buffers + + // Virtual (swap) Memory + swap, err := kvmGetSwapInfo() + if r.addErr(err) { + return + } + + m.VirtualTotal = uint64(swap.Total) * pageSize + m.VirtualUsed = uint64(swap.Used) * pageSize + m.VirtualFree = m.VirtualTotal - m.VirtualUsed +} + +func (r *reader) architecture(h *host) { + v, err := architecture() + if r.addErr(err) { + return + } + h.info.Architecture = v +} + +func (r *reader) bootTime(h *host) { + v, err := bootTime() + if r.addErr(err) { + return + } + h.info.BootTime = v +} + +func (r *reader) hostname(h *host) { + v, err := os.Hostname() + if r.addErr(err) { + return + } + h.info.Hostname = v +} + +func (r *reader) network(h *host) { + ips, macs, err := shared.Network() + if r.addErr(err) { + return + } + h.info.IPs = ips + h.info.MACs = macs +} + +func (r *reader) kernelVersion(h *host) { + v, err := kernelVersion() + if r.addErr(err) { + return + } + h.info.KernelVersion = v +} + +func (r *reader) os(h *host) { + v, err := operatingSystem() + if r.addErr(err) { + return + } + h.info.OS = v +} + +func (r *reader) time(h *host) { + h.info.Timezone, h.info.TimezoneOffsetSec = time.Now().Zone() +} + +func (r *reader) uniqueID(h *host) { + v, err := machineID() + if r.addErr(err) { + return + } + h.info.UniqueID = v +} + +type procFS struct { + procfs.FS + mountPoint string +} + +func (fs *procFS) path(p ...string) string { + elem := append([]string{fs.mountPoint}, p...) + return filepath.Join(elem...) +} diff --git a/providers/freebsd/host_freebsd_cgo_test.go b/providers/freebsd/host_freebsd_cgo_test.go new file mode 100644 index 00000000..4345cf01 --- /dev/null +++ b/providers/freebsd/host_freebsd_cgo_test.go @@ -0,0 +1,53 @@ +// 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. + +//go:build freebsd && cgo + +package freebsd + +import ( + "encoding/json" + "testing" + + "github.com/elastic/go-sysinfo/internal/registry" +) + +var _ registry.HostProvider = freebsdSystem{} + +func TestHost(t *testing.T) { + host, err := newFreeBSDSystem().Host() + if err != nil { + t.Fatal(err) + } + + t.Run("Info", func(t *testing.T) { + info := host.Info() + + data, _ := json.MarshalIndent(info, "", " ") + t.Log(string(data)) + }) + + t.Run("Memory", func(t *testing.T) { + mem, err := host.Memory() + if err != nil { + t.Fatal(err) + } + + data, _ := json.MarshalIndent(mem, "", " ") + t.Log(string(data)) + }) +} diff --git a/providers/freebsd/kvm_freebsd_cgo.go b/providers/freebsd/kvm_freebsd_cgo.go new file mode 100644 index 00000000..3272287c --- /dev/null +++ b/providers/freebsd/kvm_freebsd_cgo.go @@ -0,0 +1,58 @@ +// 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. + +//go:build freebsd && cgo + +package freebsd + +/* +#cgo LDFLAGS: -lkvm +#include +#include +#include + +#include +#include +#include +*/ +import "C" + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/unix" +) + +// kvmGetSwapInfo returns swap summary statistics for the system. It accesses +// the kernel virtual memory (kvm) images by using libkvm. +func kvmGetSwapInfo() (*kvmSwap, error) { + // Obtain a KVM file descriptor. + var errstr *C.char + kd := C.kvm_open(nil, nil, nil, unix.O_RDONLY, errstr) + if errstr != nil { + return nil, fmt.Errorf("failed calling kvm_open: %s", C.GoString(errstr)) + } + defer C.kvm_close(kd) + + var swap kvmSwap + if n, err := C.kvm_getswapinfo(kd, (*C.struct_kvm_swap)(unsafe.Pointer(&swap)), 1, 0); n != 0 { + return nil, fmt.Errorf("failed to get kvm_getswapinfo: %w", err) + } + + return &swap, nil +} diff --git a/providers/freebsd/process_freebsd_cgo.go b/providers/freebsd/process_freebsd_cgo.go new file mode 100644 index 00000000..ab6b506e --- /dev/null +++ b/providers/freebsd/process_freebsd_cgo.go @@ -0,0 +1,386 @@ +// 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. + +//go:build freebsd && cgo + +package freebsd + +import "C" + +/* +#cgo LDFLAGS: -lprocstat +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +struct kinfo_proc getProcInfoAt(struct kinfo_proc *procs, unsigned int index) { + return procs[index]; +} + +unsigned int countArrayItems(char **items) { + unsigned int i = 0; + for (i = 0; items[i] != NULL; ++i); + return i; +} + +char * itemAtIndex(char **items, unsigned int index) { + return items[index]; +} + +unsigned int countFileStats(struct filestat_list *head) { + unsigned int count = 0; + struct filestat *fst; + STAILQ_FOREACH(fst, head, next) { + ++count; + } + + return count; +} + +void copyFileStats(struct filestat_list *head, struct filestat *out, unsigned int size) { + unsigned int index = 0; + struct filestat *fst; + if (size == 0) { + return; + } + STAILQ_FOREACH(fst, head, next) { + memcpy(out, fst, sizeof(*fst)); + ++out; + --size; + } +} +*/ +import "C" + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/elastic/go-sysinfo/types" +) + +func getProcInfo(op, arg int) ([]process, error) { + procstat, err := C.procstat_open_sysctl() + if procstat == nil { + return nil, fmt.Errorf("failed to open procstat sysctl: %w", err) + } + defer C.procstat_close(procstat) + + var count C.uint = 0 + kprocs, err := C.procstat_getprocs(procstat, C.int(op), C.int(arg), &count) + if kprocs == nil { + return nil, fmt.Errorf("getprocs failed: %w", err) + } + defer C.procstat_freeprocs(procstat, kprocs) + + procs := make([]process, count) + var index C.uint + for index = 0; index < count; index++ { + proc := C.getProcInfoAt(kprocs, index) + procs[index].kinfo = proc + procs[index].pid = int(proc.ki_pid) + } + + return procs, nil +} + +func copyArray(from **C.char) []string { + if from == nil { + return nil + } + + count := C.countArrayItems(from) + out := make([]string, count) + + for index := C.uint(0); index < count; index++ { + out[index] = C.GoString(C.itemAtIndex(from, index)) + } + + return out +} + +func makeMap(from []string) map[string]string { + out := make(map[string]string, len(from)) + + for _, env := range from { + parts := strings.Split(env, "=") + if len(parts) > 1 { + out[parts[0]] = parts[1] + } + } + + return out +} + +func getProcEnv(p *process) (map[string]string, error) { + procstat, err := C.procstat_open_sysctl() + + if procstat == nil { + return nil, fmt.Errorf("failed to open procstat sysctl: %w", err) + } + defer C.procstat_close(procstat) + + env, err := C.procstat_getenvv(procstat, &p.kinfo, 0) + defer C.procstat_freeenvv(procstat) + + return makeMap(copyArray(env)), err +} + +func getProcArgs(p *process) ([]string, error) { + procstat, err := C.procstat_open_sysctl() + + if procstat == nil { + return nil, fmt.Errorf("failed to open procstat sysctl: %w", err) + } + defer C.procstat_close(procstat) + + args, err := C.procstat_getargv(procstat, &p.kinfo, 0) + defer C.procstat_freeargv(procstat) + + return copyArray(args), err +} + +func getProcPathname(p *process) (string, error) { + procstat, err := C.procstat_open_sysctl() + + if procstat == nil { + return "", fmt.Errorf("failed to open procstat sysctl: %w", err) + } + defer C.procstat_close(procstat) + + const maxlen = uint(1024) + out := make([]C.char, maxlen) + if res, err := C.procstat_getpathname(procstat, &p.kinfo, &out[0], C.ulong(maxlen)); res != 0 { + return "", err + } + return C.GoString(&out[0]), nil +} + +func getFileStats(fileStats *C.struct_filestat_list) []C.struct_filestat { + count := C.countFileStats(fileStats) + + if count < 1 { + return nil + } + + out := make([]C.struct_filestat, count) + + C.copyFileStats(fileStats, &out[0], count) + return out +} + +func getProcCWD(p *process) (string, error) { + procstat, err := C.procstat_open_sysctl() + + if procstat == nil { + return "", fmt.Errorf("failed to open procstat sysctl: %w", err) + } + defer C.procstat_close(procstat) + + fs, err := C.procstat_getfiles(procstat, &p.kinfo, 0) + if fs == nil { + return "", fmt.Errorf("failed to get files: %w", err) + } + + defer C.procstat_freefiles(procstat, fs) + + files := getFileStats(fs) + for _, f := range files { + if f.fs_uflags == C.PS_FST_UFLAG_CDIR { + return C.GoString(f.fs_path), nil + } + } + + return "", nil +} + +type process struct { + pid int + kinfo C.struct_kinfo_proc +} + +func timevalToDuration(tm C.struct_timeval) time.Duration { + return time.Duration(tm.tv_sec)*time.Second + + time.Duration(tm.tv_usec)*time.Microsecond +} + +func (p *process) CPUTime() (types.CPUTimes, error) { + procs, err := getProcInfo(C.KERN_PROC_PID, p.PID()) + if err != nil { + return types.CPUTimes{}, err + } + p.kinfo = procs[0].kinfo + + return types.CPUTimes{ + User: timevalToDuration(p.kinfo.ki_rusage.ru_utime), + System: timevalToDuration(p.kinfo.ki_rusage.ru_stime), + }, nil +} + +func (p *process) Info() (types.ProcessInfo, error) { + procs, err := getProcInfo(C.KERN_PROC_PID, p.PID()) + if err != nil { + return types.ProcessInfo{}, err + } + p.kinfo = procs[0].kinfo + + cwd, err := getProcCWD(p) + if err != nil { + return types.ProcessInfo{}, err + } + + args, err := getProcArgs(p) + if err != nil { + return types.ProcessInfo{}, err + } + + exe, _ := getProcPathname(p) + + return types.ProcessInfo{ + Name: C.GoString(&p.kinfo.ki_comm[0]), + PID: int(p.kinfo.ki_pid), + PPID: int(p.kinfo.ki_ppid), + CWD: cwd, + Exe: exe, + Args: args, + StartTime: time.Unix(int64(p.kinfo.ki_start.tv_sec), int64(p.kinfo.ki_start.tv_usec)*1000), + }, nil +} + +func (p *process) Memory() (types.MemoryInfo, error) { + pageSize, err := pageSizeBytes() + if err != nil { + return types.MemoryInfo{}, err + } + + procs, err := getProcInfo(C.KERN_PROC_PID, p.PID()) + if err != nil { + return types.MemoryInfo{}, err + } + p.kinfo = procs[0].kinfo + + return types.MemoryInfo{ + Resident: uint64(p.kinfo.ki_rssize) * pageSize, + Virtual: uint64(p.kinfo.ki_size), + }, nil +} + +func (p *process) User() (types.UserInfo, error) { + procs, err := getProcInfo(C.KERN_PROC_PID, p.PID()) + if err != nil { + return types.UserInfo{}, err + } + + p.kinfo = procs[0].kinfo + + return types.UserInfo{ + UID: strconv.FormatUint(uint64(p.kinfo.ki_ruid), 10), + EUID: strconv.FormatUint(uint64(p.kinfo.ki_uid), 10), + SUID: strconv.FormatUint(uint64(p.kinfo.ki_svuid), 10), + GID: strconv.FormatUint(uint64(p.kinfo.ki_rgid), 10), + EGID: strconv.FormatUint(uint64(p.kinfo.ki_groups[0]), 10), + SGID: strconv.FormatUint(uint64(p.kinfo.ki_svgid), 10), + }, nil +} + +func (p *process) PID() int { + return p.pid +} + +func (p *process) OpenHandles() ([]string, error) { + procstat := C.procstat_open_sysctl() + if procstat == nil { + return nil, errors.New("failed to open procstat sysctl") + } + defer C.procstat_close(procstat) + + fs := C.procstat_getfiles(procstat, &p.kinfo, 0) + defer C.procstat_freefiles(procstat, fs) + + files := getFileStats(fs) + names := make([]string, 0, len(files)) + + for _, file := range files { + if file.fs_type == 1 { + if path := C.GoString(file.fs_path); path != "" { + names = append(names, path) + } + } + } + + return names, nil +} + +func (p *process) OpenHandleCount() (int, error) { + procstat := C.procstat_open_sysctl() + if procstat == nil { + return 0, errors.New("failed to open procstat sysctl") + } + defer C.procstat_close(procstat) + + fs := C.procstat_getfiles(procstat, &p.kinfo, 0) + defer C.procstat_freefiles(procstat, fs) + return int(C.countFileStats(fs)), nil +} + +func (p *process) Environment() (map[string]string, error) { + return getProcEnv(p) +} + +func (s freebsdSystem) Processes() ([]types.Process, error) { + procs, err := getProcInfo(C.KERN_PROC_PROC, 0) + out := make([]types.Process, 0, len(procs)) + + for _, proc := range procs { + out = append(out, &process{ + pid: proc.pid, + kinfo: proc.kinfo, + }) + } + + return out, err +} + +func (s freebsdSystem) Process(pid int) (types.Process, error) { + return &process{pid: pid}, nil +} + +func (s freebsdSystem) Self() (types.Process, error) { + return s.Process(os.Getpid()) +} + +func (p *process) Parent() (types.Process, error) { + info, err := p.Info() + if err != nil { + return nil, err + } + + proc := process{pid: info.PPID} + + return &proc, nil +} diff --git a/providers/freebsd/sysctl_freebsd.go b/providers/freebsd/sysctl_freebsd.go new file mode 100644 index 00000000..0e3e15a1 --- /dev/null +++ b/providers/freebsd/sysctl_freebsd.go @@ -0,0 +1,248 @@ +// 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. + +//go:build freebsd + +package freebsd + +import ( + "fmt" + "strconv" + "strings" + "sync" + "time" + "unsafe" + + "github.com/elastic/go-sysinfo/types" + + "golang.org/x/sys/unix" +) + +var tickDuration = sync.OnceValues(func() (time.Duration, error) { + const mib = "kern.clockrate" + + c, err := unix.SysctlClockinfo(mib) + if err != nil { + return 0, fmt.Errorf("failed to get %s: %w", mib, err) + } + return time.Duration(c.Tick) * time.Microsecond, nil +}) + +var pageSizeBytes = sync.OnceValues(func() (uint64, error) { + const mib = "vm.stats.vm.v_page_size" + + v, err := unix.SysctlUint32(mib) + if err != nil { + return 0, fmt.Errorf("failed to get %s: %w", mib, err) + } + + return uint64(v), nil +}) + +func activePageCount(r *reader) uint64 { + const mib = "vm.stats.vm.v_active_count" + + v, err := unix.SysctlUint32(mib) + if r.addErr(err) { + return 0 + } + return uint64(v) +} + +func architecture() (string, error) { + const mib = "hw.machine" + + arch, err := unix.Sysctl(mib) + if err != nil { + return "", fmt.Errorf("failed to get architecture: %w", err) + } + + return arch, nil +} + +func bootTime() (time.Time, error) { + const mib = "kern.boottime" + + tv, err := unix.SysctlTimeval(mib) + if err != nil { + return time.Time{}, fmt.Errorf("failed to get host uptime: %w", err) + } + + bootTime := time.Unix(tv.Sec, tv.Usec*int64(time.Microsecond)) + return bootTime, nil +} + +// buffersUsedBytes returns the number memory bytes used as disk cache. +func buffersUsedBytes(r *reader) uint64 { + const mib = "vfs.bufspace" + + v, err := unix.SysctlUint64(mib) + if r.addErr(err) { + return 0 + } + + return v +} + +func cachePageCount(r *reader) uint64 { + const mib = "vm.stats.vm.v_cache_count" + + v, err := unix.SysctlUint32(mib) + if r.addErr(err) { + return 0 + } + + return uint64(v) +} + +const sizeOfUint64 = int(unsafe.Sizeof(uint64(0))) + +// cpuStateTimes uses sysctl kern.cp_time to get the amount of time spent in +// different CPU states. +func cpuStateTimes() (*types.CPUTimes, error) { + tickDuration, err := tickDuration() + if err != nil { + return nil, err + } + + const mib = "kern.cp_time" + buf, err := unix.SysctlRaw(mib) + if err != nil { + return nil, fmt.Errorf("failed to get %s: %w", mib, err) + } + + var clockTicks [unix.CPUSTATES]uint64 + if len(buf) < len(clockTicks)*sizeOfUint64 { + return nil, fmt.Errorf("kern.cp_time data is too short (got %d bytes)", len(buf)) + } + for i := range clockTicks { + val := *(*uint64)(unsafe.Pointer(&buf[sizeOfUint64*i])) + clockTicks[i] = val + } + + return &types.CPUTimes{ + User: time.Duration(clockTicks[unix.CP_USER]) * tickDuration, + System: time.Duration(clockTicks[unix.CP_SYS]) * tickDuration, + Idle: time.Duration(clockTicks[unix.CP_IDLE]) * tickDuration, + IRQ: time.Duration(clockTicks[unix.CP_INTR]) * tickDuration, + Nice: time.Duration(clockTicks[unix.CP_NICE]) * tickDuration, + }, nil +} + +func freePageCount(r *reader) uint64 { + const mib = "vm.stats.vm.v_free_count" + + v, err := unix.SysctlUint32(mib) + if r.addErr(err) { + return 0 + } + + return uint64(v) +} + +func inactivePageCount(r *reader) uint64 { + const mib = "vm.stats.vm.v_inactive_count" + + v, err := unix.SysctlUint32(mib) + if r.addErr(err) { + return 0 + } + + return uint64(v) +} + +func kernelVersion() (string, error) { + const mib = "kern.osrelease" + + version, err := unix.Sysctl(mib) + if err != nil { + return "", fmt.Errorf("failed to get kernel version: %w", err) + } + + return version, nil +} + +func machineID() (string, error) { + const mib = "kern.hostuuid" + + uuid, err := unix.Sysctl(mib) + if err != nil { + return "", fmt.Errorf("failed to get machine id: %w", err) + } + + return uuid, nil +} + +func operatingSystem() (*types.OSInfo, error) { + info := &types.OSInfo{ + Type: "freebsd", + Family: "freebsd", + Platform: "freebsd", + } + + osType, err := unix.Sysctl("kern.ostype") + if err != nil { + return info, err + } + info.Name = osType + + // Example: 13.0-RELEASE-p11 + osRelease, err := unix.Sysctl("kern.osrelease") + if err != nil { + return info, err + } + info.Version = osRelease + + releaseParts := strings.Split(osRelease, "-") + + majorMinor := strings.Split(releaseParts[0], ".") + if len(majorMinor) > 0 { + info.Major, _ = strconv.Atoi(majorMinor[0]) + } + if len(majorMinor) > 1 { + info.Minor, _ = strconv.Atoi(majorMinor[1]) + } + + if len(releaseParts) > 1 { + info.Build = releaseParts[1] + } + if len(releaseParts) > 2 { + info.Patch, _ = strconv.Atoi(strings.TrimPrefix(releaseParts[2], "p")) + } + + return info, nil +} + +func totalPhysicalMem(r *reader) uint64 { + const mib = "hw.physmem" + + v, err := unix.SysctlUint64(mib) + if r.addErr(err) { + return 0 + } + return v +} + +func wirePageCount(r *reader) uint64 { + const mib = "vm.stats.vm.v_wire_count" + + v, err := unix.SysctlUint32(mib) + if r.addErr(err) { + return 0 + } + return uint64(v) +} diff --git a/providers/freebsd/sysctl_freebsd_test.go b/providers/freebsd/sysctl_freebsd_test.go new file mode 100644 index 00000000..2b125162 --- /dev/null +++ b/providers/freebsd/sysctl_freebsd_test.go @@ -0,0 +1,137 @@ +// 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. + +//go:build freebsd + +package freebsd + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os/exec" + "strings" + "testing" + "time" +) + +func TestArchitecture(t *testing.T) { + arch, err := architecture() + if err != nil { + t.Fatal(err) + } + + assert.NotEmpty(t, arch) + assert.Regexp(t, `(amd64|i386|powerpc|arm(64)?|riscv|mips|sparc64|pc98)`, arch) +} + +func TestBootTime(t *testing.T) { + bootTime, err := bootTime() + if err != nil { + t.Fatal(err) + } + + bootDiff := time.Since(bootTime) + // t.Logf("bootTime in seconds: %#v", int64(bootDiff.Seconds())) + + cmd := exec.Command("/usr/bin/uptime", "--libxo=json") + upcmd, err := cmd.Output() + + if err != nil { + t.Fatal(err) + } + + t.Logf(string(upcmd)) + + type UptimeOutput struct { + UptimeInformation struct { + Uptime int64 `json:"uptime"` + } `json:"uptime-information"` + } + + var upInfo UptimeOutput + err = json.Unmarshal(upcmd, &upInfo) + + if err != nil { + t.Fatal(err) + } + + upsec := upInfo.UptimeInformation.Uptime + uptime := time.Duration(upsec * int64(time.Second)) + // t.Logf("uptime in seconds: %#v", int64(uptime.Seconds())) + + assert.InDelta(t, uptime, bootDiff, float64(5*time.Second)) +} + +func TestCPUStateTimes(t *testing.T) { + times, err := cpuStateTimes() + if err != nil { + t.Fatal(err) + } + + require.NotNil(t, times) + assert.NotZero(t, *times) +} + +func TestKernelVersion(t *testing.T) { + kernel, err := kernelVersion() + if err != nil { + t.Fatal(err) + } + + // Retrieve currently running kernel version + cmd := exec.Command("/bin/freebsd-version", "-r") + fbsdout, err := cmd.Output() + + if err != nil { + t.Fatal(err) + } + + fbsdver := strings.TrimSuffix(string(fbsdout), "\n") + + assert.NotEmpty(t, kernel) + assert.EqualValues(t, kernel, fbsdver) +} + +func TestMachineID(t *testing.T) { + machineID, err := machineID() + if err != nil { + t.Fatal(err) + } + + assert.NotEmpty(t, machineID) + assert.Regexp(t, "^[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}$", machineID) +} + +func TestOperatingSystem(t *testing.T) { + t.Run("freebsd", func(t *testing.T) { + os, err := operatingSystem() + if err != nil { + t.Fatal(err) + } + assert.Equal(t, "freebsd", os.Type) + assert.Equal(t, "freebsd", os.Family) + assert.Equal(t, "freebsd", os.Platform) + assert.Equal(t, "FreeBSD", os.Name) + assert.Regexp(t, `\d{1,2}\.\d{1,2}-(RELEASE|STABLE|CURRENT|RC[0-9]|ALPHA(\d{0,2})|BETA(\d{0,2}))(-p\d)?`, os.Version) + assert.Regexp(t, `\d{1,2}`, os.Major) + assert.Regexp(t, `\d{1,2}`, os.Minor) + assert.Regexp(t, `\d{1,2}`, os.Patch) + assert.Regexp(t, `(RELEASE|STABLE|CURRENT|RC[0-9]|ALPHA([0-9]{0,2})|BETA([0-9]{0,2}))`, os.Build) + t.Logf("%#v", os) + }) +} diff --git a/providers/freebsd/ztypes_freebsd.go b/providers/freebsd/ztypes_freebsd.go new file mode 100644 index 00000000..14807b2c --- /dev/null +++ b/providers/freebsd/ztypes_freebsd.go @@ -0,0 +1,39 @@ +// 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. + +// Code generated by cmd/cgo -godefs; and then patched up to fix +// an alignment issue +// cgo -godefs defs_freebsd.go + +package freebsd + +type kvmSwap struct { + Devname [32]int8 + Used uint32 + Total uint32 + Flags int32 + Reserved1 uint32 + Reserved2 uint32 +} + +type clockInfo struct { + Hz int32 + Tick int32 + Spare int32 + Stathz int32 + Profhz int32 +} diff --git a/providers/shared/fqdn.go b/providers/shared/fqdn.go index 48043bf5..1d1efb07 100644 --- a/providers/shared/fqdn.go +++ b/providers/shared/fqdn.go @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -//go:build linux || darwin || aix +//go:build linux || darwin || aix || freebsd package shared diff --git a/providers/windows/arch_windows.go b/providers/windows/arch_windows.go index 6a889a0f..81afb81c 100644 --- a/providers/windows/arch_windows.go +++ b/providers/windows/arch_windows.go @@ -44,7 +44,7 @@ func Architecture() (string, error) { func NativeArchitecture() (string, error) { var processMachine, nativeMachine uint16 // the pseudo handle doesn't need to be closed - var currentProcessHandle = windows.CurrentProcess() + currentProcessHandle := windows.CurrentProcess() // IsWow64Process2 was introduced in version 1709 (build 16299 acording to the tables) // https://learn.microsoft.com/en-us/windows/release-health/release-information diff --git a/providers/windows/device_windows.go b/providers/windows/device_windows.go index 372f125f..37fb81fb 100644 --- a/providers/windows/device_windows.go +++ b/providers/windows/device_windows.go @@ -153,7 +153,7 @@ func (winapiDeviceProvider) GetLogicalDrives() (uint32, error) { return windows.GetLogicalDrives() } -func (winapiDeviceProvider) QueryDosDevice(name *uint16, buf *uint16, length uint32) (uint32, error) { +func (winapiDeviceProvider) QueryDosDevice(name, buf *uint16, length uint32) (uint32, error) { return windows.QueryDosDevice(name, buf, length) } @@ -168,7 +168,7 @@ func ptrOffset(ptr *uint16, off uint32) *uint16 { return (*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(off*2))) } -func (m testingDeviceProvider) QueryDosDevice(nameW *uint16, buf *uint16, length uint32) (uint32, error) { +func (m testingDeviceProvider) QueryDosDevice(nameW, buf *uint16, length uint32) (uint32, error) { drive := byte(*nameW) if byte(*ptrOffset(nameW, 1)) != ':' { return 0, errors.New("not a drive") diff --git a/system.go b/system.go index b9a33607..b2b2825c 100644 --- a/system.go +++ b/system.go @@ -26,6 +26,7 @@ import ( // Register host and process providers. _ "github.com/elastic/go-sysinfo/providers/aix" _ "github.com/elastic/go-sysinfo/providers/darwin" + _ "github.com/elastic/go-sysinfo/providers/freebsd" _ "github.com/elastic/go-sysinfo/providers/linux" _ "github.com/elastic/go-sysinfo/providers/windows" ) diff --git a/system_test.go b/system_test.go index efb872e3..5d64b9cd 100644 --- a/system_test.go +++ b/system_test.go @@ -34,72 +34,73 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/elastic/go-sysinfo/internal/cgo" "github.com/elastic/go-sysinfo/types" ) type ProcessFeatures struct { - ProcessInfo bool Environment bool OpenHandleEnumerator bool OpenHandleCounter bool Seccomp bool Capabilities bool + NetworkCounters bool } var expectedProcessFeatures = map[string]*ProcessFeatures{ "darwin": { - ProcessInfo: true, - Environment: true, - OpenHandleEnumerator: false, - OpenHandleCounter: false, + Environment: true, }, "linux": { - ProcessInfo: true, Environment: true, OpenHandleEnumerator: true, OpenHandleCounter: true, Seccomp: true, Capabilities: true, + NetworkCounters: true, }, "windows": { - ProcessInfo: true, - OpenHandleEnumerator: false, - OpenHandleCounter: true, + OpenHandleCounter: true, }, "aix": { - ProcessInfo: true, + Environment: true, + }, + "freebsd": { Environment: true, - OpenHandleEnumerator: false, - OpenHandleCounter: false, + OpenHandleEnumerator: true, + OpenHandleCounter: true, }, } -func TestProcessFeaturesMatrix(t *testing.T) { - const GOOS = runtime.GOOS - var features ProcessFeatures +var startTime = time.Now().UTC() +func TestProcessFeaturesMatrix(t *testing.T) { process, err := Self() - if err == types.ErrNotImplemented { - assert.Nil(t, expectedProcessFeatures[GOOS], "unexpected ErrNotImplemented for %v", GOOS) + switch { + // Direct equality comparison because this is the API contract. + case types.ErrNotImplemented == err: + assert.Nil(t, expectedProcessFeatures[runtime.GOOS], "unexpected ErrNotImplemented for %v", runtime.GOOS) return - } else if err != nil { + case err != nil: t.Fatal(err) } - features.ProcessInfo = true + var features ProcessFeatures _, features.Environment = process.(types.Environment) _, features.OpenHandleEnumerator = process.(types.OpenHandleEnumerator) _, features.OpenHandleCounter = process.(types.OpenHandleCounter) _, features.Seccomp = process.(types.Seccomp) _, features.Capabilities = process.(types.Capabilities) + _, features.NetworkCounters = process.(types.NetworkCounters) + assert.Equal(t, expectedProcessFeatures[runtime.GOOS], &features) - assert.Equal(t, expectedProcessFeatures[GOOS], &features) logAsJSON(t, map[string]interface{}{ "features": features, }) } func TestSelf(t *testing.T) { + t.Log("Getting Self() process") process, err := Self() if err == types.ErrNotImplemented { t.Skip("process provider not implemented on", runtime.GOOS) @@ -116,6 +117,7 @@ func TestSelf(t *testing.T) { } } + t.Log("Getting process Info()") output := map[string]interface{}{} info, err := process.Info() if err != nil { @@ -124,6 +126,12 @@ func TestSelf(t *testing.T) { output["process.info"] = info assert.EqualValues(t, os.Getpid(), info.PID) assert.Equal(t, os.Args, info.Args) + switch { + case runtime.GOOS == "darwin" && !cgo.Enabled: + default: + assert.WithinDurationf(t, startTime, info.StartTime, 10*time.Second, "StartTime does not match test start") + assertWorkingDirectory(t, info.CWD) + } exe, err := os.Executable() if err != nil { @@ -132,6 +140,14 @@ func TestSelf(t *testing.T) { assert.Equal(t, exe, info.Exe) if user, err := process.User(); !errors.Is(err, types.ErrNotImplemented) { + t.Log("Getting process User()") + + if err != nil { + t.Fatal(err) + } + output["process.user"] = user + + user, err := process.User() if err != nil { t.Fatal(err) } @@ -151,6 +167,8 @@ func TestSelf(t *testing.T) { } if v, ok := process.(types.Environment); ok { + t.Log("Getting process Environment()") + actualEnv, err := v.Environment() if err != nil { t.Fatal(err) @@ -171,6 +189,8 @@ func TestSelf(t *testing.T) { } if memInfo, err := process.Memory(); !errors.Is(err, types.ErrNotImplemented) { + t.Log("Getting process Memory()") + require.NoError(t, err) if runtime.GOOS != "windows" { // Virtual memory may be reported as @@ -181,6 +201,7 @@ func TestSelf(t *testing.T) { output["process.mem"] = memInfo } + t.Log("Getting process CPUTime()") for { cpuTimes, err := process.CPUTime() if errors.Is(err, types.ErrNotImplemented) { @@ -199,6 +220,8 @@ func TestSelf(t *testing.T) { } if v, ok := process.(types.OpenHandleEnumerator); ok { + t.Log("Getting process OpenHandles()") + fds, err := v.OpenHandles() if assert.NoError(t, err) { output["process.fd"] = fds @@ -206,13 +229,17 @@ func TestSelf(t *testing.T) { } if v, ok := process.(types.OpenHandleCounter); ok { + t.Log("Getting process OpenHandleCount()") + count, err := v.OpenHandleCount() if assert.NoError(t, err) { - t.Log("open handles count:", count) + output["process.open_handle_count"] = count } } if v, ok := process.(types.Seccomp); ok { + t.Log("Getting process Seccomp()") + seccompInfo, err := v.Seccomp() if assert.NoError(t, err) { assert.NotZero(t, seccompInfo) @@ -221,6 +248,8 @@ func TestSelf(t *testing.T) { } if v, ok := process.(types.Capabilities); ok { + t.Log("Getting process Capabilities()") + capInfo, err := v.Capabilities() if assert.NoError(t, err) { assert.NotZero(t, capInfo) @@ -228,6 +257,16 @@ func TestSelf(t *testing.T) { } } + if v, ok := process.(types.NetworkCounters); ok { + t.Log("Getting process NetworkCounters()") + + counters, err := v.NetworkCounters() + if assert.NoError(t, err) { + assert.NotZero(t, counters) + output["process.network_counters"] = counters + } + } + logAsJSON(t, output) } @@ -310,3 +349,26 @@ func TestProcesses(t *testing.T) { info.StartTime) } } + +func assertWorkingDirectory(t *testing.T, observedWD string) { + t.Helper() + + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + expectedInfo, err := os.Stat(wd) + if err != nil { + t.Fatal(err) + } + observedInfo, err := os.Stat(observedWD) + if err != nil { + t.Fatal(err) + } + + if !os.SameFile(expectedInfo, observedInfo) { + t.Errorf("working directory does not match observed working directory, want=%#v, got=%#v", + expectedInfo, observedInfo) + } +}