This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
gohai is an SDK first. It is a Go library that OSAPI and other Go
services import to collect typed system facts, with a standalone CLI
(gohai) shipped as a thin wrapper over the same SDK. When making design
decisions, always optimize for the importer's experience first; the CLI is
secondary.
Inspired by Chef Ohai — pluggable collectors, comprehensive facts, and Ohai-compatible JSON output — but designed to be embedded in Go services rather than run as a standalone agent.
We are a wrapper and aggregator, not a re-implementor. Each collector's
job is to wrap a well-maintained backing source (Go library, provider SDK, or
a thin file/command parser) and reshape its output into our typed Info
struct.
MANDATORY: When a Go library (gopsutil, ghw, procfs, cloud SDKs) covers part of what a collector needs, use it for that part. If the library doesn't cover everything we want, add the extension logic in our collector on top of the library's output. Do not replace the library wholesale just because it's missing one piece — you lose years of accumulated bug fixes and cross-platform handling that way.
Extension pattern:
// Wrap the library for what it does well.
info, err := upstream.Get(ctx)
if err != nil { return nil, err }
// Layer our extension on top for the gap.
if shouldAddOurBit(info) {
info.OurField = readOurSource()
}Only replace the library entirely when its scope genuinely doesn't match what the collector is supposed to report (e.g. a library mixes sources in a way that produces an ambiguous value, or its output isn't reshapeable into the right semantics). When you do replace, justify the decision in the collector's Data Sources doc.
Decision order for each collector:
- ghw — canonical for physical hardware topology: CPU NUMA
- arch-aware counts, memory DIMMs/page-sizes, block devices with UUID/label/unmounted, network drivers/speed, DMI (baseboard / BIOS / chassis / product), GPU, PCI. Use ghw first for anything about static hardware shape.
- gopsutil — canonical for dynamic runtime state: memory free/available/used, disk I/O counters, network I/O counters, process enumeration, sessions (utmp), virtualization detection, host info. Use gopsutil for anything that changes per collection.
- go-sysinfo — alternative for host / platform / kernel where gopsutil is weaker. Evaluate case-by-case; don't stack both for the same fact.
- procfs — raw Linux
/procand/sysparsing when none of the above cover a field. Preferred over rolling our own scanner. - Official provider SDKs (aws-sdk-go, google.golang.org/cloud,
azure-sdk-for-go) for cloud collectors; plain
net/httpto IMDS endpoints when the SDK is too heavy. - Our own extension — last resort. ONLY the fields the
libraries above don't expose. Extensions read files via
avfs.VFSand shell out viaexecutor.Executorso tests never touch the real host. - Ohai's Ruby plugin as methodology reference only — NOT an import. We read Ohai to learn WHICH edge cases exist (fallback chains, distro quirks, retries). We then check whether ghw/gopsutil/stdlib already cover them. Only the residual gap becomes our extension code.
We learn from, but don't directly import, node_exporter — their
collectors are a gold reference for tricky Linux /proc and /sys parsing
(Apache-2 licensed). Read, understand, rewrite in our style.
Never roll your own parsing when a library covers it. If gopsutil
reads /proc/meminfo already, we don't write a second /proc/meminfo
parser — we surface the fields gopsutil already exposes on our typed
Info. If ghw enumerates block devices with UUID/label, we don't
shell out to lsblk.
Before implementing or extending a collector, verify in this order:
- Does our primary library for this collector expose the field? (Check the library's Go source, not docs. Docs may undersell.)
- If no: does a secondary library (the next one down in the Decision order) expose it?
- If still no: we need an extension. The extension uses
avfs.VFSfor file reads andexecutor.Executorfor exec calls — never plainos.ReadFile/exec.Commandin collector Collect methods.
Primary library for each collector. Changes require a PR updating this table with rationale.
| Collector | Primary | Candidate migration / supplement |
|---|---|---|
| cpu | gopsutil | ghw/cpu for NUMA/topology/arch-math |
| memory | gopsutil | ghw/memory for hugepages/page-sizes |
| filesystem | gopsutil | ghw/block for UUID/label/unmounted |
| disk | gopsutil | ghw/block for device metadata |
| network | gopsutil | ghw/net for driver/speed |
| hostname | gopsutil + stdlib | — |
| platform | gopsutil | go-sysinfo alternative considered |
| uptime | gopsutil | — |
| kernel | x/sys/unix + stdlib |
— |
| load | gopsutil | — |
| process | gopsutil | — (ghw doesn't do processes) |
| users (sessions) | gopsutil (utmp) | supplement with loginctl via executor |
| virtualization | gopsutil | go-sysinfo has some |
| fips | stdlib | No library covers |
| machine_id | gopsutil + stdlib | stdlib fallback chain |
| shard | stdlib + machine_id | — |
| init | stdlib | /proc/1/comm |
| os_release | stdlib | Our own parser |
| lsb | stdlib | supplement with lsb_release via executor |
| shells | stdlib | — |
| timezone | stdlib | — |
| root_group | stdlib (os/user) |
— |
| package_mgr | stdlib exec | executor-based |
| dmi | ghw | baseboard + BIOS + chassis + product |
| gpu | ghw | — |
| pci | ghw | — |
| block_device (planned) | ghw | — |
New collectors must justify library choice in their PR.
Migrations (gopsutil → ghw, etc.) need their own issue labeled
library-migration + collector:<name> with: current coverage,
candidate coverage, migration plan.
MANDATORY: Collector code must compile on every target platform,
with no //go:build tags anywhere. This is the pattern OSAPI uses
in internal/provider/ — study that code before writing a new
collector. Result: go test ./... on any dev machine compiles and
runs every collector's tests, coverage is visible cross-platform, and
CI on linux runners still validates actual linux runtime behavior.
Shape (see docs/adding-a-collector.md for full code examples):
pkg/gohai/collectors/<name>/
<name>.go # Info, Collector interface, base, New() factory
linux.go / darwin.go # type Linux / Darwin struct; implements Collector
debian.go / rhel.go # (only when distro diverges from generic linux)
export_test.go # SetXFn setters (upstream-library seams) for external tests
<name>_public_test.go # TestNew dispatch + single table-driven TestCollect
# keyed by a `variant` column that builds the right per-OS
# struct; compile-time interface asserts at the top.
One TestCollect per collector, end of story. No linux_public_test.go
or darwin_public_test.go files. Both OS variants are tested through the
same TestCollect method with a variant: "linux" | "darwin" column
dispatching to &X.Linux{...} or &X.Darwin{...}. Consolidation makes
the whole test surface of a collector visible at a glance; consistency
across collectors is the priority.
The factory dispatches on platform.Detect() (wraps gopsutil's
host.Info; returns "darwin" / "debian" / "rhel" / "" for generic
linux).
Key rules:
- Every struct must compile on every platform. Use cross-platform APIs
only: stdlib, gopsutil,
golang.org/x/sys/unix(per-OS layouts but compiles everywhere), ghw, cloud SDKs. No rawsyscall.Utsnameetc. - Missing OS-specific paths (e.g.
/proc/moduleson darwin) return empty gracefully — never error. - Add a
Debianvariant (orRHEL,SUSE, etc.) only when that distro family genuinely diverges. Otherwise genericLinuxcovers all non-darwin. - Dependency-inject file readers, command runners, and gopsutil calls via struct fields — lets tests exercise every branch without touching the real host.
- NEVER leak third-party types through public
Fnfields. Per-OS structFnfields are forbidden — no public field on aLinux/Darwin/ etc. variant may have a function type whose signature mentions gopsutil / ghw / procfs types. - Test seams swap at the upstream library boundary, not in the
middle. The upstream call lives in a private package var
(
var hostInfoFn = host.InfoWithContext); tests swap it via aSet<X>Fnsetter declared inexport_test.go. Do not add intermediate wrappers likereadBaseFn = readBasethat let tests bypass a bridge function — that forces a second test method (TestReadBase) to cover the bridge, which is exactly what we're consolidating away from. One seam, oneTestCollect. Collect callsreadBase(ctx)directly; tests swaphostInfoFnand the bridge mapping runs on every row. Seepkg/gohai/collectors/uptime/andpkg/gohai/collectors/load/for canonical examples. - File reads and command execution go through
FS avfs.VFSandExec executor.Executorstruct fields on the per-OS variant — these are our abstractions (not third-party types), so they're fine to expose publicly. See the "VFS + Executor Abstractions" section for the pattern.
The Collector interface and Info struct shape are the contract — whatever
backing strategy a collector uses, its output must match the typed struct
and consumer expectations.
Three-tier naming ladder. Every JSON field name comes from one of three tiers, applied in strict order of precedence:
- [OCSF][] (Open Cybersecurity Schema Framework) — primary
authority. When OCSF has a field for the concept, use its name.
Browse [schema.ocsf.io][ocsf-schema] objects:
device,device_hw_info,os,network_interface,package,process,cloud. (~108 gohai fields are tier 1.) - OpenTelemetry Resource Semantic Conventions —
when OCSF is silent. Covers CPU microarchitecture (
host.cpu.*), memory states (system.memory.*), filesystem attributes (system.filesystem.*), hardware detail (hardware.*), and process attributes (process.*). (~74 gohai fields are tier 2.) - gohai convention — for the long tail where no standard has an
opinion (~768 fields):
- Start from the backing library's field name (gopsutil/ghw),
converted to
snake_case. /procand/sysmirrors use the kernel's name insnake_case.- Unit suffixes (
_bytes,_seconds,_percent,_mhz) when the unit is ambiguous. - No abbreviations except universals:
ip,mac,pid,uid,gid,mtu,fqdn,uuid,cidr,arn,id.
- Start from the backing library's field name (gopsutil/ghw),
converted to
The complete per-field mapping with verifiable citations lives in
schemas/field-mapping.md. Fields where
OCSF is silent are tracked in
schemas/ocsf-gaps.md as upstream
contribution candidates.
Not a naming reference: Ohai (methodology only, not naming), node_exporter (methodology only), OCP (hardware design spec), CIS/SCAP/XCCDF (compliance policies). ECS, osquery, and Facter are useful cross-references but not naming authorities.
Both Go field names AND JSON tags derive from the chosen schema (OCSF primary, OpenTelemetry when OCSF is silent).
Redundant-prefix rule: JSON keys use the schema's leaf name with
any parent-object prefix stripped when that prefix duplicates our
collector name. Our output nests by collector ({"cpu": {...}, "memory": {...}}), so restating the prefix inside the nested object
is noise. Examples:
| OCSF path | Our collector | Redundant prefix? | Our JSON key |
|---|---|---|---|
device.cpu_count |
cpu |
cpu_ → strip |
count |
device.cpu_cores |
cpu |
cpu_ → strip |
cores |
device.memory_size |
memory |
memory_ → strip |
size |
os.kernel_release |
kernel |
kernel_ → strip |
release |
device.hostname |
hostname |
hostname == collector → name |
name |
process.cmd_line |
process |
no match → keep | cmd_line |
host.cpu.vendor.id |
cpu |
OTel leaf is id, parent vendor isn't our collector — keep |
vendor_id |
The full schema path (OCSF first, OTel if OCSF silent) is cited in every collector doc's Schema mapping column so consumers bridging to OCSF can write a mechanical transform.
The Go field is the PascalCase rendering of the final JSON key
(Count int \json:"count"`, Name string `json:"name"`). When Go idiom on initialisms conflicts (OCSF cpu_id→ GoCPUID, not CpuId`), Go convention wins the field name but the JSON tag still
follows the rule above. Don't invent internal names that have no
schema-mapping claim.
Do not mirror Ohai's JSON shape. Ohai is for data-source reference (what file/command to read, which distro edge cases, which fallback) — not field names or struct layout.
Before writing code for a new collector (or modifying an existing one), read Ohai's corresponding plugin and spec — but the goal is to match their collection approach (what file/command/library they read, what edge cases they handle, how they detect per-distro differences), not their JSON output shape. Ruby Mash ↔ Go struct translation isn't worthwhile to pin byte-for-byte; Go-native JSON shape is fine.
What matters: Ohai has years of accumulated bug fixes and distro-specific
quirks. Leverage that. If they read /proc/X plus fall back to cmd Y
on SUSE, we should too. If they have special handling for Amazon Linux
vs RHEL, we need to think about it too.
Fetch both files with gh api:
gh api repos/chef/ohai/contents/lib/ohai/plugins/<name>.rb --jq .content | base64 -d
gh api repos/chef/ohai/contents/spec/unit/plugins/<name>_spec.rb --jq .content | base64 -dFilenames occasionally differ — many plugins live under OS subdirs
(linux/, darwin/, windows/). Browse
repos/chef/ohai/contents/lib/ohai/plugins if the direct path 404s.
Every collector doc must carry a standard "Data Sources" section. Complex collectors that emit multiple derived facts answering different questions also get a "Signals" section.
Data Sources (required on every doc):
The Data Sources section is a self-contained spec of HOW the collector collects data, written in our voice. Numbered step-by-step, per-OS sections when behavior differs. Describe the actual sequence of reads, fallbacks, distro branches, and error handling. Do NOT frame it as a parity comparison with Ohai. Example shape:
## Data Sources
On Linux the collector cascades through multiple signals:
1. **Fast path:** if `systemd-detect-virt` is on PATH, call it.
2. **Container-runtime presence:** `which(docker)` / `which(podman)`.
3. **Xen:** `/proc/xen` and `/proc/xen/capabilities`.
4. ...Ohai is mentioned inline only when a specific methodology choice needs
attribution ("we mirror Ohai's legacy /etc/*-release fallback chain").
The section is a spec of OUR behavior, not a diff against Ohai.
Known gaps vs. Ohai is NOT a permanent section. Methodology gaps
live on GitHub as issues labeled methodology-gap and collector:<name>.
Each issue carries a "Doc after this fix lands" block with the exact
prose the fix PR pastes into the Data Sources section. When all open
methodology issues for a collector close, the doc has zero Ohai residue.
See the "Methodology Work" section below for the full workflow.
Signals (required on complex collectors like fips where multiple
fields answer different consumer questions; omit for simple collectors
like shells or root_group where the fact is a single value).
Use a prose list immediately after the Description section:
The collector reports N related signals:
- `<field>` — what it means, what source it comes from, what question
it answers for the consumer.
- `<field>` — same, including when this signal and the one above can
disagree and what that disagreement tells you.Signals are about meaning, not structure. Use them whenever a consumer can reasonably ask "which of these fields should I look at for X?" — the Signals section answers that before they have to read the field table.
This keeps docs consistent and makes it obvious at a glance whether we're leveraging Ohai's hard-won knowledge or flying solo. If Ohai has coverage we lack, either add it in the same PR or open a tracked issue — don't silently drop it.
[Reference PR adding this rule: chef/ohai#1754]
For setup, prerequisites, and contributing guidelines:
- @docs/development.md - Prerequisites, setup, code style, testing, commits, color palette, CLI architecture
- @docs/contributing.md - PR workflow and contribution guidelines
- @docs/collectors/README.md - Per-collector reference
- @schemas/README.md - JSON Schema, field-naming strategy, OCSF gap analysis
- @docs/ocsf-validation.md - How to validate OCSF output and vendor extension
just fetch / just deps / just test / just go::unit / just go::vet / just go::fmt
gohai collect --pretty # run default collectors
gohai collect --no-defaults --collector.cpu # specific collectors
gohai collect --pretty | gohai validate # validate against schema
gohai version # build infomain.go— repo-root entry point; just callscmd.Execute()cmd/— Cobra CLI subcommandsroot.go— root command, banner, context setup,AddCommandwiringcollect.go—gohai collect— collector flags, SDK wiring, delegates output tointernal/cli/validate.go—gohai validate— JSON Schema validation against embedded schema (stdin or--file)version.go—gohai version— build-time identity viacaarlos0/go-version
internal/cli/— CLI output helpers (never imported bypkg/gohai/)theme.go— maxheadroom palette (#b4a7d6lavender accent),Banner(), role-based color helpers (Mute,Accent,OK,Err,Info,Success,Failure)output.go—WriteOutput,WriteJSON,WriteFlat,WriteCollectorList— facts formatting for the collect subcommand
pkg/gohai/— Public SDKgohai.go—Gohaistruct,New(),Collect()facts.go—Factsstruct with typed collector fields and JSON/Flat methodsoptions.go— functional options (WithEnabled,WithDisabled,WithCollectors)registry.go—PublicRegistryused by CLI for flag enumeration
pkg/gohai/collectors/<name>/— Public per-collector sub-packages. Use the osapi-style per-OS struct pattern (no build tags). Seepkg/gohai/collectors/shells/for the canonical reference.<name>.go—Infostruct,Collectorinterface,basestruct (holds sharedName()/DefaultEnabled()/Dependencies()),New()factory that dispatches onplatform.Detect(), and any cross-OS helpers (shared parsing, shared constants).linux.go—type Linux struct { base; FS avfs.VFS; Exec executor.Executor }(fields only when the collector needs them) withNewLinux()and(l *Linux) Collect(ctx)method. No build tag.darwin.go—type Darwin struct { base; FS; Exec }withNewDarwin()and(d *Darwin) Collect(ctx)method. No build tag.debian.go/rhel.go(only when distro genuinely diverges) — same pattern, added to theNew()dispatch switch.<name>_public_test.go— the only test file. Contains compile-timecollector.Collectorasserts at the top,TestNewfor the factory dispatch, a single table-drivenTestCollectwhose rows carry avariant: "linux" | "darwin"column and construct the right per-OS struct, and optionally separate test methods for genuinely-pure public helpers (e.g.TestHumanDuration,TestBytesToString). Nolinux_public_test.go/darwin_public_test.gofiles.
schemas/— JSON Schema and field-naming artifactsgen/— generator tool (go run .reflectsgohai.Factsinto JSON Schema viainvopop/jsonschema);//go:generatedirective picked up byjust generategohai.schema.json— generated schema (draft 2020-12), committedschema.go—//go:embedofgohai.schema.jsonfor the validate subcommandfield-mapping.md— 803-row per-field tier mapping (OCSF/OTel/ convention) with citationsocsf-gaps.md— 73 OCSF upstream PR candidates
internal/platform/— OS/distro detection wrapping gopsutil.Detect()is a swappablevarso collector tests can force any branch without importing gopsutil.hostInfoFnis private, exposed only to platform's own tests viaexport_test.go.internal/collector/— Collector interface + registry plumbingcollector.go—Collectorinterfaceregistry.go—Registry(register, resolve deps, run concurrently)
internal/executor/— command execution abstractionexecutor.go—Executorinterface (Execute(ctx, name, args...))gen/— gomock mock generation (go generate)mocks/— generated mocks (committed)
Every .go file MUST start with the MIT license header — see any existing
Go file in the repo for the exact format. Build-tagged files put //go:build
on line 1, blank line, then the header.
Functions with parameters MUST use multi-line format:
func FunctionName(
param1 type1,
param2 type2,
) (returnType, error) {
}Zero-parameter functions stay single-line:
func (base) Name() string {
return "cpu"
}- Public tests:
*_public_test.goin test package (package gohai_testorpackage collector_test) for exported functions - Internal tests:
*_test.goin same package (package gohai) for private functions — avoid when the external package can reach what it needs via anexport_test.goalias export_test.goin the same package exposes unexported symbols to external_test.gofiles via setter functions only (SetXFn(fn) func()returning a restore func the caller defers). Do not addvar ReadX = readXtype-aliases exposing private bridges — those enabled a secondTestReadXmethod that duplicates whatTestCollectalready covers. Never put production-only code inexport_test.go; the_test.gosuffix makes it test-only- Suite naming:
*_public_test.go→{Name}PublicTestSuite,*_test.go→{Name}TestSuite - Use
testify/suitewith table-driven patterns - One
TestCollectper collector. All scenarios — both Linux and Darwin, success and error paths — live as rows in one table keyed by avariantcolumn. NoTestCollectLinux/TestCollectDarwin/TestCollectXsplits. NoTestReadXmethods that duplicate pathsTestCollectalready hits via the upstream seam. - Separate test methods are reserved for genuinely pure, independent
public helpers with their own contract (
TestHumanDuration,TestBytesToString,TestNeighFamily,TestNeighState) — not for bridges Collect already exercises. - Swap at the boundary, not in the middle.
TestCollectrows swap the raw upstream library call (hostInfoFn,partitionsFn,usersFn, ...) and let the bridge mapping run on every row. Do NOT add intermediate seams (readXFn = readX) — that's test-only scaffolding in production code, and it's what this consistency rule is specifically eliminating. - No custom assertion messages —
s.Equal(want, got), nots.Equal(want, got, "expected equal"). Matches osapi's test style - Target 100% test coverage on all packages
- Error wrapping:
fmt.Errorf("context: %w", err). Wrap upstream library errors with context — never expose raw gopsutil / ghw / procfs error types through our API. Callers must never need those packages in their module graph to handle errors - Early returns over nested if-else
- Unused parameters: rename to
_ - Import order: stdlib, third-party, local (blank-line separated)
golangci-lint with: errcheck, errname, goimports, govet, prealloc,
predeclared, revive, staticcheck. Generated files (*.gen.go, *.pb.go)
are excluded from formatting.
See @docs/development.md#branching for full conventions.
When committing changes via /commit, create a feature branch first if
currently on main. Branch names use the pattern type/short-description
(e.g., feat/add-cpu-collector, fix/memory-parsing, docs/update-readme).
Implementation planning and execution uses the superpowers plugin workflows
(writing-plans and executing-plans). Plans live in docs/superpowers/.
See @docs/development.md#commit-messages for full conventions.
Follow Conventional Commits with the
50/72 rule. Format: type(scope): description.
When committing via Claude Code, end with:
🤖 Generated with [Claude Code](https://claude.ai/code)Co-Authored-By: Claude <noreply@anthropic.com>
Step-by-step walkthrough lives in docs/adding-a-collector.md — code examples, file layout, test setup, and the commit template.
The reference implementation is pkg/gohai/collectors/shells/. Copy its
patterns exactly.
Before marking a collector complete, every item below must be true:
- Analyzed Ohai's plugin + spec for HOW it collects (data sources, distro edge cases, fallback chains). Our collection logic mirrors theirs — we inherit their years of bug fixes. Deviations are documented and justified.
- Checked OCSF schema (schema.ocsf.io) and, when OCSF is silent, OpenTelemetry Resource Semantic Conventions for canonical field names. Schema mappings recorded in the collector doc's Collected Fields table under the Schema mapping column. When a schema has a field we could emit but don't, either add it or note why.
- osapi per-OS struct pattern — no build tags, factory dispatch
on
platform.Detect(), per-OS structs each implementing Collect. - 100% test coverage.
go tool cover -func=/tmp/cov.out | grep -v '100.0%'returns nothing for the collector's files. - One
<name>_public_test.go, oneTestCollect. Linux and Darwin scenarios share the same table, keyed by avariantcolumn. Nolinux_public_test.go/darwin_public_test.gosplit files. NoTestReadXmethods shadowing bridge codeTestCollectalready exercises. Pure-helper public-function tests (e.g.TestHumanDuration) are the only legitimate extra test methods. - No intermediate seams.
export_test.goexports onlySet<X>Fnsetters that swap at the upstream library boundary (hostInfoFn,partitionsFn, etc.). NoreadXFn = readXwrappers, novar ReadX = readXtype aliases for direct bridge tests. docs/collectors/<name>.mdis a self-contained functional spec: Description (what + why in our voice), Collected Fields with Schema mapping column (OCSF path first, OpenTelemetry attribute when OCSF is silent), Platform Support, Example Output, SDK Usage, Enable/Disable, Dependencies, Data Sources (step-by-step methodology in OUR voice — not a Ohai parity table), Backing library. No "Known gaps vs. Ohai" section — methodology gaps live as GitHub issues (labeledmethodology-gap/collector:<name>).- README.md row flipped to
✅ (<backing>). - Lint clean,
just go::vetreturns 0 issues. - Commit message explains the "why" — what Ohai/OCSF cross-references drove the implementation, what extensions over the upstream library we added, any deliberate deviations.
- Check GitHub issues for tracked methodology gaps:
gh issue list --label methodology-gap --label collector:<name>. If the work closes a tracked issue, the issue's "Doc after this fix lands" block IS the doc content to paste into Data Sources. The PR description must includeCloses #N.
See docs/adding-a-collector.md for the full step-by-step walkthrough (code examples, test setup, doc template, commit template).
Methodology gaps between gohai and Ohai live on GitHub as issues
labeled methodology-gap and collector:<name>. See
gh issue list --label methodology-gap. Each issue carries:
- Full Ohai methodology breakdown, source-cited with file + line ranges.
- Our current implementation and what it misses.
- Risk / severity / which hosts fail.
- Proposed fix — concrete code plan.
- Acceptance criteria.
- "Doc after this fix lands" — the exact prose (Description +
Collected Fields table + Data Sources) the fix PR pastes into the
collector's
docs/collectors/<name>.md.
Workflow when working a methodology issue:
gh issue view <N>— read end to end, especially the "Doc after this fix lands" block.- Implement the code change per "Proposed fix" — use the VFS /
Executor abstractions if Phase 1 has landed, otherwise the
export_test.go+ private var +Set<X>Fnpattern. - Paste the issue's "Doc after this fix lands" block into the collector doc, replacing Description / Collected Fields / Data Sources as specified.
- PR description must include
Closes #N. - CI green, 100% coverage,
just go::vetclean.
When every open methodology issue closes, every collector doc reads as a self-contained spec and the SDK has zero unresolved methodology divergences from Ohai.
Collectors that read files or shell out MUST use two shared abstractions, injected as struct fields on the per-OS variant (same pattern as osapi's Agent struct).
github.com/avfs/avfs used directly —
no custom wrapper. Production wires the real OS FS via
osfs.NewWithNoIdm(); tests wire memfs.New() with canned files at
real absolute paths (/proc/meminfo, /etc/os-release, etc.). Tests
exercise the real ReadFile / Open / Stat code path against
memory-backed content — a genuine integration test of the collector's
FS interaction, not a function-stub swap.
Per-OS struct shape:
type Linux struct {
base
FS avfs.VFS
}
func NewLinux() *Linux {
return &Linux{FS: osfs.NewWithNoIdm()}
}
func (l *Linux) Collect(ctx context.Context) (any, error) {
b, err := l.FS.ReadFile("/etc/shells")
// ...
}Test shape:
f := memfs.New()
_ = f.MkdirAll("/etc", 0o755) // memfs requires the directory
_ = f.WriteFile("/etc/shells", canned, 0o644)
c := &shells.Linux{FS: f}
got, err := c.Collect(ctx)Reference implementation: pkg/gohai/collectors/shells/.
internal/executor provides a minimal interface (single method:
Execute(ctx, name, args...) ([]byte, error)) with a gomock mock at
internal/executor/mocks/. Production impl wraps exec.CommandContext
and returns combined stdout+stderr. Collectors that shell out (sysctl,
sw_vers, lsb_release, loginctl, lscpu, kextstat, etc.) hold the
Executor as a struct field.
Per-OS struct with both FS and Executor:
type Darwin struct {
base
FS avfs.VFS
Exec executor.Executor
}
func NewDarwin() *Darwin {
return &Darwin{
FS: osfs.NewWithNoIdm(),
Exec: executor.New(),
}
}Test shape (gomock):
ctrl := gomock.NewController(t)
mockExec := mocks.NewMockExecutor(ctrl)
mockExec.EXPECT().
Execute(gomock.Any(), "sw_vers", "-productVersionExtra").
Return([]byte("(a)\n"), nil)
c := &platform.Darwin{FS: memfs.New(), Exec: mockExec}Mocks are regenerated via go generate ./internal/executor/... and
committed. Pinned tool: go.uber.org/mock (maintained fork — osapi
uses the deprecated golang/mock; we picked the fork).
All new code and new collectors MUST use these abstractions. Existing
collectors still on the legacy ReadFileFn / RunCmdFn struct-field
pattern migrate as methodology work touches them. Canonical reference:
pkg/gohai/collectors/shells/ (VFS only),
pkg/gohai/collectors/platform/ (VFS + Executor).