From 348152c43585e7f9af228ae85572c584f2232aa0 Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Tue, 27 Jan 2026 16:54:05 +0100 Subject: [PATCH 01/21] sys.firmware: Add microversion to bmc.version --- gocollect-client/collectors/sys.firmware | 25 +++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/gocollect-client/collectors/sys.firmware b/gocollect-client/collectors/sys.firmware index a779266..6c126ae 100755 --- a/gocollect-client/collectors/sys.firmware +++ b/gocollect-client/collectors/sys.firmware @@ -53,6 +53,29 @@ biosinfo=$(getblock 'BIOS Information' "$dmidecode") sysinfo=$(getblock 'System Information' "$dmidecode") boardinfo=$(getblock 'Base Board Information' "$dmidecode") +get_bmc_version() { + # Should produce "1.05.29" after the example below. + # Maybe SuperMicro specific. + local line + local next=false + local val + # Firmware Revision : 1.05 + getkey 'Firmware Revision' "$mcinfo" + # Aux Firmware Rev Info : + # 0x29 + # 0x01 + # ... + while read -r line; do + if $next; then + val=$(($line)) # 0x29 + printf '.%x' "$val" # printed as ".29" + return 0 + elif [[ "$line" =~ ^Aux\ Firmware\ Rev\ Info[[:blank:]]*: ]]; then + next=true + fi + done <<< "$mcinfo" +} + mcinfo= if command -v ipmitool >/dev/null 2>&1; then #modprobe ipmi_msghandler @@ -98,7 +121,7 @@ cat << EOF "bmc": { "manufacturer": "$(getkey 'Manufacturer Name' "$mcinfo")", "product": "$(getkey 'Product Name' "$mcinfo")", - "version": "$(getkey 'Firmware Revision' "$mcinfo")", + "version": "$(get_bmc_version)", "devicerev": "$(getkey 'Device Revision' "$mcinfo")", "ipmiver": "$(getkey 'IPMI Version' "$mcinfo")" }, From 8291ffe0ca487af4f1ca090b87a520ef87e5e104 Mon Sep 17 00:00:00 2001 From: Harm Geerts Date: Wed, 28 Jan 2026 14:08:11 +0100 Subject: [PATCH 02/21] rmq2nb: Fix validation of network ID and broadcast addresses --- servers/rmq2nb/service.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/servers/rmq2nb/service.py b/servers/rmq2nb/service.py index 606a979..f50c61f 100644 --- a/servers/rmq2nb/service.py +++ b/servers/rmq2nb/service.py @@ -537,7 +537,18 @@ def is_meaningful_address(self, ip): def assign_ip_address(self, interface_id, ip, dry_run): if ip.ip == ip.network: - log.debug('%s cannot assign network ID %s to interface', self, ip) + if ip.version == 4 and ip.prefixlen not in (31, 32): + log.debug( + '%s cannot assign network ID %s to interface', self, ip) + return + elif ip.version == 6 and ip.prefixlen not in (127, 128): + log.debug( + '%s cannot assign network ID %s to interface', self, ip) + return + if (ip.ip == ip.broadcast and ip.version == 4 + and ip.prefixlen not in (31, 32)): + log.debug( + '%s cannot assign network broadcast %s to interface', self, ip) return addresses = [] From 34ff979f2e45efa8f30f207341d2cf955b616ac1 Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 16 Mar 2026 13:57:25 +0100 Subject: [PATCH 03/21] app.ps-kvmex1: Do not die on new JSON kvm args --- gocollect-client/collectors/app.ps-kvmex1 | 28 ++++++++++++----------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/gocollect-client/collectors/app.ps-kvmex1 b/gocollect-client/collectors/app.ps-kvmex1 index 66f13ce..7430c3c 100755 --- a/gocollect-client/collectors/app.ps-kvmex1 +++ b/gocollect-client/collectors/app.ps-kvmex1 @@ -27,21 +27,18 @@ valtodict() { echo "[" n=0 -ps h -o cmd -p $(pidof kvm qemu-kvm qemu-system-x86_64 | tr ' ' ,) \ - 2>/dev/null | while read kvmcmd; do - all='*' +for pid in $(pidof kvm qemu-kvm qemu-system-x86_64); do + match_first='*' optkey= - test $n -gt 0 && echo -n , - n=$((n+1)) used_keys=() n2=0 - for cmd in $kvmcmd; do + while IFS= read -r -d '' cmd; do n2=$((n2+1)) case $cmd in - $all) - echo -n "{\"argv0\":\"$cmd\"" + $match_first) + echo -n ",{\"argv0\":\"$cmd\"" bin=$cmd - all= + match_first= ;; -*) test -n "$optkey" && echo -n ",\"$optkey\":true" @@ -50,12 +47,17 @@ ps h -o cmd -p $(pidof kvm qemu-kvm qemu-system-x86_64 | tr ' ' ,) \ used_keys+=($optkey) ;; *) - echo -n ",\"$optkey\":$(valtodict "$cmd")" + if [[ "${cmd:0:1}" == "{" ]]; then + echo -n ",\"$optkey\":$cmd" + else + echo -n ",\"$optkey\":$(valtodict "$cmd")" + fi optkey= ;; esac - done + done < /proc/$pid/cmdline || continue 2>/dev/null + n=$((n+1)) test -n "$optkey" && echo -n ",\"$optkey\":true" - echo ",\"argv_all\":\"$kvmcmd\"}" -done + echo '}' +done 2>/dev/null | LC_ALL=C sort | sed -e '1s/^,//' echo "]" From 5e2e8431ac072fe674ed3b9bdf3442215fcf04b8 Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 16 Mar 2026 13:57:55 +0100 Subject: [PATCH 04/21] sys.firmware: Fix fluctuating Created args --- gocollect-client/collectors/sys.firmware | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gocollect-client/collectors/sys.firmware b/gocollect-client/collectors/sys.firmware index 6c126ae..3ecd298 100755 --- a/gocollect-client/collectors/sys.firmware +++ b/gocollect-client/collectors/sys.firmware @@ -91,7 +91,10 @@ fi fwupdatesjs= if command -v fwupdmgr >/dev/null; then # Do we also want to get 'fwupdmgr security' later as well? - fwupdatesjs=$(fwupdmgr --json get-devices 2>/dev/null) + # The "Devices" all have a "Created" that gets updated timestamps. We do + # not want fluctuating data, so replace with 0. + fwupdatesjs=$(fwupdmgr --json get-devices 2>/dev/null | + sed -e 's/\("Created"[[:blank:]]*\):[[:blank:]]*[0-9]\+/\1:0/') if [[ "$fwupdatesjs" != '{'* ]]; then fwupdatesjs= fi From d6f49e4544c2448178bdf1421bb25c7ed5c92dd7 Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Thu, 9 Apr 2026 15:52:18 +0200 Subject: [PATCH 05/21] build: Move to go modules (GO111MODULE=on) Add go.mod / go.sum with pinned dependencies. --- README.rst | 9 --------- gocollect-client/Makefile | 12 +++++------- gocollect-client/go.mod | 14 ++++++++++++++ gocollect-client/go.sum | 12 ++++++++++++ 4 files changed, 31 insertions(+), 16 deletions(-) create mode 100644 gocollect-client/go.mod create mode 100644 gocollect-client/go.sum diff --git a/README.rst b/README.rst index 581bc7b..8507254 100644 --- a/README.rst +++ b/README.rst @@ -33,15 +33,6 @@ And check this out inside that:: git clone https://github.com/ossobv/gocollect \ $GOPATH/src/github.com/ossobv/gocollect -And install prerequisites:: - - go get github.com/ossobv/go-getopt - -Possibly set env to old style module handling:: - - # go.mod file not found in current directory or any parent directory... - go env -w GO111MODULE=off # sets ~/.config/go/env: GO111MODULE=off - Packaging for Debian -------------------- diff --git a/gocollect-client/Makefile b/gocollect-client/Makefile index e5f4035..22ad9b9 100644 --- a/gocollect-client/Makefile +++ b/gocollect-client/Makefile @@ -3,7 +3,6 @@ prefix = /usr SOURCES = $(shell find . -name '*.go' -type f | sort) -GODIRS = $(shell find . -name '*.go' -type f | sed -e 's:/[^/]*.go$$::' | sort -u) SHCOLLECTORS = $(shell find collectors/ -maxdepth 1 -name '[a-z]*.*' -type f -perm /700 '!' -name '*.*.*' | sort) # Debian version spec says: # - tilde sorts before anything, so ~rc1 sorts before final @@ -23,7 +22,7 @@ clean: $(RM) gocollect gocollect: $(SOURCES) - go build $(GOFLAGS) $(GOLDFLAGS) gocollect.go + go build -o gocollect $(GOFLAGS) $(GOLDFLAGS) . if ldd gocollect | grep '=>'; then echo "ERROR: static linkage failed" >&2; \ $(RM) gocollect; false; fi @@ -140,16 +139,15 @@ gocollect-$(TGZ_VERSION).tar.gz: gocollect-bin .PHONY: check pretty testrun testrun: gocollect-bin #GOTRACEBACK=system strace -tt -fbexecve ./gocollect -c gocollect-test.conf - sudo env GOPATH=$$GOPATH GOTRACEBACK=system ./gocollect -c gocollect-test.conf + sudo env GOTRACEBACK=system ./gocollect -c gocollect-test.conf check: pretty - for d in $(GODIRS); do (cd $$d && go test); done + go test ./... pretty: git ls-files | grep '\.go$$' | while read x; do gofmt -d "$$x" | patch $$x; done - for d in $(GODIRS); do golint $$d && (cd $$d && go vet); done + golint ./... && go vet ./... # .PHONY: fetch-new-package # fetch-new-package: -# # Be sure to set GOPATH; see ./gorc. -# go get github.com/XXX +# go get github.com/XXX@latest diff --git a/gocollect-client/go.mod b/gocollect-client/go.mod new file mode 100644 index 0000000..8868ed6 --- /dev/null +++ b/gocollect-client/go.mod @@ -0,0 +1,14 @@ +module github.com/ossobv/gocollect/gocollect-client + +go 1.18 + +require ( + github.com/ghodss/yaml v1.0.0 + github.com/ossobv/go-getopt v0.0.0-20170714165814-18b9f88777ae + golang.org/x/term v0.27.0 +) + +require ( + golang.org/x/sys v0.28.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/gocollect-client/go.sum b/gocollect-client/go.sum new file mode 100644 index 0000000..685f5d7 --- /dev/null +++ b/gocollect-client/go.sum @@ -0,0 +1,12 @@ +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ossobv/go-getopt v0.0.0-20170714165814-18b9f88777ae h1:UR0dWrUS6161P/bXUYgvVXajtAKsO71rTbg1g/aZzEg= +github.com/ossobv/go-getopt v0.0.0-20170714165814-18b9f88777ae/go.mod h1:tTcdnmiXo63vXtgCJZLSArUSDaixcVgPVngo7zOJ7OY= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= From c1e61cc719918057043119cfdf8e4f98e612f894 Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Thu, 9 Apr 2026 15:52:22 +0200 Subject: [PATCH 06/21] tests: Fix TestParseArgsOrExit_NoOptions for newer golang The test relied on os.Args being empty, but go test injects its own flags (e.g. -test.testlogfile). --- gocollect-client/gocollect_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/gocollect-client/gocollect_test.go b/gocollect-client/gocollect_test.go index a512e1b..6308e0f 100644 --- a/gocollect-client/gocollect_test.go +++ b/gocollect-client/gocollect_test.go @@ -19,6 +19,7 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) { } func TestParseArgsOrExit_NoOptions(t *testing.T) { + os.Args = []string{"prog"} args := parseArgsOrExit() assertEqual(t, args["one-shot"].Bool, false, "") assertEqual(t, args["config"].String, "/etc/gocollect.conf", "") From 39f50c882bd77293ac8d61fd6fe5b3151c41889d Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Thu, 9 Apr 2026 15:53:42 +0200 Subject: [PATCH 07/21] deps: Bump requests dependency Fixes: Insecure Temp File Reuse in its extract_zipped_paths() --- servers/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers/requirements.txt b/servers/requirements.txt index 30ce13f..2ae0a41 100644 --- a/servers/requirements.txt +++ b/servers/requirements.txt @@ -1,3 +1,3 @@ pika==0.12.0 -requests==2.32.4 +requests==2.33.0 netaddr==0.8.0 From 0dae9b43d1fceb8bbc6ca00978237048765c718f Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Thu, 9 Apr 2026 15:56:18 +0200 Subject: [PATCH 08/21] cleanup: Run make pretty Note that the golang folks did not stop and appreciate the Wisdom of Years of Python for the indentation and came up with this mess: if strings.ContainsRune(key, '.') || - strings.ContainsRune(key, 0) || - strings.HasPrefix(key, "$") { + strings.ContainsRune(key, 0) || + strings.HasPrefix(key, "$") { log.Log.Printf("possibly problematic key: %s", key) This is stupid. But we're not going to fight gofmt this time. Life is too short. See the recommended Python practice: # Add some extra indentation on the conditional continuation line. if (this_is_one_thing and that_is_another_thing): do_something() Upstream-Bug: https://github.com/golang/go/issues/48064 --- gocollect-client/data/collected.go | 6 +++--- gocollect-client/runner/runner.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gocollect-client/data/collected.go b/gocollect-client/data/collected.go index e44d932..a2228a5 100644 --- a/gocollect-client/data/collected.go +++ b/gocollect-client/data/collected.go @@ -48,7 +48,7 @@ type collected struct { func NewCollected(data []byte) (Collected, error) { // Warn about periods in keys. But they seem to be legal in some // collectors. Only warn if stderr is a tty. - if (isStderrTTY()) { + if isStderrTTY() { warnAboutProblematicKeys(data) } @@ -213,8 +213,8 @@ func hasProblematicKeys(obj any) bool { case map[string]any: for key, val := range v { if strings.ContainsRune(key, '.') || - strings.ContainsRune(key, 0) || - strings.HasPrefix(key, "$") { + strings.ContainsRune(key, 0) || + strings.HasPrefix(key, "$") { log.Log.Printf("possibly problematic key: %s", key) return true } diff --git a/gocollect-client/runner/runner.go b/gocollect-client/runner/runner.go index ce0e5e0..a072283 100644 --- a/gocollect-client/runner/runner.go +++ b/gocollect-client/runner/runner.go @@ -37,7 +37,7 @@ func (r *Runner) Run() bool { } // Then run all collectors. - if (runner.runAll() != runSuccess) { + if runner.runAll() != runSuccess { return false } return true From 32f62fbd0212a11782d5300999b21ed5eb52fe5d Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Thu, 9 Apr 2026 16:04:35 +0200 Subject: [PATCH 09/21] cleanup: More make pretty --- gocollect-client/gocollect.go | 8 ++++---- gocollect-client/runner/internal.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gocollect-client/gocollect.go b/gocollect-client/gocollect.go index c977e24..7ededbe 100644 --- a/gocollect-client/gocollect.go +++ b/gocollect-client/gocollect.go @@ -284,7 +284,7 @@ func main() { // Do complete run. os.Stdout.Close() var interval int - last_success := true + lastSuccess := true for { ret := collectRunner.Run() if oneShot { @@ -297,11 +297,11 @@ func main() { if ret { // All good, run again in 4 hours interval = 4 * 3600 - last_success = true - } else if last_success { + lastSuccess = true + } else if lastSuccess { // Retry in 5 minutes if this is the first run interval = 300 - last_success = false + lastSuccess = false } else { // Keep retrying in larger intervals interval *= 2 diff --git a/gocollect-client/runner/internal.go b/gocollect-client/runner/internal.go index 3c6d101..ffd9dba 100644 --- a/gocollect-client/runner/internal.go +++ b/gocollect-client/runner/internal.go @@ -110,7 +110,7 @@ func (ri *runInfo) runAll() runStatus { break } - collectors += 1 + collectors++ } return ret From 5a9c6573a7fd478c42aa1efdc513ff4146fdb21e Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 13 Apr 2026 14:23:30 +0200 Subject: [PATCH 10/21] os.keys: Fix so sshd_config Includes are read for ssh key location --- gocollect-client/collectors/os.keys | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gocollect-client/collectors/os.keys b/gocollect-client/collectors/os.keys index f4e4c04..9b27082 100755 --- a/gocollect-client/collectors/os.keys +++ b/gocollect-client/collectors/os.keys @@ -7,7 +7,8 @@ list_key_locations() { args=$(sed -ne \ '/^AuthorizedKeysFile[[:blank:]]/{s/^[^[:blank:]]*[[:blank:]]*//p}' \ - /etc/ssh/sshd_config | tail -n1) + /etc/ssh/sshd_config.d/*.conf /etc/ssh/sshd_config 2>/dev/null | + head -n1) test -z "$args" && args=".ssh/authorized_keys .ssh/authorized_keys2" echo "$args" | sed -e 's/[[:blank:]]\+/ /g;s/^ //;s/ $//' | tr ' ' '\n' | sed -e 's#^\([^/%]\)#%h/\1#' From da2d274271bdf045a8b94c93c92c517b7d9217e2 Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Tue, 14 Apr 2026 10:39:53 +0200 Subject: [PATCH 11/21] sys.storage: Correctly show logical sector size for non-nvme --- gocollect-client/collectors/sys.storage | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gocollect-client/collectors/sys.storage b/gocollect-client/collectors/sys.storage index 8781e66..7947cfc 100755 --- a/gocollect-client/collectors/sys.storage +++ b/gocollect-client/collectors/sys.storage @@ -277,7 +277,7 @@ if command -v smartctl >/dev/null; then test -z "$sectorsize" && \ sectorsize=$(echo "$smartctl" | sed -e ' /^\(Sector Size\|Logical block size\)/!d - s/^[^:]*:.*[[:blank:]]\([0-9]\+\) bytes.*/\1/' | head -n1) + s/^[^:]*:[[:blank:]]*\([0-9]\+\) bytes.*/\1/' | head -n1) serial=$(echo "$smartctl" | sed -e ' /^Serial/!d;s/^[^:]*:[[:blank:]]*//;s/"//g') sedstatus=$(sed_status "$line") From aad51b6b6f10fdf2f4e9ba9219e254d861090bae Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Wed, 22 Apr 2026 22:32:20 +0200 Subject: [PATCH 12/21] os.uptime: Fix fluctuating uptime --- gocollect-client/collectors/os.uptime | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/gocollect-client/collectors/os.uptime b/gocollect-client/collectors/os.uptime index 22f3212..8d2a718 100755 --- a/gocollect-client/collectors/os.uptime +++ b/gocollect-client/collectors/os.uptime @@ -1,19 +1,12 @@ #!/bin/sh # vim: set ts=8 sw=4 sts=4 et ai: -# REQUIRES: coreutils(cut date sleep sort uniq) -# REQUIRES: sed(sed) +# REQUIRES: coreutils(date) +# REQUIRES: awk(awk) -# Taking the current time, the quick way; may yield incidental differing -# values. -# t0=$(( $(date +%s) - $(cut -d. -f1 /proc/uptime) )) -# Again, but this time we take the most common value out of 10. Note -# that some sleep(1) binaries may not do fractions in which case we -# fall back to sleeping entire seconds. get_t0() { - for x in 0 1 2 3 4 5 6 7 8 9; do - echo $(( $(date +%s) - $(cut -d. -f1 /proc/uptime) )) - sleep 0.1 2>/dev/null || sleep 1 - done | sort | uniq -c | sort -rn | sed -ne 's/.* \([^ ]*\)$/\1/p;q' + NOW=$(date +%s.%N) LC_ALL=C awk '{ + printf "%d\n", ENVIRON["NOW"] - $1 + 0.5 + }' /proc/uptime } t0=$(LC_ALL=C get_t0) From ec1c39bc865131fe2771b2b6035a7531a2f542be Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 07:23:54 +0200 Subject: [PATCH 13/21] core: Add stable-collector spool/mode feature for app.* collectors app.* collectors are sampled every ~24 min (4h / sample_n, default 10) and snapshots saved to /var/spool/gocollect//.json. At push time the most frequently occurring value (mode) across the last N snapshots is pushed instead of a fresh run, filtering out transient changes like short-lived processes. New spool package: Save() writes a snapshot and trims old files; LoadMode() returns the mode across the last N snapshots. Runner gains SpoolPath, SampledN, SampledPrefixes fields. The main loop is restructured: Sample() runs every sampleInterval, Run() is called every N samples. SIGHUP/SIGUSR1 forces an immediate push. --one-shot bypasses sampling and goes straight to Run(). Falls back to a live run when the spool is empty (first startup). Co-Authored-By: Claude Sonnet 4.6 --- gocollect-client/gocollect.conf.sample | 20 +++- gocollect-client/gocollect.go | 126 +++++++++++++++++++------ gocollect-client/runner/http.go | 2 +- gocollect-client/runner/internal.go | 47 ++++++++- gocollect-client/runner/runner.go | 27 +++++- gocollect-client/spool/spool.go | 96 +++++++++++++++++++ 6 files changed, 282 insertions(+), 36 deletions(-) create mode 100644 gocollect-client/spool/spool.go diff --git a/gocollect-client/gocollect.conf.sample b/gocollect-client/gocollect.conf.sample index 36d014c..4bc65ff 100644 --- a/gocollect-client/gocollect.conf.sample +++ b/gocollect-client/gocollect.conf.sample @@ -30,7 +30,25 @@ push_url = http://localhost:8000/update/{regid}/{_collector}/ # your system. (The *last* path is leading.) collectors_path = /usr/share/gocollect/collectors collectors_path = /usr/local/share/gocollect/collectors -collectors_path = /home/walter/GOPATH/src/github.com/ossobv/gocollect/collectors + +# spool_path: Directory where sampled collector snapshots are stored. +# Sampled collectors (see sampled_prefixed) are sampled sample_n times +# per push cycle and their output is spooled here. At push time the +# most frequently occurring value (mode) across the snapshots is used +# instead of a fresh run. This filters out transient changes. +# Set to an empty string to disable spooling entirely. +# Default: "/var/spool/gocollect" +#spool_path = /var/spool/gocollect + +# sampled_n: Number of snapshots to collect per push cycle. +# The push cycle is 4 hours, so with sample_n = 10 these collectors +# are sampled every 24 minutes. Default: 10 +#sampled_n = 10 + +# sampled_prefixes: Collectors whose name starts with any of these +# (whitespace separated) prefixes are treated as sampled collectors +# (sampled + mode-pushed). Default: "app." +#sampled_prefixes = app. # Optionally include these files if available. At the moment, globbing # is not supported. diff --git a/gocollect-client/gocollect.go b/gocollect-client/gocollect.go index 7ededbe..5975ab4 100644 --- a/gocollect-client/gocollect.go +++ b/gocollect-client/gocollect.go @@ -11,6 +11,7 @@ import ( "log/syslog" "os" "path/filepath" + "strconv" "strings" "github.com/ossobv/gocollect/gocollect-client/log" @@ -227,6 +228,22 @@ func createCollectRunner( ret.RegidFilename = defaultRegidFilename ret.GoCollectVersion = versionStr + // Spool / sampled collector settings. + ret.SpoolPath = "/var/spool/gocollect" + if vals, ok := config["spool_path"]; ok { + ret.SpoolPath = vals[len(vals)-1] + } + ret.SampledN = 10 + if vals, ok := config["sampled_n"]; ok { + if n, err := strconv.Atoi(vals[len(vals)-1]); err == nil && n > 0 { + ret.SampledN = n + } + } + ret.SampledPrefixes = []string{"app."} + if vals, ok := config["sampled_prefixes"]; ok { + ret.SampledPrefixes = strings.Fields(vals[len(vals)-1]) + } + return ret } @@ -250,6 +267,14 @@ func setupLogger(oneShot bool) *golog.Logger { return logger } +// minInt() is min() for golang pre-1.21. +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} + func main() { // Check basic arguments. options := parseArgsOrExit() @@ -258,14 +283,12 @@ func main() { config := parseConfigOrExit(options["config"].String) // Passed options scan. checkOptionsOrExit(options) - // Extract arguments, creating a CollectRunner. + // Extract arguments, creating a runner.Runner. collectRunner := createCollectRunner(options, config) runnerinst.SetRunner(&collectRunner) defer runnerinst.SetRunner(nil) // Create and set global logger. log.Log = setupLogger(oneShot) - // Use signals to sleep in the main thread. - sigHandler := signal.NewAlarmHupUsr1() // Do the work in /tmp. In case sub applications want to write cache // files or similar. @@ -281,33 +304,80 @@ func main() { return } - // Do complete run. + // We're done with stdout. os.Stdout.Close() - var interval int - lastSuccess := true - for { - ret := collectRunner.Run() - if oneShot { - if !ret { - log.Log.Fatal("CollectRunner.Run() returned false") - } - return + + // One-shot: run everything once (using spool data when available + // for sampled collectors) and exit. + if oneShot { + if !collectRunner.Push() { + log.Log.Fatal("collectRunner.Push() returned false") } + return + } - if ret { - // All good, run again in 4 hours - interval = 4 * 3600 - lastSuccess = true - } else if lastSuccess { - // Retry in 5 minutes if this is the first run - interval = 300 - lastSuccess = false - } else { - // Keep retrying in larger intervals - interval *= 2 - if interval > (4 * 3600) { - // Until we're at max - interval = 4 * 3600 + // Set up vars and start daemon loop. + const fullInterval = 4 * 3600 // 4 hours + samplesPerPush := collectRunner.SampledN + if samplesPerPush < 1 { + samplesPerPush = 1 + } + + // Sampled collectors (app.*) are sampled every sampleInterval and + // their output is written to the spool directory. At push time + // (every SampledN samples = fullInterval) Push() reads the mode + // (most frequent value) from the spool instead of running them live. + // = every sampleInterval + // + // Non-sampled collectors run live at Push() time. + // = once every fullInterval + var sampleInterval int = fullInterval / samplesPerPush + + daemonLoop(collectRunner, sampleInterval, samplesPerPush) +} + +// daemonLoop runs forever. +func daemonLoop(collectRunner runner.Runner, + sampleInterval int, samplesPerPush int) { + // Use signals to sleep in the main thread. + sigHandler := signal.NewAlarmHupUsr1() + + interval := sampleInterval + sampleCount := 0 + + for { + // Sample every iteration, those that need sampling. For + // unsampled ones, this is a no-op. + collectRunner.Sample() + sampleCount++ + + // If we have enough samples, we're running for approximately + // fullInterval. (Assuming every collector takes negligible time.) + log.Log.Printf( + "sampleCount %d samplesPerPush %d interval %d\n", + sampleCount, samplesPerPush, interval) // XXX + if sampleCount >= samplesPerPush { + // Push() runs all non-sampled collectors live and takes the + // mode from the sampled collectors. + ret := collectRunner.Push() + if ret { + // All is well. + sampleCount = 0 + interval = sampleInterval + } else { + // retryAttempt: how many pushes have failed so far. + retryAttempt := sampleCount - samplesPerPush + // While retry interval is lower than the sampleInterval, + // we're sampling _faster than usual_. + // But that should not be a problem unless the lowest + // interval is really low. + // Exponential backoff: 300, 600, 1200, ... capped at sampleInterval. + // Clamp retryAttempt against integer overflow. + interval = 300 * (1 << minInt(retryAttempt, 10)) // max 300k = 3.5 days + if interval > sampleInterval { + interval = sampleInterval + } + log.Log.Printf("push failed\n") // XXX } } @@ -316,6 +386,8 @@ func main() { if sig.String() != "alarm clock" { signal.Alarm(0) log.Log.Printf("Got %s to wake up early", sig.String()) + // Force a push on the next iteration. + sampleCount = samplesPerPush } } } diff --git a/gocollect-client/runner/http.go b/gocollect-client/runner/http.go index 87cd88b..ce4578a 100644 --- a/gocollect-client/runner/http.go +++ b/gocollect-client/runner/http.go @@ -1,5 +1,5 @@ // Package runner (gocollect) is the core of the GoCollect daemon. The -// Run() method will do the collecting and submitting to the central +// Push() method will do the collecting and submitting to the central // server. package runner diff --git a/gocollect-client/runner/internal.go b/gocollect-client/runner/internal.go index ffd9dba..b7638bb 100644 --- a/gocollect-client/runner/internal.go +++ b/gocollect-client/runner/internal.go @@ -1,5 +1,5 @@ // Package runner (gocollect) is the core of the GoCollect daemon. The -// Run() method will do the collecting and submitting to the central +// Push() method will do the collecting and submitting to the central // server. package runner @@ -8,10 +8,12 @@ import ( "io/ioutil" "os" "path/filepath" + "strings" "github.com/ossobv/gocollect/gocollect-client/data" "github.com/ossobv/gocollect/gocollect-client/log" "github.com/ossobv/gocollect/gocollect-client/shcollectors" + "github.com/ossobv/gocollect/gocollect-client/spool" ) type runInfo struct { @@ -35,6 +37,37 @@ func newRunInfo(r *Runner) (ri runInfo) { return ri } +// isSampled reports whether key is a sampled collector whose output +// should be spooled rather than run fresh on every push. +func (ri *runInfo) isSampled(key string) bool { + if ri.runner.SpoolPath == "" || len(ri.runner.SampledPrefixes) == 0 { + return false + } + for _, prefix := range ri.runner.SampledPrefixes { + if strings.HasPrefix(key, prefix) { + return true + } + } + return false +} + +// sampleCollectors runs all sampled collectors and saves their output to +// the spool directory. +func (ri *runInfo) sampleCollectors() { + for _, key := range ri.collectors.GetRunnable() { + if !ri.isSampled(key) { + continue + } + collected := ri.collectors.Run(key) + if collected == nil || collected.IsEmpty() { + continue + } + if err := spool.Save(ri.runner.SpoolPath, key, collected, ri.runner.SampledN); err != nil { + log.Log.Printf("spool[%s]: save error: %s", key, err) + } + } +} + func (ri *runInfo) setCoreIDData() bool { ri.coreIDData = ri.collectors.Run("core.id") if ri.coreIDData == nil { @@ -87,8 +120,16 @@ func (ri *runInfo) runAll() runStatus { // Run all collectors and push. extraContext := map[string]string{"_collector": ""} for _, collectorKey := range ri.collectors.GetRunnable() { - // Run a (patched) collector. - collected := ri.runCollector(collectorKey) + // For sampled collectors use the spool mode; fall back to a + // live run only when no spool data exists yet. + var collected data.Collected + if ri.isSampled(collectorKey) { + collected = spool.LoadMode( + ri.runner.SpoolPath, collectorKey, ri.runner.SampledN) + } + if collected == nil { + collected = ri.runCollector(collectorKey) + } if collected == nil { // logger.Printf( // "collector[%s]: exec fail", collectorKey) diff --git a/gocollect-client/runner/runner.go b/gocollect-client/runner/runner.go index a072283..9bb1eff 100644 --- a/gocollect-client/runner/runner.go +++ b/gocollect-client/runner/runner.go @@ -1,10 +1,10 @@ // Package runner (gocollect) is the core of the GoCollect daemon. The -// Run() method will do the collecting and submitting to the central +// Push() method will do the collecting and submitting to the central // server. package runner // Runner holds everything we need for gocollect action. Set all fields -// to a valid value before calling Run(). +// to a valid value before calling Push(). type Runner struct { ConfigPathBase string RegisterURL string @@ -13,11 +13,20 @@ type Runner struct { CollectorsPaths []string RegidFilename string GoCollectVersion string + + // Sampled collector (spool) settings. + // Collectors whose name starts with any SampledPrefixes are sampled via + // Sample() and stored in SpoolPath. Push() then pushes the mode + // (most frequent value) from the last SampledN snapshots instead of + // a fresh run. Set SpoolPath to "" to disable spool behaviour. + SpoolPath string + SampledN int + SampledPrefixes []string } -// Run collects data from the collectors and pushes data to the central +// Push collects data from the collectors and pushes data to the central // server. If needed, it registers first. -func (r *Runner) Run() bool { +func (r *Runner) Push() bool { runner := newRunInfo(r) // Initialize HTTP calls. @@ -43,6 +52,16 @@ func (r *Runner) Run() bool { return true } +// Sample runs all sampled collectors (those matching SampledPrefixes) and +// saves each output to SpoolPath. It is a no-op when SpoolPath is empty. +func (r *Runner) Sample() { + if r.SpoolPath == "" { + return + } + runner := newRunInfo(r) + runner.sampleCollectors() +} + // Get collects data from a single collector and returns it as a string. func (r *Runner) Get(collectorKey string) string { runner := newRunInfo(r) diff --git a/gocollect-client/spool/spool.go b/gocollect-client/spool/spool.go new file mode 100644 index 0000000..9f32e80 --- /dev/null +++ b/gocollect-client/spool/spool.go @@ -0,0 +1,96 @@ +// Package spool (gocollect) stores collector snapshots on disk and +// returns the most frequently occurring value (mode) across the last N +// snapshots. This is used by sampled collectors (e.g. app.*) to +// filter out transient changes. +package spool + +import ( + "io/ioutil" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/ossobv/gocollect/gocollect-client/data" + "github.com/ossobv/gocollect/gocollect-client/log" +) + +// Save writes collected data to //.json, +// then removes older files so that at most maxFiles are kept. +func Save(spoolPath, key string, collected data.Collected, maxFiles int) error { + dir := filepath.Join(spoolPath, key) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + filename := filepath.Join( + dir, strconv.FormatInt(time.Now().Unix(), 10)+".json") + if err := ioutil.WriteFile(filename, []byte(collected.String()), 0600); err != nil { + return err + } + + if maxFiles > 0 { + trimOldFiles(dir, maxFiles) + } + return nil +} + +// LoadMode reads the last n spool files for the given key and returns +// the most frequently occurring value as a data.Collected. Returns nil when +// no spool data exists yet. +func LoadMode(spoolPath, key string, n int) data.Collected { + dir := filepath.Join(spoolPath, key) + files, err := ioutil.ReadDir(dir) + if err != nil || len(files) == 0 { + return nil + } + + // ioutil.ReadDir returns entries sorted by name; since names are + // unix timestamps, newest entries are last. Take the last n. + if len(files) > n { + files = files[len(files)-n:] + } + + counts := make(map[string]int) + for _, f := range files { + content, err := ioutil.ReadFile(filepath.Join(dir, f.Name())) + if err == nil { + counts[string(content)]++ + } + } + + // Find the most frequent value. On a tie pick the lexicographically + // larger string (deterministic, but still arbitrary; could be used by + // a version index in front). + var best string + var bestCount int + for content, count := range counts { + if count > bestCount || (count == bestCount && content > best) { + best = content + bestCount = count + } + } + + if best == "" { + return nil + } + + collected, err := data.NewCollected([]byte(best)) + if err != nil { + log.Log.Printf("spool[%s]: parse error: %s", key, err) + return nil + } + return collected +} + +// trimOldFiles deletes the oldest files in dir until at most keep +// remain. +func trimOldFiles(dir string, keep int) { + files, err := ioutil.ReadDir(dir) + if err != nil || len(files) <= keep { + return + } + for _, f := range files[:len(files)-keep] { + os.Remove(filepath.Join(dir, f.Name())) + } +} From 5b24dcd066e4b33340a7816bb4c609f4cf26a39f Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 07:44:24 +0200 Subject: [PATCH 14/21] app.psdiff: Add psdiff.dump next to psdiff.db --- gocollect-client/collectors/app.psdiff | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/gocollect-client/collectors/app.psdiff b/gocollect-client/collectors/app.psdiff index c14410c..d17003b 100755 --- a/gocollect-client/collectors/app.psdiff +++ b/gocollect-client/collectors/app.psdiff @@ -3,8 +3,19 @@ # REQUIRES: awk(awk) # NOTE: Remember to test changes with mawk(1). -test -f /var/lib/psdiff.db && exec awk ' - BEGIN{print "{\"psdiff.db\":{\"filelines\":["} - {gsub("\\\"","");gsub("\\\\","");if(NR>1)printf ",";print "\"" $0 "\"" } - END{print "]}}"}' < /var/lib/psdiff.db -echo '{}' +lines2js() { + awk ' + {gsub("\"","");gsub("\\\\","");if(NR>1)printf ",";print "\"" $0 "\""}' +} + +if command -v psdiff >/dev/null; then + echo '{"psdiff.dump":{"filelines":[' + psdiff --net dump | lines2js + if test -f /var/lib/psdiff.db; then + echo ']},"psdiff.db":{"filelines":[' + lines2js < /var/lib/psdiff.db + fi + echo ']}}' +else + echo '{}' +fi From 0afdb7957cd046fb6ded246044dad270995147ce Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 08:12:32 +0200 Subject: [PATCH 15/21] core: Replace deprecated ioutil.ReadDir --- gocollect-client/collectors/app.fwdiff | 21 ++++++++++++++ .../collectors/core.meta/core.meta.go | 10 +++---- gocollect-client/shcollectors/find.go | 28 +++++++++++-------- gocollect-client/spool/spool.go | 6 ++-- 4 files changed, 45 insertions(+), 20 deletions(-) create mode 100755 gocollect-client/collectors/app.fwdiff diff --git a/gocollect-client/collectors/app.fwdiff b/gocollect-client/collectors/app.fwdiff new file mode 100755 index 0000000..3480428 --- /dev/null +++ b/gocollect-client/collectors/app.fwdiff @@ -0,0 +1,21 @@ +#!/bin/sh +# vim: set ts=8 sw=4 sts=4 et ai: +# REQUIRES: awk(awk) +# NOTE: Remember to test changes with mawk(1). + +lines2js() { + awk ' + {gsub("\"","");gsub("\\\\","");if(NR>1)printf ",";print "\"" $0 "\""}' +} + +if command -v fwdiff >/dev/null; then + echo '{"fwdiff.dump":{"filelines":[' + fwdiff dump 2>/dev/null | lines2js + if test -f /var/lib/fwdiff.db; then + echo ']},"fwdiff.db":{"filelines":[' + lines2js < /var/lib/fwdiff.db + fi + echo ']}}' +else + echo '{}' +fi diff --git a/gocollect-client/collectors/core.meta/core.meta.go b/gocollect-client/collectors/core.meta/core.meta.go index f6c0572..c61b8f7 100644 --- a/gocollect-client/collectors/core.meta/core.meta.go +++ b/gocollect-client/collectors/core.meta/core.meta.go @@ -4,6 +4,7 @@ package builtincollector import ( "encoding/json" "io/ioutil" + "os" "path/filepath" "strings" @@ -74,14 +75,14 @@ func getYamlData(filespath string) (map[string]interface{}, error) { // ReadDir reads the directory named by dirname and returns a list // of directory entries sorted by filename. - filelist, err := ioutil.ReadDir(filespath) + filelist, err := os.ReadDir(filespath) if err != nil { return nil, err } - for _, fileinfo := range filelist { - if fileinfo.IsDir() { - name := fileinfo.Name() + for _, direntry := range filelist { + name := direntry.Name() + if direntry.IsDir() { if !strings.HasPrefix(name, ".") { subpath := sanejoin.Join(filespath, name) data, err := getYamlData(subpath) @@ -93,7 +94,6 @@ func getYamlData(filespath string) (map[string]interface{}, error) { } } } else { - name := fileinfo.Name() if !strings.HasPrefix(name, ".") && strings.HasSuffix(name, ".yaml") { fullpath := filepath.Join(filespath, name) diff --git a/gocollect-client/shcollectors/find.go b/gocollect-client/shcollectors/find.go index 2521840..1806954 100644 --- a/gocollect-client/shcollectors/find.go +++ b/gocollect-client/shcollectors/find.go @@ -3,7 +3,6 @@ package shcollectors import ( - "io/ioutil" "os" "os/exec" "path/filepath" @@ -24,14 +23,14 @@ func Find(paths []string) *data.Collectors { for i := range paths { readpath := paths[last-i] - filelist, e := ioutil.ReadDir(readpath) + filelist, e := os.ReadDir(readpath) if e == nil { - for _, fileinfo := range filelist { - name := fileinfo.Name() + for _, direntry := range filelist { + name := direntry.Name() // Since we scan the items in reverse order, we only add // the file if it didn't exist yet. if _, exists := ret[name]; !exists { - collector := fileToCollector(fileinfo, readpath) + collector := fileToCollector(direntry, readpath) if collector != nil { ret[name] = *collector } @@ -42,9 +41,9 @@ func Find(paths []string) *data.Collectors { return &ret } -func fileToCollector(fileinfo os.FileInfo, readpath string) *data.Collector { +func fileToCollector(direntry os.DirEntry, readpath string) *data.Collector { // Ignore it if it's a directory. - if fileinfo.IsDir() { + if direntry.IsDir() { return nil } @@ -53,9 +52,9 @@ func fileToCollector(fileinfo os.FileInfo, readpath string) *data.Collector { // Our runner Run: runShellCollector, // Set full path - RunArgs: filepath.Join(readpath, fileinfo.Name()), + RunArgs: filepath.Join(readpath, direntry.Name()), // If the file is not executable, disable it - IsEnabled: isExecutable(fileinfo), + IsEnabled: isExecutable(direntry), } } @@ -118,12 +117,17 @@ func runShellCollector(key string, execpath string) data.Collected { return ret } -func isExecutable(fileinfo os.FileInfo) bool { - if fileinfo.IsDir() { +func isExecutable(direntry os.DirEntry) bool { + if direntry.IsDir() { return false } - mode := fileinfo.Mode() + info, err := direntry.Info() + if err != nil { + return false + } + + mode := info.Mode() if (mode & 0111) == 0 { return false } diff --git a/gocollect-client/spool/spool.go b/gocollect-client/spool/spool.go index 9f32e80..832e44e 100644 --- a/gocollect-client/spool/spool.go +++ b/gocollect-client/spool/spool.go @@ -40,12 +40,12 @@ func Save(spoolPath, key string, collected data.Collected, maxFiles int) error { // no spool data exists yet. func LoadMode(spoolPath, key string, n int) data.Collected { dir := filepath.Join(spoolPath, key) - files, err := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil || len(files) == 0 { return nil } - // ioutil.ReadDir returns entries sorted by name; since names are + // os.ReadDir returns entries sorted by name; since names are // unix timestamps, newest entries are last. Take the last n. if len(files) > n { files = files[len(files)-n:] @@ -86,7 +86,7 @@ func LoadMode(spoolPath, key string, n int) data.Collected { // trimOldFiles deletes the oldest files in dir until at most keep // remain. func trimOldFiles(dir string, keep int) { - files, err := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil || len(files) <= keep { return } From 0ca5d6d47bcde6c4dc5585f7211f4258b4ad985f Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 08:13:35 +0200 Subject: [PATCH 16/21] cleanup: Run make pretty --- gocollect-client/data/collected.go | 3 ++- gocollect-client/gocollect.go | 2 +- gocollect-client/runner/internal.go | 3 ++- gocollect-client/spool/spool.go | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/gocollect-client/data/collected.go b/gocollect-client/data/collected.go index a2228a5..52b6b39 100644 --- a/gocollect-client/data/collected.go +++ b/gocollect-client/data/collected.go @@ -71,7 +71,8 @@ func NewCollected(data []byte) (Collected, error) { return &tmp, nil } -// EmptyCollected creates a new empty Collected object. Use when there is no data. +// EmptyCollected creates a new empty Collected object. Use when there +// is no data. func EmptyCollected() Collected { return &collected{data: ""} } diff --git a/gocollect-client/gocollect.go b/gocollect-client/gocollect.go index 5975ab4..52253e9 100644 --- a/gocollect-client/gocollect.go +++ b/gocollect-client/gocollect.go @@ -338,7 +338,7 @@ func main() { // daemonLoop runs forever. func daemonLoop(collectRunner runner.Runner, - sampleInterval int, samplesPerPush int) { + sampleInterval int, samplesPerPush int) { // Use signals to sleep in the main thread. sigHandler := signal.NewAlarmHupUsr1() diff --git a/gocollect-client/runner/internal.go b/gocollect-client/runner/internal.go index b7638bb..09f8d20 100644 --- a/gocollect-client/runner/internal.go +++ b/gocollect-client/runner/internal.go @@ -62,7 +62,8 @@ func (ri *runInfo) sampleCollectors() { if collected == nil || collected.IsEmpty() { continue } - if err := spool.Save(ri.runner.SpoolPath, key, collected, ri.runner.SampledN); err != nil { + if err := spool.Save(ri.runner.SpoolPath, key, collected, + ri.runner.SampledN); err != nil { log.Log.Printf("spool[%s]: save error: %s", key, err) } } diff --git a/gocollect-client/spool/spool.go b/gocollect-client/spool/spool.go index 832e44e..c07cada 100644 --- a/gocollect-client/spool/spool.go +++ b/gocollect-client/spool/spool.go @@ -17,7 +17,8 @@ import ( // Save writes collected data to //.json, // then removes older files so that at most maxFiles are kept. -func Save(spoolPath, key string, collected data.Collected, maxFiles int) error { +func Save(spoolPath, key string, collected data.Collected, + maxFiles int) error { dir := filepath.Join(spoolPath, key) if err := os.MkdirAll(dir, 0700); err != nil { return err From d53ab1ce13a89815ff0634e7e4ab69ce192e9c9e Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 08:29:47 +0200 Subject: [PATCH 17/21] core: gocollect -k (--test-keys) now implies -s (--one-shot) Closes: #46 --- gocollect-client/gocollect.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/gocollect-client/gocollect.go b/gocollect-client/gocollect.go index 52253e9..17d11d5 100644 --- a/gocollect-client/gocollect.go +++ b/gocollect-client/gocollect.go @@ -70,7 +70,8 @@ func getOptionDefinition() getopt.Options { Flags: (getopt.Optional | getopt.ExampleIsDefault), DefaultValue: defaultConfigFile}, {OptionDefinition: "one-shot|s", - Description: "run once and exit", + Description: "run once and exit " + + "(implied when using --test-key)", Flags: getopt.Flag, DefaultValue: false}, {OptionDefinition: "test-key|k", @@ -199,13 +200,9 @@ func checkOptionsOrExit(options map[string]getopt.OptionValue) { } } - // Only allow --test-key with --one-shot. + // Using --test-key implies --one-shot. if _, ok := options["test-key"]; ok && !options["one-shot"].Bool { - fmt.Fprintf( - os.Stderr, - "%s: --test-key only works together with --one-shot.\n", - filepath.Base(os.Args[0])) - os.Exit(1) + options["one-shot"] = getopt.OptionValue{Bool: true} } } From 220a3c9160f0c04e7a5b89767e801c2929eea475 Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 08:39:27 +0200 Subject: [PATCH 18/21] version: Bump to 0.9.9 --- CHANGES.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index fde3c07..85b9e75 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,19 @@ Changes ------- +* v0.9.9 [2026-04-27]: + + - core: Add stable-collector spool/mode feature for app.* collectors + - core: gocollect -k (--test-keys) now implies -s (--one-shot) + - app.psdiff: Add psdiff.dump next to psdiff.db + - app.ps-kvmex1: Do not die on new JSON kvm args + - os.keys: Fix so sshd_config Includes are read for ssh key location + - os.uptime: Fix fluctuating uptime + - sys.firmware: Add microversion to bmc.version + - sys.firmware: Fix fluctuating Created args + - sys.storage: Correctly show logical sector size for non-nvme + - rmq2nb: Fix validation of network ID and broadcast addresses + * v0.9.8 [2026-01-05]: - sys.firmware: Add BMC board into to sys.firmware. From 7fa50601508be3161882a2429d6106efcc4b50eb Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 12:26:44 +0200 Subject: [PATCH 19/21] app.needsrestart: New collector to find unrestarted libs/bins This will be one of the sampled collectors. Change: osso-org/changes#2389 --- gocollect-client/collectors/app.needsrestart | 194 +++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100755 gocollect-client/collectors/app.needsrestart diff --git a/gocollect-client/collectors/app.needsrestart b/gocollect-client/collectors/app.needsrestart new file mode 100755 index 0000000..a32e8a4 --- /dev/null +++ b/gocollect-client/collectors/app.needsrestart @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +gocollect collector: app.needsrestart + +Reports packages that need a restart (i.e. they are not up to date), +systemd services that need a restart (i.e. they are either not up to date, or +they are using old libraries; see previous). Also reports other executables +which cannot be pinpointed to a systemd service. + +This is used as part of the custom authenticated scanning implementation. + +Output might look like: + + { + // Packages that we cannot "trust" to be up to date yet. + "apt_packages": [ + "gocollect", + "libcap2", + "libpam-cap" + ], + // Local services that will need some kind of restart. + "systemd_services": [ + "dnsmasq.service", + "docker.service", + "fwupd.service", + "gocollect.service", + "lldpd.service" + ], + // Not found by 'systemd status PID'. Could be in LXC or other namespace + "other_executables": [ + "/usr/bin/dockerd", + "/usr/bin/sudo", + "/usr/lib/postgresql/14/bin/postgres" + ] + } +""" +import sys +from json import dumps as json_dumps +from os import geteuid, listdir, readlink, stat +from subprocess import SubprocessError, check_output + + +class Process: + __slots__ = ('pid', 'exe', 'deleted', '_systemd_service') + FILE_PACKAGES = {} # (devid, file) => some-apt-package + + @classmethod + def get_all(cls): + for entry in listdir('/proc'): + if entry.isdigit(): + yield cls.from_pid(entry) + + @classmethod + def from_pid(cls, pid): + return cls(pid) + + def __init__(self, pid): + self.pid = pid + try: + self.exe = readlink(f'/proc/{pid}/exe') + except OSError: + self.exe = None + return + + self.deleted = set() + if self.exe.endswith(' (deleted)'): + self.exe = self.exe[0:-10] + self.deleted.add(self.exe) + + # Check proc maps. All of them. + try: + with open(f'/proc/{pid}/maps') as fp: + for line in fp: + line = line.rstrip() + if not line.endswith(' (deleted)'): + continue + # format: addr perms offset dev inode pathname + parts = line.split(None, 5) + if len(parts) >= 6: + path = parts[5] + assert path.endswith(' (deleted)'), path + path = path[0:-10] + if (path.startswith('/') + and (path.endswith('.so') or '.so.' in path)): + self.deleted.add(path) + except OSError: + self.exe = None + + self._systemd_service = None + + @staticmethod + def _usrmerge_options(path): + # After usrmerge some package have files in /lib while they are + # accessed as /usr/lib. + if path.startswith('/usr/'): + return (path, path[4:]) + return (path,) + + @property + def packages_needing_restart(self): + """Return apt packages for deleted executable files.""" + packages = set() + + # Rightfully assume that places where /var/lib/dpkg has the same + # filesystem, the library we're looking for has the same filesystem as + # well. + dpkg_path = f'/proc/{self.pid}/root/var/lib/dpkg' + try: + st = stat(dpkg_path) + except OSError: + return packages + devid = st.st_dev + + for deleted in self.deleted: + for exe in self._usrmerge_options(deleted): + if (devid, exe) in self.FILE_PACKAGES: + packages.add(self.FILE_PACKAGES[(devid, exe)]) + break + + try: + with open('/dev/null', 'w') as DEVNULL: + result = check_output( + ('dpkg', f'--admindir={dpkg_path}', '-S', exe), + stderr=DEVNULL, timeout=15, env={'LC_ALL': 'C'}) + except (SubprocessError, FileNotFoundError): + pass + else: + # Ex: systemd-timesyncd: /usr/lib/systemd/systemd-timesyncd + lines = result.decode('utf-8', 'replace').split('\n') + lines = [ + line for line in lines if line.endswith(f': {exe}')] + if lines: + pkg = sorted(lines)[0].split(':', 1)[0] + packages.add(pkg) + self.FILE_PACKAGES[(devid, exe)] = pkg + break + + return list(packages) + + @property + def systemd_service(self): + """Return systemd unit name for pid, or None.""" + if self._systemd_service is None: + try: + with open('/dev/null', 'w') as DEVNULL: + result = check_output( + ('systemctl', 'status', str(self.pid)), + stderr=DEVNULL, timeout=15, env={'LC_ALL': 'C'}) + except (SubprocessError, FileNotFoundError): + return None + + service = ( + result.decode('utf-8', 'replace') + .split('\n', 1)[0][2:].split(' - ', 1)[0]) + if service.endswith('.service'): + self._systemd_service = service + else: + self._systemd_service = False + return self._systemd_service + + +def collect(): + packages_needing_restart = set() + services_needing_restart = set() + unknown_needing_restart = set() + + for process in Process.get_all(): + if not process.exe or not process.deleted: + continue + + packages_needing_restart.update(process.packages_needing_restart) + + if process.systemd_service: + services_needing_restart.add(process.systemd_service) + else: + unknown_needing_restart.add(process.exe) + + return { + 'apt_packages': sorted(packages_needing_restart), + 'systemd_services': sorted(services_needing_restart), + 'other_executables': sorted(unknown_needing_restart), + } + + +def main(): + if geteuid() != 0: + print('warning: running without root; /proc entries may be unreadable', + file=sys.stderr) + + print(json_dumps(collect())) + + +if __name__ == '__main__': + main() From 29ad9f327069389dbb2ffb09730ed0aaafc7966a Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 13:51:15 +0200 Subject: [PATCH 20/21] app.ossochange: New collector to fetch change tickets --- gocollect-client/collectors/app.ossochange | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100755 gocollect-client/collectors/app.ossochange diff --git a/gocollect-client/collectors/app.ossochange b/gocollect-client/collectors/app.ossochange new file mode 100755 index 0000000..faa7d40 --- /dev/null +++ b/gocollect-client/collectors/app.ossochange @@ -0,0 +1,14 @@ +#!/bin/sh +# vim: set ts=8 sw=4 sts=4 et ai: +# LABELS: optional +# REQUIRES: jq(jq) +# REQUIRES: sed(sed) +# REQUIRES: systemd(journalctl) + +if command -v journalctl >/dev/null && command -v jq >/dev/null; then + journalctl -t osso-change -S '-1 day' -o cat | jq -s "\ + map(select(.action==\"workon\" and .ticket) | .ticket) \ + | unique | {tickets: .}" 2>/dev/null +else + echo '{}' +fi From f50dce2bf2ab476d3783fb8c39bbe306023f61d2 Mon Sep 17 00:00:00 2001 From: Walter Doekes Date: Mon, 27 Apr 2026 13:52:52 +0200 Subject: [PATCH 21/21] version: Bump to 0.9.10 --- CHANGES.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 85b9e75..868f924 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changes ------- +* v0.9.10 [2026-04-27]: + + - app.needsrestart: New collector to find unrestarted libs/bins + - app.ossochange: New collector to fetch change tickets + * v0.9.9 [2026-04-27]: - core: Add stable-collector spool/mode feature for app.* collectors