This commit is contained in:
Jeffrey Paul 2025-05-01 03:25:38 -07:00
parent 2e8bbfee34
commit ce2e3dcf46
18 changed files with 235 additions and 570 deletions

View File

@ -1,37 +1,35 @@
package sysinfo package sysinfo
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// App holds CLI state.
type App struct { type App struct {
force bool force bool
jsonOut bool jsonOut bool
verbose bool verbose bool
aptUpdated bool cmd *cobra.Command
cmd *cobra.Command
} }
// NewApp configures root command.
func NewApp() (*App, error) { func NewApp() (*App, error) {
a := &App{} a := &App{}
root := &cobra.Command{ root := &cobra.Command{
Use: "sysinfo", Use: "sysinfo",
Short: "capture block-device / system snapshot", Short: "capture system snapshot",
Version: Version, // <-- --version flag Version: Version,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
RunE: a.run, RunE: a.run,
} }
root.Flags().BoolVarP(&a.force, "force", "f", false, root.Flags().BoolVarP(&a.force, "force", "f", false,
"overwrite /etc/sysinfo if it exists") "overwrite /etc/sysinfo")
root.Flags().BoolVar(&a.jsonOut, "json", false, root.Flags().BoolVar(&a.jsonOut, "json", false,
"emit JSON snapshot to stdout") "emit JSON snapshot to stdout")
root.Flags().BoolVarP(&a.verbose, "verbose", "v", false, root.Flags().BoolVarP(&a.verbose, "verbose", "v", false,
@ -40,9 +38,9 @@ func NewApp() (*App, error) {
root.AddCommand(&cobra.Command{ root.AddCommand(&cobra.Command{
Use: "schema", Use: "schema",
Short: "print the JSON schema", Short: "print JSON schema",
Run: func(*cobra.Command, []string) { 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 {
func (a *App) run(_ *cobra.Command, _ []string) error { if err := ensureUbuntu(); err != nil {
if err := a.ensureUbuntu(); err != nil {
return err
}
if err := a.ensureDeps(); err != nil {
return err return err
} }
pm := newPackageManager(a.logf)
ctx := &Context{ ctx := &Context{
Now: time.Now().UTC(), Now: time.Now().UTC(),
Logf: a.logf, Logf: a.logf,
Run: a.runCmd, Run: runCmd,
SafeRun: a.safeRun, SafeRun: safeRun,
SafeRead: a.safeRead, SafeRead: safeRead,
} }
snap := &Snapshot{ snap := &Snapshot{
@ -86,17 +82,38 @@ func (a *App) run(_ *cobra.Command, _ []string) error {
PackagesCollector{}, ZFSCollector{}, PackagesCollector{}, ZFSCollector{},
} }
for _, c := range collectors { for _, col := range collectors {
raw, err := c.Collect(ctx) if err := col.EnsurePrerequisites(pm, ctx); err != nil {
if err != nil { ctx.Logf("%s: prereq error: %v", col.SectionKey(), err)
ctx.Logf("%s: %v", c.Key(), err)
continue 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 { 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)
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -5,7 +5,7 @@ import (
"time" "time"
) )
// Context is shared with every Collector. // Context carries helpers every Collector may need.
type Context struct { type Context struct {
Now time.Time Now time.Time
Logf func(string, ...any) Logf func(string, ...any)
@ -14,8 +14,18 @@ type Context struct {
SafeRead func(string) string SafeRead func(string) string
} }
// Collector produces one JSON fragment. // Collector gathers one snapshot section.
type Collector interface { type Collector interface {
Key() string // SectionKey becomes the JSON field name (e.g. "system", "zfs").
Collect(*Context) (json.RawMessage, error) 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)
} }

View File

@ -5,13 +5,25 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
) )
type BlockCollector struct{} 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="([^"]+)"`) rePU := regexp.MustCompile(`PARTUUID="([^"]+)"`)
all := map[string]any{} all := map[string]any{}
@ -31,9 +43,9 @@ func (BlockCollector) Collect(c *Context) (json.RawMessage, error) {
"smartctl": c.SafeRun("smartctl", "-a", "/dev/"+dev), "smartctl": c.SafeRun("smartctl", "-a", "/dev/"+dev),
"blkid": c.SafeRun("blkid", "-p", "/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) bd["sfdisk"] = json.RawMessage(sfd)
lsb, _ := c.runCmd("lsblk", "-J", "/dev/"+dev) lsb, _ := c.Run("lsblk", "-J", "/dev/"+dev)
bd["lsblk"] = json.RawMessage(lsb) bd["lsblk"] = json.RawMessage(lsb)
parts := map[string]any{} parts := map[string]any{}
@ -45,17 +57,15 @@ func (BlockCollector) Collect(c *Context) (json.RawMessage, error) {
_ = json.Unmarshal(lsb, &ls) _ = json.Unmarshal(lsb, &ls)
for _, d := range ls.Blockdevices { for _, d := range ls.Blockdevices {
for _, ch := range d.Children { for _, ch := range d.Children {
partdev := "/dev/" + ch.Name part := "/dev/" + ch.Name
blk := c.SafeRun("blkid", "-p", partdev) blk := c.SafeRun("blkid", "-p", part)
m := rePU.FindStringSubmatch(blk) m := rePU.FindStringSubmatch(blk)
if len(m) != 2 { if len(m) != 2 {
continue continue
} }
uuid := strings.ToLower(m[1]) uuid := strings.ToLower(m[1])
pinfo := map[string]any{ pinfo := map[string]any{"blkid": blk}
"blkid": blk, plsb, _ := c.Run("lsblk", "-J", part)
}
plsb, _ := c.runCmd("lsblk", "-J", partdev)
pinfo["lsblk"] = json.RawMessage(plsb) pinfo["lsblk"] = json.RawMessage(plsb)
parts[uuid] = pinfo parts[uuid] = pinfo
} }

View File

@ -4,14 +4,34 @@ import (
"encoding/json" "encoding/json"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
type NetworkCollector struct{} 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{} sections := map[string]any{}
nets, _ := filepath.Glob("/sys/class/net/*") nets, _ := filepath.Glob("/sys/class/net/*")
@ -25,16 +45,18 @@ func (NetworkCollector) Collect(c *Context) (json.RawMessage, error) {
if mac == "" { if mac == "" {
continue continue
} }
entry := map[string]any{ entry := map[string]any{
"iface": iface, "iface": iface,
"timestamp": c.Now.Format(time.RFC3339), "timestamp": c.Now.Format(time.RFC3339),
"link": c.SafeRun("ip", "-details", "link", "show", iface), "link": c.SafeRun("ip", "-details", "link", "show", iface),
"ethtool": c.SafeRun("ethtool", 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) entry["ip_addr"] = json.RawMessage(ipJSON)
stats, _ := c.readNetStats(iface) stats, _ := readNetStats(iface)
entry["statistics"] = json.RawMessage(stats) entry["statistics"] = json.RawMessage(stats)
if def[iface] { if def[iface] {
@ -47,3 +69,21 @@ func (NetworkCollector) Collect(c *Context) (json.RawMessage, error) {
} }
return json.Marshal(sections) 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
}

View File

@ -4,9 +4,17 @@ import "encoding/json"
type PackagesCollector struct{} type PackagesCollector struct{}
func (PackagesCollector) Key() string { return "packages" } func (PackagesCollector) SectionKey() string { return "packages" }
func (PackagesCollector) Collect(c *Context) (json.RawMessage, error) {
out := c.SafeRun("dpkg-query", "-W", func (PackagesCollector) EnsurePrerequisites(_ PackageManager, _ *Context) error {
"-f=${Package} ${Version}\\n") 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}) return json.Marshal(map[string]string{"dpkg": out})
} }

View File

@ -4,8 +4,20 @@ import "encoding/json"
type SensorsCollector struct{} type SensorsCollector struct{}
func (SensorsCollector) Key() string { return "sensors" } func (SensorsCollector) SectionKey() string { return "sensors" }
func (SensorsCollector) Collect(c *Context) (json.RawMessage, error) {
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{ return json.Marshal(map[string]string{
"output": c.SafeRun("sensors"), "output": c.SafeRun("sensors"),
}) })

View File

@ -3,20 +3,32 @@ package sysinfo
import ( import (
"encoding/json" "encoding/json"
"strings" "strings"
"time"
) )
type SystemCollector struct{} 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{ data := map[string]string{
"timestamp": c.Now.Format(time.RFC3339), "timestamp": c.Now.Format(time.RFC3339),
"distro": "ubuntu",
"uname": c.SafeRun("uname", "-a"), "uname": c.SafeRun("uname", "-a"),
"lsb_release": c.SafeRun("lsb_release", "-a"), "lsb_release": c.SafeRun("lsb_release", "-a"),
"cpuinfo": c.SafeRead("/proc/cpuinfo"), "cpuinfo": c.SafeRead("/proc/cpuinfo"),
"meminfo": c.SafeRead("/proc/meminfo"), "meminfo": c.SafeRead("/proc/meminfo"),
"distro": "ubuntu",
} }
serial := strings.TrimSpace( serial := strings.TrimSpace(
c.SafeRun("dmidecode", "-s", "system-serial-number")) c.SafeRun("dmidecode", "-s", "system-serial-number"))

View File

@ -3,16 +3,25 @@ package sysinfo
import ( import (
"encoding/json" "encoding/json"
"strings" "strings"
"time"
) )
type ZFSCollector struct{} type ZFSCollector struct{}
func (ZFSCollector) Key() string { return "zfs" } func (ZFSCollector) SectionKey() string { return "zfs" }
func (ZFSCollector) Collect(c *Context) (json.RawMessage, error) { func (ZFSCollector) EnsurePrerequisites(pm PackageManager, _ *Context) error {
if c.SafeRun("which", "zpool") == "" { if pm.Distro() == "ubuntu" {
return json.Marshal(nil) // no zfs installed 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( pools := strings.Fields(
c.SafeRun("zpool", "list", "-H", "-o", "name")) c.SafeRun("zpool", "list", "-H", "-o", "name"))
section := map[string]any{} section := map[string]any{}

View File

@ -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
}

View File

@ -13,17 +13,16 @@ import (
) )
const ( const (
rootDir = "/etc/sysinfo" rootDir = "/etc/sysinfo"
snapshotJSON = "snapshot.json" snapshotJSON = "snapshot.json"
debianFrontEnv = "DEBIAN_FRONTEND=noninteractive"
) )
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* platform check */ /* platform check (Ubuntu) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
func (a *App) ensureUbuntu() error { func ensureUbuntu() error {
out, err := a.runCmd("lsb_release", "-is") out, err := runCmd("lsb_release", "-is")
if err != nil { if err != nil {
return fmt.Errorf("unsupported OS: lsb_release not found") 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...) cmd := exec.Command(name, args...)
var buf bytes.Buffer var buf bytes.Buffer
cmd.Stdout, cmd.Stderr = &buf, &buf cmd.Stdout, cmd.Stderr = &buf, &buf
@ -46,38 +45,25 @@ func (a *App) runCmd(name string, args ...string) ([]byte, error) {
return buf.Bytes(), err return buf.Bytes(), err
} }
func (a *App) safeRun(name string, args ...string) string { func safeRun(name string, args ...string) string {
out, _ := a.runCmd(name, args...) out, _ := runCmd(name, args...)
return string(out) return string(out)
} }
func (a *App) runJSON(dst *json.RawMessage, func safeRead(path string) string {
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) b, _ := ioutil.ReadFile(path)
return string(b) return string(b)
} }
/* ------------------------------------------------------------------ */ func emitJSON(f *os.File, v any) error {
/* JSON emitter */
/* ------------------------------------------------------------------ */
func (a *App) emitJSON(f *os.File, s *Snapshot) error {
enc := json.NewEncoder(f) enc := json.NewEncoder(f)
enc.SetIndent("", " ") enc.SetIndent("", " ")
return enc.Encode(s) return enc.Encode(v)
} }
/* ------------------------------------------------------------------ */ /* ---------- generic helpers reused in collectors ------------------ */
/* network helpers */
/* ------------------------------------------------------------------ */
func (a *App) readNetStats(iface string) (json.RawMessage, error) { func readNetStats(iface string) (json.RawMessage, error) {
dir := filepath.Join("/sys/class/net", iface, "statistics") dir := filepath.Join("/sys/class/net", iface, "statistics")
ent, err := ioutil.ReadDir(dir) ent, err := ioutil.ReadDir(dir)
if err != nil { 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) n, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
m[f.Name()] = n m[f.Name()] = n
} }
j, _ := json.Marshal(m) return 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)
}
} }

View File

@ -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
}

View File

@ -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()
}