From ce2e3dcf46f592194a8a163cb53aba33b47d2b4e Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 May 2025 03:25:38 -0700 Subject: [PATCH] latest --- internal/sysinfo/app.go | 75 ++++++++------ internal/sysinfo/collect_block.go | 85 ---------------- internal/sysinfo/collect_network.go | 57 ----------- internal/sysinfo/collect_packages.go | 14 --- internal/sysinfo/collect_sensors.go | 13 --- internal/sysinfo/collect_system.go | 38 ------- internal/sysinfo/collect_zfs.go | 32 ------ internal/sysinfo/collector.go | 18 +++- internal/sysinfo/collector_block.go | 30 ++++-- internal/sysinfo/collector_network.go | 50 ++++++++- internal/sysinfo/collector_packages.go | 16 ++- internal/sysinfo/collector_sensors.go | 16 ++- internal/sysinfo/collector_system.go | 18 +++- internal/sysinfo/collector_zfs.go | 17 +++- internal/sysinfo/deps.go | 29 ------ internal/sysinfo/helpers.go | 121 +++------------------- internal/sysinfo/output.go | 135 ------------------------- internal/sysinfo/packagemanager.go | 41 ++++++++ 18 files changed, 235 insertions(+), 570 deletions(-) delete mode 100644 internal/sysinfo/collect_block.go delete mode 100644 internal/sysinfo/collect_network.go delete mode 100644 internal/sysinfo/collect_packages.go delete mode 100644 internal/sysinfo/collect_sensors.go delete mode 100644 internal/sysinfo/collect_system.go delete mode 100644 internal/sysinfo/collect_zfs.go delete mode 100644 internal/sysinfo/deps.go delete mode 100644 internal/sysinfo/output.go create mode 100644 internal/sysinfo/packagemanager.go diff --git a/internal/sysinfo/app.go b/internal/sysinfo/app.go index 0d9f8bd..72b6a51 100644 --- a/internal/sysinfo/app.go +++ b/internal/sysinfo/app.go @@ -1,37 +1,35 @@ package sysinfo import ( + "encoding/json" "fmt" "os" + "path/filepath" "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 + force bool + jsonOut bool + verbose 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 + Short: "capture system snapshot", + Version: Version, SilenceUsage: true, SilenceErrors: true, RunE: a.run, } - root.Flags().BoolVarP(&a.force, "force", "f", false, - "overwrite /etc/sysinfo if it exists") + "overwrite /etc/sysinfo") root.Flags().BoolVar(&a.jsonOut, "json", false, "emit JSON snapshot to stdout") root.Flags().BoolVarP(&a.verbose, "verbose", "v", false, @@ -40,9 +38,9 @@ func NewApp() (*App, error) { root.AddCommand(&cobra.Command{ Use: "schema", - Short: "print the JSON schema", + Short: "print JSON schema", Run: func(*cobra.Command, []string) { - fmt.Fprintln(os.Stdout, JSONSchema) + fmt.Println(JSONSchema) }, }) @@ -58,21 +56,19 @@ func (a *App) logf(f string, v ...any) { } } -// 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 { +func (a *App) run(*cobra.Command, []string) error { + if err := ensureUbuntu(); err != nil { return err } + pm := newPackageManager(a.logf) + ctx := &Context{ Now: time.Now().UTC(), Logf: a.logf, - Run: a.runCmd, - SafeRun: a.safeRun, - SafeRead: a.safeRead, + Run: runCmd, + SafeRun: safeRun, + SafeRead: safeRead, } snap := &Snapshot{ @@ -86,17 +82,38 @@ func (a *App) run(_ *cobra.Command, _ []string) error { PackagesCollector{}, ZFSCollector{}, } - for _, c := range collectors { - raw, err := c.Collect(ctx) - if err != nil { - ctx.Logf("%s: %v", c.Key(), err) + for _, col := range collectors { + if err := col.EnsurePrerequisites(pm, ctx); err != nil { + ctx.Logf("%s: prereq error: %v", col.SectionKey(), err) continue } - snap.Sections[c.Key()] = raw + if !col.IsSupported(pm, ctx) { + ctx.Logf("%s: not supported on this host", col.SectionKey()) + continue + } + raw, err := col.CollectData(ctx) + if err != nil { + ctx.Logf("%s: %v", col.SectionKey(), err) + continue + } + snap.Sections[col.SectionKey()] = raw } if a.jsonOut { - return a.emitJSON(os.Stdout, snap) + return emitJSON(os.Stdout, snap) } - return a.writeHierarchy(snap) + + 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 + } + f, err := os.Create(filepath.Join(rootDir, snapshotJSON)) + if err != nil { + return err + } + defer f.Close() + return emitJSON(f, snap) } diff --git a/internal/sysinfo/collect_block.go b/internal/sysinfo/collect_block.go deleted file mode 100644 index f4c5ce6..0000000 --- a/internal/sysinfo/collect_block.go +++ /dev/null @@ -1,85 +0,0 @@ -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 -} diff --git a/internal/sysinfo/collect_network.go b/internal/sysinfo/collect_network.go deleted file mode 100644 index 914ac0e..0000000 --- a/internal/sysinfo/collect_network.go +++ /dev/null @@ -1,57 +0,0 @@ -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 -} diff --git a/internal/sysinfo/collect_packages.go b/internal/sysinfo/collect_packages.go deleted file mode 100644 index e8f0f03..0000000 --- a/internal/sysinfo/collect_packages.go +++ /dev/null @@ -1,14 +0,0 @@ -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 -} diff --git a/internal/sysinfo/collect_sensors.go b/internal/sysinfo/collect_sensors.go deleted file mode 100644 index 7287f5d..0000000 --- a/internal/sysinfo/collect_sensors.go +++ /dev/null @@ -1,13 +0,0 @@ -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 -} diff --git a/internal/sysinfo/collect_system.go b/internal/sysinfo/collect_system.go deleted file mode 100644 index afc5616..0000000 --- a/internal/sysinfo/collect_system.go +++ /dev/null @@ -1,38 +0,0 @@ -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 -} diff --git a/internal/sysinfo/collect_zfs.go b/internal/sysinfo/collect_zfs.go deleted file mode 100644 index a2250fa..0000000 --- a/internal/sysinfo/collect_zfs.go +++ /dev/null @@ -1,32 +0,0 @@ -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 -} diff --git a/internal/sysinfo/collector.go b/internal/sysinfo/collector.go index 7dfe6df..394215a 100644 --- a/internal/sysinfo/collector.go +++ b/internal/sysinfo/collector.go @@ -5,7 +5,7 @@ import ( "time" ) -// Context is shared with every Collector. +// Context carries helpers every Collector may need. type Context struct { Now time.Time Logf func(string, ...any) @@ -14,8 +14,18 @@ type Context struct { SafeRead func(string) string } -// Collector produces one JSON fragment. +// Collector gathers one snapshot section. type Collector interface { - Key() string - Collect(*Context) (json.RawMessage, error) + // SectionKey becomes the JSON field name (e.g. "system", "zfs"). + SectionKey() string + + // EnsurePrerequisites installs/verifies required tools via the + // provided PackageManager. Must be idempotent. + EnsurePrerequisites(pm PackageManager, ctx *Context) error + + // IsSupported decides if CollectData should run on this machine. + IsSupported(pm PackageManager, ctx *Context) bool + + // CollectData returns the JSON blob for this section. + CollectData(ctx *Context) (json.RawMessage, error) } diff --git a/internal/sysinfo/collector_block.go b/internal/sysinfo/collector_block.go index ef47115..7c7daf8 100644 --- a/internal/sysinfo/collector_block.go +++ b/internal/sysinfo/collector_block.go @@ -5,13 +5,25 @@ import ( "path/filepath" "regexp" "strings" + "time" ) type BlockCollector struct{} -func (BlockCollector) Key() string { return "blockdevs" } +func (BlockCollector) SectionKey() string { return "blockdevs" } -func (BlockCollector) Collect(c *Context) (json.RawMessage, error) { +func (BlockCollector) EnsurePrerequisites(pm PackageManager, _ *Context) error { + if pm.Distro() == "ubuntu" { + return pm.InstallPackages("smartmontools", "util-linux", "blkid") + } + return nil +} + +func (BlockCollector) IsSupported(pm PackageManager, _ *Context) bool { + return pm.ExecExists("lsblk") +} + +func (BlockCollector) CollectData(c *Context) (json.RawMessage, error) { rePU := regexp.MustCompile(`PARTUUID="([^"]+)"`) all := map[string]any{} @@ -31,9 +43,9 @@ func (BlockCollector) Collect(c *Context) (json.RawMessage, error) { "smartctl": c.SafeRun("smartctl", "-a", "/dev/"+dev), "blkid": c.SafeRun("blkid", "-p", "/dev/"+dev), } - sfd, _ := c.runCmd("sfdisk", "-J", "/dev/"+dev) + sfd, _ := c.Run("sfdisk", "-J", "/dev/"+dev) bd["sfdisk"] = json.RawMessage(sfd) - lsb, _ := c.runCmd("lsblk", "-J", "/dev/"+dev) + lsb, _ := c.Run("lsblk", "-J", "/dev/"+dev) bd["lsblk"] = json.RawMessage(lsb) parts := map[string]any{} @@ -45,17 +57,15 @@ func (BlockCollector) Collect(c *Context) (json.RawMessage, error) { _ = json.Unmarshal(lsb, &ls) for _, d := range ls.Blockdevices { for _, ch := range d.Children { - partdev := "/dev/" + ch.Name - blk := c.SafeRun("blkid", "-p", partdev) + part := "/dev/" + ch.Name + blk := c.SafeRun("blkid", "-p", part) m := rePU.FindStringSubmatch(blk) if len(m) != 2 { continue } uuid := strings.ToLower(m[1]) - pinfo := map[string]any{ - "blkid": blk, - } - plsb, _ := c.runCmd("lsblk", "-J", partdev) + pinfo := map[string]any{"blkid": blk} + plsb, _ := c.Run("lsblk", "-J", part) pinfo["lsblk"] = json.RawMessage(plsb) parts[uuid] = pinfo } diff --git a/internal/sysinfo/collector_network.go b/internal/sysinfo/collector_network.go index be8b61a..d5736e9 100644 --- a/internal/sysinfo/collector_network.go +++ b/internal/sysinfo/collector_network.go @@ -4,14 +4,34 @@ import ( "encoding/json" "path/filepath" "strings" + "time" ) type NetworkCollector struct{} -func (NetworkCollector) Key() string { return "network" } +func (NetworkCollector) SectionKey() string { return "network" } -func (NetworkCollector) Collect(c *Context) (json.RawMessage, error) { - def, _ := c.defaultIfaceSet() +/* ------------------------------------------------------------------ */ +/* prerequisite / support checks */ +/* ------------------------------------------------------------------ */ + +func (NetworkCollector) EnsurePrerequisites(pm PackageManager, _ *Context) error { + if pm.Distro() == "ubuntu" { + return pm.InstallPackages("iproute2", "curl", "ethtool") + } + return nil +} + +func (NetworkCollector) IsSupported(pm PackageManager, _ *Context) bool { + return pm.ExecExists("ip") +} + +/* ------------------------------------------------------------------ */ +/* collection */ +/* ------------------------------------------------------------------ */ + +func (NetworkCollector) CollectData(c *Context) (json.RawMessage, error) { + def, _ := defaultIfaceSet(c) sections := map[string]any{} nets, _ := filepath.Glob("/sys/class/net/*") @@ -25,16 +45,18 @@ func (NetworkCollector) Collect(c *Context) (json.RawMessage, error) { if mac == "" { continue } + entry := map[string]any{ "iface": iface, "timestamp": c.Now.Format(time.RFC3339), "link": c.SafeRun("ip", "-details", "link", "show", iface), "ethtool": c.SafeRun("ethtool", iface), } - ipJSON, _ := c.runCmd("ip", "-j", "address", "show", iface) + + ipJSON, _ := c.Run("ip", "-j", "address", "show", iface) entry["ip_addr"] = json.RawMessage(ipJSON) - stats, _ := c.readNetStats(iface) + stats, _ := readNetStats(iface) entry["statistics"] = json.RawMessage(stats) if def[iface] { @@ -47,3 +69,21 @@ func (NetworkCollector) Collect(c *Context) (json.RawMessage, error) { } return json.Marshal(sections) } + +/* ------------------------------------------------------------------ */ +/* helpers */ +/* ------------------------------------------------------------------ */ + +func defaultIfaceSet(c *Context) (map[string]bool, error) { + out, err := c.Run("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 +} diff --git a/internal/sysinfo/collector_packages.go b/internal/sysinfo/collector_packages.go index dc821f4..67ecd3f 100644 --- a/internal/sysinfo/collector_packages.go +++ b/internal/sysinfo/collector_packages.go @@ -4,9 +4,17 @@ import "encoding/json" type PackagesCollector struct{} -func (PackagesCollector) Key() string { return "packages" } -func (PackagesCollector) Collect(c *Context) (json.RawMessage, error) { - out := c.SafeRun("dpkg-query", "-W", - "-f=${Package} ${Version}\\n") +func (PackagesCollector) SectionKey() string { return "packages" } + +func (PackagesCollector) EnsurePrerequisites(_ PackageManager, _ *Context) error { + return nil // core tool +} + +func (PackagesCollector) IsSupported(pm PackageManager, _ *Context) bool { + return pm.ExecExists("dpkg-query") +} + +func (PackagesCollector) CollectData(c *Context) (json.RawMessage, error) { + out := c.SafeRun("dpkg-query", "-W", "-f=${Package} ${Version}\\n") return json.Marshal(map[string]string{"dpkg": out}) } diff --git a/internal/sysinfo/collector_sensors.go b/internal/sysinfo/collector_sensors.go index 13d085b..84bf8a3 100644 --- a/internal/sysinfo/collector_sensors.go +++ b/internal/sysinfo/collector_sensors.go @@ -4,8 +4,20 @@ import "encoding/json" type SensorsCollector struct{} -func (SensorsCollector) Key() string { return "sensors" } -func (SensorsCollector) Collect(c *Context) (json.RawMessage, error) { +func (SensorsCollector) SectionKey() string { return "sensors" } + +func (SensorsCollector) EnsurePrerequisites(pm PackageManager, _ *Context) error { + if pm.Distro() == "ubuntu" { + return pm.InstallPackages("lm-sensors") + } + return nil +} + +func (SensorsCollector) IsSupported(pm PackageManager, _ *Context) bool { + return pm.ExecExists("sensors") +} + +func (SensorsCollector) CollectData(c *Context) (json.RawMessage, error) { return json.Marshal(map[string]string{ "output": c.SafeRun("sensors"), }) diff --git a/internal/sysinfo/collector_system.go b/internal/sysinfo/collector_system.go index 3c34582..7052894 100644 --- a/internal/sysinfo/collector_system.go +++ b/internal/sysinfo/collector_system.go @@ -3,20 +3,32 @@ package sysinfo import ( "encoding/json" "strings" + "time" ) type SystemCollector struct{} -func (SystemCollector) Key() string { return "system" } +func (SystemCollector) SectionKey() string { return "system" } -func (SystemCollector) Collect(c *Context) (json.RawMessage, error) { +func (SystemCollector) EnsurePrerequisites(pm PackageManager, _ *Context) error { + if pm.Distro() == "ubuntu" { + return pm.InstallPackages("dmidecode") + } + return nil +} + +func (SystemCollector) IsSupported(_ PackageManager, _ *Context) bool { + return true +} + +func (SystemCollector) CollectData(c *Context) (json.RawMessage, error) { data := map[string]string{ "timestamp": c.Now.Format(time.RFC3339), + "distro": "ubuntu", "uname": c.SafeRun("uname", "-a"), "lsb_release": c.SafeRun("lsb_release", "-a"), "cpuinfo": c.SafeRead("/proc/cpuinfo"), "meminfo": c.SafeRead("/proc/meminfo"), - "distro": "ubuntu", } serial := strings.TrimSpace( c.SafeRun("dmidecode", "-s", "system-serial-number")) diff --git a/internal/sysinfo/collector_zfs.go b/internal/sysinfo/collector_zfs.go index 64af3a3..9502325 100644 --- a/internal/sysinfo/collector_zfs.go +++ b/internal/sysinfo/collector_zfs.go @@ -3,16 +3,25 @@ package sysinfo import ( "encoding/json" "strings" + "time" ) type ZFSCollector struct{} -func (ZFSCollector) Key() string { return "zfs" } +func (ZFSCollector) SectionKey() string { return "zfs" } -func (ZFSCollector) Collect(c *Context) (json.RawMessage, error) { - if c.SafeRun("which", "zpool") == "" { - return json.Marshal(nil) // no zfs installed +func (ZFSCollector) EnsurePrerequisites(pm PackageManager, _ *Context) error { + if pm.Distro() == "ubuntu" { + return pm.InstallPackages("zfsutils-linux") } + return nil +} + +func (ZFSCollector) IsSupported(pm PackageManager, _ *Context) bool { + return pm.ExecExists("zpool") +} + +func (ZFSCollector) CollectData(c *Context) (json.RawMessage, error) { pools := strings.Fields( c.SafeRun("zpool", "list", "-H", "-o", "name")) section := map[string]any{} diff --git a/internal/sysinfo/deps.go b/internal/sysinfo/deps.go deleted file mode 100644 index 7959f28..0000000 --- a/internal/sysinfo/deps.go +++ /dev/null @@ -1,29 +0,0 @@ -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 -} diff --git a/internal/sysinfo/helpers.go b/internal/sysinfo/helpers.go index e26a6f2..8c71dc6 100644 --- a/internal/sysinfo/helpers.go +++ b/internal/sysinfo/helpers.go @@ -13,17 +13,16 @@ import ( ) const ( - rootDir = "/etc/sysinfo" - snapshotJSON = "snapshot.json" - debianFrontEnv = "DEBIAN_FRONTEND=noninteractive" + rootDir = "/etc/sysinfo" + snapshotJSON = "snapshot.json" ) /* ------------------------------------------------------------------ */ -/* platform check */ +/* platform check (Ubuntu) */ /* ------------------------------------------------------------------ */ -func (a *App) ensureUbuntu() error { - out, err := a.runCmd("lsb_release", "-is") +func ensureUbuntu() error { + out, err := runCmd("lsb_release", "-is") if err != nil { return fmt.Errorf("unsupported OS: lsb_release not found") } @@ -35,10 +34,10 @@ func (a *App) ensureUbuntu() error { } /* ------------------------------------------------------------------ */ -/* command helpers */ +/* shell helpers */ /* ------------------------------------------------------------------ */ -func (a *App) runCmd(name string, args ...string) ([]byte, error) { +func runCmd(name string, args ...string) ([]byte, error) { cmd := exec.Command(name, args...) var buf bytes.Buffer cmd.Stdout, cmd.Stderr = &buf, &buf @@ -46,38 +45,25 @@ func (a *App) runCmd(name string, args ...string) ([]byte, error) { return buf.Bytes(), err } -func (a *App) safeRun(name string, args ...string) string { - out, _ := a.runCmd(name, args...) +func safeRun(name string, args ...string) string { + out, _ := 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 { +func safeRead(path string) string { b, _ := ioutil.ReadFile(path) return string(b) } -/* ------------------------------------------------------------------ */ -/* JSON emitter */ -/* ------------------------------------------------------------------ */ - -func (a *App) emitJSON(f *os.File, s *Snapshot) error { +func emitJSON(f *os.File, v any) error { enc := json.NewEncoder(f) enc.SetIndent("", " ") - return enc.Encode(s) + return enc.Encode(v) } -/* ------------------------------------------------------------------ */ -/* network helpers */ -/* ------------------------------------------------------------------ */ +/* ---------- generic helpers reused in collectors ------------------ */ -func (a *App) readNetStats(iface string) (json.RawMessage, error) { +func readNetStats(iface string) (json.RawMessage, error) { dir := filepath.Join("/sys/class/net", iface, "statistics") ent, err := ioutil.ReadDir(dir) if err != nil { @@ -89,82 +75,5 @@ func (a *App) readNetStats(iface string) (json.RawMessage, error) { 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) - } + return json.Marshal(m) } diff --git a/internal/sysinfo/output.go b/internal/sysinfo/output.go deleted file mode 100644 index 4175316..0000000 --- a/internal/sysinfo/output.go +++ /dev/null @@ -1,135 +0,0 @@ -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 -} diff --git a/internal/sysinfo/packagemanager.go b/internal/sysinfo/packagemanager.go new file mode 100644 index 0000000..981011f --- /dev/null +++ b/internal/sysinfo/packagemanager.go @@ -0,0 +1,41 @@ +package sysinfo + +import ( + "os/exec" +) + +// PackageManager is an abstraction over distro package tooling. +type PackageManager interface { + Distro() string + InstallPackages(pkgs ...string) error + ExecExists(bin string) bool +} + +/* ------------------------------------------------------------------ */ +/* aptManager (Ubuntu / Debian) */ +/* ------------------------------------------------------------------ */ + +type aptManager struct { + logf func(string, ...any) +} + +func newPackageManager(logf func(string, ...any)) PackageManager { + return &aptManager{logf: logf} +} + +func (a *aptManager) Distro() string { return "ubuntu" } + +func (a *aptManager) ExecExists(bin string) bool { + _, err := exec.LookPath(bin) + return err == nil +} + +func (a *aptManager) InstallPackages(pkgs ...string) error { + args := append([]string{"-y", "install"}, pkgs...) + cmd := exec.Command("apt-get", args...) + cmd.Env = append(cmd.Env, "DEBIAN_FRONTEND=noninteractive") + cmd.Stdout = nil + cmd.Stderr = nil + a.logf("apt install %v", pkgs) + return cmd.Run() +}