mesh/delivery ready: it delivers once merged
mesh/merge-gate out of date: judged against a trunk it does not name, the trunk is now ef26dba2753a; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against a trunk it does not name, the trunk is now ef26dba2753a; judged again on a recheck or when it is merged
Go's monotonic clock stops while a laptop is suspended, so a day judged by it could pass a day and a half of wall time before the machine said what it is. And a hung statfs left a goroutine and thread blocked per apply; now one check runs at a time and reports after it say the sizes unknown until it ends.
446 lines
14 KiB
Go
446 lines
14 KiB
Go
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()
|
|
}
|