This commit is contained in:
Jeffrey Paul 2025-05-01 03:07:12 -07:00
commit b63bab0582
17 changed files with 911 additions and 0 deletions

16
Makefile Normal file
View File

@ -0,0 +1,16 @@
# Infer VERSION from the most recent tag; if none, use short commit hash.
VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || \
git rev-parse --short HEAD)
GOLDFLAGS += -s -w \
-X 'git.eeqj.de/sneak/sysinfo/internal/sysinfo.Version=$(VERSION)'
all: build
build:
GOFLAGS=-ldflags="$(GOLDFLAGS)" go build ./cmd/sysinfo
install:
GOFLAGS=-ldflags="$(GOLDFLAGS)" go install ./cmd/sysinfo
.PHONY: all build install

20
cmd/sysinfo/main.go Normal file
View File

@ -0,0 +1,20 @@
package main
import (
"fmt"
"os"
"git.eeqj.de/sneak/sysinfo/internal/sysinfo"
)
func main() {
app, err := sysinfo.NewApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := app.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

23
go.mod Normal file
View File

@ -0,0 +1,23 @@
module git.eeqj.de/sneak/sysinfo
go 1.23.4
require (
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

42
go.sum Normal file
View File

@ -0,0 +1,42 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

102
internal/sysinfo/app.go Normal file
View File

@ -0,0 +1,102 @@
package sysinfo
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// App holds CLI state.
type App struct {
force bool
jsonOut bool
verbose bool
aptUpdated bool
cmd *cobra.Command
}
// NewApp configures root command.
func NewApp() (*App, error) {
a := &App{}
root := &cobra.Command{
Use: "sysinfo",
Short: "capture block-device / system snapshot",
Version: Version, // <-- --version flag
SilenceUsage: true,
SilenceErrors: true,
RunE: a.run,
}
root.Flags().BoolVarP(&a.force, "force", "f", false,
"overwrite /etc/sysinfo if it exists")
root.Flags().BoolVar(&a.jsonOut, "json", false,
"emit JSON snapshot to stdout")
root.Flags().BoolVarP(&a.verbose, "verbose", "v", false,
"verbose progress")
_ = viper.BindPFlags(root.Flags())
root.AddCommand(&cobra.Command{
Use: "schema",
Short: "print the JSON schema",
Run: func(*cobra.Command, []string) {
fmt.Fprintln(os.Stdout, JSONSchema)
},
})
a.cmd = root
return a, nil
}
func (a *App) Execute() error { return a.cmd.Execute() }
func (a *App) logf(f string, v ...any) {
if a.verbose {
_, _ = fmt.Fprintf(os.Stderr, f+"\n", v...)
}
}
// run executes the snapshot workflow.
func (a *App) run(_ *cobra.Command, _ []string) error {
if err := a.ensureUbuntu(); err != nil {
return err
}
if err := a.ensureDeps(); err != nil {
return err
}
ctx := &Context{
Now: time.Now().UTC(),
Logf: a.logf,
Run: a.runCmd,
SafeRun: a.safeRun,
SafeRead: a.safeRead,
}
snap := &Snapshot{
Timestamp: ctx.Now.Format(time.RFC3339),
Sections: map[string]json.RawMessage{},
}
collectors := []Collector{
SystemCollector{}, BlockCollector{},
NetworkCollector{}, SensorsCollector{},
PackagesCollector{}, ZFSCollector{},
}
for _, c := range collectors {
raw, err := c.Collect(ctx)
if err != nil {
ctx.Logf("%s: %v", c.Key(), err)
continue
}
snap.Sections[c.Key()] = raw
}
if a.jsonOut {
return a.emitJSON(os.Stdout, snap)
}
return a.writeHierarchy(snap)
}

View File

@ -0,0 +1,85 @@
package sysinfo
import (
"encoding/json"
"path/filepath"
"regexp"
"strings"
)
// BlockDevice + Partition live with this collector.
type BlockDevice struct {
Timestamp string `json:"timestamp"`
Smartctl string `json:"smartctl"`
LuksDump string `json:"luksDump,omitempty"`
Sfdisk json.RawMessage `json:"sfdisk"`
Blkid string `json:"blkid"`
Lsblk json.RawMessage `json:"lsblk"`
Partitions map[string]*Partition `json:"partitions"`
}
type Partition struct {
Blkid string `json:"blkid"`
Lsblk json.RawMessage `json:"lsblk"`
}
func (a *App) collectBlock(s *Snapshot, now string) error {
links, _ := filepath.Glob("/dev/disk/by-id/*")
rePU := regexp.MustCompile(`PARTUUID="([^"]+)"`)
seen := map[string]bool{}
for _, link := range links {
base := filepath.Base(link)
target, _ := filepath.EvalSymlinks(link)
dev := filepath.Base(target)
if seen[dev] {
continue
}
seen[dev] = true
bd := &BlockDevice{
Timestamp: now,
Partitions: map[string]*Partition{},
}
devPath := filepath.Join("/dev", dev)
bd.Smartctl = a.safeRun("smartctl", "-a", devPath)
if out := a.safeRun("cryptsetup", "luksDump", devPath); out != "" {
bd.LuksDump = out
}
_ = json.Unmarshal(
a.runJSON(&bd.Sfdisk, "sfdisk", "-J", devPath), &bd.Sfdisk)
bd.Blkid = a.safeRun("blkid", "-p", devPath)
_ = json.Unmarshal(
a.runJSON(&bd.Lsblk, "lsblk", "-J", devPath), &bd.Lsblk)
// enumerate partitions
var ls struct {
Blockdevices []struct {
Children []struct{ Name string `json:"name"` } `json:"children"`
} `json:"blockdevices"`
}
_ = json.Unmarshal(bd.Lsblk, &ls)
for _, d := range ls.Blockdevices {
for _, c := range d.Children {
part := filepath.Join("/dev", c.Name)
blk := a.safeRun("blkid", "-p", part)
m := rePU.FindStringSubmatch(blk)
if len(m) != 2 {
continue
}
uuid := strings.ToLower(m[1])
p := &Partition{Blkid: blk}
_ = json.Unmarshal(
a.runJSON(&p.Lsblk, "lsblk", "-J", part), &p.Lsblk)
bd.Partitions[uuid] = p
}
}
if s.Blockdevs == nil {
s.Blockdevs = map[string]*BlockDevice{}
}
s.Blockdevs[base] = bd
}
return nil
}

View File

@ -0,0 +1,57 @@
package sysinfo
import (
"encoding/json"
"path/filepath"
"strings"
)
// NetIface lives here.
type NetIface struct {
Iface string `json:"iface"`
IPAddr json.RawMessage `json:"ip_addr"`
Link string `json:"link"`
Ethtool string `json:"ethtool,omitempty"`
Stats json.RawMessage `json:"statistics"`
IPInfo json.RawMessage `json:"ipinfo,omitempty"`
Timestamp string `json:"timestamp"`
}
func (a *App) collectNetwork(s *Snapshot, now string) error {
defMap, _ := a.defaultIfaceSet()
nets, _ := filepath.Glob("/sys/class/net/*")
for _, n := range nets {
if strings.HasSuffix(n, "/lo") {
continue
}
iface := filepath.Base(n)
mac := strings.ToLower(strings.ReplaceAll(
a.safeRead(filepath.Join(n, "address")), ":", ""))
if mac == "" {
continue
}
nif := &NetIface{
Iface: iface,
Timestamp: now,
}
_ = json.Unmarshal(
a.runJSON(&nif.IPAddr, "ip", "-j", "address", "show", iface),
&nif.IPAddr)
nif.Link = a.safeRun("ip", "-details", "link", "show", iface)
nif.Ethtool = a.safeRun("ethtool", iface)
nif.Stats, _ = a.readNetStats(iface)
if defMap[iface] {
if o := a.safeRun("curl", "--interface", iface, "-s",
"https://ipinfo.io"); json.Valid([]byte(o)) {
nif.IPInfo = json.RawMessage(o)
}
}
if s.Network == nil {
s.Network = map[string]*NetIface{}
}
s.Network[mac] = nif
}
return nil
}

View File

@ -0,0 +1,14 @@
package sysinfo
// PackagesData lives with this collector.
type PackagesData struct {
Dpkg string `json:"dpkg"`
}
func (a *App) collectPackages(s *Snapshot, _ string) error {
out := a.safeRun("dpkg-query", "-W",
"-f=${Package} ${Version}\\n")
s.Packages = &PackagesData{Dpkg: out}
return nil
}

View File

@ -0,0 +1,13 @@
package sysinfo
// SensorsData lives here.
type SensorsData struct {
Output string `json:"output"`
}
func (a *App) collectSensors(s *Snapshot, _ string) error {
out := a.safeRun("sensors")
s.Sensors = &SensorsData{Output: out}
return nil
}

View File

@ -0,0 +1,38 @@
package sysinfo
import "strings"
// SystemData lives with this collector.
type SystemData struct {
Timestamp string `json:"timestamp"`
ID string `json:"id"`
Distro string `json:"distro"`
LSBRel string `json:"lsb_release"`
Uname string `json:"uname"`
CPUInfo string `json:"cpuinfo"`
MemInfo string `json:"meminfo"`
Dmidecode string `json:"dmidecode,omitempty"`
}
func (a *App) collectSystem(s *Snapshot, now string) error {
sys := &SystemData{
Timestamp: now,
LSBRel: a.safeRun("lsb_release", "-a"),
Uname: a.safeRun("uname", "-a"),
CPUInfo: a.safeRead("/proc/cpuinfo"),
MemInfo: a.safeRead("/proc/meminfo"),
Dmidecode: a.safeRun("dmidecode"),
Distro: "ubuntu",
}
if id := strings.TrimSpace(
a.safeRun("dmidecode", "-s", "system-serial-number")); id != "" {
sys.ID = id
} else {
sys.ID = a.primaryMAC()
}
s.System = sys
return nil
}

View File

@ -0,0 +1,32 @@
package sysinfo
import "strings"
// ZPool represents details for one ZFS pool.
type ZPool struct {
Timestamp string `json:"timestamp"`
Status string `json:"status"`
Get string `json:"get"`
ZfsList string `json:"zfs_list"`
}
func (a *App) collectZFS(s *Snapshot, now string) error {
if a.safeRun("which", "zpool") == "" {
return nil // ZFS not installed
}
pools := strings.Fields(a.safeRun("zpool", "list", "-H", "-o", "name"))
if len(pools) == 0 {
return nil
}
if s.ZFS == nil {
s.ZFS = map[string]*ZPool{}
}
for _, p := range pools {
z := &ZPool{Timestamp: now}
z.Status = a.safeRun("zpool", "status", "-v", p)
z.Get = a.safeRun("zpool", "get", "-H", "all", p)
z.ZfsList = a.safeRun("zfs", "list", "-Hp", "-r", p)
s.ZFS[p] = z
}
return nil
}

29
internal/sysinfo/deps.go Normal file
View File

@ -0,0 +1,29 @@
package sysinfo
import "os/exec"
func (a *App) ensureDeps() error {
req := map[string]string{
"smartctl": "smartmontools",
"cryptsetup":"cryptsetup",
"sfdisk": "util-linux",
"lsblk": "util-linux",
"blkid": "util-linux",
"ip": "iproute2",
"ethtool": "ethtool",
"dmidecode": "dmidecode",
"sensors": "lm-sensors",
"curl": "curl",
"jc": "jc",
"dpkg": "dpkg",
}
for bin, pkg := range req {
if _, err := exec.LookPath(bin); err != nil {
a.logf("apt install %s", pkg)
if err := a.aptInstall(pkg); err != nil {
return err
}
}
}
return nil
}

170
internal/sysinfo/helpers.go Normal file
View File

@ -0,0 +1,170 @@
package sysinfo
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
const (
rootDir = "/etc/sysinfo"
snapshotJSON = "snapshot.json"
debianFrontEnv = "DEBIAN_FRONTEND=noninteractive"
)
/* ------------------------------------------------------------------ */
/* platform check */
/* ------------------------------------------------------------------ */
func (a *App) ensureUbuntu() error {
out, err := a.runCmd("lsb_release", "-is")
if err != nil {
return fmt.Errorf("unsupported OS: lsb_release not found")
}
if strings.ToLower(strings.TrimSpace(string(out))) != "ubuntu" {
return fmt.Errorf("unsupported OS: %s",
strings.TrimSpace(string(out)))
}
return nil
}
/* ------------------------------------------------------------------ */
/* command helpers */
/* ------------------------------------------------------------------ */
func (a *App) runCmd(name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...)
var buf bytes.Buffer
cmd.Stdout, cmd.Stderr = &buf, &buf
err := cmd.Run()
return buf.Bytes(), err
}
func (a *App) safeRun(name string, args ...string) string {
out, _ := a.runCmd(name, args...)
return string(out)
}
func (a *App) runJSON(dst *json.RawMessage,
name string, args ...string) []byte {
out, _ := a.runCmd(name, args...)
*dst = json.RawMessage(out)
return out
}
func (a *App) safeRead(path string) string {
b, _ := ioutil.ReadFile(path)
return string(b)
}
/* ------------------------------------------------------------------ */
/* JSON emitter */
/* ------------------------------------------------------------------ */
func (a *App) emitJSON(f *os.File, s *Snapshot) error {
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(s)
}
/* ------------------------------------------------------------------ */
/* network helpers */
/* ------------------------------------------------------------------ */
func (a *App) readNetStats(iface string) (json.RawMessage, error) {
dir := filepath.Join("/sys/class/net", iface, "statistics")
ent, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}
m := map[string]int64{}
for _, f := range ent {
b, _ := ioutil.ReadFile(filepath.Join(dir, f.Name()))
n, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
m[f.Name()] = n
}
j, _ := json.Marshal(m)
return j, nil
}
func (a *App) defaultIfaceSet() (map[string]bool, error) {
out, err := a.runCmd("ip", "-j", "route", "show", "default")
if err != nil {
return nil, err
}
var routes []struct{ Dev string `json:"dev"` }
_ = json.Unmarshal(out, &routes)
m := map[string]bool{}
for _, r := range routes {
m[r.Dev] = true
}
return m, nil
}
func (a *App) primaryMAC() string {
ifs, _ := ioutil.ReadDir("/sys/class/net")
for _, f := range ifs {
if f.Name() == "lo" {
continue
}
mac := a.safeRead(filepath.Join(
"/sys/class/net", f.Name(), "address"))
mac = strings.ToLower(strings.ReplaceAll(
strings.TrimSpace(mac), ":", ""))
if mac != "" {
return mac
}
}
return ""
}
/* ------------------------------------------------------------------ */
/* apt helpers */
/* ------------------------------------------------------------------ */
func (a *App) aptInstall(pkg string) error {
if !a.aptUpdated {
if err := a.runApt("apt-get", "update"); err != nil {
return err
}
a.aptUpdated = true
}
return a.runApt("apt-get", "-y", "install", pkg)
}
func (a *App) runApt(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Env = append(os.Environ(), debianFrontEnv)
if a.verbose {
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
}
return cmd.Run()
}
/* ------------------------------------------------------------------ */
/* file helpers */
/* ------------------------------------------------------------------ */
func (a *App) mustWriteText(dir, name, content string) {
if err := ioutil.WriteFile(
filepath.Join(dir, name),
[]byte(content), 0644); err != nil {
panic(err)
}
}
func (a *App) mustWriteJSON(dir, name string, raw json.RawMessage) {
if len(raw) == 0 {
return
}
if err := ioutil.WriteFile(
filepath.Join(dir, name), raw, 0644); err != nil {
panic(err)
}
}

135
internal/sysinfo/output.go Normal file
View File

@ -0,0 +1,135 @@
package sysinfo
import (
"fmt"
"os"
"path/filepath"
)
// writeHierarchy mirrors the Snapshot into /etc/sysinfo.
func (a *App) writeHierarchy(s *Snapshot) error {
if _, err := os.Stat(rootDir); err == nil && !a.force {
return fmt.Errorf("%s exists; use --force", rootDir)
}
_ = os.RemoveAll(rootDir)
if err := os.MkdirAll(rootDir, 0755); err != nil {
return err
}
a.mustWriteText(rootDir, "snapshot_time.txt", s.SnapshotTime)
if err := a.writeSystem(
filepath.Join(rootDir, "system"), s.System); err != nil {
return err
}
if s.Sensors != nil {
sDir := filepath.Join(rootDir, "sensors")
if err := os.MkdirAll(sDir, 0755); err != nil {
return err
}
a.mustWriteText(sDir, "sensors.txt", s.Sensors.Output)
}
for id, bd := range s.Blockdevs {
if err := a.writeBlockDevice(
filepath.Join(rootDir, "blockdevs", id), bd); err != nil {
return err
}
}
for mac, n := range s.Network {
if err := a.writeNetIface(
filepath.Join(rootDir, "network", mac), n); err != nil {
return err
}
}
for pool, z := range s.ZFS {
zDir := filepath.Join(rootDir, "zfs", "pools", pool)
if err := os.MkdirAll(zDir, 0755); err != nil {
return err
}
a.mustWriteText(zDir, "timestamp.txt", z.Timestamp)
a.mustWriteText(zDir, "status.txt", z.Status)
a.mustWriteText(zDir, "get.txt", z.Get)
a.mustWriteText(zDir, "zfs_list.txt", z.ZfsList)
}
if s.Packages != nil {
pDir := filepath.Join(rootDir, "packages")
if err := os.MkdirAll(pDir, 0755); err != nil {
return err
}
a.mustWriteText(pDir, "dpkg_list.txt", s.Packages.Dpkg)
}
f, err := os.Create(filepath.Join(rootDir, snapshotJSON))
if err != nil {
return err
}
defer f.Close()
return a.emitJSON(f, s)
}
/* ------------------------------------------------------------------ */
/* helpers for leaf sections */
/* ------------------------------------------------------------------ */
func (a *App) writeSystem(dir string, sys *SystemData) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
a.mustWriteText(dir, "timestamp.txt", sys.Timestamp)
a.mustWriteText(dir, "id.txt", sys.ID)
a.mustWriteText(dir, "distro.txt", sys.Distro)
a.mustWriteText(dir, "lsb_release.txt", sys.LSBRel)
a.mustWriteText(dir, "uname.txt", sys.Uname)
a.mustWriteText(dir, "cpuinfo.txt", sys.CPUInfo)
a.mustWriteText(dir, "meminfo.txt", sys.MemInfo)
if sys.Dmidecode != "" {
a.mustWriteText(dir, "dmidecode.txt", sys.Dmidecode)
}
return nil
}
func (a *App) writeBlockDevice(dir string, bd *BlockDevice) error {
if err := os.MkdirAll(
filepath.Join(dir, "partitions"), 0755); err != nil {
return err
}
a.mustWriteText(dir, "timestamp.txt", bd.Timestamp)
a.mustWriteText(dir, "smartctl.txt", bd.Smartctl)
if bd.LuksDump != "" {
a.mustWriteText(dir, "luksDump.txt", bd.LuksDump)
}
a.mustWriteJSON(dir, "sfdisk.json", bd.Sfdisk)
a.mustWriteText(dir, "blkid.txt", bd.Blkid)
a.mustWriteJSON(dir, "lsblk.json", bd.Lsblk)
for uuid, p := range bd.Partitions {
pDir := filepath.Join(dir, "partitions", uuid)
if err := os.MkdirAll(pDir, 0755); err != nil {
return err
}
a.mustWriteText(pDir, "blkid.txt", p.Blkid)
a.mustWriteJSON(pDir, "lsblk.json", p.Lsblk)
}
return nil
}
func (a *App) writeNetIface(dir string, n *NetIface) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
a.mustWriteText(dir, "timestamp.txt", n.Timestamp)
a.mustWriteText(dir, "iface.txt", n.Iface)
a.mustWriteJSON(dir, "ip_addr.json", n.IPAddr)
a.mustWriteText(dir, "link.txt", n.Link)
if n.Ethtool != "" {
a.mustWriteText(dir, "ethtool.txt", n.Ethtool)
}
a.mustWriteJSON(dir, "statistics.json", n.Stats)
if len(n.IPInfo) > 0 {
a.mustWriteJSON(dir, "ipinfo.json", n.IPInfo)
}
return nil
}

118
internal/sysinfo/schema.go Normal file
View File

@ -0,0 +1,118 @@
package sysinfo
// JSONSchema draft-2020-12. Updated to include “packages” and a
// “distro” field inside the system block so multiple Linux distros are
// representable even though only Ubuntu is collected today.
const JSONSchema = `
{
"$schema":"https://json-schema.org/draft/2020-12/schema",
"title":"Sysinfo snapshot",
"type":"object",
"required":["snapshot_time","system"],
"properties":{
"snapshot_time":{"type":"string","format":"date-time"},
"system":{"$ref":"#/$defs/system"},
"sensors":{"$ref":"#/$defs/sensors"},
"blockdevs":{
"type":"object",
"patternProperties":{"^[A-Za-z0-9._-]+$":{"$ref":"#/$defs/block"}},
"additionalProperties":false
},
"network":{
"type":"object",
"patternProperties":{"^[0-9a-f]{12}$":{"$ref":"#/$defs/iface"}},
"additionalProperties":false
},
"zfs":{
"type":"object",
"patternProperties":{"^[A-Za-z0-9._-]+$":{"$ref":"#/$defs/zpool"}},
"additionalProperties":false
},
"packages":{"$ref":"#/$defs/packages"}
},
"$defs":{
"system":{
"type":"object",
"required":["timestamp","id","distro","uname"],
"properties":{
"timestamp":{"type":"string","format":"date-time"},
"id":{"type":"string"},
"distro":{"type":"string"},
"lsb_release":{"type":"string"},
"uname":{"type":"string"},
"cpuinfo":{"type":"string"},
"meminfo":{"type":"string"},
"dmidecode":{"type":"string"}
},
"additionalProperties":false
},
"sensors":{
"type":"object",
"properties":{"output":{"type":"string"}},
"required":["output"],
"additionalProperties":false
},
"block":{
"type":"object",
"required":["timestamp","smartctl","sfdisk","blkid","lsblk",
"partitions"],
"properties":{
"timestamp":{"type":"string","format":"date-time"},
"smartctl":{"type":"string"},
"luksDump":{"type":"string"},
"sfdisk":{"type":"object"},
"blkid":{"type":"string"},
"lsblk":{"type":"object"},
"partitions":{
"type":"object",
"patternProperties":{
"^[A-Fa-f0-9-]+$":{"$ref":"#/$defs/partition"}
},
"additionalProperties":false
}
},
"additionalProperties":false
},
"partition":{
"type":"object",
"required":["blkid","lsblk"],
"properties":{
"blkid":{"type":"string"},
"lsblk":{"type":"object"}
},
"additionalProperties":false
},
"iface":{
"type":"object",
"required":["iface","ip_addr","link","statistics","timestamp"],
"properties":{
"iface":{"type":"string"},
"ip_addr":{"type":"object"},
"link":{"type":"string"},
"ethtool":{"type":"string"},
"statistics":{"type":"object"},
"ipinfo":{"type":"object"},
"timestamp":{"type":"string","format":"date-time"}
},
"additionalProperties":false
},
"zpool":{
"type":"object",
"required":["timestamp","status","get","zfs_list"],
"properties":{
"timestamp":{"type":"string","format":"date-time"},
"status":{"type":"string"},
"get":{"type":"string"},
"zfs_list":{"type":"string"}
},
"additionalProperties":false
},
"packages":{
"type":"object",
"properties":{"dpkg":{"type":"string"}},
"required":["dpkg"],
"additionalProperties":false
}
}
}
`

View File

@ -0,0 +1,12 @@
package sysinfo
// Snapshot is the top-level JSON object.
type Snapshot struct {
SnapshotTime string `json:"snapshot_time"`
System *SystemData `json:"system"`
Sensors *SensorsData `json:"sensors,omitempty"`
Blockdevs map[string]*BlockDevice `json:"blockdevs,omitempty"`
Network map[string]*NetIface `json:"network,omitempty"`
ZFS map[string]*ZPool `json:"zfs,omitempty"`
Packages *PackagesData `json:"packages,omitempty"`
}

View File

@ -0,0 +1,5 @@
package sysinfo
// Version is overridden at build time via:
// go build -ldflags "-X git.eeqj.de/sneak/sysinfo/internal/sysinfo.Version=1.2.3"
var Version = "dev"