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
4 changes: 2 additions & 2 deletions .github/workflows/codeql-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
set -euo pipefail
test -z "$(git status --porcelain)"
git merge-base --is-ancestor c8663d12dd253ef13258750dca056d4b1219fc10 HEAD
grep -Fq 'VERSION_OVERRIDE: v0.13.23' .github/workflows/validate.yml
grep -Fq 'VERSION_OVERRIDE: v0.13.24' .github/workflows/validate.yml

- name: Install the release Go toolchain
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
Expand All @@ -65,7 +65,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.23' \
-ldflags='-w -s -X main.VERSION=v0.13.24' \
-o "$GITHUB_WORKSPACE/bin/node-agent" ./

- name: Analyze without publishing temporary alerts
Expand Down
20 changes: 10 additions & 10 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.23
RUNTIME_IMAGE: pasturestack/node-agent:v0.13.24
TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
VERSION_OVERRIDE: v0.13.23
VERSION_OVERRIDE: v0.13.24
steps:
- name: Check out candidate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -49,19 +49,19 @@ 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.23 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image
test -s dist/artifacts/node-agent-0.13.23.tar.gz
test -s dist/artifacts/node-agent-0.13.23-windows-amd64.zip
TAG=v0.13.24 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image
test -s dist/artifacts/node-agent-0.13.24.tar.gz
test -s dist/artifacts/node-agent-0.13.24-windows-amd64.zip
test "$(cat dist/image)" = "$RUNTIME_IMAGE"
gzip -t dist/artifacts/node-agent-0.13.23.tar.gz
unzip -t dist/artifacts/node-agent-0.13.23-windows-amd64.zip
sha256sum dist/artifacts/node-agent-0.13.23.tar.gz \
dist/artifacts/node-agent-0.13.23-windows-amd64.zip \
gzip -t dist/artifacts/node-agent-0.13.24.tar.gz
unzip -t dist/artifacts/node-agent-0.13.24-windows-amd64.zip
sha256sum dist/artifacts/node-agent-0.13.24.tar.gz \
dist/artifacts/node-agent-0.13.24-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.23' evidence/runtime-version.txt
grep -Fxq 'node-agent version v0.13.24' evidence/runtime-version.txt

- name: Verify reproducible Linux binary
shell: bash
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,32 +34,32 @@ jobs:
id: package
env:
CROSS: "1"
VERSION_OVERRIDE: v0.13.23
VERSION_OVERRIDE: v0.13.24
run: |
set -euo pipefail
make ci
linux_asset="dist/artifacts/node-agent-0.13.23.tar.gz"
windows_asset="dist/artifacts/node-agent-0.13.23-windows-amd64.zip"
linux_asset="dist/artifacts/node-agent-0.13.24.tar.gz"
windows_asset="dist/artifacts/node-agent-0.13.24-windows-amd64.zip"
test -s "$linux_asset"
test -s "$windows_asset"
gzip -t "$linux_asset"
unzip -t "$windows_asset"
(
cd dist/artifacts
sha256sum \
node-agent-0.13.23.tar.gz \
node-agent-0.13.23-windows-amd64.zip >SHA256SUMS
node-agent-0.13.24.tar.gz \
node-agent-0.13.24-windows-amd64.zip >SHA256SUMS
)
printf 'NODE_AGENT_VALIDATION_OK source=%s version=%s artifacts=linux,windows\n' \
"$GITHUB_SHA" "$VERSION_OVERRIDE"

- name: Retain reviewed release candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: node-agent-0.13.23
name: node-agent-0.13.24
path: |
dist/artifacts/node-agent-0.13.23.tar.gz
dist/artifacts/node-agent-0.13.23-windows-amd64.zip
dist/artifacts/node-agent-0.13.24.tar.gz
dist/artifacts/node-agent-0.13.24-windows-amd64.zip
dist/artifacts/SHA256SUMS
if-no-files-found: error
retention-days: 30
Expand Down
94 changes: 78 additions & 16 deletions core/compute/compute_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"net/netip"
"os"
"path"
"strconv"
"strings"

Expand Down Expand Up @@ -310,24 +311,10 @@ func setupFieldsHostConfig(fields model.InstanceFields, hostConfig *container.Ho

hostConfig.SecurityOpt = fields.SecurityOpt

deviceMappings := []container.DeviceMapping{}
devices := fields.Devices
for _, device := range devices {
parts := strings.Split(device, ":")
permission := "rwm"
if len(parts) == 3 {
permission = parts[2]
}
deviceMappings = append(deviceMappings,
container.DeviceMapping{
PathOnHost: parts[0],
PathInContainer: parts[1],
CgroupPermissions: permission,
})
if err := setupHardware(fields, hostConfig); err != nil {
return err
}

hostConfig.Devices = deviceMappings

hostConfig.DNS = make([]netip.Addr, 0, len(fields.DNS))
for _, value := range fields.DNS {
address, err := netip.ParseAddr(value)
Expand Down Expand Up @@ -413,6 +400,81 @@ func setupFieldsHostConfig(fields model.InstanceFields, hostConfig *container.Ho
return nil
}

func setupHardware(fields model.InstanceFields, hostConfig *container.HostConfig) error {
if fields.ShmSize < 0 {
return errors.New("shmSize must be non-negative bytes")
}
if fields.ShmSize > 0 && fields.IpcMode != "" && fields.IpcMode != "private" && fields.IpcMode != "shareable" {
return errors.New("shmSize requires private or shareable IPC")
}
if strings.Trim(fields.Runtime, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") != "" {
return errors.New("runtime must be a registered runtime name")
}
hostConfig.Runtime = fields.Runtime
hostConfig.DeviceRequests = nil
for _, request := range fields.DeviceRequests {
if request.Count < -1 || (request.Count != 0 && len(request.DeviceIDs) > 0) || (request.Count == 0 && len(request.DeviceIDs) == 0) {
return errors.New("deviceRequests requires count (-1 for all or a positive integer) OR deviceIds")
}
if len(request.Capabilities) == 0 {
return errors.New("deviceRequests requires capabilities")
}
for _, capabilities := range request.Capabilities {
if len(capabilities) == 0 {
return errors.New("deviceRequests capability groups cannot be empty")
}
for _, capability := range capabilities {
if strings.TrimSpace(capability) == "" {
return errors.New("deviceRequests capabilities cannot be blank")
}
}
}
seen := map[string]bool{}
for _, id := range request.DeviceIDs {
if strings.TrimSpace(id) == "" || seen[id] {
return errors.New("deviceRequests deviceIds must be non-empty and unique")
}
seen[id] = true
}
hostConfig.DeviceRequests = append(hostConfig.DeviceRequests, container.DeviceRequest{
Driver: request.Driver, Count: request.Count, DeviceIDs: request.DeviceIDs,
Capabilities: request.Capabilities, Options: request.Options,
})
}
hostConfig.Devices = nil
for _, device := range fields.Devices {
parts := strings.Split(device, ":")
if len(parts) > 3 || !path.IsAbs(parts[0]) || path.Clean(parts[0]) != parts[0] {
return errors.New("devices requires an absolute host path, optionally :container-path[:rwm]; CDI identifiers are not supported")
}
target, permissions := parts[0], "rwm"
if len(parts) >= 2 {
if path.IsAbs(parts[1]) {
target = parts[1]
} else if len(parts) == 2 {
permissions = parts[1]
} else {
return errors.New("devices container path must be absolute")
}
}
if len(parts) == 3 {
permissions = parts[2]
}
if path.Clean(target) != target || permissions == "" || strings.Trim(permissions, "rwm") != "" {
return errors.New("invalid devices path or permissions")
}
for _, permission := range "rwm" {
if strings.Count(permissions, string(permission)) > 1 {
return errors.New("duplicate device permission")
}
}
hostConfig.Devices = append(hostConfig.Devices, container.DeviceMapping{
PathOnHost: parts[0], PathInContainer: target, CgroupPermissions: permissions,
})
}
return nil
}

func setupComputeResourceFields(hostConfig *container.HostConfig, instance model.Instance) {
const dockerMinMemoryReservation = 6 * 1024 * 1024

Expand Down
81 changes: 81 additions & 0 deletions core/compute/hardware_docker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//go:build linux

package compute

import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"

"github.com/PastureStack/node-agent/internal/dockerapi/client"
"github.com/PastureStack/node-agent/internal/dockerapi/types"
"github.com/PastureStack/node-agent/model"
"github.com/moby/moby/api/types/container"
)

// Explicit opt-in: uses an already present immutable image, an isolated network
// namespace, no host mounts/ports, and removes only the exact container it made.
// Do not invoke the legacy whole-daemon test harness on a shared development VM.
func TestHardwareDockerRoundTrip(t *testing.T) {
image := os.Getenv("PASTURESTACK_HARDWARE_TEST_IMAGE")
if image == "" {
t.Skip("set PASTURESTACK_HARDWARE_TEST_IMAGE to an existing approved sha256 image")
}
if len(image) != 71 || image[:7] != "sha256:" {
t.Fatal("test image must be an immutable local image ID")
}
cli, err := client.NewEnvClient()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
var fields model.InstanceFields
// Decode through the real agent wire contract, including signed limits.
if err = json.Unmarshal([]byte(`{"shmSize":2147483648,"ipcMode":"private","runtime":"runc","pidsLimit":64,"cpuQuota":50000,"cpuPeriod":100000,"runInit":true,"devices":["/dev/null:rw"],"groupAdd":["993"],"tmpfs":{"/tmp":"rw,noexec,nosuid,size=64m"},"sysctls":{"net.core.somaxconn":"1024"},"ulimits":[{"name":"nofile","soft":1024,"hard":4096}]}`), &fields); err != nil {
t.Fatal(err)
}
var hc container.HostConfig
if err = setupFieldsHostConfig(fields, &hc); err != nil {
t.Fatal(err)
}
hc.NetworkMode = "none"
created, err := cli.ContainerCreate(ctx, &container.Config{Image: image, Entrypoint: []string{"/bin/sh"},
Cmd: []string{"-c", "sleep 60"}, Labels: map[string]string{"io.pasturestack.test": "hardware-roundtrip"}}, &hc, nil,
fmt.Sprintf("pasturestack-hardware-test-%d", time.Now().UnixNano()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanup, done := context.WithTimeout(context.Background(), 20*time.Second)
defer done()
if err := cli.ContainerRemove(cleanup, created.ID, types.ContainerRemoveOptions{Force: true}); err != nil {
t.Errorf("exact test container cleanup: %v", err)
}
})
if err = cli.ContainerStart(ctx, created.ID, types.ContainerStartOptions{}); err != nil {
t.Fatal(err)
}
actual, err := cli.ContainerInspect(ctx, created.ID)
if err != nil {
t.Fatal(err)
}
if !actual.State.Running {
t.Fatal("fixture did not start")
}
c := actual.HostConfig
if c.ShmSize != 2147483648 || c.Runtime != "runc" || c.IpcMode != "private" || c.PidsLimit == nil || *c.PidsLimit != 64 ||
c.CPUQuota != 50000 || c.CPUPeriod != 100000 || c.Init == nil || !*c.Init || c.Privileged || len(c.CapAdd) != 0 || len(c.SecurityOpt) != 0 {
t.Fatal("Docker runtime did not preserve resource/isolation settings")
}
if c.Tmpfs["/tmp"] != "rw,noexec,nosuid,size=64m" || c.Sysctls["net.core.somaxconn"] != "1024" ||
len(c.Ulimits) != 1 || c.Ulimits[0].Name != "nofile" || c.Ulimits[0].Soft != 1024 || c.Ulimits[0].Hard != 4096 ||
len(c.Devices) != 1 || c.Devices[0].PathOnHost != "/dev/null" || c.Devices[0].CgroupPermissions != "rw" ||
len(c.GroupAdd) != 1 || c.GroupAdd[0] != "993" {
t.Fatal("Docker runtime lost advanced options")
}
t.Log("real Docker create/start/inspect passed; GPU hardware execution is not covered by this test")
}
69 changes: 69 additions & 0 deletions core/compute/hardware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//go:build linux

package compute

import (
"encoding/json"
"testing"

"github.com/PastureStack/node-agent/model"
"github.com/moby/moby/api/types/container"
)

func TestHardwareLaunchContract(t *testing.T) {
var fields model.InstanceFields
err := json.Unmarshal([]byte(`{"runtime":"nvidia","shmSize":2147483648,"ipcMode":"private","groupAdd":["993"],"devices":["/dev/dri/renderD128"],"deviceRequests":[{"driver":"nvidia","deviceIds":["GPU-test"],"capabilities":[["gpu"]]}]}`), &fields)
if err != nil {
t.Fatal(err)
}
var actual container.HostConfig
if err = setupFieldsHostConfig(fields, &actual); err != nil {
t.Fatal(err)
}
if actual.ShmSize != 2147483648 || actual.Runtime != "nvidia" || actual.IpcMode != "private" || actual.GroupAdd[0] != "993" {
t.Fatalf("lost resource options: %+v", actual)
}
if len(actual.DeviceRequests) != 1 || actual.DeviceRequests[0].DeviceIDs[0] != "GPU-test" || actual.DeviceRequests[0].Capabilities[0][0] != "gpu" {
t.Fatal("lost GPU request")
}
if actual.Devices[0].PathInContainer != "/dev/dri/renderD128" {
t.Fatal("shorthand device not preserved")
}
if actual.Privileged || len(actual.SecurityOpt) > 0 {
t.Fatal("hardware must not escalate privilege")
}
encoded, err := json.Marshal(actual)
if err != nil {
t.Fatal(err)
}
var inspected model.HostConfig
if err := json.Unmarshal(encoded, &inspected); err != nil {
t.Fatal(err)
}
if inspected.Runtime != actual.Runtime || len(inspected.DeviceRequests) != 1 || inspected.DeviceRequests[0].DeviceIDs[0] != "GPU-test" {
t.Fatal("Docker inspect drops GPU/runtime identity")
}
}

func TestHardwareRejectsInvalidRequests(t *testing.T) {
cases := []model.InstanceFields{
{ShmSize: -1}, {ShmSize: 1024, IpcMode: "host"}, {Runtime: "runc --evil"},
{Devices: []string{""}}, {Devices: []string{"relative"}}, {Devices: []string{"/dev/dri:"}},
{Devices: []string{"/dev/dri:/dev/dri:bad"}}, {Devices: []string{"nvidia.com/gpu=all"}},
{DeviceRequests: []model.DeviceRequest{{Count: -2, Capabilities: [][]string{{"gpu"}}}}},
{DeviceRequests: []model.DeviceRequest{{Count: 1, DeviceIDs: []string{"GPU-test"}, Capabilities: [][]string{{"gpu"}}}}},
{DeviceRequests: []model.DeviceRequest{{Count: 1}}},
{DeviceRequests: []model.DeviceRequest{{Count: 1, Capabilities: [][]string{{}}}}},
{DeviceRequests: []model.DeviceRequest{{DeviceIDs: []string{"x", "x"}, Capabilities: [][]string{{"gpu"}}}}},
}
for i, fields := range cases {
if setupFieldsHostConfig(fields, &container.HostConfig{}) == nil {
t.Errorf("case %d accepted", i)
}
}
for _, spec := range []string{"/dev/dri", "/dev/dri:rw", "/dev/dri:/dev/dri:rw"} {
if err := setupFieldsHostConfig(model.InstanceFields{Devices: []string{spec}}, &container.HostConfig{}); err != nil {
t.Errorf("%s: %v", spec, err)
}
}
}
Loading