package profile import ( "context" "fmt" "os" "sort" "strconv" "strings" "sync" "syscall" "time" ) // Machine is what this machine is made of, as it says itself (novox/hq issue 160): what somebody // deciding where a module should go wants to know, beside what it can do. // // **A fact it could not determine is said, never zeroed.** Every fact below is either present or // named in Unknown with why; a machine reporting 0 bytes of memory would be read as a machine with // none. So a field is omitted only when Unknown says why. type Machine struct { // ObservedAt is when this was read: the age every reader shows beside it. ObservedAt time.Time `json:"observed_at"` // KernelRelease is the running kernel's release, as /proc/sys/kernel/osrelease says it. (The // profile's own `kernel` is the kernel's family, `linux`.) KernelRelease string `json:"kernel_release,omitempty"` // Distribution and its Version, from /etc/os-release: VERSION_ID, or BUILD_ID for a rolling // distribution that has no version (Arch Linux says `rolling`). Distribution string `json:"distribution,omitempty"` Version string `json:"version,omitempty"` // Processor is its model name; Cores the physical cores and Threads the logical processors. Processor string `json:"processor,omitempty"` Cores int `json:"cores,omitempty"` Threads int `json:"threads,omitempty"` MemoryBytes int64 `json:"memory_bytes,omitempty"` // Virtual is `physical`, or the kind of virtual machine or container it is (`kvm`, `lxc`), as // systemd-detect-virt names it. Virtual string `json:"virtual,omitempty"` // BootedAt is when it booted: its uptime is the time since, so the fact does not change between // two reports of the same boot. BootedAt *time.Time `json:"booted_at,omitempty"` Timezone string `json:"timezone,omitempty"` // Filesystems is each local filesystem mounted, by where, with its size and what is free. Filesystems []Filesystem `json:"filesystems,omitempty"` // Unknown names each fact that could not be determined, and why. Unknown map[string]string `json:"unknown,omitempty"` } // Filesystem is one mounted local filesystem. type Filesystem struct { Mount string `json:"mount"` Device string `json:"device"` Type string `json:"type"` // SizeBytes and FreeBytes are what statfs says: free is what an unprivileged writer may still // use (f_bavail), the number a person deciding where data goes wants. SizeBytes int64 `json:"size_bytes"` FreeBytes int64 `json:"free_bytes"` } // The facts, by the names Unknown says them under. const ( FactKernelRelease = "kernel release" FactDistribution = "distribution" FactVersion = "version" FactProcessor = "processor" FactCores = "cores" FactThreads = "threads" FactMemory = "memory" FactVirtual = "virtual or physical" FactUptime = "uptime" FactTimezone = "timezone" FactFilesystems = "filesystems" FactPublicAddress = "public address" ) // MachineFacts is every fact Unknown may name, in the order a reader shows them. var MachineFacts = []string{FactKernelRelease, FactDistribution, FactVersion, FactProcessor, FactCores, FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactPublicAddress} // publicAddressUnknown is why the public uplink address is not said. A machine behind a NAT cannot see // its public address from itself, and asking a service outside the mesh is a dependency the mesh does not // take on unasked; how a machine learns it is not decided yet, so it is said as unknown rather than // guessed from a private address. const publicAddressUnknown = "a machine behind a NAT cannot see its public address from itself, and how it " + "learns it is not decided yet" // Sources is where the facts are read from. Replaced in tests only for the parsing layer; the real // sources are exercised against this machine as well (novox/hq ADR 0017). type Sources struct { Read func(path string) ([]byte, error) Readlink func(path string) (string, error) Run Runner // Statfs is (size, free) in bytes of the filesystem mounted at a path. Statfs func(path string) (size, free int64, err error) Now func() time.Time } // RealSources reads this machine. func RealSources() Sources { return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now} } func statfs(path string) (int64, int64, error) { var s syscall.Statfs_t if err := syscall.Statfs(path, &s); err != nil { return 0, 0, err } return int64(s.Blocks) * int64(s.Bsize), int64(s.Bavail) * int64(s.Bsize), nil } // DescribeMachine reads what this machine is made of. It never fails: a fact that cannot be read is // named in Unknown with why, and the rest are still said. func DescribeMachine(ctx context.Context, src Sources) Machine { if src.Now == nil { src.Now = time.Now } m := Machine{ObservedAt: src.Now().UTC(), Unknown: map[string]string{}} unknown := func(fact string, why string) { m.Unknown[fact] = why } if b, err := src.Read("/proc/sys/kernel/osrelease"); err != nil { unknown(FactKernelRelease, err.Error()) } else if r := strings.TrimSpace(string(b)); r == "" { unknown(FactKernelRelease, "/proc/sys/kernel/osrelease is empty") } else { m.KernelRelease = r } if b, err := src.Read("/etc/os-release"); err != nil { unknown(FactDistribution, err.Error()) unknown(FactVersion, err.Error()) } else { name, version, whyName, whyVersion := distributionOf(string(b)) m.Distribution, m.Version = name, version if whyName != "" { unknown(FactDistribution, whyName) } if whyVersion != "" { unknown(FactVersion, whyVersion) } } if b, err := src.Read("/proc/cpuinfo"); err != nil { unknown(FactProcessor, err.Error()) unknown(FactCores, err.Error()) unknown(FactThreads, err.Error()) } else { c := processorOf(string(b)) m.Processor, m.Cores, m.Threads = c.model, c.cores, c.threads for fact, why := range c.unknown { unknown(fact, why) } } if b, err := src.Read("/proc/meminfo"); err != nil { unknown(FactMemory, err.Error()) } else if kb, ok := memTotalKB(string(b)); !ok { unknown(FactMemory, "MemTotal not found in /proc/meminfo") } else { m.MemoryBytes = kb * 1024 } m.Virtual, _ = virtualOf(ctx, src.Run, unknown) if b, err := src.Read("/proc/stat"); err != nil { unknown(FactUptime, err.Error()) } else if booted, ok := bootedAt(string(b)); !ok { unknown(FactUptime, "btime not found in /proc/stat") } else { m.BootedAt = &booted } if tz, why := timezoneOf(src); why != "" { unknown(FactTimezone, why) } else { m.Timezone = tz } if b, err := src.Read("/proc/self/mounts"); err != nil { unknown(FactFilesystems, err.Error()) } else { fs, why := filesystemsOf(ctx, string(b), src.Statfs) m.Filesystems = fs if why != "" { unknown(FactFilesystems, why) } } unknown(FactPublicAddress, publicAddressUnknown) return m } // distributionOf is the distribution's name and version from an os-release file, with why either is // not said. NAME first, then ID; VERSION_ID first, then BUILD_ID. func distributionOf(osRelease string) (name, version, whyName, whyVersion string) { fields := map[string]string{} for _, line := range strings.Split(osRelease, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, found := strings.Cut(line, "=") if !found { continue } fields[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`) } name = firstOf(fields["NAME"], fields["ID"]) version = firstOf(fields["VERSION_ID"], fields["BUILD_ID"]) if name == "" { whyName = "/etc/os-release names neither NAME nor ID" } if version == "" { whyVersion = "/etc/os-release names neither VERSION_ID nor BUILD_ID" } return name, version, whyName, whyVersion } func firstOf(values ...string) string { for _, v := range values { if v != "" { return v } } return "" } type processor struct { model string cores, threads int unknown map[string]string } // processorOf reads /proc/cpuinfo: the model from `model name` (or `Model`, `Hardware`, `cpu model`, // which other architectures use), threads from its `processor` entries, and cores from the distinct // (physical id, core id) pairs — which an architecture that does not say them leaves unknown rather than // guessed equal to the threads. func processorOf(cpuinfo string) processor { p := processor{unknown: map[string]string{}} pairs := map[string]bool{} physical := "0" var models = map[string]string{} for _, line := range strings.Split(cpuinfo, "\n") { key, value, found := strings.Cut(line, ":") if !found { continue } key, value = strings.TrimSpace(key), strings.TrimSpace(value) switch key { case "processor": p.threads++ case "physical id": physical = value case "core id": pairs[physical+"/"+value] = true case "model name", "Model", "Hardware", "cpu model": if _, had := models[key]; !had { models[key] = value } } } p.model = firstOf(models["model name"], models["cpu model"], models["Model"], models["Hardware"]) p.cores = len(pairs) if p.model == "" { p.unknown[FactProcessor] = "/proc/cpuinfo names no model" } if p.threads == 0 { p.unknown[FactThreads] = "/proc/cpuinfo lists no processor" } if p.cores == 0 { p.unknown[FactCores] = "/proc/cpuinfo says no core id" } return p } func memTotalKB(meminfo string) (int64, bool) { for _, line := range strings.Split(meminfo, "\n") { if !strings.HasPrefix(line, "MemTotal:") { continue } parts := strings.Fields(line) if len(parts) < 2 { return 0, false } kb, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || kb <= 0 { return 0, false } return kb, true } return 0, false } // virtualOf asks systemd-detect-virt. It says `none` on a physical machine, and exits 1 doing so. func virtualOf(ctx context.Context, run Runner, unknown func(string, string)) (string, bool) { if run == nil { unknown(FactVirtual, "nothing here runs commands") return "", false } out, err := run(ctx, "systemd-detect-virt") switch said := strings.TrimSpace(out); { case said == "none": return "physical", true case said != "" && err == nil: return said, true case err != nil: unknown(FactVirtual, "systemd-detect-virt: "+err.Error()) default: unknown(FactVirtual, "systemd-detect-virt said nothing") } return "", false } func bootedAt(stat string) (time.Time, bool) { for _, line := range strings.Split(stat, "\n") { parts := strings.Fields(line) if len(parts) == 2 && parts[0] == "btime" { secs, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || secs <= 0 { return time.Time{}, false } return time.Unix(secs, 0).UTC(), true } } return time.Time{}, false } // timezoneOf is the zone /etc/localtime links into the zoneinfo tree, or /etc/timezone's. func timezoneOf(src Sources) (string, string) { var whys []string if src.Readlink != nil { target, err := src.Readlink("/etc/localtime") if err == nil { if _, zone, found := strings.Cut(target, "zoneinfo/"); found && zone != "" { return zone, "" } whys = append(whys, "/etc/localtime links to "+target+", outside a zoneinfo tree") } else { whys = append(whys, "/etc/localtime: "+err.Error()) } } if b, err := src.Read("/etc/timezone"); err == nil { if zone := strings.TrimSpace(string(b)); zone != "" { return zone, "" } whys = append(whys, "/etc/timezone is empty") } else { whys = append(whys, err.Error()) } return "", strings.Join(whys, "; ") } // localTypes are the filesystem types said: those on a local device. A network filesystem is left out on // purpose — statfs on a server that stopped answering blocks, and this runs on every apply. var localTypes = map[string]bool{"ext2": true, "ext3": true, "ext4": true, "xfs": true, "btrfs": true, "zfs": true, "f2fs": true, "vfat": true, "exfat": true, "ntfs": true, "ntfs3": true, "fuseblk": true, "jfs": true, "reiserfs": true, "bcachefs": true} // sizing is held while a check of the filesystems' sizes runs. var sizing sync.Mutex // filesystemsOf lists each local filesystem once — a device mounted at several places (a btrfs // subvolume, a bind mount) under its shortest mount — with its size and what is free. Bounded by the // context, so a filesystem whose statfs hangs cannot hang the apply that reports it. func filesystemsOf(ctx context.Context, mounts string, stat func(string) (int64, int64, error)) ([]Filesystem, string) { if stat == nil { return nil, "nothing here can ask a filesystem its size" } byDevice := map[string]Filesystem{} for _, line := range strings.Split(mounts, "\n") { f := strings.Fields(line) if len(f) < 3 || !localTypes[f[2]] { continue } mount := unescapeMount(f[1]) if had, ok := byDevice[f[0]]; ok && len(had.Mount) <= len(mount) { continue } byDevice[f[0]] = Filesystem{Mount: mount, Device: f[0], Type: f[2]} } if len(byDevice) == 0 { return nil, "/proc/self/mounts lists no local filesystem" } // One check at a time, across every report: a filesystem whose statfs hangs keeps the one check that // asked it, and every report after it says the sizes unknown until it finishes, rather than leaving // another goroutine and thread blocked behind it on every apply. if !sizing.TryLock() { return nil, "the last check of the filesystems' sizes has not finished yet" } type answer struct { fs []Filesystem why []string } done := make(chan answer, 1) go func() { defer sizing.Unlock() var a answer for _, fs := range byDevice { size, free, err := stat(fs.Mount) if err != nil { a.why = append(a.why, fmt.Sprintf("%s: %v", fs.Mount, err)) continue } fs.SizeBytes, fs.FreeBytes = size, free a.fs = append(a.fs, fs) } done <- a }() select { case a := <-done: sort.Slice(a.fs, func(i, j int) bool { return a.fs[i].Mount < a.fs[j].Mount }) sort.Strings(a.why) return a.fs, strings.Join(a.why, "; ") case <-ctx.Done(): return nil, "asking the filesystems their size did not finish: " + ctx.Err().Error() } } // unescapeMount undoes the octal escapes /proc/self/mounts writes a space or tab in a path as. func unescapeMount(path string) string { if !strings.Contains(path, `\`) { return path } var b strings.Builder for i := 0; i < len(path); i++ { if path[i] == '\\' && i+3 < len(path) { if n, err := strconv.ParseUint(path[i+1:i+4], 8, 8); err == nil { b.WriteByte(byte(n)) i += 3 continue } } b.WriteByte(path[i]) } return b.String() }