test(apiserver): add integration tests for P0+P1 backend features

Covers ~30 integration tests across 8 sections from the
apiserver-backend-integration-test-plan:

- §1: Cluster config push (store population, merge semantics, zero-preserve)
- §2: Host control (OnHostUpdated merge, validateHost, preCheck, batch ops)
- §3: Resource value push (all 7 value types, nil/unknown discriminant, overwrite)
- §4: Heartbeat keep-alive (interval update, zero-preserve)
- §5: Cross-feature (config+heartbeat, config+host, worker+config field preservation)
- §6: RBAC (cluster_admin bypass, algorithm confusion, expired tokens, hostgroup bypass)
- §7: Store thread safety (concurrent config pushes, host control+config, resource values)
- §8: Input safety (hostname length, empty lists, malformed payloads)

All tests use CGO_ENABLED=0 with //go:build !cgo build tag.
§8.1 hostname-length skipped pending P1-2b implementation.
This commit is contained in:
Rongsong Shen 2026-04-18 11:56:31 +00:00
parent 0a3de8b040
commit 86a65d640a
8 changed files with 1805 additions and 24 deletions

View file

@ -11,6 +11,7 @@ package backend
#include "helper.h"
#include "idgen.h"
#include "controller.capnp.h"
#include "val_helpers.h"
// backend_ctx_t wraps xcecli_ctx_t for use as the C handler's arg.
typedef struct {
@ -49,15 +50,7 @@ static void jobctrl_set_ckptoption(xce_jobctrl_t *ctrl, xce_ckpt_options_t *ckpt
ctrl->u.ckptoption = ckpt;
}
// xce_val_t union accessors — CGO cannot access C union fields by name.
static int xce_val_which(xce_val_t *v) { return v->which; }
static int32_t xce_val_i32(xce_val_t *v) { return v->u.ivalue32; }
static int64_t xce_val_i64(xce_val_t *v) { return v->u.ivalue64; }
static uint32_t xce_val_u32(xce_val_t *v) { return v->u.uvalue32; }
static uint64_t xce_val_u64(xce_val_t *v) { return v->u.uvalue64; }
static double xce_val_fval(xce_val_t *v) { return v->u.fvalue; }
static char* xce_val_str(xce_val_t *v) { return v->u.svalue; }
static int xce_val_bool(xce_val_t *v) { return v->u.bvalue; }
// xce_val_t union accessors moved to val_helpers.h (shared with convert.go).
*/
import "C"

View file

@ -5,6 +5,7 @@ package backend
/*
#include <stdlib.h>
#include "client.h"
#include "val_helpers.h"
*/
import "C"
@ -286,7 +287,7 @@ func cResvalToDistribution(vp *C.xce_resval_t) *models.ResourceDistributionInfo
rd.NumericValue = float64(C.xce_val_u64(vp.val))
case C.Value_u_fvalue:
rd.ValueType = int(models.NUMERIC)
rd.NumericValue = C.xce_val_fval(vp.val) // C.double → float64, no explicit cast needed
rd.NumericValue = float64(C.xce_val_fval(vp.val))
case C.Value_u_svalue:
rd.ValueType = int(models.STRING)
rd.StringValue = C.GoString(C.xce_val_str(vp.val))

View file

@ -0,0 +1,505 @@
//go:build !cgo
package backend
import (
"apiserver/models"
"apiserver/store"
"apiserver/utils"
"sync/atomic"
"testing"
"go.uber.org/zap"
)
// resetIntegrationStores reinitializes all global stores for test isolation.
func resetIntegrationStores() {
store.InitStores()
utils.Log = zap.NewNop()
}
// ========== §1. P0-1: Cluster Config Push ==========
// --- 1.1 P0: Config push populates all stores in a single call ---
func TestIntegration_ConfigPushPopulatesAllStores(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
queues := []models.QueueInfo{{QueueName: "normal", Priority: 30}}
resources := []models.ResourceDefinitionInfo{{ResourceName: "mem", Description: "memory", ValueType: 1}}
hosts := []models.HostInfo{{HostName: "node01", HostModel: "x86_64"}}
cluster := &models.ClusterInfo{ClusterName: "c1", HeartbeatInterval: 30, Vcpus: 16}
dispatchConfigPush(h, queues, resources, hosts, cluster)
if _, ok := store.Queues.Get("normal"); !ok {
t.Error("Queues store should contain 'normal'")
}
if _, ok := store.Resources.Get("mem"); !ok {
t.Error("Resources store should contain 'mem'")
}
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("Hosts store should contain 'node01'")
}
if got.HostModel != "x86_64" {
t.Errorf("node01 HostModel = %q, want x86_64", got.HostModel)
}
gotC, ok := store.Clusters.Get("c1")
if !ok {
t.Fatal("Clusters store should contain 'c1'")
}
if gotC.HeartbeatInterval != 30 {
t.Errorf("c1 HeartbeatInterval = %d, want 30", gotC.HeartbeatInterval)
}
if gotC.Vcpus != 16 {
t.Errorf("c1 Vcpus = %d, want 16", gotC.Vcpus)
}
}
// --- 1.2 P0: Second config push merges (does not replace) ---
func TestIntegration_SecondConfigPushMerges(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
// Seed worker-sourced data
store.Hosts.Put("node01", models.HostInfo{
HostName: "node01",
HostStatus: 0x00010000,
CPUFactor: 2.5,
NumCPUs: 32,
})
// First config push
h.OnHostConfigUpdated(&models.HostInfo{
HostName: "node01",
HostModel: "modelA",
HostType: "CENTOS7",
MaxSlot: 16,
})
// Second config push with distinct model
h.OnHostConfigUpdated(&models.HostInfo{
HostName: "node01",
HostModel: "modelB",
})
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("node01 should exist after merge")
}
// Worker-sourced fields preserved
if got.HostStatus != 0x00010000 {
t.Errorf("HostStatus = 0x%08X, want 0x00010000 (preserved)", got.HostStatus)
}
if got.CPUFactor != 2.5 {
t.Errorf("CPUFactor = %f, want 2.5 (preserved)", got.CPUFactor)
}
// Config field reflects second push
if got.HostModel != "modelB" {
t.Errorf("HostModel = %q, want modelB (second push)", got.HostModel)
}
}
// --- 1.3 P0: Cluster update with zero HeartbeatInterval preserves existing ---
func TestIntegration_ZeroHeartbeatIntervalPreservesExisting(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
store.Clusters.Put("c1", models.ClusterInfo{
ClusterName: "c1",
HeartbeatInterval: 45,
})
h.OnClusterUpdated(&models.ClusterInfo{
ClusterName: "c1",
HeartbeatInterval: 0,
Vcpus: 8,
})
got, ok := store.Clusters.Get("c1")
if !ok {
t.Fatal("c1 should exist in store")
}
if got.HeartbeatInterval != 45 {
t.Errorf("HeartbeatInterval = %d, want 45 (zero should be ignored)", got.HeartbeatInterval)
}
if got.Vcpus != 8 {
t.Errorf("Vcpus = %d, want 8", got.Vcpus)
}
}
// --- 1.4 P1: Config push with nil cluster argument is a no-op ---
// (Already covered by TestDispatchConfigPush_NilClusterIsSkipped in handler_test.go,
// but included here for integration plan completeness.)
// --- 1.5 P1: Config push with nil queues/resources/hosts is safe ---
// (Already covered by TestDispatchConfigPush_EmptySlicesNoOp in handler_test.go.)
// ========== §3. P1-1: Resource Value Push ==========
// --- 3.1 P0: All 7 value types convert correctly with store round-trip ---
func TestIntegration_ResourceValueRoundTrip_AllTypes(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
tests := []struct {
name string
raw rawResvalData
wantType int
check func(t *testing.T, got models.ResourceDistributionInfo)
}{
{
name: "i32",
raw: rawResvalData{name: "r_i32", host: "node01", valWhich: 0, i32: 16},
wantType: int(models.NUMERIC),
check: func(t *testing.T, got models.ResourceDistributionInfo) {
if got.NumericValue != 16 {
t.Errorf("NumericValue = %f, want 16", got.NumericValue)
}
},
},
{
name: "i64",
raw: rawResvalData{name: "r_i64", host: "node01", valWhich: 2, i64: 999},
wantType: int(models.NUMERIC),
check: func(t *testing.T, got models.ResourceDistributionInfo) {
if got.NumericValue != 999 {
t.Errorf("NumericValue = %f, want 999", got.NumericValue)
}
},
},
{
name: "u32",
raw: rawResvalData{name: "r_u32", host: "node01", valWhich: 1, u32: 4294967295},
wantType: int(models.NUMERIC),
check: func(t *testing.T, got models.ResourceDistributionInfo) {
if got.NumericValue != float64(uint32(4294967295)) {
t.Errorf("NumericValue = %f, want 4294967295", got.NumericValue)
}
},
},
{
name: "u64",
raw: rawResvalData{name: "r_u64", host: "node01", valWhich: 3, u64: 100},
wantType: int(models.NUMERIC),
check: func(t *testing.T, got models.ResourceDistributionInfo) {
if got.NumericValue != 100 {
t.Errorf("NumericValue = %f, want 100", got.NumericValue)
}
},
},
{
name: "fval",
raw: rawResvalData{name: "r_fval", host: "node01", valWhich: 5, fval: 3.14},
wantType: int(models.NUMERIC),
check: func(t *testing.T, got models.ResourceDistributionInfo) {
if got.NumericValue != 3.14 {
t.Errorf("NumericValue = %f, want 3.14", got.NumericValue)
}
},
},
{
name: "sval",
raw: rawResvalData{name: "r_sval", host: "node01", valWhich: 4, sval: "LINUX64"},
wantType: int(models.STRING),
check: func(t *testing.T, got models.ResourceDistributionInfo) {
if got.StringValue != "LINUX64" {
t.Errorf("StringValue = %q, want LINUX64", got.StringValue)
}
},
},
{
name: "bval",
raw: rawResvalData{name: "r_bval", host: "node01", valWhich: 6, bval: true},
wantType: int(models.BOOLEAN),
check: func(t *testing.T, got models.ResourceDistributionInfo) {
if !got.BooleanValue {
t.Errorf("BooleanValue = false, want true")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rd := rawResvalToDistribution(tt.raw)
if rd == nil {
t.Fatalf("rawResvalToDistribution returned nil for %s", tt.name)
}
h.OnResourceLocationUpdated(rd)
key := tt.raw.name + ":" + tt.raw.host
got, ok := store.ResourceLocations.Get(key)
if !ok {
t.Fatalf("store should contain key %q after OnResourceLocationUpdated", key)
}
if got.ValueType != tt.wantType {
t.Errorf("ValueType = %d, want %d", got.ValueType, tt.wantType)
}
tt.check(t, got)
})
}
}
// --- 3.2 P1: Nil val produces nil return (no store update) ---
func TestIntegration_NilValNoStoreUpdate(t *testing.T) {
resetIntegrationStores()
r := rawResvalData{name: "nilres", host: "node01", valNil: true}
got := rawResvalToDistribution(r)
if got != nil {
t.Errorf("expected nil for nil val, got %+v", got)
}
if store.ResourceLocations.Count() != 0 {
t.Errorf("ResourceLocations count = %d, want 0", store.ResourceLocations.Count())
}
}
// --- 3.3 P1: Unknown discriminant produces nil return ---
func TestIntegration_UnknownDiscriminantNoStoreUpdate(t *testing.T) {
resetIntegrationStores()
r := rawResvalData{name: "unkres", host: "node01", valWhich: 99}
got := rawResvalToDistribution(r)
if got != nil {
t.Errorf("expected nil for unknown discriminant, got %+v", got)
}
if store.ResourceLocations.Count() != 0 {
t.Errorf("ResourceLocations count = %d, want 0", store.ResourceLocations.Count())
}
}
// --- 3.4 P0: Multiple resource values in a single push ---
func TestIntegration_MultipleResourceValues(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "mem", Location: "node01", NumericValue: 65536.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "swap", Location: "node02", NumericValue: 32768.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
if store.ResourceLocations.Count() != 2 {
t.Errorf("ResourceLocations count = %d, want 2", store.ResourceLocations.Count())
}
got1, ok1 := store.ResourceLocations.Get("mem:node01")
if !ok1 || got1.NumericValue != 65536.0 {
t.Errorf("mem:node01 NumericValue = %f, want 65536.0", got1.NumericValue)
}
got2, ok2 := store.ResourceLocations.Get("swap:node02")
if !ok2 || got2.NumericValue != 32768.0 {
t.Errorf("swap:node02 NumericValue = %f, want 32768.0", got2.NumericValue)
}
}
// --- 3.5 P1: Resource value update overwrites previous value ---
func TestIntegration_ResourceValueOverwrite(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "mem", Location: "node01", NumericValue: 100.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "mem", Location: "node01", NumericValue: 200.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
got, ok := store.ResourceLocations.Get("mem:node01")
if !ok {
t.Fatal("mem:node01 should exist")
}
if got.NumericValue != 200.0 {
t.Errorf("NumericValue = %f, want 200.0 (last-write-wins)", got.NumericValue)
}
}
// --- 3.6 P1: Composite key format is res_name:location ---
func TestIntegration_CompositeKeyFormat(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "mem", Location: "node01", NumericValue: 42.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
if !store.ResourceLocations.Has("mem:node01") {
t.Error("store should have key 'mem:node01'")
}
if store.ResourceLocations.Has("mem") {
t.Error("store should NOT have key 'mem' (without location)")
}
}
// ========== §4. P1-3: Heartbeat Keep-Alive ==========
// NOTE: Tests MUST NOT call t.Parallel() — heartbeatIntervalSecs is package-level state.
// --- 4.1 P0: Heartbeat interval updates on cluster config push ---
func TestIntegration_HeartbeatInterval_UpdatesOnClusterPush(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
atomic.StoreInt32(&heartbeatIntervalSecs, 30)
h.OnClusterUpdated(&models.ClusterInfo{ClusterName: "c1", HeartbeatInterval: 45})
if got := atomic.LoadInt32(&heartbeatIntervalSecs); got != 45 {
t.Errorf("heartbeatIntervalSecs = %d, want 45", got)
}
}
// --- 4.2 P1: Cluster push with HeartbeatInterval=0 preserves current ---
func TestIntegration_HeartbeatInterval_ZeroPreservesCurrent(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
atomic.StoreInt32(&heartbeatIntervalSecs, 45)
h.OnClusterUpdated(&models.ClusterInfo{ClusterName: "c1", HeartbeatInterval: 0})
if got := atomic.LoadInt32(&heartbeatIntervalSecs); got != 45 {
t.Errorf("heartbeatIntervalSecs = %d, want 45 (zero should be ignored)", got)
}
}
// ========== §5. Cross-Feature Integration ==========
// --- 5.1 P0: Config push updates heartbeat interval AND stores cluster data ---
func TestIntegration_CrossFeature_HeartbeatAndClusterData(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
atomic.StoreInt32(&heartbeatIntervalSecs, 30)
dispatchConfigPush(h, nil, nil, nil, &models.ClusterInfo{
ClusterName: "c1", HeartbeatInterval: 60, Vcpus: 32,
})
got, ok := store.Clusters.Get("c1")
if !ok {
t.Fatal("c1 should exist in Clusters store")
}
if got.HeartbeatInterval != 60 {
t.Errorf("HeartbeatInterval = %d, want 60", got.HeartbeatInterval)
}
if got.Vcpus != 32 {
t.Errorf("Vcpus = %d, want 32", got.Vcpus)
}
if got := atomic.LoadInt32(&heartbeatIntervalSecs); got != 60 {
t.Errorf("heartbeatIntervalSecs = %d, want 60", got)
}
}
// --- 5.2 P1: Config push + host visibility in same host ---
func TestIntegration_CrossFeature_ConfigPushAndHostVisible(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
dispatchConfigPush(h, nil, nil, []models.HostInfo{
{HostName: "node01", HostModel: "x86_64"},
}, nil)
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("node01 should exist after config push")
}
if got.HostModel != "x86_64" {
t.Errorf("HostModel = %q, want x86_64", got.HostModel)
}
if !store.Hosts.Has("node01") {
t.Error("store.Hosts.Has('node01') should be true — config-push data is visible in the host store")
}
}
// --- 5.3 P1: Resource push after host config push — same host ---
func TestIntegration_CrossFeature_ResourceAfterHostConfig(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
h.OnHostConfigUpdated(&models.HostInfo{HostName: "node01", HostModel: "x86_64"})
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "mem", Location: "node01", NumericValue: 65536.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
gotHost, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("node01 should exist in Hosts store")
}
if gotHost.HostModel != "x86_64" {
t.Errorf("HostModel = %q, want x86_64 (should be preserved)", gotHost.HostModel)
}
gotRes, ok := store.ResourceLocations.Get("mem:node01")
if !ok {
t.Fatal("mem:node01 should exist in ResourceLocations")
}
if gotRes.NumericValue != 65536.0 {
t.Errorf("NumericValue = %f, want 65536.0", gotRes.NumericValue)
}
// Host config should not have been overwritten by resource push
gotHost2, _ := store.Hosts.Get("node01")
if gotHost2.HostModel != "x86_64" {
t.Errorf("HostModel after resource push = %q, want x86_64 (unchanged)", gotHost2.HostModel)
}
}
// --- 5.4 P1: Worker push after config push preserves config fields ---
func TestIntegration_CrossFeature_WorkerPreservesConfigFields(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
h.OnHostConfigUpdated(&models.HostInfo{HostName: "node01", HostModel: "x86_64", MaxSlot: 8})
h.OnHostUpdated(&models.HostInfo{HostName: "node01", HostStatus: 3, CPUFactor: 2.5, NumCPUs: 16})
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("node01 should exist after both pushes")
}
if got.HostModel != "x86_64" {
t.Errorf("HostModel = %q, want x86_64 (config preserved)", got.HostModel)
}
if got.MaxSlot != 8 {
t.Errorf("MaxSlot = %d, want 8 (config preserved)", got.MaxSlot)
}
if got.HostStatus != 3 {
t.Errorf("HostStatus = %d, want 3 (worker updated)", got.HostStatus)
}
if got.CPUFactor != 2.5 {
t.Errorf("CPUFactor = %f, want 2.5 (worker updated)", got.CPUFactor)
}
}
// --- 5.5 P1: Multiple sequential config pushes ---
func TestIntegration_CrossFeature_SequentialConfigPushes(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
atomic.StoreInt32(&heartbeatIntervalSecs, 30)
dispatchConfigPush(h, nil, nil, nil, &models.ClusterInfo{
ClusterName: "c1", Vcpus: 16, HeartbeatInterval: 30,
})
dispatchConfigPush(h, nil, nil, nil, &models.ClusterInfo{
ClusterName: "c1", Vcpus: 32, HeartbeatInterval: 60,
})
got, ok := store.Clusters.Get("c1")
if !ok {
t.Fatal("c1 should exist after pushes")
}
if got.Vcpus != 32 {
t.Errorf("Vcpus = %d, want 32 (last-write-wins)", got.Vcpus)
}
if got.HeartbeatInterval != 60 {
t.Errorf("HeartbeatInterval = %d, want 60", got.HeartbeatInterval)
}
if hb := atomic.LoadInt32(&heartbeatIntervalSecs); hb != 60 {
t.Errorf("heartbeatIntervalSecs = %d, want 60", hb)
}
}

View file

@ -0,0 +1,19 @@
#ifndef VAL_HELPERS_H
#define VAL_HELPERS_H
#include "xce/types.h"
// Thin accessors for xce_val_t union fields.
// CGO cannot access C union fields by name (they appear as [N]byte in Go),
// so these helpers extract typed values without unsafe pointer arithmetic.
static inline int xce_val_which(xce_val_t *v) { return v->which; }
static inline int32_t xce_val_i32(xce_val_t *v) { return v->u.ivalue32; }
static inline int64_t xce_val_i64(xce_val_t *v) { return v->u.ivalue64; }
static inline uint32_t xce_val_u32(xce_val_t *v) { return v->u.uvalue32; }
static inline uint64_t xce_val_u64(xce_val_t *v) { return v->u.uvalue64; }
static inline double xce_val_fval(xce_val_t *v) { return v->u.fvalue; }
static inline char* xce_val_str(xce_val_t *v) { return v->u.svalue; }
static inline int xce_val_bool(xce_val_t *v) { return v->u.bvalue; }
#endif /* VAL_HELPERS_H */