metis/pkg/sentinel/collector.go

237 lines
6.4 KiB
Go

package sentinel
import (
"encoding/json"
"os"
"os/exec"
"strings"
"metis/pkg/facts"
)
type nodeConfig struct {
USBScratch *usbScratchConfig `json:"usb_scratch,omitempty"`
}
type usbScratchConfig struct {
Mountpoint string `json:"mountpoint,omitempty"`
UUID string `json:"uuid,omitempty"`
Label string `json:"label,omitempty"`
FS string `json:"fs,omitempty"`
BindTargets []string `json:"bind_targets,omitempty"`
}
// Snapshot captures host-level facts.
type Snapshot struct {
Hostname string `json:"hostname,omitempty"`
Kernel string `json:"kernel,omitempty"`
OSImage string `json:"os_image,omitempty"`
K3sVersion string `json:"k3s_version,omitempty"`
Containerd string `json:"containerd,omitempty"`
PackageSample map[string]string `json:"package_sample,omitempty"` // small subset to detect drift
DropInsSample map[string]string `json:"dropins_sample,omitempty"` // path->content hash/sample
USBScratch *facts.USBScratch `json:"usb_scratch,omitempty"`
Notes string `json:"notes,omitempty"`
}
// Collect gathers a minimal set of facts; intended to run inside a DaemonSet pod with host mounts.
func Collect() *Snapshot {
return &Snapshot{
Hostname: runAndTrim("hostname"),
Kernel: runAndTrim("uname", "-r"),
OSImage: osRelease(),
K3sVersion: runAndTrim("k3s", "version"),
Containerd: runAndTrim("containerd", "--version"),
PackageSample: pkgSample(),
USBScratch: collectUSBScratch(),
}
}
func collectUSBScratch() *facts.USBScratch {
raw, err := commandOutput("cat", "/etc/metis/node.json")
if err != nil || len(strings.TrimSpace(string(raw))) == 0 {
return nil
}
var cfg nodeConfig
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.USBScratch == nil {
return nil
}
desired := cfg.USBScratch
scratch := &facts.USBScratch{
Mountpoint: desired.Mountpoint,
UUID: desired.UUID,
Label: desired.Label,
FS: desired.FS,
}
source, fsType, mounted := mountInfo(desired.Mountpoint)
scratch.MountHealthy = mounted && strings.TrimSpace(source) != ""
if scratch.MountHealthy && desired.FS != "" && fsType != "" {
scratch.MountHealthy = strings.EqualFold(fsType, desired.FS)
}
if scratch.FS == "" && fsType != "" {
scratch.FS = fsType
}
device := source
if device == "" && desired.UUID != "" {
device = resolveDeviceByUUID(desired.UUID)
}
if device == "" && desired.Label != "" {
device = resolveDeviceByLabel(desired.Label)
}
if device != "" {
export := blkidExport(device)
if desired.UUID != "" {
scratch.UUIDHealthy = export["UUID"] == desired.UUID
}
if desired.Label != "" {
scratch.LabelHealthy = export["LABEL"] == desired.Label
}
if scratch.FS == "" {
scratch.FS = export["TYPE"]
}
}
healthy := true
if len(desired.BindTargets) > 0 {
scratch.BindTargets = make([]facts.USBBindTarget, 0, len(desired.BindTargets))
for _, target := range desired.BindTargets {
ok := bindHealthy(target, desired.Mountpoint)
if !ok {
healthy = false
}
scratch.BindTargets = append(scratch.BindTargets, facts.USBBindTarget{
Path: target,
Healthy: ok,
})
}
scratch.BindHealthy = healthy
} else {
scratch.BindHealthy = true
}
return scratch
}
func runAndTrim(cmd string, args ...string) string {
out, err := commandOutput(cmd, args...)
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func osRelease() string {
out, err := commandOutput("cat", "/etc/os-release")
if err != nil {
return ""
}
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "PRETTY_NAME=") {
return strings.Trim(line[len("PRETTY_NAME="):], "\"")
}
}
return ""
}
// pkgSample grabs a tiny subset of package versions to detect drift without collecting everything.
func pkgSample() map[string]string {
names := []string{"containerd", "k3s", "nvidia-container-toolkit", "linux-image-raspi"}
result := map[string]string{}
for _, n := range names {
v := pkgVersion(n)
if v != "" {
result[n] = v
}
}
return result
}
func pkgVersion(name string) string {
// Try dpkg-query first.
out, err := commandOutput("dpkg-query", "-W", "-f", "${Version}", name)
if err == nil && len(out) > 0 {
return strings.TrimSpace(string(out))
}
// Fallback rpm.
out, err = commandOutput("rpm", "-q", "--qf", "%{VERSION}-%{RELEASE}", name)
if err == nil && len(out) > 0 {
return strings.TrimSpace(string(out))
}
return ""
}
func mountInfo(target string) (string, string, bool) {
target = strings.TrimSpace(target)
if target == "" {
return "", "", false
}
out, err := commandOutput("findmnt", "-P", "-n", "-T", target, "-o", "SOURCE,TARGET,FSTYPE")
if err != nil {
return "", "", false
}
fields := parseKeyValues(string(out))
source := fields["SOURCE"]
fsType := fields["FSTYPE"]
return source, fsType, strings.TrimSpace(fields["TARGET"]) == target
}
func bindHealthy(target, source string) bool {
target = strings.TrimSpace(target)
source = strings.TrimSpace(source)
if target == "" || source == "" {
return false
}
mountSource, _, mounted := mountInfo(target)
return mounted && strings.TrimSpace(mountSource) == source
}
func resolveDeviceByUUID(uuid string) string {
uuid = strings.TrimSpace(uuid)
if uuid == "" {
return ""
}
return runAndTrim("blkid", "-U", uuid)
}
func resolveDeviceByLabel(label string) string {
label = strings.TrimSpace(label)
if label == "" {
return ""
}
return runAndTrim("blkid", "-L", label)
}
func blkidExport(device string) map[string]string {
device = strings.TrimSpace(device)
if device == "" {
return map[string]string{}
}
out, err := commandOutput("blkid", "-o", "export", device)
if err != nil {
return map[string]string{}
}
return parseKeyValues(string(out))
}
func parseKeyValues(out string) map[string]string {
result := map[string]string{}
for _, field := range strings.Fields(strings.TrimSpace(out)) {
key, value, ok := strings.Cut(field, "=")
if !ok {
continue
}
result[key] = strings.Trim(value, `"`)
}
return result
}
func commandOutput(cmd string, args ...string) ([]byte, error) {
if os.Getenv("METIS_SENTINEL_NSENTER") == "1" {
nsenterArgs := []string{"-t", "1", "-m", "-u", "-n", "-i", "-p", "--", cmd}
nsenterArgs = append(nsenterArgs, args...)
return exec.Command("nsenter", nsenterArgs...).Output()
}
return exec.Command(cmd, args...).Output()
}