Skip to content
Draft
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
51 changes: 51 additions & 0 deletions pkg/interlink/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,57 @@ type LogStruct struct {
Opts ContainerLogOpts `json:"Opts"`
}

// PingResponse represents the optional structured response from the InterLink plugin ping endpoint.
// Plugins may return a JSON body with this structure to report their status and available resources.
// If the response body cannot be parsed as this structure, it is treated as a plain text response
// for backward compatibility.
type PingResponse struct {
// Status is the ping status string (e.g., "ok")
Status string `json:"status,omitempty"`
// Resources optionally contains resource capacity information reported by the plugin.
// When present, the Virtual Kubelet will update the node's Capacity and Allocatable fields.
Resources *ResourcesResponse `json:"resources,omitempty"`
// Taints optionally contains a list of taints to apply to the node.
// When present (even as an empty list), the node's non-system taints are replaced with
// this list. When absent, existing taints are left unchanged.
Taints *[]TaintResponse `json:"taints,omitempty"`
}

// TaintResponse represents a Kubernetes taint to be applied to the virtual node,
// as reported by a plugin in a ping response.
type TaintResponse struct {
// Key is the taint key (e.g., "virtual-node.interlink/no-schedule")
Key string `json:"key"`
// Value is the taint value (optional)
Value string `json:"value,omitempty"`
// Effect specifies the taint effect: "NoSchedule", "PreferNoSchedule", or "NoExecute"
Effect string `json:"effect"`
}

// ResourcesResponse represents the resource capacity information optionally returned by a plugin
// in a ping response. All fields are optional; omitted fields leave the current node capacity
// unchanged, preserving backward compatibility with plugins that do not report resources.
type ResourcesResponse struct {
// CPU specifies the total CPU capacity (e.g., "100", "2000m")
CPU string `json:"cpu,omitempty"`
// Memory specifies the total memory capacity (e.g., "128Gi", "64000Mi")
Memory string `json:"memory,omitempty"`
// Pods specifies the maximum number of pods this node can handle
Pods string `json:"pods,omitempty"`
// Accelerators lists hardware accelerators available on this node (GPUs, FPGAs, etc.)
Accelerators []AcceleratorResponse `json:"accelerators,omitempty"`
}

// AcceleratorResponse represents a hardware accelerator (GPU, FPGA, etc.) reported by a plugin
// in a ping response.
type AcceleratorResponse struct {
// ResourceType specifies the Kubernetes extended-resource name (e.g., "nvidia.com/gpu", "xilinx.com/fpga")
ResourceType string `json:"resourceType"`
// Available indicates how many units of this accelerator are available, expressed as a
// Kubernetes quantity (e.g., "8", "16")
Available string `json:"available"`
}

// SpanConfig holds configuration for OpenTelemetry spans.
// It's used to set additional attributes on tracing spans.
type SpanConfig struct {
Expand Down
123 changes: 123 additions & 0 deletions pkg/interlink/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,126 @@ func TestPodStatus_MultipleContainers(t *testing.T) {
assert.Equal(t, "container1", decoded.Containers[0].Name)
assert.Equal(t, "init1", decoded.InitContainers[0].Name)
}

func TestPingResponse_JSONSerialization(t *testing.T) {
tests := []struct {
name string
input string
wantResp PingResponse
}{
{
name: "status only",
input: `{"status":"ok"}`,
wantResp: PingResponse{
Status: "ok",
Resources: nil,
},
},
{
name: "status with resources",
input: `{"status":"ok","resources":{"cpu":"100","memory":"256Gi","pods":"1000"}}`,
wantResp: PingResponse{
Status: "ok",
Resources: &ResourcesResponse{
CPU: "100",
Memory: "256Gi",
Pods: "1000",
},
},
},
{
name: "status with resources and accelerators",
input: `{"status":"ok","resources":{"cpu":"50","memory":"128Gi","pods":"500","accelerators":[{"resourceType":"nvidia.com/gpu","available":"8"},{"resourceType":"xilinx.com/fpga","available":"2"}]}}`,
wantResp: PingResponse{
Status: "ok",
Resources: &ResourcesResponse{
CPU: "50",
Memory: "128Gi",
Pods: "500",
Accelerators: []AcceleratorResponse{
{ResourceType: "nvidia.com/gpu", Available: "8"},
{ResourceType: "xilinx.com/fpga", Available: "2"},
},
},
},
},
{
name: "empty response (backward compat plain text)",
input: ``,
wantResp: PingResponse{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.input == "" {
// Plain-text ping responses should fail to unmarshal gracefully
var resp PingResponse
err := json.Unmarshal([]byte(tt.input), &resp)
assert.Error(t, err, "empty string should fail JSON unmarshal")
return
}

var resp PingResponse
err := json.Unmarshal([]byte(tt.input), &resp)
require.NoError(t, err)
assert.Equal(t, tt.wantResp.Status, resp.Status)
if tt.wantResp.Resources == nil {
assert.Nil(t, resp.Resources)
} else {
require.NotNil(t, resp.Resources)
assert.Equal(t, tt.wantResp.Resources.CPU, resp.Resources.CPU)
assert.Equal(t, tt.wantResp.Resources.Memory, resp.Resources.Memory)
assert.Equal(t, tt.wantResp.Resources.Pods, resp.Resources.Pods)
assert.Equal(t, tt.wantResp.Resources.Accelerators, resp.Resources.Accelerators)
}
})
}
}

func TestPingResponse_RoundTrip(t *testing.T) {
original := PingResponse{
Status: "ok",
Resources: &ResourcesResponse{
CPU: "200",
Memory: "512Gi",
Pods: "2000",
Accelerators: []AcceleratorResponse{
{ResourceType: "nvidia.com/gpu", Available: "16"},
{ResourceType: "amd.com/gpu", Available: "4"},
},
},
}

data, err := json.Marshal(original)
require.NoError(t, err)

var decoded PingResponse
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)

assert.Equal(t, original.Status, decoded.Status)
require.NotNil(t, decoded.Resources)
assert.Equal(t, original.Resources.CPU, decoded.Resources.CPU)
assert.Equal(t, original.Resources.Memory, decoded.Resources.Memory)
assert.Equal(t, original.Resources.Pods, decoded.Resources.Pods)
assert.Equal(t, original.Resources.Accelerators, decoded.Resources.Accelerators)
}

func TestResourcesResponse_PartialFields(t *testing.T) {
// Only CPU is specified; other fields should be empty (omitted)
input := `{"cpu":"100"}`
var res ResourcesResponse
err := json.Unmarshal([]byte(input), &res)
require.NoError(t, err)
assert.Equal(t, "100", res.CPU)
assert.Empty(t, res.Memory)
assert.Empty(t, res.Pods)
assert.Empty(t, res.Accelerators)

// Marshal back — memory/pods/accelerators should be omitted
data, err := json.Marshal(res)
require.NoError(t, err)
assert.NotContains(t, string(data), `"memory"`)
assert.NotContains(t, string(data), `"pods"`)
}
138 changes: 138 additions & 0 deletions pkg/virtualkubelet/config_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package virtualkubelet

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
types "github.com/interlink-hq/interlink/pkg/interlink"
"k8s.io/apimachinery/pkg/api/resource"
)

Expand Down Expand Up @@ -176,3 +178,139 @@ func TestGetResources_AcceleratorQuantities(t *testing.T) {
fpgaQty := resourceList["xilinx.com/fpga"]
assert.Equal(t, int64(1), fpgaQty.Value(), "xilinx.com/fpga should be 1")
}

func TestUpdateNodeResources_CPUMemoryPods(t *testing.T) {
config := Config{
Resources: Resources{
CPU: "10",
Memory: "32Gi",
Pods: "100",
},
}
provider, err := NewProviderConfig(config, "test-node", "v1.0", "linux", "10.0.0.1", 10250, nil)
assert.NoError(t, err)

ctx := context.Background()
resources := &types.ResourcesResponse{
CPU: "200",
Memory: "512Gi",
Pods: "2000",
}
provider.updateNodeResources(ctx, resources)

cpuQty := provider.node.Status.Capacity["cpu"]
assert.Equal(t, int64(200), cpuQty.Value())
memQty := provider.node.Status.Capacity["memory"]
assert.Equal(t, "512Gi", memQty.String())
podsQty := provider.node.Status.Capacity["pods"]
assert.Equal(t, int64(2000), podsQty.Value())

// Allocatable should also be updated
allocCPU := provider.node.Status.Allocatable["cpu"]
assert.Equal(t, 0, cpuQty.Cmp(allocCPU), "allocatable CPU should match capacity CPU")
allocMem := provider.node.Status.Allocatable["memory"]
assert.Equal(t, 0, memQty.Cmp(allocMem), "allocatable memory should match capacity memory")
allocPods := provider.node.Status.Allocatable["pods"]
assert.Equal(t, 0, podsQty.Cmp(allocPods), "allocatable pods should match capacity pods")
}

func TestUpdateNodeResources_Accelerators(t *testing.T) {
config := Config{
Resources: Resources{
CPU: "10",
Memory: "32Gi",
Pods: "100",
},
}
provider, err := NewProviderConfig(config, "test-node", "v1.0", "linux", "10.0.0.1", 10250, nil)
assert.NoError(t, err)

ctx := context.Background()
resources := &types.ResourcesResponse{
Accelerators: []types.AcceleratorResponse{
{ResourceType: "nvidia.com/gpu", Available: "8"},
{ResourceType: "xilinx.com/fpga", Available: "2"},
},
}
provider.updateNodeResources(ctx, resources)

gpuQty := provider.node.Status.Capacity["nvidia.com/gpu"]
assert.Equal(t, int64(8), gpuQty.Value())
fpgaQty := provider.node.Status.Capacity["xilinx.com/fpga"]
assert.Equal(t, int64(2), fpgaQty.Value())
}

func TestUpdateNodeResources_InvalidValues(t *testing.T) {
config := Config{
Resources: Resources{
CPU: "10",
Memory: "32Gi",
Pods: "100",
},
}
provider, err := NewProviderConfig(config, "test-node", "v1.0", "linux", "10.0.0.1", 10250, nil)
assert.NoError(t, err)

originalCPU := provider.node.Status.Capacity["cpu"].DeepCopy()

ctx := context.Background()
resources := &types.ResourcesResponse{
CPU: "not-a-valid-quantity",
}
provider.updateNodeResources(ctx, resources)

// CPU should remain unchanged when invalid value is provided
currentCPU := provider.node.Status.Capacity["cpu"]
assert.Equal(t, originalCPU.Value(), currentCPU.Value())
}

func TestUpdateNodeResources_Nil(t *testing.T) {
config := Config{
Resources: Resources{
CPU: "10",
Memory: "32Gi",
Pods: "100",
},
}
provider, err := NewProviderConfig(config, "test-node", "v1.0", "linux", "10.0.0.1", 10250, nil)
assert.NoError(t, err)

originalCPU := provider.node.Status.Capacity["cpu"].DeepCopy()

ctx := context.Background()
// Passing nil should be a no-op
provider.updateNodeResources(ctx, nil)

currentCPU := provider.node.Status.Capacity["cpu"]
assert.Equal(t, originalCPU.Value(), currentCPU.Value())
}

func TestUpdateNodeResources_PartialUpdate(t *testing.T) {
config := Config{
Resources: Resources{
CPU: "10",
Memory: "32Gi",
Pods: "100",
},
}
provider, err := NewProviderConfig(config, "test-node", "v1.0", "linux", "10.0.0.1", 10250, nil)
assert.NoError(t, err)

originalMemory := provider.node.Status.Capacity["memory"].DeepCopy()
originalPods := provider.node.Status.Capacity["pods"].DeepCopy()

ctx := context.Background()
// Only update CPU, leave memory and pods unchanged
resources := &types.ResourcesResponse{
CPU: "500",
}
provider.updateNodeResources(ctx, resources)

cpuQty := provider.node.Status.Capacity["cpu"]
assert.Equal(t, int64(500), cpuQty.Value())
// Memory and pods should be unchanged
currentMemory := provider.node.Status.Capacity["memory"]
assert.Equal(t, originalMemory.Value(), currentMemory.Value())
currentPods := provider.node.Status.Capacity["pods"]
assert.Equal(t, originalPods.Value(), currentPods.Value())
}
Loading
Loading