mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-07 06:21:26 +00:00
Merge branch 'main' into fix/package-updates-sandbox
This commit is contained in:
42 files changed
+1261
-397
No files matched your search
@@ -10,42 +10,43 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
# henrygd/beszel
|
||||
- image: henrygd/beszel
|
||||
# beszel
|
||||
- image: beszel
|
||||
dockerfile: ./internal/dockerfile_hub
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# henrygd/beszel-agent:alpine
|
||||
- image: henrygd/beszel-agent
|
||||
|
||||
# beszel-agent
|
||||
- image: beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# beszel-agent:alpine
|
||||
- image: beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent_alpine
|
||||
flavor: latest=false
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=alpine
|
||||
type=semver,pattern={{version}}-alpine
|
||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||
type=semver,pattern={{major}}-alpine
|
||||
|
||||
# henrygd/beszel-agent-nvidia
|
||||
- image: henrygd/beszel-agent-nvidia
|
||||
# beszel-agent-nvidia
|
||||
- image: beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
@@ -53,122 +54,21 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# henrygd/beszel-agent-nvidia:slim
|
||||
- image: henrygd/beszel-agent-nvidia
|
||||
# beszel-agent-nvidia:slim
|
||||
- image: beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||
flavor: latest=false
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=slim
|
||||
type=semver,pattern={{version}}-slim
|
||||
type=semver,pattern={{major}}.{{minor}}-slim
|
||||
type=semver,pattern={{major}}-slim
|
||||
|
||||
# henrygd/beszel-agent-intel
|
||||
- image: henrygd/beszel-agent-intel
|
||||
# beszel-agent-intel
|
||||
- image: beszel-agent-intel
|
||||
dockerfile: ./internal/dockerfile_agent_intel
|
||||
platforms: linux/amd64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel
|
||||
- image: ghcr.io/${{ github.repository }}/beszel
|
||||
dockerfile: ./internal/dockerfile_hub
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-nvidia
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||
flavor: latest=false
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=slim
|
||||
type=semver,pattern={{version}}-slim
|
||||
type=semver,pattern={{major}}.{{minor}}-slim
|
||||
type=semver,pattern={{major}}-slim
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-intel
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||
dockerfile: ./internal/dockerfile_agent_intel
|
||||
platforms: linux/amd64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent:alpine
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent_alpine
|
||||
flavor: latest=false
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=alpine
|
||||
type=semver,pattern={{version}}-alpine
|
||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||
type=semver,pattern={{major}}-alpine
|
||||
|
||||
# henrygd/beszel-agent
|
||||
- image: henrygd/beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
@@ -203,7 +103,11 @@ jobs:
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
# Each image is built once and pushed to both registries.
|
||||
# Docker Hub is skipped when its token is missing (e.g. forks).
|
||||
images: |
|
||||
name=henrygd/${{ matrix.image }},enable=${{ secrets.DOCKERHUB_TOKEN != '' }}
|
||||
name=ghcr.io/${{ github.repository }}/${{ matrix.image }}
|
||||
# Variant images must not overwrite the standard image's latest tag.
|
||||
flavor: ${{ matrix.flavor || 'latest=auto' }}
|
||||
tags: ${{ matrix.tags }}
|
||||
@@ -211,13 +115,20 @@ jobs:
|
||||
# https://github.com/docker/login-action
|
||||
- name: Login to Docker Hub
|
||||
env:
|
||||
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||
password_secret_exists: ${{ secrets.DOCKERHUB_TOKEN != '' && 'true' || 'false' }}
|
||||
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||
password: ${{ secrets[matrix.password_secret] }}
|
||||
registry: ${{ matrix.registry }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
@@ -227,7 +138,7 @@ jobs:
|
||||
context: ./
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }}
|
||||
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
|
||||
@@ -27,6 +27,7 @@ type Agent struct {
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
zfs bool // true if system has arcstats
|
||||
memCalc string // Memory calculation formula
|
||||
forceUseCgroup bool // Explicitly use the cgroup mount root for CPU accounting
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
||||
@@ -79,6 +80,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
}
|
||||
|
||||
agent.memCalc, _ = utils.GetEnv("MEM_CALC")
|
||||
|
||||
if useCgroup, exists := utils.GetEnv("USE_CGROUP"); exists {
|
||||
agent.forceUseCgroup = useCgroup == "true"
|
||||
}
|
||||
agent.initializeCpu()
|
||||
agent.sensorConfig = agent.newSensorConfig()
|
||||
|
||||
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
||||
@@ -106,6 +112,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
|
||||
slog.Debug(beszel.Version)
|
||||
|
||||
agent.warnIfRootCgroup()
|
||||
|
||||
// initialize docker manager
|
||||
agent.dockerManager = newDockerManager(agent)
|
||||
|
||||
|
||||
+5
-5
@@ -30,17 +30,17 @@ type CpuMetrics struct {
|
||||
Iowait float64
|
||||
Steal float64
|
||||
Idle float64
|
||||
// fromCgroup is set when Total comes from cgroup accounting (LXC) rather
|
||||
// than /proc/stat, so per-core /proc/stat usage would not match it.
|
||||
// fromCgroup is set when Total comes from cgroup accounting rather than
|
||||
// /proc/stat, so per-core /proc/stat usage would not match it.
|
||||
fromCgroup bool
|
||||
}
|
||||
|
||||
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
|
||||
// It returns percentages for total, user, system, iowait, and steal time.
|
||||
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
|
||||
func getCpuMetrics(cacheTimeMs uint16, forceUseCgroup bool) (CpuMetrics, error) {
|
||||
// Inside LXC, lxcfs serves /proc/stat with the host cores' counters, not
|
||||
// the guest's own usage. Prefer the cgroup's CPU accounting there. (#2332)
|
||||
if metrics, ok := containerCpuMetrics(cacheTimeMs); ok {
|
||||
// the guest's own usage. Prefer cgroup accounting there or when requested. (#2332)
|
||||
if metrics, ok := containerCpuMetrics(cacheTimeMs, forceUseCgroup); ok {
|
||||
metrics.fromCgroup = true
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
+73
-124
@@ -3,6 +3,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -23,15 +24,15 @@ import (
|
||||
// reflects only the guest's processes, so inside LXC we derive CPU% from that
|
||||
// instead.
|
||||
//
|
||||
// Other runtimes (Docker, Podman, k8s) are deliberately left alone: the agent
|
||||
// is normally deployed there to monitor the host, and the host's /proc/stat is
|
||||
// exactly what it should report.
|
||||
// Other runtimes (Docker, Podman, k8s) use /proc/stat by default: the agent
|
||||
// is normally deployed there to monitor the host. BESZEL_AGENT_USE_CGROUP=true
|
||||
// explicitly opts into cgroup-root CPU accounting outside LXC.
|
||||
|
||||
// File paths and hooks are variables so tests can point them at fixtures.
|
||||
var (
|
||||
cpuCgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point
|
||||
cpuCgroupMountinfo = "/proc/self/mountinfo"
|
||||
cpuProcSelfCgroup = "/proc/self/cgroup"
|
||||
cgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point
|
||||
cgroupMountinfo = "/proc/self/mountinfo"
|
||||
procSelfCgroup = "/proc/self/cgroup"
|
||||
cpuSystemdContPath = "/run/systemd/container"
|
||||
cpuNumCPU = runtime.NumCPU
|
||||
cpuNow = time.Now
|
||||
@@ -50,7 +51,7 @@ func detectLxc() bool {
|
||||
// lxcfs mounted over /proc/stat is the direct cause of the host-core
|
||||
// counters. Only match that mount point: an LXC host also has lxcfs
|
||||
// mounted, but at /var/lib/lxcfs.
|
||||
if data, err := os.ReadFile(cpuCgroupMountinfo); err == nil && procStatFromLxcfs(data) {
|
||||
if data, err := os.ReadFile(cgroupMountinfo); err == nil && procStatFromLxcfs(data) {
|
||||
return true
|
||||
}
|
||||
// set by liblxc for the container init and inherited on non-systemd guests
|
||||
@@ -65,6 +66,12 @@ func detectLxc() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// useCgroup enables cgroup-root accounting automatically in LXC or
|
||||
// explicitly when requested by the agent configuration.
|
||||
func useCgroup(forceUse bool) bool {
|
||||
return forceUse || inLxc()
|
||||
}
|
||||
|
||||
// procStatFromLxcfs reports whether mountinfo shows lxcfs mounted on /proc/stat.
|
||||
func procStatFromLxcfs(mountinfo []byte) bool {
|
||||
for line := range strings.SplitSeq(string(mountinfo), "\n") {
|
||||
@@ -91,10 +98,10 @@ type cgroupCpuSample struct {
|
||||
|
||||
var lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
|
||||
|
||||
// init seeds the LXC CPU baseline so the first reported value is a real
|
||||
// delta since startup rather than zero.
|
||||
func init() {
|
||||
if !inLxc() {
|
||||
// initializeCpu seeds the cgroup baseline after Agent configuration is read,
|
||||
// so the first reported CPU value is a delta instead of zero.
|
||||
func (a *Agent) initializeCpu() {
|
||||
if !useCgroup(a.forceUseCgroup) {
|
||||
return
|
||||
}
|
||||
if s, ok := readContainerCpuSample(); ok {
|
||||
@@ -103,12 +110,47 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// containerCpuMetrics derives CPU metrics from the guest's own cgroup
|
||||
// accounting when running inside LXC. It returns ok=false everywhere else and
|
||||
// whenever cgroup accounting is unreadable, so callers keep the /proc/stat
|
||||
// path.
|
||||
func containerCpuMetrics(cacheTimeMs uint16) (CpuMetrics, bool) {
|
||||
if !inLxc() {
|
||||
// warnIfRootCgroup logs at startup when USE_CGROUP would read the host's root
|
||||
// cgroup (e.g. a container run with --cgroupns=host), whose accounting covers
|
||||
// the whole machine rather than the container.
|
||||
func (a *Agent) warnIfRootCgroup() {
|
||||
if a.forceUseCgroup && inRootCgroupV2() {
|
||||
slog.Warn("USE_CGROUP is reading the host's root cgroup; use --cgroupns=private to monitor the container")
|
||||
}
|
||||
}
|
||||
|
||||
// cgroupV2Dir returns the cgroup v2 mount point (the mount root, not the
|
||||
// agent's own leaf), or false if the process is not in the v2 hierarchy.
|
||||
func cgroupV2Dir() (string, bool) {
|
||||
if !utils.InCgroupV2(procSelfCgroup) {
|
||||
return "", false
|
||||
}
|
||||
if mount := utils.CgroupMountPoint(cgroupMountinfo, "cgroup2", ""); mount != "" {
|
||||
return mount, true
|
||||
}
|
||||
return cgroupRoot, true
|
||||
}
|
||||
|
||||
// inRootCgroupV2 reports whether the visible cgroup v2 mount is the root
|
||||
// cgroup. Every cgroup except the root has a cgroup.type file.
|
||||
func inRootCgroupV2() bool {
|
||||
dir, ok := cgroupV2Dir()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "cgroup.controllers")); err != nil {
|
||||
return false // no cgroup v2 mount here
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(dir, "cgroup.type"))
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// containerCpuMetrics derives CPU metrics from the cgroup mount root's
|
||||
// accounting when running inside LXC or explicitly enabled. It returns
|
||||
// ok=false when disabled or whenever cgroup accounting is unreadable, so
|
||||
// callers keep the /proc/stat path.
|
||||
func containerCpuMetrics(cacheTimeMs uint16, forceUseCgroup bool) (CpuMetrics, bool) {
|
||||
if !useCgroup(forceUseCgroup) {
|
||||
return CpuMetrics{}, false
|
||||
}
|
||||
cur, ok := readContainerCpuSample()
|
||||
@@ -164,21 +206,21 @@ func readContainerCpuSample() (cgroupCpuSample, bool) {
|
||||
// in /proc/self/cgroup (its service cgroup, or the ".lxc" leaf when started
|
||||
// from an attached shell) only covers a subset and must not be descended into.
|
||||
func readCgroupV2CpuSample() (cgroupCpuSample, bool) {
|
||||
if !inCgroupV2() {
|
||||
dir, ok := cgroupV2Dir()
|
||||
if !ok {
|
||||
return cgroupCpuSample{}, false // no v2 membership; try v1
|
||||
}
|
||||
dir := cpuCgroupRoot
|
||||
if mount := cgroupMountPoint("cgroup2", ""); mount != "" {
|
||||
dir = mount
|
||||
stat, err := utils.ReadCgroupStat(filepath.Join(dir, "cpu.stat"))
|
||||
if err != nil {
|
||||
return cgroupCpuSample{}, false
|
||||
}
|
||||
stat := filepath.Join(dir, "cpu.stat")
|
||||
usage, ok := cgroupStatValue(stat, "usage_usec")
|
||||
usage, ok := stat["usage_usec"]
|
||||
if !ok {
|
||||
return cgroupCpuSample{}, false
|
||||
}
|
||||
s := cgroupCpuSample{usageUsec: usage, cores: cpuCgroupCores(dir)}
|
||||
s.userUsec, _ = cgroupStatValue(stat, "user_usec")
|
||||
s.systemUsec, _ = cgroupStatValue(stat, "system_usec")
|
||||
s.userUsec = stat["user_usec"]
|
||||
s.systemUsec = stat["system_usec"]
|
||||
return s, true
|
||||
}
|
||||
|
||||
@@ -187,7 +229,7 @@ func readCgroupV2CpuSample() (cgroupCpuSample, bool) {
|
||||
// accounting includes every child cgroup, so it is read directly rather than
|
||||
// the agent's own sub-cgroup.
|
||||
func readCgroupV1CpuSample() (cgroupCpuSample, bool) {
|
||||
dir := cgroupMountPoint("cgroup", "cpuacct")
|
||||
dir := utils.CgroupMountPoint(cgroupMountinfo, "cgroup", "cpuacct")
|
||||
if dir == "" {
|
||||
return cgroupCpuSample{}, false
|
||||
}
|
||||
@@ -197,78 +239,13 @@ func readCgroupV1CpuSample() (cgroupCpuSample, bool) {
|
||||
}
|
||||
s := cgroupCpuSample{usageUsec: usageNs / 1000, cores: cpuCgroupCores(dir)}
|
||||
// cpuacct.stat reports user/system in USER_HZ jiffies.
|
||||
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "user"); ok {
|
||||
s.userUsec = v * 1e6 / cpuUserHZ
|
||||
}
|
||||
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "system"); ok {
|
||||
s.systemUsec = v * 1e6 / cpuUserHZ
|
||||
if stat, err := utils.ReadCgroupStat(filepath.Join(dir, "cpuacct.stat")); err == nil {
|
||||
s.userUsec = stat["user"] * 1e6 / cpuUserHZ
|
||||
s.systemUsec = stat["system"] * 1e6 / cpuUserHZ
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// inCgroupV2 reports whether /proc/self/cgroup lists the v2 unified hierarchy
|
||||
// (a "0::<path>" entry).
|
||||
func inCgroupV2() bool {
|
||||
data, err := os.ReadFile(cpuProcSelfCgroup)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "0::") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cgroupMountPoint returns the mount point of a cgroup hierarchy from
|
||||
// /proc/self/mountinfo: the cgroup2 mount for v2, or the cgroup mount whose
|
||||
// super options list the wanted v1 controller.
|
||||
func cgroupMountPoint(fstype, v1ctrl string) string {
|
||||
data, err := os.ReadFile(cpuCgroupMountinfo)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
left, right, found := strings.Cut(line, " - ")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
post := strings.Fields(right)
|
||||
if len(post) == 0 || post[0] != fstype {
|
||||
continue
|
||||
}
|
||||
if v1ctrl != "" && !mountOptHas(post, v1ctrl) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(left)
|
||||
if len(fields) >= 5 {
|
||||
return unescapeMountPoint(fields[4])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mountOptHas reports whether the comma-separated super options (field 3 after
|
||||
// the " - " separator) contain opt.
|
||||
func mountOptHas(post []string, opt string) bool {
|
||||
if len(post) < 3 {
|
||||
return false
|
||||
}
|
||||
for _, o := range strings.Split(post[2], ",") {
|
||||
if o == opt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// unescapeMountPoint decodes octal escapes (e.g. \040 for space) used in
|
||||
// mountinfo paths.
|
||||
func unescapeMountPoint(s string) string {
|
||||
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s)
|
||||
}
|
||||
|
||||
// cpuCgroupCores returns how many CPU cores the cgroup at dir may use: the
|
||||
// smallest of the process affinity mask, the cgroup cpuset, and the CPU quota.
|
||||
func cpuCgroupCores(dir string) float64 {
|
||||
@@ -335,38 +312,10 @@ func cpuQuotaCores(dir string) (float64, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if quota, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 {
|
||||
if period, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 {
|
||||
if quota, ok := utils.ReadIntFile(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 {
|
||||
if period, ok := utils.ReadIntFile(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 {
|
||||
return float64(quota) / float64(period), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// cgroupStatValue returns the value of key in a cgroup "key value" stat file.
|
||||
func cgroupStatValue(path, key string) (uint64, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
name, value, found := strings.Cut(line, " ")
|
||||
if !found || name != key {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
||||
return v, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// readCgroupInt reads a file containing a single signed integer
|
||||
// (cpu.cfs_quota_us is -1 when no quota is set).
|
||||
func readCgroupInt(path string) (int64, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
return v, err == nil
|
||||
}
|
||||
+150
-64
@@ -22,13 +22,13 @@ func swapCpuContainerSeams(t *testing.T) {
|
||||
numCPU func() int
|
||||
now func() time.Time
|
||||
}{
|
||||
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuSystemdContPath, cpuNumCPU, cpuNow,
|
||||
cgroupRoot, cgroupMountinfo, procSelfCgroup, cpuSystemdContPath, cpuNumCPU, cpuNow,
|
||||
}
|
||||
origInLxc := inLxc
|
||||
samples := lastCgroupCpuSamples
|
||||
env, hadEnv := os.LookupEnv("container")
|
||||
t.Cleanup(func() {
|
||||
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuSystemdContPath = backup.root, backup.mountinfo, backup.selfCgroup, backup.systemdCont
|
||||
cgroupRoot, cgroupMountinfo, procSelfCgroup, cpuSystemdContPath = backup.root, backup.mountinfo, backup.selfCgroup, backup.systemdCont
|
||||
cpuNumCPU, cpuNow = backup.numCPU, backup.now
|
||||
inLxc = origInLxc
|
||||
lastCgroupCpuSamples = samples
|
||||
@@ -40,11 +40,13 @@ func swapCpuContainerSeams(t *testing.T) {
|
||||
inLxc = sync.OnceValue(detectLxc)
|
||||
lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
|
||||
os.Unsetenv("container")
|
||||
t.Setenv("BESZEL_AGENT_USE_CGROUP", "")
|
||||
t.Setenv("USE_CGROUP", "")
|
||||
|
||||
tmp := t.TempDir()
|
||||
cpuCgroupRoot = filepath.Join(tmp, "cgroup")
|
||||
cpuCgroupMountinfo = filepath.Join(tmp, "mountinfo")
|
||||
cpuProcSelfCgroup = filepath.Join(tmp, "self-cgroup")
|
||||
cgroupRoot = filepath.Join(tmp, "cgroup")
|
||||
cgroupMountinfo = filepath.Join(tmp, "mountinfo")
|
||||
procSelfCgroup = filepath.Join(tmp, "self-cgroup")
|
||||
cpuSystemdContPath = filepath.Join(tmp, "systemd-container")
|
||||
}
|
||||
|
||||
@@ -80,16 +82,16 @@ func TestDetectLxc(t *testing.T) {
|
||||
{"systemd container lxc", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "lxc\n") }, true},
|
||||
{"systemd container nspawn", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "systemd-nspawn\n") }, false},
|
||||
{"lxcfs serving /proc/stat", func(t *testing.T) {
|
||||
writeCpuFixture(t, cpuCgroupMountinfo,
|
||||
writeCpuFixture(t, cgroupMountinfo,
|
||||
"31 25 0:28 / /proc/stat rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
|
||||
}, true},
|
||||
// an LXC host (e.g. Proxmox) mounts lxcfs too, but not over its own /proc
|
||||
{"lxcfs mounted on host", func(t *testing.T) {
|
||||
writeCpuFixture(t, cpuCgroupMountinfo,
|
||||
writeCpuFixture(t, cgroupMountinfo,
|
||||
"45 25 0:40 / /var/lib/lxcfs rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
|
||||
}, false},
|
||||
{"cgroup-only mountinfo", func(t *testing.T) {
|
||||
writeCpuFixture(t, cpuCgroupMountinfo,
|
||||
writeCpuFixture(t, cgroupMountinfo,
|
||||
"36 25 0:32 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw,nsdelegate\n")
|
||||
}, false},
|
||||
}
|
||||
@@ -104,12 +106,12 @@ func TestDetectLxc(t *testing.T) {
|
||||
|
||||
func TestReadCgroupV2CpuSample(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"),
|
||||
"usage_usec 3000000\nuser_usec 2000000\nsystem_usec 1000000\nnr_throttled 7\n")
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "2,5-7\n")
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpuset.cpus.effective"), "2,5-7\n")
|
||||
cpuNumCPU = func() int { return 8 }
|
||||
|
||||
s, ok := readCgroupV2CpuSample()
|
||||
@@ -127,10 +129,10 @@ func TestReadCgroupV2PrefersContainerRoot(t *testing.T) {
|
||||
for _, rel := range []string{"system.slice/beszel-agent.service", ".lxc"} {
|
||||
t.Run(rel, func(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/"+rel+"\n")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, rel, "cpu.stat"), "usage_usec 5\n")
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/"+rel+"\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 9000\n")
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, rel, "cpu.stat"), "usage_usec 5\n")
|
||||
|
||||
s, ok := readCgroupV2CpuSample()
|
||||
require.True(t, ok)
|
||||
@@ -142,9 +144,9 @@ func TestReadCgroupV2PrefersContainerRoot(t *testing.T) {
|
||||
// Same for v1: the cpuacct mount root covers the agent's sibling services.
|
||||
func TestReadCgroupV1PrefersContainerRoot(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpu,cpuacct:/system.slice/beszel-agent.service\n")
|
||||
writeCpuFixture(t, procSelfCgroup, "3:cpu,cpuacct:/system.slice/beszel-agent.service\n")
|
||||
v1 := filepath.Join(t.TempDir(), "cpu,cpuacct")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo,
|
||||
writeCpuFixture(t, cgroupMountinfo,
|
||||
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpu,cpuacct\n")
|
||||
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "9000000\n")
|
||||
writeCpuFixture(t, filepath.Join(v1, "system.slice/beszel-agent.service/cpuacct.usage"), "5000\n")
|
||||
@@ -156,9 +158,9 @@ func TestReadCgroupV1PrefersContainerRoot(t *testing.T) {
|
||||
|
||||
func TestReadCgroupV1CpuSample(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpuacct:/\n2:memory:/\n")
|
||||
writeCpuFixture(t, procSelfCgroup, "3:cpuacct:/\n2:memory:/\n")
|
||||
v1 := filepath.Join(t.TempDir(), "cpuacct")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo,
|
||||
writeCpuFixture(t, cgroupMountinfo,
|
||||
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpuacct\n")
|
||||
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "2000000000\n")
|
||||
writeCpuFixture(t, filepath.Join(v1, "cpuacct.stat"), "user 100\nsystem 50\n")
|
||||
@@ -175,24 +177,24 @@ func TestReadCgroupV1CpuSample(t *testing.T) {
|
||||
func TestContainerCpuMetricsMath(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
markLxc(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "0-3\n")
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpuset.cpus.effective"), "0-3\n")
|
||||
cpuNumCPU = func() int { return 8 }
|
||||
advance := fakeNow(t)
|
||||
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"),
|
||||
"usage_usec 1000000\nuser_usec 600000\nsystem_usec 400000\n")
|
||||
m, ok := containerCpuMetrics(60000)
|
||||
m, ok := containerCpuMetrics(60000, false)
|
||||
require.True(t, ok)
|
||||
assert.Zero(t, m.Total) // first call only seeds the baseline
|
||||
|
||||
// 1s elapsed, container burned 2 core-seconds on 4 usable cores
|
||||
advance(time.Second)
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"),
|
||||
"usage_usec 3000000\nuser_usec 1600000\nsystem_usec 900000\n")
|
||||
m, ok = containerCpuMetrics(60000)
|
||||
m, ok = containerCpuMetrics(60000, false)
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 50, m.Total, 0.01)
|
||||
assert.InDelta(t, 25, m.User, 0.01)
|
||||
@@ -205,18 +207,18 @@ func TestContainerCpuMetricsMath(t *testing.T) {
|
||||
func TestContainerCpuMetricsHonorsQuota(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
markLxc(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores
|
||||
cpuNumCPU = func() int { return 8 }
|
||||
advance := fakeNow(t)
|
||||
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
|
||||
containerCpuMetrics(60000)
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
|
||||
containerCpuMetrics(60000, false)
|
||||
advance(time.Second)
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
|
||||
m, ok := containerCpuMetrics(60000)
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
|
||||
m, ok := containerCpuMetrics(60000, false)
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 50, m.Total, 0.01) // 1 core-second against a 2-core quota
|
||||
}
|
||||
@@ -224,30 +226,30 @@ func TestContainerCpuMetricsHonorsQuota(t *testing.T) {
|
||||
func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
markLxc(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
|
||||
cpuNumCPU = func() int { return 4 }
|
||||
advance := fakeNow(t)
|
||||
|
||||
// seed the baseline, then do not advance the clock: elapsed <= 0
|
||||
containerCpuMetrics(60000)
|
||||
m, ok := containerCpuMetrics(60000)
|
||||
containerCpuMetrics(60000, false)
|
||||
m, ok := containerCpuMetrics(60000, false)
|
||||
require.True(t, ok)
|
||||
assert.Zero(t, m.Total)
|
||||
|
||||
// counter goes backwards (cgroup recreated): report zero and re-baseline
|
||||
advance(time.Second)
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 100000\n")
|
||||
m, ok = containerCpuMetrics(60000)
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 100000\n")
|
||||
m, ok = containerCpuMetrics(60000, false)
|
||||
require.True(t, ok)
|
||||
assert.Zero(t, m.Total)
|
||||
|
||||
// next tick measures from the new baseline, not the stale one
|
||||
advance(time.Second)
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1100000\n")
|
||||
m, ok = containerCpuMetrics(60000)
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1100000\n")
|
||||
m, ok = containerCpuMetrics(60000, false)
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 25, m.Total, 0.01) // 1e6 usec / (1s * 4 cores)
|
||||
}
|
||||
@@ -255,34 +257,118 @@ func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) {
|
||||
func TestContainerCpuMetricsFallbacks(t *testing.T) {
|
||||
t.Run("not in lxc", func(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
_, ok := containerCpuMetrics(60000)
|
||||
_, ok := containerCpuMetrics(60000, false)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
// Docker agents monitor the host, so cgroup accounting must not kick in
|
||||
// even when it is readable and no LXC marker is present.
|
||||
t.Run("docker container", func(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
|
||||
_, ok := containerCpuMetrics(60000)
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
|
||||
_, ok := containerCpuMetrics(60000, false)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
t.Run("in lxc without cgroup accounting", func(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
markLxc(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
||||
// cpuCgroupRoot has no cpu.stat
|
||||
_, ok := containerCpuMetrics(60000)
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
// cgroupRoot has no cpu.stat
|
||||
_, ok := containerCpuMetrics(60000, false)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestContainerCpuMetricsExplicitOptIn(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/system.slice/beszel-agent.service\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
|
||||
cpuNumCPU = func() int { return 4 }
|
||||
advance := fakeNow(t)
|
||||
|
||||
a := &Agent{forceUseCgroup: true}
|
||||
a.initializeCpu()
|
||||
require.False(t, lastCgroupCpuSamples[60000].at.IsZero())
|
||||
advance(time.Second)
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 3000000\n")
|
||||
m, err := getCpuMetrics(60000, a.forceUseCgroup)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 50, m.Total, 0.01)
|
||||
assert.True(t, m.fromCgroup)
|
||||
}
|
||||
|
||||
func TestContainerCpuMetricsExplicitOptInUnreadable(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
_, ok := containerCpuMetrics(60000, true)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestInRootCgroupV2(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
|
||||
// no cgroup v2 membership
|
||||
writeCpuFixture(t, procSelfCgroup, "4:cpu,cpuacct:/docker/abc\n")
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cgroup.controllers"), "cpu memory\n")
|
||||
assert.False(t, inRootCgroupV2())
|
||||
|
||||
// --cgroupns=host: the mount is the host root, which has no cgroup.type
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/system.slice/docker-abc.scope\n")
|
||||
assert.True(t, inRootCgroupV2())
|
||||
|
||||
// --cgroupns=private: the mount is the container's own (non-root) cgroup
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cgroup.type"), "domain\n")
|
||||
assert.False(t, inRootCgroupV2())
|
||||
|
||||
// no cgroup v2 mount at all
|
||||
require.NoError(t, os.RemoveAll(cgroupRoot))
|
||||
assert.False(t, inRootCgroupV2())
|
||||
}
|
||||
|
||||
func TestInitializeCpuDetectsLxc(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
markLxc(t)
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
|
||||
|
||||
a := &Agent{}
|
||||
a.initializeCpu()
|
||||
assert.EqualValues(t, 1000000, lastCgroupCpuSamples[60000].usageUsec)
|
||||
}
|
||||
|
||||
func TestNewAgentInitializesCgroupCpuFromEnv(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
t.Setenv("BESZEL_AGENT_USE_CGROUP", "true")
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/system.slice/beszel-agent.service\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
|
||||
cpuNumCPU = func() int { return 4 }
|
||||
advance := fakeNow(t)
|
||||
|
||||
a, err := NewAgent(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
require.True(t, a.forceUseCgroup)
|
||||
assert.EqualValues(t, 1000000, lastCgroupCpuSamples[60000].usageUsec)
|
||||
|
||||
// Changing the environment later must not change this agent's CPU source.
|
||||
t.Setenv("BESZEL_AGENT_USE_CGROUP", "false")
|
||||
advance(time.Second)
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 3000000\n")
|
||||
m, err := getCpuMetrics(60000, a.forceUseCgroup)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 50, m.Total, 0.01)
|
||||
assert.True(t, m.fromCgroup)
|
||||
}
|
||||
|
||||
// The host path must keep reporting through gopsutil untouched.
|
||||
func TestGetCpuMetricsHostFallback(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
m, err := getCpuMetrics(60000)
|
||||
m, err := getCpuMetrics(60000, false)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, m.fromCgroup)
|
||||
assert.GreaterOrEqual(t, m.Total, 0.0)
|
||||
@@ -294,20 +380,20 @@ func TestGetCpuMetricsHostFallback(t *testing.T) {
|
||||
func TestGetCpuMetricsPrefersCgroup(t *testing.T) {
|
||||
swapCpuContainerSeams(t)
|
||||
markLxc(t)
|
||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
require.NoError(t, os.MkdirAll(cgroupRoot, 0o755))
|
||||
cpuNumCPU = func() int { return 4 }
|
||||
advance := fakeNow(t)
|
||||
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 0\n")
|
||||
m, err := getCpuMetrics(60000)
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 0\n")
|
||||
m, err := getCpuMetrics(60000, false)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, m.Total)
|
||||
|
||||
advance(time.Second)
|
||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
|
||||
m, err = getCpuMetrics(60000)
|
||||
writeCpuFixture(t, filepath.Join(cgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
|
||||
m, err = getCpuMetrics(60000, false)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 50, m.Total, 0.01)
|
||||
assert.True(t, m.fromCgroup) // per-core usage is skipped for this source
|
||||
|
||||
@@ -4,6 +4,10 @@ package agent
|
||||
|
||||
// containerCpuMetrics is Linux-only (cgroup accounting). Other platforms keep
|
||||
// the gopsutil /proc path.
|
||||
func containerCpuMetrics(uint16) (CpuMetrics, bool) {
|
||||
func containerCpuMetrics(uint16, bool) (CpuMetrics, bool) {
|
||||
return CpuMetrics{}, false
|
||||
}
|
||||
|
||||
func (a *Agent) initializeCpu() {}
|
||||
|
||||
func (a *Agent) warnIfRootCgroup() {}
|
||||
@@ -0,0 +1,55 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/agent/zfs"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
)
|
||||
|
||||
// memoryMetrics holds byte counters. For cgroups, Used excludes file cache,
|
||||
// while retaining shared memory/tmpfs. BuffCache is the subtracted file cache.
|
||||
type memoryMetrics struct {
|
||||
Total, Used, BuffCache uint64
|
||||
}
|
||||
|
||||
var memoryVirtualMemory = mem.VirtualMemory
|
||||
|
||||
func (a *Agent) updateMemoryStats(stats *system.Stats) {
|
||||
v, err := memoryVirtualMemory()
|
||||
var hostTotal uint64
|
||||
if err == nil {
|
||||
hostTotal = v.Total
|
||||
// Swap retains its existing /proc/meminfo source in both memory paths.
|
||||
stats.Swap = utils.BytesToGigabytes(v.SwapTotal)
|
||||
stats.SwapUsed = utils.BytesToGigabytes(saturatingSub(v.SwapTotal, v.SwapFree))
|
||||
}
|
||||
|
||||
metrics, fromCgroup := containerMemoryMetrics(hostTotal, a.forceUseCgroup)
|
||||
if !fromCgroup {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
used, cacheBuff, _ := calculateHostMemoryUsage(v, a.memCalc == "htop")
|
||||
metrics = memoryMetrics{Total: v.Total, Used: used, BuffCache: cacheBuff}
|
||||
// Host ARC must not be subtracted from container-scoped accounting.
|
||||
if a.zfs {
|
||||
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < metrics.Used {
|
||||
metrics.Used -= arcSize
|
||||
stats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.Mem = utils.BytesToGigabytes(metrics.Total)
|
||||
stats.MemUsed = utils.BytesToGigabytes(metrics.Used)
|
||||
stats.MemBuffCache = utils.BytesToGigabytes(metrics.BuffCache)
|
||||
if metrics.Total > 0 {
|
||||
stats.MemPct = utils.TwoDecimals(float64(metrics.Used) / float64(metrics.Total) * 100)
|
||||
}
|
||||
if a.systemDetails.MemoryTotal != metrics.Total {
|
||||
a.updateSystemDetails(func(details *system.Details) {
|
||||
details.MemoryTotal = metrics.Total
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
)
|
||||
|
||||
// containerMemoryMetrics is only used when USE_CGROUP is explicitly enabled.
|
||||
// Unlike /proc/stat, lxcfs already serves a container-scoped /proc/meminfo,
|
||||
// so LXC keeps the existing memory path by default.
|
||||
func containerMemoryMetrics(hostTotal uint64, forceUseCgroup bool) (memoryMetrics, bool) {
|
||||
if !forceUseCgroup {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
if dir, ok := cgroupV2Dir(); ok {
|
||||
if metrics, ok := readCgroupMemoryMetrics(dir, hostTotal, true); ok {
|
||||
return metrics, true
|
||||
}
|
||||
}
|
||||
if dir := utils.CgroupMountPoint(cgroupMountinfo, "cgroup", "memory"); dir != "" {
|
||||
return readCgroupMemoryMetrics(dir, hostTotal, false)
|
||||
}
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
|
||||
// Read the hierarchy mount root to include sibling services and descendants,
|
||||
// rather than the agent's own service cgroup. A v1 unlimited limit is a
|
||||
// page-aligned LONG_MAX sentinel; the threshold also covers 32-bit kernels.
|
||||
func readCgroupMemoryMetrics(dir string, hostTotal uint64, v2 bool) (memoryMetrics, bool) {
|
||||
usageFile, limitFile, cacheKey, shmemKey := "memory.usage_in_bytes", "memory.limit_in_bytes", "total_cache", "total_shmem"
|
||||
if v2 {
|
||||
usageFile, limitFile, cacheKey, shmemKey = "memory.current", "memory.max", "file", "shmem"
|
||||
}
|
||||
usage, ok := utils.ReadUintFile(filepath.Join(dir, usageFile))
|
||||
if !ok {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
rawLimit, ok := utils.ReadStringFileOK(filepath.Join(dir, limitFile))
|
||||
if !ok {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
var limit uint64
|
||||
if !(v2 && rawLimit == "max") {
|
||||
var err error
|
||||
limit, err = strconv.ParseUint(rawLimit, 10, 64)
|
||||
if err != nil {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
if !v2 && (limit >= (uint64(1)<<63)-(1<<20) || limit == 2147479552) {
|
||||
limit = 0 // v1 unlimited sentinel
|
||||
} else if limit == 0 {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
}
|
||||
total := limit
|
||||
if hostTotal > 0 && (total == 0 || hostTotal < total) {
|
||||
total = hostTotal
|
||||
}
|
||||
if total == 0 {
|
||||
return memoryMetrics{}, false // unlimited and no usable host total
|
||||
}
|
||||
stat, err := utils.ReadCgroupStat(filepath.Join(dir, "memory.stat"))
|
||||
if err != nil {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
cache, ok := stat[cacheKey]
|
||||
if !ok {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
shmem, ok := stat[shmemKey]
|
||||
if !ok {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
// Both cache (v1) and file (v2) include shmem/tmpfs, which must remain
|
||||
// charged as used memory rather than being classified as file cache.
|
||||
cache = min(saturatingSub(cache, shmem), usage)
|
||||
return memoryMetrics{Total: total, Used: usage - cache, BuffCache: cache}, true
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//go:build testing && linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupMemoryCgroup(t *testing.T, v2 bool) string {
|
||||
t.Helper()
|
||||
swapCpuContainerSeams(t)
|
||||
if v2 {
|
||||
writeCpuFixture(t, procSelfCgroup, "0::/system.slice/agent.service\n")
|
||||
writeCpuFixture(t, cgroupMountinfo, "")
|
||||
return cgroupRoot
|
||||
}
|
||||
dir := filepath.Join(filepath.Dir(cgroupRoot), "memory")
|
||||
writeCpuFixture(t, procSelfCgroup, "2:memory:/system.slice/agent.service\n")
|
||||
writeCpuFixture(t, cgroupMountinfo,
|
||||
"30 25 0:26 / "+dir+" rw - cgroup cgroup rw,memory\n")
|
||||
return dir
|
||||
}
|
||||
|
||||
func writeMemoryFixture(t *testing.T, dir string, v2 bool, usage, limit, stat string) {
|
||||
t.Helper()
|
||||
usageName, limitName := "memory.usage_in_bytes", "memory.limit_in_bytes"
|
||||
if v2 {
|
||||
usageName, limitName = "memory.current", "memory.max"
|
||||
}
|
||||
writeCpuFixture(t, filepath.Join(dir, usageName), usage)
|
||||
writeCpuFixture(t, filepath.Join(dir, limitName), limit)
|
||||
writeCpuFixture(t, filepath.Join(dir, "memory.stat"), stat)
|
||||
}
|
||||
|
||||
func TestContainerMemoryMetrics(t *testing.T) {
|
||||
for _, v2 := range []bool{true, false} {
|
||||
t.Run(strconv.FormatBool(v2), func(t *testing.T) {
|
||||
dir := setupMemoryCgroup(t, v2)
|
||||
key, shmemKey := "total_cache", "total_shmem"
|
||||
if v2 {
|
||||
key, shmemKey = "file", "shmem"
|
||||
}
|
||||
stat := func(cache, shmem string) string {
|
||||
return key + " " + cache + "\n" + shmemKey + " " + shmem + "\n"
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
name, usage, limit, stat string
|
||||
host uint64
|
||||
want memoryMetrics
|
||||
ok bool
|
||||
}{
|
||||
{"file cache excluded", "600", "1000", stat("200", "0"), 2000, memoryMetrics{1000, 400, 200}, true},
|
||||
{"shared memory retained", "600", "1000", stat("200", "50"), 2000, memoryMetrics{1000, 450, 150}, true},
|
||||
{"shared memory larger than cache", "600", "1000", stat("200", "300"), 2000, memoryMetrics{1000, 600, 0}, true},
|
||||
{"zero usage", "0", "1000", stat("0", "0"), 2000, memoryMetrics{1000, 0, 0}, true},
|
||||
{"cache larger than charge", "100", "1000", stat("200", "0"), 2000, memoryMetrics{1000, 0, 100}, true},
|
||||
{"host caps limit", "600", "3000", stat("200", "0"), 2000, memoryMetrics{2000, 400, 200}, true},
|
||||
{"finite without host", "600", "1000", stat("200", "0"), 0, memoryMetrics{1000, 400, 200}, true},
|
||||
{"missing cache key", "600", "1000", shmemKey + " 0\n", 2000, memoryMetrics{}, false},
|
||||
{"missing shared memory key", "600", "1000", key + " 200\n", 2000, memoryMetrics{}, false},
|
||||
{"invalid cache", "600", "1000", stat("-1", "0"), 2000, memoryMetrics{}, false},
|
||||
{"invalid shared memory", "600", "1000", stat("200", "-1"), 2000, memoryMetrics{}, false},
|
||||
{"invalid usage", "bad", "1000", stat("200", "0"), 2000, memoryMetrics{}, false},
|
||||
{"invalid limit", "600", "bad", stat("200", "0"), 2000, memoryMetrics{}, false},
|
||||
{"zero limit", "600", "0", stat("200", "0"), 2000, memoryMetrics{}, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
writeMemoryFixture(t, dir, v2, tt.usage, tt.limit, tt.stat)
|
||||
metrics, ok := containerMemoryMetrics(tt.host, true)
|
||||
assert.Equal(t, tt.ok, ok)
|
||||
assert.Equal(t, tt.want, metrics)
|
||||
})
|
||||
}
|
||||
limit := "9223372036854771712"
|
||||
if v2 {
|
||||
limit = "max"
|
||||
}
|
||||
writeMemoryFixture(t, dir, v2, "600", limit, stat("200", "0"))
|
||||
writeMemoryFixture(t, filepath.Join(dir, "system.slice/agent.service"), v2, "1", "100", stat("0", "0"))
|
||||
metrics, ok := containerMemoryMetrics(2000, true)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, memoryMetrics{2000, 400, 200}, metrics)
|
||||
_, ok = containerMemoryMetrics(0, true)
|
||||
assert.False(t, ok)
|
||||
if !v2 {
|
||||
writeMemoryFixture(t, dir, false, "600", "2147479552", stat("200", "0"))
|
||||
metrics, ok = containerMemoryMetrics(2000, true)
|
||||
require.True(t, ok)
|
||||
assert.EqualValues(t, 2000, metrics.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerMemoryScopeAndEnablement(t *testing.T) {
|
||||
dir := setupMemoryCgroup(t, true)
|
||||
// A discovered non-default mount must win over the default root.
|
||||
mount := filepath.Join(t.TempDir(), "unified")
|
||||
writeCpuFixture(t, cgroupMountinfo,
|
||||
"30 25 0:26 /guest "+mount+" rw - cgroup2 cgroup2 rw\n")
|
||||
writeMemoryFixture(t, dir, true, "1", "1000", "file 0\nshmem 0\n")
|
||||
writeMemoryFixture(t, mount, true, "600", "1000", "file 200\nshmem 0\n")
|
||||
writeMemoryFixture(t, filepath.Join(mount, "system.slice/agent.service"), true, "10", "100", "file 0\nshmem 0\n")
|
||||
_, ok := containerMemoryMetrics(2000, false)
|
||||
assert.False(t, ok) // Docker/host agents keep the existing path by default.
|
||||
markLxc(t)
|
||||
inLxc = sync.OnceValue(detectLxc) // Simulate a fresh process in LXC.
|
||||
_, ok = containerMemoryMetrics(2000, false)
|
||||
assert.False(t, ok) // lxcfs already serves container-scoped /proc/meminfo.
|
||||
metrics, ok := containerMemoryMetrics(2000, true)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, memoryMetrics{1000, 400, 200}, metrics)
|
||||
require.NoError(t, os.Remove(filepath.Join(mount, "memory.stat")))
|
||||
_, ok = containerMemoryMetrics(2000, true)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestUpdateMemoryStatsCgroupAndFallback(t *testing.T) {
|
||||
dir := setupMemoryCgroup(t, true)
|
||||
const gib = uint64(1 << 30)
|
||||
writeMemoryFixture(t, dir, true, strconv.FormatUint(6*gib, 10), strconv.FormatUint(10*gib, 10),
|
||||
"file "+strconv.FormatUint(2*gib, 10)+"\nshmem 0\n")
|
||||
original := memoryVirtualMemory
|
||||
t.Cleanup(func() { memoryVirtualMemory = original })
|
||||
memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) {
|
||||
return &mem.VirtualMemoryStat{Total: 20 * gib, Used: 12 * gib, Free: 4 * gib,
|
||||
Cached: 4 * gib, SwapTotal: 2 * gib, SwapFree: gib}, nil
|
||||
}
|
||||
a := &Agent{forceUseCgroup: true, memCalc: "htop", zfs: true}
|
||||
var stats system.Stats
|
||||
a.updateMemoryStats(&stats)
|
||||
assert.Equal(t, float64(10), stats.Mem)
|
||||
assert.Equal(t, float64(4), stats.MemUsed)
|
||||
assert.Equal(t, float64(2), stats.MemBuffCache)
|
||||
assert.Equal(t, float64(40), stats.MemPct)
|
||||
assert.Zero(t, stats.MemZfsArc)
|
||||
assert.Equal(t, float64(2), stats.Swap)
|
||||
assert.Equal(t, float64(1), stats.SwapUsed)
|
||||
assert.Equal(t, 10*gib, a.systemDetails.MemoryTotal)
|
||||
assert.True(t, a.detailsDirty)
|
||||
|
||||
a.detailsDirty = false
|
||||
a.updateMemoryStats(&system.Stats{})
|
||||
assert.False(t, a.detailsDirty)
|
||||
writeCpuFixture(t, filepath.Join(dir, "memory.max"), strconv.FormatUint(8*gib, 10))
|
||||
a.updateMemoryStats(&system.Stats{})
|
||||
assert.Equal(t, 8*gib, a.systemDetails.MemoryTotal)
|
||||
assert.True(t, a.detailsDirty)
|
||||
|
||||
// A missing required field falls back to the original htop calculation.
|
||||
writeCpuFixture(t, filepath.Join(dir, "memory.stat"), "file 123\n")
|
||||
a.zfs = false
|
||||
stats = system.Stats{}
|
||||
a.updateMemoryStats(&stats)
|
||||
assert.Equal(t, float64(20), stats.Mem)
|
||||
assert.Equal(t, float64(12), stats.MemUsed)
|
||||
assert.Equal(t, float64(4), stats.MemBuffCache)
|
||||
assert.Equal(t, float64(60), stats.MemPct)
|
||||
assert.Equal(t, 20*gib, a.systemDetails.MemoryTotal)
|
||||
|
||||
// Finite cgroup limits still work if /proc/meminfo cannot be read.
|
||||
memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) { return nil, errors.New("unreadable") }
|
||||
writeCpuFixture(t, filepath.Join(dir, "memory.stat"), "file 0\nshmem 0\n")
|
||||
stats = system.Stats{}
|
||||
a.updateMemoryStats(&stats)
|
||||
assert.Equal(t, float64(8), stats.Mem)
|
||||
assert.Equal(t, float64(6), stats.MemUsed)
|
||||
assert.Equal(t, float64(75), stats.MemPct)
|
||||
assert.Zero(t, stats.Swap)
|
||||
}
|
||||
|
||||
func TestMemoryDetailsInitiallyUseCgroupLimit(t *testing.T) {
|
||||
dir := setupMemoryCgroup(t, true)
|
||||
writeMemoryFixture(t, dir, true, "600", "1000", "file 200\nshmem 0\n")
|
||||
original := memoryVirtualMemory
|
||||
t.Cleanup(func() { memoryVirtualMemory = original })
|
||||
memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) {
|
||||
return &mem.VirtualMemoryStat{Total: 2000}, nil
|
||||
}
|
||||
a := &Agent{forceUseCgroup: true}
|
||||
a.refreshSystemDetails()
|
||||
assert.EqualValues(t, 1000, a.systemDetails.MemoryTotal)
|
||||
}
|
||||
|
||||
// Captured from this container: active file cache made the working-set
|
||||
// calculation report ~80 GiB despite only ~17 GiB of anonymous RSS.
|
||||
func TestMemoryStatsExcludeActiveFileCache(t *testing.T) {
|
||||
dir := setupMemoryCgroup(t, false)
|
||||
const usage = uint64(135881601024)
|
||||
const cache = uint64(116823166976)
|
||||
const shmem = uint64(77180928)
|
||||
writeMemoryFixture(t, dir, false, strconv.FormatUint(usage, 10), "137438953472",
|
||||
"total_cache 116823166976\ntotal_shmem 77180928\n"+
|
||||
"total_inactive_file 49902608384\ntotal_active_file 67684671488\n")
|
||||
original := memoryVirtualMemory
|
||||
t.Cleanup(func() { memoryVirtualMemory = original })
|
||||
memoryVirtualMemory = func() (*mem.VirtualMemoryStat, error) {
|
||||
return &mem.VirtualMemoryStat{Total: 1580621720 * 1024}, nil
|
||||
}
|
||||
metrics, ok := containerMemoryMetrics(1580621720*1024, true)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, usage-(cache-shmem), metrics.Used)
|
||||
assert.Equal(t, cache-shmem, metrics.BuffCache)
|
||||
assert.Equal(t, usage, metrics.Used+metrics.BuffCache)
|
||||
var stats system.Stats
|
||||
a := &Agent{forceUseCgroup: true}
|
||||
a.updateMemoryStats(&stats)
|
||||
assert.Equal(t, float64(17.82), stats.MemUsed)
|
||||
assert.Equal(t, float64(108.73), stats.MemBuffCache)
|
||||
assert.Equal(t, float64(13.92), stats.MemPct)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
func containerMemoryMetrics(uint64, bool) (memoryMetrics, bool) {
|
||||
return memoryMetrics{}, false
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -20,7 +21,11 @@ type MonitorManager struct {
|
||||
}
|
||||
|
||||
func newMonitorManager() *MonitorManager {
|
||||
return newMonitorManagerWithProbe(networkMonitorProbe(&http.Client{Timeout: monitor.MaxProbeTimeout}))
|
||||
client := &http.Client{Timeout: monitor.MaxProbeTimeout}
|
||||
insecureTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
insecureClient := &http.Client{Timeout: monitor.MaxProbeTimeout, Transport: insecureTransport}
|
||||
return newMonitorManagerWithProbe(networkMonitorProbe(client, insecureClient))
|
||||
}
|
||||
|
||||
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
|
||||
|
||||
@@ -18,7 +18,9 @@ const networkMonitorUserAgent = "Beszel-Agent/" + beszel.Version + " (+https://b
|
||||
// Implementations must honor cancellation and bound their execution time.
|
||||
type monitorProbe func(context.Context, monitor.Config) (int64, error)
|
||||
|
||||
func networkMonitorProbe(client *http.Client) monitorProbe {
|
||||
// networkMonitorProbe returns a probe that uses client for HTTP monitors, or
|
||||
// insecureClient for HTTP monitors that skip TLS certificate verification.
|
||||
func networkMonitorProbe(client, insecureClient *http.Client) monitorProbe {
|
||||
return func(ctx context.Context, config monitor.Config) (int64, error) {
|
||||
switch config.Protocol {
|
||||
case "icmp":
|
||||
@@ -26,6 +28,9 @@ func networkMonitorProbe(client *http.Client) monitorProbe {
|
||||
case "tcp":
|
||||
return monitorTCP(ctx, config.Target, config.Port)
|
||||
case "http":
|
||||
if config.SkipTLSVerify {
|
||||
return monitorHTTP(ctx, insecureClient, config.Target)
|
||||
}
|
||||
return monitorHTTP(ctx, client, config.Target)
|
||||
case "dns":
|
||||
return monitorDNS(ctx, config.Target, config.Server)
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestMonitorManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing
|
||||
|
||||
pm := &MonitorManager{
|
||||
monitors: make(map[string]*monitorTask),
|
||||
probe: networkMonitorProbe(server.Client()),
|
||||
probe: networkMonitorProbe(server.Client(), nil),
|
||||
}
|
||||
|
||||
resp, err := pm.HandleSyncRequest(monitor.SyncRequest{
|
||||
@@ -261,6 +261,24 @@ func TestMonitorHTTP(t *testing.T) {
|
||||
assert.Equal(t, int64(-1), responseUs)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("self-signed certificate", func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
probe := newMonitorManager().probe
|
||||
config := monitor.Config{Protocol: "http", Target: server.URL}
|
||||
|
||||
responseUs, err := probe(context.Background(), config)
|
||||
assert.Equal(t, int64(-1), responseUs)
|
||||
require.Error(t, err)
|
||||
|
||||
config.SkipTLSVerify = true
|
||||
responseUs, err = probe(context.Background(), config)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, responseUs, int64(0))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMonitorTCP(t *testing.T) {
|
||||
@@ -576,7 +594,7 @@ func TestMonitorProbeTimeoutRecordsLoss(t *testing.T) {
|
||||
defer server.Close()
|
||||
defer close(release)
|
||||
pm := newMonitorManager()
|
||||
pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond})
|
||||
pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond}, nil)
|
||||
task := newMonitorTask(monitor.Config{ID: "timeout", Protocol: "http", Target: server.URL})
|
||||
defer task.cancel()
|
||||
|
||||
|
||||
@@ -141,7 +141,10 @@ func runningInContainer() bool {
|
||||
|
||||
func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
|
||||
switch {
|
||||
case commandExists("apt-get"):
|
||||
// openSUSE's zypper-aptitude provides an apt-get wrapper around zypper, so
|
||||
// apt also requires the apt package in dpkg's database. dpkg alone is not
|
||||
// enough because openSUSE packages it too.
|
||||
case commandExists("apt-get") && exec.Command("dpkg-query", "-W", "apt").Run() == nil:
|
||||
return "apt", checkApt
|
||||
case commandExists("dnf"):
|
||||
return "dnf", newDnfCheck(dataDir)
|
||||
|
||||
@@ -268,6 +268,29 @@ exit 1`,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectPackageManager(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commands map[string]string
|
||||
manager string
|
||||
}{
|
||||
{"apt", map[string]string{"apt-get": "", "dpkg-query": "exit 0"}, "apt"},
|
||||
{"zypper-aptitude", map[string]string{"apt-get": "", "zypper": ""}, "zypper"},
|
||||
{"zypper-aptitude with dpkg", map[string]string{"apt-get": "", "dpkg-query": "exit 1", "zypper": ""}, "zypper"},
|
||||
{"dnf", map[string]string{"dnf": "", "rpm": ""}, "dnf"},
|
||||
{"none", nil, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeCommands(t, tt.commands)
|
||||
// only the fake commands, so the host's package managers are not found
|
||||
t.Setenv("PATH", strings.SplitN(os.Getenv("PATH"), string(os.PathListSeparator), 2)[0])
|
||||
manager, _ := detectPackageManager(t.TempDir())
|
||||
assert.Equal(t, tt.manager, manager)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZypperTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
file string
|
||||
|
||||
+6
-1
@@ -151,11 +151,16 @@ func (a *Agent) handleSession(s ssh.Session) {
|
||||
s.Exit(1)
|
||||
return
|
||||
}
|
||||
// A legacy hub never sends a request, so the session is done once the payload is out.
|
||||
_ = s.Exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
var req common.HubRequest[cbor.RawMessage]
|
||||
if err := cbor.NewDecoder(s).Decode(&req); err != nil {
|
||||
// Fallback to legacy one-shot if the first decode fails
|
||||
// Only reachable for hubs that report >= MinVersionAgentResponse: a
|
||||
// malformed or truncated request falls back to the one-shot payload
|
||||
// so the peer still receives something parseable.
|
||||
if err2 := a.handleLegacyStats(s, hubVersion); err2 != nil {
|
||||
slog.Error("Error encoding stats (fallback)", "err", err2)
|
||||
s.Exit(1)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
@@ -972,3 +974,184 @@ func TestGetHubVersionConcurrent(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////
|
||||
/////////////// Session Protocol Tests //////////////////////////
|
||||
/////////////////////////////////////////////////////////////////
|
||||
|
||||
// dialTestAgentClient starts an agent SSH server on a private unix socket and
|
||||
// connects a real SSH client whose ClientVersion mimics a hub of the given
|
||||
// version, so session-protocol behavior can be exercised end to end.
|
||||
func dialTestAgentClient(t *testing.T, clientVersion string) *gossh.Client {
|
||||
t.Helper()
|
||||
|
||||
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(t, err)
|
||||
signer, err := gossh.NewSignerFromKey(privKey)
|
||||
require.NoError(t, err)
|
||||
sshPubKey, err := gossh.NewPublicKey(pubKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
|
||||
agent, err := NewAgent("")
|
||||
require.NoError(t, err)
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- agent.StartServer(ServerOptions{
|
||||
Network: "unix",
|
||||
Addr: socketFile,
|
||||
Keys: []gossh.PublicKey{sshPubKey},
|
||||
})
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = agent.StopServer()
|
||||
select {
|
||||
case <-errChan:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for the listener instead of a fixed sleep.
|
||||
require.Eventually(t, func() bool {
|
||||
conn, err := net.Dial("unix", socketFile)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
|
||||
client, err := gossh.Dial("unix", socketFile, &gossh.ClientConfig{
|
||||
User: "u",
|
||||
Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
Timeout: 4 * time.Second,
|
||||
ClientVersion: clientVersion,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
// waitSessionOutput runs readFn on a session stream and fails the test if it
|
||||
// does not finish quickly. A healthy session ends in milliseconds; the guard
|
||||
// exists so a handler that blocks (the bug this suite guards against) fails
|
||||
// the test instead of hanging it until the 70s idle timeout.
|
||||
func waitSessionOutput(t *testing.T, readFn func() ([]byte, error)) []byte {
|
||||
t.Helper()
|
||||
type result struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
resCh := make(chan result, 1)
|
||||
go func() {
|
||||
output, err := readFn()
|
||||
resCh <- result{output, err}
|
||||
}()
|
||||
select {
|
||||
case res := <-resCh:
|
||||
require.NoError(t, res.err)
|
||||
return res.output
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("session did not complete within 10s; handler appears to be blocked")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyHubGetsSinglePayload pins the legacy one-shot contract: a hub
|
||||
// older than MinVersionAgentResponse never sends a request, so the agent must
|
||||
// write exactly one stats payload, exit with status 0, and end the session
|
||||
// promptly. Regression target: the handler used to fall through into the
|
||||
// request decode after the legacy write, read an immediate EOF from the hub's
|
||||
// empty stdin, and send a second payload via the decode-failure fallback.
|
||||
// Covers all three routes into the legacy path: a pre-0.13 CBOR hub, a
|
||||
// pre-0.12 JSON hub, and a client whose version string cannot be parsed.
|
||||
func TestLegacyHubGetsSinglePayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
clientVersion string
|
||||
cborFormat bool
|
||||
}{
|
||||
{name: "legacy cbor hub 0.12.0", clientVersion: "SSH-2.0-beszel_0.12.0", cborFormat: true},
|
||||
{name: "legacy json hub 0.11.1", clientVersion: "SSH-2.0-beszel_0.11.1", cborFormat: false},
|
||||
{name: "unparsable client version", clientVersion: "SSH-2.0-OpenSSH_8.0", cborFormat: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := dialTestAgentClient(t, tt.clientVersion)
|
||||
|
||||
session, err := client.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = session.Close() }()
|
||||
|
||||
output := waitSessionOutput(t, func() ([]byte, error) {
|
||||
return session.Output("")
|
||||
})
|
||||
require.NotEmpty(t, output, "legacy hub must receive a stats payload")
|
||||
|
||||
// Exactly one payload: decode the first object, then require the
|
||||
// stream to be exhausted. A second write would leave bytes here.
|
||||
var data system.CombinedData
|
||||
if tt.cborFormat {
|
||||
dec := cbor.NewDecoder(bytes.NewReader(output))
|
||||
require.NoError(t, dec.Decode(&data), "payload must be valid CBOR")
|
||||
var extra any
|
||||
require.ErrorIs(t, dec.Decode(&extra), io.EOF,
|
||||
"legacy hub must receive exactly one payload, got trailing bytes")
|
||||
} else {
|
||||
dec := json.NewDecoder(bytes.NewReader(output))
|
||||
require.NoError(t, dec.Decode(&data), "payload must be valid JSON")
|
||||
var extra map[string]any
|
||||
require.ErrorIs(t, dec.Decode(&extra), io.EOF,
|
||||
"legacy hub must receive exactly one payload, got trailing bytes")
|
||||
}
|
||||
// ConnectionType is stamped by the SSH path itself, so it is a
|
||||
// deterministic (environment-independent) proof that the payload
|
||||
// came from the SSH handler.
|
||||
assert.Equal(t, system.ConnectionTypeSSH, data.Info.ConnectionType,
|
||||
"payload must be stamped as SSH-sourced")
|
||||
|
||||
// Output() returns an *ssh.ExitError when the handler exits
|
||||
// non-zero; reaching this point with no error means exit status 0.
|
||||
// (An s.Exit(1) from a failed legacy write would surface here.)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModernHubRequestResponse guards the new-protocol path against
|
||||
// regressions from the legacy-path changes: a hub at or above
|
||||
// MinVersionAgentResponse sends a CBOR request and must get exactly one
|
||||
// AgentResponse with populated system data and a clean exit.
|
||||
func TestModernHubRequestResponse(t *testing.T) {
|
||||
client := dialTestAgentClient(t, "SSH-2.0-beszel_0.21.0")
|
||||
|
||||
session, err := client.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = session.Close() }()
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
require.NoError(t, err)
|
||||
stdin, err := session.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, session.Shell())
|
||||
|
||||
// Same request shape the hub's SSH transport sends.
|
||||
require.NoError(t, cbor.NewEncoder(stdin).Encode(common.HubRequest[any]{Action: common.GetData}))
|
||||
require.NoError(t, stdin.Close())
|
||||
|
||||
output := waitSessionOutput(t, func() ([]byte, error) {
|
||||
return io.ReadAll(stdout)
|
||||
})
|
||||
|
||||
var resp common.AgentResponse
|
||||
require.NoError(t, cbor.Unmarshal(output, &resp), "response must be a single valid CBOR AgentResponse")
|
||||
assert.Empty(t, resp.Error)
|
||||
require.NotNil(t, resp.SystemData, "GetData response must carry system data")
|
||||
assert.Equal(t, system.ConnectionTypeSSH, resp.SystemData.Info.ConnectionType,
|
||||
"GetData response must be stamped as SSH-sourced")
|
||||
|
||||
// Wait() returns an *ssh.ExitError on non-zero exit; nil means status 0.
|
||||
require.NoError(t, session.Wait())
|
||||
}
|
||||
+9
-31
@@ -106,11 +106,16 @@ func (a *Agent) refreshSystemDetails() {
|
||||
|
||||
// total memory
|
||||
a.systemDetails.MemoryTotal = hostInfo.MemTotal
|
||||
if a.systemDetails.MemoryTotal == 0 {
|
||||
if v, err := mem.VirtualMemory(); err == nil {
|
||||
hostMemoryTotal := hostInfo.MemTotal
|
||||
if v, err := memoryVirtualMemory(); err == nil {
|
||||
hostMemoryTotal = v.Total
|
||||
if a.systemDetails.MemoryTotal == 0 {
|
||||
a.systemDetails.MemoryTotal = v.Total
|
||||
}
|
||||
}
|
||||
if metrics, ok := containerMemoryMetrics(hostMemoryTotal, a.forceUseCgroup); ok {
|
||||
a.systemDetails.MemoryTotal = metrics.Total
|
||||
}
|
||||
|
||||
// zfs
|
||||
if _, err := zfs.ARCSize(); err != nil {
|
||||
@@ -156,7 +161,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
}
|
||||
|
||||
// cpu metrics
|
||||
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
|
||||
cpuMetrics, err := getCpuMetrics(cacheTimeMs, a.forceUseCgroup)
|
||||
if err == nil {
|
||||
systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total)
|
||||
systemStats.CpuBreakdown = []float64{
|
||||
@@ -190,34 +195,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||
}
|
||||
|
||||
// memory
|
||||
if v, err := mem.VirtualMemory(); err == nil {
|
||||
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
|
||||
// swap
|
||||
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
|
||||
systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed)
|
||||
v.Used = used
|
||||
// if a.memCalc == "legacy" {
|
||||
// v.Used = v.Total - v.Free - v.Buffers - v.Cached
|
||||
// cacheBuff = v.Total - v.Free - v.Used
|
||||
// v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||
// }
|
||||
// subtract ZFS ARC size from used memory and add as its own category
|
||||
if a.zfs {
|
||||
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||
v.Used = v.Used - arcSize
|
||||
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
||||
}
|
||||
}
|
||||
if v.Total > 0 {
|
||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||
} else {
|
||||
v.UsedPercent = 0
|
||||
}
|
||||
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
||||
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
||||
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
||||
systemStats.MemPct = utils.TwoDecimals(v.UsedPercent)
|
||||
}
|
||||
a.updateMemoryStats(&systemStats)
|
||||
|
||||
// disk usage
|
||||
a.updateDiskUsage(&systemStats)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//go:build linux
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InCgroupV2 reports whether the given /proc/self/cgroup file lists the
|
||||
// unified hierarchy (a "0::<path>" entry).
|
||||
func InCgroupV2(path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "0::") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CgroupMountPoint finds a hierarchy's mount point in the given mountinfo file.
|
||||
// Use fstype "cgroup2" and an empty controller for v2, or "cgroup" and the
|
||||
// desired controller for v1. It returns the mount root, not a process's leaf.
|
||||
func CgroupMountPoint(path, fstype, controller string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
left, right, found := strings.Cut(line, " - ")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
post := strings.Fields(right)
|
||||
if len(post) == 0 || post[0] != fstype {
|
||||
continue
|
||||
}
|
||||
if controller != "" && !mountOptHas(post, controller) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(left)
|
||||
if len(fields) >= 5 {
|
||||
return unescapeMountPoint(fields[4])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mountOptHas checks the comma-separated super options after the separator.
|
||||
func mountOptHas(post []string, opt string) bool {
|
||||
if len(post) < 3 {
|
||||
return false
|
||||
}
|
||||
for o := range strings.SplitSeq(post[2], ",") {
|
||||
if o == opt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unescapeMountPoint(s string) string {
|
||||
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s)
|
||||
}
|
||||
|
||||
// ReadCgroupStat reads all unsigned counters from a cgroup key/value stat file
|
||||
// in one read. Malformed entries are omitted; callers must check required keys.
|
||||
func ReadCgroupStat(path string) (map[string]uint64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make(map[string]uint64)
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) != 2 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseUint(fields[1], 10, 64)
|
||||
if err == nil {
|
||||
values[fields[0]] = value
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ReadIntFile parses a signed decimal integer (e.g. a v1 CPU quota of -1).
|
||||
func ReadIntFile(path string) (int64, bool) {
|
||||
raw, ok := ReadStringFileOK(path)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//go:build linux
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInCgroupV2(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cgroup")
|
||||
assert.False(t, InCgroupV2(path))
|
||||
for _, tt := range []struct {
|
||||
name, contents string
|
||||
want bool
|
||||
}{
|
||||
{"v1", "3:cpu,cpuacct:/system.slice/agent.service\n2:memory:/\n", false},
|
||||
{"v2 service", "0::/system.slice/agent.service\n", true},
|
||||
{"hybrid", "2:memory:/\n0::/\n", true},
|
||||
{"empty", "", false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0o644))
|
||||
assert.Equal(t, tt.want, InCgroupV2(path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgroupMountPoint(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mountinfo")
|
||||
assert.Empty(t, CgroupMountPoint(path, "cgroup2", ""))
|
||||
contents := "malformed\n" +
|
||||
"30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu,cpuacct\n" +
|
||||
"31 25 0:27 / /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n" +
|
||||
`32 25 0:28 /guest /sys/fs/cgroup/unified\040root\134name rw - cgroup2 cgroup2 rw` + "\n"
|
||||
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
|
||||
assert.Equal(t, "/sys/fs/cgroup/cpu", CgroupMountPoint(path, "cgroup", "cpuacct"))
|
||||
assert.Equal(t, "/sys/fs/cgroup/memory", CgroupMountPoint(path, "cgroup", "memory"))
|
||||
assert.Equal(t, `/sys/fs/cgroup/unified root\name`, CgroupMountPoint(path, "cgroup2", ""))
|
||||
assert.Empty(t, CgroupMountPoint(path, "cgroup", "mem"))
|
||||
assert.Empty(t, CgroupMountPoint(path, "cgroup", "cpuset"))
|
||||
}
|
||||
|
||||
func TestReadCgroupStat(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "memory.stat")
|
||||
_, err := ReadCgroupStat(path)
|
||||
require.Error(t, err)
|
||||
contents := "anon 1024\nfile\t2048\ninactive_file 0\n" +
|
||||
"negative -1\noverflow 18446744073709551616\ninvalid abc\nmissing\nextra 1 2\n"
|
||||
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
|
||||
stat, err := ReadCgroupStat(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]uint64{"anon": 1024, "file": 2048, "inactive_file": 0}, stat)
|
||||
_, ok := stat["inactive_file"]
|
||||
assert.True(t, ok)
|
||||
_, ok = stat["usage_usec"]
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestReadIntFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "quota")
|
||||
_, ok := ReadIntFile(path)
|
||||
assert.False(t, ok)
|
||||
for _, tt := range []struct {
|
||||
contents string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{" -1\n", -1, true},
|
||||
{"100000\n", 100000, true},
|
||||
{"0", 0, true},
|
||||
{"max", 0, false},
|
||||
{"9223372036854775808", 9223372036854775807, false},
|
||||
} {
|
||||
t.Run(tt.contents, func(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0o644))
|
||||
value, ok := ReadIntFile(path)
|
||||
assert.Equal(t, tt.ok, ok)
|
||||
if ok {
|
||||
assert.Equal(t, tt.want, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ type Config struct {
|
||||
// Server is the DNS server to query (host or host:port, default port 53).
|
||||
// Only used when Protocol is "dns"; empty means use the system resolver.
|
||||
Server string `cbor:"5,keyasint,omitempty"`
|
||||
// SkipTLSVerify disables certificate verification for HTTPS targets, such as
|
||||
// those with self-signed certificates. Only used when Protocol is "http".
|
||||
SkipTLSVerify bool `cbor:"6,keyasint,omitempty" db:"skipTlsVerify"`
|
||||
}
|
||||
|
||||
// CertInfo holds details of the leaf TLS certificate presented by a target.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build testing
|
||||
|
||||
package hub_test
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build testing
|
||||
|
||||
package hub_test
|
||||
|
||||
import (
|
||||
|
||||
@@ -68,6 +68,10 @@ func bindNetworkMonitorsEvents(hub *Hub) {
|
||||
if protocol != "dns" {
|
||||
e.Record.Set("server", "")
|
||||
}
|
||||
// only http uses TLS verification - clear it for other protocols
|
||||
if protocol != "http" {
|
||||
e.Record.Set("skipTlsVerify", false)
|
||||
}
|
||||
ID := generateMonitorID(systemID, *monitorConfigFromRecord(e.Record))
|
||||
if ID != e.Record.Id {
|
||||
newRecord := copyMonitorToNewRecord(e.Record, ID)
|
||||
@@ -115,6 +119,8 @@ func monitorConfigFromRecord(record *core.Record) *monitor.Config {
|
||||
Port: uint16(record.GetInt("port")),
|
||||
Interval: uint16(record.GetInt("interval")),
|
||||
Server: record.GetString("server"),
|
||||
// SkipTLSVerify is a probe option, not part of the monitor's identity, so it does not affect the ID.
|
||||
SkipTLSVerify: record.GetBool("skipTlsVerify"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +145,7 @@ func copyMonitorToNewRecord(oldRecord *core.Record, newID string) *core.Record {
|
||||
collection := oldRecord.Collection()
|
||||
newRecord := core.NewRecord(collection)
|
||||
newRecord.Id = newID
|
||||
fields := []string{"system", "target", "protocol", "port", "server", "interval", "enabled"}
|
||||
fields := []string{"system", "target", "protocol", "port", "server", "interval", "enabled", "skipTlsVerify"}
|
||||
for _, field := range fields {
|
||||
newRecord.Set(field, oldRecord.Get(field))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build testing
|
||||
|
||||
package hub
|
||||
|
||||
import (
|
||||
@@ -293,20 +295,21 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
|
||||
|
||||
oldRecord := core.NewRecord(collection)
|
||||
oldRecord.Load(map[string]any{
|
||||
"system": "sys123",
|
||||
"target": "https://example.com",
|
||||
"protocol": "http",
|
||||
"port": 443,
|
||||
"server": "1.1.1.1",
|
||||
"interval": 60,
|
||||
"enabled": true,
|
||||
"res": 1200,
|
||||
"resAvg1h": 1300,
|
||||
"resMin1h": 900,
|
||||
"resMax1h": 1600,
|
||||
"loss1h": 5,
|
||||
"certInfo": map[string]any{"expires": 1800000000000},
|
||||
"updated": "2026-04-29 12:00:00.000Z",
|
||||
"system": "sys123",
|
||||
"target": "https://example.com",
|
||||
"protocol": "http",
|
||||
"port": 443,
|
||||
"server": "1.1.1.1",
|
||||
"interval": 60,
|
||||
"enabled": true,
|
||||
"skipTlsVerify": true,
|
||||
"res": 1200,
|
||||
"resAvg1h": 1300,
|
||||
"resMin1h": 900,
|
||||
"resMax1h": 1600,
|
||||
"loss1h": 5,
|
||||
"certInfo": map[string]any{"expires": 1800000000000},
|
||||
"updated": "2026-04-29 12:00:00.000Z",
|
||||
})
|
||||
|
||||
newRecord := copyMonitorToNewRecord(oldRecord, "next12345")
|
||||
@@ -317,6 +320,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
|
||||
assert.Equal(t, 443, newRecord.GetInt("port"))
|
||||
assert.Equal(t, "1.1.1.1", newRecord.GetString("server"))
|
||||
assert.True(t, newRecord.GetBool("enabled"))
|
||||
assert.True(t, newRecord.GetBool("skipTlsVerify"))
|
||||
assert.Contains(t, []string{"", "null"}, newRecord.GetString("certInfo"))
|
||||
assert.Zero(t, newRecord.GetFloat("res"))
|
||||
assert.Zero(t, newRecord.GetFloat("resAvg1h"))
|
||||
|
||||
@@ -177,6 +177,23 @@ func TestNetworkMonitorReconnectSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMonitorConfigsForSystemIncludesSkipTLSVerify(t *testing.T) {
|
||||
sys, app := newTestSystemWithHub(t)
|
||||
collection, err := app.FindCachedCollectionByNameOrId("network_monitors")
|
||||
require.NoError(t, err)
|
||||
record := core.NewRecord(collection)
|
||||
record.Load(map[string]any{
|
||||
"system": sys.Id, "target": "https://self-signed.example", "protocol": "http",
|
||||
"interval": 60, "enabled": true, "skipTlsVerify": true,
|
||||
})
|
||||
require.NoError(t, app.SaveNoValidate(record))
|
||||
|
||||
configs, err := sys.manager.GetMonitorConfigsForSystem(sys.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, configs, 1)
|
||||
require.True(t, configs[0].SkipTLSVerify, "full sync must keep the skip TLS verify option")
|
||||
}
|
||||
|
||||
func TestGetMonitorConfigsForSystemQueryError(t *testing.T) {
|
||||
sys, app := newTestSystemWithHub(t)
|
||||
_, err := app.DB().NewQuery("DROP TABLE network_monitors").Execute()
|
||||
|
||||
@@ -380,7 +380,7 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
||||
func (sm *SystemManager) GetMonitorConfigsForSystem(systemID string) ([]monitor.Config, error) {
|
||||
var configs []monitor.Config
|
||||
err := sm.hub.DB().
|
||||
NewQuery("SELECT id, target, protocol, port, interval, server FROM network_monitors WHERE system = {:system} AND enabled = true").
|
||||
NewQuery("SELECT id, target, protocol, port, interval, server, skipTlsVerify FROM network_monitors WHERE system = {:system} AND enabled = true").
|
||||
Bind(dbx.Params{"system": systemID}).
|
||||
All(&configs)
|
||||
return configs, err
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("network_monitors")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collection.Fields.Add(&core.BoolField{Id: "nm_skip_tls_verify", Name: "skipTlsVerify"})
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("network_monitors")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collection.Fields.RemoveByName("skipTlsVerify")
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from "@/components/ui/chart"
|
||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
import { hasChartValues, useYAxisWidth } from "./hooks"
|
||||
import { ChartNoValues } from "./chart-no-values"
|
||||
import type { AxisDomain } from "recharts/types/util/types"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
|
||||
@@ -118,6 +119,9 @@ export default function AreaChartDefault({
|
||||
if (displayData.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (!hasChartValues(displayData, dataPoints)) {
|
||||
return <ChartNoValues ref={ref} />
|
||||
}
|
||||
// if (logRender) {
|
||||
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import type { Ref } from "react"
|
||||
import Spinner from "@/components/spinner"
|
||||
|
||||
/**
|
||||
* Shown in place of a chart whose records contain no values (e.g. every probe failed).
|
||||
* The opacity-100 class hides ChartCard's loading spinner, same as a rendered chart.
|
||||
* Takes the chart's intersection observer ref so the chart keeps updating once values arrive.
|
||||
*/
|
||||
export function ChartNoValues({ ref }: { ref?: Ref<HTMLDivElement> }) {
|
||||
return (
|
||||
<div ref={ref} className="absolute inset-0">
|
||||
<Spinner msg={t`Waiting for enough records to display`} className="bg-card opacity-100" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,14 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
|
||||
}, [containerData])
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any series has a numeric value in the data. Without one, recharts renders no y-axis
|
||||
* ticks, so the y-axis width is never measured and the chart would stay hidden.
|
||||
*/
|
||||
export function hasChartValues<T>(data: T[], dataPoints?: { dataKey: (data: T) => unknown }[]) {
|
||||
return !!dataPoints?.some(({ dataKey }) => data.some((record) => typeof dataKey(record) === "number"))
|
||||
}
|
||||
|
||||
/** Sets the correct width of the y axis in recharts based on the longest label */
|
||||
export function useYAxisWidth() {
|
||||
const [yAxisWidth, setYAxisWidth] = useState(0)
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from "@/components/ui/chart"
|
||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||
import type { ChartOptions, SystemStatsRecord } from "@/types"
|
||||
import { useYAxisWidth } from "./hooks"
|
||||
import { hasChartValues, useYAxisWidth } from "./hooks"
|
||||
import { ChartNoValues } from "./chart-no-values"
|
||||
import type { AxisDomain } from "recharts/types/util/types"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
|
||||
@@ -141,7 +142,7 @@ export default function LineChartDefault({
|
||||
dataKey={dataPoint.dataKey}
|
||||
name={dataPoint.label}
|
||||
type="monotoneX"
|
||||
dot={dataPoint.dot || false}
|
||||
dot={dataPoint.dot ?? (connectNulls ? false : isolatedDot)}
|
||||
strokeWidth={1.5}
|
||||
stroke={color}
|
||||
strokeOpacity={dataPoint.strokeOpacity}
|
||||
@@ -160,6 +161,9 @@ export default function LineChartDefault({
|
||||
if (displayData.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (!hasChartValues(displayData, dataPoints)) {
|
||||
return <ChartNoValues ref={ref} />
|
||||
}
|
||||
// if (logRender) {
|
||||
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||
// }
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -40,6 +41,7 @@ type MonitorValues = {
|
||||
port: number
|
||||
server: string
|
||||
interval: string
|
||||
skipTlsVerify: boolean
|
||||
}
|
||||
|
||||
type NormalizedMonitorValues = Omit<MonitorValues, "system" | "interval"> & {
|
||||
@@ -63,6 +65,7 @@ const NormalizedMonitorValuesSchema = v.pipe(
|
||||
port: v.number(),
|
||||
server: v.pipe(v.string(), v.trim()),
|
||||
interval: MonitorIntervalSchema,
|
||||
skipTlsVerify: v.boolean(),
|
||||
}),
|
||||
v.transform((input): NormalizedMonitorValues => {
|
||||
let { protocol, port } = input
|
||||
@@ -84,6 +87,8 @@ const NormalizedMonitorValuesSchema = v.pipe(
|
||||
// Only DNS monitors use a custom server; clear it for other protocols.
|
||||
server: protocol === "dns" ? input.server : "",
|
||||
interval: input.interval,
|
||||
// Only HTTPS targets use TLS; clear it for everything else.
|
||||
skipTlsVerify: protocol === "http" && isHttpsTarget(httpTarget) && input.skipTlsVerify,
|
||||
}
|
||||
}),
|
||||
v.forward(
|
||||
@@ -121,6 +126,10 @@ const BulkMonitorSchema = v.object({
|
||||
server: v.optional(v.pipe(v.string(), v.trim())),
|
||||
})
|
||||
|
||||
function isHttpsTarget(target: string) {
|
||||
return /^https:\/\//i.test(target)
|
||||
}
|
||||
|
||||
function normalizeHttpTarget(target: string, port = 0) {
|
||||
const useExplicitPort = port > 0 && port !== 80 && port !== 443
|
||||
const hasOriginOnlyTarget = /^https?:\/\/[^/?#]+$/i.test(target)
|
||||
@@ -198,6 +207,7 @@ function parseBulkMonitorLine(line: string, lineNumber: number, system: string)
|
||||
port: parsed.output.port ? Number(parsed.output.port) : 0,
|
||||
server: parsed.output.server || "",
|
||||
interval: parsed.output.interval || `${defaultInterval}`,
|
||||
skipTlsVerify: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -720,6 +730,7 @@ function MonitorDialogContent({
|
||||
const [port, setPort] = useState(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
|
||||
const [server, setServer] = useState(monitor?.protocol === "dns" ? (monitor.server ?? "") : "")
|
||||
const [monitorInterval, setMonitorInterval] = useState(String(monitor?.interval ?? defaultInterval))
|
||||
const [skipTlsVerify, setSkipTlsVerify] = useState(monitor?.skipTlsVerify ?? false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedSystemId, setSelectedSystemId] = useState(monitor?.system ?? "")
|
||||
const [selectedSystemIds, setSelectedSystemIds] = useState<Set<string>>(new Set())
|
||||
@@ -728,6 +739,8 @@ function MonitorDialogContent({
|
||||
const { t } = useLingui()
|
||||
const isEditing = !!monitor
|
||||
const dnsTargetIsIp = protocol === "dns" && isIpAddress(target.trim())
|
||||
// Bare hostnames are normalized to https, so they can also skip verification.
|
||||
const usesTls = protocol === "http" && !/^http:\/\//i.test(target.trim())
|
||||
|
||||
// When the dialog is opened, initialize form fields with monitor values (if editing) or defaults (if adding).
|
||||
useEffect(() => {
|
||||
@@ -740,6 +753,7 @@ function MonitorDialogContent({
|
||||
setPort(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
|
||||
setServer(monitor?.protocol === "dns" ? (monitor.server ?? "") : "")
|
||||
setMonitorInterval(String(monitor?.interval ?? defaultInterval))
|
||||
setSkipTlsVerify(monitor?.skipTlsVerify ?? false)
|
||||
setSelectedSystemId(monitor?.system ?? "")
|
||||
setSelectedSystemIds(new Set())
|
||||
setLoading(false)
|
||||
@@ -761,6 +775,7 @@ function MonitorDialogContent({
|
||||
port: protocol === "tcp" ? Number(port) : 0,
|
||||
server: protocol === "dns" ? server.trim() : "",
|
||||
interval: monitorInterval,
|
||||
skipTlsVerify,
|
||||
},
|
||||
monitor ? monitor.enabled : true
|
||||
)
|
||||
@@ -910,6 +925,22 @@ function MonitorDialogContent({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{usesTls && (
|
||||
<label
|
||||
htmlFor="monitor-skip-tls-verify"
|
||||
className="flex items-center justify-between gap-4 cursor-pointer rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 px-3.5 py-3"
|
||||
>
|
||||
<div className="grid gap-1 select-none">
|
||||
<span className="text-sm font-medium">
|
||||
<Trans>Ignore TLS certificate errors</Trans>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans>Use for self-signed certificates.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<Switch id="monitor-skip-tls-verify" checked={skipTlsVerify} onCheckedChange={setSkipTlsVerify} />
|
||||
</label>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{!isEditing && onOpenBulkAdd && (
|
||||
<Button
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PlayCircleIcon,
|
||||
CopyIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldOffIcon,
|
||||
} from "lucide-react"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import type { NetworkMonitorRecord, SystemRecord } from "@/types"
|
||||
@@ -50,6 +51,8 @@ const protocolColors: Record<string, string> = {
|
||||
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
|
||||
http: "bg-green-500/15! text-green-700 dark:text-green-400",
|
||||
dns: "bg-amber-500/15! text-amber-600 dark:text-amber-400",
|
||||
// HTTP monitors that skip TLS certificate verification
|
||||
insecure: "bg-orange-500/15! text-orange-600 dark:text-orange-400",
|
||||
}
|
||||
|
||||
const SYSTEM_STATUS_COLORS = {
|
||||
@@ -175,8 +178,16 @@ export function getMonitorColumns(
|
||||
meta: { label: t`Protocol` },
|
||||
accessorFn: (record) => record.protocol,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
cell: ({ row, getValue }) => {
|
||||
const protocol = getValue() as string
|
||||
if (row.original.skipTlsVerify) {
|
||||
return (
|
||||
<Badge className={cn("uppercase gap-1", protocolColors.insecure)}>
|
||||
{protocol}
|
||||
<ShieldOffIcon className="size-3" />
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return <Badge className={cn("uppercase", protocolColors[protocol])}>{protocol}</Badge>
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getMonitorTarget, monitorGapRecord } from "@/lib/network-monitor-utils"
|
||||
import LineChartDefault, { isolatedDot } from "@/components/charts/line-chart"
|
||||
import LineChartDefault from "@/components/charts/line-chart"
|
||||
import type { DataPoint } from "@/components/charts/line-chart"
|
||||
import { decimalString, formatMicroseconds, matchesFilterGroups, parseFilterGroups, toFixedFloat } from "@/lib/utils"
|
||||
import { $monitorFilter } from "@/lib/stores"
|
||||
@@ -61,7 +61,8 @@ function MonitorChart({
|
||||
const points: DataPoint<NetworkMonitorStatsRecord>[] = []
|
||||
const visibleIDs: string[] = []
|
||||
const filterGroups = parseFilterGroups(filter)
|
||||
const dot = chartData.chartTime === "1m"
|
||||
// show every point at 1m; otherwise the chart default draws only isolated points
|
||||
const dot = chartData.chartTime === "1m" || undefined
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = sortedMonitors[i]
|
||||
const label = getLabel(p)
|
||||
@@ -138,7 +139,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
||||
record.stats?.[monitor?.id ?? ""]?.[metric] ?? null
|
||||
const avgPoint = {
|
||||
label: "Avg",
|
||||
dot: isolatedDot,
|
||||
dataKey: dataFn("res_avg"),
|
||||
color: 1,
|
||||
order: 0,
|
||||
@@ -150,7 +150,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
||||
return [
|
||||
{
|
||||
label: "Max",
|
||||
dot: isolatedDot,
|
||||
dataKey: dataFn("res_max"),
|
||||
color: 3,
|
||||
order: 0,
|
||||
@@ -158,7 +157,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
||||
avgPoint,
|
||||
{
|
||||
label: "Min",
|
||||
dot: isolatedDot,
|
||||
dataKey: dataFn("res_min"),
|
||||
color: 2,
|
||||
order: 2,
|
||||
|
||||
@@ -151,6 +151,7 @@ export function useSystemData(id: string) {
|
||||
return
|
||||
}
|
||||
let isFirst = true
|
||||
const { expectedInterval } = chartTimeData["1m"]
|
||||
pb.realtime
|
||||
.subscribe(
|
||||
`rt_metrics`,
|
||||
@@ -168,9 +169,9 @@ export function useSystemData(id: string) {
|
||||
setContainerData(containerPoint ? [containerPoint] : [])
|
||||
return
|
||||
}
|
||||
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
|
||||
setSystemStats((prev) => appendData(prev, [statsPoint], expectedInterval, 60))
|
||||
if (containerPoint) {
|
||||
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
|
||||
setContainerData((prev) => appendData(prev, [containerPoint], expectedInterval, 60))
|
||||
}
|
||||
},
|
||||
{ query: { system: system.id } }
|
||||
|
||||
@@ -82,7 +82,7 @@ async function fetchMonitorStats(
|
||||
}
|
||||
|
||||
const NETWORK_MONITOR_FIELDS =
|
||||
"id,system,target,protocol,port,server,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated"
|
||||
"id,system,target,protocol,port,server,interval,skipTlsVerify,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated"
|
||||
|
||||
interface UseNetworkMonitorsProps {
|
||||
systemId?: string
|
||||
|
||||
Vendored
+2
@@ -694,6 +694,8 @@ export interface NetworkMonitorRecord {
|
||||
protocol: "icmp" | "tcp" | "http" | "dns"
|
||||
port: number
|
||||
server: string
|
||||
/** Skip TLS certificate verification for HTTPS targets. */
|
||||
skipTlsVerify: boolean
|
||||
res: number
|
||||
resMin1h: number
|
||||
resMax1h: number
|
||||
|
||||
@@ -2,9 +2,9 @@ apiVersion: v1
|
||||
description: Installs beszel-agent in kubernetes
|
||||
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
|
||||
name: beszel-agent
|
||||
appVersion: "0.20.0"
|
||||
appVersion: "0.21.0"
|
||||
# Bump this version when publishing chart changes.
|
||||
version: 0.1.7
|
||||
version: 0.1.8
|
||||
sources:
|
||||
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
|
||||
- https://www.beszel.dev/
|
||||
|
||||
@@ -80,7 +80,7 @@ Essential parameters to configure:
|
||||
| `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key |
|
||||
| `secret.tokenKey` | `token` | Key name in the secret for the authentication token |
|
||||
| `image.repository` | `henrygd/beszel-agent` | Container image |
|
||||
| `image.tag` | Chart AppVersion (0.20.0) | Image version |
|
||||
| `image.tag` | Chart AppVersion (0.21.0) | Image version |
|
||||
| `hostNetwork` | `false` | Use host network for network monitoring |
|
||||
| `tolerations` | Allows all taints | Tolerations for running on tainted nodes |
|
||||
|
||||
@@ -385,7 +385,7 @@ helm upgrade beszel-agent ./beszel-agent \
|
||||
|
||||
# Change image version
|
||||
helm upgrade beszel-agent ./beszel-agent \
|
||||
--set image.tag="0.20.0"
|
||||
--set image.tag="0.21.0"
|
||||
```
|
||||
|
||||
### Restart All Agents
|
||||
@@ -522,7 +522,7 @@ kubectl get secret beszel-agent -o jsonpath='{.data.ssh-key}' | base64 -d
|
||||
## Chart Information
|
||||
|
||||
- **Chart Version**: 0.1.0
|
||||
- **App Version**: 0.20.0
|
||||
- **App Version**: 0.21.0
|
||||
- **Kubernetes Version**: 1.19+
|
||||
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ apiVersion: v1
|
||||
description: Installs beszel-hub in kubernetes
|
||||
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
|
||||
name: beszel-hub
|
||||
appVersion: "0.20.0"
|
||||
appVersion: "0.21.0"
|
||||
# Bump this version when publishing chart changes.
|
||||
version: 0.1.7
|
||||
version: 0.1.8
|
||||
sources:
|
||||
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
|
||||
- https://www.beszel.dev/
|
||||
|
||||
@@ -47,7 +47,7 @@ Key configuration options in `values.yaml`:
|
||||
|-----------|---------|-------------|
|
||||
| `replicaCount` | `1` | Number of Beszel Hub replicas |
|
||||
| `image.repository` | `henrygd/beszel` | Container image repository |
|
||||
| `image.tag` | Chart AppVersion (0.20.0) | Container image tag |
|
||||
| `image.tag` | Chart AppVersion (0.21.0) | Container image tag |
|
||||
| `image.pullPolicy` | `IfNotPresent` | Image pull policy |
|
||||
| `service.port` | `8090` | Service port |
|
||||
| `persistentVolumeClaim.enabled` | `true` | Enable persistent volume |
|
||||
@@ -169,7 +169,7 @@ tolerations:
|
||||
```yaml
|
||||
replicaCount: 3
|
||||
image:
|
||||
tag: "0.20.0"
|
||||
tag: "0.21.0"
|
||||
service:
|
||||
type: LoadBalancer
|
||||
ingress:
|
||||
@@ -330,7 +330,7 @@ By default, Beszel Hub uses a PersistentVolumeClaim for data storage. Ensure you
|
||||
## Chart Information
|
||||
|
||||
- **Chart Version**: 0.1.0
|
||||
- **App Version**: 0.20.0
|
||||
- **App Version**: 0.21.0
|
||||
- **Kubernetes Version**: 1.19+
|
||||
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)
|
||||
|
||||
|
||||
Reference in new issue
Block a user