Skip to content

Commit b0ef4f5

Browse files
ledermannclaude
andcommitted
test: cover host probes without a live host
The coverage gate failed on GitHub with 99.63 percent. Four files covered less than 100 percent: docker_report, host_metrics, host_stats and cgroup_reader. These lines had no test of their own. The suite ran them by accident on the development Mac. A Docker daemon runs there, so the support bundle got a real snapshot and rendered the container and network tables. The macOS readers sysctl, vm_stat and sw_vers also answer there. The GitHub runner has no Docker daemon in the fast job and runs Linux, so these lines never ran. Add stubbed examples for each of these paths. They give the same result on Linux and on macOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cf34629 commit b0ef4f5

4 files changed

Lines changed: 320 additions & 7 deletions

File tree

spec/services/host_stats_spec.rb

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,52 @@ def write_proc_file(name, content)
182182
end
183183
end
184184

185-
# macOS dev fallback: no /proc at all, so the numbers come from sysctl.
185+
# macOS dev fallback: no /proc at all, so the numbers come from sysctl and
186+
# vm_stat. Both are stubbed, so the examples also run on Linux CI, where
187+
# these commands exist but answer nothing.
186188
describe 'the sysctl fallback' do
187189
it 'reports nothing when sysctl cannot be run' do
188190
allow(described_class).to receive(:capture_int).and_raise(Errno::ENOENT, 'sysctl')
189191

190192
expect(described_class.send(:mem_from_sysctl)).to be_nil
191193
end
194+
195+
it 'reports total RAM from sysctl and available RAM from the vm_stat pages' do
196+
page_size = Etc.sysconf(Etc::SC_PAGE_SIZE)
197+
stub_sysctl(total: '8589934592')
198+
stub_vm_stat(<<~VM_STAT)
199+
Mach Virtual Memory Statistics: (page size of #{page_size} bytes)
200+
Pages free: 10000.
201+
Pages inactive: 20000.
202+
Pages speculative: 5000.
203+
VM_STAT
204+
205+
expect(described_class.send(:mem_from_sysctl)).to eq([8_589_934_592, 35_000 * page_size])
206+
end
207+
208+
it 'reports nothing when sysctl answers no memory size' do
209+
stub_sysctl(total: 'unknown')
210+
211+
expect(described_class.send(:mem_from_sysctl)).to be_nil
212+
end
213+
214+
it 'reports nothing when vm_stat fails' do
215+
stub_sysctl(total: '8589934592')
216+
stub_vm_stat('vm_stat: command not found', success: false)
217+
218+
expect(described_class.send(:mem_from_sysctl)).to be_nil
219+
end
220+
221+
def stub_sysctl(total:)
222+
allow(Open3).to receive(:capture2e).and_call_original
223+
allow(Open3).to receive(:capture2e).with('sysctl', '-n', 'hw.memsize')
224+
.and_return([total, instance_double(Process::Status, success?: true)])
225+
end
226+
227+
def stub_vm_stat(output, success: true)
228+
allow(Open3).to receive(:capture2e).with('vm_stat')
229+
.and_return([output, instance_double(Process::Status, success?: success)])
230+
end
192231
end
193232

194233
describe '.proc_root' do

spec/services/support_bundle/system_info/cgroup_reader_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,46 @@
7070
end
7171
end
7272

73+
describe '.v2?' do
74+
it 'is true when the unified hierarchy exposes cgroup.controllers' do
75+
stub_host_file('/sys/fs/cgroup/cgroup.controllers')
76+
77+
expect(described_class).to be_v2
78+
expect(described_class.source).to eq('cgroup v2 (container limit)')
79+
end
80+
81+
it 'is false without it, so the v1 paths are used' do
82+
stub_missing_host_file('/sys/fs/cgroup/cgroup.controllers')
83+
84+
expect(described_class).not_to be_v2
85+
expect(described_class.source).to eq('cgroup v1 (container limit)')
86+
end
87+
end
88+
89+
describe '.cpu_quota_cores' do
90+
it 'divides quota by period on cgroup v2' do
91+
stub_cgroup(v2: true, '/sys/fs/cgroup/cpu.max' => '150000 100000')
92+
93+
expect(described_class.cpu_quota_cores).to eq(1.5)
94+
end
95+
96+
it 'reads the two CFS files on cgroup v1' do
97+
stub_cgroup(
98+
v2: false,
99+
'/sys/fs/cgroup/cpu/cpu.cfs_quota_us' => '200000',
100+
'/sys/fs/cgroup/cpu/cpu.cfs_period_us' => '100000',
101+
)
102+
103+
expect(described_class.cpu_quota_cores).to eq(2.0)
104+
end
105+
106+
it 'is nil when cgroup v2 reports no quota' do
107+
stub_cgroup(v2: true, '/sys/fs/cgroup/cpu.max' => 'max 100000')
108+
109+
expect(described_class.cpu_quota_cores).to be_nil
110+
end
111+
end
112+
73113
describe '.cpuset_cores' do
74114
context 'when the cpuset covers every host CPU' do
75115
before do

spec/services/support_bundle/system_info/docker_report_spec.rb

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,111 @@
2323
end
2424
end
2525

26+
describe '.engine' do
27+
it 'reports version, API version, platform and storage driver' do
28+
snapshot = {
29+
version: { 'Version' => '27.0.3', 'ApiVersion' => '1.46', 'Os' => 'linux', 'Arch' => 'arm64' },
30+
info: { 'Driver' => 'overlay2' },
31+
}
32+
33+
expect(described_class.engine(snapshot)).to eq(
34+
'Version' => '27.0.3',
35+
'API version' => '1.46',
36+
'OS/Arch' => 'linux/arm64',
37+
'Storage driver' => 'overlay2',
38+
)
39+
end
40+
41+
it 'shows the snapshot error as the status' do
42+
expect(described_class.engine(error: 'unavailable: boom')).to eq('Status' => 'unavailable: boom')
43+
end
44+
end
45+
46+
describe '.compose' do
47+
it 'reports the version of the compose plugin' do
48+
allow(SupportBundle::SystemInfo::OutputFormatter)
49+
.to receive(:capture).with('docker', 'compose', 'version', '--short').and_return('2.32.4')
50+
51+
expect(described_class.compose).to eq('Version' => '2.32.4')
52+
end
53+
end
54+
55+
describe '.containers' do
56+
it 'passes the snapshot error through' do
57+
expect(described_class.containers(error: 'unavailable: boom')).to eq('unavailable: boom')
58+
end
59+
60+
it 'reports an empty daemon instead of an empty table' do
61+
expect(described_class.containers(containers: [])).to eq('No containers found.')
62+
end
63+
64+
it 'counts the containers and lists the running ones first' do
65+
snapshot = {
66+
containers: [
67+
fake_container('influxdb', { 'solectrus_default' => {} },
68+
state: 'exited', status: 'Exited (0) 2 hours ago', image: 'influxdb:2.7'),
69+
fake_container('helios', { 'default' => {}, 'solectrus_default' => {} },
70+
state: 'running', status: 'Up 3 minutes', image: 'ghcr.io/solectrus/helios'),
71+
],
72+
}
73+
74+
result = described_class.containers(snapshot)
75+
rows = result.lines.map(&:strip)
76+
77+
expect(rows.first).to eq('2 total (running: 1, stopped: 1)')
78+
expect(rows[3]).to start_with('helios')
79+
expect(rows[3]).to include('running', 'Up 3 minutes', 'default,solectrus_default')
80+
expect(rows[4]).to start_with('influxdb')
81+
expect(rows[4]).to include('exited', 'influxdb:2.7')
82+
end
83+
84+
# A container started outside compose may carry no name; the short id is
85+
# the only handle support has on it, and it has no network of its own.
86+
it 'falls back to the short id and a dash for a nameless container' do
87+
nameless = instance_double(
88+
Docker::Container,
89+
info: { 'Id' => 'ab12cd34ef567890', 'State' => 'running', 'Status' => 'Up 1 second', 'Image' => 'busybox' },
90+
)
91+
92+
result = described_class.containers(containers: [nameless])
93+
94+
expect(result).to include('ab12cd34ef56')
95+
expect(result.lines.last).to match(/busybox\s+-/)
96+
end
97+
end
98+
99+
describe '.networks' do
100+
it 'passes the snapshot error through' do
101+
expect(described_class.networks(error: 'unavailable: boom')).to eq('unavailable: boom')
102+
end
103+
104+
it 'reports nothing to show when the daemon has no containers' do
105+
expect(described_class.networks(containers: [])).to eq('No networks found.')
106+
end
107+
108+
it 'reports nothing to show when no container is attached to a network' do
109+
bare = instance_double(Docker::Container, info: { 'Names' => ['/orphan'] })
110+
111+
expect(described_class.networks(containers: [bare])).to eq('No networks found.')
112+
end
113+
114+
it 'lists each network with its member count and names' do
115+
snapshot = {
116+
containers: [
117+
fake_container('influxdb', { 'solectrus_default' => {} }),
118+
fake_container('dashboard', { 'solectrus_default' => {} }),
119+
fake_container('helios', { 'default' => {} }),
120+
],
121+
}
122+
123+
rows = described_class.networks(snapshot).lines.map(&:strip)
124+
125+
expect(rows.first).to match(/NAME\s+CONTAINERS\s+NAMES/)
126+
expect(rows[1]).to match(/\Adefault\s+1\s+helios\z/)
127+
expect(rows[2]).to match(/\Asolectrus_default\s+2\s+dashboard, influxdb\z/)
128+
end
129+
end
130+
26131
describe '.network_membership' do
27132
it 'groups containers by attached network' do
28133
containers = [
@@ -51,12 +156,18 @@
51156

52157
expect(described_class.network_membership([bare])).to eq({})
53158
end
159+
end
54160

55-
def fake_container(name, networks)
56-
instance_double(
57-
Docker::Container,
58-
info: { 'Names' => ["/#{name}"], 'NetworkSettings' => { 'Networks' => networks } },
59-
)
60-
end
161+
def fake_container(name, networks, state: 'running', status: 'Up 1 minute', image: 'alpine')
162+
instance_double(
163+
Docker::Container,
164+
info: {
165+
'Names' => ["/#{name}"],
166+
'State' => state,
167+
'Status' => status,
168+
'Image' => image,
169+
'NetworkSettings' => { 'Networks' => networks },
170+
},
171+
)
61172
end
62173
end

spec/services/support_bundle/system_info/host_metrics_spec.rb

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,10 +408,133 @@
408408
end
409409
end
410410

411+
# macOS dev boxes have no /proc and no cgroup filesystem; every value comes
412+
# from sysctl and vm_stat there. The stubs keep these examples deterministic
413+
# on Linux CI, where those binaries answer nothing useful.
414+
describe 'the macOS fallbacks' do
415+
before do
416+
stub_missing_host_file('/usr/sbin/sysctl')
417+
stub_host_file('/sbin/sysctl')
418+
end
419+
420+
it 'reads model and core count from sysctl' do
421+
stub_capture(%w[sysctl -n machdep.cpu.brand_string hw.ncpu], "Apple M4 Pro\n14")
422+
423+
expect(described_class.cpu_from_sysctl).to eq('Model' => 'Apple M4 Pro', 'Cores' => '14')
424+
end
425+
426+
it 'falls back to "unknown" when sysctl answers nothing' do
427+
stub_capture(%w[sysctl -n machdep.cpu.brand_string hw.ncpu], '')
428+
429+
expect(described_class.cpu_from_sysctl).to eq('Model' => 'unknown', 'Cores' => 'unknown')
430+
end
431+
432+
it 'reports total RAM from sysctl and available RAM from vm_stat' do
433+
stub_capture(%w[sysctl -n hw.memsize hw.pagesize], "2147483648\n16384")
434+
stub_capture(['vm_stat'], <<~VM_STAT)
435+
Mach Virtual Memory Statistics: (page size of 16384 bytes)
436+
Pages free: 10000.
437+
Pages inactive: 20000.
438+
Pages speculative: 2536.
439+
VM_STAT
440+
441+
expect(described_class.memory_from_sysctl).to eq('Total' => '2 GB', 'Available' => '508 MB')
442+
end
443+
444+
it 'reports unknown available RAM when sysctl gives no page size' do
445+
stub_capture(%w[sysctl -n hw.memsize hw.pagesize], '2147483648')
446+
447+
expect(described_class.memory_from_sysctl).to eq('Total' => '2 GB', 'Available' => 'unknown')
448+
end
449+
450+
it 'reads the product name, version and build from sw_vers' do
451+
stub_host_file('/usr/bin/sw_vers')
452+
stub_capture(['sw_vers'], <<~SW_VERS)
453+
ProductName:\t\tmacOS
454+
ProductVersion:\t\t15.0
455+
BuildVersion:\t\t24A335
456+
SW_VERS
457+
458+
expect(described_class.macos_os_release).to eq('macOS 15.0 (24A335)')
459+
expect(described_class.os_release).to eq('macOS 15.0 (24A335)')
460+
end
461+
end
462+
463+
describe '.os_release' do
464+
it 'is unavailable when neither os-release nor sw_vers exist' do
465+
stub_missing_host_file('/etc/os-release')
466+
stub_missing_host_file('/usr/bin/sw_vers')
467+
468+
expect(described_class.os_release).to eq('unavailable')
469+
end
470+
end
471+
472+
describe '.disk' do
473+
before { allow(Rails.configuration).to receive(:data_path).and_return('/data') }
474+
475+
it 'reports the parsed df values next to the data path' do
476+
allow(described_class).to receive(:parse_df).with('/data').and_return('Total' => '1 TB')
477+
478+
expect(described_class.disk).to eq('Data path' => '/data', 'Total' => '1 TB')
479+
end
480+
481+
it 'falls back to the raw df output when parsing fails' do
482+
allow(described_class).to receive(:parse_df).with('/data').and_return(nil)
483+
stub_capture(%w[df -kP /data], 'df: /data: No such file or directory')
484+
485+
expect(described_class.disk).to eq(
486+
'Data path' => '/data',
487+
'Usage' => 'df: /data: No such file or directory',
488+
)
489+
end
490+
end
491+
492+
describe '.data_volumes' do
493+
let(:data_path) { Dir.mktmpdir }
494+
495+
before { allow(Rails.configuration).to receive(:data_path).and_return(data_path) }
496+
497+
after { FileUtils.remove_entry(data_path) }
498+
499+
it 'reports the size of every subdirectory of the data path' do
500+
FileUtils.mkdir_p(File.join(data_path, 'influxdb'))
501+
FileUtils.mkdir_p(File.join(data_path, 'postgresql'))
502+
File.write(File.join(data_path, 'compose.yaml'), "services:\n")
503+
504+
result = described_class.data_volumes
505+
506+
expect(result.keys).to eq(%w[influxdb postgresql])
507+
expect(result.values).to all(match(/\A\d+(\.\d+)? (Bytes|[KMGT]B)\z/))
508+
end
509+
510+
it 'reports an empty data path' do
511+
expect(described_class.data_volumes).to eq('Status' => 'no data directories found')
512+
end
513+
514+
it 'reports a data path that does not exist' do
515+
allow(Rails.configuration).to receive(:data_path).and_return(File.join(data_path, 'gone'))
516+
517+
expect(described_class.data_volumes).to eq('Status' => 'data path unavailable')
518+
end
519+
520+
it 'reports unknown sizes when du fails' do
521+
FileUtils.mkdir_p(File.join(data_path, 'influxdb'))
522+
allow(described_class).to receive(:directory_sizes).and_call_original
523+
stub_capture(['du', '-sk', File.join(data_path, 'influxdb')], 'failed (exit 1): du: cannot read')
524+
525+
expect(described_class.data_volumes).to eq('influxdb' => 'unknown')
526+
end
527+
end
528+
411529
# Single helper for the cgroup-stub pattern: pass `v2: true/false` plus a
412530
# path => content mapping. Files not in the mapping return nil.
413531
def stub_cgroup(v2:, **paths) # rubocop:disable Naming/MethodParameterName
414532
allow(SupportBundle::SystemInfo::CgroupReader).to receive(:v2?).and_return(v2)
415533
allow(SupportBundle::SystemInfo::CgroupReader).to receive(:read_first_line) { |path| paths[path] }
416534
end
535+
536+
def stub_capture(command, output)
537+
allow(SupportBundle::SystemInfo::OutputFormatter)
538+
.to receive(:capture).with(*command).and_return(output)
539+
end
417540
end

0 commit comments

Comments
 (0)