Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/codeql-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ jobs:
set -euo pipefail
test -z "$(git status --porcelain)"
git merge-base --is-ancestor c8663d12dd253ef13258750dca056d4b1219fc10 HEAD
grep -Fq 'VERSION_OVERRIDE: v0.13.28' .github/workflows/validate.yml
grep -Fq 'VERSION_OVERRIDE: v0.13.29' .github/workflows/validate.yml
grep -Fq 'SHA256SUMS' scripts/package
grep -Fq 'bash scripts/verify-package "$linux_asset"' .github/workflows/validate.yml
grep -Fq 'bash scripts/verify-package dist/artifacts/node-agent-0.13.28.tar.gz' .github/workflows/security-release-gate.yml
grep -Fq 'bash scripts/verify-package dist/artifacts/node-agent-0.13.29.tar.gz' .github/workflows/security-release-gate.yml

- name: Install the release Go toolchain
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
Expand All @@ -68,7 +68,7 @@ jobs:
export GOFLAGS=-mod=vendor
mkdir -p "$GITHUB_WORKSPACE/bin"
CGO_ENABLED=0 go build -trimpath -tags 'netgo osusergo' \
-ldflags='-w -s -X main.VERSION=v0.13.28' \
-ldflags='-w -s -X main.VERSION=v0.13.29' \
-o "$GITHUB_WORKSPACE/bin/node-agent" ./

- name: Analyze without publishing temporary alerts
Expand Down
22 changes: 11 additions & 11 deletions .github/workflows/security-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ jobs:
timeout-minutes: 120
env:
DAPPER_IMAGE: pasturestack/node-agent-dapper:${{ github.sha }}
RUNTIME_IMAGE: pasturestack/node-agent:v0.13.28
RUNTIME_IMAGE: pasturestack/node-agent:v0.13.29
TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
VERSION_OVERRIDE: v0.13.28
VERSION_OVERRIDE: v0.13.29
steps:
- name: Check out candidate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -50,20 +50,20 @@ jobs:
set -euo pipefail
CROSS=1 VERSION_OVERRIDE="$VERSION_OVERRIDE" make DAPPER_IMAGE="$DAPPER_IMAGE" ci
make DAPPER_IMAGE="$DAPPER_IMAGE" IMAGE_NAME=pasturestack/node-agent \
TAG=v0.13.28 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image
test -s dist/artifacts/node-agent-0.13.28.tar.gz
test -s dist/artifacts/node-agent-0.13.28-windows-amd64.zip
TAG=v0.13.29 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image
test -s dist/artifacts/node-agent-0.13.29.tar.gz
test -s dist/artifacts/node-agent-0.13.29-windows-amd64.zip
test "$(cat dist/image)" = "$RUNTIME_IMAGE"
gzip -t dist/artifacts/node-agent-0.13.28.tar.gz
bash scripts/verify-package dist/artifacts/node-agent-0.13.28.tar.gz
unzip -t dist/artifacts/node-agent-0.13.28-windows-amd64.zip
sha256sum dist/artifacts/node-agent-0.13.28.tar.gz \
dist/artifacts/node-agent-0.13.28-windows-amd64.zip \
gzip -t dist/artifacts/node-agent-0.13.29.tar.gz
bash scripts/verify-package dist/artifacts/node-agent-0.13.29.tar.gz
unzip -t dist/artifacts/node-agent-0.13.29-windows-amd64.zip
sha256sum dist/artifacts/node-agent-0.13.29.tar.gz \
dist/artifacts/node-agent-0.13.29-windows-amd64.zip \
bin/node-agent > evidence/product-artifacts.sha256
docker image inspect "$RUNTIME_IMAGE" > evidence/runtime-image-inspect.json
docker run --rm --entrypoint /usr/bin/node-agent "$RUNTIME_IMAGE" --version \
> evidence/runtime-version.txt
grep -Fxq 'node-agent version v0.13.28' evidence/runtime-version.txt
grep -Fxq 'node-agent version v0.13.29' evidence/runtime-version.txt

- name: Verify reproducible Linux binary
shell: bash
Expand Down
66 changes: 55 additions & 11 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ on:
branches:
- 'verification/node-agent-*'
workflow_dispatch:
inputs:
validation_scope:
description: 'Full CI or focused host-disk tests and native cross-platform packages'
type: choice
required: true
default: full
options:
- full
- host-disk

permissions:
contents: read
Expand All @@ -30,37 +39,72 @@ jobs:
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
test -z "$(git status --short)"

- name: Install the release Go toolchain for host-disk validation
if: github.event_name == 'workflow_dispatch' && inputs.validation_scope == 'host-disk'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.27.2
check-latest: false
cache: false

- name: Test and package immutable artifacts
id: package
env:
CROSS: "1"
VERSION_OVERRIDE: v0.13.28
VERSION_OVERRIDE: v0.13.29
VALIDATION_SCOPE: ${{ inputs.validation_scope || 'full' }}
run: |
set -euo pipefail
make ci
linux_asset="dist/artifacts/node-agent-0.13.28.tar.gz"
windows_asset="dist/artifacts/node-agent-0.13.28-windows-amd64.zip"
case "$VALIDATION_SCOPE" in
full)
make ci
;;
host-disk)
export GOTOOLCHAIN=local GOTELEMETRY=off GOWORK=off
export GOFLAGS=-mod=vendor GOPROXY=off GOSUMDB=off
export GOOS=linux GOARCH=amd64 GOAMD64=v1 CGO_ENABLED=0
test "$(go version)" = 'go version go1.27.2 linux/amd64'
test -z "$(gofmt -l core/hostinfo/disk_info_linux.go core/hostinfo/disk_info_linux_test.go)"
go test -count=1 -v ./core/hostinfo ./core/ping
bash scripts/build
bash scripts/package
;;
*)
printf 'Unknown validation scope: %s\n' "$VALIDATION_SCOPE" >&2
exit 1
;;
esac
linux_asset="dist/artifacts/node-agent-0.13.29.tar.gz"
windows_asset="dist/artifacts/node-agent-0.13.29-windows-amd64.zip"
test -s "$linux_asset"
test -s "$windows_asset"
gzip -t "$linux_asset"
bash scripts/verify-package "$linux_asset"
unzip -t "$windows_asset"
if [ "$VALIDATION_SCOPE" = host-disk ]; then
package_id=$(git rev-parse --short=32 HEAD)
tar -xOzf "$linux_asset" "$package_id/bin/node-agent" | cmp bin/linux/amd64/node-agent -
unzip -p "$windows_asset" pasturestack/node-agent.exe | cmp bin/windows/amd64/node-agent.exe -
unzip -p "$windows_asset" pasturestack/startup_node-agent.ps1 | cmp package/startup_agent.ps1 -
sha256sum bin/linux/amd64/node-agent bin/windows/amd64/node-agent.exe package/startup_agent.ps1
fi
(
cd dist/artifacts
sha256sum \
node-agent-0.13.28.tar.gz \
node-agent-0.13.28-windows-amd64.zip >SHA256SUMS
node-agent-0.13.29.tar.gz \
node-agent-0.13.29-windows-amd64.zip >SHA256SUMS
sha256sum -c SHA256SUMS
)
printf 'NODE_AGENT_VALIDATION_OK source=%s version=%s artifacts=linux,windows\n' \
"$GITHUB_SHA" "$VERSION_OVERRIDE"
printf 'NODE_AGENT_VALIDATION_OK source=%s version=%s scope=%s artifacts=linux,windows\n' \
"$GITHUB_SHA" "$VERSION_OVERRIDE" "$VALIDATION_SCOPE"

- name: Retain reviewed release candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: node-agent-0.13.28
name: node-agent-0.13.29
path: |
dist/artifacts/node-agent-0.13.28.tar.gz
dist/artifacts/node-agent-0.13.28-windows-amd64.zip
dist/artifacts/node-agent-0.13.29.tar.gz
dist/artifacts/node-agent-0.13.29-windows-amd64.zip
dist/artifacts/SHA256SUMS
if-no-files-found: error
retention-days: 30
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ From a Docker-capable Linux host:
make test
make build
make package
make package-image IMAGE_NAME=pasturestack/node-agent TAG=poc
make package-image IMAGE_NAME=pasturestack/node-agent TAG=v0.13.29
```

Packaging is local only and does not push an image. See [COMPATIBILITY.md](COMPATIBILITY.md), [SECURITY.md](SECURITY.md), and [ORIGIN.md](ORIGIN.md).
Expand All @@ -33,14 +33,22 @@ client 0.5, gopsutil v4.26, mapstructure v2.5, netlink v1.3, and netns v0.0.5.
The retired AWS SDK v1, Aliyungo, root Docker module, GOPATH/Godeps, and Trash
dependency paths are not part of the build.

`VERSION_OVERRIDE=v0.13.28 make package` produces the deterministic Linux asset `node-agent-0.13.28.tar.gz`. The archive carries the legacy SHA-1 and current SHA-256 manifests required by the authenticated configcontent installer. The producer release is published by this repository; Server can embed that verified archive and distribute it through the existing configuration channel.
`VERSION_OVERRIDE=v0.13.29 make package` produces the deterministic Linux asset `node-agent-0.13.29.tar.gz`. The archive carries the legacy SHA-1 and current SHA-256 manifests required by the authenticated configcontent installer. The producer release is published by this repository; Server can embed that verified archive and distribute it through the existing configuration channel.

On Linux, Node supervises the separately maintained `host-api` producer at `${PASTURESTACK_HOME:-${CATTLE_HOME:-/var/lib/pasturestack}}/bin/host-api`. Host API `0.38.5` must be installed before the Node package by the existing `pyagent` package sequence. Node verifies its exact version, forwards the current agent/host identity only through process environment, restarts an exited child, and binds its lifetime to Node. Missing or incompatible producers never fall back to the embedded legacy Host API. Stream authorization, target binding, and private durable terminal evidence remain owned by the Host API producer.

Windows retains the existing embedded compatibility path; this release does not provide Windows stream delegation or durable terminal evidence support. A replacement Windows bootstrap image and upgrade/rollback tests are still required before Windows hosts are supported. The Server-distributed Windows artifact remains independently pinned to `0.13.27`; the Linux `0.13.28` candidate and CI cross-builds do not establish new Windows runtime acceptance.
Windows retains the existing embedded compatibility path; this release does not provide Windows stream delegation or durable terminal evidence support. A replacement Windows bootstrap image and upgrade/rollback tests are still required before Windows hosts are supported. The Server-distributed Windows artifact remains independently pinned to `0.13.27`; Linux `0.13.29` and CI cross-builds do not establish new Windows runtime acceptance.

The `host.port.check` event performs a read-only host-port preflight through the existing agent event channel. It reports Docker bindings from running and stopped containers and, on Linux, listening TCP/UDP sockets visible through the existing host `/proc` mount. Incomplete host socket inspection is reported as unknown; it is never presented as an available port.

Linux disk information reads filesystem usage through the root of the process
whose mounts are enumerated, including when the agent has a separate mount
namespace. Direct host execution retains direct paths. Read-only Snap filesystem
images are excluded from disk capacity; an unreadable mount reports a collection
warning while preserving other successfully collected filesystems and mount points.
Host `localStorageMb` reports available space in whole MiB, not total disk capacity;
`CATTLE_LOCAL_STORAGE_MB_OVERRIDE` retains priority over the measured value.

The test harness starts a disposable inner Docker daemon and generates its BusyBox image, build contexts, and Git fixtures locally. It does not mount the host Docker socket or depend on mutable registry images and retired external test endpoints.

## License and attribution
Expand Down
14 changes: 3 additions & 11 deletions core/hostinfo/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,12 @@ func (d DiskCollector) GetData() (map[string]interface{}, error) {
"dockerStorageDriver": infoData.Info.Driver,
}

mfs, err := d.getMachineFilesystems(infoData)
fileSystems, mountPoints, err := d.collectDiskInfo(infoData)
data["fileSystems"] = fileSystems
data["mountPoints"] = mountPoints
if err != nil {
return data, errors.Wrap(err, constants.DiskGetDataError+"failed get filesystem info")
}
for key, value := range mfs {
data["fileSystems"].(map[string]interface{})[key] = value
}
mp, err := d.getMountPoints()
if err != nil {
return data, errors.Wrap(err, constants.DiskGetDataError+"failed get mountpoint info")
}
for key, value := range mp {
data["mountPoints"].(map[string]interface{})[key] = value
}

return data, nil
}
Expand Down
50 changes: 25 additions & 25 deletions core/hostinfo/disk_info.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package hostinfo

import (
"errors"
"math"
"path/filepath"
"strings"

"github.com/PastureStack/node-agent/model"
"github.com/PastureStack/node-agent/utilities/utils"
"github.com/shirou/gopsutil/v4/disk"
"math"
"strings"
)

func (d DiskCollector) convertUnits(number uint64) float64 {
Expand Down Expand Up @@ -42,43 +45,40 @@ func (d DiskCollector) includeInFilesystem(infoData model.InfoData, device strin
return include
}

func (d DiskCollector) getMountPoints() (map[string]interface{}, error) {
data := map[string]interface{}{}
func (d DiskCollector) collectDiskInfo(infoData model.InfoData) (map[string]interface{}, map[string]interface{}, error) {
fileSystems := map[string]interface{}{}
mountPoints := map[string]interface{}{}
partitions, err := disk.Partitions(false)
if err != nil {
return data, err
return fileSystems, mountPoints, err
}
root := diskUsageRoot()
var usageErrors []error
for _, partition := range partitions {
usage, err := disk.Usage(partition.Mountpoint)
// Snap packages are read-only filesystem images, not host disk capacity.
if partition.Fstype == "squashfs" {
continue
}
mountpoint := partition.Mountpoint
if root != "" {
mountpoint = filepath.Join(root, mountpoint)
}
usage, err := disk.Usage(mountpoint)
if err != nil {
return map[string]interface{}{}, err
usageErrors = append(usageErrors, err)
continue
}
data[partition.Device] = map[string]interface{}{
mountPoints[partition.Device] = map[string]interface{}{
"free": d.convertUnits(usage.Free),
"total": d.convertUnits(usage.Total),
"used": d.convertUnits(usage.Used),
"percentage": usage.UsedPercent,
}
}
return data, nil
}

func (d DiskCollector) getMachineFilesystems(infoData model.InfoData) (map[string]interface{}, error) {
data := map[string]interface{}{}
partitions, err := disk.Partitions(false)
if err != nil {
return data, err
}
for _, partition := range partitions {
if d.includeInFilesystem(infoData, partition.Device) {
usage, err := disk.Usage(partition.Mountpoint)
if err != nil {
return map[string]interface{}{}, err
}
data[utils.InterfaceToString(partition.Device)] = map[string]interface{}{
fileSystems[partition.Device] = map[string]interface{}{
"capacity": d.convertUnits(usage.Total),
}
}
}
return data, nil
return fileSystems, mountPoints, errors.Join(usageErrors...)
}
82 changes: 82 additions & 0 deletions core/hostinfo/disk_info_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//go:build linux

package hostinfo

import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
)

// gopsutil enumerates /proc/1's mounts by default, even when the agent has
// another mount namespace. Resolve usage through that same process's root.
func diskUsageRoot() string {
proc := os.Getenv("HOST_PROC")
if proc == "" {
proc = "/proc"
}
process := filepath.Join(proc, "1")
if override := os.Getenv("HOST_PROC_MOUNTINFO"); override != "" {
process = filepath.Dir(override)
} else if !hasDiskMountTable(process) {
// Match gopsutil's fallback when PID 1's mount table is unavailable.
process = filepath.Join(proc, "self")
}

hostNamespace, hostErr := diskMountNamespace(process)
selfNamespace, selfErr := diskMountNamespace(filepath.Join(proc, "self"))
if hostErr == nil && selfErr == nil && hostNamespace == selfNamespace {
// Preserve direct host operation without requiring access to PID 1's root.
return ""
}
return filepath.Join(process, "root")
}

// The configured proc directory is operator-owned, like gopsutil's mount-table
// source. Read only the fixed namespace link within it; its target is an
// identifier, never a filesystem path to follow (proc namespace links are magic).
func diskMountNamespace(process string) (uint64, error) {
directory, err := os.OpenRoot(process)
if err != nil {
return 0, err
}
defer directory.Close()
identifier, err := directory.Readlink("ns/mnt")
if err != nil {
return 0, err
}
if !strings.HasPrefix(identifier, "mnt:[") || !strings.HasSuffix(identifier, "]") {
return 0, os.ErrInvalid
}
value := identifier[len("mnt:[") : len(identifier)-1]
for _, digit := range value {
if digit < '0' || digit > '9' {
return 0, os.ErrInvalid
}
}
namespace, err := strconv.ParseUint(value, 10, 64)
if err != nil || namespace == 0 {
return 0, os.ErrInvalid
}
return namespace, nil
}

func hasDiskMountTable(process string) bool {
return hasDiskMountTableWithOpen(process, os.Open)
}

func hasDiskMountTableWithOpen(process string, openFile func(string) (*os.File, error)) bool {
file, err := openFile(filepath.Join(process, "mountinfo"))
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// Match gopsutil for every open path error, including permission errors.
file, err = openFile(filepath.Join(process, "mounts"))
}
if err != nil {
return false
}
file.Close()
return true
}
Loading
Loading