2026-03-31 14:52:50 -03:00
|
|
|
package sentinel
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-11 01:08:08 -03:00
|
|
|
"encoding/json"
|
2026-03-31 14:52:50 -03:00
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"strings"
|
2026-04-11 01:08:08 -03:00
|
|
|
|
|
|
|
|
"metis/pkg/facts"
|
2026-03-31 14:52:50 -03:00
|
|
|
)
|
|
|
|
|
|
2026-04-11 01:08:08 -03:00
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
// 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
|
2026-04-11 01:08:08 -03:00
|
|
|
USBScratch *facts.USBScratch `json:"usb_scratch,omitempty"`
|
2026-03-31 14:52:50 -03:00
|
|
|
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(),
|
2026-04-11 01:08:08 -03:00
|
|
|
USBScratch: collectUSBScratch(),
|
2026-03-31 14:52:50 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 01:08:08 -03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
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 ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 01:08:08 -03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 14:52:50 -03:00
|
|
|
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()
|
|
|
|
|
}
|