mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
1068 lines
37 KiB
Go
1068 lines
37 KiB
Go
// Copyright 2026 The gVisor Authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package openclaw benchmarks the maximum pod density and amortized memory
|
|
// footprint of OpenClaw Node.js gateways running inside Kata microVM sandboxes
|
|
// (Cloud Hypervisor and Firecracker) on GKE.
|
|
//
|
|
// Methodology:
|
|
//
|
|
// - Density & Health Monitoring: Target pods are launched sequentially on a single
|
|
// dedicated test node. A client pod running on a separate client node polls the
|
|
// new pod's readyz endpoint via HTTP, while continuously checking that all previously
|
|
// started neighbor pods remain responsive (pings successful) and are in the 'Running'
|
|
// phase (not evicted or terminated by GKE). A single failure terminates the scaling loop.
|
|
//
|
|
// - Memory Footprint: A background unsandboxed helper container logs host /proc/meminfo
|
|
// throughout the benchmark run. At any timestamp, host Memory Used is calculated as:
|
|
// Memory Used = MemTotal - MemAvailable.
|
|
//
|
|
// We isolate the marginal memory footprint of the sandboxes by taking the delta between
|
|
// peak density (N pods) and baseline (0 pods):
|
|
//
|
|
// Marginal Memory
|
|
// = Memory Used (Peak) - Memory Used (Baseline)
|
|
// = (MemTotal_Peak - MemAvailable_Peak) - (MemTotal_Baseline - MemAvailable_Baseline)
|
|
//
|
|
// The footprint per pod is calculated as: Marginal Memory / SuccessCount.
|
|
package openclaw
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gvisor.dev/gvisor/runsc/flag"
|
|
"gvisor.dev/gvisor/test/kubernetes/benchmetric"
|
|
"gvisor.dev/gvisor/test/kubernetes/k8sctx"
|
|
"gvisor.dev/gvisor/test/kubernetes/testcluster"
|
|
v13 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/util/intstr"
|
|
"k8s.io/client-go/kubernetes"
|
|
)
|
|
|
|
const (
|
|
// The container image used for the OpenClaw Node.js gateway targets.
|
|
openclawImage = "ghcr.io/openclaw/openclaw:slim"
|
|
// The target port exposed by the OpenClaw HTTP gateway.
|
|
openclawPort = 18789
|
|
// The authentication bearer token configured for the OpenClaw readyz endpoints.
|
|
gatewayToken = "benchmark-token"
|
|
// The GKE Headless Service name used for target pod DNS routing.
|
|
serviceName = "openclaw-svc"
|
|
// The duration the client pinger sleeps between consecutive HTTP requests.
|
|
clientPingIntervalSec = 0.5
|
|
// The maximum duration we wait for a target pod to schedule and successfully respond.
|
|
podBootTimeout = 60 * time.Second
|
|
)
|
|
|
|
// Default to 256 because max number of pods per node allowed by GKE is 256.
|
|
// https://docs.cloud.google.com/kubernetes-engine/docs/how-to/flexible-pod-cidr#max_pods_default
|
|
var maxPodsTarget = flag.Int("openclaw-benchmark-max-pods", 256, "Target number of pods for MaxPods benchmark (acts as a cap)")
|
|
|
|
// Resource requirements for the target pods. Parameterizing these values allows
|
|
// dynamically testing node capacity under different VM sizing bounds and CPU/Memory constraints.
|
|
var targetCPURequest = flag.String("openclaw-target-cpu-request", "250m", "CPU request for target openclaw pods (e.g. 250m, 1)")
|
|
var targetMemRequest = flag.String("openclaw-target-mem-request", "256Mi", "Memory request for target openclaw pods (e.g. 256Mi, 1Gi)")
|
|
var targetCPULimit = flag.String("openclaw-target-cpu-limit", "4", "CPU limit for target openclaw pods")
|
|
var targetMemLimit = flag.String("openclaw-target-mem-limit", "16Gi", "Memory limit for target openclaw pods")
|
|
var clientPingTimeoutSec = flag.Int("openclaw-client-ping-timeout-sec", 10, "The client pinger's curl timeout limit for individual readyz HTTP requests")
|
|
|
|
// RunOpenClaw benchmarks OpenClaw.
|
|
func RunOpenClaw(ctx context.Context, t *testing.T, _ k8sctx.KubernetesContext, cluster *testcluster.TestCluster) {
|
|
benchmarkNS := cluster.Namespace(testcluster.NamespaceBenchmark)
|
|
if err := benchmarkNS.Reset(ctx); err != nil {
|
|
t.Fatalf("cannot reset namespace: %v", err)
|
|
}
|
|
defer benchmarkNS.Cleanup(ctx)
|
|
|
|
// Create ConfigMap for openclaw.json
|
|
configMap := &v13.ConfigMap{
|
|
ObjectMeta: v1.ObjectMeta{
|
|
Name: "openclaw-config",
|
|
Namespace: benchmarkNS.Namespace,
|
|
},
|
|
Data: map[string]string{
|
|
"openclaw.json": `{
|
|
"gateway": {
|
|
"mode": "local",
|
|
"port": 18789,
|
|
"bind": "lan",
|
|
"auth": {
|
|
"mode": "token",
|
|
"token": "` + gatewayToken + `"
|
|
}
|
|
}
|
|
}`,
|
|
},
|
|
}
|
|
err := cluster.Do(ctx, func(ctx context.Context, client kubernetes.Interface) error {
|
|
_, err := client.CoreV1().ConfigMaps(benchmarkNS.Namespace).Create(ctx, configMap, v1.CreateOptions{})
|
|
return err
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create ConfigMap: %v", err)
|
|
}
|
|
|
|
// Create Secret for gateway token
|
|
secret := &v13.Secret{
|
|
ObjectMeta: v1.ObjectMeta{
|
|
Name: "openclaw-secrets",
|
|
Namespace: benchmarkNS.Namespace,
|
|
},
|
|
StringData: map[string]string{
|
|
"OPENCLAW_GATEWAY_TOKEN": gatewayToken,
|
|
},
|
|
}
|
|
err = cluster.Do(ctx, func(ctx context.Context, client kubernetes.Interface) error {
|
|
_, err := client.CoreV1().Secrets(benchmarkNS.Namespace).Create(ctx, secret, v1.CreateOptions{})
|
|
return err
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Secret: %v", err)
|
|
}
|
|
|
|
// Create Headless Service for DNS resolution of pods
|
|
service := newOpenClawService(benchmarkNS, serviceName, true)
|
|
service, err = cluster.CreateService(ctx, service)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create service: %v", err)
|
|
}
|
|
defer cluster.DeleteService(ctx, service)
|
|
|
|
t.Run("MaxPods", func(t *testing.T) {
|
|
measureMaxPods(ctx, t, cluster, benchmarkNS)
|
|
})
|
|
}
|
|
|
|
// measureMaxPods orchestrates the OpenClaw pod density test.
|
|
func measureMaxPods(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster, ns *testcluster.Namespace) {
|
|
maxPodsCap := *maxPodsTarget
|
|
t.Logf("Testing max pods density (cap: %d)...", maxPodsCap)
|
|
|
|
dnsNames, podNames := generatePodNames(ns, maxPodsCap)
|
|
|
|
// 1. Start the memory monitor pod first to log host memory dynamically.
|
|
memMonitor, err := startMemoryMonitor(ctx, cluster, ns.Namespace)
|
|
if err != nil {
|
|
t.Fatalf("Failed to start memory monitor pod: %v", err)
|
|
}
|
|
defer cluster.DeletePod(ctx, memMonitor)
|
|
|
|
// 2. Setup the client pinger pod for health checking.
|
|
clientPod, err := createClientPod(ctx, t, cluster, ns, dnsNames)
|
|
if err != nil {
|
|
t.Fatalf("Failed to setup client pod: %v", err)
|
|
}
|
|
defer cluster.DeletePod(ctx, clientPod)
|
|
|
|
startSec := time.Now().Unix()
|
|
|
|
// 3. Main loop: launch and verify pods sequentially.
|
|
successCount, pods, stopReason, lastGoodMem := launchAndVerifyTestPods(ctx, t, cluster, ns, podNames, clientPod, memMonitor, startSec)
|
|
|
|
defer func() {
|
|
t.Logf("Cleaning up %d OpenClaw pods...", len(pods))
|
|
for _, p := range pods {
|
|
if p != nil {
|
|
cluster.DeletePod(ctx, p)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// 4. Verify they all ended up on the same test node.
|
|
verifySameNode(ctx, t, cluster, pods)
|
|
|
|
// 5. Measure node memory usage, record to BigQuery, and print summary.
|
|
recordAndPrintResults(ctx, t, cluster, successCount, lastGoodMem, stopReason)
|
|
}
|
|
|
|
// generatePodNames creates DNS and pod names for all possible openclaw pods.
|
|
func generatePodNames(ns *testcluster.Namespace, cap int) (dnsNames []string, podNames []string) {
|
|
dnsNames = make([]string, cap)
|
|
podNames = make([]string, cap)
|
|
for i := 0; i < cap; i++ {
|
|
podName := fmt.Sprintf("openclaw-maxpods-%d", i)
|
|
podNames[i] = podName
|
|
dnsNames[i] = fmt.Sprintf("%s.%s.%s.svc.cluster.local", podName, serviceName, ns.Namespace)
|
|
}
|
|
return
|
|
}
|
|
|
|
// createClientPod creates and configures the client pod, and waits for it to start running.
|
|
//
|
|
// The client pod script runs a persistent background loop for each DNS name. Once it
|
|
// connects successfully, it logs "SUCCESS". If a previously healthy pod fails to respond
|
|
// to a readyz check, it immediately logs "DEAD" and exits.
|
|
func createClientPod(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster, ns *testcluster.Namespace, dnsNames []string) (*v13.Pod, error) {
|
|
clientCmd := `
|
|
echo "CLIENT_READY"
|
|
i=0
|
|
for dns in $DNS_NAMES; do
|
|
(
|
|
active=0
|
|
idx=$i
|
|
err_file="/tmp/curl_err_${idx}"
|
|
while true; do
|
|
code=$(curl -s -S -o /dev/null -w "%{http_code}" -m $PING_TIMEOUT -H "Authorization: Bearer $GATEWAY_TOKEN" http://${dns}:${OPENCLAW_PORT}/readyz 2> $err_file)
|
|
curl_status=$?
|
|
if [ "$code" = "200" ] && [ $curl_status -eq 0 ]; then
|
|
if [ $active -eq 0 ]; then
|
|
end=$(($(date +%s) * 1000000000))
|
|
echo "SUCCESS ${idx}: $end ($(date -u '+%Y-%m-%d %H:%M:%S'))"
|
|
active=1
|
|
fi
|
|
else
|
|
if [ $active -eq 1 ]; then
|
|
dead_time=$(($(date +%s) * 1000000000))
|
|
curl_err=$(cat $err_file 2>/dev/null || true)
|
|
curl_err=$(echo $curl_err | tr '\n' ' ')
|
|
echo "DEAD ${idx}: $dead_time (status=$code, curl=$curl_status, err=\"$curl_err\") ($(date -u '+%Y-%m-%d %H:%M:%S'))"
|
|
rm -f $err_file
|
|
exit 1
|
|
fi
|
|
fi
|
|
sleep $PING_INTERVAL
|
|
done
|
|
) &
|
|
i=$((i + 1))
|
|
done
|
|
wait
|
|
`
|
|
|
|
clientPodName := "client-poll-maxpods"
|
|
clientPod := &v13.Pod{
|
|
ObjectMeta: v1.ObjectMeta{
|
|
Name: clientPodName,
|
|
Namespace: ns.Namespace,
|
|
},
|
|
Spec: v13.PodSpec{
|
|
Containers: []v13.Container{
|
|
{
|
|
Name: "client",
|
|
Image: "curlimages/curl:latest",
|
|
Command: []string{"/bin/sh", "-c", clientCmd},
|
|
Env: []v13.EnvVar{
|
|
{Name: "DNS_NAMES", Value: strings.Join(dnsNames, " ")},
|
|
{Name: "PING_TIMEOUT", Value: strconv.Itoa(*clientPingTimeoutSec)},
|
|
{Name: "GATEWAY_TOKEN", Value: gatewayToken},
|
|
{Name: "OPENCLAW_PORT", Value: strconv.Itoa(openclawPort)},
|
|
{Name: "PING_INTERVAL", Value: fmt.Sprintf("%.1f", clientPingIntervalSec)},
|
|
},
|
|
},
|
|
},
|
|
RestartPolicy: v13.RestartPolicyNever,
|
|
},
|
|
}
|
|
clientPod, err := cluster.ConfigurePodForClientNodepool(ctx, clientPod)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to configure client pod: %w", err)
|
|
}
|
|
|
|
clientPod, err = cluster.CreatePod(ctx, clientPod)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create client pod: %w", err)
|
|
}
|
|
|
|
// Wait for client pod to be running
|
|
if err := cluster.WaitForPodRunning(ctx, clientPod); err != nil {
|
|
cluster.DeletePod(ctx, clientPod)
|
|
return nil, fmt.Errorf("client pod did not start running: %w", err)
|
|
}
|
|
|
|
return clientPod, nil
|
|
}
|
|
|
|
// nextAdaptiveBatchSize calculates the next batch size for adaptive scaling based on
|
|
// available host memory headroom and current per-pod memory usage.
|
|
// Returns the next batch size and whether adaptive step increases should remain enabled.
|
|
func nextAdaptiveBatchSize(currentBatchSize, currentPods, maxPods int, memAvail, memTotal, totalMarginalMem int64, canIncreaseStep bool) (int, bool) {
|
|
if !canIncreaseStep || memTotal <= 0 {
|
|
return 1, false
|
|
}
|
|
|
|
avgMemPerPod := 1.5 * 1024 * 1024 * 1024 // 1.5 GiB default estimate
|
|
if currentPods > 0 && totalMarginalMem > 0 {
|
|
avgMemPerPod = float64(totalMarginalMem) / float64(currentPods)
|
|
}
|
|
|
|
hasRoom := func(n int) bool {
|
|
if n < 1 || (currentPods+n) > maxPods {
|
|
return false
|
|
}
|
|
// Check if available memory can accommodate n pods (leaving 5% of host memory as safety margin).
|
|
return (float64(n) * avgMemPerPod) <= float64(memAvail)-float64(memTotal)*0.05
|
|
}
|
|
|
|
if hasRoom(currentBatchSize * 2) {
|
|
return currentBatchSize * 2, true
|
|
} else if hasRoom(currentBatchSize) {
|
|
return currentBatchSize, true
|
|
} else if currentBatchSize >= 2 && hasRoom(currentBatchSize/2) {
|
|
return currentBatchSize / 2, true
|
|
}
|
|
return 1, false
|
|
}
|
|
|
|
// launchAndVerifyTestPods runs the sequential launch loop, checking for scheduling issues
|
|
// and ensuring previously started pods remain responsive.
|
|
//
|
|
// To ensure density stability, the harness:
|
|
// 1. Checks GKE API once per second to verify all previous pods are in 'Running' phase and not Evicted.
|
|
// 2. Scans client logs to ensure no previous pod has reported "DEAD" (unresponsive due to CPU contention).
|
|
// 3. If any previous pod fails, we immediately stop the launch loop and record the current success count.
|
|
func launchAndVerifyTestPods(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster, ns *testcluster.Namespace, podNames []string, clientPod *v13.Pod, memMonitor *v13.Pod, startSec int64) (successCount int, pods []*v13.Pod, stopReason string, lastGoodMem int64) {
|
|
cap := len(podNames)
|
|
stopReason = "reached-cap"
|
|
bSize := 1
|
|
canIncreaseStep := true
|
|
|
|
for i := 0; i < cap; {
|
|
batchEnd := i + bSize
|
|
if batchEnd > cap {
|
|
batchEnd = cap
|
|
}
|
|
|
|
t.Logf("Launching batch of pods from %d to %d (batch size: %d)...", i, batchEnd-1, batchEnd-i)
|
|
createdAt := time.Now()
|
|
|
|
// Launch pods in this batch
|
|
var batchPods []*v13.Pod
|
|
var launchErr error
|
|
for j := i; j < batchEnd; j++ {
|
|
pod, err := launchTestPod(ctx, cluster, ns, podNames[j])
|
|
if err != nil {
|
|
t.Logf("Failed to launch pod %s: %v", podNames[j], err)
|
|
launchErr = err
|
|
stopReason = fmt.Sprintf("launch-failed-%s", podNames[j])
|
|
break
|
|
}
|
|
batchPods = append(batchPods, pod)
|
|
pods = append(pods, pod)
|
|
}
|
|
if launchErr != nil {
|
|
break
|
|
}
|
|
|
|
// Wait for scheduling of all pods in this batch
|
|
var schedErr error
|
|
var schedFailed bool
|
|
var scheduledCount int
|
|
for _, pod := range batchPods {
|
|
sReason, sErr := waitForPodScheduling(ctx, t, cluster, pod)
|
|
if sErr != nil || sReason != "" {
|
|
stopReason = sReason
|
|
schedErr = sErr
|
|
schedFailed = true
|
|
break
|
|
}
|
|
scheduledCount++
|
|
}
|
|
|
|
// Verify health of all pods that scheduled successfully
|
|
actualBatchEnd := i + scheduledCount
|
|
if scheduledCount > 0 {
|
|
stableDensity, hReason, hErr := verifyBatchHealth(ctx, t, cluster, clientPod, pods, podNames, i, actualBatchEnd, createdAt)
|
|
if hErr != nil {
|
|
if stableDensity > successCount {
|
|
successCount = stableDensity
|
|
}
|
|
stopReason = hReason
|
|
break
|
|
}
|
|
successCount = stableDensity
|
|
}
|
|
|
|
if schedErr != nil || schedFailed {
|
|
break
|
|
}
|
|
|
|
t.Logf("Batch %d to %d is healthy. Total healthy pods: %d", i, batchEnd-1, successCount)
|
|
|
|
// Measure memory progress while the node is healthy
|
|
nowSec := time.Now().Unix()
|
|
mem, memAvail, memTotal, err := getMarginalMemory(ctx, cluster, memMonitor, startSec, nowSec)
|
|
if err == nil {
|
|
lastGoodMem = mem
|
|
t.Logf("Marginal memory at density %d: %d bytes (%.3f GiB)", successCount, mem, float64(mem)/(1024*1024*1024))
|
|
} else {
|
|
t.Logf("Warning: Failed to get memory progress at density %d: %v", successCount, err)
|
|
}
|
|
|
|
// Advance index i by the number of pods attempted in this batch
|
|
i += bSize
|
|
|
|
// Settle time
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// Adaptive step size calculation based on remaining host memory room.
|
|
if err == nil && memTotal > 0 {
|
|
memUsedPercent := (float64(memTotal-memAvail) / float64(memTotal)) * 100.0
|
|
t.Logf("Host memory used: %.1f%% (%d MiB available / %d MiB total)", memUsedPercent, memAvail/(1024*1024), memTotal/(1024*1024))
|
|
}
|
|
newBSize, stillCanIncrease := nextAdaptiveBatchSize(bSize, i, cap, memAvail, memTotal, lastGoodMem, canIncreaseStep)
|
|
if canIncreaseStep && !stillCanIncrease {
|
|
t.Logf("Memory headroom low; reverting to step size 1 for remaining pods (adaptive increase disabled)")
|
|
}
|
|
bSize = newBSize
|
|
canIncreaseStep = stillCanIncrease
|
|
t.Logf("Adaptive step size for next batch: %d", bSize)
|
|
}
|
|
|
|
return successCount, pods, stopReason, lastGoodMem
|
|
}
|
|
|
|
// launchTestPod configures and creates a new test pod target.
|
|
func launchTestPod(ctx context.Context, cluster *testcluster.TestCluster, ns *testcluster.Namespace, podName string) (*v13.Pod, error) {
|
|
pod := newOpenClawPod(ns, podName, openclawImage, podName, serviceName)
|
|
pod, err := cluster.ConfigurePodForRuntimeTestNodepool(ctx, pod)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to configure pod: %w", err)
|
|
}
|
|
pod, err = cluster.CreatePod(ctx, pod)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create pod: %w", err)
|
|
}
|
|
return pod, nil
|
|
}
|
|
|
|
// waitForPodScheduling checks GKE API until NodeName is assigned or detects Unschedulable status.
|
|
func waitForPodScheduling(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster, pod *v13.Pod) (stopReason string, err error) {
|
|
podName := pod.Name
|
|
startWait := time.Now()
|
|
for time.Since(startWait) < 10*time.Second {
|
|
p, err := cluster.GetPod(ctx, pod)
|
|
if err != nil {
|
|
time.Sleep(500 * time.Millisecond)
|
|
continue
|
|
}
|
|
isUnschedulable := false
|
|
var unschedulableMessage string
|
|
for _, cond := range p.Status.Conditions {
|
|
if cond.Type == v13.PodScheduled && cond.Status == v13.ConditionFalse && cond.Reason == v13.PodReasonUnschedulable {
|
|
isUnschedulable = true
|
|
unschedulableMessage = cond.Message
|
|
break
|
|
}
|
|
}
|
|
if isUnschedulable {
|
|
t.Logf("Pod %s is unschedulable: %s", podName, unschedulableMessage)
|
|
|
|
return fmt.Sprintf("unschedulable-%s: %s", podName, unschedulableMessage), nil
|
|
}
|
|
if p.Spec.NodeName != "" {
|
|
return "", nil
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
t.Logf("Pod %s failed to schedule (or was unschedulable)", podName)
|
|
return fmt.Sprintf("schedule-timeout-%s", podName), fmt.Errorf("scheduling timeout")
|
|
}
|
|
|
|
// verifyBatchHealth polls the GKE API and client logs until all current batch pods respond successfully.
|
|
// If any pod experiences an involuntary disruption (eviction, crash, deletion) or pinger failure,
|
|
// it returns the last known stable density, a stop reason, and a non-nil error to terminate the loop.
|
|
//
|
|
// Returns:
|
|
// - stableDensity (int): The number of pods verified to be running stably.
|
|
// - stopReason (string): Text describing the condition that stopped the benchmark.
|
|
// - err (error): Non-nil error if the benchmark must stop due to a failure.
|
|
func verifyBatchHealth(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster, clientPod *v13.Pod, pods []*v13.Pod, podNames []string, batchStart, batchEnd int, createdAt time.Time) (int, string, error) {
|
|
lastK8sCheck := time.Now()
|
|
hasReachedRunning := make(map[string]bool)
|
|
|
|
for time.Since(createdAt) < podBootTimeout {
|
|
// 1. Check GKE API status once per second
|
|
if time.Since(lastK8sCheck) > 1*time.Second {
|
|
stableDensity, stopReason, err := checkTargetPodsAPI(ctx, t, cluster, clientPod, pods, podNames, batchStart, batchEnd, hasReachedRunning)
|
|
if err != nil {
|
|
return stableDensity, stopReason, err
|
|
}
|
|
lastK8sCheck = time.Now()
|
|
}
|
|
|
|
// 2. Read client logs
|
|
logs, err := cluster.ReadPodLogs(ctx, clientPod)
|
|
if err != nil {
|
|
time.Sleep(200 * time.Millisecond)
|
|
continue
|
|
}
|
|
|
|
// 3. Scan client logs for DEAD/SUCCESS pinger markers
|
|
stableDensity, stopReason, err := checkClientLogs(t, logs, batchStart, batchEnd)
|
|
if err != nil {
|
|
return stableDensity, stopReason, err
|
|
}
|
|
if stopReason == "all-success" {
|
|
return batchEnd, "", nil
|
|
}
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
|
|
t.Logf("Stopped sequence at batch %d-%d due to boot timeout.", batchStart, batchEnd-1)
|
|
logs, logErr := cluster.ReadPodLogs(ctx, clientPod)
|
|
if logErr == nil {
|
|
t.Logf("Client logs on failure:\n%s", logs)
|
|
}
|
|
res, _ := parseClientLogs(logs)
|
|
return res.uniqueSuccesses, fmt.Sprintf("health-timeout-batch-%d-%d", batchStart, batchEnd-1), fmt.Errorf("health check timeout")
|
|
}
|
|
|
|
// checkTargetPodsAPI queries the GKE API for target pods to detect lifecycle failures:
|
|
// - Pod disappearance from GKE (NotFound)
|
|
// - Pod deletion in progress (DeletionTimestamp)
|
|
// - Previously running pods falling out of PodRunning state
|
|
// - New pods crashing during boot (PodFailed)
|
|
// - Container process terminations (Terminated state)
|
|
//
|
|
// Returns:
|
|
// - stableDensity (int): Last known stable density if a failure is detected, 0 otherwise.
|
|
// - stopReason (string): Descriptive failure reason if stopped, empty otherwise.
|
|
// - err (error): Non-nil if a lifecycle failure was detected.
|
|
func checkTargetPodsAPI(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster, clientPod *v13.Pod, pods []*v13.Pod, podNames []string, batchStart, batchEnd int, hasReachedRunning map[string]bool) (int, string, error) {
|
|
if len(pods) == 0 || pods[0] == nil {
|
|
return 0, "", nil
|
|
}
|
|
podList, err := cluster.ListPods(ctx, pods[0].Namespace)
|
|
if err != nil {
|
|
t.Logf("Warning: ListPods failed during health check: %v", err)
|
|
return 0, "", nil
|
|
}
|
|
podMap := make(map[string]*v13.Pod, len(podList.Items))
|
|
for i := range podList.Items {
|
|
podMap[podList.Items[i].Name] = &podList.Items[i]
|
|
}
|
|
|
|
for j := 0; j < batchEnd; j++ {
|
|
p, ok := podMap[podNames[j]]
|
|
if !ok {
|
|
// Failure 1: Pod has been completely deleted and removed from the API.
|
|
t.Logf("Pod %d (%s) disappeared from GKE", j, podNames[j])
|
|
uniqueSuccesses := countUniqueSuccesses(ctx, cluster, clientPod)
|
|
stableDensity := uniqueSuccesses
|
|
if j < uniqueSuccesses {
|
|
stableDensity = uniqueSuccesses - 1
|
|
}
|
|
return stableDensity, fmt.Sprintf("peer-not-found-%s", podNames[j]), fmt.Errorf("pod disappeared from GKE")
|
|
}
|
|
|
|
// Failure 2: Pod is undergoing graceful shutdown (DeletionTimestamp is set).
|
|
if p.DeletionTimestamp != nil {
|
|
t.Logf("Pod %d (%s) is being deleted (DeletionTimestamp: %v)", j, podNames[j], p.DeletionTimestamp)
|
|
uniqueSuccesses := countUniqueSuccesses(ctx, cluster, clientPod)
|
|
stableDensity := uniqueSuccesses
|
|
if j < uniqueSuccesses {
|
|
stableDensity = uniqueSuccesses - 1
|
|
}
|
|
return stableDensity, fmt.Sprintf("peer-deleted-%s", podNames[j]), fmt.Errorf("pod is being deleted")
|
|
}
|
|
|
|
isPrevious := j < batchStart
|
|
// Enforce PodRunning dynamically for all previous pods, and any current-batch pods that have already reached Running state.
|
|
if isPrevious || hasReachedRunning[p.Name] {
|
|
// Failure 3: Pod fell out of Running phase (e.g. rescheduling or eviction).
|
|
if p.Status.Phase != v13.PodRunning {
|
|
t.Logf("Pod %d (%s) fell out of Running phase (Phase: %s)", j, podNames[j], p.Status.Phase)
|
|
uniqueSuccesses := countUniqueSuccesses(ctx, cluster, clientPod)
|
|
stableDensity := uniqueSuccesses
|
|
if j < uniqueSuccesses {
|
|
stableDensity = uniqueSuccesses - 1
|
|
}
|
|
return stableDensity, fmt.Sprintf("peer-unhealthy-%s", podNames[j]), fmt.Errorf("pod fell out of Running phase")
|
|
}
|
|
} else {
|
|
// Lock in the Running status for current-batch pods once they transition
|
|
if p.Status.Phase == v13.PodRunning {
|
|
hasReachedRunning[p.Name] = true
|
|
} else if p.Status.Phase == v13.PodFailed {
|
|
// Failure 4: Newly launched pod crashed during startup.
|
|
t.Logf("Current batch pod %d (%s) failed during startup (Phase: %s)", j, podNames[j], p.Status.Phase)
|
|
uniqueSuccesses := countUniqueSuccesses(ctx, cluster, clientPod)
|
|
stableDensity := uniqueSuccesses
|
|
if j < uniqueSuccesses {
|
|
stableDensity = uniqueSuccesses - 1
|
|
}
|
|
return stableDensity, fmt.Sprintf("peer-failed-%s", podNames[j]), fmt.Errorf("pod failed during startup")
|
|
}
|
|
}
|
|
|
|
// Failure 5: Individual container process exited (VMM or application crash).
|
|
for _, cStatus := range p.Status.ContainerStatuses {
|
|
if cStatus.State.Terminated != nil {
|
|
exitCode := cStatus.State.Terminated.ExitCode
|
|
t.Logf("Pod %d (%s) container %s terminated: ExitCode=%d, Reason=%s, Message=%s", j, podNames[j], cStatus.Name, exitCode, cStatus.State.Terminated.Reason, cStatus.State.Terminated.Message)
|
|
uniqueSuccesses := countUniqueSuccesses(ctx, cluster, clientPod)
|
|
stableDensity := uniqueSuccesses
|
|
if j < uniqueSuccesses {
|
|
stableDensity = uniqueSuccesses - 1
|
|
}
|
|
return stableDensity, fmt.Sprintf("container-crashed-%s", podNames[j]), fmt.Errorf("container terminated unexpectedly")
|
|
}
|
|
}
|
|
}
|
|
return 0, "", nil
|
|
}
|
|
|
|
type clientLogParseResult struct {
|
|
failedPodIndex int
|
|
failureDetail string
|
|
uniqueSuccesses int
|
|
}
|
|
|
|
// parseClientLogs scans client logs to detect the first DEAD event and extract details,
|
|
// while also counting unique SUCCESS events before that failure.
|
|
func parseClientLogs(logs string) (clientLogParseResult, error) {
|
|
lines := strings.Split(logs, "\n")
|
|
successes := make(map[int]bool)
|
|
var res clientLogParseResult
|
|
res.failedPodIndex = -1
|
|
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "SUCCESS ") {
|
|
parts := strings.Split(line, " ")
|
|
if len(parts) >= 2 {
|
|
idxStr := strings.TrimSuffix(parts[1], ":")
|
|
idx, err := strconv.Atoi(idxStr)
|
|
if err == nil {
|
|
successes[idx] = true
|
|
}
|
|
}
|
|
} else if strings.HasPrefix(line, "DEAD ") {
|
|
parts := strings.Split(line, " ")
|
|
if len(parts) >= 2 {
|
|
idxStr := strings.TrimSuffix(parts[1], ":")
|
|
idx, err := strconv.Atoi(idxStr)
|
|
if err == nil {
|
|
res.failedPodIndex = idx
|
|
|
|
// Extract details between parentheses
|
|
header := fmt.Sprintf("DEAD %d:", idx)
|
|
idxOfHeader := strings.Index(line, header)
|
|
if idxOfHeader != -1 {
|
|
detailPart := line[idxOfHeader+len(header):]
|
|
startIdx := strings.Index(detailPart, "(")
|
|
if startIdx != -1 {
|
|
openParens := 0
|
|
for i := startIdx; i < len(detailPart); i++ {
|
|
if detailPart[i] == '(' {
|
|
openParens++
|
|
} else if detailPart[i] == ')' {
|
|
openParens--
|
|
if openParens == 0 {
|
|
res.failureDetail = detailPart[startIdx+1 : i]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
res.uniqueSuccesses = len(successes)
|
|
return res, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
res.uniqueSuccesses = len(successes)
|
|
return res, fmt.Errorf("no failure detected")
|
|
}
|
|
|
|
func countUniqueSuccesses(ctx context.Context, cluster *testcluster.TestCluster, clientPod *v13.Pod) int {
|
|
logs, err := cluster.ReadPodLogs(ctx, clientPod)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
res, _ := parseClientLogs(logs)
|
|
return res.uniqueSuccesses
|
|
}
|
|
|
|
// checkClientLogs scans client pinger logs for:
|
|
// - DEAD pinger reports (indicating readyz HTTP timeout or guest kernel panic)
|
|
// - Boot success markers (SUCCESS) for all pods in the current batch
|
|
//
|
|
// Returns:
|
|
// - stableDensity (int): Last known stable density if a failure is detected,
|
|
// or batchEnd if all pods in the batch succeeded.
|
|
// - stopReason (string): "all-success" if all batch pods are verified healthy,
|
|
// or a failure description if a pod reported DEAD.
|
|
// - err (error): Non-nil if a pod failure (DEAD) is found.
|
|
func checkClientLogs(t *testing.T, logs string, batchStart, batchEnd int) (int, string, error) {
|
|
res, err := parseClientLogs(logs)
|
|
if err == nil {
|
|
t.Logf("Pod %d reported DEAD in client logs: %s", res.failedPodIndex, res.failureDetail)
|
|
stableDensity := res.uniqueSuccesses
|
|
if res.failedPodIndex < res.uniqueSuccesses {
|
|
stableDensity = res.uniqueSuccesses - 1
|
|
}
|
|
return stableDensity, fmt.Sprintf("peer-died-%d (%s)", res.failedPodIndex, res.failureDetail), fmt.Errorf("pod died")
|
|
}
|
|
|
|
// Verify all pods in the current batch have successfully booted
|
|
allSuccess := true
|
|
for j := batchStart; j < batchEnd; j++ {
|
|
successPattern := fmt.Sprintf("SUCCESS %d:", j)
|
|
if !strings.Contains(logs, successPattern) {
|
|
allSuccess = false
|
|
break
|
|
}
|
|
}
|
|
if allSuccess {
|
|
return batchEnd, "all-success", nil
|
|
}
|
|
|
|
return res.uniqueSuccesses, "", nil
|
|
}
|
|
|
|
// verifySameNode logs all nodes where the pods were scheduled and issues a warning
|
|
// if they ended up on multiple nodes.
|
|
func verifySameNode(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster, pods []*v13.Pod) {
|
|
nodeNames := make(map[string]struct{})
|
|
if len(pods) == 0 || pods[0] == nil {
|
|
return
|
|
}
|
|
podList, err := cluster.ListPods(ctx, pods[0].Namespace)
|
|
if err != nil {
|
|
t.Logf("Warning: ListPods failed in verifySameNode: %v", err)
|
|
return
|
|
}
|
|
for _, p := range podList.Items {
|
|
if p.Spec.NodeName != "" && strings.HasPrefix(p.Name, "openclaw-maxpods-") {
|
|
nodeNames[p.Spec.NodeName] = struct{}{}
|
|
}
|
|
}
|
|
t.Logf("Pods scheduled on nodes: %v", nodeNames)
|
|
if len(nodeNames) > 1 {
|
|
t.Errorf("WARNING: Pods are scheduled on multiple nodes: %v, want 1 node", nodeNames)
|
|
}
|
|
}
|
|
|
|
// recordAndPrintResults calculates the true marginal memory usage on the host node,
|
|
// records results (density and memory) to BigQuery, and prints the BENCHMARK SUMMARY.
|
|
//
|
|
// Memory calculation: We read the logs of the background mem-monitor pod. We extract
|
|
// the MemTotal and MemAvailable values at startSec and endSec, and calculate the delta:
|
|
// Marginal Memory = (MemTotal_Peak - MemAvailable_Peak) - (MemTotal_Baseline - MemAvailable_Baseline)
|
|
// This is recorded as the true marginal memory footprint consumed by the sandboxes.
|
|
func recordAndPrintResults(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster, successCount int, marginalMem int64, stopReason string) {
|
|
recorder, err := benchmetric.GetRecorder(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get recorder: %v", err)
|
|
}
|
|
|
|
// Record metrics: Max Density (success count) to BigQuery.
|
|
err = recorder.Record(ctx, "OpenClawMaxDensity",
|
|
benchmetric.Count(uint64(successCount), "max-healthy-pods"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Failed to record metrics: %v", err)
|
|
}
|
|
|
|
if successCount > 0 && marginalMem > 0 {
|
|
avgMem := float64(marginalMem) / float64(successCount)
|
|
|
|
t.Logf("Marginal Memory Used: %d bytes (%.3f GiB)", marginalMem, float64(marginalMem)/(1024*1024*1024))
|
|
t.Logf("Amortized Marginal Memory per pod: %.0f bytes (%.3f GiB)", avgMem, avgMem/(1024*1024*1024))
|
|
|
|
// Record memory metrics to BigQuery.
|
|
// Metric name is 'marginal-memory-B' and 'marginal-memory-per-pod-B'
|
|
err = recorder.Record(ctx, "OpenClawMaxDensityMem",
|
|
benchmetric.SpecificBytes(float64(marginalMem), "marginal-memory"),
|
|
benchmetric.SpecificBytes(avgMem, "marginal-memory-per-pod"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Failed to record memory metrics: %v", err)
|
|
}
|
|
}
|
|
|
|
// Print nicely formatted summary
|
|
t.Logf("==================================================")
|
|
t.Logf(" BENCHMARK SUMMARY")
|
|
t.Logf("==================================================")
|
|
logNodeAllocatableResources(ctx, t, cluster)
|
|
t.Logf(" Max Density: %d pods", successCount)
|
|
t.Logf(" Stop Reason: %s", stopReason)
|
|
if successCount > 0 {
|
|
if marginalMem > 0 {
|
|
avgMem := float64(marginalMem) / float64(successCount)
|
|
t.Logf(" Marginal Memory Used: %d bytes (%.3f GiB)", marginalMem, float64(marginalMem)/(1024*1024*1024))
|
|
t.Logf(" Amortized Marginal Memory: %.0f bytes (%.3f GiB)", avgMem, avgMem/(1024*1024*1024))
|
|
} else {
|
|
t.Logf(" Memory Usage: N/A (metrics missing)")
|
|
}
|
|
}
|
|
t.Logf("==================================================")
|
|
}
|
|
|
|
// startMemoryMonitor provisions a persistent unsandboxed pod on the test nodepool
|
|
// to periodically log host node memory statistics from /proc/meminfo.
|
|
func startMemoryMonitor(ctx context.Context, cluster *testcluster.TestCluster, namespace string) (*v13.Pod, error) {
|
|
pod := &v13.Pod{
|
|
ObjectMeta: v1.ObjectMeta{
|
|
Name: "mem-monitor",
|
|
Namespace: namespace,
|
|
},
|
|
Spec: v13.PodSpec{
|
|
Containers: []v13.Container{
|
|
{
|
|
Name: "monitor",
|
|
Image: "alpine:latest",
|
|
Command: []string{"/bin/sh", "-c", "while true; do echo \"$(date +%s):$(grep MemTotal /proc/meminfo | awk '{print $2}'):$(grep MemAvailable /proc/meminfo | awk '{print $2}')\"; sleep 1; done"},
|
|
Resources: v13.ResourceRequirements{
|
|
Requests: v13.ResourceList{
|
|
v13.ResourceCPU: resource.MustParse("10m"),
|
|
v13.ResourceMemory: resource.MustParse("32Mi"),
|
|
},
|
|
Limits: v13.ResourceList{
|
|
v13.ResourceCPU: resource.MustParse("100m"),
|
|
v13.ResourceMemory: resource.MustParse("128Mi"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
RestartPolicy: v13.RestartPolicyNever,
|
|
},
|
|
}
|
|
pod, err := cluster.ConfigurePodForRuntimeTestNodepool(ctx, pod)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to configure monitor pod: %w", err)
|
|
}
|
|
pod.Spec.RuntimeClassName = nil // Run unsandboxed (runc) to read host /proc/meminfo
|
|
// Tolerate gVisor node taint if running on a gVisor cluster.
|
|
pod.Spec.Tolerations = append(pod.Spec.Tolerations, v13.Toleration{
|
|
Key: "sandbox.gke.io/runtime",
|
|
Operator: v13.TolerationOpEqual,
|
|
Value: "gvisor",
|
|
Effect: v13.TaintEffectNoSchedule,
|
|
})
|
|
|
|
pod, err = cluster.CreatePod(ctx, pod)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create monitor pod: %w", err)
|
|
}
|
|
|
|
if err := cluster.WaitForPodRunning(ctx, pod); err != nil {
|
|
cluster.DeletePod(ctx, pod)
|
|
return nil, fmt.Errorf("monitor pod failed to start running: %w", err)
|
|
}
|
|
|
|
return pod, nil
|
|
}
|
|
|
|
// getMarginalMemory reads the logs of the background monitor pod, extracts the
|
|
// MemAvailable values at startSec and endSec, and calculates the delta in bytes,
|
|
// along with current available and total host memory in bytes.
|
|
func getMarginalMemory(ctx context.Context, cluster *testcluster.TestCluster, monitorPod *v13.Pod, startSec, endSec int64) (int64, int64, int64, error) {
|
|
logs, err := cluster.ReadPodLogs(ctx, monitorPod)
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
|
|
var startAvail, endAvail, endTotal int64
|
|
var startFound, endFound bool
|
|
|
|
// Parse log lines: "timestamp:total:available"
|
|
for _, line := range strings.Split(logs, "\n") {
|
|
parts := strings.Split(line, ":")
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
ts, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
total, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
avail, err := strconv.ParseInt(parts[2], 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Capture the first available sample at or after startSec
|
|
if ts >= startSec && !startFound {
|
|
startAvail = avail
|
|
startFound = true
|
|
}
|
|
// Capture the last available sample at or before endSec
|
|
if ts <= endSec {
|
|
endAvail = avail
|
|
endTotal = total
|
|
endFound = true
|
|
}
|
|
}
|
|
|
|
if !startFound || !endFound {
|
|
return 0, 0, 0, fmt.Errorf("failed to find start or end memory samples in monitor logs")
|
|
}
|
|
|
|
// Return values in bytes (meminfo values are in KB)
|
|
usedKb := startAvail - endAvail
|
|
if usedKb < 0 {
|
|
usedKb = 0 // Guard against noise
|
|
}
|
|
return usedKb * 1024, endAvail * 1024, endTotal * 1024, nil
|
|
}
|
|
|
|
func newOpenClawPod(namespace *testcluster.Namespace, name, image, hostname, subdomain string) *v13.Pod {
|
|
pod := &v13.Pod{
|
|
TypeMeta: v1.TypeMeta{
|
|
Kind: "Pod",
|
|
APIVersion: "v1",
|
|
},
|
|
ObjectMeta: v1.ObjectMeta{
|
|
Name: name,
|
|
Namespace: namespace.Namespace,
|
|
Labels: map[string]string{"app": "openclaw-benchmark"},
|
|
},
|
|
Spec: v13.PodSpec{
|
|
Hostname: hostname,
|
|
Subdomain: subdomain,
|
|
Containers: []v13.Container{
|
|
{
|
|
Name: "gateway",
|
|
Image: image,
|
|
Command: []string{"node", "/app/dist/index.js", "gateway", "run"},
|
|
Ports: []v13.ContainerPort{
|
|
{
|
|
Name: "gateway",
|
|
ContainerPort: openclawPort,
|
|
},
|
|
},
|
|
Env: []v13.EnvVar{
|
|
{
|
|
Name: "HOME",
|
|
Value: "/home/node",
|
|
},
|
|
{
|
|
Name: "OPENCLAW_CONFIG_DIR",
|
|
Value: "/home/node/.openclaw",
|
|
},
|
|
{
|
|
Name: "NODE_ENV",
|
|
Value: "production",
|
|
},
|
|
{
|
|
Name: "OPENCLAW_GATEWAY_TOKEN",
|
|
ValueFrom: &v13.EnvVarSource{
|
|
SecretKeyRef: &v13.SecretKeySelector{
|
|
LocalObjectReference: v13.LocalObjectReference{
|
|
Name: "openclaw-secrets",
|
|
},
|
|
Key: "OPENCLAW_GATEWAY_TOKEN",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
VolumeMounts: []v13.VolumeMount{
|
|
{
|
|
Name: "config-volume",
|
|
MountPath: "/home/node/.openclaw/openclaw.json",
|
|
SubPath: "openclaw.json",
|
|
},
|
|
{
|
|
Name: "tmp-volume",
|
|
MountPath: "/tmp",
|
|
},
|
|
},
|
|
Resources: v13.ResourceRequirements{
|
|
Requests: v13.ResourceList{
|
|
v13.ResourceCPU: resource.MustParse(*targetCPURequest),
|
|
v13.ResourceMemory: resource.MustParse(*targetMemRequest),
|
|
},
|
|
Limits: v13.ResourceList{
|
|
v13.ResourceCPU: resource.MustParse(*targetCPULimit),
|
|
v13.ResourceMemory: resource.MustParse(*targetMemLimit),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Volumes: []v13.Volume{
|
|
{
|
|
Name: "config-volume",
|
|
VolumeSource: v13.VolumeSource{
|
|
ConfigMap: &v13.ConfigMapVolumeSource{
|
|
LocalObjectReference: v13.LocalObjectReference{
|
|
Name: "openclaw-config",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "tmp-volume",
|
|
VolumeSource: v13.VolumeSource{
|
|
EmptyDir: &v13.EmptyDirVolumeSource{},
|
|
},
|
|
},
|
|
},
|
|
RestartPolicy: v13.RestartPolicyNever,
|
|
},
|
|
}
|
|
return pod
|
|
}
|
|
|
|
func newOpenClawService(namespace *testcluster.Namespace, name string, headless bool) *v13.Service {
|
|
spec := v13.ServiceSpec{
|
|
Selector: map[string]string{"app": "openclaw-benchmark"},
|
|
Ports: []v13.ServicePort{
|
|
{
|
|
Name: "gateway",
|
|
Protocol: v13.ProtocolTCP,
|
|
Port: openclawPort,
|
|
TargetPort: intstr.FromInt(openclawPort),
|
|
},
|
|
},
|
|
}
|
|
if headless {
|
|
spec.ClusterIP = "None"
|
|
}
|
|
return namespace.GetService(name, spec)
|
|
}
|
|
|
|
// logNodeAllocatableResources queries and logs the test node's allocatable capacity and running pods.
|
|
func logNodeAllocatableResources(ctx context.Context, t *testing.T, cluster *testcluster.TestCluster) {
|
|
err := cluster.Do(ctx, func(ctx context.Context, client kubernetes.Interface) error {
|
|
nodes, err := client.CoreV1().Nodes().List(ctx, v1.ListOptions{
|
|
LabelSelector: fmt.Sprintf("%s=%s", testcluster.NodePoolTypeKey, testcluster.TestRuntimeNodepoolName),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(nodes.Items) == 0 {
|
|
return fmt.Errorf("no nodes found in test nodepool")
|
|
}
|
|
node := nodes.Items[0]
|
|
|
|
cpu := node.Status.Allocatable[v13.ResourceCPU]
|
|
mem := node.Status.Allocatable[v13.ResourceMemory]
|
|
pods := node.Status.Allocatable[v13.ResourcePods]
|
|
|
|
t.Logf(" Node Allocatable:")
|
|
t.Logf(" CPU: %s", cpu.String())
|
|
t.Logf(" Memory: %s", mem.String())
|
|
t.Logf(" Pods: %s", pods.String())
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Logf(" Warning: Could not determine Node Allocatable resources: %v", err)
|
|
}
|
|
}
|