diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a77d6a3..07e15c9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,7 +9,7 @@ jobs: check: strategy: matrix: - os: [ubuntu-latest, ubuntu-24.04-arm] + os: [ubuntu-latest, ubuntu-24.04-arm, macos-15, macos-15-intel] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -20,15 +20,22 @@ jobs: - run: make lint test build - name: Run without root and verify JSON run: | - case "$(uname -m)" in aarch64) arch=arm64 ;; x86_64) arch=amd64 ;; esac + case "$(uname -m)" in aarch64|arm64) arch=arm64 ;; x86_64) arch=amd64 ;; esac + case "$(uname -s)" in Linux) platform=linux ;; Darwin) platform=darwin ;; esac version=$(sed -n 's/^const Version = "\([^"]*\)"/\1/p' version.go) - ./dist/sysinfo-$version-linux-$arch > /tmp/sysinfo.json + ./dist/sysinfo-$version-$platform-$arch > /tmp/sysinfo.json python3 - <<'PYTHON' - import json, os + import json, os, sys with open('/tmp/sysinfo.json') as f: report = json.load(f) assert report['runtime']['page_size_bytes'] == os.sysconf('SC_PAGE_SIZE') assert report['runtime']['resource_limits'] - assert report['runtime']['libc'] - assert report['runtime']['vm']['overcommit_memory'] in ['0', '1', '2'] + if sys.platform == 'linux': + assert report['runtime']['libc'] + assert report['runtime']['vm']['overcommit_memory'] in ['0', '1', '2'] + else: + assert report['os']['name'] == 'macOS' + assert report['os']['version'] + assert report['kernel']['release'] + assert report['memory']['size'] > 0 PYTHON diff --git a/AGENTS.md b/AGENTS.md index 866b4a2..03be766 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,15 @@ ## 项目定位与边界 -Linux 系统信息 CLI,基于 zcalusic/sysinfo 增强。直接执行输出 JSON,辅助判断二进制构建与运行环境的兼容性;不自动作兼容性结论。 +Linux / macOS 系统信息 CLI,基于 zcalusic/sysinfo 增强。直接执行输出 JSON,辅助判断二进制构建与运行环境的兼容性;不自动作兼容性结论。 ## 代码地图 ```text cmd/sysinfo/ # CLI 入口,输出一次 JSON *.go # 沿用上游系统信息采集结构 -runtime.go # 基础页大小、资源限制、内存与内核策略 +runtime*.go # 基础页大小与平台运行环境 +platform_*.go # Linux / macOS 采集入口 libc.go # 静态读取已安装 libc 的版本证据 cpuid/ # 上游 CPU 指令封装 ``` @@ -17,7 +18,7 @@ cpuid/ # 上游 CPU 指令封装 ## 关键约定 - 采集只读,不创建或修复系统文件,不执行外部程序。 -- 标准库实现;发布使用 CGO_ENABLED=0,不依赖客户机的 libc 或语言运行时。 +- 标准库实现;发布使用 CGO_ENABLED=0,Linux 静态链接,macOS 仅使用系统自带库,不依赖额外语言运行时。 - 缺失证据不转换为正常默认值;新增运行环境采集错误保存在 runtime.errors。 - 保留上游版权及许可证。版本由 version.go 管理。 - 验证入口:make fix、make lint、make test、make build;build 一次产出全部支持架构,跨编译不等于客户机运行验证。 diff --git a/Makefile b/Makefile index df4f35c..a50cd36 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ TEST_WORKERS ?= 4 LINT_WORKERS ?= 4 .DEFAULT_GOAL := build -.PHONY: fix lint test build build-all build-linux-amd64 build-linux-arm64 +.PHONY: fix lint test build build-all build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64 fix: $(GO) fmt ./... @@ -16,13 +16,15 @@ lint: $(GO) vet -p $(LINT_WORKERS) ./... CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) vet -p $(LINT_WORKERS) ./... CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) vet -p $(LINT_WORKERS) ./... + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) vet -p $(LINT_WORKERS) ./... + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) vet -p $(LINT_WORKERS) ./... test: $(GO) test -p $(TEST_WORKERS) -parallel 1 $(TEST_PACKAGES) # One static binary per architecture serves supported Linux distributions and # kernels. Do not create kernel/glibc-labelled copies of the same executable. -build: build-linux-amd64 build-linux-arm64 +build: build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64 build-all: build @@ -31,3 +33,9 @@ build-linux-amd64: build-linux-arm64: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GOARM64=v8.0 $(GO) build -trimpath -ldflags='-s -w' -o "$(DIST_DIR)/$(ARTIFACT_PREFIX)-linux-arm64" ./cmd/sysinfo + +build-darwin-amd64: + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 GOAMD64=v1 $(GO) build -trimpath -ldflags='-s -w' -o "$(DIST_DIR)/$(ARTIFACT_PREFIX)-darwin-amd64" ./cmd/sysinfo + +build-darwin-arm64: + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 GOARM64=v8.0 $(GO) build -trimpath -ldflags='-s -w' -o "$(DIST_DIR)/$(ARTIFACT_PREFIX)-darwin-arm64" ./cmd/sysinfo diff --git a/README.md b/README.md index f2667b7..6739ba2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # sysinfo -sysinfo is a standalone, read-only Linux CLI for collecting the system facts needed +sysinfo is a standalone, read-only Linux and macOS CLI for collecting the system facts needed to build, distribute and troubleshoot native applications. It gathers OS, kernel, CPU, memory, libc and process-limit information into a single JSON report, so users can compare environments, choose suitable build targets and investigate startup failures. @@ -14,16 +14,21 @@ Run it without arguments to print the report; redirect stdout to save it. ```sh ./sysinfo-1.1.4-linux-arm64 > sysinfo.json -# On x86-64: +# On Linux x86-64: ./sysinfo-1.1.4-linux-amd64 > sysinfo.json +# On Apple Silicon macOS: +./sysinfo-1.1.4-darwin-arm64 > sysinfo.json ``` -The binary needs no Bun, Node, Python, shell utilities, or installed libc. +The binary needs no Bun, Node, Python or shell utilities. Linux builds are static; +macOS builds use only system libraries shipped with macOS. It does not execute subprocesses or modify system files. Root is optional; firmware/serial information may be unavailable without permission. ## Information collected +On Linux: + - OS distribution, kernel and userland architecture, CPU and common feature flags. - Hardware inventory inherited from upstream: firmware, memory, storage and network. - Actual base page size in bytes (including 64 KiB ARM64 systems), independently of huge pages. @@ -31,6 +36,14 @@ firmware/serial information may be unavailable without permission. - This process's soft/hard resource limits, including virtual address space and core dumps. - `/proc/meminfo`, overcommit policy/ratio/size, mapping limits and cgroup membership. +On macOS, native system APIs collect the OS version/build, Darwin kernel, host and +process architectures, CPU model/core counts, physical memory, base page size, +resource limits and network interfaces. Linux-specific libc, overcommit and cgroup +fields remain empty, with platform notes in the report. Firmware/storage inventory +and ARM CPU feature flags are not collected on macOS. +Under Rosetta, the report identifies translation and separates the ARM64 host +from the AMD64 process. Page size and resource limits describe the running process. + Reports describe the environment visible to the collector. Run it from the same shell/container/service context as the failing program when investigating limits. Memory data comes from procfs; cgroup membership is included as context, but cgroup @@ -58,21 +71,23 @@ make test make build # dist/sysinfo-1.1.4-linux-amd64 # dist/sysinfo-1.1.4-linux-arm64 +# dist/sysinfo-1.1.4-darwin-amd64 +# dist/sysinfo-1.1.4-darwin-arm64 # Optional: build a single architecture, or choose an output directory. make build-linux-arm64 make build DIST_DIR=/tmp/sysinfo-dist ``` -`make` and `make build-all` also build both architectures. Artifact versions come +`make` and `make build-all` also build all four platform binaries. Artifact versions come from `version.go`, matching the version in the JSON report. AMD64 uses the v1 CPU baseline; ARM64 uses ARMv8.0. Both Linux builds use `CGO_ENABLED=0`, so there are no separate Debian/Kylin, glibc or kernel-labelled binaries. Go's documented Linux kernel requirements are listed in [Minimum Requirements](https://go.dev/wiki/MinimumRequirements); the collector also needs access to procfs/sysfs for the corresponding fields. -macOS can cross-compile and run parser tests; -the CLI itself supports Linux only. Kernel/distribution and page-size combinations +All four builds use `CGO_ENABLED=0`. macOS binaries use native sysctl and resource-limit +APIs without invoking external commands. Kernel/distribution and page-size combinations still require runtime validation on those systems. ## Origin and license diff --git a/cmd/sysinfo/main.go b/cmd/sysinfo/main.go index 7485ea0..26c1196 100644 --- a/cmd/sysinfo/main.go +++ b/cmd/sysinfo/main.go @@ -12,19 +12,15 @@ import ( "fmt" "log" "os" - "runtime" "github.com/compforge/sysinfo" ) func main() { if len(os.Args) != 1 { - fmt.Fprintln(os.Stderr, "usage: sysinfo (prints Linux system information as JSON)") + fmt.Fprintln(os.Stderr, "usage: sysinfo (prints system information as JSON)") os.Exit(2) } - if runtime.GOOS != "linux" { - log.Fatal("sysinfo supports Linux only") - } var si sysinfo.SysInfo diff --git a/kernel_darwin.go b/kernel_darwin.go index 73e0c98..bd297a5 100644 --- a/kernel_darwin.go +++ b/kernel_darwin.go @@ -3,6 +3,8 @@ package sysinfo +import "syscall" + // Kernel information. type Kernel struct { Release string `json:"release,omitempty"` @@ -11,4 +13,14 @@ type Kernel struct { } func (si *SysInfo) getKernelInfo() { + si.Kernel.Release = si.sysctlString("kern.osrelease") + si.Kernel.Version = si.sysctlString("kern.version") + si.Kernel.Architecture = si.sysctlString("hw.machine") + // Under Rosetta hw.machine may describe the translated process. ARM64 + // support or the translation flag identifies the native host; + // runtime.process_architecture still describes the running executable. + arm64, _ := syscall.SysctlUint32("hw.optional.arm64") + if arm64 == 1 || si.Runtime.Translation == "rosetta2" { + si.Kernel.Architecture = "arm64" + } } diff --git a/platform_darwin.go b/platform_darwin.go new file mode 100644 index 0000000..88748e6 --- /dev/null +++ b/platform_darwin.go @@ -0,0 +1,130 @@ +package sysinfo + +import ( + "encoding/binary" + "fmt" + "net" + "os" + "strconv" + "strings" + "syscall" + "time" +) + +func (si *SysInfo) sysctlString(name string) string { + value, err := syscall.Sysctl(name) + if err != nil { + si.Runtime.Errors[name] = err.Error() + } + return strings.TrimSpace(value) +} + +func (si *SysInfo) sysctlUint32(name string) uint32 { + value, err := syscall.SysctlUint32(name) + if err != nil { + si.Runtime.Errors[name] = err.Error() + } + return value +} + +func (si *SysInfo) getPlatformInfo() { + si.getKernelInfo() + si.OS = OS{Name: "macOS", Vendor: "Apple", Version: si.sysctlString("kern.osproductversion"), + Release: si.sysctlString("kern.osversion"), Architecture: si.Kernel.Architecture} + var err error + si.Node.Hostname, err = os.Hostname() + if err != nil { + si.Runtime.Errors["hostname"] = err.Error() + } + si.Node.Timezone, _ = time.Now().Zone() + si.Product.Vendor = "Apple" + si.Product.Name = si.sysctlString("hw.model") + si.CPU.Model = si.sysctlString("machdep.cpu.brand_string") + si.CPU.Cpus = uint(si.sysctlUint32("hw.packages")) + si.CPU.Cores = uint(si.sysctlUint32("hw.physicalcpu")) + si.CPU.Threads = uint(si.sysctlUint32("hw.logicalcpu")) + // x86 feature strings are optional on Apple Silicon. Do not translate + // unrelated ARM capability keys into x86 instruction-set claims. + if value, err := syscall.Sysctl("machdep.cpu.vendor"); err == nil { + si.CPU.Vendor = value + } + for _, name := range []string{"machdep.cpu.features", "machdep.cpu.leaf7_features", "machdep.cpu.extfeatures"} { + if value, err := syscall.Sysctl(name); err == nil { + si.CPU.Features = append(si.CPU.Features, strings.Fields(strings.ToLower(value))...) + } + } + if value, err := sysctlUint64("hw.memsize"); err == nil { + si.Memory.Size = uint(value / (1024 * 1024)) + si.Runtime.Memory["physical_bytes"] = strconv.FormatUint(value, 10) + } else { + si.Runtime.Errors["hw.memsize"] = err.Error() + } + interfaces, err := net.Interfaces() + if err != nil { + si.Runtime.Errors["network_interfaces"] = err.Error() + return + } + for _, iface := range interfaces { + if iface.Flags&net.FlagLoopback != 0 { + continue + } + si.Network = append(si.Network, NetworkDevice{Name: iface.Name, MACAddress: iface.HardwareAddr.String()}) + } +} + +func sysctlUint64(name string) (uint64, error) { + value, err := syscall.Sysctl(name) + if err != nil { + return 0, err + } + return decodeSysctlUint64(value) +} + +func decodeSysctlUint64(value string) (uint64, error) { + // syscall.Sysctl removes one trailing NUL even for binary data. Restore + // that byte for uint64 values. Both supported Darwin architectures are LE. + if len(value) != 7 && len(value) != 8 { + return 0, fmt.Errorf("expected 8-byte sysctl value, got %d bytes", len(value)) + } + var data [8]byte + copy(data[:], value) + return binary.LittleEndian.Uint64(data[:]), nil +} + +func (si *SysInfo) getPlatformRuntime() { + if translated, err := syscall.SysctlUint32("sysctl.proc_translated"); err == nil && translated == 1 { + si.Runtime.Translation = "rosetta2" + } + si.Runtime.Notes = []string{ + "macOS uses the system libSystem runtime; Linux libc, overcommit and cgroup fields do not apply.", + "macOS firmware, storage inventory and ARM CPU feature flags are not collected.", + } + for _, limit := range []struct { + name string + resource int + unit string + }{ + {"Max cpu time", syscall.RLIMIT_CPU, "seconds"}, + {"Max file size", syscall.RLIMIT_FSIZE, "bytes"}, + {"Max data size", syscall.RLIMIT_DATA, "bytes"}, + {"Max stack size", syscall.RLIMIT_STACK, "bytes"}, + {"Max core file size", syscall.RLIMIT_CORE, "bytes"}, + {"Max address space", syscall.RLIMIT_AS, "bytes"}, + {"Max open files", syscall.RLIMIT_NOFILE, "files"}, + } { + var value syscall.Rlimit + if err := syscall.Getrlimit(limit.resource, &value); err != nil { + si.Runtime.Errors[limit.name] = err.Error() + continue + } + si.Runtime.Limits = append(si.Runtime.Limits, ResourceLimit{Name: limit.name, + Soft: darwinLimit(value.Cur), Hard: darwinLimit(value.Max), Unit: limit.unit}) + } +} + +func darwinLimit(value uint64) string { + if value == uint64(syscall.RLIM_INFINITY) { + return "unlimited" + } + return strconv.FormatUint(value, 10) +} diff --git a/platform_darwin_test.go b/platform_darwin_test.go new file mode 100644 index 0000000..950d83e --- /dev/null +++ b/platform_darwin_test.go @@ -0,0 +1,65 @@ +package sysinfo + +import ( + "encoding/binary" + "os" + "strings" + "syscall" + "testing" +) + +func TestDarwinReport(t *testing.T) { + var si SysInfo + si.GetSysInfo() + if si.OS.Name != "macOS" || si.OS.Version == "" || si.Kernel.Release == "" || si.Kernel.Architecture == "" { + t.Fatalf("missing platform information: %+v %+v", si.OS, si.Kernel) + } + if si.CPU.Cores == 0 || si.CPU.Threads == 0 || si.Memory.Size == 0 || si.Runtime.PageSizeBytes != os.Getpagesize() { + t.Fatalf("missing hardware information: %+v %+v", si.CPU, si.Memory) + } + var limits syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limits); err != nil { + t.Fatal(err) + } + found := false + for _, limit := range si.Runtime.Limits { + if limit.Name == "Max open files" { + found = true + if limit.Soft != darwinLimit(limits.Cur) || limit.Hard != darwinLimit(limits.Max) { + t.Fatal(limit) + } + } + } + if !found { + t.Fatal("missing open file limit") + } + for key := range si.Runtime.Errors { + if key == "platform" || strings.HasPrefix(key, "/proc/") { + t.Fatalf("Linux collector ran: %s", key) + } + } + if len(si.Runtime.Libc) != 0 || len(si.Runtime.VM) != 0 || len(si.Runtime.Notes) == 0 { + t.Fatal("missing platform boundary") + } + if si.Runtime.Translation == "rosetta2" && (si.Kernel.Architecture != "arm64" || si.Runtime.ProcessArch != "amd64") { + t.Fatalf("incorrect translated architecture: %+v %+v", si.Kernel, si.Runtime) + } +} + +func TestDecodeSysctlUint64(t *testing.T) { + for _, value := range []uint64{0, 64 << 30, 0xffffffffffffffff} { + var data [8]byte + binary.LittleEndian.PutUint64(data[:], value) + raw := strings.TrimSuffix(string(data[:]), "\x00") + got, err := decodeSysctlUint64(raw) + if err != nil || got != value { + t.Fatalf("%d: got %d, %v", value, got, err) + } + } + if _, err := decodeSysctlUint64("bad"); err == nil { + t.Fatal("accepted invalid width") + } + if darwinLimit(uint64(syscall.RLIM_INFINITY)) != "unlimited" || darwinLimit(0) != "0" { + t.Fatal("invalid limit rendering") + } +} diff --git a/platform_linux.go b/platform_linux.go new file mode 100644 index 0000000..a91ffdd --- /dev/null +++ b/platform_linux.go @@ -0,0 +1,24 @@ +package sysinfo + +func (si *SysInfo) getPlatformInfo() { + // DMI info + si.getProductInfo() + si.getBoardInfo() + si.getChassisInfo() + si.getBIOSInfo() + + // SMBIOS info + si.getMemoryInfo() + + // Node info + si.getNodeInfo() // depends on BIOS info + + // Hardware info + si.getCPUInfo() // depends on Node info + si.getStorageInfo() + si.getNetworkInfo() + + // Software info + si.getOSInfo() + si.getKernelInfo() +} diff --git a/runtime.go b/runtime.go index 67a452b..7435292 100644 --- a/runtime.go +++ b/runtime.go @@ -14,12 +14,14 @@ import ( type RuntimeInfo struct { PageSizeBytes int `json:"page_size_bytes"` ProcessArch string `json:"process_architecture"` + Translation string `json:"translation,omitempty"` GoVersion string `json:"go_version"` Libc []Libc `json:"libc"` Limits []ResourceLimit `json:"resource_limits"` Memory map[string]string `json:"memory"` VM map[string]string `json:"vm"` Cgroup string `json:"cgroup_membership,omitempty"` + Notes []string `json:"notes,omitempty"` Errors map[string]string `json:"errors,omitempty"` } @@ -42,44 +44,7 @@ func (si *SysInfo) getRuntimeInfo() { Memory: map[string]string{}, VM: map[string]string{}, Errors: map[string]string{}, } - if runtime.GOOS != "linux" { - r.Errors["platform"] = "Linux is required" - return - } - read := func(path string) string { - data, err := os.ReadFile(path) - if err != nil { - r.Errors[path] = err.Error() - return "" - } - value := strings.TrimSpace(string(data)) - if value == "" { - r.Errors[path] = "file is empty" - } - return value - } - if data := read("/proc/self/limits"); data != "" { - var err error - r.Limits, err = parseLimits(data) - if err != nil { - r.Errors["/proc/self/limits"] = err.Error() - } - } - for _, line := range strings.Split(read("/proc/meminfo"), "\n") { - if key, value, ok := strings.Cut(line, ":"); ok { - r.Memory[key] = strings.TrimSpace(value) - } - } - for _, name := range []string{"overcommit_memory", "overcommit_ratio", "overcommit_kbytes", "max_map_count", "mmap_min_addr"} { - if value := read("/proc/sys/vm/" + name); value != "" { - r.VM[name] = value - } - } - r.Cgroup = read("/proc/self/cgroup") - r.Libc, r.Errors["libc"] = findLibc() - if r.Errors["libc"] == "" { - delete(r.Errors, "libc") - } + si.getPlatformRuntime() } func parseLimits(data string) ([]ResourceLimit, error) { diff --git a/runtime_linux.go b/runtime_linux.go new file mode 100644 index 0000000..fab9d2a --- /dev/null +++ b/runtime_linux.go @@ -0,0 +1,44 @@ +package sysinfo + +import ( + "os" + "strings" +) + +func (si *SysInfo) getPlatformRuntime() { + r := &si.Runtime + read := func(path string) string { + data, err := os.ReadFile(path) + if err != nil { + r.Errors[path] = err.Error() + return "" + } + value := strings.TrimSpace(string(data)) + if value == "" { + r.Errors[path] = "file is empty" + } + return value + } + if data := read("/proc/self/limits"); data != "" { + var err error + r.Limits, err = parseLimits(data) + if err != nil { + r.Errors["/proc/self/limits"] = err.Error() + } + } + for _, line := range strings.Split(read("/proc/meminfo"), "\n") { + if key, value, ok := strings.Cut(line, ":"); ok { + r.Memory[key] = strings.TrimSpace(value) + } + } + for _, name := range []string{"overcommit_memory", "overcommit_ratio", "overcommit_kbytes", "max_map_count", "mmap_min_addr"} { + if value := read("/proc/sys/vm/" + name); value != "" { + r.VM[name] = value + } + } + r.Cgroup = read("/proc/self/cgroup") + r.Libc, r.Errors["libc"] = findLibc() + if r.Errors["libc"] == "" { + delete(r.Errors, "libc") + } +} diff --git a/sysinfo.go b/sysinfo.go index 9938529..d72610e 100644 --- a/sysinfo.go +++ b/sysinfo.go @@ -2,7 +2,7 @@ // // Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. -// Package sysinfo is a Go library providing Linux OS / kernel / hardware system information. +// Package sysinfo is a Go library providing Linux and macOS / kernel / hardware system information. package sysinfo // SysInfo struct encapsulates all other information structs. @@ -30,24 +30,5 @@ func (si *SysInfo) GetSysInfo() { // Meta info si.getMetaInfo() - // DMI info - si.getProductInfo() - si.getBoardInfo() - si.getChassisInfo() - si.getBIOSInfo() - - // SMBIOS info - si.getMemoryInfo() - - // Node info - si.getNodeInfo() // depends on BIOS info - - // Hardware info - si.getCPUInfo() // depends on Node info - si.getStorageInfo() - si.getNetworkInfo() - - // Software info - si.getOSInfo() - si.getKernelInfo() + si.getPlatformInfo() }