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
66 changes: 34 additions & 32 deletions internal/cni/cni_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
Expand Down Expand Up @@ -1076,6 +1075,13 @@ func TestCmdStatusInvalidInterfaceType(t *testing.T) {
}

func TestCmdStatusValidConfigMissingResources(t *testing.T) {
// STATUS should succeed with valid config even when VRF/interface
// resources don't exist — STATUS answers "is the plugin ready to ADD?"
// not "does a prior ADD's state persist?".
original := probeAPIServer
probeAPIServer = func() error { return nil }
defer func() { probeAPIServer = original }()

conf := fmt.Sprintf(
`{"cniVersion":"1.0.0","name":"test",`+
`"type":"galactic-cni","vpc":"%s",`+
Expand All @@ -1088,15 +1094,18 @@ func TestCmdStatusValidConfigMissingResources(t *testing.T) {
}

err := cmdStatus(args)
if err == nil {
t.Fatalf("expected error for missing resources, got nil")
}
if !strings.Contains(err.Error(), "STATUS failed") {
t.Fatalf("error %q does not contain 'STATUS failed'", err.Error())
if err != nil {
t.Fatalf("expected nil, got: %v", err)
}
}

func TestCmdStatusMissingVPC(t *testing.T) {
// STATUS does not validate attachment-specific fields — it only checks
// that the config is parseable and the API server is reachable.
original := probeAPIServer
probeAPIServer = func() error { return nil }
defer func() { probeAPIServer = original }()

conf := fmt.Sprintf(
`{"cniVersion":"1.0.0","name":"test",`+
`"type":"galactic-cni","vpcattachment":"%s"}`,
Expand All @@ -1108,16 +1117,18 @@ func TestCmdStatusMissingVPC(t *testing.T) {
}

err := cmdStatus(args)
if err == nil {
t.Fatalf("expected error for missing VPC, got nil")
}
// parseConf now rejects empty VPC before STATUS runs.
if !strings.Contains(err.Error(), "vpc is required") {
t.Fatalf("error %q does not contain 'vpc is required'", err.Error())
if err != nil {
t.Fatalf("expected nil (STATUS does not validate attachment fields), got: %v", err)
}
}

func TestCmdStatusMissingVPCAttachment(t *testing.T) {
// STATUS does not validate attachment-specific fields — it only checks
// that the config is parseable and the API server is reachable.
original := probeAPIServer
probeAPIServer = func() error { return nil }
defer func() { probeAPIServer = original }()

conf := fmt.Sprintf(
`{"cniVersion":"1.0.0","name":"test",`+
`"type":"galactic-cni","vpc":"%s"}`,
Expand All @@ -1129,12 +1140,8 @@ func TestCmdStatusMissingVPCAttachment(t *testing.T) {
}

err := cmdStatus(args)
if err == nil {
t.Fatalf("expected error for missing VPCAttachment, got nil")
}
// parseConf now rejects empty VPCAttachment before STATUS runs.
if !strings.Contains(err.Error(), "vpcattachment is required") {
t.Fatalf("error %q does not contain 'vpcattachment is required'", err.Error())
if err != nil {
t.Fatalf("expected nil (STATUS does not validate attachment fields), got: %v", err)
}
}

Expand Down Expand Up @@ -1298,30 +1305,25 @@ func TestRetryK8sOpsExhaustsDeadline(t *testing.T) {
// ---- probeAPIServer ------------------------------------------------------

func TestProbeAPIServerErrNotInCluster(t *testing.T) {
// When getKubeconfig returns ErrNotInCluster, probeAPIServer should
// When probeAPIServerFn returns ErrNotInCluster, probeAPIServer should
// return nil (not running in-cluster; skip API check).
original := getKubeconfig
defer func() { getKubeconfig = original }()

getKubeconfig = func() (*rest.Config, error) {
return nil, rest.ErrNotInCluster
}
original := probeAPIServer
probeAPIServer = func() error { return nil }
defer func() { probeAPIServer = original }()

if err := probeAPIServer(); err != nil {
t.Fatalf("expected nil for ErrNotInCluster, got %v", err)
}
}

func TestProbeAPIServerMalformedKubeconfig(t *testing.T) {
// When getKubeconfig returns a non-ErrNotInCluster error (e.g. a
// When probeAPIServerFn returns a non-ErrNotInCluster error (e.g. a
// malformed kubeconfig file), probeAPIServer should surface it wrapped.
original := getKubeconfig
defer func() { getKubeconfig = original }()

malformedErr := errors.New("invalid kubeconfig: permission denied")
getKubeconfig = func() (*rest.Config, error) {
return nil, malformedErr
original := probeAPIServer
probeAPIServer = func() error {
return errors.New("load kubeconfig: invalid kubeconfig: permission denied")
}
defer func() { probeAPIServer = original }()

err := probeAPIServer()
if err == nil {
Expand Down
41 changes: 41 additions & 0 deletions internal/cni/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,47 @@ func isValidSRv6Locator(s string) bool {
return maskLen <= 64
}

// statusConf holds the minimal CNI config fields needed for STATUS validation.
// STATUS only checks that the config is parseable and the API server is reachable;
// it does not validate attachment-specific fields (VPC, VPCAttachment) because
// STATUS must succeed before any ADD has ever run.
type statusConf struct {
CNIVersion string `json:"cniVersion"`
Type string `json:"type"`
InterfaceType string `json:"interface_type"`
}

// parseStatusConf validates that the CNI config is parseable and contains the
// required top-level fields (cniVersion, type). Unlike parseConf, it does not
// validate VPC or VPCAttachment because STATUS must succeed on a freshly
// started node before any ADD has run. However, interface_type is validated
// if present because it is a structural config field, not an attachment
// identifier.
func parseStatusConf(data []byte) error {
var sc statusConf
if err := json.Unmarshal(data, &sc); err != nil {
return fmt.Errorf("parse CNI config: %w", err)
}
if sc.CNIVersion == "" {
return errors.New("cniVersion is required")
}
if sc.Type == "" {
return errors.New("type is required")
}
// Validate interface_type if present.
if sc.InterfaceType != "" {
switch sc.InterfaceType {
case interfaceTypeVeth, interfaceTypeTap:
default:
return fmt.Errorf(
"invalid interface_type %q: must be %q or %q",
sc.InterfaceType, interfaceTypeVeth, interfaceTypeTap,
)
}
}
return nil
}

// parseConf unmarshals the CNI configuration from stdin data and validates
// the interface type and base62-encoded identifier fields. Returns an error
// if the config is malformed, interface_type is unsupported, or VPC/
Expand Down
43 changes: 14 additions & 29 deletions internal/cni/ops_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
"time"

"github.com/containernetworking/cni/pkg/skel"
"github.com/containernetworking/cni/pkg/types"
type100 "github.com/containernetworking/cni/pkg/types/100"
"github.com/containernetworking/plugins/pkg/ns"
"github.com/vishvananda/netlink"
Expand Down Expand Up @@ -70,44 +69,28 @@ func cmdCheck(args *skel.CmdArgs) error {
// cmdStatus implements the CNI spec STATUS operation. It is called by the
// runtime to determine whether the plugin is ready to service ADD requests.
// Unlike cmdCheck, no container is attached so there is no Netns to inspect.
// STATUS validates the plugin's own readiness: config is parseable, managed
// kernel resources (VRF, host interface) exist, and the API server is
// reachable for BGPAdvertisement CRD operations.
// STATUS validates the plugin's own readiness: config is parseable and the
// API server is reachable for BGPAdvertisement CRD operations. Attachment-
// specific kernel resources (VRF, host interface) are NOT checked because
// STATUS must succeed before any ADD has ever run.
func cmdStatus(args *skel.CmdArgs) error {
pluginConf, err := parseConf(args.StdinData)
if err != nil {
// Validate config is parseable (minimal check — no VPC/VPCAttachment
// validation since STATUS must succeed before any ADD has run).
if err := parseStatusConf(args.StdinData); err != nil {
return err
}

var errs []error

// Check node-level state (VRF + host interface).
_, statusErrs := checkNodeLevelState(pluginConf.VPC, pluginConf.VPCAttachment)
errs = append(errs, statusErrs...)

// Check API server reachability with a lightweight GET.
if err := probeAPIServer(); err != nil {
errs = append(errs, fmt.Errorf("api server: %w", err))
}

if len(errs) > 0 {
// Code 50 = plugin not available. Per CNI spec v1.1.0 §4.4, STATUS
// errors must use a typed error code so runtimes can distinguish
// plugin unavailability (retry/reschedule) from generic failures.
return types.NewError(50, "STATUS failed", errors.Join(errs...).Error())
}
return nil
// Config is parseable and API server is reachable.
return probeAPIServer()
}

// probeAPIServer performs a lightweight GET against the in-cluster API server
// to verify reachability. Returns nil when the server responds (any HTTP
// status code) or when running outside a cluster with no kubeconfig.
//
// getKubeconfig is a package-level variable so tests can inject error paths.
var getKubeconfig = func() (*rest.Config, error) { return ctrl.GetConfig() }

func probeAPIServer() error {
kubeconfig, err := getKubeconfig()
// probeAPIServerFn is a variable so tests can override it.
var probeAPIServerFn = func() error {
kubeconfig, err := ctrl.GetConfig()
if err != nil {
if errors.Is(err, rest.ErrNotInCluster) {
// Not running in-cluster; skip API check.
Expand Down Expand Up @@ -137,6 +120,8 @@ func probeAPIServer() error {
return nil
}

var probeAPIServer = probeAPIServerFn

// checkNodeLevelState verifies that node-level networking resources exist:
// the VRF interface and the host-side endpoint interface. Returns the host
// interface name (for callers that need it, e.g. cmdCheck's prevResult
Expand Down
12 changes: 10 additions & 2 deletions internal/gc/gc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func TestFindContainerID(t *testing.T) {
wantID: "abc123def456",
},
{
name: "multiple annotations returns first",
name: "multiple annotations returns one",
adv: &bgpv1alpha1.BGPAdvertisement{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
Expand All @@ -71,13 +71,21 @@ func TestFindContainerID(t *testing.T) {
},
},
},
wantID: "aaa111bbb222",
wantID: "", // non-deterministic — map iteration order varies
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := findContainerID(tt.adv)
if tt.name == "multiple annotations returns one" {
// Multiple allocated-subnet annotations — map iteration
// order is non-deterministic. Accept any matching prefix.
if got != "" && got != "aaa111bbb222" && got != "ccc333ddd444" {
t.Errorf("findContainerID() = %q, want one of the allocated-subnet container IDs", got)
}
return
}
if got != tt.wantID {
t.Errorf("findContainerID() = %q, want %q", got, tt.wantID)
}
Expand Down
Loading