From f4d9fe4a90d4cb4f1db2bddb46bd14c9533f9b32 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Mon, 11 May 2026 15:20:33 +0100 Subject: [PATCH 01/11] Add Calico fields to model/ocp/NetworkConfig, add UTs to solidify the expected behaviour Emit Calico annotations for VM template in vSphere builder use ipAddrs annotation rather than ipAddrsNoIpam. + update some docstrings validation+testing for calico in VSphere, + stubs in other providers. * When implicit-VLAN is used, infer the correct VLAN from the l2Bridge spec, rather than reporting VLAN=0. * Richer error reporting when JSON formatting fails on fetching NAD. adds new CalicoIssue when there IS an l2Bridge spec, but no VLAN inside Factors-out NetworkConfig fetch&parse where duplicated code existed Note: pkg/controller/plan/validation.go seemed to have a bug where, if the last NAD in the list failed to parse, the resultant error would not follow the same (log&continue) codepath as the prior NADs. Instead, the err would bubble out of the for-loop and get retured by `validateUserDefinedNetwork`. This commit makes the error handling consistent for all iterations. separate pure-Calico misconfigurations from VM config mismatches *Caches fetched NADs in mapNetworks to avoid repeated fetches for the same NAD. *Adds new Calico Issue "NAD unreadable" *Increased UT test-case coverage Grant forklift-controller RBAC for Calico Network/IPPool reads Signed-off-by: Alex O'Regan --- operator/.downstream_manifests | 9 + operator/.upstream_manifests | 9 + .../config/rbac/forklift-controller_role.yaml | 11 + pkg/controller/plan/adapter/base/calico.go | 40 ++ .../plan/adapter/base/calico_test.go | 114 ++++ .../plan/adapter/base/calico_validation.go | 45 ++ pkg/controller/plan/adapter/base/doc.go | 49 ++ pkg/controller/plan/adapter/base/nad.go | 22 + pkg/controller/plan/adapter/base/nad_test.go | 147 ++++++ .../plan/adapter/hyperv/validator.go | 12 + .../plan/adapter/nutanix/validator.go | 12 + pkg/controller/plan/adapter/ocp/validator.go | 12 + .../plan/adapter/openstack/validator.go | 12 + .../plan/adapter/ovfbase/validator.go | 12 + .../plan/adapter/ovirt/validator.go | 12 + .../plan/adapter/vsphere/builder.go | 42 +- .../plan/adapter/vsphere/builder_test.go | 232 +++++++++ .../plan/adapter/vsphere/validator.go | 264 +++++++++- .../plan/adapter/vsphere/validator_test.go | 487 ++++++++++++++++++ pkg/controller/plan/validation.go | 129 ++++- pkg/controller/provider/model/ocp/model.go | 29 +- .../provider/model/ocp/model_test.go | 137 +++++ pkg/controller/provider/web/ocp/base.go | 4 +- pkg/lib/client/calico/ippool.go | 122 +++++ pkg/lib/client/calico/ippool_test.go | 206 ++++++++ pkg/lib/client/calico/network.go | 126 +++++ pkg/lib/client/calico/network_test.go | 204 ++++++++ pkg/provider/ec2/controller/validator/noop.go | 10 + 28 files changed, 2488 insertions(+), 22 deletions(-) create mode 100644 pkg/controller/plan/adapter/base/calico.go create mode 100644 pkg/controller/plan/adapter/base/calico_test.go create mode 100644 pkg/controller/plan/adapter/base/calico_validation.go create mode 100644 pkg/controller/plan/adapter/base/nad.go create mode 100644 pkg/controller/plan/adapter/base/nad_test.go create mode 100644 pkg/controller/provider/model/ocp/model_test.go create mode 100644 pkg/lib/client/calico/ippool.go create mode 100644 pkg/lib/client/calico/ippool_test.go create mode 100644 pkg/lib/client/calico/network.go create mode 100644 pkg/lib/client/calico/network_test.go diff --git a/operator/.downstream_manifests b/operator/.downstream_manifests index 8f7b19ac2a..c102ae1051 100644 --- a/operator/.downstream_manifests +++ b/operator/.downstream_manifests @@ -11222,6 +11222,15 @@ rules: - get - list - watch +- apiGroups: + - projectcalico.org + resources: + - networks + - ippools + verbs: + - get + - list + - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/operator/.upstream_manifests b/operator/.upstream_manifests index c52a98a721..f7990f3321 100644 --- a/operator/.upstream_manifests +++ b/operator/.upstream_manifests @@ -11222,6 +11222,15 @@ rules: - get - list - watch +- apiGroups: + - projectcalico.org + resources: + - networks + - ippools + verbs: + - get + - list + - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/operator/config/rbac/forklift-controller_role.yaml b/operator/config/rbac/forklift-controller_role.yaml index b8e2d13f1b..e7e3886450 100644 --- a/operator/config/rbac/forklift-controller_role.yaml +++ b/operator/config/rbac/forklift-controller_role.yaml @@ -244,6 +244,17 @@ rules: - routes verbs: - create + - get + - list + - watch +# Calico L2 (PMREQ-810): the Plan validator reads Calico Network and IPPool +# CRs to validate L2-bridge NAD destinations and per-VM static IPs. +- apiGroups: + - projectcalico.org + resources: + - networks + - ippools + verbs: - get - list - watch \ No newline at end of file diff --git a/pkg/controller/plan/adapter/base/calico.go b/pkg/controller/plan/adapter/base/calico.go new file mode 100644 index 0000000000..bcf21fe778 --- /dev/null +++ b/pkg/controller/plan/adapter/base/calico.go @@ -0,0 +1,40 @@ +package base + +import ( + "encoding/json" + "fmt" + + meta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + CalicoAnnHwAddrFmt = "cni.projectcalico.org/%s.hwAddr" + CalicoAnnIPsFmt = "cni.projectcalico.org/%s.ipAddrs" +) + +// SetCalicoMAC writes the cni.projectcalico.org/.hwAddr annotation +// onto m. Lazy-inits Annotations when nil. +func SetCalicoMAC(m *meta.ObjectMeta, ifname, mac string) { + if m.Annotations == nil { + m.Annotations = map[string]string{} + } + m.Annotations[fmt.Sprintf(CalicoAnnHwAddrFmt, ifname)] = mac +} + +// SetCalicoStaticIPs JSON-marshals ips and writes the +// cni.projectcalico.org/.ipAddrs annotation. No-op when ips is empty. +// Lazy-inits Annotations when nil. +func SetCalicoStaticIPs(m *meta.ObjectMeta, ifname string, ips []string) error { + if len(ips) == 0 { + return nil + } + encoded, err := json.Marshal(ips) + if err != nil { + return err + } + if m.Annotations == nil { + m.Annotations = map[string]string{} + } + m.Annotations[fmt.Sprintf(CalicoAnnIPsFmt, ifname)] = string(encoded) + return nil +} diff --git a/pkg/controller/plan/adapter/base/calico_test.go b/pkg/controller/plan/adapter/base/calico_test.go new file mode 100644 index 0000000000..eeaa28b40c --- /dev/null +++ b/pkg/controller/plan/adapter/base/calico_test.go @@ -0,0 +1,114 @@ +package base + +import ( + "testing" + + meta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSetCalicoMAC(t *testing.T) { + tests := []struct { + name string + initial map[string]string + ifname string + mac string + wantKey string + wantVal string + }{ + { + name: "NilAnnotationsLazyInits", + initial: nil, + ifname: "net-0", + mac: "aa:bb:cc:dd:ee:ff", + wantKey: "cni.projectcalico.org/net-0.hwAddr", + wantVal: "aa:bb:cc:dd:ee:ff", + }, + { + name: "ExistingAnnotationsPreserved", + initial: map[string]string{"foo": "bar"}, + ifname: "net-1", + mac: "11:22:33:44:55:66", + wantKey: "cni.projectcalico.org/net-1.hwAddr", + wantVal: "11:22:33:44:55:66", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &meta.ObjectMeta{Annotations: tt.initial} + SetCalicoMAC(m, tt.ifname, tt.mac) + if got := m.Annotations[tt.wantKey]; got != tt.wantVal { + t.Errorf("annotations[%q] = %q, want %q", tt.wantKey, got, tt.wantVal) + } + if tt.initial != nil { + if got := m.Annotations["foo"]; got != "bar" { + t.Errorf("pre-existing annotation lost: %q", got) + } + } + }) + } +} + +func TestSetCalicoStaticIPs(t *testing.T) { + tests := []struct { + name string + initial map[string]string + ifname string + ips []string + wantKey string + wantVal string + wantMissed bool // when true, expect the key to NOT be present + }{ + { + name: "SingleIP", + ifname: "net-0", + ips: []string{"10.0.0.5"}, + wantKey: "cni.projectcalico.org/net-0.ipAddrs", + wantVal: `["10.0.0.5"]`, + }, + { + name: "MultipleIPs", + ifname: "net-1", + ips: []string{"10.0.0.5", "10.0.0.6"}, + wantKey: "cni.projectcalico.org/net-1.ipAddrs", + wantVal: `["10.0.0.5","10.0.0.6"]`, + }, + { + name: "EmptySliceIsNoOp", + ifname: "net-0", + ips: nil, + wantKey: "cni.projectcalico.org/net-0.ipAddrs", + wantMissed: true, + }, + { + name: "ExistingAnnotationsPreserved", + initial: map[string]string{"foo": "bar"}, + ifname: "net-0", + ips: []string{"10.0.0.5"}, + wantKey: "cni.projectcalico.org/net-0.ipAddrs", + wantVal: `["10.0.0.5"]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &meta.ObjectMeta{Annotations: tt.initial} + if err := SetCalicoStaticIPs(m, tt.ifname, tt.ips); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, present := m.Annotations[tt.wantKey] + if tt.wantMissed { + if present { + t.Errorf("annotations[%q] present = %q, want missing", tt.wantKey, got) + } + return + } + if got != tt.wantVal { + t.Errorf("annotations[%q] = %q, want %q", tt.wantKey, got, tt.wantVal) + } + if tt.initial != nil { + if v := m.Annotations["foo"]; v != "bar" { + t.Errorf("pre-existing annotation lost: %q", v) + } + } + }) + } +} diff --git a/pkg/controller/plan/adapter/base/calico_validation.go b/pkg/controller/plan/adapter/base/calico_validation.go new file mode 100644 index 0000000000..6b763f3f0d --- /dev/null +++ b/pkg/controller/plan/adapter/base/calico_validation.go @@ -0,0 +1,45 @@ +package base + +import ( + calicoclient "github.com/kubev2v/forklift/pkg/lib/client/calico" + "k8s.io/apimachinery/pkg/types" +) + +// ResolvedCalicoNAD captures the destination-cluster resources backing a +// Calico-referencing NAD after all resource-level validations have passed. +// Per-VM checks read from this directly instead of re-fetching Network and +// IPPool objects for every VM. +type ResolvedCalicoNAD struct { + // Network is the Calico Network CR name referenced by the NAD. + Network string + // VLAN is the resolved l2Bridge VLAN entry (subnets non-empty). + VLAN calicoclient.VLANEntry + // EligiblePools are the IPPools whose CIDRs overlap any subnet in VLAN. + EligiblePools []calicoclient.IPPool +} + +// CalicoValidationCache holds resolved state for every Calico-referencing +// NAD that passed plan-level validation. NADs with any resource-level issue +// are absent from the map: per-VM checks treat that as "skip silently — the +// failure is already surfaced at plan level". +type CalicoValidationCache struct { + NADs map[types.NamespacedName]*ResolvedCalicoNAD +} + +// CalicoNADIssue is a resource-level Calico failure tied to a specific NAD +// rather than a VM. Surfaced by ValidateCalicoNADs and rendered into the +// plan-level CalicoNetworkInvalid condition. +type CalicoNADIssue struct { + NAD types.NamespacedName + Kind CalicoIssueKind + Network string + VLAN uint16 +} + +// CalicoValidationResult is the output of ValidateCalicoNADs: +// resource-level issues to report at plan level, and a cache of healthy +// NADs for downstream per-VM checks. +type CalicoValidationResult struct { + Issues []CalicoNADIssue + Cache *CalicoValidationCache +} diff --git a/pkg/controller/plan/adapter/base/doc.go b/pkg/controller/plan/adapter/base/doc.go index a6ab933ba8..ddf10cda57 100644 --- a/pkg/controller/plan/adapter/base/doc.go +++ b/pkg/controller/plan/adapter/base/doc.go @@ -300,6 +300,55 @@ type Validator interface { GuestToolsInstalled(vmRef ref.Ref) (ok bool, err error) // Validate that VM does not need to collapse any snapshots into a single base file ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) + // ValidateCalicoNADs validates every Calico-referencing NAD in the + // plan's network map. Issues are NAD-scoped (network/IPPool config); + // the returned cache is consumed by CalicoVMIssues. + ValidateCalicoNADs(client client.Client) (CalicoValidationResult, error) + // CalicoVMIssues returns per-VM Calico issues (IP membership in subnet + // / IPPool). Reads only from the cache produced by ValidateCalicoNADs; + // VMs whose mapped NAD failed plan-level validation are skipped here + // — their failure is already reported via CalicoNetworkInvalid. + CalicoVMIssues(vmRef ref.Ref, cache *CalicoValidationCache) ([]CalicoIssue, error) +} + +// CalicoIssueKind enumerates the Calico Network failure modes. +type CalicoIssueKind string + +const ( + // CalicoIssueNADUnreadable indicates the destination NAD could not be + // fetched or parsed (NotFound, malformed JSON, transient API error). The + // NAD's Calico configuration is unknowable until this is resolved. + CalicoIssueNADUnreadable CalicoIssueKind = "NADUnreadable" + // CalicoIssueNetworkNotFound no Network CR existed. + CalicoIssueNetworkNotFound CalicoIssueKind = "NetworkNotFound" + // CalicoIssueNetworkHasNoL2Bridge Network CR existed but had no L2Bridge field spec'd. + CalicoIssueNetworkHasNoL2Bridge CalicoIssueKind = "NetworkHasNoL2Bridge" + // CalicoIssueNetworkHasNoVLANs Network CR's L2Bridge had an empty vlans list (no VLAN to select). + CalicoIssueNetworkHasNoVLANs CalicoIssueKind = "NetworkHasNoVLANs" + // CalicoIssueVLANNotInNetwork NIC's NAD entry's VLAN was not present in the referenced Network CR. + CalicoIssueVLANNotInNetwork CalicoIssueKind = "VLANNotInNetwork" + // CalicoIssueVLANAmbiguous NIC's NAD entry had no VLAN, and Network CR had more than one VLAN to choose from. + CalicoIssueVLANAmbiguous CalicoIssueKind = "VLANAmbiguous" + // CalicoIssueVLANHasNoIPPool no IPPool existed satisfying the VLAN subnet's requirements. + CalicoIssueVLANHasNoIPPool CalicoIssueKind = "VLANHasNoIPPool" + // CalicoIssueIPNotInSubnet NIC's IP was not in any Network.spec.l2Bridge.vlans[].subnets[].cidr. + CalicoIssueIPNotInSubnet CalicoIssueKind = "IPNotInSubnet" + // CalicoIssueIPNotInIPPool NIC's IP was not in any Calico IPPool. + CalicoIssueIPNotInIPPool CalicoIssueKind = "IPNotInIPPool" +) + +// CalicoIssue represents a per-VM Calico Network validation failure: the +// VM's NIC IP does not fit the destination's Calico Network VLAN subnet or +// IPPool. NAD-level issues (NetworkNotFound, NetworkHasNoL2Bridge, etc.) +// are surfaced via CalicoNADIssue, not this type. +type CalicoIssue struct { + Kind CalicoIssueKind + // Network is the Calico Network CR name reference. + Network string + // VLAN is the resolved l2Bridge.vlans[].vlan.id (always non-zero). + VLAN uint16 + // IP is the source VM IP. + IP string } // DestinationClient API. diff --git a/pkg/controller/plan/adapter/base/nad.go b/pkg/controller/plan/adapter/base/nad.go new file mode 100644 index 0000000000..3e6166ae0a --- /dev/null +++ b/pkg/controller/plan/adapter/base/nad.go @@ -0,0 +1,22 @@ +package base + +import ( + "context" + + k8snet "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" + model "github.com/kubev2v/forklift/pkg/controller/provider/model/ocp" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// FetchAndParseNAD GETs the NetworkAttachmentDefinition at namespace/name from +// the destination cluster and unmarshals its Spec.Config into a +// model.NetworkConfig. +// An empty Spec.Config yields a zero-valued NetworkConfig and no error. +func FetchAndParseNAD(ctx context.Context, c client.Client, namespace, name string) (*model.NetworkConfig, error) { + nad := &k8snet.NetworkAttachmentDefinition{} + key := client.ObjectKey{Namespace: namespace, Name: name} + if err := c.Get(ctx, key, nad); err != nil { + return nil, err + } + return model.ParseNAD(nad) +} diff --git a/pkg/controller/plan/adapter/base/nad_test.go b/pkg/controller/plan/adapter/base/nad_test.go new file mode 100644 index 0000000000..a735729ee7 --- /dev/null +++ b/pkg/controller/plan/adapter/base/nad_test.go @@ -0,0 +1,147 @@ +package base + +import ( + "context" + "testing" + + k8snet "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" + model "github.com/kubev2v/forklift/pkg/controller/provider/model/ocp" + k8serr "k8s.io/apimachinery/pkg/api/errors" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newFakeClientWithNADs(nads ...*k8snet.NetworkAttachmentDefinition) (*fake.ClientBuilder, error) { + scheme := runtime.NewScheme() + if err := k8snet.AddToScheme(scheme); err != nil { + return nil, err + } + b := fake.NewClientBuilder().WithScheme(scheme) + for _, nad := range nads { + b = b.WithRuntimeObjects(nad) + } + return b, nil +} + +func TestFetchAndParseNAD(t *testing.T) { + calicoL2Config := `{"type":"calico","network":"datacenter-vlans","vlan":100,"ipam":{"type":"calico-ipam"}}` + ovnKConfig := `{"type":"ovn-k8s-cni-overlay","role":"primary","subnets":"10.0.0.0/24","topology":"layer3"}` + + calicoNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: meta.ObjectMeta{Namespace: "default", Name: "calico-l2"}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: calicoL2Config}, + } + ovnNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: meta.ObjectMeta{Namespace: "default", Name: "ovn-udn"}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: ovnKConfig}, + } + emptyConfigNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: meta.ObjectMeta{Namespace: "default", Name: "empty"}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: ""}, + } + malformedNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: meta.ObjectMeta{Namespace: "default", Name: "broken"}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: "not json"}, + } + + cb, err := newFakeClientWithNADs(calicoNAD, ovnNAD, emptyConfigNAD, malformedNAD) + if err != nil { + t.Fatalf("scheme setup: %v", err) + } + c := cb.Build() + + tests := []struct { + name string + namespace string + nadName string + wantNil bool + wantErr bool + wantNotFound bool + check func(*testing.T, *model.NetworkConfig) + }{ + { + name: "CalicoL2Found", + namespace: "default", + nadName: "calico-l2", + check: func(t *testing.T, c *model.NetworkConfig) { + if c.Type != model.CalicoCNIType { + t.Errorf("Type = %q, want %q", c.Type, model.CalicoCNIType) + } + if c.Network != "datacenter-vlans" { + t.Errorf("Network = %q, want datacenter-vlans", c.Network) + } + if c.VLAN != 100 { + t.Errorf("VLAN = %d, want 100", c.VLAN) + } + if !c.ReferencesCalicoNetwork() { + t.Errorf("ReferencesCalicoNetwork() = false, want true") + } + }, + }, + { + name: "OvnKFound", + namespace: "default", + nadName: "ovn-udn", + check: func(t *testing.T, c *model.NetworkConfig) { + if c.Type != model.OvnOverlayType { + t.Errorf("Type = %q, want %q", c.Type, model.OvnOverlayType) + } + if c.ReferencesCalicoNetwork() { + t.Errorf("ReferencesCalicoNetwork() = true, want false") + } + }, + }, + { + name: "EmptyConfigYieldsZeroValueAndNoError", + namespace: "default", + nadName: "empty", + check: func(t *testing.T, c *model.NetworkConfig) { + if c.Type != "" || c.Network != "" || c.VLAN != 0 { + t.Errorf("got non-zero config from empty Spec.Config: %+v", c) + } + if c.ReferencesCalicoNetwork() { + t.Errorf("ReferencesCalicoNetwork() = true on zero config, want false") + } + }, + }, + { + name: "NotFoundPropagatesAsError", + namespace: "default", + nadName: "missing", + wantNil: true, + wantErr: true, + wantNotFound: true, + }, + { + name: "MalformedJSONReturnsError", + namespace: "default", + nadName: "broken", + wantNil: true, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := FetchAndParseNAD(context.Background(), c, tt.namespace, tt.nadName) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) + } + if tt.wantNotFound && !k8serr.IsNotFound(err) { + t.Errorf("err = %v, want IsNotFound", err) + } + if tt.wantNil { + if cfg != nil { + t.Errorf("cfg = %+v, want nil", cfg) + } + return + } + if cfg == nil { + t.Fatal("cfg = nil, want non-nil") + } + if tt.check != nil { + tt.check(t, cfg) + } + }) + } +} diff --git a/pkg/controller/plan/adapter/hyperv/validator.go b/pkg/controller/plan/adapter/hyperv/validator.go index ee0feef2d4..001bab946f 100644 --- a/pkg/controller/plan/adapter/hyperv/validator.go +++ b/pkg/controller/plan/adapter/hyperv/validator.go @@ -285,3 +285,15 @@ func (r *Validator) GuestToolsInstalled(_ ref.Ref) (bool, error) { func (r *Validator) ConsolidationNeeded(_ ref.Ref) (bool, error) { return false, nil } + +// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a +// target for Calico-Network IP/MAC preservation today. +func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { + return planbase.CalicoValidationResult{}, nil +} + +// CalicoVMIssues returns no issues. Non-vSphere providers aren't a target +// for Calico-Network IP/MAC preservation today. +func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/nutanix/validator.go b/pkg/controller/plan/adapter/nutanix/validator.go index dea3f054c8..88434c6ae3 100644 --- a/pkg/controller/plan/adapter/nutanix/validator.go +++ b/pkg/controller/plan/adapter/nutanix/validator.go @@ -102,3 +102,15 @@ func (r *Validator) GuestToolsInstalled(_ ref.Ref) (bool, error) { func (r *Validator) ConsolidationNeeded(_ ref.Ref) (bool, error) { return false, nil } + +// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a +// target for Calico-Network IP/MAC preservation today. +func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { + return planbase.CalicoValidationResult{}, nil +} + +// CalicoVMIssues returns no issues. Non-vSphere providers aren't a target +// for Calico-Network IP/MAC preservation today. +func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/ocp/validator.go b/pkg/controller/plan/adapter/ocp/validator.go index 6b4d5732ae..cb2a79ebd0 100644 --- a/pkg/controller/plan/adapter/ocp/validator.go +++ b/pkg/controller/plan/adapter/ocp/validator.go @@ -405,3 +405,15 @@ func (r *Validator) PVCNameTemplate(vmRef ref.Ref, pvcNameTemplate string) (ok b return true, nil } + +// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a +// target for Calico-Network IP/MAC preservation today. +func (r *Validator) ValidateCalicoNADs(_ k8sclient.Client) (planbase.CalicoValidationResult, error) { + return planbase.CalicoValidationResult{}, nil +} + +// CalicoVMIssues returns no issues. Non-vSphere providers aren't a target +// for Calico-Network IP/MAC preservation today. +func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/openstack/validator.go b/pkg/controller/plan/adapter/openstack/validator.go index 1396be2206..979f9612e2 100644 --- a/pkg/controller/plan/adapter/openstack/validator.go +++ b/pkg/controller/plan/adapter/openstack/validator.go @@ -390,3 +390,15 @@ func (r *Validator) GuestToolsInstalled(vmRef ref.Ref) (ok bool, err error) { func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) { return } + +// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a +// target for Calico-Network IP/MAC preservation today. +func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { + return planbase.CalicoValidationResult{}, nil +} + +// CalicoVMIssues returns no issues. Non-vSphere providers aren't a target +// for Calico-Network IP/MAC preservation today. +func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/ovfbase/validator.go b/pkg/controller/plan/adapter/ovfbase/validator.go index 109d2b047d..1696824f92 100644 --- a/pkg/controller/plan/adapter/ovfbase/validator.go +++ b/pkg/controller/plan/adapter/ovfbase/validator.go @@ -222,3 +222,15 @@ func (r *Validator) GuestToolsInstalled(vmRef ref.Ref) (ok bool, err error) { func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) { return } + +// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a +// target for Calico-Network IP/MAC preservation today. +func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { + return planbase.CalicoValidationResult{}, nil +} + +// CalicoVMIssues returns no issues. Non-vSphere providers aren't a target +// for Calico-Network IP/MAC preservation today. +func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/ovirt/validator.go b/pkg/controller/plan/adapter/ovirt/validator.go index df0c23f7ac..b153841578 100644 --- a/pkg/controller/plan/adapter/ovirt/validator.go +++ b/pkg/controller/plan/adapter/ovirt/validator.go @@ -299,3 +299,15 @@ func (r *Validator) GuestToolsInstalled(vmRef ref.Ref) (ok bool, err error) { func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) { return } + +// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a +// target for Calico-Network IP/MAC preservation today. +func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { + return planbase.CalicoValidationResult{}, nil +} + +// CalicoVMIssues returns no issues. Non-vSphere providers aren't a target +// for Calico-Network IP/MAC preservation today. +func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/vsphere/builder.go b/pkg/controller/plan/adapter/vsphere/builder.go index 608c99a7d8..3878e63304 100644 --- a/pkg/controller/plan/adapter/vsphere/builder.go +++ b/pkg/controller/plan/adapter/vsphere/builder.go @@ -27,6 +27,7 @@ import ( planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" utils "github.com/kubev2v/forklift/pkg/controller/plan/util" + ocpmodel "github.com/kubev2v/forklift/pkg/controller/provider/model/ocp" "github.com/kubev2v/forklift/pkg/controller/provider/model/vsphere" "github.com/kubev2v/forklift/pkg/controller/provider/web" model "github.com/kubev2v/forklift/pkg/controller/provider/web/vsphere" @@ -46,6 +47,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + k8stypes "k8s.io/apimachinery/pkg/types" k8svalidation "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/version" "k8s.io/utils/ptr" @@ -888,7 +890,7 @@ func isIPv4(address string) bool { return ip != nil && ip.To4() != nil } -func (r *Builder) findInterfaceIps(vm *model.VM, nic vsphere.NIC) []string { +func findInterfaceIps(vm *model.VM, nic vsphere.NIC) []string { var interfaceIps []string for _, net := range vm.GuestNetworks { if net.DeviceConfigId == nic.DeviceKey { @@ -904,6 +906,11 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err var kNetworks []cnv.Network var kInterfaces []cnv.Interface staticIpInterfaces := make(map[string][]string) + calicoMacInterfaces := map[string]string{} + calicoIpInterfaces := map[string][]string{} + + // cache for network configs to avoid duplicate GETs. + nadCache := map[k8stypes.NamespacedName]*ocpmodel.NetworkConfig{} numNetworks := 0 hasUDN := r.Plan.DestinationHasUdnNetwork(r.Destination) @@ -964,7 +971,7 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err Name: planbase.UdnL2bridge, } if r.Plan.Spec.PreserveStaticIPs { - ips := r.findInterfaceIps(vm, nic) + ips := findInterfaceIps(vm, nic) if len(ips) > 0 { staticIpInterfaces[networkName] = ips } @@ -977,6 +984,28 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err NetworkName: path.Join(mapped.Destination.Namespace, mapped.Destination.Name), } kInterface.Bridge = &cnv.InterfaceBridge{} + + nadKey := k8stypes.NamespacedName{ + Namespace: mapped.Destination.Namespace, + Name: mapped.Destination.Name, + } + cfg, cached := nadCache[nadKey] + if !cached { + cfg, err = planbase.FetchAndParseNAD(context.TODO(), r.Destination.Client, + nadKey.Namespace, nadKey.Name) + if err != nil { + return err + } + nadCache[nadKey] = cfg + } + if cfg != nil && cfg.ReferencesCalicoNetwork() { + calicoMacInterfaces[networkName] = nic.MAC + if r.Plan.Spec.PreserveStaticIPs { + if ips := findInterfaceIps(vm, nic); len(ips) > 0 { + calicoIpInterfaces[networkName] = ips + } + } + } } kNetworks = append(kNetworks, kNetwork) @@ -997,6 +1026,15 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err } object.Template.ObjectMeta.Annotations[planbase.AnnStaticUdnIp] = string(staticIpInterfacesAnnotation) } + + for ifname, mac := range calicoMacInterfaces { + planbase.SetCalicoMAC(&object.Template.ObjectMeta, ifname, mac) + } + for ifname, ips := range calicoIpInterfaces { + if err = planbase.SetCalicoStaticIPs(&object.Template.ObjectMeta, ifname, ips); err != nil { + return + } + } return } diff --git a/pkg/controller/plan/adapter/vsphere/builder_test.go b/pkg/controller/plan/adapter/vsphere/builder_test.go index 8f967bada3..8bd17034bd 100644 --- a/pkg/controller/plan/adapter/vsphere/builder_test.go +++ b/pkg/controller/plan/adapter/vsphere/builder_test.go @@ -2,7 +2,9 @@ package vsphere import ( "context" + "fmt" + k8snet "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" v1beta1 "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1/plan" "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1/ref" @@ -1217,6 +1219,235 @@ var _ = Describe("vSphere builder", func() { Entry("should set instance UUID correctly", "12345", "", "12345"), Entry("should set BIOS UUID if instance UUID is missing", "", "54321", "54321"), ) + + Context("mapNetworks Calico annotations", func() { + const ( + netID = "net-id-1" + netKey = "net-key-1" + ifname = "net-0" + nicMAC = "aa:bb:cc:dd:ee:01" + nicIP = "10.0.0.5" + nadName = "calico-l2-nad" + hwAnnKey = "cni.projectcalico.org/net-0.hwAddr" + ipsAnnKey = "cni.projectcalico.org/net-0.ipAddrs" + ) + buildAndCall := func(nadConfig string, preserveIPs bool) (map[string]string, error) { + nad := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: meta.ObjectMeta{Namespace: "test", Name: nadName}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: nadConfig}, + } + builder := createBuilder(nad) + builder.Source.Inventory = &mockInventory{ + networks: map[string]model.Network{ + netID: {Resource: model.Resource{ID: netID}, Variant: vsphere.NetDvPortGroup, Key: netKey}, + }, + } + builder.Plan.Spec.PreserveStaticIPs = preserveIPs + builder.Context.Map.Network = &v1beta1.NetworkMap{ + Spec: v1beta1.NetworkMapSpec{ + Map: []v1beta1.NetworkPair{{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: netID}}, + Destination: v1beta1.DestinationNetwork{ + Type: "multus", Namespace: "test", Name: nadName, + }, + }}, + }, + } + vm := &model.VM{ + NICs: []vsphere.NIC{{ + Network: vsphere.Ref{ID: netKey}, + MAC: nicMAC, + DeviceKey: 4001, + }}, + GuestNetworks: []vsphere.GuestNetwork{{ + MAC: nicMAC, + IP: nicIP, + DeviceConfigId: 4001, + Origin: ManualOrigin, + PrefixLength: 24, + }}, + } + spec := &cnv.VirtualMachineSpec{Template: &cnv.VirtualMachineInstanceTemplateSpec{}} + err := builder.mapNetworks(vm, spec) + return spec.Template.ObjectMeta.Annotations, err + } + + It("emits MAC and IP annotations for a Calico L2 NAD with PreserveStaticIPs", func() { + annotations, err := buildAndCall(`{"type":"calico","network":"datacenter-vlans","vlan":100}`, true) + Expect(err).NotTo(HaveOccurred()) + Expect(annotations).To(HaveKeyWithValue(hwAnnKey, nicMAC)) + Expect(annotations).To(HaveKeyWithValue(ipsAnnKey, fmt.Sprintf(`["%s"]`, nicIP))) + }) + + It("emits MAC only when PreserveStaticIPs is false", func() { + annotations, err := buildAndCall(`{"type":"calico","network":"datacenter-vlans"}`, false) + Expect(err).NotTo(HaveOccurred()) + Expect(annotations).To(HaveKeyWithValue(hwAnnKey, nicMAC)) + Expect(annotations).NotTo(HaveKey(ipsAnnKey)) + }) + + It("emits nothing for a Calico L3 NAD (no network field)", func() { + annotations, err := buildAndCall(`{"type":"calico"}`, true) + Expect(err).NotTo(HaveOccurred()) + Expect(annotations).NotTo(HaveKey(hwAnnKey)) + Expect(annotations).NotTo(HaveKey(ipsAnnKey)) + }) + + It("emits nothing for an OVN-K UDN NAD", func() { + annotations, err := buildAndCall(`{"type":"ovn-k8s-cni-overlay","subnets":"10.0.0.0/24"}`, true) + Expect(err).NotTo(HaveOccurred()) + Expect(annotations).NotTo(HaveKey(hwAnnKey)) + Expect(annotations).NotTo(HaveKey(ipsAnnKey)) + }) + + It("emits nothing for a NAD with empty Spec.Config", func() { + annotations, err := buildAndCall("", true) + Expect(err).NotTo(HaveOccurred()) + Expect(annotations).NotTo(HaveKey(hwAnnKey)) + Expect(annotations).NotTo(HaveKey(ipsAnnKey)) + }) + + // Multi-NIC support — the builder iterates vm.NICs and emits a per-NIC + // kInterface (net-0, net-1, …) plus per-interface Calico annotations. + // runMulti is a more general helper accepting one entry per NIC. + type nicSpec struct { + netID string // source network ID (matched via mockInventory) + mac string + deviceKey int32 + ip string // empty → no GuestNetworks entry for this NIC + } + type nadSpec struct { + name string + config string + } + runMulti := func(nics []nicSpec, nads []nadSpec, preserveIPs bool, mapPairs []v1beta1.NetworkPair) (map[string]string, error) { + objs := []runtime.Object{} + for _, n := range nads { + objs = append(objs, &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: meta.ObjectMeta{Namespace: "test", Name: n.name}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: n.config}, + }) + } + builder := createBuilder(objs...) + networks := map[string]model.Network{} + for _, n := range nics { + networks[n.netID] = model.Network{ + Resource: model.Resource{ID: n.netID}, + Variant: vsphere.NetDvPortGroup, + Key: n.netID, // match by ID in buildNICResolver + } + } + builder.Source.Inventory = &mockInventory{networks: networks} + builder.Plan.Spec.PreserveStaticIPs = preserveIPs + builder.Context.Map.Network = &v1beta1.NetworkMap{ + Spec: v1beta1.NetworkMapSpec{Map: mapPairs}, + } + vmNICs := make([]vsphere.NIC, 0, len(nics)) + vmGuestNets := []vsphere.GuestNetwork{} + for _, n := range nics { + vmNICs = append(vmNICs, vsphere.NIC{ + Network: vsphere.Ref{ID: n.netID}, + MAC: n.mac, + DeviceKey: n.deviceKey, + }) + if n.ip != "" { + vmGuestNets = append(vmGuestNets, vsphere.GuestNetwork{ + MAC: n.mac, + IP: n.ip, + DeviceConfigId: n.deviceKey, + Origin: ManualOrigin, + PrefixLength: 24, + }) + } + } + vm := &model.VM{NICs: vmNICs, GuestNetworks: vmGuestNets} + spec := &cnv.VirtualMachineSpec{Template: &cnv.VirtualMachineInstanceTemplateSpec{}} + err := builder.mapNetworks(vm, spec) + return spec.Template.ObjectMeta.Annotations, err + } + calicoL2 := func(name string) string { + return fmt.Sprintf(`{"type":"calico","network":"%s"}`, name) + } + + It("emits per-interface annotations for 3 NICs on 3 distinct Calico L2 NADs", func() { + nics := []nicSpec{ + {netID: "src-a", mac: "aa:bb:cc:00:00:01", deviceKey: 4001, ip: "10.0.0.5"}, + {netID: "src-b", mac: "aa:bb:cc:00:00:02", deviceKey: 4002, ip: "10.0.1.5"}, + {netID: "src-c", mac: "aa:bb:cc:00:00:03", deviceKey: 4003, ip: "10.0.2.5"}, + } + nads := []nadSpec{ + {name: "nad-a", config: calicoL2("net-a")}, + {name: "nad-b", config: calicoL2("net-b")}, + {name: "nad-c", config: calicoL2("net-c")}, + } + pairs := []v1beta1.NetworkPair{ + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-a"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "nad-a"}}, + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-b"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "nad-b"}}, + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-c"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "nad-c"}}, + } + ann, err := runMulti(nics, nads, true, pairs) + Expect(err).NotTo(HaveOccurred()) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-0.hwAddr", "aa:bb:cc:00:00:01")) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-1.hwAddr", "aa:bb:cc:00:00:02")) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-2.hwAddr", "aa:bb:cc:00:00:03")) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-0.ipAddrs", `["10.0.0.5"]`)) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-1.ipAddrs", `["10.0.1.5"]`)) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-2.ipAddrs", `["10.0.2.5"]`)) + }) + + It("emits 3 distinct annotation sets when multiple NICs reference the same Calico Network via distinct NADs", func() { + // Each NIC gets its own NAD via the NAD pool; all three NADs name + // the same Calico Network. Invariant under test: per-interface + // annotation keys (net-0/net-1/net-2) stay distinct even when the + // underlying Calico Network is shared. + nics := []nicSpec{ + {netID: "src-a", mac: "aa:bb:cc:00:00:01", deviceKey: 4001}, + {netID: "src-b", mac: "aa:bb:cc:00:00:02", deviceKey: 4002}, + {netID: "src-c", mac: "aa:bb:cc:00:00:03", deviceKey: 4003}, + } + nads := []nadSpec{ + {name: "calico-nad-a", config: calicoL2("shared-net")}, + {name: "calico-nad-b", config: calicoL2("shared-net")}, + {name: "calico-nad-c", config: calicoL2("shared-net")}, + } + pairs := []v1beta1.NetworkPair{ + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-a"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "calico-nad-a"}}, + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-b"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "calico-nad-b"}}, + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-c"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "calico-nad-c"}}, + } + ann, err := runMulti(nics, nads, false, pairs) + Expect(err).NotTo(HaveOccurred()) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-0.hwAddr", "aa:bb:cc:00:00:01")) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-1.hwAddr", "aa:bb:cc:00:00:02")) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-2.hwAddr", "aa:bb:cc:00:00:03")) + }) + + It("gates per-NIC: emits annotations only for the NICs whose destination NAD is Calico L2", func() { + // NIC 0: Calico L2 → expect annotations. + // NIC 1: plain Multus, no CNI config → expect nothing. + // NIC 2: Calico L3 (type=calico but no `network` field) → expect nothing. + nics := []nicSpec{ + {netID: "src-calico", mac: "aa:bb:cc:00:00:01", deviceKey: 4001}, + {netID: "src-plain", mac: "aa:bb:cc:00:00:02", deviceKey: 4002}, + {netID: "src-l3", mac: "aa:bb:cc:00:00:03", deviceKey: 4003}, + } + nads := []nadSpec{ + {name: "calico-nad", config: calicoL2("net-a")}, + {name: "plain-nad", config: ""}, + {name: "calico-l3-nad", config: `{"type":"calico"}`}, + } + pairs := []v1beta1.NetworkPair{ + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-calico"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "calico-nad"}}, + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-plain"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "plain-nad"}}, + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-l3"}}, Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "calico-l3-nad"}}, + } + ann, err := runMulti(nics, nads, false, pairs) + Expect(err).NotTo(HaveOccurred()) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-0.hwAddr", "aa:bb:cc:00:00:01")) + Expect(ann).NotTo(HaveKey("cni.projectcalico.org/net-1.hwAddr")) + Expect(ann).NotTo(HaveKey("cni.projectcalico.org/net-2.hwAddr")) + }) + }) }) var _ = Describe("PopulatorOffloadInfo", func() { @@ -1948,6 +2179,7 @@ func createBuilder(objs ...runtime.Object) *Builder { _ = core.AddToScheme(scheme) _ = rbacv1.AddToScheme(scheme) _ = storagev1.AddToScheme(scheme) + _ = k8snet.AddToScheme(scheme) v1beta1.SchemeBuilder.AddToScheme(scheme) client := fake.NewClientBuilder(). WithScheme(scheme). diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index 36e4ccfa81..f7bd7f1ae4 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -2,7 +2,6 @@ package vsphere import ( "context" - "encoding/json" "fmt" "net" "strings" @@ -19,10 +18,14 @@ import ( "github.com/kubev2v/forklift/pkg/controller/provider/web/base" model "github.com/kubev2v/forklift/pkg/controller/provider/web/vsphere" "github.com/kubev2v/forklift/pkg/controller/validation" + calicoclient "github.com/kubev2v/forklift/pkg/lib/client/calico" liberr "github.com/kubev2v/forklift/pkg/lib/error" "github.com/vmware/govmomi/vim25/types" core "k8s.io/api/core/v1" - "sigs.k8s.io/controller-runtime/pkg/client" + k8serr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + k8stypes "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" ) const ( @@ -303,7 +306,7 @@ func (r *Validator) shouldMigrateSharedDisks(vm *model.VM) bool { return r.Plan.Spec.MigrateSharedDisks } -func (r *Validator) SharedDisks(vmRef ref.Ref, client client.Client) (ok bool, msg string, category string, err error) { +func (r *Validator) SharedDisks(vmRef ref.Ref, client k8sclient.Client) (ok bool, msg string, category string, err error) { vm := &model.VM{} err = r.Source.Inventory.Find(vm, vmRef) if err != nil { @@ -487,8 +490,8 @@ func rootDiskExcluded(vm *model.VM, rootDiskSpec string, exclude []string) (bus return root.BusAddress, excluded } -func (r *Validator) getUdnSubnet(k8sClient client.Client) (string, error) { - key := client.ObjectKey{ +func (r *Validator) getUdnSubnet(k8sClient k8sclient.Client) (string, error) { + key := k8sclient.ObjectKey{ Name: r.Plan.Spec.TargetNamespace, } namespace := &core.Namespace{} @@ -502,9 +505,9 @@ func (r *Validator) getUdnSubnet(k8sClient client.Client) (string, error) { } nadList := &k8snet.NetworkAttachmentDefinitionList{} - listOpts := []client.ListOption{ - client.InNamespace(r.Plan.Spec.TargetNamespace), - client.MatchingLabels{nadLabelUDN: ""}, + listOpts := []k8sclient.ListOption{ + k8sclient.InNamespace(r.Plan.Spec.TargetNamespace), + k8sclient.MatchingLabels{nadLabelUDN: ""}, } err = k8sClient.List(context.TODO(), nadList, listOpts...) @@ -512,8 +515,7 @@ func (r *Validator) getUdnSubnet(k8sClient client.Client) (string, error) { return "", err } for _, nad := range nadList.Items { - var networkConfig ocpmodel.NetworkConfig - err = json.Unmarshal([]byte(nad.Spec.Config), &networkConfig) + networkConfig, err := ocpmodel.ParseNAD(&nad) if err != nil { r.Log.Info("Skipping NAD: failed to parse network config", "namespace", nad.Namespace, "name", nad.Name, "error", err.Error()) continue @@ -549,7 +551,7 @@ func (r *Validator) getSourceNetworkForPodNetworkTarget(vmRef ref.Ref) (net *mod return } -func (r *Validator) UdnStaticIPs(vmRef ref.Ref, client client.Client) (ok bool, err error) { +func (r *Validator) UdnStaticIPs(vmRef ref.Ref, client k8sclient.Client) (ok bool, err error) { // Check static IPs if !r.Plan.DestinationHasUdnNetwork(client) { return true, nil @@ -729,3 +731,243 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) } return vm.ConsolidationNeeded, nil } + +// ValidateCalicoNADs walks every Multus destination in the plan's network +// map, fetches each Calico-referencing NAD, and validates the NAD/Network/ +// IPPool resources. NADs that pass all checks are recorded in the returned +// cache for downstream per-VM checks (see CalicoVMIssues). +// +// Resource-level short-circuit ordering matches the legacy per-VM walk: +// failure to find the Network or to resolve a VLAN entry prevents the +// IPPool check; failure of the IPPool check excludes the NAD from the +// cache entirely. +func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValidationResult, error) { + result := planbase.CalicoValidationResult{ + Cache: &planbase.CalicoValidationCache{ + NADs: map[k8stypes.NamespacedName]*planbase.ResolvedCalicoNAD{}, + }, + } + if r.Plan.Referenced.Map.Network == nil { + return result, nil + } + + seenNAD := map[k8stypes.NamespacedName]struct{}{} + var pools []calicoclient.IPPool + poolsLoaded := false + + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + if pair.Destination.Type != planbase.Multus { + continue + } + key := k8stypes.NamespacedName{ + Namespace: pair.Destination.Namespace, + Name: pair.Destination.Name, + } + if _, dup := seenNAD[key]; dup { + continue + } + seenNAD[key] = struct{}{} + + cfg, err := planbase.FetchAndParseNAD(context.TODO(), c, key.Namespace, key.Name) + if err != nil { + if r.Log != nil { + r.Log.Error(err, "Calico NAD: failed to fetch/parse", + "namespace", key.Namespace, "name", key.Name) + } + result.Issues = append(result.Issues, planbase.CalicoNADIssue{ + NAD: key, + Kind: planbase.CalicoIssueNADUnreadable, + }) + continue + } + if !cfg.ReferencesCalicoNetwork() { + continue + } + + issueBase := planbase.CalicoNADIssue{NAD: key, Network: cfg.Network, VLAN: cfg.VLAN} + + nw, err := calicoclient.GetNetwork(context.TODO(), c, cfg.Network) + if err != nil { + // IsNoMatchError covers clusters with no projectcalico.org/v3 + // CRD installed — the Network kind itself is unknown to the API + // server. From the user's perspective this is indistinguishable + // from a missing Network CR. + if k8serr.IsNotFound(err) || meta.IsNoMatchError(err) { + issueBase.Kind = planbase.CalicoIssueNetworkNotFound + result.Issues = append(result.Issues, issueBase) + continue + } + return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String(), "network", cfg.Network) + } + if nw.L2Bridge == nil { + issueBase.Kind = planbase.CalicoIssueNetworkHasNoL2Bridge + result.Issues = append(result.Issues, issueBase) + continue + } + + entry, vlanIssueKind := resolveVLANEntry(nw.L2Bridge.VLANs, cfg.VLAN) + if vlanIssueKind != "" { + issueBase.Kind = vlanIssueKind + result.Issues = append(result.Issues, issueBase) + continue + } + // Past this point the NAD's VLAN has been resolved to a concrete + // Network entry; report that VID downstream rather than the raw + // (possibly-zero) NAD value. + issueBase.VLAN = entry.VID + + if !poolsLoaded { + pools, err = calicoclient.ListIPPools(context.TODO(), c) + if err != nil { + return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + } + poolsLoaded = true + } + if !calicoclient.HasEligiblePool(pools, entry.Subnets) { + issueBase.Kind = planbase.CalicoIssueVLANHasNoIPPool + result.Issues = append(result.Issues, issueBase) + continue + } + + eligible := calicoclient.EligiblePools(pools, entry.Subnets) + result.Cache.NADs[key] = &planbase.ResolvedCalicoNAD{ + Network: cfg.Network, + VLAN: *entry, + EligiblePools: eligible, + } + } + return result, nil +} + +// CalicoVMIssues returns per-VM Calico issues for vmRef using the cache +// from ValidateCalicoNADs. Per-NIC checks fire only when +// plan.Spec.PreserveStaticIPs is true. NICs whose mapped NAD is not in the +// cache are silently skipped: the NAD's failure was already reported at +// plan level via CalicoNetworkInvalid. +// +// Issues are deduplicated by {Kind, Network, VLAN, IP}, so two NICs +// hitting the same failure mode yield a single issue. IPNotInSubnet +// short-circuits IPNotInIPPool for the same IP. +func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { + if !r.Plan.Spec.PreserveStaticIPs { + return nil, nil + } + if cache == nil || len(cache.NADs) == 0 { + return nil, nil + } + if r.Plan.Referenced.Map.Network == nil { + return nil, nil + } + vm := &model.VM{} + if err := r.Source.Inventory.Find(vm, vmRef); err != nil { + return nil, liberr.Wrap(err, "vm", vmRef.String()) + } + + var issues []planbase.CalicoIssue + seen := map[planbase.CalicoIssue]struct{}{} + emit := func(i planbase.CalicoIssue) { + if _, ok := seen[i]; ok { + return + } + seen[i] = struct{}{} + issues = append(issues, i) + } + nadPool := planbase.NewNADPool() + nicKeys, pairsBySource, err := r.buildNICResolver(vm.NICs) + if err != nil { + return nil, liberr.Wrap(err, "vm", vmRef) + } + + for i, nic := range vm.NICs { + pair, allocated := planbase.AllocateNetwork(nadPool, pairsBySource[nicKeys[i]]) + if !allocated || pair.Destination.Type != planbase.Multus { + continue + } + key := k8stypes.NamespacedName{ + Namespace: pair.Destination.Namespace, + Name: pair.Destination.Name, + } + resolved, ok := cache.NADs[key] + if !ok { + continue + } + issueBase := planbase.CalicoIssue{Network: resolved.Network, VLAN: resolved.VLAN.VID} + for _, ip := range findInterfaceIps(vm, nic) { + perIP := issueBase + perIP.IP = ip + if !ipInAnySubnet(ip, resolved.VLAN.Subnets) { + perIP.Kind = planbase.CalicoIssueIPNotInSubnet + emit(perIP) + continue + } + if calicoclient.EligiblePoolForIP(resolved.EligiblePools, ip, resolved.VLAN.Subnets) == nil { + perIP.Kind = planbase.CalicoIssueIPNotInIPPool + emit(perIP) + } + } + } + return issues, nil +} + +// buildNICResolver indexes the NetworkMap pairs by source-network ID and Key +// so a per-NIC lookup returns every candidate destination. Mirrors the +// Builder's resolver so the Validator validates exactly what the Builder +// will allocate. +func (r *Validator) buildNICResolver(nics []vsphere.NIC) ([]string, map[string][]api.NetworkPair, error) { + pairsBySource := map[string][]api.NetworkPair{} + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + network := &model.Network{} + if err := r.Source.Inventory.Find(network, pair.Source.Ref); err != nil { + return nil, nil, liberr.Wrap(err, "buildNICResolver, source", pair.Source.String()) + } + if network.Variant == vsphere.NetDvPortGroup || network.Variant == vsphere.OpaqueNetwork { + pairsBySource[network.Key] = append(pairsBySource[network.Key], pair) + } + pairsBySource[network.ID] = append(pairsBySource[network.ID], pair) + } + nicKeys := make([]string, len(nics)) + for i, nic := range nics { + nicKeys[i] = nic.Network.ID + } + return nicKeys, pairsBySource, nil +} + +// resolveVLANEntry returns the l2Bridge.vlans[] entry matched by nadVLAN. +// When no entry matches, returns nil entry plus a non-empty CalicoIssueKind +// describing the failure: NetworkHasNoVLANs (vlans list is empty), +// VLANAmbiguous (NAD omits vlan and Network has multiple entries), or +// VLANNotInNetwork (NAD's vlan is absent from the Network's entries). +func resolveVLANEntry(vlans []calicoclient.VLANEntry, nadVLAN uint16) (*calicoclient.VLANEntry, planbase.CalicoIssueKind) { + if len(vlans) == 0 { + return nil, planbase.CalicoIssueNetworkHasNoVLANs + } + if nadVLAN == 0 { + if len(vlans) > 1 { + return nil, planbase.CalicoIssueVLANAmbiguous + } + return &vlans[0], "" + } + for i := range vlans { + if vlans[i].VID == nadVLAN { + return &vlans[i], "" + } + } + return nil, planbase.CalicoIssueVLANNotInNetwork +} + +func ipInAnySubnet(ip string, subnets []string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return false + } + for _, s := range subnets { + _, n, err := net.ParseCIDR(s) + if err != nil { + continue + } + if n.Contains(parsed) { + return true + } + } + return false +} diff --git a/pkg/controller/plan/adapter/vsphere/validator_test.go b/pkg/controller/plan/adapter/vsphere/validator_test.go index d8640c8494..7443d4b7bf 100644 --- a/pkg/controller/plan/adapter/vsphere/validator_test.go +++ b/pkg/controller/plan/adapter/vsphere/validator_test.go @@ -3,7 +3,9 @@ package vsphere import ( "errors" + "fmt" + k8snet "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" v1beta1 "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" planapi "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1/plan" "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1/ref" @@ -14,10 +16,16 @@ import ( "github.com/kubev2v/forklift/pkg/controller/provider/web/base" model "github.com/kubev2v/forklift/pkg/controller/provider/web/vsphere" "github.com/kubev2v/forklift/pkg/controller/validation" + calicoclient "github.com/kubev2v/forklift/pkg/lib/client/calico" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) var ErrNotImplemented = errors.New("not implemented") @@ -664,6 +672,485 @@ var _ = Describe("vsphere validation tests", func() { Entry("non-root disk with explicit root is valid", []string{"scsi0:0"}, "/dev/sdb", true, "", ""), ) + Describe("Calico Network validation", func() { + // Reusable identifiers across cases. + const ( + srcNetID = "src-1" + nadName = "calico-nad" + nadNS = "workloads" + netName = "vlan100" + ) + + makeCalicoNAD := func(vlan int) *k8snet.NetworkAttachmentDefinition { + cfg := fmt.Sprintf(`{"type":"calico","network":"%s","vlan":%d}`, netName, vlan) + return &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: nadName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: cfg}, + } + } + makeNetwork := func(spec map[string]interface{}) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(calicoclient.NetworkGVK) + u.SetName(netName) + if spec != nil { + _ = unstructured.SetNestedField(u.Object, spec, "spec") + } + return u + } + makeIPPool := func(name, cidr string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(calicoclient.IPPoolGVK) + u.SetName(name) + _ = unstructured.SetNestedField(u.Object, cidr, "spec", "cidr") + return u + } + // L2Bridge spec helpers — VLAN 100 maps to subnet 10.100.0.0/24. + l2Single := map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(100)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.100.0.0/24"}}, + }, + }, + }, + } + l2Multi := map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(100)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.100.0.0/24"}}, + }, + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(200)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.200.0.0/24"}}, + }, + }, + }, + } + + // setup builds a Validator + fake client. nicIP is the NIC's guest IP, + // preserveIPs controls Plan.Spec.PreserveStaticIPs. + setup := func(nicIP string, preserveIPs bool, k8sObjs ...runtime.Object) (*Validator, client.Client, ref.Ref) { + scheme := runtime.NewScheme() + _ = k8snet.AddToScheme(scheme) + scheme.AddKnownTypeWithName(calicoclient.NetworkGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.NetworkGVK.GroupVersion().WithKind("NetworkList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK.GroupVersion().WithKind("IPPoolList"), &unstructured.UnstructuredList{}) + c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(k8sObjs...).Build() + + vm := model.VM{ + VM1: model.VM1{VM0: model.VM0{ID: "test-vm-id", Name: "test-vm"}}, + NICs: []vsphere.NIC{{Network: vsphere.Ref{ID: srcNetID}, DeviceKey: 4001}}, + GuestNetworks: []vsphere.GuestNetwork{ + {IP: nicIP, DeviceConfigId: 4001}, + }, + } + inventory := &mockInventory{ + vm: vm, + networks: map[string]model.Network{ + srcNetID: {Resource: model.Resource{ID: srcNetID}, Variant: vsphere.NetDvPortGroup, Key: srcNetID}, + }, + } + plan := createPlan() + plan.Spec.PreserveStaticIPs = preserveIPs + plan.Referenced.Map.Network = &v1beta1.NetworkMap{ + Spec: v1beta1.NetworkMapSpec{ + Map: []v1beta1.NetworkPair{{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: srcNetID}}, + Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: nadName, + }, + }}, + }, + } + ctx := plancontext.Context{Plan: plan, Source: plancontext.Source{Inventory: inventory}} + return &Validator{Context: &ctx}, c, ref.Ref{Name: "test-vm-id", ID: "test-vm-id"} + } + + // nadIssueKinds extracts the set of NAD-issue kinds from a slice. + nadIssueKinds := func(issues []planbase.CalicoNADIssue) []planbase.CalicoIssueKind { + out := make([]planbase.CalicoIssueKind, 0, len(issues)) + for _, i := range issues { + out = append(out, i.Kind) + } + return out + } + + Describe("ValidateCalicoNADs (plan-level)", func() { + It("returns empty results when NetworkMap is nil", func() { + v, c, _ := setup("10.100.0.5", true) + v.Plan.Referenced.Map.Network = nil + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache).NotTo(BeNil()) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + + It("happy path — populates cache, no issues, when Network, VLAN, IPPool all line up", func() { + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24"), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.NADs).To(HaveLen(1)) + entry := result.Cache.NADs[k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}] + Expect(entry).NotTo(BeNil()) + Expect(entry.VLAN.VID).To(Equal(uint16(100))) + Expect(entry.EligiblePools).To(HaveLen(1)) + }) + + It("emits NetworkNotFound when the referenced Network is missing", func() { + v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(100)) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, + Kind: planbase.CalicoIssueNetworkNotFound, + Network: netName, + VLAN: 100, + })) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + + It("emits NetworkHasNoL2Bridge when the Network exists but has no l2Bridge", func() { + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(map[string]interface{}{}), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkHasNoL2Bridge)) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + + It("emits NetworkHasNoVLANs when the Network's l2Bridge.vlans list is empty", func() { + // L2Bridge is spec'd but vlans is empty — distinct from + // NetworkHasNoL2Bridge (no l2Bridge at all). Should not be + // reported as VLANAmbiguous even though the NAD omits vlan. + emptyVLANs := map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{}, + }, + } + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), makeNetwork(emptyVLANs), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkHasNoVLANs)) + }) + + It("emits NetworkHasNoVLANs even when the NAD specifies a vlan ID", func() { + // Same root cause: Network has no VLAN entries. The NAD's vlan + // value is moot — there's nothing to match against. + emptyVLANs := map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{}, + }, + } + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(emptyVLANs), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkHasNoVLANs)) + }) + + It("emits VLANNotInNetwork when the NAD vlan ID doesn't match any entry", func() { + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(999), makeNetwork(l2Single), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANNotInNetwork)) + }) + + It("emits VLANAmbiguous when the NAD omits vlan and Network has multiple entries", func() { + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), makeNetwork(l2Multi), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANAmbiguous)) + }) + + It("emits VLANHasNoIPPool when no IPPool overlaps the VLAN subnet", func() { + v, c, _ := setup("10.100.0.5", false, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("cluster-default", "10.0.0.0/8"), // pool too large + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANHasNoIPPool)) + }) + + It("resolves implicit VLAN (NAD omits vlan, Network has one entry) to the Network's VID in the cache", func() { + // NAD has vlan=0 (omitted); Network has exactly one entry with + // id=100. The cache entry must carry the resolved VID, so per-VM + // checks (and downstream condition messages) see VLAN=100. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24"), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + entry := result.Cache.NADs[k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}] + Expect(entry).NotTo(BeNil()) + Expect(entry.VLAN.VID).To(Equal(uint16(100))) + }) + + It("dedupes the same NAD when referenced by multiple network-map pairs", func() { + // Same NAD destination referenced by two source networks. Without + // dedup, ValidateCalicoNADs would emit NetworkNotFound twice and + // double-fetch the NAD. + v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(100)) + v.Plan.Referenced.Map.Network.Spec.Map = append( + v.Plan.Referenced.Map.Network.Spec.Map, + v1beta1.NetworkPair{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-2"}}, + Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: nadName, + }, + }, + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(HaveLen(1)) + Expect(result.Issues[0].Kind).To(Equal(planbase.CalicoIssueNetworkNotFound)) + }) + + It("emits NADUnreadable when the network map references a missing NAD", func() { + // Network map destination points at a NAD that doesn't exist on + // the destination cluster. ValidateCalicoNADs must not propagate + // the NotFound — it should soft-fail and surface a NADUnreadable + // issue so the plan validation pass can complete. + v, c, _ := setup("10.100.0.5", true) // no NAD in the client + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, + Kind: planbase.CalicoIssueNADUnreadable, + })) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + + It("emits NADUnreadable when the NAD spec.config is malformed JSON", func() { + badNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: nadName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: `{not-valid-json`}, + } + v, c, _ := setup("10.100.0.5", true, badNAD) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, + Kind: planbase.CalicoIssueNADUnreadable, + })) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + + It("skips Multus NADs that aren't Calico-typed", func() { + // An OVN-K8s overlay NAD shouldn't surface a CalicoNADIssue or + // occupy a cache slot — it's outside the scope of this validator. + ovnNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "ovn-nad", Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{ + Config: `{"type":"ovn-k8s-cni-overlay","name":"my-net"}`, + }, + } + v, c, _ := setup("10.100.0.5", true, ovnNAD) + v.Plan.Referenced.Map.Network.Spec.Map[0].Destination.Name = "ovn-nad" + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + }) + + Describe("plan-level / per-VM cross-cut", func() { + It("surviving healthy NAD is still checked per-VM when another NAD in the map is broken", func() { + // Two NADs in the map: the original (broken — no Network CR) and + // a second healthy one. The VM has a NIC mapped to the healthy + // NAD with an out-of-subnet IP. CalicoVMIssues must skip the + // broken-NAD NIC silently and still emit IPNotInSubnet for the + // surviving NIC. + const ( + healthyNADName = "calico-nad-2" + healthyNetName = "vlan200" + healthySrcID = "src-2" + ) + healthyNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: healthyNADName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{ + Config: fmt.Sprintf(`{"type":"calico","network":"%s","vlan":200}`, healthyNetName), + }, + } + healthyNet := &unstructured.Unstructured{} + healthyNet.SetGroupVersionKind(calicoclient.NetworkGVK) + healthyNet.SetName(healthyNetName) + _ = unstructured.SetNestedField(healthyNet.Object, map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(200)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.200.0.0/24"}}, + }, + }, + }, + }, "spec") + + // NIC 4001 (src-1) maps to the broken NAD; NIC 4002 (src-2) maps + // to the healthy NAD and carries an out-of-subnet IP. + v, c, vmRef := setup("10.100.0.5", true, + makeCalicoNAD(100), // broken — no Network CR + healthyNAD, healthyNet, + makeIPPool("vlan200-pool", "10.200.0.0/24"), + ) + v.Plan.Referenced.Map.Network.Spec.Map = append( + v.Plan.Referenced.Map.Network.Spec.Map, + v1beta1.NetworkPair{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: healthySrcID}}, + Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: healthyNADName, + }, + }, + ) + inv := v.Source.Inventory.(*mockInventory) + inv.networks[healthySrcID] = model.Network{ + Resource: model.Resource{ID: healthySrcID}, Variant: vsphere.NetDvPortGroup, Key: healthySrcID, + } + inv.vm.NICs = append(inv.vm.NICs, vsphere.NIC{Network: vsphere.Ref{ID: healthySrcID}, DeviceKey: 4002}) + inv.vm.GuestNetworks = append(inv.vm.GuestNetworks, vsphere.GuestNetwork{IP: "192.168.1.5", DeviceConfigId: 4002}) + + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(HaveLen(1)) + Expect(result.Issues[0].NAD).To(Equal(k8stypes.NamespacedName{Namespace: nadNS, Name: nadName})) + Expect(result.Cache.NADs).To(HaveLen(1)) + Expect(result.Cache.NADs).To(HaveKey(k8stypes.NamespacedName{Namespace: nadNS, Name: healthyNADName})) + + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoIssue{ + Kind: planbase.CalicoIssueIPNotInSubnet, Network: healthyNetName, VLAN: 200, IP: "192.168.1.5", + })) + }) + }) + + Describe("CalicoVMIssues (per-VM)", func() { + It("returns no issues when the cache is nil", func() { + v, _, vmRef := setup("10.100.0.5", true) + issues, err := v.CalicoVMIssues(vmRef, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(BeEmpty()) + }) + + It("emits IPNotInSubnet when preserveStaticIPs is on and source IP is outside the VLAN subnet", func() { + v, c, vmRef := setup("192.168.1.5", true, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24"), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoIssue{ + Kind: planbase.CalicoIssueIPNotInSubnet, Network: netName, VLAN: 100, IP: "192.168.1.5", + })) + }) + + It("emits IPNotInIPPool when preserveStaticIPs is on and no eligible pool covers the source IP", func() { + v, c, vmRef := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-upper", "10.100.0.128/25"), // covers VLAN but not 10.100.0.5 + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoIssue{ + Kind: planbase.CalicoIssueIPNotInIPPool, Network: netName, VLAN: 100, IP: "10.100.0.5", + })) + }) + + It("returns no issues when preserveStaticIPs is false", func() { + // Source IP would fail subnet check, but preservation is off. + v, c, vmRef := setup("192.168.1.5", false, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24"), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(BeEmpty()) + }) + + It("silently skips NICs whose NAD failed plan-level validation", func() { + // Network is missing, so ValidateCalicoNADs flags the NAD and + // leaves the cache empty. CalicoVMIssues must not re-emit per-VM + // issues for that NAD — the failure is already reported at plan + // level. + v, c, vmRef := setup("10.100.0.5", true, makeCalicoNAD(100)) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Cache.NADs).To(BeEmpty()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(BeEmpty()) + }) + + It("deduplicates identical per-NIC IP issues", func() { + // Two NICs on the same source network, two NetworkMap entries each + // pointing at a distinct NAD; both NADs reference the same Calico + // Network. The pool gives NIC-0 NAD-A and NIC-1 NAD-B. Both NICs + // carry the same out-of-subnet IP so both emit identical + // {Kind, Network, VLAN, IP}; CalicoVMIssues must dedup to one. + nadAName := "calico-nad-a" + nadBName := "calico-nad-b" + cfg := fmt.Sprintf(`{"type":"calico","network":"%s","vlan":100}`, netName) + nadA := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: nadAName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: cfg}, + } + nadB := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: nadBName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: cfg}, + } + v, c, vmRef := setup("192.168.1.5", true, + nadA, nadB, makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24"), + ) + vm := v.Source.Inventory.(*mockInventory).vm + vm.NICs = append(vm.NICs, vsphere.NIC{Network: vsphere.Ref{ID: srcNetID}, DeviceKey: 4002}) + vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "192.168.1.5", DeviceConfigId: 4002}) + v.Source.Inventory.(*mockInventory).vm = vm + + v.Plan.Referenced.Map.Network.Spec.Map = []v1beta1.NetworkPair{ + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: srcNetID}}, Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: nadAName, + }}, + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: srcNetID}}, Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: nadBName, + }}, + } + + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoIssue{ + Kind: planbase.CalicoIssueIPNotInSubnet, Network: netName, VLAN: 100, IP: "192.168.1.5", + })) + }) + }) + }) }) func createPlan() *v1beta1.Plan { diff --git a/pkg/controller/plan/validation.go b/pkg/controller/plan/validation.go index 54bdc8745b..d74e9c103b 100644 --- a/pkg/controller/plan/validation.go +++ b/pkg/controller/plan/validation.go @@ -4,7 +4,6 @@ import ( "context" "crypto/md5" "encoding/hex" - "encoding/json" "errors" "fmt" "net" @@ -66,6 +65,9 @@ const ( VMMultiplePodNetworkMappings = "VMMultiplePodNetworkMappings" VMMissingGuestIPs = "VMMissingGuestIPs" VMIpNotMatchingUdnSubnet = "VMIpNotMatchingUdnSubnet" + CalicoNetworkInvalid = "CalicoNetworkInvalid" + VMIpNotInCalicoSubnet = "VMIpNotInCalicoSubnet" + VMIpNotInCalicoIPPool = "VMIpNotInCalicoIPPool" VMMissingChangedBlockTracking = "VMMissingChangedBlockTracking" VMHasSnapshots = "VMHasSnapshots" VMConsolidationNeeded = "VMConsolidationNeeded" @@ -214,6 +216,11 @@ func (r *Reconciler) validate(plan *api.Plan) error { return err } + calicoCache, err := r.validateCalicoNetwork(ctx) + if err != nil { + return err + } + err = r.validateNetAppShift(ctx) if err != nil { return err @@ -227,7 +234,7 @@ func (r *Reconciler) validate(plan *api.Plan) error { return err } - if err = r.validateVM(plan, ctx); err != nil { + if err = r.validateVM(plan, ctx, calicoCache); err != nil { return err } @@ -518,8 +525,7 @@ func (r *Reconciler) validateUserDefinedNetwork(ctx *plancontext.Context) (err e } for _, nad := range nads.Items { - var networkConfig model.NetworkConfig - err = json.Unmarshal([]byte(nad.Spec.Config), &networkConfig) + networkConfig, err := model.ParseNAD(&nad) if err != nil { r.Log.Info("Skipping NAD: failed to parse network config", "namespace", nad.Namespace, "name", nad.Name, "error", err.Error()) continue @@ -541,6 +547,55 @@ func (r *Reconciler) validateUserDefinedNetwork(ctx *plancontext.Context) (err e return } +// validateCalicoNetwork validates every Calico-referencing NAD referenced by +// the plan's network map. Resource-level issues (Network CR missing, no +// l2Bridge, no eligible IPPool, etc.) are surfaced as a plan-level +// CalicoNetworkInvalid condition whose items are the offending NAD +// references. Healthy NADs are returned in a cache for per-VM checks. +func (r *Reconciler) validateCalicoNetwork(ctx *plancontext.Context) (*planbase.CalicoValidationCache, error) { + provider := ctx.Plan.Referenced.Provider.Source + if provider == nil { + return nil, nil + } + pAdapter, err := adapter.New(provider) + if err != nil { + return nil, err + } + validator, err := pAdapter.Validator(ctx) + if err != nil { + return nil, err + } + result, err := validator.ValidateCalicoNADs(ctx.Destination.Client) + if err != nil { + return nil, err + } + if len(result.Issues) == 0 { + return result.Cache, nil + } + + cond := libcnd.Condition{ + Type: CalicoNetworkInvalid, + Status: True, + Reason: NotValid, + Category: api.CategoryCritical, + Message: "One or more Calico Network destinations are invalid", + Items: []string{}, + } + details := make([]string, 0, len(result.Issues)) + seenNAD := map[string]bool{} + for _, issue := range result.Issues { + ref := issue.NAD.String() + if !seenNAD[ref] { + seenNAD[ref] = true + cond.Items = append(cond.Items, ref) + } + details = append(details, calicoNADIssueDetail(issue)) + } + cond.Message = fmt.Sprintf("%s: %s.", cond.Message, strings.Join(details, "; ")) + ctx.Plan.Status.SetCondition(cond) + return result.Cache, nil +} + func (r *Reconciler) getDestinationNamespaceNads(ctx *plancontext.Context) (*k8snet.NetworkAttachmentDefinitionList, error) { nadList := &k8snet.NetworkAttachmentDefinitionList{} listOpts := []client.ListOption{ @@ -719,7 +774,7 @@ func aggregateWarningConcerns(v interface{}, vmRef string, unsupportedOVFExportS } // Validate listed VMs. -func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context) error { +func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context, calicoCache *planbase.CalicoValidationCache) error { if plan.Status.HasCondition(Executing) { return nil } @@ -819,6 +874,22 @@ func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context) error Message: "VM IP does not match with the primary UDN subnet", Items: []string{}, } + vmIpNotInCalicoSubnet := libcnd.Condition{ + Type: VMIpNotInCalicoSubnet, + Status: True, + Reason: NotValid, + Category: api.CategoryCritical, + Message: "VM static IP does not fall within any subnet of the mapped Calico Network VLAN.", + Items: []string{}, + } + vmIpNotInCalicoIPPool := libcnd.Condition{ + Type: VMIpNotInCalicoIPPool, + Status: True, + Reason: NotValid, + Category: api.CategoryCritical, + Message: "VM static IP is within the Calico Network VLAN subnet but no IPPool covers it.", + Items: []string{}, + } missingCbtForWarm := libcnd.Condition{ Type: VMMissingChangedBlockTracking, Status: True, @@ -1303,6 +1374,25 @@ func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context) error vmIpDoesNotMatchUdnSubnet.Items = append(vmIpDoesNotMatchUdnSubnet.Items, ref.String()) } } + calicoIssues, err := validator.CalicoVMIssues(*ref, calicoCache) + if err != nil { + return err + } + addedSubnet, addedPool := false, false + for _, issue := range calicoIssues { + switch issue.Kind { + case planbase.CalicoIssueIPNotInSubnet: + if !addedSubnet { + vmIpNotInCalicoSubnet.Items = append(vmIpNotInCalicoSubnet.Items, ref.String()) + addedSubnet = true + } + case planbase.CalicoIssueIPNotInIPPool: + if !addedPool { + vmIpNotInCalicoIPPool.Items = append(vmIpNotInCalicoIPPool.Items, ref.String()) + addedPool = true + } + } + } // Destination. provider = plan.Provider.Destination if provider == nil { @@ -1433,6 +1523,12 @@ func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context) error if len(vmIpDoesNotMatchUdnSubnet.Items) > 0 { plan.Status.SetCondition(vmIpDoesNotMatchUdnSubnet) } + if len(vmIpNotInCalicoSubnet.Items) > 0 { + plan.Status.SetCondition(vmIpNotInCalicoSubnet) + } + if len(vmIpNotInCalicoIPPool.Items) > 0 { + plan.Status.SetCondition(vmIpNotInCalicoIPPool) + } if len(vmHasSnapshotsForWarm.Items) > 0 { plan.Status.SetCondition(vmHasSnapshotsForWarm) } @@ -1867,6 +1963,29 @@ func (r *Reconciler) validateVddkImage(plan *api.Plan) (err error) { return } +// calicoNADIssueDetail formats a per-NAD detail phrase for the plan-level +// CalicoNetworkInvalid condition's Message: e.g. +// "default/foo (NetworkNotFound network=\"calico-vlan\")". +func calicoNADIssueDetail(i planbase.CalicoNADIssue) string { + switch i.Kind { + case planbase.CalicoIssueNADUnreadable: + return fmt.Sprintf("%s (NADUnreadable)", i.NAD.String()) + case planbase.CalicoIssueNetworkNotFound: + return fmt.Sprintf("%s (NetworkNotFound network=%q)", i.NAD.String(), i.Network) + case planbase.CalicoIssueNetworkHasNoL2Bridge: + return fmt.Sprintf("%s (NetworkHasNoL2Bridge network=%q)", i.NAD.String(), i.Network) + case planbase.CalicoIssueNetworkHasNoVLANs: + return fmt.Sprintf("%s (NetworkHasNoVLANs network=%q)", i.NAD.String(), i.Network) + case planbase.CalicoIssueVLANNotInNetwork: + return fmt.Sprintf("%s (VLANNotInNetwork network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) + case planbase.CalicoIssueVLANAmbiguous: + return fmt.Sprintf("%s (VLANAmbiguous network=%q)", i.NAD.String(), i.Network) + case planbase.CalicoIssueVLANHasNoIPPool: + return fmt.Sprintf("%s (VLANHasNoIPPool network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) + } + return fmt.Sprintf("%s (%s)", i.NAD.String(), i.Kind) +} + func missingStaticIPsMessage(plan *api.Plan) string { guestTools := "guest tools" source := plan.Provider.Source diff --git a/pkg/controller/provider/model/ocp/model.go b/pkg/controller/provider/model/ocp/model.go index 84b6946fec..9349c3312d 100644 --- a/pkg/controller/provider/model/ocp/model.go +++ b/pkg/controller/provider/model/ocp/model.go @@ -1,6 +1,8 @@ package ocp import ( + "encoding/json" + "fmt" "path" "strconv" @@ -205,9 +207,10 @@ const ( RolePrimary RoleType = "primary" RoleSecondary RoleType = "secondary" OvnOverlayType NadType = "ovn-k8s-cni-overlay" + CalicoCNIType NadType = "calico" ) -// NetworkConfig represents the structure of the OVN-Kubernetes CNI configuration JSON. +// NetworkConfig represents the structure of the OVN-Kubernetes, or Calico CNI configuration JSON. // The `json:"..."` tags are used by the encoding/json package to map the JSON keys // to the struct fields during marshalling and unmarshalling. type NetworkConfig struct { @@ -220,9 +223,33 @@ type NetworkConfig struct { Subnets string `json:"subnets"` Topology TopologyType `json:"topology"` Type NadType `json:"type"` + + // Name of a projectcalico.org/v3 Network resource the NAD attaches to. + Network string `json:"network,omitempty"` + // 802.1Q VLAN ID (1-4094) for Calico CNI. Zero means unspecified. + VLAN uint16 `json:"vlan,omitempty"` } func (m *NetworkConfig) IsUnsupportedUdn() bool { return m.Type == OvnOverlayType && (m.Role == RolePrimary || m.Topology == TopologyLayer3) } + +// ReferencesCalicoNetwork reports whether the NAD invokes the Calico CNI and +// names a projectcalico.org/v3 Network resource. +func (m *NetworkConfig) ReferencesCalicoNetwork() bool { + return m.Type == CalicoCNIType && m.Network != "" +} + +// ParseNAD unmarshals nad.Spec.Config into a NetworkConfig. +// An empty Spec.Config yields a zero-valued NetworkConfig and no error. +func ParseNAD(nad *net.NetworkAttachmentDefinition) (*NetworkConfig, error) { + cfg := &NetworkConfig{} + if nad.Spec.Config == "" { + return cfg, nil + } + if err := json.Unmarshal([]byte(nad.Spec.Config), cfg); err != nil { + return nil, fmt.Errorf("nad %s/%s: parse Spec.Config: %w", nad.Namespace, nad.Name, err) + } + return cfg, nil +} diff --git a/pkg/controller/provider/model/ocp/model_test.go b/pkg/controller/provider/model/ocp/model_test.go new file mode 100644 index 0000000000..0eafb5a500 --- /dev/null +++ b/pkg/controller/provider/model/ocp/model_test.go @@ -0,0 +1,137 @@ +package ocp + +import ( + "encoding/json" + "testing" +) + +func TestNetworkConfig_UnmarshalCalico(t *testing.T) { + tests := []struct { + name string + input string + wantType NadType + wantNetwork string + wantVLAN uint16 + }{ + { + name: "CalicoL2WithExplicitVLAN", + input: `{ + "cniVersion": "0.3.1", + "type": "calico", + "network": "datacenter-vlans", + "vlan": 100, + "ipam": {"type": "calico-ipam"}, + "datastore_type": "kubernetes", + "kubernetes": {"kubeconfig": "/etc/cni/net.d/calico-kubeconfig"} + }`, + wantType: CalicoCNIType, + wantNetwork: "datacenter-vlans", + wantVLAN: 100, + }, + { + name: "CalicoL2ExplicitVLANZero", + input: `{"type":"calico","network":"flat-net","vlan":0}`, + wantType: CalicoCNIType, + wantNetwork: "flat-net", + wantVLAN: 0, + }, + { + name: "CalicoL3NoNetworkField", + input: `{"type":"calico","ipam":{"type":"calico-ipam"}}`, + wantType: CalicoCNIType, + wantNetwork: "", + wantVLAN: 0, + }, + { + name: "OvnKConfigIgnoresCalicoFields", + input: `{ + "cniVersion": "0.3.1", + "type": "ovn-k8s-cni-overlay", + "name": "udn", + "role": "primary", + "topology": "layer3", + "subnets": "10.0.0.0/24" + }`, + wantType: OvnOverlayType, + wantNetwork: "", + wantVLAN: 0, + }, + { + name: "UnknownCNI", + input: `{"type":"bridge","bridge":"cni0","ipam":{"type":"host-local"}}`, + wantType: "bridge", + wantNetwork: "", + wantVLAN: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NetworkConfig{} + if err := json.Unmarshal([]byte(tt.input), &cfg); err != nil { + t.Fatalf("Unmarshal returned unexpected error: %v", err) + } + if cfg.Type != tt.wantType { + t.Errorf("Type = %q, want %q", cfg.Type, tt.wantType) + } + if cfg.Network != tt.wantNetwork { + t.Errorf("Network = %q, want %q", cfg.Network, tt.wantNetwork) + } + if cfg.VLAN != tt.wantVLAN { + t.Errorf("VLAN = %d, want %d", cfg.VLAN, tt.wantVLAN) + } + }) + } +} + +func TestNetworkConfig_ReferencesCalicoNetwork(t *testing.T) { + tests := []struct { + name string + cfg NetworkConfig + want bool + }{ + { + name: "CalicoTypeAndNetworkSet", + cfg: NetworkConfig{Type: CalicoCNIType, Network: "datacenter-vlans"}, + want: true, + }, + { + name: "CalicoTypeNoNetwork", + cfg: NetworkConfig{Type: CalicoCNIType}, + want: false, + }, + { + name: "OvnKTypeWithNetworkField", + cfg: NetworkConfig{Type: OvnOverlayType, Network: "datacenter-vlans"}, + want: false, + }, + { + name: "EmptyType", + cfg: NetworkConfig{Network: "x"}, + want: false, + }, + { + name: "Zero", + cfg: NetworkConfig{}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.ReferencesCalicoNetwork(); got != tt.want { + t.Errorf("ReferencesCalicoNetwork() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNetworkConfig_IsUnsupportedUdn_UnaffectedByCalicoFields(t *testing.T) { + cfg := NetworkConfig{ + Type: OvnOverlayType, + Role: RolePrimary, + Network: "datacenter-vlans", + VLAN: 100, + } + if !cfg.IsUnsupportedUdn() { + t.Errorf("IsUnsupportedUdn() = false, want true; Calico fields must not interfere with the UDN predicate") + } +} diff --git a/pkg/controller/provider/web/ocp/base.go b/pkg/controller/provider/web/ocp/base.go index bd636eeb37..2c32a61bf5 100644 --- a/pkg/controller/provider/web/ocp/base.go +++ b/pkg/controller/provider/web/ocp/base.go @@ -2,7 +2,6 @@ package ocp import ( "context" - "encoding/json" pathlib "path" "github.com/gin-gonic/gin" @@ -343,8 +342,7 @@ func (h Handler) NetworkAttachmentDefinitions(ctx *gin.Context, provider *api.Pr for _, nad := range list.Items { m := model.NetworkAttachmentDefinition{} m.With(&nad) - networkConfig := model.NetworkConfig{} - if err := json.Unmarshal([]byte(nad.Spec.Config), &networkConfig); err == nil { + if networkConfig, err := model.ParseNAD(&nad); err == nil { if networkConfig.IsUnsupportedUdn() { log.Info("NAD is not supported UDN configuration, skipping", "nad", nad, "networkConfig", networkConfig) continue diff --git a/pkg/lib/client/calico/ippool.go b/pkg/lib/client/calico/ippool.go new file mode 100644 index 0000000000..8fd7815a7f --- /dev/null +++ b/pkg/lib/client/calico/ippool.go @@ -0,0 +1,122 @@ +package calico + +import ( + "context" + "fmt" + "net" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// IPPoolGVK is the GroupVersionKind of projectcalico.org/v3 IPPool. +var IPPoolGVK = schema.GroupVersionKind{ + Group: "projectcalico.org", + Version: "v3", + Kind: "IPPool", +} + +// IPPool is a parsed view of projectcalico.org/v3 IPPool. +type IPPool struct { + Name string + CIDR string +} + +// ListIPPools lists all projectcalico.org/v3 IPPool CRs on the cluster. +func ListIPPools(ctx context.Context, c client.Client) ([]IPPool, error) { + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(IPPoolGVK.GroupVersion().WithKind("IPPoolList")) + if err := c.List(ctx, ul); err != nil { + return nil, err + } + pools := make([]IPPool, 0, len(ul.Items)) + for i := range ul.Items { + u := &ul.Items[i] + cidr, _, err := unstructured.NestedString(u.Object, "spec", "cidr") + if err != nil { + return nil, fmt.Errorf("ippool %q: parse spec.cidr: %w", u.GetName(), err) + } + pools = append(pools, IPPool{Name: u.GetName(), CIDR: cidr}) + } + return pools, nil +} + +// HasEligiblePool reports whether at least one pool's CIDR is contained +// within at least one of vlanSubnets. When false, Calico IPAM has nothing +// to allocate from for this VLAN and CNI ADD will fail regardless of any +// per-pod IP request. +func HasEligiblePool(pools []IPPool, vlanSubnets []string) bool { + for i := range pools { + if poolContainedInAnyVLANSubnet(pools[i].CIDR, vlanSubnets) { + return true + } + } + return false +} + +// EligiblePools returns the subset of pools whose CIDR is contained within +// at least one vlanSubnet. Callers cache the result so per-IP membership +// checks don't repeat the containment filter. +func EligiblePools(pools []IPPool, vlanSubnets []string) []IPPool { + out := make([]IPPool, 0, len(pools)) + for i := range pools { + if poolContainedInAnyVLANSubnet(pools[i].CIDR, vlanSubnets) { + out = append(out, pools[i]) + } + } + return out +} + +// EligiblePoolForIP returns the first pool that (a) contains the given IP and +// (b) is itself contained within at least one VLAN subnet. Returns nil when +// no pool qualifies. +func EligiblePoolForIP(pools []IPPool, ip string, vlanSubnets []string) *IPPool { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return nil + } + for i := range pools { + p := &pools[i] + if !ipInCIDR(parsedIP, p.CIDR) { + continue + } + if !poolContainedInAnyVLANSubnet(p.CIDR, vlanSubnets) { + continue + } + return p + } + return nil +} + +func ipInCIDR(ip net.IP, cidr string) bool { + _, n, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + return n.Contains(ip) +} + +// poolContainedInAnyVLANSubnet reports whether the pool CIDR is fully +// contained within one of the VLAN subnets — i.e., every address in the pool +// also belongs to the VLAN subnet. +func poolContainedInAnyVLANSubnet(poolCIDR string, vlanSubnets []string) bool { + _, poolNet, err := net.ParseCIDR(poolCIDR) + if err != nil { + return false + } + poolMaskBits, _ := poolNet.Mask.Size() + for _, vs := range vlanSubnets { + _, vlanNet, err := net.ParseCIDR(vs) + if err != nil { + continue + } + vlanMaskBits, _ := vlanNet.Mask.Size() + // pool contained in vlan only if vlan prefix is shorter (or equal) + // AND the pool's network address belongs to the vlan subnet. + if vlanMaskBits <= poolMaskBits && vlanNet.Contains(poolNet.IP) { + return true + } + } + return false +} diff --git a/pkg/lib/client/calico/ippool_test.go b/pkg/lib/client/calico/ippool_test.go new file mode 100644 index 0000000000..6c9f2fd0ba --- /dev/null +++ b/pkg/lib/client/calico/ippool_test.go @@ -0,0 +1,206 @@ +package calico + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func makeIPPool(name, cidr string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(IPPoolGVK) + u.SetName(name) + _ = unstructured.SetNestedField(u.Object, cidr, "spec", "cidr") + return u +} + +func newFakeClientWithIPPools(objs ...runtime.Object) *fake.ClientBuilder { + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(IPPoolGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(IPPoolGVK.GroupVersion().WithKind("IPPoolList"), &unstructured.UnstructuredList{}) + b := fake.NewClientBuilder().WithScheme(scheme) + for _, o := range objs { + b = b.WithRuntimeObjects(o) + } + return b +} + +func TestListIPPools_Empty(t *testing.T) { + c := newFakeClientWithIPPools().Build() + pools, err := ListIPPools(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pools) != 0 { + t.Errorf("got %d pools, want 0", len(pools)) + } +} + +func TestListIPPools_Multiple(t *testing.T) { + c := newFakeClientWithIPPools( + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + makeIPPool("vlan100-pool", "10.100.0.0/24"), + makeIPPool("vlan200-pool", "10.200.0.0/24"), + ).Build() + + pools, err := ListIPPools(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pools) != 3 { + t.Fatalf("got %d pools, want 3", len(pools)) + } + want := map[string]string{ + "default-ipv4-ippool": "10.244.0.0/16", + "vlan100-pool": "10.100.0.0/24", + "vlan200-pool": "10.200.0.0/24", + } + for _, p := range pools { + if want[p.Name] != p.CIDR { + t.Errorf("pool %q CIDR = %q, want %q", p.Name, p.CIDR, want[p.Name]) + } + } +} + +func TestHasEligiblePool(t *testing.T) { + tests := []struct { + name string + pools []IPPool + vlanSubnets []string + want bool + }{ + { + name: "PoolContainedInVLANSubnet", + pools: []IPPool{{CIDR: "10.100.0.0/24"}}, + vlanSubnets: []string{"10.100.0.0/24"}, + want: true, + }, + { + name: "PoolStrictlyContainedInVLANSubnet", + pools: []IPPool{{CIDR: "10.100.0.128/25"}}, + vlanSubnets: []string{"10.100.0.0/24"}, + want: true, + }, + { + name: "PoolLargerThanVLANSubnet", + pools: []IPPool{{CIDR: "10.0.0.0/8"}}, + vlanSubnets: []string{"10.100.0.0/24"}, + want: false, + }, + { + name: "PoolOnDifferentNetwork", + pools: []IPPool{{CIDR: "10.244.0.0/16"}}, + vlanSubnets: []string{"10.100.0.0/24"}, + want: false, + }, + { + name: "AtLeastOnePoolMatches", + pools: []IPPool{{CIDR: "10.244.0.0/16"}, {CIDR: "10.100.0.0/24"}}, + vlanSubnets: []string{"10.100.0.0/24"}, + want: true, + }, + { + name: "MultipleVLANSubnets", + pools: []IPPool{{CIDR: "10.200.0.0/24"}}, + vlanSubnets: []string{"10.100.0.0/24", "10.200.0.0/24"}, + want: true, + }, + { + name: "NoPools", + pools: nil, + vlanSubnets: []string{"10.100.0.0/24"}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HasEligiblePool(tt.pools, tt.vlanSubnets); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestEligiblePoolForIP(t *testing.T) { + pools := []IPPool{ + {Name: "default-ipv4-ippool", CIDR: "10.244.0.0/16"}, // cluster default, not in VLAN + {Name: "vlan100-pool", CIDR: "10.100.0.0/24"}, // matches VLAN 100 subnet exactly + {Name: "vlan100-subpool", CIDR: "10.100.0.128/25"}, // contained within VLAN 100 subnet + {Name: "vlan200-pool", CIDR: "10.200.0.0/24"}, // matches VLAN 200 subnet + } + + tests := []struct { + name string + ip string + vlanSubnets []string + wantPool string // empty means expect nil + }{ + { + name: "IPInExactlyMatchingPool", + ip: "10.100.0.5", + vlanSubnets: []string{"10.100.0.0/24"}, + wantPool: "vlan100-pool", + }, + { + name: "IPInSubpoolWithinVLAN", + ip: "10.100.0.200", + vlanSubnets: []string{"10.100.0.0/24"}, + // Either vlan100-pool or vlan100-subpool covers it; first match wins. + wantPool: "vlan100-pool", + }, + { + name: "IPNotInVLANSubnet", + ip: "10.244.5.1", // in cluster default pool but VLAN list excludes it + vlanSubnets: []string{"10.100.0.0/24"}, + wantPool: "", + }, + { + name: "PoolNotContainedInVLAN", + ip: "10.100.0.5", + vlanSubnets: []string{"10.100.0.0/26"}, // VLAN is smaller than vlan100-pool + wantPool: "", + }, + { + name: "MultipleVLANSubnets", + ip: "10.200.0.5", + vlanSubnets: []string{"10.100.0.0/24", "10.200.0.0/24"}, + wantPool: "vlan200-pool", + }, + { + name: "InvalidIP", + ip: "not-an-ip", + vlanSubnets: []string{"10.100.0.0/24"}, + wantPool: "", + }, + { + name: "NoPools", + ip: "10.100.0.5", + vlanSubnets: []string{"10.100.0.0/24"}, + wantPool: "", // pools list is empty in this branch by override below + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + poolList := pools + if tt.name == "NoPools" { + poolList = nil + } + got := EligiblePoolForIP(poolList, tt.ip, tt.vlanSubnets) + if tt.wantPool == "" { + if got != nil { + t.Errorf("got pool %+v, want nil", got) + } + return + } + if got == nil { + t.Fatalf("got nil pool, want %q", tt.wantPool) + } + if got.Name != tt.wantPool { + t.Errorf("got pool %q, want %q", got.Name, tt.wantPool) + } + }) + } +} diff --git a/pkg/lib/client/calico/network.go b/pkg/lib/client/calico/network.go new file mode 100644 index 0000000000..c7e1334e03 --- /dev/null +++ b/pkg/lib/client/calico/network.go @@ -0,0 +1,126 @@ +package calico + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// NetworkGVK is the GroupVersionKind of projectcalico.org/v3 Network. +var NetworkGVK = schema.GroupVersionKind{ + Group: "projectcalico.org", + Version: "v3", + Kind: "Network", +} + +// VLANEntry is a parsed entry of Network.spec.l2Bridge.vlans. +type VLANEntry struct { + VID uint16 + Subnets []string +} + +// L2BridgeSpec holds the fields of Network.spec.l2Bridge that the validator +// inspects. +type L2BridgeSpec struct { + VLANs []VLANEntry +} + +// Network is a thin parsed view of projectcalico.org/v3 Network. +type Network struct { + Name string + L2Bridge *L2BridgeSpec // nil when the Network has no l2Bridge spec +} + +// GetNetwork fetches projectcalico.org/v3 Network/name from the destination +// cluster and returns a parsed Network. The CR is cluster-scoped. +func GetNetwork(ctx context.Context, c client.Client, name string) (*Network, error) { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(NetworkGVK) + if err := c.Get(ctx, client.ObjectKey{Name: name}, u); err != nil { + return nil, err + } + return parseNetwork(u) +} + +func parseNetwork(u *unstructured.Unstructured) (*Network, error) { + n := &Network{Name: u.GetName()} + + l2Bridge, found, err := unstructured.NestedMap(u.Object, "spec", "l2Bridge") + if err != nil { + return nil, fmt.Errorf("parse spec.l2Bridge: %w", err) + } + if !found { + return n, nil + } + + vlansRaw, found, err := unstructured.NestedSlice(l2Bridge, "vlans") + if err != nil { + return nil, fmt.Errorf("parse spec.l2Bridge.vlans: %w", err) + } + spec := &L2BridgeSpec{} + if found { + for i, v := range vlansRaw { + entryMap, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("spec.l2Bridge.vlans[%d]: not an object", i) + } + entry, err := parseVLANEntry(entryMap, i) + if err != nil { + return nil, err + } + spec.VLANs = append(spec.VLANs, entry) + } + } + n.L2Bridge = spec + return n, nil +} + +func parseVLANEntry(m map[string]interface{}, idx int) (VLANEntry, error) { + entry := VLANEntry{} + + vidRaw, found, err := unstructured.NestedFieldNoCopy(m, "vlan", "id") + if err != nil { + return entry, fmt.Errorf("vlans[%d].vlan.id: %w", idx, err) + } + if !found { + return entry, fmt.Errorf("vlans[%d].vlan.id: missing", idx) + } + var id int64 + switch v := vidRaw.(type) { + case int64: + id = v + case float64: + id = int64(v) + if float64(id) != v { + return entry, fmt.Errorf("vlans[%d].vlan.id: non-integer %g", idx, v) + } + default: + return entry, fmt.Errorf("vlans[%d].vlan.id: unexpected type %T", idx, vidRaw) + } + if id < 1 || id > 4094 { + return entry, fmt.Errorf("vlans[%d].vlan.id: %d out of range (1-4094)", idx, id) + } + entry.VID = uint16(id) + + subnetsRaw, _, err := unstructured.NestedSlice(m, "subnets") + if err != nil { + return entry, fmt.Errorf("vlans[%d].subnets: %w", idx, err) + } + for j, s := range subnetsRaw { + sMap, ok := s.(map[string]interface{}) + if !ok { + return entry, fmt.Errorf("vlans[%d].subnets[%d]: not an object", idx, j) + } + cidr, _, err := unstructured.NestedString(sMap, "cidr") + if err != nil { + return entry, fmt.Errorf("vlans[%d].subnets[%d].cidr: %w", idx, j, err) + } + if cidr != "" { + entry.Subnets = append(entry.Subnets, cidr) + } + } + return entry, nil +} diff --git a/pkg/lib/client/calico/network_test.go b/pkg/lib/client/calico/network_test.go new file mode 100644 index 0000000000..0f405f647e --- /dev/null +++ b/pkg/lib/client/calico/network_test.go @@ -0,0 +1,204 @@ +package calico + +import ( + "context" + "testing" + + k8serr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// makeNetwork builds an unstructured projectcalico.org/v3 Network with the +// given name and spec map. +func makeNetwork(name string, spec map[string]interface{}) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(NetworkGVK) + u.SetName(name) + if spec != nil { + _ = unstructured.SetNestedField(u.Object, spec, "spec") + } + return u +} + +func newFakeClientWith(objs ...runtime.Object) (*fake.ClientBuilder, error) { + scheme := runtime.NewScheme() + // Register list-kind so the fake client knows how to map GVK -> Go type + // for unstructured Network objects. For Gets without a list-kind, the + // fake client falls back to using the GVK from the object itself. + scheme.AddKnownTypeWithName(NetworkGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(NetworkGVK.GroupVersion().WithKind("NetworkList"), &unstructured.UnstructuredList{}) + b := fake.NewClientBuilder().WithScheme(scheme) + for _, o := range objs { + b = b.WithRuntimeObjects(o) + } + return b, nil +} + +func TestGetNetwork_NotFound(t *testing.T) { + cb, err := newFakeClientWith() + if err != nil { + t.Fatalf("scheme setup: %v", err) + } + c := cb.Build() + + got, err := GetNetwork(context.Background(), c, "missing") + if got != nil { + t.Errorf("got non-nil network = %+v, want nil", got) + } + if !k8serr.IsNotFound(err) { + t.Errorf("err = %v, want IsNotFound", err) + } +} + +func TestGetNetwork_NoL2Bridge(t *testing.T) { + // Network exists but has no l2Bridge spec. + nw := makeNetwork("flat-net", map[string]interface{}{ + // no l2Bridge key + }) + cb, _ := newFakeClientWith(nw) + c := cb.Build() + + got, err := GetNetwork(context.Background(), c, "flat-net") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("got nil network, want non-nil") + } + if got.Name != "flat-net" { + t.Errorf("Name = %q, want flat-net", got.Name) + } + if got.L2Bridge != nil { + t.Errorf("L2Bridge = %+v, want nil", got.L2Bridge) + } +} + +func TestGetNetwork_SingleVLAN(t *testing.T) { + nw := makeNetwork("vlan100", map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(100)}, + "subnets": []interface{}{ + map[string]interface{}{"cidr": "10.100.0.0/24"}, + }, + }, + }, + }, + }) + cb, _ := newFakeClientWith(nw) + c := cb.Build() + + got, err := GetNetwork(context.Background(), c, "vlan100") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.L2Bridge == nil { + t.Fatal("L2Bridge = nil, want non-nil") + } + if len(got.L2Bridge.VLANs) != 1 { + t.Fatalf("VLANs len = %d, want 1", len(got.L2Bridge.VLANs)) + } + v := got.L2Bridge.VLANs[0] + if v.VID != 100 { + t.Errorf("VID = %d, want 100", v.VID) + } + if len(v.Subnets) != 1 || v.Subnets[0] != "10.100.0.0/24" { + t.Errorf("Subnets = %v, want [10.100.0.0/24]", v.Subnets) + } +} + +func TestGetNetwork_MultipleVLANs(t *testing.T) { + nw := makeNetwork("datacenter-vlans", map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(100)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.100.0.0/24"}}, + }, + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(200)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.200.0.0/24"}}, + }, + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(300)}, + "subnets": []interface{}{ + map[string]interface{}{"cidr": "10.30.0.0/16"}, + map[string]interface{}{"cidr": "10.31.0.0/16"}, + }, + }, + }, + }, + }) + cb, _ := newFakeClientWith(nw) + c := cb.Build() + + got, err := GetNetwork(context.Background(), c, "datacenter-vlans") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.L2Bridge == nil || len(got.L2Bridge.VLANs) != 3 { + t.Fatalf("VLANs len = %d, want 3", len(got.L2Bridge.VLANs)) + } + wantVIDs := []uint16{100, 200, 300} + for i, want := range wantVIDs { + if got.L2Bridge.VLANs[i].VID != want { + t.Errorf("VLANs[%d].VID = %d, want %d", i, got.L2Bridge.VLANs[i].VID, want) + } + } + // Multi-subnet VLAN entry + multi := got.L2Bridge.VLANs[2].Subnets + if len(multi) != 2 || multi[0] != "10.30.0.0/16" || multi[1] != "10.31.0.0/16" { + t.Errorf("VLANs[2].Subnets = %v, want [10.30.0.0/16 10.31.0.0/16]", multi) + } +} + +func TestGetNetwork_InvalidVLANID(t *testing.T) { + cases := []struct { + name string + vlan map[string]interface{} // value placed at spec.l2Bridge.vlans[0].vlan + }{ + { + name: "missing id", + vlan: map[string]interface{}{}, + }, + { + name: "negative id", + vlan: map[string]interface{}{"id": int64(-1)}, + }, + { + name: "id above range", + vlan: map[string]interface{}{"id": int64(5000)}, + }, + { + name: "id is reserved zero", + vlan: map[string]interface{}{"id": int64(0)}, + }, + { + name: "non-integer float id", + vlan: map[string]interface{}{"id": 1.5}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + nw := makeNetwork("bad-vlan", map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": tc.vlan, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.30.0.0/16"}}, + }, + }, + }, + }) + cb, _ := newFakeClientWith(nw) + c := cb.Build() + + if _, err := GetNetwork(context.Background(), c, "bad-vlan"); err == nil { + t.Fatalf("expected error, got nil") + } + }) + } +} diff --git a/pkg/provider/ec2/controller/validator/noop.go b/pkg/provider/ec2/controller/validator/noop.go index 614e057b9c..3da54d4d59 100644 --- a/pkg/provider/ec2/controller/validator/noop.go +++ b/pkg/provider/ec2/controller/validator/noop.go @@ -87,3 +87,13 @@ func (r *Validator) WarmMigration() bool { func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (bool, error) { return false, nil } + +// ValidateCalicoNADs returns empty results (not applicable for EC2). +func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { + return planbase.CalicoValidationResult{}, nil +} + +// CalicoVMIssues returns no issues (not applicable for EC2). +func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { + return nil, nil +} From 1d0e365e0595517141aac49944e9448aff921f91 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Mon, 22 Jun 2026 11:34:41 +0100 Subject: [PATCH 02/11] Warn when a Calico-typed NAD is in L3 mode (no identity preservation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NAD whose Spec.Config is just {"type": "calico"} (no "network" field) is a valid Calico-CNI configuration — it requests legacy L3 IPAM mode. But Forklift's identity-preservation work only fires for L2-attached NADs (gated on ReferencesCalicoNetwork() == "type==calico" AND "network != \"\""), so a user opting into this without an L2 reference silently loses MAC/IP preservation at migration time. This commit surfaces that gap as a Warn-class Plan condition instead of silently skipping the NAD. Changes: * New CalicoIssueKind: NADMissingNetwork. * New CalicoValidationResult.Warnings slice, distinct from .Issues so the dispatcher can render Critical and Warn classes independently. * vSphere ValidateCalicoNADs: emit the warning when type==calico and network is empty, between NAD parse and the existing non-Calico skip. * New Plan condition type CalicoNetworkWarning (Category: Warn) alongside CalicoNetworkInvalid (Category: Critical). Dispatcher logic deduplicated via a shared buildCalicoNADCondition helper so both classes go through the same itemize/format path. * Unit tests: warn-in-isolation case + Critical+Warn coexistence case to lock in the dispatcher's independence between the two classes. Stale-condition cleanup is handled by the existing BeginStaging / EndStaging pattern in controller.go — when the user fixes the NAD the warning is naturally pruned on the next reconcile. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Alex O'Regan --- .../plan/adapter/base/calico_validation.go | 10 +-- pkg/controller/plan/adapter/base/doc.go | 5 ++ .../plan/adapter/vsphere/validator.go | 11 ++++ .../plan/adapter/vsphere/validator_test.go | 63 +++++++++++++++++++ pkg/controller/plan/validation.go | 49 +++++++++++---- 5 files changed, 121 insertions(+), 17 deletions(-) diff --git a/pkg/controller/plan/adapter/base/calico_validation.go b/pkg/controller/plan/adapter/base/calico_validation.go index 6b763f3f0d..0acfc6815a 100644 --- a/pkg/controller/plan/adapter/base/calico_validation.go +++ b/pkg/controller/plan/adapter/base/calico_validation.go @@ -37,9 +37,11 @@ type CalicoNADIssue struct { } // CalicoValidationResult is the output of ValidateCalicoNADs: -// resource-level issues to report at plan level, and a cache of healthy -// NADs for downstream per-VM checks. +// resource-level issues to report at plan level, warnings that describe +// degraded-but-not-blocked configurations, and a cache of healthy NADs +// for downstream per-VM checks. type CalicoValidationResult struct { - Issues []CalicoNADIssue - Cache *CalicoValidationCache + Issues []CalicoNADIssue + Warnings []CalicoNADIssue + Cache *CalicoValidationCache } diff --git a/pkg/controller/plan/adapter/base/doc.go b/pkg/controller/plan/adapter/base/doc.go index ddf10cda57..13eab0d531 100644 --- a/pkg/controller/plan/adapter/base/doc.go +++ b/pkg/controller/plan/adapter/base/doc.go @@ -335,6 +335,11 @@ const ( CalicoIssueIPNotInSubnet CalicoIssueKind = "IPNotInSubnet" // CalicoIssueIPNotInIPPool NIC's IP was not in any Calico IPPool. CalicoIssueIPNotInIPPool CalicoIssueKind = "IPNotInIPPool" + // CalicoIssueNADMissingNetwork the NAD requests the Calico CNI but does + // not name a projectcalico.org Network resource (no "network" field). + // This is Calico's legacy L3 IPAM mode; identity preservation (MAC + IP) + // will not be applied for NICs mapped to this NAD. Warn-level. + CalicoIssueNADMissingNetwork CalicoIssueKind = "NADMissingNetwork" ) // CalicoIssue represents a per-VM Calico Network validation failure: the diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index f7bd7f1ae4..49c2294e54 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -780,6 +780,17 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid }) continue } + // type:calico without a "network" field is Calico's legacy L3 IPAM + // mode. Forklift's identity preservation only applies to the L2 + // path; warn the user that MAC/IP annotations will not be emitted + // for NICs mapped here. + if cfg.Type == ocpmodel.CalicoCNIType && cfg.Network == "" { + result.Warnings = append(result.Warnings, planbase.CalicoNADIssue{ + NAD: key, + Kind: planbase.CalicoIssueNADMissingNetwork, + }) + continue + } if !cfg.ReferencesCalicoNetwork() { continue } diff --git a/pkg/controller/plan/adapter/vsphere/validator_test.go b/pkg/controller/plan/adapter/vsphere/validator_test.go index 7443d4b7bf..cd5bf2fc22 100644 --- a/pkg/controller/plan/adapter/vsphere/validator_test.go +++ b/pkg/controller/plan/adapter/vsphere/validator_test.go @@ -971,6 +971,69 @@ var _ = Describe("vsphere validation tests", func() { Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.NADs).To(BeEmpty()) }) + + It("warns when a Calico-typed NAD has no 'network' field (L3 mode, no identity preservation)", func() { + // type:calico without a network reference is Calico's legacy L3 + // IPAM mode. Forklift's MAC/IP annotation stamping is gated on + // the presence of a Calico Network reference, so this NAD would + // silently miss preservation. Warn-class issue surfaces the gap + // without blocking the migration. + l3NAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: nadName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: `{"type":"calico"}`}, + } + v, c, _ := setup("10.100.0.5", true, l3NAD) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, + Kind: planbase.CalicoIssueNADMissingNetwork, + })) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + + It("populates Issues and Warnings independently when both classes coexist", func() { + // Two NADs in the same NetworkMap: the original (L2 Calico, but + // the referenced Network CR is absent — Critical NetworkNotFound) + // and a second L3-mode NAD (Warn NADMissingNetwork). Asserts the + // dispatcher's independence claim: both slices populated, items + // disjoint, no cross-contamination between Critical and Warn. + const ( + l3NADName = "calico-nad-l3" + l3SrcID = "src-l3" + ) + l3NAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: l3NADName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: `{"type":"calico"}`}, + } + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), // existing nadName, references missing Network "vlan100" + l3NAD, + ) + v.Plan.Referenced.Map.Network.Spec.Map = append( + v.Plan.Referenced.Map.Network.Spec.Map, + v1beta1.NetworkPair{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: l3SrcID}}, + Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: l3NADName, + }, + }, + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, + Kind: planbase.CalicoIssueNetworkNotFound, + Network: netName, + VLAN: 100, + })) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: l3NADName}, + Kind: planbase.CalicoIssueNADMissingNetwork, + })) + Expect(result.Cache.NADs).To(BeEmpty()) + }) }) Describe("plan-level / per-VM cross-cut", func() { diff --git a/pkg/controller/plan/validation.go b/pkg/controller/plan/validation.go index d74e9c103b..439430ea3f 100644 --- a/pkg/controller/plan/validation.go +++ b/pkg/controller/plan/validation.go @@ -66,6 +66,7 @@ const ( VMMissingGuestIPs = "VMMissingGuestIPs" VMIpNotMatchingUdnSubnet = "VMIpNotMatchingUdnSubnet" CalicoNetworkInvalid = "CalicoNetworkInvalid" + CalicoNetworkWarning = "CalicoNetworkWarning" VMIpNotInCalicoSubnet = "VMIpNotInCalicoSubnet" VMIpNotInCalicoIPPool = "VMIpNotInCalicoIPPool" VMMissingChangedBlockTracking = "VMMissingChangedBlockTracking" @@ -569,31 +570,51 @@ func (r *Reconciler) validateCalicoNetwork(ctx *plancontext.Context) (*planbase. if err != nil { return nil, err } - if len(result.Issues) == 0 { - return result.Cache, nil + if cond, ok := buildCalicoNADCondition( + CalicoNetworkInvalid, api.CategoryCritical, + "One or more Calico Network destinations are invalid", + result.Issues, + ); ok { + ctx.Plan.Status.SetCondition(cond) } + if cond, ok := buildCalicoNADCondition( + CalicoNetworkWarning, api.CategoryWarn, + "One or more Calico NADs will not receive identity preservation", + result.Warnings, + ); ok { + ctx.Plan.Status.SetCondition(cond) + } + return result.Cache, nil +} +// buildCalicoNADCondition assembles a plan-level Calico NAD condition from a +// slice of per-NAD issues. Items are deduplicated by NAD reference; Message +// concatenates a per-issue detail phrase for each (including duplicates, so +// distinct kinds against the same NAD are both visible). Returns ok=false +// when issues is empty so callers can skip SetCondition. +func buildCalicoNADCondition(condType, category, baseMsg string, issues []planbase.CalicoNADIssue) (libcnd.Condition, bool) { + if len(issues) == 0 { + return libcnd.Condition{}, false + } cond := libcnd.Condition{ - Type: CalicoNetworkInvalid, + Type: condType, Status: True, Reason: NotValid, - Category: api.CategoryCritical, - Message: "One or more Calico Network destinations are invalid", + Category: category, Items: []string{}, } - details := make([]string, 0, len(result.Issues)) - seenNAD := map[string]bool{} - for _, issue := range result.Issues { + details := make([]string, 0, len(issues)) + seen := map[string]bool{} + for _, issue := range issues { ref := issue.NAD.String() - if !seenNAD[ref] { - seenNAD[ref] = true + if !seen[ref] { + seen[ref] = true cond.Items = append(cond.Items, ref) } details = append(details, calicoNADIssueDetail(issue)) } - cond.Message = fmt.Sprintf("%s: %s.", cond.Message, strings.Join(details, "; ")) - ctx.Plan.Status.SetCondition(cond) - return result.Cache, nil + cond.Message = fmt.Sprintf("%s: %s.", baseMsg, strings.Join(details, "; ")) + return cond, true } func (r *Reconciler) getDestinationNamespaceNads(ctx *plancontext.Context) (*k8snet.NetworkAttachmentDefinitionList, error) { @@ -1982,6 +2003,8 @@ func calicoNADIssueDetail(i planbase.CalicoNADIssue) string { return fmt.Sprintf("%s (VLANAmbiguous network=%q)", i.NAD.String(), i.Network) case planbase.CalicoIssueVLANHasNoIPPool: return fmt.Sprintf("%s (VLANHasNoIPPool network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) + case planbase.CalicoIssueNADMissingNetwork: + return fmt.Sprintf("%s (NADMissingNetwork: type=calico without 'network' field; MAC/IP preservation not applied)", i.NAD.String()) } return fmt.Sprintf("%s (%s)", i.NAD.String(), i.Kind) } From a06593dbba0aa268a9fdbb76022e2c99527b5e63 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Mon, 22 Jun 2026 11:37:57 +0100 Subject: [PATCH 03/11] Address CodeRabbit feedback: wrap NAD GET error + fix test nil-deref * pkg/controller/plan/adapter/base/nad.go: wrap the NAD GET error with namespace/name context using fmt.Errorf("%w"). Downstream k8serr.IsNotFound and meta.IsNoMatchError both unwrap through %w, so the validator's CalicoNetworkNotFound / NoMatchError disambiguation continues to work. * pkg/lib/client/calico/network_test.go: split the combined "if got.L2Bridge == nil || len(got.L2Bridge.VLANs) != 3" check into two distinct t.Fatal calls. The original code would have panicked on a nil L2Bridge because the t.Fatalf args still dereferenced through it, masking the real regression with a panic stack trace. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Alex O'Regan --- pkg/controller/plan/adapter/base/nad.go | 3 ++- pkg/lib/client/calico/network_test.go | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/controller/plan/adapter/base/nad.go b/pkg/controller/plan/adapter/base/nad.go index 3e6166ae0a..fe39c3be8a 100644 --- a/pkg/controller/plan/adapter/base/nad.go +++ b/pkg/controller/plan/adapter/base/nad.go @@ -2,6 +2,7 @@ package base import ( "context" + "fmt" k8snet "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" model "github.com/kubev2v/forklift/pkg/controller/provider/model/ocp" @@ -16,7 +17,7 @@ func FetchAndParseNAD(ctx context.Context, c client.Client, namespace, name stri nad := &k8snet.NetworkAttachmentDefinition{} key := client.ObjectKey{Namespace: namespace, Name: name} if err := c.Get(ctx, key, nad); err != nil { - return nil, err + return nil, fmt.Errorf("get NetworkAttachmentDefinition %s/%s: %w", namespace, name, err) } return model.ParseNAD(nad) } diff --git a/pkg/lib/client/calico/network_test.go b/pkg/lib/client/calico/network_test.go index 0f405f647e..f51a1be50f 100644 --- a/pkg/lib/client/calico/network_test.go +++ b/pkg/lib/client/calico/network_test.go @@ -139,7 +139,10 @@ func TestGetNetwork_MultipleVLANs(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if got.L2Bridge == nil || len(got.L2Bridge.VLANs) != 3 { + if got.L2Bridge == nil { + t.Fatal("L2Bridge = nil, want non-nil") + } + if len(got.L2Bridge.VLANs) != 3 { t.Fatalf("VLANs len = %d, want 3", len(got.L2Bridge.VLANs)) } wantVIDs := []uint16{100, 200, 300} From 21bbc5f7f5f1343839102b5540921ef2a72b94d1 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Wed, 1 Jul 2026 09:40:18 +0100 Subject: [PATCH 04/11] Preserve VM network identity on Calico destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrating a VM onto a Calico cluster gave its pod a fresh MAC and a fresh IP, breaking workloads keyed to the source identity (MAC-based licences, IP allowlists, static in-guest addressing). This adds opt-in identity preservation, driven from the NetworkMap. A `type: pod` destination gains an optional `calico` block. Its presence — even empty (`calico: {}`) — opts the VM's primary NIC into preservation: the source MAC is carried over, and the source IP too when the Plan sets preserveStaticIPs. Naming a Calico Network under the block requests an L2 attach. `calico.network` selects the Network and `calico.vlan` selects the VLAN within it; the two are required together, and a Network named without a VLAN is rejected rather than auto-selected. Secondary NICs keep their `type: multus` shape — a Multus entry pointing at a Calico-backed NAD now also carries the source NIC's identity. Before a migration starts, Forklift checks what the destination's Calico can actually do and blocks the Plan up front when a request can't be honoured: Calico absent, the Network resource absent, a requested VLAN missing, a NIC carrying more than one IPv4, or a source other than vSphere — the only source supported in this delivery. Review fixes folded in: - IPPool eligibility is allowedUses-aware. The Calico client parses disabled and allowedUses (nil-vs-empty is load-bearing: an absent field means Calico's default ["Workload","Tunnel"]), replacing the CIDR-only pool helpers: L2 attach requires an L2Workload-allowed pool inside the VLAN subnet, plain L3 preservation requires a Workload-allowed pool, and disabled pools never match. - The plan-level "preserving static IPs on pod networking" warning no longer fires for a calico-flagged pod entry - preserving the IP on the pod network is exactly what that entry does. - NAD-path hardening: the NAD walk caches parsed configs so a fetch/parse failure is reported once as NADUnreadable instead of being retried per entry, and the per-VM primary matcher only considers pod-type entries. - A source NIC without a MAC no longer produces an empty hwAddr annotation that would fail the pod at CNI ADD. - A NetworkMap reference to a Calico Network that is not an l2Bridge network (e.g. a VRF network) is reported accurately as NetworkTypeUnsupported instead of a misleading VLAN error: the Network is fetched and classified before any VLAN handling, so a missing Network now also reports NetworkNotFound ahead of VLANRequired. - L2 attach requires Calico's BPF dataplane: a plan attaching to an l2Bridge network on a cluster whose FelixConfiguration does not set bpfEnabled: true is blocked with a Critical condition up front rather than failing at pod creation. RBAC gains read access to felixconfigurations. - Doc and CRD text synced to explicit-VLAN semantics: vlan 0 is documented as "not set" and rejected whenever a Network is named; the regenerated CRD and operator bundles carry the same text. - RBAC comment reworded; test coverage aligned across the vSphere validator and builder suites. # Conflicts: # pkg/controller/plan/adapter/vsphere/validator_test.go Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Alex O'Regan --- operator/.downstream_manifests | 29 +- operator/.upstream_manifests | 29 +- .../forklift.konveyor.io_networkmaps.yaml | 28 +- .../config/rbac/forklift-controller_role.yaml | 8 +- pkg/apis/forklift/v1beta1/mapping.go | 26 +- pkg/apis/forklift/v1beta1/mapping_test.go | 106 ++ .../forklift/v1beta1/zz_generated.deepcopy.go | 26 +- pkg/controller/plan/adapter/base/calico.go | 123 ++- .../plan/adapter/base/calico_test.go | 146 +++ .../plan/adapter/base/calico_validation.go | 62 ++ pkg/controller/plan/adapter/base/doc.go | 124 ++- .../plan/adapter/hyperv/validator.go | 23 + .../hyperv/validator_calico_primary_test.go | 41 + .../plan/adapter/nutanix/validator.go | 23 + .../nutanix/validator_calico_primary_test.go | 41 + pkg/controller/plan/adapter/ocp/validator.go | 23 + .../ocp/validator_calico_primary_test.go | 41 + .../plan/adapter/openstack/validator.go | 23 + .../validator_calico_primary_test.go | 41 + .../plan/adapter/ovfbase/validator.go | 23 + .../ovfbase/validator_calico_primary_test.go | 41 + .../plan/adapter/ovirt/validator.go | 23 + .../ovirt/validator_calico_primary_test.go | 41 + .../plan/adapter/vsphere/builder.go | 53 +- .../plan/adapter/vsphere/builder_test.go | 141 ++- .../plan/adapter/vsphere/validator.go | 414 +++++++- .../plan/adapter/vsphere/validator_test.go | 918 +++++++++++++++++- pkg/controller/plan/validation.go | 187 +++- pkg/controller/plan/validation_test.go | 49 + pkg/lib/client/calico/felixconfig.go | 53 + pkg/lib/client/calico/felixconfig_test.go | 129 +++ pkg/lib/client/calico/ippool.go | 162 +++- pkg/lib/client/calico/ippool_test.go | 258 ++--- pkg/lib/client/calico/network.go | 13 + pkg/lib/client/calico/network_test.go | 27 + pkg/provider/ec2/controller/validator/noop.go | 23 + 36 files changed, 3290 insertions(+), 228 deletions(-) create mode 100644 pkg/apis/forklift/v1beta1/mapping_test.go create mode 100644 pkg/controller/plan/adapter/hyperv/validator_calico_primary_test.go create mode 100644 pkg/controller/plan/adapter/nutanix/validator_calico_primary_test.go create mode 100644 pkg/controller/plan/adapter/ocp/validator_calico_primary_test.go create mode 100644 pkg/controller/plan/adapter/openstack/validator_calico_primary_test.go create mode 100644 pkg/controller/plan/adapter/ovfbase/validator_calico_primary_test.go create mode 100644 pkg/controller/plan/adapter/ovirt/validator_calico_primary_test.go create mode 100644 pkg/lib/client/calico/felixconfig.go create mode 100644 pkg/lib/client/calico/felixconfig_test.go diff --git a/operator/.downstream_manifests b/operator/.downstream_manifests index c102ae1051..1b4c0af1bb 100644 --- a/operator/.downstream_manifests +++ b/operator/.downstream_manifests @@ -5755,11 +5755,35 @@ spec: destination: description: Destination network. properties: + calico: + description: |- + Calico marks the destination's default pod network as Calico-provided + and opts the VM's primary NIC into Calico identity preservation: the + source MAC is carried over, and the source IPs too when the Plan sets + preserveStaticIPs. Applies only when Type == "pod". + properties: + network: + description: |- + Name of a cluster-scoped projectcalico.org/v3 Network CR for L2 + primary attach. Empty value means no L2 attach: Calico's default L3 + IPAM is used. Applies only when the entry's Type == "pod". + type: string + vlan: + description: |- + 802.1Q VLAN ID within the named Calico Network. Applies only when + Network != "" and is then required: it must match a VLAN entry's + vlan.id in the named Network. Value 0 (or omitted) means "not set" + and is rejected at Plan validation whenever a Network is named. + maximum: 4094 + minimum: 0 + type: integer + type: object name: - description: The name. + description: The name. Applies only when Type == "multus" + (the NetworkAttachmentDefinition name). type: string namespace: - description: The namespace (multus only). + description: The namespace. Applies only when Type == "multus". type: string type: description: |- @@ -11227,6 +11251,7 @@ rules: resources: - networks - ippools + - felixconfigurations verbs: - get - list diff --git a/operator/.upstream_manifests b/operator/.upstream_manifests index f7990f3321..97afa6d0ed 100644 --- a/operator/.upstream_manifests +++ b/operator/.upstream_manifests @@ -5755,11 +5755,35 @@ spec: destination: description: Destination network. properties: + calico: + description: |- + Calico marks the destination's default pod network as Calico-provided + and opts the VM's primary NIC into Calico identity preservation: the + source MAC is carried over, and the source IPs too when the Plan sets + preserveStaticIPs. Applies only when Type == "pod". + properties: + network: + description: |- + Name of a cluster-scoped projectcalico.org/v3 Network CR for L2 + primary attach. Empty value means no L2 attach: Calico's default L3 + IPAM is used. Applies only when the entry's Type == "pod". + type: string + vlan: + description: |- + 802.1Q VLAN ID within the named Calico Network. Applies only when + Network != "" and is then required: it must match a VLAN entry's + vlan.id in the named Network. Value 0 (or omitted) means "not set" + and is rejected at Plan validation whenever a Network is named. + maximum: 4094 + minimum: 0 + type: integer + type: object name: - description: The name. + description: The name. Applies only when Type == "multus" + (the NetworkAttachmentDefinition name). type: string namespace: - description: The namespace (multus only). + description: The namespace. Applies only when Type == "multus". type: string type: description: |- @@ -11227,6 +11251,7 @@ rules: resources: - networks - ippools + - felixconfigurations verbs: - get - list diff --git a/operator/config/crd/bases/forklift.konveyor.io_networkmaps.yaml b/operator/config/crd/bases/forklift.konveyor.io_networkmaps.yaml index fdf4f37210..e59ffc0c21 100644 --- a/operator/config/crd/bases/forklift.konveyor.io_networkmaps.yaml +++ b/operator/config/crd/bases/forklift.konveyor.io_networkmaps.yaml @@ -53,11 +53,35 @@ spec: destination: description: Destination network. properties: + calico: + description: |- + Calico marks the destination's default pod network as Calico-provided + and opts the VM's primary NIC into Calico identity preservation: the + source MAC is carried over, and the source IPs too when the Plan sets + preserveStaticIPs. Applies only when Type == "pod". + properties: + network: + description: |- + Name of a cluster-scoped projectcalico.org/v3 Network CR for L2 + primary attach. Empty value means no L2 attach: Calico's default L3 + IPAM is used. Applies only when the entry's Type == "pod". + type: string + vlan: + description: |- + 802.1Q VLAN ID within the named Calico Network. Applies only when + Network != "" and is then required: it must match a VLAN entry's + vlan.id in the named Network. Value 0 (or omitted) means "not set" + and is rejected at Plan validation whenever a Network is named. + maximum: 4094 + minimum: 0 + type: integer + type: object name: - description: The name. + description: The name. Applies only when Type == "multus" + (the NetworkAttachmentDefinition name). type: string namespace: - description: The namespace (multus only). + description: The namespace. Applies only when Type == "multus". type: string type: description: |- diff --git a/operator/config/rbac/forklift-controller_role.yaml b/operator/config/rbac/forklift-controller_role.yaml index e7e3886450..8b1f5ca6fb 100644 --- a/operator/config/rbac/forklift-controller_role.yaml +++ b/operator/config/rbac/forklift-controller_role.yaml @@ -247,14 +247,16 @@ rules: - get - list - watch -# Calico L2 (PMREQ-810): the Plan validator reads Calico Network and IPPool -# CRs to validate L2-bridge NAD destinations and per-VM static IPs. +# The Plan validator reads Calico Network and IPPool resources on the +# destination cluster to validate L2-bridge NAD destinations and per-VM +# static IPs. - apiGroups: - projectcalico.org resources: - networks - ippools + - felixconfigurations verbs: - get - list - - watch \ No newline at end of file + - watch diff --git a/pkg/apis/forklift/v1beta1/mapping.go b/pkg/apis/forklift/v1beta1/mapping.go index 93ef4e0bf5..10b38be542 100644 --- a/pkg/apis/forklift/v1beta1/mapping.go +++ b/pkg/apis/forklift/v1beta1/mapping.go @@ -39,10 +39,32 @@ type DestinationNetwork struct { // - ignored: Network is excluded from mapping // +kubebuilder:validation:Enum=pod;multus;ignored Type string `json:"type"` - // The namespace (multus only). + // The namespace. Applies only when Type == "multus". Namespace string `json:"namespace,omitempty"` - // The name. + // The name. Applies only when Type == "multus" (the NetworkAttachmentDefinition name). Name string `json:"name,omitempty"` + // Calico marks the destination's default pod network as Calico-provided + // and opts the VM's primary NIC into Calico identity preservation: the + // source MAC is carried over, and the source IPs too when the Plan sets + // preserveStaticIPs. Applies only when Type == "pod". + Calico *CalicoDestination `json:"calico,omitempty"` +} + +// CalicoDestination qualifies a type: pod destination whose default pod +// network is provided by Calico. Its presence (even empty) is the opt-in +// for primary-NIC identity preservation. +type CalicoDestination struct { + // Name of a cluster-scoped projectcalico.org/v3 Network CR for L2 + // primary attach. Empty value means no L2 attach: Calico's default L3 + // IPAM is used. Applies only when the entry's Type == "pod". + Network string `json:"network,omitempty"` + // 802.1Q VLAN ID within the named Calico Network. Applies only when + // Network != "" and is then required: it must match a VLAN entry's + // vlan.id in the named Network. Value 0 (or omitted) means "not set" + // and is rejected at Plan validation whenever a Network is named. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=4094 + Vlan uint16 `json:"vlan,omitempty"` } // NetworkSourceRef extends Ref with an optional VLAN qualifier for network disambiguation. diff --git a/pkg/apis/forklift/v1beta1/mapping_test.go b/pkg/apis/forklift/v1beta1/mapping_test.go new file mode 100644 index 0000000000..fd3c9b7056 --- /dev/null +++ b/pkg/apis/forklift/v1beta1/mapping_test.go @@ -0,0 +1,106 @@ +package v1beta1 + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestDestinationNetwork_RoundTrip_NoCalico(t *testing.T) { + // Entries without the calico field should round-trip without a "calico" + // key appearing in the JSON. + for _, typ := range []string{"pod", "multus", "ignored"} { + in := DestinationNetwork{Type: typ, Namespace: "ns", Name: "n"} + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal %q: %v", typ, err) + } + if strings.Contains(string(raw), "calico") { + t.Errorf("type=%q JSON includes calico key: %s", typ, raw) + } + var out DestinationNetwork + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal %q: %v", typ, err) + } + if !reflect.DeepEqual(out, in) { + t.Errorf("round-trip mismatch for %q: got %+v, want %+v", typ, out, in) + } + } +} + +func TestDestinationNetwork_RoundTrip_CalicoEmpty(t *testing.T) { + // The empty calico block is the minimal opt-in — its presence must + // survive a round trip (nil vs empty-struct distinction is load-bearing). + in := DestinationNetwork{Type: "pod", Calico: &CalicoDestination{}} + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"calico":{}`) { + t.Errorf("JSON missing empty calico block: %s", raw) + } + var out DestinationNetwork + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Calico == nil { + t.Fatalf("calico block lost in round trip: %s", raw) + } + if !reflect.DeepEqual(out, in) { + t.Errorf("round-trip mismatch: got %+v, want %+v", out, in) + } +} + +func TestDestinationNetwork_RoundTrip_CalicoWithNetwork(t *testing.T) { + in := DestinationNetwork{Type: "pod", Calico: &CalicoDestination{Network: "vlan100"}} + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"calico":{"network":"vlan100"}`) { + t.Errorf("JSON missing calico.network: %s", raw) + } + var out DestinationNetwork + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(out, in) { + t.Errorf("round-trip mismatch: got %+v, want %+v", out, in) + } +} + +func TestDestinationNetwork_RoundTrip_CalicoWithNetworkAndVlan(t *testing.T) { + in := DestinationNetwork{Type: "pod", Calico: &CalicoDestination{Network: "vlan100", Vlan: 100}} + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"network":"vlan100"`) { + t.Errorf("JSON missing calico.network: %s", raw) + } + if !strings.Contains(string(raw), `"vlan":100`) { + t.Errorf("JSON missing calico.vlan: %s", raw) + } + var out DestinationNetwork + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(out, in) { + t.Errorf("round-trip mismatch: got %+v, want %+v", out, in) + } +} + +func TestCalicoDestination_OmitsZeroVlan(t *testing.T) { + // Zero vlan means "not set" (a named Network requires an explicit VLAN, + // enforced at Plan validation) and must serialize as omitted so a stored + // object round-trips without growing a spurious vlan: 0 field. + in := DestinationNetwork{Type: "pod", Calico: &CalicoDestination{Network: "prod", Vlan: 0}} + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(raw), `"vlan":`) { + t.Errorf("Vlan=0 should be omitted, got: %s", raw) + } +} diff --git a/pkg/apis/forklift/v1beta1/zz_generated.deepcopy.go b/pkg/apis/forklift/v1beta1/zz_generated.deepcopy.go index 4ef78ac9b5..3e9fb9aa48 100644 --- a/pkg/apis/forklift/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/forklift/v1beta1/zz_generated.deepcopy.go @@ -47,6 +47,21 @@ func (in *AAPConfig) DeepCopy() *AAPConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CalicoDestination) DeepCopyInto(out *CalicoDestination) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CalicoDestination. +func (in *CalicoDestination) DeepCopy() *CalicoDestination { + if in == nil { + return nil + } + out := new(CalicoDestination) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Connection) DeepCopyInto(out *Connection) { *out = *in @@ -227,6 +242,11 @@ func (in *CsiVolumeImport) DeepCopy() *CsiVolumeImport { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DestinationNetwork) DeepCopyInto(out *DestinationNetwork) { *out = *in + if in.Calico != nil { + in, out := &in.Calico, &out.Calico + *out = new(CalicoDestination) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DestinationNetwork. @@ -1062,7 +1082,9 @@ func (in *NetworkMapSpec) DeepCopyInto(out *NetworkMapSpec) { if in.Map != nil { in, out := &in.Map, &out.Map *out = make([]NetworkPair, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } @@ -1095,7 +1117,7 @@ func (in *NetworkNameTemplateData) DeepCopy() *NetworkNameTemplateData { func (in *NetworkPair) DeepCopyInto(out *NetworkPair) { *out = *in out.Source = in.Source - out.Destination = in.Destination + in.Destination.DeepCopyInto(&out.Destination) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPair. diff --git a/pkg/controller/plan/adapter/base/calico.go b/pkg/controller/plan/adapter/base/calico.go index bcf21fe778..e34bbcc3a8 100644 --- a/pkg/controller/plan/adapter/base/calico.go +++ b/pkg/controller/plan/adapter/base/calico.go @@ -3,18 +3,50 @@ package base import ( "encoding/json" "fmt" + "strconv" meta "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( + // Per-interface (Calico secondary-NAD path) annotation keys. Scoped by + // the VMI network name (net-0, net-1, or template-derived). At runtime + // KubeVirt names the pod-side interface with its hashed scheme, but + // Calico's CNI reverse-maps that back to the VMI network name for + // virt-launcher pods and keys per-interface annotations by it — so the + // VMI network name is the correct key here, not the hashed name. CalicoAnnHwAddrFmt = "cni.projectcalico.org/%s.hwAddr" CalicoAnnIPsFmt = "cni.projectcalico.org/%s.ipAddrs" + + // Unscoped (Calico primary-NIC path) annotation keys. Applied to the + // VM's pod template once, addressing the pod's primary interface + // (Calico's default). The Networks annotation pulls in an L2 attach via + // a named projectcalico.org/v3 Network CR; absence means default L3 + // IPAM. The Vlan annotation selects the 802.1Q VLAN within that + // Network; every Network reference requires an explicit VLAN (a zero + // VLAN means "not set" and Plan validation rejects it whenever a + // network is named), so Vlan is always written alongside Networks. + CalicoAnnPrimaryHwAddr = "cni.projectcalico.org/hwAddr" + CalicoAnnPrimaryIPs = "cni.projectcalico.org/ipAddrs" + CalicoAnnPrimaryNetwork = "cni.projectcalico.org/networks" + CalicoAnnPrimaryVlan = "cni.projectcalico.org/vlan" + + // AnnAllowPodBridgeNetworkLiveMigration is KubeVirt's opt-in for live + // migration of VMs whose pod-network interface uses Bridge binding. + // Calico-primary VMs are bridge-bound, so without this annotation they + // cannot live-migrate — and Calico's IP persistence across live + // migration is a headline capability of the L2 feature. + AnnAllowPodBridgeNetworkLiveMigration = "kubevirt.io/allow-pod-bridge-network-live-migration" ) // SetCalicoMAC writes the cni.projectcalico.org/.hwAddr annotation -// onto m. Lazy-inits Annotations when nil. +// onto m. No-op when mac is empty (inventory tolerates NICs without a MAC; +// an empty hwAddr annotation would fail the pod at CNI ADD). Lazy-inits +// Annotations when nil. func SetCalicoMAC(m *meta.ObjectMeta, ifname, mac string) { + if mac == "" { + return + } if m.Annotations == nil { m.Annotations = map[string]string{} } @@ -38,3 +70,92 @@ func SetCalicoStaticIPs(m *meta.ObjectMeta, ifname string, ips []string) error { m.Annotations[fmt.Sprintf(CalicoAnnIPsFmt, ifname)] = string(encoded) return nil } + +// CalicoPrimaryParams collects the Calico-primary annotation inputs so a +// single StampCalicoPrimary call can write all of them at once. +type CalicoPrimaryParams struct { + // MAC is the source NIC's MAC address. Empty → no annotation. + MAC string + // IPs are the IPv4 addresses of the source NIC (passed through only + // when Plan.Spec.PreserveStaticIPs is true). Empty/nil → no annotation. + IPs []string + // Network is the name of a projectcalico.org/v3 Network CR for L2 + // attach. Empty → no annotation; pod uses Calico's default L3 IPAM. + Network string + // Vlan is the 802.1Q VLAN ID within the named Network. Zero means "not + // set" → no annotation. Plan validation rejects a zero VLAN whenever a + // Network is named, so a Network always arrives here with a non-zero + // Vlan. + Vlan uint16 +} + +// SetCalicoPrimaryMAC writes the unscoped cni.projectcalico.org/hwAddr +// annotation. No-op when mac is empty. Lazy-inits Annotations when nil. +func SetCalicoPrimaryMAC(m *meta.ObjectMeta, mac string) { + if mac == "" { + return + } + if m.Annotations == nil { + m.Annotations = map[string]string{} + } + m.Annotations[CalicoAnnPrimaryHwAddr] = mac +} + +// SetCalicoPrimaryStaticIPs JSON-marshals ips and writes the unscoped +// cni.projectcalico.org/ipAddrs annotation. No-op when ips is empty. +// Lazy-inits Annotations when nil. +func SetCalicoPrimaryStaticIPs(m *meta.ObjectMeta, ips []string) error { + if len(ips) == 0 { + return nil + } + encoded, err := json.Marshal(ips) + if err != nil { + return err + } + if m.Annotations == nil { + m.Annotations = map[string]string{} + } + m.Annotations[CalicoAnnPrimaryIPs] = string(encoded) + return nil +} + +// SetCalicoPrimaryNetwork writes the cni.projectcalico.org/networks +// annotation with the named Calico Network CR. No-op when network is empty +// (default L3 IPAM). Lazy-inits Annotations when nil. +func SetCalicoPrimaryNetwork(m *meta.ObjectMeta, network string) { + if network == "" { + return + } + if m.Annotations == nil { + m.Annotations = map[string]string{} + } + m.Annotations[CalicoAnnPrimaryNetwork] = network +} + +// SetCalicoPrimaryVlan writes the cni.projectcalico.org/vlan annotation as a +// decimal 802.1Q VLAN ID. No-op when vlan is zero — zero means "not set", +// and Plan validation rejects it whenever a Network is named, so a named +// Network is always accompanied by this annotation. Lazy-inits Annotations +// when nil. +func SetCalicoPrimaryVlan(m *meta.ObjectMeta, vlan uint16) { + if vlan == 0 { + return + } + if m.Annotations == nil { + m.Annotations = map[string]string{} + } + m.Annotations[CalicoAnnPrimaryVlan] = strconv.Itoa(int(vlan)) +} + +// StampCalicoPrimary applies the full Calico-primary annotation set to m in +// one call. Each individual annotation is no-op on its empty input, so +// callers can pass zero-value fields freely. +func StampCalicoPrimary(m *meta.ObjectMeta, p CalicoPrimaryParams) error { + SetCalicoPrimaryMAC(m, p.MAC) + if err := SetCalicoPrimaryStaticIPs(m, p.IPs); err != nil { + return err + } + SetCalicoPrimaryNetwork(m, p.Network) + SetCalicoPrimaryVlan(m, p.Vlan) + return nil +} diff --git a/pkg/controller/plan/adapter/base/calico_test.go b/pkg/controller/plan/adapter/base/calico_test.go index eeaa28b40c..8b29d59bf7 100644 --- a/pkg/controller/plan/adapter/base/calico_test.go +++ b/pkg/controller/plan/adapter/base/calico_test.go @@ -46,6 +46,13 @@ func TestSetCalicoMAC(t *testing.T) { } }) } + t.Run("EmptyMacIsNoOp", func(t *testing.T) { + m := &meta.ObjectMeta{} + SetCalicoMAC(m, "net-0", "") + if m.Annotations != nil { + t.Errorf("annotations should be nil, got %v", m.Annotations) + } + }) } func TestSetCalicoStaticIPs(t *testing.T) { @@ -112,3 +119,142 @@ func TestSetCalicoStaticIPs(t *testing.T) { }) } } + +func TestSetCalicoPrimaryMAC(t *testing.T) { + t.Run("NilAnnotationsLazyInits", func(t *testing.T) { + m := &meta.ObjectMeta{} + SetCalicoPrimaryMAC(m, "aa:bb:cc:dd:ee:ff") + if got := m.Annotations[CalicoAnnPrimaryHwAddr]; got != "aa:bb:cc:dd:ee:ff" { + t.Errorf("got %q, want %q", got, "aa:bb:cc:dd:ee:ff") + } + }) + t.Run("EmptyMacIsNoOp", func(t *testing.T) { + m := &meta.ObjectMeta{} + SetCalicoPrimaryMAC(m, "") + if m.Annotations != nil { + t.Errorf("annotations should be nil, got %v", m.Annotations) + } + }) + t.Run("ExistingAnnotationsPreserved", func(t *testing.T) { + m := &meta.ObjectMeta{Annotations: map[string]string{"foo": "bar"}} + SetCalicoPrimaryMAC(m, "11:22:33:44:55:66") + if got := m.Annotations[CalicoAnnPrimaryHwAddr]; got != "11:22:33:44:55:66" { + t.Errorf("got %q, want %q", got, "11:22:33:44:55:66") + } + if got := m.Annotations["foo"]; got != "bar" { + t.Errorf("pre-existing annotation lost: %q", got) + } + }) +} + +func TestSetCalicoPrimaryStaticIPs(t *testing.T) { + t.Run("SingleIPLazyInit", func(t *testing.T) { + m := &meta.ObjectMeta{} + if err := SetCalicoPrimaryStaticIPs(m, []string{"10.0.0.5"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := m.Annotations[CalicoAnnPrimaryIPs]; got != `["10.0.0.5"]` { + t.Errorf("got %q, want %q", got, `["10.0.0.5"]`) + } + }) + t.Run("MultipleIPs", func(t *testing.T) { + m := &meta.ObjectMeta{} + if err := SetCalicoPrimaryStaticIPs(m, []string{"10.0.0.5", "10.0.0.6"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := m.Annotations[CalicoAnnPrimaryIPs]; got != `["10.0.0.5","10.0.0.6"]` { + t.Errorf("got %q, want %q", got, `["10.0.0.5","10.0.0.6"]`) + } + }) + t.Run("EmptySliceIsNoOp", func(t *testing.T) { + m := &meta.ObjectMeta{} + if err := SetCalicoPrimaryStaticIPs(m, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, present := m.Annotations[CalicoAnnPrimaryIPs]; present { + t.Errorf("annotation should not be present") + } + }) +} + +func TestSetCalicoPrimaryNetwork(t *testing.T) { + t.Run("PopulatedLazyInit", func(t *testing.T) { + m := &meta.ObjectMeta{} + SetCalicoPrimaryNetwork(m, "vlan100") + if got := m.Annotations[CalicoAnnPrimaryNetwork]; got != "vlan100" { + t.Errorf("got %q, want %q", got, "vlan100") + } + }) + t.Run("EmptyIsNoOp", func(t *testing.T) { + m := &meta.ObjectMeta{} + SetCalicoPrimaryNetwork(m, "") + if m.Annotations != nil { + t.Errorf("annotations should be nil, got %v", m.Annotations) + } + }) +} + +func TestSetCalicoPrimaryVlan(t *testing.T) { + t.Run("PopulatedLazyInit", func(t *testing.T) { + m := &meta.ObjectMeta{} + SetCalicoPrimaryVlan(m, 200) + if got := m.Annotations[CalicoAnnPrimaryVlan]; got != "200" { + t.Errorf("got %q, want %q", got, "200") + } + }) + t.Run("ZeroIsNoOp", func(t *testing.T) { + m := &meta.ObjectMeta{} + SetCalicoPrimaryVlan(m, 0) + if m.Annotations != nil { + t.Errorf("annotations should be nil, got %v", m.Annotations) + } + }) +} + +func TestStampCalicoPrimary(t *testing.T) { + t.Run("AllFieldsPopulated", func(t *testing.T) { + m := &meta.ObjectMeta{} + err := StampCalicoPrimary(m, CalicoPrimaryParams{ + MAC: "aa:bb:cc:dd:ee:ff", IPs: []string{"10.0.0.5"}, Network: "vlan100", Vlan: 100, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Annotations[CalicoAnnPrimaryHwAddr] != "aa:bb:cc:dd:ee:ff" { + t.Errorf("hwAddr missing") + } + if m.Annotations[CalicoAnnPrimaryIPs] != `["10.0.0.5"]` { + t.Errorf("ipAddrs missing") + } + if m.Annotations[CalicoAnnPrimaryNetwork] != "vlan100" { + t.Errorf("networks missing") + } + if m.Annotations[CalicoAnnPrimaryVlan] != "100" { + t.Errorf("vlan missing") + } + }) + t.Run("ZeroValuesAllNoOp", func(t *testing.T) { + m := &meta.ObjectMeta{} + if err := StampCalicoPrimary(m, CalicoPrimaryParams{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Annotations != nil { + t.Errorf("annotations should be nil with all-zero params, got %v", m.Annotations) + } + }) + t.Run("MACOnly", func(t *testing.T) { + m := &meta.ObjectMeta{} + if err := StampCalicoPrimary(m, CalicoPrimaryParams{MAC: "aa:bb:cc:dd:ee:ff"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, has := m.Annotations[CalicoAnnPrimaryIPs]; has { + t.Errorf("ipAddrs should be absent") + } + if _, has := m.Annotations[CalicoAnnPrimaryNetwork]; has { + t.Errorf("networks should be absent") + } + if m.Annotations[CalicoAnnPrimaryHwAddr] != "aa:bb:cc:dd:ee:ff" { + t.Errorf("hwAddr missing") + } + }) +} diff --git a/pkg/controller/plan/adapter/base/calico_validation.go b/pkg/controller/plan/adapter/base/calico_validation.go index 0acfc6815a..eafebed329 100644 --- a/pkg/controller/plan/adapter/base/calico_validation.go +++ b/pkg/controller/plan/adapter/base/calico_validation.go @@ -1,6 +1,7 @@ package base import ( + "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1/ref" calicoclient "github.com/kubev2v/forklift/pkg/lib/client/calico" "k8s.io/apimachinery/pkg/types" ) @@ -45,3 +46,64 @@ type CalicoValidationResult struct { Warnings []CalicoNADIssue Cache *CalicoValidationCache } + +// ResolvedCalicoPrimary captures the destination-cluster resources backing a +// calico-flagged NetworkMap entry (a type: pod destination carrying the +// calico field) after plan-level validation has passed. Per-VM checks read +// from this directly instead of re-fetching Network and IPPool objects for +// every VM. +// +// The struct accommodates both Calico-primary cases: +// - Case A (calico.network == ""): implicit L3 IPAM. Network/VLAN are zero; +// L3EligiblePools is the pool set the per-VM check uses to validate IP fit. +// - Case C (calico.network != ""): L2 attach via named Calico Network CR. +// Network/VLAN are populated; L2EligiblePools is the L2Workload-restricted +// pool set whose CIDR is contained in the matched VLAN's subnet(s). +type ResolvedCalicoPrimary struct { + // Network is the named Calico Network CR (empty for Case A). + Network string + // VLAN is the resolved l2Bridge VLAN entry (zero-value for Case A). + VLAN calicoclient.VLANEntry + // L2EligiblePools is the L2Workload-restricted pool set for Case C. + L2EligiblePools []calicoclient.IPPool + // L3EligiblePools is the L3-eligible pool set for Case A. + L3EligiblePools []calicoclient.IPPool + // Source is the NetworkMap entry's source ref — used by per-VM dispatch + // to identify which NIC source maps to the calico primary entry. + Source ref.Ref +} + +// CalicoPrimaryValidationCache holds resolved state for the (at most one) +// calico-flagged NetworkMap entry that passed plan-level validation. Primary +// is nil when no calico-flagged entry exists OR when plan-level validation +// failed for the entry — per-VM checks treat nil as "skip silently, the +// failure is already surfaced at plan level". +type CalicoPrimaryValidationCache struct { + Primary *ResolvedCalicoPrimary +} + +// CalicoPrimaryIssue is a plan- or VM-level failure for a calico-flagged +// NetworkMap entry. VMRef is zero-value for plan-level issues and populated +// for per-VM issues. +// +// All fields are comparable types, so the struct can be used directly as a +// map key for dedup. Per-VM dispatch uses CalicoPrimaryIssue as the dedup key +// — each per-VM invocation only sees one VMRef, so dedup-within-VM is the +// natural behaviour. +type CalicoPrimaryIssue struct { + VMRef ref.Ref + Kind CalicoIssueKind + Network string + VLAN uint16 + IP string +} + +// CalicoPrimaryValidationResult is the output of ValidateCalicoPrimary: +// plan-level issues to report at plan level, warnings that describe +// degraded-but-not-blocked configurations, and a cache for downstream +// per-VM checks. +type CalicoPrimaryValidationResult struct { + Issues []CalicoPrimaryIssue + Warnings []CalicoPrimaryIssue + Cache *CalicoPrimaryValidationCache +} diff --git a/pkg/controller/plan/adapter/base/doc.go b/pkg/controller/plan/adapter/base/doc.go index 13eab0d531..75dfa1b367 100644 --- a/pkg/controller/plan/adapter/base/doc.go +++ b/pkg/controller/plan/adapter/base/doc.go @@ -309,6 +309,29 @@ type Validator interface { // VMs whose mapped NAD failed plan-level validation are skipped here // — their failure is already reported via CalicoNetworkInvalid. CalicoVMIssues(vmRef ref.Ref, cache *CalicoValidationCache) ([]CalicoIssue, error) + // ValidateCalicoPrimary validates the (at most one) calico-flagged + // NetworkMap entry — a type: pod destination carrying the calico + // field. Returns plan-level issues (CRD presence, UDN conflict, + // Network/VLAN/IPPool resolution) plus a cache consumed by + // CalicoPrimaryIssues. On non-vSphere providers, returns a single + // CalicoIssuePrimaryProviderUnsupported issue when any calico-flagged + // entry is present. + // + // Precondition: Plan.Referenced.Map.Network is populated by the + // dispatcher before this is called. With a nil NetworkMap, returns an + // empty result and a non-nil cache with Primary == nil. + ValidateCalicoPrimary(client client.Client) (CalicoPrimaryValidationResult, error) + // CalicoPrimaryIssues returns per-VM issues for the calico-flagged + // primary NIC (IP membership in IPPool / VLAN subnet, gated on + // PreserveStaticIPs). Reads only from the cache produced by + // ValidateCalicoPrimary; when the cache is nil or Primary is nil (plan + // failed, or no calico-flagged entry exists), returns nil. + // + // When IP preservation is on but the VM has no findable IPv4 IPs + // (IPv6-only or no GuestNetworks reported), no per-VM issue is emitted + // — the builder will likewise emit no ipAddrs annotation. Both + // behaviours are correct: preservation is best-effort. + CalicoPrimaryIssues(vmRef ref.Ref, cache *CalicoPrimaryValidationCache) ([]CalicoPrimaryIssue, error) } // CalicoIssueKind enumerates the Calico Network failure modes. @@ -321,25 +344,122 @@ const ( CalicoIssueNADUnreadable CalicoIssueKind = "NADUnreadable" // CalicoIssueNetworkNotFound no Network CR existed. CalicoIssueNetworkNotFound CalicoIssueKind = "NetworkNotFound" + // CalicoIssueNetworkCRDAbsent the destination cluster does not have the + // projectcalico.org/v3 Network CRD installed (the Calico install is too + // old or doesn't ship the L2 feature). The NAD references a Calico + // Network but the CRD cannot be queried — distinct from a missing CR, + // which is CalicoIssueNetworkNotFound. + CalicoIssueNetworkCRDAbsent CalicoIssueKind = "NetworkCRDAbsent" + // CalicoIssueNetworkTypeUnsupported the referenced Network CR is not an + // l2Bridge network (e.g. a VRF network). Only l2Bridge networks are + // supported for identity preservation today. + CalicoIssueNetworkTypeUnsupported CalicoIssueKind = "NetworkTypeUnsupported" // CalicoIssueNetworkHasNoL2Bridge Network CR existed but had no L2Bridge field spec'd. CalicoIssueNetworkHasNoL2Bridge CalicoIssueKind = "NetworkHasNoL2Bridge" // CalicoIssueNetworkHasNoVLANs Network CR's L2Bridge had an empty vlans list (no VLAN to select). CalicoIssueNetworkHasNoVLANs CalicoIssueKind = "NetworkHasNoVLANs" // CalicoIssueVLANNotInNetwork NIC's NAD entry's VLAN was not present in the referenced Network CR. CalicoIssueVLANNotInNetwork CalicoIssueKind = "VLANNotInNetwork" - // CalicoIssueVLANAmbiguous NIC's NAD entry had no VLAN, and Network CR had more than one VLAN to choose from. - CalicoIssueVLANAmbiguous CalicoIssueKind = "VLANAmbiguous" + // CalicoIssueVLANRequired the NAD references a Calico Network but names + // no VLAN. A VLAN must be stated explicitly whenever a Network is + // selected; Forklift does not auto-select, not even for a single-VLAN + // Network. + CalicoIssueVLANRequired CalicoIssueKind = "VLANRequired" // CalicoIssueVLANHasNoIPPool no IPPool existed satisfying the VLAN subnet's requirements. CalicoIssueVLANHasNoIPPool CalicoIssueKind = "VLANHasNoIPPool" // CalicoIssueIPNotInSubnet NIC's IP was not in any Network.spec.l2Bridge.vlans[].subnets[].cidr. CalicoIssueIPNotInSubnet CalicoIssueKind = "IPNotInSubnet" // CalicoIssueIPNotInIPPool NIC's IP was not in any Calico IPPool. CalicoIssueIPNotInIPPool CalicoIssueKind = "IPNotInIPPool" + // CalicoIssueTooManyIPs the NIC carries more than one IPv4 address. + // Calico's ipAddrs annotation accepts at most one IPv4 per interface, + // so preservation cannot represent this NIC — CNI ADD would fail and + // the pod would never start. + CalicoIssueTooManyIPs CalicoIssueKind = "TooManyIPs" // CalicoIssueNADMissingNetwork the NAD requests the Calico CNI but does // not name a projectcalico.org Network resource (no "network" field). // This is Calico's legacy L3 IPAM mode; identity preservation (MAC + IP) // will not be applied for NICs mapped to this NAD. Warn-level. CalicoIssueNADMissingNetwork CalicoIssueKind = "NADMissingNetwork" + // CalicoIssueDataplaneNotBPF a NAD resolved to an l2Bridge network but + // the destination Calico install is not running the BPF dataplane + // (FelixConfiguration "default" has bpfEnabled false or unset). L2 + // networks require the BPF dataplane. Emitted once per plan. + CalicoIssueDataplaneNotBPF CalicoIssueKind = "DataplaneNotBPF" + + // Calico-primary IssueKinds. Used by Validator.ValidateCalicoPrimary + // and Validator.CalicoPrimaryIssues for the calico-flagged NetworkMap + // path (type: pod destinations carrying the calico field). The Primary + // prefix disambiguates from the NAD-path kinds above. + + // CalicoIssuePrimaryProviderUnsupported indicates a NetworkMap with a + // calico-flagged entry is being consumed by a provider that does not + // support the feature in this release (any non-vSphere provider). + CalicoIssuePrimaryProviderUnsupported CalicoIssueKind = "PrimaryProviderUnsupported" + // CalicoIssuePrimaryUnsupported indicates the destination cluster does + // not have Calico installed (projectcalico.org/v3 IPPool CRD absent). + // Network CRD absence with IPPool present is reported as the more + // specific CalicoIssuePrimaryNetworkCRDAbsent. + CalicoIssuePrimaryUnsupported CalicoIssueKind = "PrimaryUnsupported" + // CalicoIssuePrimaryNetworkCRDAbsent the destination cluster has Calico + // (IPPool CRD present) but the projectcalico.org/v3 Network CRD is not + // installed. The user requested L2 attach via calico.network, but the + // install does not ship the L2 feature. Case A (calico.network == "") + // continues to work in this state and is not blocked. + CalicoIssuePrimaryNetworkCRDAbsent CalicoIssueKind = "PrimaryNetworkCRDAbsent" + // CalicoIssuePrimaryConflictsWithUDN indicates the destination namespace + // is labelled for a UDN primary network — incompatible with the calico + // field. + CalicoIssuePrimaryConflictsWithUDN CalicoIssueKind = "PrimaryConflictsWithUDN" + // CalicoIssuePrimaryNetworkNotFound the named projectcalico.org/v3 + // Network CR does not exist on the destination cluster. + CalicoIssuePrimaryNetworkNotFound CalicoIssueKind = "PrimaryNetworkNotFound" + // CalicoIssuePrimaryNetworkTypeUnsupported the named Network CR is not + // an l2Bridge network (e.g. a VRF network). Only l2Bridge networks are + // supported for identity preservation today. + CalicoIssuePrimaryNetworkTypeUnsupported CalicoIssueKind = "PrimaryNetworkTypeUnsupported" + // CalicoIssuePrimaryNetworkHasNoL2Bridge the named Network CR has no + // l2Bridge spec — incompatible with L2 attach. + CalicoIssuePrimaryNetworkHasNoL2Bridge CalicoIssueKind = "PrimaryNetworkHasNoL2Bridge" + // CalicoIssuePrimaryNetworkHasNoVLANs the named Network CR's + // l2Bridge.vlans is empty. + CalicoIssuePrimaryNetworkHasNoVLANs CalicoIssueKind = "PrimaryNetworkHasNoVLANs" + // CalicoIssuePrimaryVLANRequired calico.network is set but calico.vlan + // is not. A VLAN must be stated explicitly whenever a Network is + // selected; Forklift does not auto-select, not even for a single-VLAN + // Network. + CalicoIssuePrimaryVLANRequired CalicoIssueKind = "PrimaryVLANRequired" + // CalicoIssuePrimaryVLANNotInNetwork the user-specified calico.vlan does + // not match any vlan.id in the named Network CR. + CalicoIssuePrimaryVLANNotInNetwork CalicoIssueKind = "PrimaryVLANNotInNetwork" + // CalicoIssuePrimaryNoEligibleIPPool no IPPool covers either the L3 + // allocation (Case A) or the matched VLAN's subnet (Case C). + CalicoIssuePrimaryNoEligibleIPPool CalicoIssueKind = "PrimaryNoEligibleIPPool" + // CalicoIssuePrimaryIPNotInSubnet (Case C only) the source NIC IP + // falls outside the matched VLAN's subnets. + CalicoIssuePrimaryIPNotInSubnet CalicoIssueKind = "PrimaryIPNotInSubnet" + // CalicoIssuePrimaryTooManyIPs the calico-mapped NIC carries more than + // one IPv4 address. Calico's ipAddrs annotation accepts at most one + // IPv4 per interface, so preservation cannot represent this NIC — CNI + // ADD would fail and the pod would never start. + CalicoIssuePrimaryTooManyIPs CalicoIssueKind = "PrimaryTooManyIPs" + // CalicoIssuePrimaryFieldsMisplaced the calico block is set on a + // non-pod entry, or calico.vlan is set without calico.network, or more + // than one calico-flagged entry exists in the map. The Network field + // on the issue carries the offending value for message disambiguation. + CalicoIssuePrimaryFieldsMisplaced CalicoIssueKind = "PrimaryFieldsMisplaced" + // CalicoIssuePrimaryStaticIPsNotPreserved indicates a calico-flagged + // NetworkMap entry exists while Plan.Spec.PreserveStaticIPs is false. + // Bridge binding is still enabled (Calico requires it for L2 attach), + // so DHCP-configured guests will pick up the Calico-assigned IP via + // the veth. Static-IP-configured guests can have a divergent in-guest + // IP, with associated Calico drops. Warn-class — informational only. + CalicoIssuePrimaryStaticIPsNotPreserved CalicoIssueKind = "PrimaryStaticIPsNotPreserved" + // CalicoIssuePrimaryDataplaneNotBPF calico.network resolved to an + // l2Bridge network but the destination Calico install is not running + // the BPF dataplane (FelixConfiguration "default" has bpfEnabled false + // or unset). L2 networks require the BPF dataplane. + CalicoIssuePrimaryDataplaneNotBPF CalicoIssueKind = "PrimaryDataplaneNotBPF" ) // CalicoIssue represents a per-VM Calico Network validation failure: the diff --git a/pkg/controller/plan/adapter/hyperv/validator.go b/pkg/controller/plan/adapter/hyperv/validator.go index 001bab946f..e9c7f6f12c 100644 --- a/pkg/controller/plan/adapter/hyperv/validator.go +++ b/pkg/controller/plan/adapter/hyperv/validator.go @@ -297,3 +297,26 @@ func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidati func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } + +// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry +// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — +// the feature is not supported on this provider in this release. +func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { + if r.Plan.Referenced.Map.Network == nil { + return planbase.CalicoPrimaryValidationResult{}, nil + } + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + if pair.Destination.Calico != nil { + return planbase.CalicoPrimaryValidationResult{ + Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, + }, nil + } + } + return planbase.CalicoPrimaryValidationResult{}, nil +} + +// CalicoPrimaryIssues returns nil. The plan-level rejection in +// ValidateCalicoPrimary short-circuits before per-VM dispatch. +func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/hyperv/validator_calico_primary_test.go b/pkg/controller/plan/adapter/hyperv/validator_calico_primary_test.go new file mode 100644 index 0000000000..107e2055df --- /dev/null +++ b/pkg/controller/plan/adapter/hyperv/validator_calico_primary_test.go @@ -0,0 +1,41 @@ +package hyperv + +import ( + "testing" + + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" + plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" +) + +func newValidatorWithMap(pairs []api.NetworkPair) *Validator { + plan := &api.Plan{} + plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} + return &Validator{Context: &plancontext.Context{Plan: plan}} +} + +func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { + t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) + } +} + +func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 0 { + t.Errorf("expected no issues, got %+v", result.Issues) + } +} diff --git a/pkg/controller/plan/adapter/nutanix/validator.go b/pkg/controller/plan/adapter/nutanix/validator.go index 88434c6ae3..402f11c13e 100644 --- a/pkg/controller/plan/adapter/nutanix/validator.go +++ b/pkg/controller/plan/adapter/nutanix/validator.go @@ -114,3 +114,26 @@ func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidati func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } + +// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry +// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — +// the feature is not supported on this provider in this release. +func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { + if r.Plan.Referenced.Map.Network == nil { + return planbase.CalicoPrimaryValidationResult{}, nil + } + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + if pair.Destination.Calico != nil { + return planbase.CalicoPrimaryValidationResult{ + Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, + }, nil + } + } + return planbase.CalicoPrimaryValidationResult{}, nil +} + +// CalicoPrimaryIssues returns nil. The plan-level rejection in +// ValidateCalicoPrimary short-circuits before per-VM dispatch. +func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/nutanix/validator_calico_primary_test.go b/pkg/controller/plan/adapter/nutanix/validator_calico_primary_test.go new file mode 100644 index 0000000000..77f5f65235 --- /dev/null +++ b/pkg/controller/plan/adapter/nutanix/validator_calico_primary_test.go @@ -0,0 +1,41 @@ +package nutanix + +import ( + "testing" + + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" + plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" +) + +func newValidatorWithMap(pairs []api.NetworkPair) *Validator { + plan := &api.Plan{} + plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} + return &Validator{Context: &plancontext.Context{Plan: plan}} +} + +func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { + t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) + } +} + +func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 0 { + t.Errorf("expected no issues, got %+v", result.Issues) + } +} diff --git a/pkg/controller/plan/adapter/ocp/validator.go b/pkg/controller/plan/adapter/ocp/validator.go index cb2a79ebd0..9918dc4fa8 100644 --- a/pkg/controller/plan/adapter/ocp/validator.go +++ b/pkg/controller/plan/adapter/ocp/validator.go @@ -417,3 +417,26 @@ func (r *Validator) ValidateCalicoNADs(_ k8sclient.Client) (planbase.CalicoValid func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } + +// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry +// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — +// the feature is not supported on this provider in this release. +func (r *Validator) ValidateCalicoPrimary(_ k8sclient.Client) (planbase.CalicoPrimaryValidationResult, error) { + if r.Plan.Referenced.Map.Network == nil { + return planbase.CalicoPrimaryValidationResult{}, nil + } + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + if pair.Destination.Calico != nil { + return planbase.CalicoPrimaryValidationResult{ + Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, + }, nil + } + } + return planbase.CalicoPrimaryValidationResult{}, nil +} + +// CalicoPrimaryIssues returns nil. The plan-level rejection in +// ValidateCalicoPrimary short-circuits before per-VM dispatch. +func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/ocp/validator_calico_primary_test.go b/pkg/controller/plan/adapter/ocp/validator_calico_primary_test.go new file mode 100644 index 0000000000..5f873bd028 --- /dev/null +++ b/pkg/controller/plan/adapter/ocp/validator_calico_primary_test.go @@ -0,0 +1,41 @@ +package ocp + +import ( + "testing" + + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" + plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" +) + +func newValidatorWithMap(pairs []api.NetworkPair) *Validator { + plan := &api.Plan{} + plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} + return &Validator{Context: &plancontext.Context{Plan: plan}} +} + +func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { + t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) + } +} + +func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 0 { + t.Errorf("expected no issues, got %+v", result.Issues) + } +} diff --git a/pkg/controller/plan/adapter/openstack/validator.go b/pkg/controller/plan/adapter/openstack/validator.go index 979f9612e2..61ed31cce8 100644 --- a/pkg/controller/plan/adapter/openstack/validator.go +++ b/pkg/controller/plan/adapter/openstack/validator.go @@ -402,3 +402,26 @@ func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidati func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } + +// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry +// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — +// the feature is not supported on this provider in this release. +func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { + if r.Plan.Referenced.Map.Network == nil { + return planbase.CalicoPrimaryValidationResult{}, nil + } + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + if pair.Destination.Calico != nil { + return planbase.CalicoPrimaryValidationResult{ + Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, + }, nil + } + } + return planbase.CalicoPrimaryValidationResult{}, nil +} + +// CalicoPrimaryIssues returns nil. The plan-level rejection in +// ValidateCalicoPrimary short-circuits before per-VM dispatch. +func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/openstack/validator_calico_primary_test.go b/pkg/controller/plan/adapter/openstack/validator_calico_primary_test.go new file mode 100644 index 0000000000..8d66affe76 --- /dev/null +++ b/pkg/controller/plan/adapter/openstack/validator_calico_primary_test.go @@ -0,0 +1,41 @@ +package openstack + +import ( + "testing" + + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" + plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" +) + +func newValidatorWithMap(pairs []api.NetworkPair) *Validator { + plan := &api.Plan{} + plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} + return &Validator{Context: &plancontext.Context{Plan: plan}} +} + +func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { + t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) + } +} + +func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 0 { + t.Errorf("expected no issues, got %+v", result.Issues) + } +} diff --git a/pkg/controller/plan/adapter/ovfbase/validator.go b/pkg/controller/plan/adapter/ovfbase/validator.go index 1696824f92..3caaac5138 100644 --- a/pkg/controller/plan/adapter/ovfbase/validator.go +++ b/pkg/controller/plan/adapter/ovfbase/validator.go @@ -234,3 +234,26 @@ func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidati func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } + +// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry +// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — +// the feature is not supported on this provider in this release. +func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { + if r.Plan.Referenced.Map.Network == nil { + return planbase.CalicoPrimaryValidationResult{}, nil + } + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + if pair.Destination.Calico != nil { + return planbase.CalicoPrimaryValidationResult{ + Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, + }, nil + } + } + return planbase.CalicoPrimaryValidationResult{}, nil +} + +// CalicoPrimaryIssues returns nil. The plan-level rejection in +// ValidateCalicoPrimary short-circuits before per-VM dispatch. +func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/ovfbase/validator_calico_primary_test.go b/pkg/controller/plan/adapter/ovfbase/validator_calico_primary_test.go new file mode 100644 index 0000000000..129aa8c5a1 --- /dev/null +++ b/pkg/controller/plan/adapter/ovfbase/validator_calico_primary_test.go @@ -0,0 +1,41 @@ +package ovfbase + +import ( + "testing" + + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" + plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" +) + +func newValidatorWithMap(pairs []api.NetworkPair) *Validator { + plan := &api.Plan{} + plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} + return &Validator{Context: &plancontext.Context{Plan: plan}} +} + +func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { + t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) + } +} + +func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 0 { + t.Errorf("expected no issues, got %+v", result.Issues) + } +} diff --git a/pkg/controller/plan/adapter/ovirt/validator.go b/pkg/controller/plan/adapter/ovirt/validator.go index b153841578..827cd7ddfd 100644 --- a/pkg/controller/plan/adapter/ovirt/validator.go +++ b/pkg/controller/plan/adapter/ovirt/validator.go @@ -311,3 +311,26 @@ func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidati func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } + +// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry +// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — +// the feature is not supported on this provider in this release. +func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { + if r.Plan.Referenced.Map.Network == nil { + return planbase.CalicoPrimaryValidationResult{}, nil + } + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + if pair.Destination.Calico != nil { + return planbase.CalicoPrimaryValidationResult{ + Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, + }, nil + } + } + return planbase.CalicoPrimaryValidationResult{}, nil +} + +// CalicoPrimaryIssues returns nil. The plan-level rejection in +// ValidateCalicoPrimary short-circuits before per-VM dispatch. +func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { + return nil, nil +} diff --git a/pkg/controller/plan/adapter/ovirt/validator_calico_primary_test.go b/pkg/controller/plan/adapter/ovirt/validator_calico_primary_test.go new file mode 100644 index 0000000000..5d8a2f65f6 --- /dev/null +++ b/pkg/controller/plan/adapter/ovirt/validator_calico_primary_test.go @@ -0,0 +1,41 @@ +package ovirt + +import ( + "testing" + + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" + plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" +) + +func newValidatorWithMap(pairs []api.NetworkPair) *Validator { + plan := &api.Plan{} + plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} + return &Validator{Context: &plancontext.Context{Plan: plan}} +} + +func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { + t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) + } +} + +func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { + v := newValidatorWithMap([]api.NetworkPair{{ + Destination: api.DestinationNetwork{Type: planbase.Pod}, + }}) + result, err := v.ValidateCalicoPrimary(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues) != 0 { + t.Errorf("expected no issues, got %+v", result.Issues) + } +} diff --git a/pkg/controller/plan/adapter/vsphere/builder.go b/pkg/controller/plan/adapter/vsphere/builder.go index 3878e63304..8ecc634fce 100644 --- a/pkg/controller/plan/adapter/vsphere/builder.go +++ b/pkg/controller/plan/adapter/vsphere/builder.go @@ -908,6 +908,16 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err staticIpInterfaces := make(map[string][]string) calicoMacInterfaces := map[string]string{} calicoIpInterfaces := map[string][]string{} + // A calico-flagged pod mapping produces at most one NIC's worth of + // primary annotations per VM: the map-level validator rejects multiple + // calico-flagged entries, and the per-VM VMMultiplePodNetworkMappings + // Critical rejects a VM with two NICs resolving to one pod entry — so + // single locals are enough. + var calicoPrimary bool + var calicoPrimaryMAC string + var calicoPrimaryIPs []string + var calicoPrimaryNetwork string + var calicoPrimaryVlan uint16 // cache for network configs to avoid duplicate GETs. nadCache := map[k8stypes.NamespacedName]*ocpmodel.NetworkConfig{} @@ -966,7 +976,27 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err switch mapped.Destination.Type { case Pod: kNetwork.Pod = &cnv.PodNetwork{} - if hasUDN { + if mapped.Destination.Calico != nil { + // The destination's default pod network is Calico and the + // user opted into identity preservation: Bridge binding + // always, MAC always, IPs when the Plan preserves static + // IPs, Network + Vlan annotations when the user named a + // projectcalico.org/v3 Network CR. Plan validation rejects + // a network entry without an explicit VLAN, so a named + // Network always arrives here with a non-zero Vlan. + kInterface.Bridge = &cnv.InterfaceBridge{} + calicoPrimary = true + calicoPrimaryMAC = nic.MAC + if r.Plan.Spec.PreserveStaticIPs { + if ips := findInterfaceIps(vm, nic); len(ips) > 0 { + calicoPrimaryIPs = ips + } + } + if mapped.Destination.Calico.Network != "" { + calicoPrimaryNetwork = mapped.Destination.Calico.Network + calicoPrimaryVlan = mapped.Destination.Calico.Vlan + } + } else if hasUDN { kInterface.Binding = &cnv.PluginBinding{ Name: planbase.UdnL2bridge, } @@ -999,6 +1029,10 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err nadCache[nadKey] = cfg } if cfg != nil && cfg.ReferencesCalicoNetwork() { + // Calico identity preservation on a secondary NIC: the + // source MAC is always carried over via the scoped hwAddr + // annotation, and the source IPs when the Plan preserves + // static IPs. Non-Calico NADs are untouched. calicoMacInterfaces[networkName] = nic.MAC if r.Plan.Spec.PreserveStaticIPs { if ips := findInterfaceIps(vm, nic); len(ips) > 0 { @@ -1035,6 +1069,23 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err return } } + if err = planbase.StampCalicoPrimary(&object.Template.ObjectMeta, planbase.CalicoPrimaryParams{ + MAC: calicoPrimaryMAC, + IPs: calicoPrimaryIPs, + Network: calicoPrimaryNetwork, + Vlan: calicoPrimaryVlan, + }); err != nil { + return + } + // Bridge binding on the pod network blocks live migration unless the + // VM opts in. Calico's IP persistence is designed to survive live + // migration, so opt calico-primary VMs in. + if calicoPrimary { + if object.Template.ObjectMeta.Annotations == nil { + object.Template.ObjectMeta.Annotations = map[string]string{} + } + object.Template.ObjectMeta.Annotations[planbase.AnnAllowPodBridgeNetworkLiveMigration] = "true" + } return } diff --git a/pkg/controller/plan/adapter/vsphere/builder_test.go b/pkg/controller/plan/adapter/vsphere/builder_test.go index 8bd17034bd..9024b0b961 100644 --- a/pkg/controller/plan/adapter/vsphere/builder_test.go +++ b/pkg/controller/plan/adapter/vsphere/builder_test.go @@ -1280,7 +1280,7 @@ var _ = Describe("vSphere builder", func() { }) It("emits MAC only when PreserveStaticIPs is false", func() { - annotations, err := buildAndCall(`{"type":"calico","network":"datacenter-vlans"}`, false) + annotations, err := buildAndCall(`{"type":"calico","network":"datacenter-vlans","vlan":100}`, false) Expect(err).NotTo(HaveOccurred()) Expect(annotations).To(HaveKeyWithValue(hwAnnKey, nicMAC)) Expect(annotations).NotTo(HaveKey(ipsAnnKey)) @@ -1448,6 +1448,145 @@ var _ = Describe("vSphere builder", func() { Expect(ann).NotTo(HaveKey("cni.projectcalico.org/net-2.hwAddr")) }) }) + + Context("mapNetworks Calico primary annotations", func() { + const ( + netID = "src-primary" + nicMAC = "aa:bb:cc:dd:ee:01" + nicIP = "10.244.0.5" + ) + // runPrimary builds a Plan with a single type: calico NetworkMap + // entry and runs mapNetworks against a one-NIC VM. extraPair is + // appended to the NetworkMap (for mixed primary+secondary tests). + runPrimary := func(dest v1beta1.DestinationNetwork, preserveIPs bool, extraPairs []v1beta1.NetworkPair, extraNICs []vsphere.NIC, extraGuestNets []vsphere.GuestNetwork, k8sObjs ...runtime.Object) (*cnv.VirtualMachineSpec, error) { + builder := createBuilder(k8sObjs...) + networks := map[string]model.Network{ + netID: {Resource: model.Resource{ID: netID}, Variant: vsphere.NetDvPortGroup, Key: netID}, + } + for _, p := range extraPairs { + networks[p.Source.ID] = model.Network{ + Resource: model.Resource{ID: p.Source.ID}, + Variant: vsphere.NetDvPortGroup, + Key: p.Source.ID, + } + } + builder.Source.Inventory = &mockInventory{networks: networks} + builder.Plan.Spec.PreserveStaticIPs = preserveIPs + pairs := []v1beta1.NetworkPair{ + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: netID}}, Destination: dest}, + } + pairs = append(pairs, extraPairs...) + builder.Context.Map.Network = &v1beta1.NetworkMap{Spec: v1beta1.NetworkMapSpec{Map: pairs}} + nics := []vsphere.NIC{{Network: vsphere.Ref{ID: netID}, MAC: nicMAC, DeviceKey: 4001}} + nics = append(nics, extraNICs...) + gnets := []vsphere.GuestNetwork{{MAC: nicMAC, IP: nicIP, DeviceConfigId: 4001, Origin: ManualOrigin, PrefixLength: 24}} + gnets = append(gnets, extraGuestNets...) + vm := &model.VM{NICs: nics, GuestNetworks: gnets} + spec := &cnv.VirtualMachineSpec{Template: &cnv.VirtualMachineInstanceTemplateSpec{}} + err := builder.mapNetworks(vm, spec) + return spec, err + } + + It("Case A no preserveStaticIPs: MAC only, Bridge binding, no IP/Network", func() { + spec, err := runPrimary(v1beta1.DestinationNetwork{Type: "pod", Calico: &v1beta1.CalicoDestination{}}, false, nil, nil, nil) + Expect(err).NotTo(HaveOccurred()) + ann := spec.Template.ObjectMeta.Annotations + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/hwAddr", nicMAC)) + Expect(ann).NotTo(HaveKey("cni.projectcalico.org/ipAddrs")) + Expect(ann).NotTo(HaveKey("cni.projectcalico.org/networks")) + // Bridge on the pod network blocks live migration unless the VM + // opts in; calico-primary VMs are opted in automatically. + Expect(ann).To(HaveKeyWithValue("kubevirt.io/allow-pod-bridge-network-live-migration", "true")) + // Bridge binding always for calico-flagged mappings. + Expect(spec.Template.Spec.Domain.Devices.Interfaces).To(HaveLen(1)) + Expect(spec.Template.Spec.Domain.Devices.Interfaces[0].Bridge).NotTo(BeNil()) + // Pod network for the cluster-default CNI attach. + Expect(spec.Template.Spec.Networks).To(HaveLen(1)) + Expect(spec.Template.Spec.Networks[0].Pod).NotTo(BeNil()) + }) + + It("plain type: pod (no calico) gets no calico or live-migration annotations", func() { + spec, err := runPrimary(v1beta1.DestinationNetwork{Type: "pod"}, true, nil, nil, nil) + Expect(err).NotTo(HaveOccurred()) + ann := spec.Template.ObjectMeta.Annotations + Expect(ann).NotTo(HaveKey("cni.projectcalico.org/hwAddr")) + Expect(ann).NotTo(HaveKey("kubevirt.io/allow-pod-bridge-network-live-migration")) + }) + + It("Case A with preserveStaticIPs: MAC + IPs", func() { + spec, err := runPrimary(v1beta1.DestinationNetwork{Type: "pod", Calico: &v1beta1.CalicoDestination{}}, true, nil, nil, nil) + Expect(err).NotTo(HaveOccurred()) + ann := spec.Template.ObjectMeta.Annotations + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/hwAddr", nicMAC)) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/ipAddrs", fmt.Sprintf(`["%s"]`, nicIP))) + Expect(ann).NotTo(HaveKey("cni.projectcalico.org/networks")) + }) + + It("L2 attach (network + vlan), no preserveStaticIPs: MAC + Network + vlan, no IPs", func() { + spec, err := runPrimary( + v1beta1.DestinationNetwork{Type: "pod", Calico: &v1beta1.CalicoDestination{Network: "datacenter-vlans", Vlan: 100}}, + false, nil, nil, nil) + Expect(err).NotTo(HaveOccurred()) + ann := spec.Template.ObjectMeta.Annotations + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/hwAddr", nicMAC)) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/networks", "datacenter-vlans")) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/vlan", "100")) + Expect(ann).NotTo(HaveKey("cni.projectcalico.org/ipAddrs")) + }) + + It("L2 attach (network + vlan) with preserveStaticIPs: MAC + IPs + Network + vlan", func() { + spec, err := runPrimary( + v1beta1.DestinationNetwork{Type: "pod", Calico: &v1beta1.CalicoDestination{Network: "datacenter-vlans", Vlan: 200}}, + true, nil, nil, nil) + Expect(err).NotTo(HaveOccurred()) + ann := spec.Template.ObjectMeta.Annotations + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/hwAddr", nicMAC)) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/ipAddrs", fmt.Sprintf(`["%s"]`, nicIP))) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/networks", "datacenter-vlans")) + // Calico's VLAN selection is annotation-driven; the user's + // validated choice must reach the pod. + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/vlan", "200")) + }) + + It("a calico block pins the source MAC on the interface", func() { + spec, err := runPrimary(v1beta1.DestinationNetwork{Type: "pod", Calico: &v1beta1.CalicoDestination{}}, false, nil, nil, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(spec.Template.Spec.Domain.Devices.Interfaces).To(HaveLen(1)) + Expect(spec.Template.Spec.Domain.Devices.Interfaces[0].MacAddress).To(Equal(nicMAC)) + }) + + It("Mixed: calico-flagged primary + type:multus secondary coexist with correct scoping", func() { + // Primary NIC on src-primary, secondary NIC on src-secondary + // (mapped to a Calico L2 NAD). Annotations: unscoped for primary, + // VMI-network-name-scoped for secondary (Calico reverse-maps the + // pod interface name back to the VMI network name). + secondaryNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: meta.ObjectMeta{Namespace: "test", Name: "secondary-calico-nad"}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: `{"type":"calico","network":"sec-vlan","vlan":100}`}, + } + extraPair := v1beta1.NetworkPair{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-secondary"}}, + Destination: v1beta1.DestinationNetwork{Type: "multus", Namespace: "test", Name: "secondary-calico-nad"}, + } + extraNIC := vsphere.NIC{Network: vsphere.Ref{ID: "src-secondary"}, MAC: "aa:bb:cc:00:00:99", DeviceKey: 4002} + extraGN := vsphere.GuestNetwork{MAC: "aa:bb:cc:00:00:99", IP: "10.100.0.5", DeviceConfigId: 4002, Origin: ManualOrigin, PrefixLength: 24} + spec, err := runPrimary( + v1beta1.DestinationNetwork{Type: "pod", Calico: &v1beta1.CalicoDestination{Network: "primary-net", Vlan: 100}}, + true, + []v1beta1.NetworkPair{extraPair}, + []vsphere.NIC{extraNIC}, + []vsphere.GuestNetwork{extraGN}, + secondaryNAD) + Expect(err).NotTo(HaveOccurred()) + ann := spec.Template.ObjectMeta.Annotations + // Primary (unscoped) annotations. + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/hwAddr", nicMAC)) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/networks", "primary-net")) + // Secondary (per-iface) annotations on net-1 (primary NIC is net-0). + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-1.hwAddr", "aa:bb:cc:00:00:99")) + Expect(ann).To(HaveKeyWithValue("cni.projectcalico.org/net-1.ipAddrs", `["10.100.0.5"]`)) + }) + }) }) var _ = Describe("PopulatorOffloadInfo", func() { diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index 49c2294e54..dc56d9945a 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -737,10 +737,12 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) // IPPool resources. NADs that pass all checks are recorded in the returned // cache for downstream per-VM checks (see CalicoVMIssues). // -// Resource-level short-circuit ordering matches the legacy per-VM walk: -// failure to find the Network or to resolve a VLAN entry prevents the -// IPPool check; failure of the IPPool check excludes the NAD from the -// cache entirely. +// Resource-level short-circuit ordering: the Network is fetched and +// classified first, so a missing Network or an unsupported network type +// (e.g. VRF) is reported before any VLAN handling; failure to resolve a +// VLAN entry prevents the IPPool check; failure of the IPPool check +// excludes the NAD from the cache entirely. The BPF-dataplane check runs +// once per plan, on the first NAD that resolves to an l2Bridge network. func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValidationResult, error) { result := planbase.CalicoValidationResult{ Cache: &planbase.CalicoValidationCache{ @@ -751,9 +753,10 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid return result, nil } - seenNAD := map[k8stypes.NamespacedName]struct{}{} + nadCfgs := map[k8stypes.NamespacedName]*ocpmodel.NetworkConfig{} var pools []calicoclient.IPPool poolsLoaded := false + bpfChecked := false for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { if pair.Destination.Type != planbase.Multus { @@ -763,13 +766,13 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid Namespace: pair.Destination.Namespace, Name: pair.Destination.Name, } - if _, dup := seenNAD[key]; dup { + if _, dup := nadCfgs[key]; dup { continue } - seenNAD[key] = struct{}{} cfg, err := planbase.FetchAndParseNAD(context.TODO(), c, key.Namespace, key.Name) if err != nil { + nadCfgs[key] = nil if r.Log != nil { r.Log.Error(err, "Calico NAD: failed to fetch/parse", "namespace", key.Namespace, "name", key.Name) @@ -780,6 +783,7 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid }) continue } + nadCfgs[key] = cfg // type:calico without a "network" field is Calico's legacy L3 IPAM // mode. Forklift's identity preservation only applies to the L2 // path; warn the user that MAC/IP annotations will not be emitted @@ -799,16 +803,29 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid nw, err := calicoclient.GetNetwork(context.TODO(), c, cfg.Network) if err != nil { - // IsNoMatchError covers clusters with no projectcalico.org/v3 - // CRD installed — the Network kind itself is unknown to the API - // server. From the user's perspective this is indistinguishable - // from a missing Network CR. - if k8serr.IsNotFound(err) || meta.IsNoMatchError(err) { + switch { + case meta.IsNoMatchError(err): + // Network kind unknown to the apiserver — Calico is + // present but the install doesn't ship the Network CRD + // (no L2 feature). NAD refers to it, can't honour. + issueBase.Kind = planbase.CalicoIssueNetworkCRDAbsent + result.Issues = append(result.Issues, issueBase) + continue + case k8serr.IsNotFound(err): issueBase.Kind = planbase.CalicoIssueNetworkNotFound result.Issues = append(result.Issues, issueBase) continue + default: + return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String(), "network", cfg.Network) } - return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String(), "network", cfg.Network) + } + // Classify the Network before any VLAN handling: a non-l2Bridge + // network (e.g. a VRF network) legitimately carries no VLAN, so the + // VLANRequired / VLAN-matching checks below would mislead. + if nw.IsVRF { + issueBase.Kind = planbase.CalicoIssueNetworkTypeUnsupported + result.Issues = append(result.Issues, issueBase) + continue } if nw.L2Bridge == nil { issueBase.Kind = planbase.CalicoIssueNetworkHasNoL2Bridge @@ -816,31 +833,58 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid continue } + // L2 networks require the BPF dataplane. Checked once per plan, on + // the first NAD that resolves to an l2Bridge network; the issue is + // plan-scoped and does not block the remaining per-NAD checks. + if !bpfChecked { + bpfChecked = true + bpfEnabled, err := calicoclient.GetBPFEnabled(context.TODO(), c) + if err != nil { + return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + } + if !bpfEnabled { + ib := issueBase + ib.Kind = planbase.CalicoIssueDataplaneNotBPF + result.Issues = append(result.Issues, ib) + } + } + + // A Network reference requires an explicit VLAN. Forklift does not + // auto-select, not even for a single-VLAN Network. + if cfg.VLAN == 0 { + issueBase.Kind = planbase.CalicoIssueVLANRequired + result.Issues = append(result.Issues, issueBase) + continue + } + entry, vlanIssueKind := resolveVLANEntry(nw.L2Bridge.VLANs, cfg.VLAN) if vlanIssueKind != "" { issueBase.Kind = vlanIssueKind result.Issues = append(result.Issues, issueBase) continue } - // Past this point the NAD's VLAN has been resolved to a concrete - // Network entry; report that VID downstream rather than the raw - // (possibly-zero) NAD value. - issueBase.VLAN = entry.VID if !poolsLoaded { pools, err = calicoclient.ListIPPools(context.TODO(), c) if err != nil { - return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + // IPPool CRD absent (with the Network CRD present — an + // unusual install) means no pool can ever satisfy the + // VLAN's subnets; fall through to the no-pool issue below + // rather than hard-erroring the reconcile. + if !meta.IsNoMatchError(err) { + return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + } + pools = nil } poolsLoaded = true } - if !calicoclient.HasEligiblePool(pools, entry.Subnets) { + eligible := calicoclient.L2WorkloadEligiblePools(pools, entry.Subnets) + if len(eligible) == 0 { issueBase.Kind = planbase.CalicoIssueVLANHasNoIPPool result.Issues = append(result.Issues, issueBase) continue } - eligible := calicoclient.EligiblePools(pools, entry.Subnets) result.Cache.NADs[key] = &planbase.ResolvedCalicoNAD{ Network: cfg.Network, VLAN: *entry, @@ -903,7 +947,18 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati continue } issueBase := planbase.CalicoIssue{Network: resolved.Network, VLAN: resolved.VLAN.VID} - for _, ip := range findInterfaceIps(vm, nic) { + ips := findInterfaceIps(vm, nic) + // Calico's ipAddrs annotation accepts at most one IPv4 per + // interface; a NIC with more can't be represented and would fail + // the pod at CNI ADD. + if len(ips) > 1 { + multi := issueBase + multi.Kind = planbase.CalicoIssueTooManyIPs + multi.IP = strings.Join(ips, ",") + emit(multi) + continue + } + for _, ip := range ips { perIP := issueBase perIP.IP = ip if !ipInAnySubnet(ip, resolved.VLAN.Subnets) { @@ -911,7 +966,7 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati emit(perIP) continue } - if calicoclient.EligiblePoolForIP(resolved.EligiblePools, ip, resolved.VLAN.Subnets) == nil { + if calicoclient.L2WorkloadEligiblePoolForIP(resolved.EligiblePools, ip, resolved.VLAN.Subnets) == nil { perIP.Kind = planbase.CalicoIssueIPNotInIPPool emit(perIP) } @@ -920,6 +975,306 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati return issues, nil } +// ValidateCalicoPrimary validates the (at most one) calico-flagged +// NetworkMap entry — a type: pod destination carrying the calico field. +// Returns plan-level issues (CRD presence, UDN conflict, +// Network/VLAN/IPPool resolution, field misplacement) plus a cache consumed +// by CalicoPrimaryIssues. +// +// Precondition: Plan.Referenced.Map.Network is populated by the dispatcher +// before this is called. With a nil NetworkMap, returns an empty result and +// a non-nil cache with Primary == nil. +// +// The implementation runs the L3 IPPool list once (catches "Calico CRDs +// absent" via meta.IsNoMatchError), then dispatches on case: +// - Case A (calico.network == ""): L3 IPAM — filter to L3-eligible +// pools; per-VM check validates IP fit. +// - Case C (calico.network != ""): GetNetwork → network classification +// (non-l2Bridge types, e.g. VRF, are unsupported) → BPF-dataplane check +// → VLAN is mandatory (VLANRequired if absent) → VLAN entry → +// L2Workload pool filter scoped to the matched VLAN's subnet(s). +func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPrimaryValidationResult, error) { + result := planbase.CalicoPrimaryValidationResult{ + Cache: &planbase.CalicoPrimaryValidationCache{}, + } + if r.Plan.Referenced.Map.Network == nil { + return result, nil + } + + // Pass 1: classify entries, surface field-misplacement issues. + var calicoEntries []api.NetworkPair + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + dest := pair.Destination + if dest.Calico == nil { + continue + } + // The calico block qualifies the pod (primary) attachment only. + if dest.Type != planbase.Pod { + result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ + Kind: planbase.CalicoIssuePrimaryFieldsMisplaced, + Network: dest.Calico.Network, + VLAN: dest.Calico.Vlan, + }) + continue + } + calicoEntries = append(calicoEntries, pair) + // vlan-without-network is a field-placement error within the block. + if dest.Calico.Network == "" && dest.Calico.Vlan != 0 { + result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ + Kind: planbase.CalicoIssuePrimaryFieldsMisplaced, + VLAN: dest.Calico.Vlan, + }) + } + } + + // More than one calico-flagged pod entry in the map. + if len(calicoEntries) > 1 { + result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ + Kind: planbase.CalicoIssuePrimaryFieldsMisplaced, + }) + } + if len(calicoEntries) == 0 { + return result, nil + } + + // First (and, if well-configured, only) calico pod entry drives the + // cache. + entry := calicoEntries[0] + calico := entry.Destination.Calico + issueBase := planbase.CalicoPrimaryIssue{Network: calico.Network, VLAN: calico.Vlan} + + // Bridge binding is always on for calico-flagged mappings, so a + // DHCP-configured guest will pick up the Calico-assigned IP via the + // veth. A guest with a static in-guest IP, on the other hand, will + // keep that IP, which Calico can drop traffic from if it differs from + // the assigned address. Emit a Warn-class issue so the user sees the + // trade-off — no behavioural gate; preservation is the user's + // responsibility. + if !r.Plan.Spec.PreserveStaticIPs { + result.Warnings = append(result.Warnings, planbase.CalicoPrimaryIssue{ + Kind: planbase.CalicoIssuePrimaryStaticIPsNotPreserved, + }) + } + + // CRD presence check via ListIPPools. meta.IsNoMatchError → CRDs absent. + pools, err := calicoclient.ListIPPools(context.TODO(), c) + if err != nil { + if meta.IsNoMatchError(err) { + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryUnsupported + result.Issues = append(result.Issues, ib) + return result, nil + } + return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) + } + + // UDN conflict: target namespace is labelled for UDN primary network. + if r.Plan.DestinationHasUdnNetwork(c) { + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryConflictsWithUDN + result.Issues = append(result.Issues, ib) + return result, nil + } + + // Case A: implicit L3 IPAM. Cache L3-eligible pools for per-VM check. + if calico.Network == "" { + result.Cache.Primary = &planbase.ResolvedCalicoPrimary{ + Source: entry.Source.Ref, + L3EligiblePools: calicoclient.L3EligiblePools(pools), + } + return result, nil + } + + // Case C: L2 attach via named Network CR. + nw, err := calicoclient.GetNetwork(context.TODO(), c, calico.Network) + if err != nil { + switch { + case meta.IsNoMatchError(err): + // The Network kind is unknown to the apiserver — Calico is + // installed (IPPool present) but its install does not ship + // the L2 feature. User requested calico.network; can't honour. + // Case A (calico.network == "") would have short-circuited + // earlier without reaching this branch. + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryNetworkCRDAbsent + result.Issues = append(result.Issues, ib) + return result, nil + case k8serr.IsNotFound(err): + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryNetworkNotFound + result.Issues = append(result.Issues, ib) + return result, nil + default: + if r.Log != nil { + r.Log.Error(err, "Calico-primary: failed to fetch Network", + "network", calico.Network) + } + return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) + } + } + // Classify the Network before any VLAN handling: a non-l2Bridge network + // (e.g. a VRF network) legitimately carries no VLAN, so the VLANRequired + // / VLAN-matching checks below would mislead. + if nw.IsVRF { + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryNetworkTypeUnsupported + result.Issues = append(result.Issues, ib) + return result, nil + } + if nw.L2Bridge == nil { + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryNetworkHasNoL2Bridge + result.Issues = append(result.Issues, ib) + return result, nil + } + + // L2 networks require the BPF dataplane. The issue is plan-scoped and + // does not block the remaining checks. + bpfEnabled, err := calicoclient.GetBPFEnabled(context.TODO(), c) + if err != nil { + return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) + } + if !bpfEnabled { + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryDataplaneNotBPF + result.Issues = append(result.Issues, ib) + } + + // A Network reference requires an explicit VLAN. Forklift does not + // auto-select, not even for a single-VLAN Network. + if calico.Vlan == 0 { + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryVLANRequired + result.Issues = append(result.Issues, ib) + return result, nil + } + + vlanEntry, vlanIssueKind := resolveVLANEntry(nw.L2Bridge.VLANs, calico.Vlan) + if vlanIssueKind != "" { + ib := issueBase + ib.Kind = translateVLANIssueKindToPrimary(vlanIssueKind) + result.Issues = append(result.Issues, ib) + return result, nil + } + + l2Pools := calicoclient.L2WorkloadEligiblePools(pools, vlanEntry.Subnets) + if len(l2Pools) == 0 { + ib := issueBase + ib.Kind = planbase.CalicoIssuePrimaryNoEligibleIPPool + result.Issues = append(result.Issues, ib) + return result, nil + } + + result.Cache.Primary = &planbase.ResolvedCalicoPrimary{ + Network: calico.Network, + VLAN: *vlanEntry, + L2EligiblePools: l2Pools, + Source: entry.Source.Ref, + } + return result, nil +} + +// CalicoPrimaryIssues returns per-VM Calico-primary issues for vmRef using +// the cache from ValidateCalicoPrimary. Per-NIC checks fire only when +// plan.Spec.PreserveStaticIPs is true. When IP preservation is on but the +// VM has no findable IPv4 IPs (IPv6-only or no GuestNetworks reported), no +// per-VM issue is emitted — the builder will likewise emit no ipAddrs +// annotation. Both behaviours are correct: preservation is best-effort. +// +// Issues are deduplicated by the full CalicoPrimaryIssue value (VMRef is the +// same across one per-VM invocation, so dedup naturally applies within VM). +func (r *Validator) CalicoPrimaryIssues(vmRef ref.Ref, cache *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { + if !r.Plan.Spec.PreserveStaticIPs { + return nil, nil + } + if cache == nil || cache.Primary == nil { + return nil, nil + } + if r.Plan.Referenced.Map.Network == nil { + return nil, nil + } + vm := &model.VM{} + if err := r.Source.Inventory.Find(vm, vmRef); err != nil { + return nil, liberr.Wrap(err, "vm", vmRef.String()) + } + + primary := cache.Primary + var issues []planbase.CalicoPrimaryIssue + seen := map[planbase.CalicoPrimaryIssue]struct{}{} + emit := func(i planbase.CalicoPrimaryIssue) { + if _, ok := seen[i]; ok { + return + } + seen[i] = struct{}{} + issues = append(issues, i) + } + nadPool := planbase.NewNADPool() + nicKeys, pairsBySource, err := r.buildNICResolver(vm.NICs) + if err != nil { + return nil, liberr.Wrap(err, "vm", vmRef.String()) + } + + for i, nic := range vm.NICs { + pair, allocated := planbase.AllocateNetwork(nadPool, pairsBySource[nicKeys[i]]) + // Only the calico-flagged pod entry's NIC is the primary candidate; + // multus-mapped NICs are checked on the NAD path (CalicoVMIssues). + if !allocated || pair.Destination.Type != planbase.Pod || pair.Destination.Calico == nil { + continue + } + issueBase := planbase.CalicoPrimaryIssue{VMRef: vmRef, Network: primary.Network, VLAN: primary.VLAN.VID} + ips := findInterfaceIps(vm, nic) + // Calico's ipAddrs annotation accepts at most one IPv4 per + // interface; a NIC with more can't be represented and would fail + // the pod at CNI ADD. + if len(ips) > 1 { + multi := issueBase + multi.Kind = planbase.CalicoIssuePrimaryTooManyIPs + multi.IP = strings.Join(ips, ",") + emit(multi) + continue + } + for _, ip := range ips { + perIP := issueBase + perIP.IP = ip + if primary.Network == "" { + // Case A: implicit L3 IPAM. Pool must cover IP. + if calicoclient.L3EligiblePoolForIP(primary.L3EligiblePools, ip) == nil { + perIP.Kind = planbase.CalicoIssuePrimaryNoEligibleIPPool + emit(perIP) + } + continue + } + // Case C: IP must be in matched VLAN subnet AND covered by an + // L2Workload pool. + if !ipInAnySubnet(ip, primary.VLAN.Subnets) { + perIP.Kind = planbase.CalicoIssuePrimaryIPNotInSubnet + emit(perIP) + continue + } + if calicoclient.L2WorkloadEligiblePoolForIP(primary.L2EligiblePools, ip, primary.VLAN.Subnets) == nil { + perIP.Kind = planbase.CalicoIssuePrimaryNoEligibleIPPool + emit(perIP) + } + } + } + return issues, nil +} + +// translateVLANIssueKindToPrimary converts the secondary-NAD-path VLAN issue +// kinds returned by resolveVLANEntry into the Calico-primary equivalents. +// The shared resolver returns the NAD-path kinds; the primary path emits its +// own kinds so users can disambiguate primary vs secondary failures in the +// Plan condition. +func translateVLANIssueKindToPrimary(k planbase.CalicoIssueKind) planbase.CalicoIssueKind { + switch k { + case planbase.CalicoIssueNetworkHasNoVLANs: + return planbase.CalicoIssuePrimaryNetworkHasNoVLANs + case planbase.CalicoIssueVLANNotInNetwork: + return planbase.CalicoIssuePrimaryVLANNotInNetwork + } + return k +} + // buildNICResolver indexes the NetworkMap pairs by source-network ID and Key // so a per-NIC lookup returns every candidate destination. Mirrors the // Builder's resolver so the Validator validates exactly what the Builder @@ -944,20 +1299,15 @@ func (r *Validator) buildNICResolver(nics []vsphere.NIC) ([]string, map[string][ } // resolveVLANEntry returns the l2Bridge.vlans[] entry matched by nadVLAN. -// When no entry matches, returns nil entry plus a non-empty CalicoIssueKind -// describing the failure: NetworkHasNoVLANs (vlans list is empty), -// VLANAmbiguous (NAD omits vlan and Network has multiple entries), or -// VLANNotInNetwork (NAD's vlan is absent from the Network's entries). +// Callers reject a zero nadVLAN before reaching here (a Network reference +// requires an explicit VLAN), so nadVLAN is always non-zero. When no entry +// matches, returns nil entry plus a non-empty CalicoIssueKind describing the +// failure: NetworkHasNoVLANs (vlans list is empty) or VLANNotInNetwork (the +// requested vlan is absent from the Network's entries). func resolveVLANEntry(vlans []calicoclient.VLANEntry, nadVLAN uint16) (*calicoclient.VLANEntry, planbase.CalicoIssueKind) { if len(vlans) == 0 { return nil, planbase.CalicoIssueNetworkHasNoVLANs } - if nadVLAN == 0 { - if len(vlans) > 1 { - return nil, planbase.CalicoIssueVLANAmbiguous - } - return &vlans[0], "" - } for i := range vlans { if vlans[i].VID == nadVLAN { return &vlans[i], "" diff --git a/pkg/controller/plan/adapter/vsphere/validator_test.go b/pkg/controller/plan/adapter/vsphere/validator_test.go index cd5bf2fc22..3fd8c2baba 100644 --- a/pkg/controller/plan/adapter/vsphere/validator_test.go +++ b/pkg/controller/plan/adapter/vsphere/validator_test.go @@ -2,6 +2,7 @@ package vsphere import ( + "context" "errors" "fmt" @@ -14,11 +15,14 @@ import ( "github.com/kubev2v/forklift/pkg/controller/provider/model/vsphere" "github.com/kubev2v/forklift/pkg/controller/provider/web" "github.com/kubev2v/forklift/pkg/controller/provider/web/base" + ocp "github.com/kubev2v/forklift/pkg/controller/provider/web/ocp" model "github.com/kubev2v/forklift/pkg/controller/provider/web/vsphere" "github.com/kubev2v/forklift/pkg/controller/validation" calicoclient "github.com/kubev2v/forklift/pkg/lib/client/calico" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + core "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -26,10 +30,35 @@ import ( "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" ) var ErrNotImplemented = errors.New("not implemented") +// makeFelixConfiguration builds the cluster-wide "default" FelixConfiguration +// with spec.bpfEnabled set as given. +func makeFelixConfiguration(bpfEnabled bool) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(calicoclient.FelixConfigurationGVK) + u.SetName("default") + _ = unstructured.SetNestedField(u.Object, bpfEnabled, "spec", "bpfEnabled") + return u +} + +// withDefaultFelix appends a BPF-enabled "default" FelixConfiguration unless +// objs already carries a FelixConfiguration. l2Bridge networks are only valid +// on a BPF dataplane, so the Calico setup helpers install one by default; +// dataplane specs supply their own (bpfEnabled false, or per-node-only) to +// exercise the failure modes. +func withDefaultFelix(objs []runtime.Object) []runtime.Object { + for _, o := range objs { + if u, ok := o.(*unstructured.Unstructured); ok && u.GroupVersionKind() == calicoclient.FelixConfigurationGVK { + return objs + } + } + return append(objs, makeFelixConfiguration(true)) +} + // Mock inventory struct and methods for testing type mockInventory struct { ds model.Datastore @@ -37,6 +66,7 @@ type mockInventory struct { vm model.VM networks map[string]model.Network // keyed by ID customDefs []model.CustomFieldDef // global custom field definitions + destVMs []ocp.VM // served by List for destination inventories } // defaultVM returns a VM with sensible defaults for testing @@ -151,6 +181,8 @@ func (m *mockInventory) List(list interface{}, param ...web.Param) error { switch v := list.(type) { case *[]model.CustomFieldDef: *v = m.customDefs + case *[]ocp.VM: + *v = m.destVMs } return nil } @@ -697,11 +729,23 @@ var _ = Describe("vsphere validation tests", func() { } return u } - makeIPPool := func(name, cidr string) *unstructured.Unstructured { + makeIPPool := func(name, cidr string, allowedUses ...string) *unstructured.Unstructured { u := &unstructured.Unstructured{} u.SetGroupVersionKind(calicoclient.IPPoolGVK) u.SetName(name) _ = unstructured.SetNestedField(u.Object, cidr, "spec", "cidr") + if len(allowedUses) > 0 { + ifaces := make([]interface{}, len(allowedUses)) + for i, v := range allowedUses { + ifaces[i] = v + } + _ = unstructured.SetNestedSlice(u.Object, ifaces, "spec", "allowedUses") + } + return u + } + makeDisabledIPPool := func(name, cidr string, allowedUses ...string) *unstructured.Unstructured { + u := makeIPPool(name, cidr, allowedUses...) + _ = unstructured.SetNestedField(u.Object, true, "spec", "disabled") return u } // L2Bridge spec helpers — VLAN 100 maps to subnet 10.100.0.0/24. @@ -729,9 +773,20 @@ var _ = Describe("vsphere validation tests", func() { }, }, } + // VRF (routed, L3) Network spec — a real network type, but not one + // identity preservation supports. + vrfSpec := map[string]interface{}{ + "vrf": map[string]interface{}{ + "hostConfig": []interface{}{ + map[string]interface{}{"nodeSelector": "all()"}, + }, + }, + } // setup builds a Validator + fake client. nicIP is the NIC's guest IP, - // preserveIPs controls Plan.Spec.PreserveStaticIPs. + // preserveIPs controls Plan.Spec.PreserveStaticIPs. A BPF-enabled + // "default" FelixConfiguration is installed unless the spec supplies + // its own (see withDefaultFelix). setup := func(nicIP string, preserveIPs bool, k8sObjs ...runtime.Object) (*Validator, client.Client, ref.Ref) { scheme := runtime.NewScheme() _ = k8snet.AddToScheme(scheme) @@ -739,7 +794,9 @@ var _ = Describe("vsphere validation tests", func() { scheme.AddKnownTypeWithName(calicoclient.NetworkGVK.GroupVersion().WithKind("NetworkList"), &unstructured.UnstructuredList{}) scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK, &unstructured.Unstructured{}) scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK.GroupVersion().WithKind("IPPoolList"), &unstructured.UnstructuredList{}) - c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(k8sObjs...).Build() + scheme.AddKnownTypeWithName(calicoclient.FelixConfigurationGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.FelixConfigurationGVK.GroupVersion().WithKind("FelixConfigurationList"), &unstructured.UnstructuredList{}) + c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(withDefaultFelix(k8sObjs)...).Build() vm := model.VM{ VM1: model.VM1{VM0: model.VM0{ID: "test-vm-id", Name: "test-vm"}}, @@ -793,7 +850,7 @@ var _ = Describe("vsphere validation tests", func() { It("happy path — populates cache, no issues, when Network, VLAN, IPPool all line up", func() { v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(100), makeNetwork(l2Single), - makeIPPool("vlan100-pool", "10.100.0.0/24"), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) @@ -805,6 +862,42 @@ var _ = Describe("vsphere validation tests", func() { Expect(entry.EligiblePools).To(HaveLen(1)) }) + It("emits NetworkCRDAbsent when the Network CRD itself is missing", func() { + // Calico installed (NAD readable, IPPool path would work) + // but no projectcalico.org/v3 Network CRD on the cluster. + // The NAD references a Calico Network → can't honour the + // L2 attach → Critical NetworkCRDAbsent (distinct from a + // missing CR which would be NetworkNotFound). + v, _, _ := setup("10.100.0.5", true, makeCalicoNAD(100)) + networkGK := calicoclient.NetworkGVK.GroupKind() + scheme := runtime.NewScheme() + _ = k8snet.AddToScheme(scheme) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK.GroupVersion().WithKind("IPPoolList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(calicoclient.NetworkGVK, &unstructured.Unstructured{}) + c := fake.NewClientBuilder().WithScheme(scheme). + WithRuntimeObjects(makeCalicoNAD(100)). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, inner client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if obj.GetObjectKind().GroupVersionKind() == calicoclient.NetworkGVK { + return &meta.NoKindMatchError{GroupKind: networkGK} + } + // Fall through to the underlying fake client + // for other GETs (notably the NAD). + return inner.Get(ctx, key, obj, opts...) + }, + }).Build() + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, + Kind: planbase.CalicoIssueNetworkCRDAbsent, + Network: netName, + VLAN: 100, + })) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + It("emits NetworkNotFound when the referenced Network is missing", func() { v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(100)) result, err := v.ValidateCalicoNADs(c) @@ -828,21 +921,123 @@ var _ = Describe("vsphere validation tests", func() { Expect(result.Cache.NADs).To(BeEmpty()) }) - It("emits NetworkHasNoVLANs when the Network's l2Bridge.vlans list is empty", func() { - // L2Bridge is spec'd but vlans is empty — distinct from - // NetworkHasNoL2Bridge (no l2Bridge at all). Should not be - // reported as VLANAmbiguous even though the NAD omits vlan. - emptyVLANs := map[string]interface{}{ - "l2Bridge": map[string]interface{}{ - "vlans": []interface{}{}, + It("emits VLANRequired when the NAD references a Calico Network but omits vlan", func() { + // A Network reference requires an explicit VLAN. The Network + // is fetched and classified first (an l2Bridge network here), + // then the missing vlan is rejected before any VLAN matching. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), makeNetwork(l2Multi), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANRequired)) + }) + + It("emits NetworkNotFound (not VLANRequired) when the Network is missing and vlan is unset", func() { + // The Network lookup precedes the vlan-required check: with a + // missing Network the accurate report is NetworkNotFound — + // asking for a vlan first would send the user chasing the + // wrong fix. + v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(0)) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkNotFound)) + }) + + It("emits NetworkTypeUnsupported when the referenced Network is a VRF network", func() { + // A VRF NAD legitimately carries no vlan; the misleading + // VLANRequired must not fire. The reference stops at + // classification and is not cached. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), makeNetwork(vrfSpec), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkTypeUnsupported)) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + + It("emits NetworkTypeUnsupported even when the NAD sets a vlan on a VRF network", func() { + // Same root cause — the network type is wrong; a VLAN check + // against a VRF network would mislead. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(vrfSpec), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkTypeUnsupported)) + Expect(result.Cache.NADs).To(BeEmpty()) + }) + + It("does not emit a dataplane issue when FelixConfiguration has bpfEnabled true", func() { + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + makeFelixConfiguration(true), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.NADs).To(HaveLen(1)) + }) + + It("emits DataplaneNotBPF when FelixConfiguration has bpfEnabled false", func() { + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + makeFelixConfiguration(false), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueDataplaneNotBPF)) + // The issue is plan-scoped; the NAD's own configuration is + // valid and stays cached so per-VM checks still run. + Expect(result.Cache.NADs).To(HaveLen(1)) + }) + + It("emits DataplaneNotBPF when the default FelixConfiguration is missing", func() { + // Only a per-node FelixConfiguration exists (suppresses the + // setup helper's auto-injected BPF-enabled default). Felix + // then runs the cluster default dataplane, which is not BPF. + perNode := makeFelixConfiguration(true) + perNode.SetName("node.worker-1") + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + perNode, + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueDataplaneNotBPF)) + }) + + It("emits DataplaneNotBPF once for a plan with multiple l2Bridge NADs", func() { + // The dataplane is a cluster property; two healthy l2Bridge + // NADs must not double-report it. + secondNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "calico-nad-2", Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{ + Config: fmt.Sprintf(`{"type":"calico","network":"%s","vlan":100}`, netName), }, } v, c, _ := setup("10.100.0.5", true, - makeCalicoNAD(0), makeNetwork(emptyVLANs), + makeCalicoNAD(100), secondNAD, makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + makeFelixConfiguration(false), + ) + v.Plan.Referenced.Map.Network.Spec.Map = append( + v.Plan.Referenced.Map.Network.Spec.Map, + v1beta1.NetworkPair{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-2"}}, + Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: "calico-nad-2", + }, + }, ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) - Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkHasNoVLANs)) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueDataplaneNotBPF)) + Expect(result.Cache.NADs).To(HaveLen(2)) }) It("emits NetworkHasNoVLANs even when the NAD specifies a vlan ID", func() { @@ -870,39 +1065,39 @@ var _ = Describe("vsphere validation tests", func() { Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANNotInNetwork)) }) - It("emits VLANAmbiguous when the NAD omits vlan and Network has multiple entries", func() { - v, c, _ := setup("10.100.0.5", true, - makeCalicoNAD(0), makeNetwork(l2Multi), + It("emits VLANHasNoIPPool when no IPPool overlaps the VLAN subnet", func() { + v, c, _ := setup("10.100.0.5", false, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("cluster-default", "10.0.0.0/8", "L2Workload"), // pool too large ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) - Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANAmbiguous)) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANHasNoIPPool)) }) - It("emits VLANHasNoIPPool when no IPPool overlaps the VLAN subnet", func() { + It("emits VLANHasNoIPPool when the only covering pool is disabled", func() { + // The pool's CIDR matches the VLAN subnet, but a disabled pool + // can never satisfy a CNI ADD — it must not pass validation. v, c, _ := setup("10.100.0.5", false, makeCalicoNAD(100), makeNetwork(l2Single), - makeIPPool("cluster-default", "10.0.0.0/8"), // pool too large + makeDisabledIPPool("vlan100-disabled", "10.100.0.0/24", "L2Workload"), ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANHasNoIPPool)) }) - It("resolves implicit VLAN (NAD omits vlan, Network has one entry) to the Network's VID in the cache", func() { - // NAD has vlan=0 (omitted); Network has exactly one entry with - // id=100. The cache entry must carry the resolved VID, so per-VM - // checks (and downstream condition messages) see VLAN=100. - v, c, _ := setup("10.100.0.5", true, - makeCalicoNAD(0), makeNetwork(l2Single), - makeIPPool("vlan100-pool", "10.100.0.0/24"), + It("emits VLANHasNoIPPool when the only covering pool lacks the L2Workload use", func() { + // A Workload-only pool covers the subnet, but Calico won't + // assign L2-attached addresses from it — it must not pass + // validation. + v, c, _ := setup("10.100.0.5", false, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-workload-only", "10.100.0.0/24", "Workload"), ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) - Expect(result.Issues).To(BeEmpty()) - entry := result.Cache.NADs[k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}] - Expect(entry).NotTo(BeNil()) - Expect(entry.VLAN.VID).To(Equal(uint16(100))) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANHasNoIPPool)) }) It("dedupes the same NAD when referenced by multiple network-map pairs", func() { @@ -1073,7 +1268,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, vmRef := setup("10.100.0.5", true, makeCalicoNAD(100), // broken — no Network CR healthyNAD, healthyNet, - makeIPPool("vlan200-pool", "10.200.0.0/24"), + makeIPPool("vlan200-pool", "10.200.0.0/24", "L2Workload"), ) v.Plan.Referenced.Map.Network.Spec.Map = append( v.Plan.Referenced.Map.Network.Spec.Map, @@ -1117,7 +1312,7 @@ var _ = Describe("vsphere validation tests", func() { It("emits IPNotInSubnet when preserveStaticIPs is on and source IP is outside the VLAN subnet", func() { v, c, vmRef := setup("192.168.1.5", true, makeCalicoNAD(100), makeNetwork(l2Single), - makeIPPool("vlan100-pool", "10.100.0.0/24"), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) @@ -1131,7 +1326,7 @@ var _ = Describe("vsphere validation tests", func() { It("emits IPNotInIPPool when preserveStaticIPs is on and no eligible pool covers the source IP", func() { v, c, vmRef := setup("10.100.0.5", true, makeCalicoNAD(100), makeNetwork(l2Single), - makeIPPool("vlan100-upper", "10.100.0.128/25"), // covers VLAN but not 10.100.0.5 + makeIPPool("vlan100-upper", "10.100.0.128/25", "L2Workload"), // covers VLAN but not 10.100.0.5 ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) @@ -1146,7 +1341,7 @@ var _ = Describe("vsphere validation tests", func() { // Source IP would fail subnet check, but preservation is off. v, c, vmRef := setup("192.168.1.5", false, makeCalicoNAD(100), makeNetwork(l2Single), - makeIPPool("vlan100-pool", "10.100.0.0/24"), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) @@ -1169,6 +1364,25 @@ var _ = Describe("vsphere validation tests", func() { Expect(issues).To(BeEmpty()) }) + It("emits TooManyIPs when a NIC carries more than one IPv4", func() { + // Calico's ipAddrs annotation accepts at most one IPv4 per + // interface; a multi-IPv4 NIC would fail the pod at CNI ADD. + v, c, vmRef := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + ) + vm := v.Source.Inventory.(*mockInventory).vm + vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "10.100.0.6", DeviceConfigId: 4001}) + v.Source.Inventory.(*mockInventory).vm = vm + + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(HaveLen(1)) + Expect(issues[0].Kind).To(Equal(planbase.CalicoIssueTooManyIPs)) + }) + It("deduplicates identical per-NIC IP issues", func() { // Two NICs on the same source network, two NetworkMap entries each // pointing at a distinct NAD; both NADs reference the same Calico @@ -1188,7 +1402,7 @@ var _ = Describe("vsphere validation tests", func() { } v, c, vmRef := setup("192.168.1.5", true, nadA, nadB, makeNetwork(l2Single), - makeIPPool("vlan100-pool", "10.100.0.0/24"), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) vm := v.Source.Inventory.(*mockInventory).vm vm.NICs = append(vm.NICs, vsphere.NIC{Network: vsphere.Ref{ID: srcNetID}, DeviceKey: 4002}) @@ -1214,6 +1428,640 @@ var _ = Describe("vsphere validation tests", func() { }) }) }) + + Describe("Calico Primary network validation", func() { + const ( + srcNetID = "src-1" + netName = "vlan100" + targetNS = "test" + ) + + makeNetwork := func(spec map[string]interface{}) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(calicoclient.NetworkGVK) + u.SetName(netName) + if spec != nil { + _ = unstructured.SetNestedField(u.Object, spec, "spec") + } + return u + } + makeIPPool := func(name, cidr string, allowedUses ...string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(calicoclient.IPPoolGVK) + u.SetName(name) + _ = unstructured.SetNestedField(u.Object, cidr, "spec", "cidr") + if len(allowedUses) > 0 { + ifaces := make([]interface{}, len(allowedUses)) + for i, v := range allowedUses { + ifaces[i] = v + } + _ = unstructured.SetNestedSlice(u.Object, ifaces, "spec", "allowedUses") + } + return u + } + makeUDNNamespace := func() *core.Namespace { + return &core.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: targetNS, + Labels: map[string]string{"k8s.ovn.org/primary-user-defined-network": ""}, + }, + } + } + l2Single := map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(100)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.100.0.0/24"}}, + }, + }, + }, + } + l2Multi := map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(100)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.100.0.0/24"}}, + }, + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(200)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.200.0.0/24"}}, + }, + }, + }, + } + // VRF (routed, L3) Network spec — a real network type, but not one + // identity preservation supports. + vrfSpec := map[string]interface{}{ + "vrf": map[string]interface{}{ + "hostConfig": []interface{}{ + map[string]interface{}{"nodeSelector": "all()"}, + }, + }, + } + + // setupPrimary builds a Validator + fake client with a single + // type: calico NetworkMap entry sourced from srcNetID. extraPairs + // adds additional NetworkMap entries (for coexistence / misuse tests). + // With registerCalicoKinds, a BPF-enabled "default" FelixConfiguration + // is installed unless the spec supplies its own (see withDefaultFelix). + setupPrimary := func(nicIP string, preserveIPs bool, dest v1beta1.DestinationNetwork, extraPairs []v1beta1.NetworkPair, registerCalicoKinds bool, k8sObjs ...runtime.Object) (*Validator, client.Client, ref.Ref) { + scheme := runtime.NewScheme() + _ = k8snet.AddToScheme(scheme) + _ = core.AddToScheme(scheme) + if registerCalicoKinds { + scheme.AddKnownTypeWithName(calicoclient.NetworkGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.NetworkGVK.GroupVersion().WithKind("NetworkList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK.GroupVersion().WithKind("IPPoolList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(calicoclient.FelixConfigurationGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.FelixConfigurationGVK.GroupVersion().WithKind("FelixConfigurationList"), &unstructured.UnstructuredList{}) + k8sObjs = withDefaultFelix(k8sObjs) + } + c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(k8sObjs...).Build() + + vm := model.VM{ + VM1: model.VM1{VM0: model.VM0{ID: "test-vm-id", Name: "test-vm"}}, + NICs: []vsphere.NIC{{Network: vsphere.Ref{ID: srcNetID}, DeviceKey: 4001}}, + GuestNetworks: []vsphere.GuestNetwork{ + {IP: nicIP, DeviceConfigId: 4001}, + }, + } + inventory := &mockInventory{ + vm: vm, + networks: map[string]model.Network{ + srcNetID: {Resource: model.Resource{ID: srcNetID}, Variant: vsphere.NetDvPortGroup, Key: srcNetID}, + }, + } + plan := createPlan() + plan.Spec.PreserveStaticIPs = preserveIPs + pairs := []v1beta1.NetworkPair{ + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: srcNetID}}, Destination: dest}, + } + pairs = append(pairs, extraPairs...) + plan.Referenced.Map.Network = &v1beta1.NetworkMap{ + Spec: v1beta1.NetworkMapSpec{Map: pairs}, + } + ctx := plancontext.Context{Plan: plan, Source: plancontext.Source{Inventory: inventory}} + return &Validator{Context: &ctx}, c, ref.Ref{Name: "test-vm-id", ID: "test-vm-id"} + } + + kinds := func(issues []planbase.CalicoPrimaryIssue) []planbase.CalicoIssueKind { + out := make([]planbase.CalicoIssueKind, 0, len(issues)) + for _, i := range issues { + out = append(out, i.Kind) + } + return out + } + + Describe("ValidateCalicoPrimary (plan-level)", func() { + It("returns empty results when NetworkMap is nil", func() { + v, c, _ := setupPrimary("10.100.0.5", false, v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}}, nil, true) + v.Plan.Referenced.Map.Network = nil + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache).NotTo(BeNil()) + Expect(result.Cache.Primary).To(BeNil()) + }) + + It("returns empty results when NetworkMap has no calico entries", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.Primary).To(BeNil()) + }) + + It("happy path Case A (implicit L3 IPAM) — populates L3EligiblePools, no issues", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.Primary).NotTo(BeNil()) + Expect(result.Cache.Primary.Network).To(BeEmpty()) + Expect(result.Cache.Primary.L3EligiblePools).To(HaveLen(1)) + }) + + It("happy path Case C (single-VLAN Network, explicit VLAN)", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.Primary).NotTo(BeNil()) + Expect(result.Cache.Primary.Network).To(Equal(netName)) + Expect(result.Cache.Primary.VLAN.VID).To(Equal(uint16(100))) + Expect(result.Cache.Primary.L2EligiblePools).To(HaveLen(1)) + }) + + It("happy path Case C (multi-VLAN Network, explicit VLAN)", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 200}} + v, c, _ := setupPrimary("10.200.0.5", false, dest, nil, true, + makeNetwork(l2Multi), + makeIPPool("vlan200-pool", "10.200.0.0/24", "L2Workload"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.Primary.VLAN.VID).To(Equal(uint16(200))) + }) + + It("emits PrimaryUnsupported when Calico CRDs are absent", func() { + // The fake client does not natively return NoKindMatchError + // for unregistered kinds, so we wrap it with an interceptor + // that returns the error the real API server would on a + // cluster without the projectcalico.org/v3 CRDs installed. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, _, _ := setupPrimary("10.244.0.5", false, dest, nil, true) + ipoolGK := calicoclient.IPPoolGVK.GroupKind() + ipoolListGVK := calicoclient.IPPoolGVK.GroupVersion().WithKind("IPPoolList") + c := fake.NewClientBuilder(). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(ctx context.Context, _ client.WithWatch, list client.ObjectList, _ ...client.ListOption) error { + if list.GetObjectKind().GroupVersionKind() == ipoolListGVK { + return &meta.NoKindMatchError{GroupKind: ipoolGK} + } + return nil + }, + }).Build() + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryUnsupported)) + Expect(result.Cache.Primary).To(BeNil()) + }) + + It("emits PrimaryConflictsWithUDN when target namespace is UDN-labelled", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + udnNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "udn-primary", + Namespace: targetNS, + Labels: map[string]string{"k8s.ovn.org/user-defined-network": ""}, + }, + } + v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, makeUDNNamespace(), udnNAD, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryConflictsWithUDN)) + }) + + It("emits PrimaryFieldsMisplaced when the calico block is set on a non-pod entry", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Multus, Calico: &v1beta1.CalicoDestination{Network: "leaked"}} + v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryFieldsMisplaced)) + }) + + It("emits PrimaryFieldsMisplaced when calico.vlan is set without calico.network", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Vlan: 100}} + v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ContainElement(planbase.CalicoIssuePrimaryFieldsMisplaced)) + }) + + It("allows a plain pod entry to coexist with a calico-flagged entry", func() { + // A calico-flagged entry IS a pod entry; a second plain pod + // entry on another source network is not a plan-level + // conflict (a VM with NICs on both would trip the existing + // per-VM multiple-pod-mappings condition instead). + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + extra := []v1beta1.NetworkPair{ + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-2"}}, Destination: v1beta1.DestinationNetwork{Type: planbase.Pod}}, + } + v, c, _ := setupPrimary("10.244.0.5", false, dest, extra, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.Primary).NotTo(BeNil()) + }) + + It("emits PrimaryFieldsMisplaced for multiple calico-flagged entries", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + extra := []v1beta1.NetworkPair{ + {Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-2"}}, Destination: v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}}}, + } + v, c, _ := setupPrimary("10.244.0.5", false, dest, extra, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ContainElement(planbase.CalicoIssuePrimaryFieldsMisplaced)) + }) + + It("emits PrimaryFieldsMisplaced when the calico block is set on a multus entry", func() { + dest := v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: "ns", Name: "nad", + Calico: &v1beta1.CalicoDestination{}, + } + v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryFieldsMisplaced)) + // A multus block never seeds the primary cache. + Expect(result.Cache.Primary).To(BeNil()) + }) + + It("emits PrimaryFieldsMisplaced when the calico block is set on an ignored entry", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Ignored, Calico: &v1beta1.CalicoDestination{}} + v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryFieldsMisplaced)) + }) + + It("emits PrimaryNetworkNotFound when calico.network names a missing CR", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: "missing", Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkNotFound)) + }) + + It("emits PrimaryNetworkCRDAbsent when calico.network is set but Network CRD missing (IPPool present)", func() { + // Calico installed (IPPool CRD present) but L2 feature not + // shipped (Network CRD absent). User asked for L2 attach; + // can't honour. Case A (no CalicoNetwork) would pass. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, _, _ := setupPrimary("10.100.0.5", false, dest, nil, true) + networkGK := calicoclient.NetworkGVK.GroupKind() + // Stand up a client where IPPool listing succeeds but + // GetNetwork returns NoKindMatchError. + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK.GroupVersion().WithKind("IPPoolList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(calicoclient.NetworkGVK, &unstructured.Unstructured{}) + c := fake.NewClientBuilder().WithScheme(scheme). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, _ client.WithWatch, key client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + if obj.GetObjectKind().GroupVersionKind() == calicoclient.NetworkGVK { + return &meta.NoKindMatchError{GroupKind: networkGK} + } + return nil + }, + }).Build() + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkCRDAbsent)) + }) + + It("emits PrimaryNetworkHasNoL2Bridge when the Network has no l2Bridge spec", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(map[string]interface{}{}), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkHasNoL2Bridge)) + }) + + It("emits PrimaryNetworkHasNoVLANs when l2Bridge.vlans is empty", func() { + emptyVLANs := map[string]interface{}{ + "l2Bridge": map[string]interface{}{"vlans": []interface{}{}}, + } + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, makeNetwork(emptyVLANs)) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkHasNoVLANs)) + }) + + It("emits PrimaryVLANRequired when calico.network is set without calico.vlan", func() { + // A Network reference requires an explicit VLAN. The Network + // is fetched and classified first (an l2Bridge network here), + // then the missing vlan is rejected before any VLAN matching. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(l2Single), + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryVLANRequired)) + }) + + It("emits PrimaryNetworkNotFound (not PrimaryVLANRequired) when the Network is missing and calico.vlan is unset", func() { + // The Network lookup precedes the vlan-required check: with a + // missing Network the accurate report is NetworkNotFound — + // asking for a vlan first would send the user chasing the + // wrong fix. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: "missing"}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkNotFound)) + }) + + It("emits PrimaryNetworkTypeUnsupported when calico.network names a VRF network", func() { + // A VRF reference legitimately carries no vlan; the misleading + // PrimaryVLANRequired must not fire, and the primary cache + // must not seed. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(vrfSpec), + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkTypeUnsupported)) + Expect(result.Cache.Primary).To(BeNil()) + }) + + It("emits PrimaryNetworkTypeUnsupported even when calico.vlan is set on a VRF network", func() { + // Same root cause — the network type is wrong; a VLAN check + // against a VRF network would mislead. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(vrfSpec), + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkTypeUnsupported)) + Expect(result.Cache.Primary).To(BeNil()) + }) + + It("does not emit a dataplane issue when FelixConfiguration has bpfEnabled true", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + makeFelixConfiguration(true), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.Primary).NotTo(BeNil()) + }) + + It("emits PrimaryDataplaneNotBPF when FelixConfiguration has bpfEnabled false", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + makeFelixConfiguration(false), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryDataplaneNotBPF)) + // The issue is plan-scoped; the mapping itself is valid and + // the cache still seeds so per-VM checks run. + Expect(result.Cache.Primary).NotTo(BeNil()) + }) + + It("emits PrimaryDataplaneNotBPF when the default FelixConfiguration is missing", func() { + // Only a per-node FelixConfiguration exists (suppresses the + // setup helper's auto-injected BPF-enabled default). Felix + // then runs the cluster default dataplane, which is not BPF. + perNode := makeFelixConfiguration(true) + perNode.SetName("node.worker-1") + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + perNode, + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryDataplaneNotBPF)) + }) + + It("does not run the dataplane check for Case A (no calico.network)", func() { + // Case A is plain L3 IPAM — no l2Bridge network is engaged, so + // a non-BPF dataplane is fine. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + makeFelixConfiguration(false), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.Primary).NotTo(BeNil()) + }) + + It("emits PrimaryVLANNotInNetwork when calico.vlan is absent from the Network's VLAN list", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 999}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, makeNetwork(l2Single)) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryVLANNotInNetwork)) + }) + + It("emits PrimaryNoEligibleIPPool when no L2Workload pool covers the VLAN subnet", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, + makeNetwork(l2Single), + makeIPPool("workload-only", "10.100.0.0/24", "Workload"), // missing L2Workload + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNoEligibleIPPool)) + }) + + It("emits PrimaryStaticIPsNotPreserved as a Warning when preserveStaticIPs is false", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(kinds(result.Warnings)).To(ConsistOf(planbase.CalicoIssuePrimaryStaticIPsNotPreserved)) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Cache.Primary).NotTo(BeNil()) + }) + + It("does not emit PrimaryStaticIPsNotPreserved when preserveStaticIPs is true", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, c, _ := setupPrimary("10.244.0.5", true, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Warnings).To(BeEmpty()) + }) + }) + + Describe("CalicoPrimaryIssues (per-VM)", func() { + It("returns nil when preserveStaticIPs is false", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, c, vmRef := setupPrimary("10.244.0.5", false, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(BeEmpty()) + }) + + It("returns nil when cache is nil", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, _, vmRef := setupPrimary("10.244.0.5", true, dest, nil, true) + issues, err := v.CalicoPrimaryIssues(vmRef, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(BeEmpty()) + }) + + It("returns nil when cache.Primary is nil (plan-level failed or no calico entry)", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod} + v, c, vmRef := setupPrimary("10.244.0.5", true, dest, nil, true) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(BeEmpty()) + }) + + It("Case A: emits NoEligibleIPPool when no L3 pool covers the source IP", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, c, vmRef := setupPrimary("192.168.1.5", true, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoPrimaryIssue{ + VMRef: vmRef, Kind: planbase.CalicoIssuePrimaryNoEligibleIPPool, IP: "192.168.1.5", + })) + }) + + It("Case C: emits IPNotInSubnet when source IP is outside the VLAN subnet", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, vmRef := setupPrimary("192.168.1.5", true, dest, nil, true, + makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoPrimaryIssue{ + VMRef: vmRef, Kind: planbase.CalicoIssuePrimaryIPNotInSubnet, + Network: netName, VLAN: 100, IP: "192.168.1.5", + })) + }) + + It("Case C: emits NoEligibleIPPool when no L2Workload pool covers the source IP", func() { + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, vmRef := setupPrimary("10.100.0.5", true, dest, nil, true, + makeNetwork(l2Single), + // pool covers VLAN subnet but excludes 10.100.0.5 + makeIPPool("vlan100-upper", "10.100.0.128/25", "L2Workload"), + ) + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoPrimaryIssue{ + VMRef: vmRef, Kind: planbase.CalicoIssuePrimaryNoEligibleIPPool, + Network: netName, VLAN: 100, IP: "10.100.0.5", + })) + }) + + It("deduplicates identical per-NIC IP issues", func() { + // Two NICs on the same source network, both mapped to the same + // calico entry. Both NICs carry the same out-of-subnet IP so + // both emit identical {Kind, Network, VLAN, IP}; dedup → one. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} + v, c, vmRef := setupPrimary("192.168.1.5", true, dest, nil, true, + makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + ) + vm := v.Source.Inventory.(*mockInventory).vm + vm.NICs = append(vm.NICs, vsphere.NIC{Network: vsphere.Ref{ID: srcNetID}, DeviceKey: 4002}) + vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "192.168.1.5", DeviceConfigId: 4002}) + v.Source.Inventory.(*mockInventory).vm = vm + + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoPrimaryIssue{ + VMRef: vmRef, Kind: planbase.CalicoIssuePrimaryIPNotInSubnet, + Network: netName, VLAN: 100, IP: "192.168.1.5", + })) + }) + + It("emits PrimaryTooManyIPs when a calico-mapped NIC carries more than one IPv4", func() { + // Calico's ipAddrs annotation accepts at most one IPv4 per + // interface; a multi-IPv4 NIC would fail the pod at CNI ADD. + dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} + v, c, vmRef := setupPrimary("10.244.0.5", true, dest, nil, true, + makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), + ) + vm := v.Source.Inventory.(*mockInventory).vm + vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "10.244.0.6", DeviceConfigId: 4001}) + v.Source.Inventory.(*mockInventory).vm = vm + + result, err := v.ValidateCalicoPrimary(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(HaveLen(1)) + Expect(issues[0].Kind).To(Equal(planbase.CalicoIssuePrimaryTooManyIPs)) + }) + }) + }) }) func createPlan() *v1beta1.Plan { diff --git a/pkg/controller/plan/validation.go b/pkg/controller/plan/validation.go index 439430ea3f..37255d0faa 100644 --- a/pkg/controller/plan/validation.go +++ b/pkg/controller/plan/validation.go @@ -67,8 +67,11 @@ const ( VMIpNotMatchingUdnSubnet = "VMIpNotMatchingUdnSubnet" CalicoNetworkInvalid = "CalicoNetworkInvalid" CalicoNetworkWarning = "CalicoNetworkWarning" + CalicoPrimaryInvalid = "CalicoPrimaryInvalid" + CalicoPrimaryWarning = "CalicoPrimaryWarning" VMIpNotInCalicoSubnet = "VMIpNotInCalicoSubnet" VMIpNotInCalicoIPPool = "VMIpNotInCalicoIPPool" + VMTooManyIPsForCalico = "VMTooManyIPsForCalico" VMMissingChangedBlockTracking = "VMMissingChangedBlockTracking" VMHasSnapshots = "VMHasSnapshots" VMConsolidationNeeded = "VMConsolidationNeeded" @@ -222,6 +225,11 @@ func (r *Reconciler) validate(plan *api.Plan) error { return err } + calicoPrimaryResult, err := r.validateCalicoPrimary(ctx) + if err != nil { + return err + } + err = r.validateNetAppShift(ctx) if err != nil { return err @@ -235,7 +243,7 @@ func (r *Reconciler) validate(plan *api.Plan) error { return err } - if err = r.validateVM(plan, ctx, calicoCache); err != nil { + if err = r.validateVM(plan, ctx, calicoCache, calicoPrimaryResult); err != nil { return err } @@ -587,6 +595,80 @@ func (r *Reconciler) validateCalicoNetwork(ctx *plancontext.Context) (*planbase. return result.Cache, nil } +// validateCalicoPrimary validates the calico-flagged NetworkMap entry (a +// type: pod destination carrying the calico field), if any. +// Emits the Warn-class CalicoPrimaryWarning condition immediately since +// warnings are plan-scoped. The Critical CalicoPrimaryInvalid condition is +// deferred to validateVM, which appends per-VM issues onto the same result +// so both layers fold into a single condition. +func (r *Reconciler) validateCalicoPrimary(ctx *plancontext.Context) (*planbase.CalicoPrimaryValidationResult, error) { + provider := ctx.Plan.Referenced.Provider.Source + if provider == nil { + return &planbase.CalicoPrimaryValidationResult{}, nil + } + pAdapter, err := adapter.New(provider) + if err != nil { + return nil, err + } + validator, err := pAdapter.Validator(ctx) + if err != nil { + return nil, err + } + result, err := validator.ValidateCalicoPrimary(ctx.Destination.Client) + if err != nil { + return nil, err + } + if cond, ok := buildCalicoPrimaryCondition( + CalicoPrimaryWarning, api.CategoryWarn, + "The Calico primary-network mapping has informational warnings", + result.Warnings, + ); ok { + ctx.Plan.Status.SetCondition(cond) + } + return &result, nil +} + +// buildCalicoPrimaryCondition assembles a plan-level Calico-primary condition +// from a slice of issues. Items deduplicate by issue ref (per-VM VMRef when +// set, or a "(plan)" synthetic identifier for plan-level issues). Message +// concatenates a per-issue detail phrase. Returns ok=false when issues is +// empty so callers can skip SetCondition. +func buildCalicoPrimaryCondition(condType, category, baseMsg string, issues []planbase.CalicoPrimaryIssue) (libcnd.Condition, bool) { + if len(issues) == 0 { + return libcnd.Condition{}, false + } + cond := libcnd.Condition{ + Type: condType, + Status: True, + Reason: NotValid, + Category: category, + Items: []string{}, + } + details := make([]string, 0, len(issues)) + seen := map[string]bool{} + for _, issue := range issues { + item := calicoPrimaryIssueItem(issue) + if !seen[item] { + seen[item] = true + cond.Items = append(cond.Items, item) + } + details = append(details, calicoPrimaryIssueDetail(issue)) + } + cond.Message = fmt.Sprintf("%s: %s.", baseMsg, strings.Join(details, "; ")) + return cond, true +} + +// calicoPrimaryIssueItem returns the Items entry for an issue. Per-VM issues +// use the VMRef; plan-level issues use a synthetic "(plan)" identifier since +// there is no resource-specific ref to attach (the offending NetworkMap entry +// is plan-scoped). +func calicoPrimaryIssueItem(i planbase.CalicoPrimaryIssue) string { + if !i.VMRef.NotSet() { + return i.VMRef.String() + } + return "(plan)" +} + // buildCalicoNADCondition assembles a plan-level Calico NAD condition from a // slice of per-NAD issues. Items are deduplicated by NAD reference; Message // concatenates a per-issue detail phrase for each (including duplicates, so @@ -674,7 +756,10 @@ func (r *Reconciler) validateNetworkMap(plan *api.Plan) (err error) { if plan.Provider.Source != nil && plan.Provider.Source.SupportsPreserveStaticIps() && plan.Spec.PreserveStaticIPs { var hasMappingToPodNetwork bool for _, networkMap := range mp.Spec.Map { - if networkMap.Destination.Type == Pod { + // A calico-flagged pod entry preserves the VM's IP on the pod + // network (that is the feature), so the masquerade warning + // below does not apply to it. + if networkMap.Destination.Type == Pod && networkMap.Destination.Calico == nil { hasMappingToPodNetwork = true break } @@ -795,7 +880,7 @@ func aggregateWarningConcerns(v interface{}, vmRef string, unsupportedOVFExportS } // Validate listed VMs. -func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context, calicoCache *planbase.CalicoValidationCache) error { +func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context, calicoCache *planbase.CalicoValidationCache, calicoPrimaryResult *planbase.CalicoPrimaryValidationResult) error { if plan.Status.HasCondition(Executing) { return nil } @@ -911,6 +996,14 @@ func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context, calico Message: "VM static IP is within the Calico Network VLAN subnet but no IPPool covers it.", Items: []string{}, } + vmTooManyIPsForCalico := libcnd.Condition{ + Type: VMTooManyIPsForCalico, + Status: True, + Reason: NotValid, + Category: api.CategoryCritical, + Message: "VM NIC has more than one IPv4 address; Calico static IP preservation supports at most one IPv4 per interface.", + Items: []string{}, + } missingCbtForWarm := libcnd.Condition{ Type: VMMissingChangedBlockTracking, Status: True, @@ -1399,7 +1492,7 @@ func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context, calico if err != nil { return err } - addedSubnet, addedPool := false, false + addedSubnet, addedPool, addedTooMany := false, false, false for _, issue := range calicoIssues { switch issue.Kind { case planbase.CalicoIssueIPNotInSubnet: @@ -1412,7 +1505,21 @@ func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context, calico vmIpNotInCalicoIPPool.Items = append(vmIpNotInCalicoIPPool.Items, ref.String()) addedPool = true } + case planbase.CalicoIssueTooManyIPs: + if !addedTooMany { + vmTooManyIPsForCalico.Items = append(vmTooManyIPsForCalico.Items, ref.String()) + addedTooMany = true + } + } + } + // Per-VM Calico-primary issues fold into the same Critical condition + // as the plan-level ones; the condition is emitted after this loop. + if calicoPrimaryResult != nil { + primaryIssues, err := validator.CalicoPrimaryIssues(*ref, calicoPrimaryResult.Cache) + if err != nil { + return err } + calicoPrimaryResult.Issues = append(calicoPrimaryResult.Issues, primaryIssues...) } // Destination. provider = plan.Provider.Destination @@ -1550,6 +1657,20 @@ func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context, calico if len(vmIpNotInCalicoIPPool.Items) > 0 { plan.Status.SetCondition(vmIpNotInCalicoIPPool) } + if len(vmTooManyIPsForCalico.Items) > 0 { + plan.Status.SetCondition(vmTooManyIPsForCalico) + } + // CalicoPrimaryInvalid carries both plan-level (from validateCalicoPrimary) + // and per-VM (collected during this validateVM pass) issues. + if calicoPrimaryResult != nil { + if cond, ok := buildCalicoPrimaryCondition( + CalicoPrimaryInvalid, api.CategoryCritical, + "The Calico primary-network mapping is not valid", + calicoPrimaryResult.Issues, + ); ok { + plan.Status.SetCondition(cond) + } + } if len(vmHasSnapshotsForWarm.Items) > 0 { plan.Status.SetCondition(vmHasSnapshotsForWarm) } @@ -1984,6 +2105,54 @@ func (r *Reconciler) validateVddkImage(plan *api.Plan) (err error) { return } +// calicoPrimaryIssueDetail formats a per-issue detail phrase for the +// plan-level CalicoPrimaryInvalid / CalicoPrimaryWarning Message. Per-VM +// issues carry a VMRef prefix; plan-level issues do not. +func calicoPrimaryIssueDetail(i planbase.CalicoPrimaryIssue) string { + prefix := "" + if !i.VMRef.NotSet() { + prefix = i.VMRef.String() + " " + } + switch i.Kind { + case planbase.CalicoIssuePrimaryProviderUnsupported: + return fmt.Sprintf("%s(PrimaryProviderUnsupported: feature is vSphere-only in this release)", prefix) + case planbase.CalicoIssuePrimaryUnsupported: + return fmt.Sprintf("%s(PrimaryUnsupported: Calico is not installed on the destination — projectcalico.org/v3 IPPool CRD absent)", prefix) + case planbase.CalicoIssuePrimaryNetworkCRDAbsent: + return fmt.Sprintf("%s(PrimaryNetworkCRDAbsent network=%q: destination Calico install does not ship the projectcalico.org/v3 Network CRD; remove calico.network or upgrade Calico)", prefix, i.Network) + case planbase.CalicoIssuePrimaryConflictsWithUDN: + return fmt.Sprintf("%s(PrimaryConflictsWithUDN: target namespace is labelled for a UDN primary network)", prefix) + case planbase.CalicoIssuePrimaryNetworkNotFound: + return fmt.Sprintf("%s(PrimaryNetworkNotFound network=%q)", prefix, i.Network) + case planbase.CalicoIssuePrimaryNetworkTypeUnsupported: + return fmt.Sprintf("%s(PrimaryNetworkTypeUnsupported network=%q: the referenced Network is not an L2 bridge network; only l2Bridge networks are supported)", prefix, i.Network) + case planbase.CalicoIssuePrimaryDataplaneNotBPF: + return fmt.Sprintf("%s(PrimaryDataplaneNotBPF network=%q: the destination Calico install is not running the BPF dataplane; L2 networks require FelixConfiguration bpfEnabled: true)", prefix, i.Network) + case planbase.CalicoIssuePrimaryNetworkHasNoL2Bridge: + return fmt.Sprintf("%s(PrimaryNetworkHasNoL2Bridge network=%q)", prefix, i.Network) + case planbase.CalicoIssuePrimaryNetworkHasNoVLANs: + return fmt.Sprintf("%s(PrimaryNetworkHasNoVLANs network=%q)", prefix, i.Network) + case planbase.CalicoIssuePrimaryVLANRequired: + return fmt.Sprintf("%s(PrimaryVLANRequired network=%q: calico.network is set but calico.vlan is not; an explicit VLAN is required)", prefix, i.Network) + case planbase.CalicoIssuePrimaryVLANNotInNetwork: + return fmt.Sprintf("%s(PrimaryVLANNotInNetwork network=%q vlan=%d)", prefix, i.Network, i.VLAN) + case planbase.CalicoIssuePrimaryNoEligibleIPPool: + if i.IP != "" { + return fmt.Sprintf("%s(PrimaryNoEligibleIPPool ip=%s network=%q vlan=%d)", prefix, i.IP, i.Network, i.VLAN) + } + return fmt.Sprintf("%s(PrimaryNoEligibleIPPool network=%q vlan=%d)", prefix, i.Network, i.VLAN) + case planbase.CalicoIssuePrimaryIPNotInSubnet: + return fmt.Sprintf("%s(PrimaryIPNotInSubnet ip=%s network=%q vlan=%d)", prefix, i.IP, i.Network, i.VLAN) + case planbase.CalicoIssuePrimaryTooManyIPs: + return fmt.Sprintf("%s(PrimaryTooManyIPs ips=%s: Calico static IP preservation supports at most one IPv4 per interface)", prefix, i.IP) + case planbase.CalicoIssuePrimaryFieldsMisplaced: + return fmt.Sprintf("%s(PrimaryFieldsMisplaced: calico block set on a non-pod entry, calico.vlan without calico.network, or multiple calico-flagged entries)", prefix) + case planbase.CalicoIssuePrimaryStaticIPsNotPreserved: + return fmt.Sprintf("%s(PrimaryStaticIPsNotPreserved: preserveStaticIPs is false; DHCP-configured guests will pick up the Calico-assigned IP via the veth, static-IP guests may have a divergent in-guest IP)", prefix) + } + return fmt.Sprintf("%s(%s)", prefix, i.Kind) +} + // calicoNADIssueDetail formats a per-NAD detail phrase for the plan-level // CalicoNetworkInvalid condition's Message: e.g. // "default/foo (NetworkNotFound network=\"calico-vlan\")". @@ -1993,14 +2162,20 @@ func calicoNADIssueDetail(i planbase.CalicoNADIssue) string { return fmt.Sprintf("%s (NADUnreadable)", i.NAD.String()) case planbase.CalicoIssueNetworkNotFound: return fmt.Sprintf("%s (NetworkNotFound network=%q)", i.NAD.String(), i.Network) + case planbase.CalicoIssueNetworkCRDAbsent: + return fmt.Sprintf("%s (NetworkCRDAbsent network=%q: destination Calico install does not ship the projectcalico.org/v3 Network CRD)", i.NAD.String(), i.Network) + case planbase.CalicoIssueNetworkTypeUnsupported: + return fmt.Sprintf("%s (NetworkTypeUnsupported network=%q: the referenced Network is not an L2 bridge network; only l2Bridge networks are supported)", i.NAD.String(), i.Network) + case planbase.CalicoIssueDataplaneNotBPF: + return fmt.Sprintf("%s (DataplaneNotBPF: the destination Calico install is not running the BPF dataplane; L2 networks require FelixConfiguration bpfEnabled: true)", i.NAD.String()) case planbase.CalicoIssueNetworkHasNoL2Bridge: return fmt.Sprintf("%s (NetworkHasNoL2Bridge network=%q)", i.NAD.String(), i.Network) case planbase.CalicoIssueNetworkHasNoVLANs: return fmt.Sprintf("%s (NetworkHasNoVLANs network=%q)", i.NAD.String(), i.Network) case planbase.CalicoIssueVLANNotInNetwork: return fmt.Sprintf("%s (VLANNotInNetwork network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) - case planbase.CalicoIssueVLANAmbiguous: - return fmt.Sprintf("%s (VLANAmbiguous network=%q)", i.NAD.String(), i.Network) + case planbase.CalicoIssueVLANRequired: + return fmt.Sprintf("%s (VLANRequired network=%q: the NAD references a Calico Network but names no VLAN; an explicit VLAN is required)", i.NAD.String(), i.Network) case planbase.CalicoIssueVLANHasNoIPPool: return fmt.Sprintf("%s (VLANHasNoIPPool network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) case planbase.CalicoIssueNADMissingNetwork: diff --git a/pkg/controller/plan/validation_test.go b/pkg/controller/plan/validation_test.go index 380caaf459..7e1483d1ad 100644 --- a/pkg/controller/plan/validation_test.go +++ b/pkg/controller/plan/validation_test.go @@ -117,6 +117,55 @@ var _ = ginkgo.Describe("Plan Validations", func() { }) }) + ginkgo.Describe("validateNetworkMap preserveStaticIPs warning", func() { + // The NetMapPreservingIPsOnPodNetwork Warn fires when static IPs + // are preserved onto a plain pod-network mapping (masquerade drops + // the IP). A calico-flagged pod entry preserves the IP — that is + // the feature — so it must not trip the warning. + setup := func(entries []api.NetworkPair) *api.Plan { + source := createProvider(sourceName, sourceNamespace, "https://source", api.VSphere, &core.ObjectReference{}) + destination := createProvider(destName, destNamespace, "", api.OpenShift, &core.ObjectReference{}) + plan := createPlan(testPlanName, testNamespace, source, destination) + plan.Spec.PreserveStaticIPs = true + netMap := &api.NetworkMap{ + ObjectMeta: meta.ObjectMeta{Name: "test-netmap", Namespace: testNamespace}, + Spec: api.NetworkMapSpec{Map: entries}, + } + netMap.Status.Conditions.SetCondition(libcnd.Condition{Type: libcnd.Ready, Status: libcnd.True}) + plan.Spec.Map.Network = core.ObjectReference{Name: "test-netmap", Namespace: testNamespace} + reconciler = createFakeReconciler(plan, source, destination, netMap) + return plan + } + + ginkgo.It("warns for a plain type: pod entry", func() { + plan := setup([]api.NetworkPair{ + {Destination: api.DestinationNetwork{Type: Pod}}, + }) + err := reconciler.validateNetworkMap(plan) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(plan.Status.HasCondition(NetMapPreservingIPsOnPodNetwork)).To(gomega.BeTrue()) + }) + + ginkgo.It("does not warn for a calico-flagged pod entry", func() { + plan := setup([]api.NetworkPair{ + {Destination: api.DestinationNetwork{Type: Pod, Calico: &api.CalicoDestination{}}}, + }) + err := reconciler.validateNetworkMap(plan) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(plan.Status.HasCondition(NetMapPreservingIPsOnPodNetwork)).To(gomega.BeFalse()) + }) + + ginkgo.It("still warns when a plain pod entry coexists with a calico-flagged one", func() { + plan := setup([]api.NetworkPair{ + {Destination: api.DestinationNetwork{Type: Pod, Calico: &api.CalicoDestination{}}}, + {Destination: api.DestinationNetwork{Type: Pod}}, + }) + err := reconciler.validateNetworkMap(plan) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(plan.Status.HasCondition(NetMapPreservingIPsOnPodNetwork)).To(gomega.BeTrue()) + }) + }) + ginkgo.Describe("GuestToolsIssue aggregation", func() { var ( mockValidator *mockGuestToolsValidator diff --git a/pkg/lib/client/calico/felixconfig.go b/pkg/lib/client/calico/felixconfig.go new file mode 100644 index 0000000000..4e05735dd0 --- /dev/null +++ b/pkg/lib/client/calico/felixconfig.go @@ -0,0 +1,53 @@ +package calico + +import ( + "context" + "fmt" + + k8serr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// FelixConfigurationGVK is the GroupVersionKind of projectcalico.org/v3 +// FelixConfiguration. +var FelixConfigurationGVK = schema.GroupVersionKind{ + Group: "projectcalico.org", + Version: "v3", + Kind: "FelixConfiguration", +} + +// felixConfigurationName is the name of the cluster-wide FelixConfiguration. +const felixConfigurationName = "default" + +// bpfEnabledField is the FelixConfiguration spec field that switches Felix to +// the BPF dataplane. This is the single place the field name appears — if the +// canonical name turns out to differ, correcting this constant is the fix. +const bpfEnabledField = "bpfEnabled" + +// GetBPFEnabled reports whether the destination Calico install runs the BPF +// dataplane, by reading spec.bpfEnabled of the cluster-scoped +// FelixConfiguration named "default". Felix defaults to the non-BPF +// dataplane, so "not enabled" is reported when the field is false or absent, +// when no "default" FelixConfiguration exists, and when the API server does +// not know the FelixConfiguration kind at all. Any other GET failure is +// returned as an error. +func GetBPFEnabled(ctx context.Context, c client.Client) (bool, error) { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(FelixConfigurationGVK) + err := c.Get(ctx, client.ObjectKey{Name: felixConfigurationName}, u) + switch { + case err == nil: + case meta.IsNoMatchError(err) || k8serr.IsNotFound(err): + return false, nil + default: + return false, err + } + enabled, _, err := unstructured.NestedBool(u.Object, "spec", bpfEnabledField) + if err != nil { + return false, fmt.Errorf("parse spec.%s: %w", bpfEnabledField, err) + } + return enabled, nil +} diff --git a/pkg/lib/client/calico/felixconfig_test.go b/pkg/lib/client/calico/felixconfig_test.go new file mode 100644 index 0000000000..5f4c0a6867 --- /dev/null +++ b/pkg/lib/client/calico/felixconfig_test.go @@ -0,0 +1,129 @@ +package calico + +import ( + "context" + "errors" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +// makeFelixConfiguration builds an unstructured projectcalico.org/v3 +// FelixConfiguration named "default" with the given spec map. +func makeFelixConfiguration(spec map[string]interface{}) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(FelixConfigurationGVK) + u.SetName(felixConfigurationName) + if spec != nil { + _ = unstructured.SetNestedField(u.Object, spec, "spec") + } + return u +} + +func newFelixFakeClientWith(objs ...runtime.Object) client.Client { + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(FelixConfigurationGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(FelixConfigurationGVK.GroupVersion().WithKind("FelixConfigurationList"), &unstructured.UnstructuredList{}) + return fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build() +} + +func TestGetBPFEnabled_True(t *testing.T) { + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "bpfEnabled": true, + })) + got, err := GetBPFEnabled(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got { + t.Error("got false, want true") + } +} + +func TestGetBPFEnabled_False(t *testing.T) { + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "bpfEnabled": false, + })) + got, err := GetBPFEnabled(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got { + t.Error("got true, want false") + } +} + +func TestGetBPFEnabled_FieldAbsent(t *testing.T) { + // bpfEnabled defaults to false when the field is not set. + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "logSeverityScreen": "Info", + })) + got, err := GetBPFEnabled(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got { + t.Error("got true, want false") + } +} + +func TestGetBPFEnabled_DefaultNotFound(t *testing.T) { + // No "default" FelixConfiguration: Felix runs with defaults, and the + // default dataplane is not BPF. Per-node configurations don't count. + perNode := makeFelixConfiguration(map[string]interface{}{"bpfEnabled": true}) + perNode.SetName("node.worker-1") + c := newFelixFakeClientWith(perNode) + got, err := GetBPFEnabled(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got { + t.Error("got true, want false") + } +} + +func TestGetBPFEnabled_KindAbsent(t *testing.T) { + // The API server does not know the FelixConfiguration kind. Treated as + // "not BPF", not as an error. + c := fake.NewClientBuilder(). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, _ client.WithWatch, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error { + return &meta.NoKindMatchError{GroupKind: FelixConfigurationGVK.GroupKind()} + }, + }).Build() + got, err := GetBPFEnabled(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got { + t.Error("got true, want false") + } +} + +func TestGetBPFEnabled_BadFieldType(t *testing.T) { + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "bpfEnabled": "yes", + })) + if _, err := GetBPFEnabled(context.Background(), c); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestGetBPFEnabled_TransientError(t *testing.T) { + // Any GET failure other than NotFound / kind-absent must propagate. + boom := errors.New("connection refused") + c := fake.NewClientBuilder(). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, _ client.WithWatch, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error { + return boom + }, + }).Build() + if _, err := GetBPFEnabled(context.Background(), c); !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } +} diff --git a/pkg/lib/client/calico/ippool.go b/pkg/lib/client/calico/ippool.go index 8fd7815a7f..74d8c9e378 100644 --- a/pkg/lib/client/calico/ippool.go +++ b/pkg/lib/client/calico/ippool.go @@ -17,10 +17,29 @@ var IPPoolGVK = schema.GroupVersionKind{ Kind: "IPPool", } +// AllowedUseL2Workload is the value Calico uses in spec.allowedUses to mark an +// IPPool as usable for L2 workloads (i.e. attached via a Calico Network CR's +// l2Bridge VLAN). An IPPool without L2Workload in its allowedUses (including +// the default-when-absent ["Workload","Tunnel"]) is not eligible for L2 attach. +const AllowedUseL2Workload = "L2Workload" + +// AllowedUseWorkload is the value Calico uses in spec.allowedUses to mark an +// IPPool as a source of workload (pod) address assignments. Calico IPAM only +// assigns workload addresses from pools that permit this use (explicitly, or +// via the default-when-absent ["Workload","Tunnel"]). +const AllowedUseWorkload = "Workload" + // IPPool is a parsed view of projectcalico.org/v3 IPPool. +// +// AllowedUses distinguishes "absent" (nil) from "explicitly empty" ([]string{}). +// When the spec.allowedUses field is absent in the manifest, Calico applies the +// default ["Workload","Tunnel"] — L3-usable but not L2Workload-usable. The +// helpers in this file treat nil and explicit-empty accordingly. type IPPool struct { - Name string - CIDR string + Name string + CIDR string + Disabled bool + AllowedUses []string } // ListIPPools lists all projectcalico.org/v3 IPPool CRs on the cluster. @@ -37,51 +56,102 @@ func ListIPPools(ctx context.Context, c client.Client) ([]IPPool, error) { if err != nil { return nil, fmt.Errorf("ippool %q: parse spec.cidr: %w", u.GetName(), err) } - pools = append(pools, IPPool{Name: u.GetName(), CIDR: cidr}) + disabled, _, err := unstructured.NestedBool(u.Object, "spec", "disabled") + if err != nil { + return nil, fmt.Errorf("ippool %q: parse spec.disabled: %w", u.GetName(), err) + } + // nil vs explicit-empty distinction is load-bearing for the L3/L2 helpers. + var allowedUses []string + rawUses, found, err := unstructured.NestedSlice(u.Object, "spec", "allowedUses") + if err != nil { + return nil, fmt.Errorf("ippool %q: parse spec.allowedUses: %w", u.GetName(), err) + } + if found { + allowedUses = make([]string, 0, len(rawUses)) + for _, v := range rawUses { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("ippool %q: spec.allowedUses contains non-string entry %T", u.GetName(), v) + } + allowedUses = append(allowedUses, s) + } + } + pools = append(pools, IPPool{ + Name: u.GetName(), + CIDR: cidr, + Disabled: disabled, + AllowedUses: allowedUses, + }) } return pools, nil } -// HasEligiblePool reports whether at least one pool's CIDR is contained -// within at least one of vlanSubnets. When false, Calico IPAM has nothing -// to allocate from for this VLAN and CNI ADD will fail regardless of any -// per-pod IP request. -func HasEligiblePool(pools []IPPool, vlanSubnets []string) bool { - for i := range pools { - if poolContainedInAnyVLANSubnet(pools[i].CIDR, vlanSubnets) { +func ipInCIDR(ip net.IP, cidr string) bool { + _, n, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + return n.Contains(ip) +} + +// isL3Eligible reports whether a pool can serve as a source of IP allocations +// for a Calico-primary pod without L2 attach (Case A — implicit L3 IPAM). +// A nil AllowedUses means the field was absent and the Calico default +// ["Workload","Tunnel"] applies (workload-assignable). A non-nil slice is +// eligible iff it contains "Workload" — Calico IPAM only assigns workload +// addresses from pools that permit that use, so a Tunnel- or +// LoadBalancer-only pool (and an explicit empty slice) is not eligible. +func isL3Eligible(p *IPPool) bool { + if p.Disabled { + return false + } + if p.AllowedUses == nil { + return true + } + return containsAllowedUse(p, AllowedUseWorkload) +} + +// containsAllowedUse reports whether the pool's AllowedUses contains the named +// use. Returns false for both nil and explicit-empty AllowedUses — neither +// includes any specific use (the Calico-default nil expands to +// ["Workload","Tunnel"], which does not include L2Workload). +func containsAllowedUse(p *IPPool, use string) bool { + for _, u := range p.AllowedUses { + if u == use { return true } } return false } -// EligiblePools returns the subset of pools whose CIDR is contained within -// at least one vlanSubnet. Callers cache the result so per-IP membership -// checks don't repeat the containment filter. -func EligiblePools(pools []IPPool, vlanSubnets []string) []IPPool { +// L3EligiblePools returns the subset of pools usable for Case A — implicit +// L3 IPAM — Calico-primary attach. A pool is L3-eligible when it is not +// disabled and its allowedUses contains "Workload" (or is absent, implying +// the Calico default ["Workload","Tunnel"]). +func L3EligiblePools(pools []IPPool) []IPPool { out := make([]IPPool, 0, len(pools)) for i := range pools { - if poolContainedInAnyVLANSubnet(pools[i].CIDR, vlanSubnets) { + if isL3Eligible(&pools[i]) { out = append(out, pools[i]) } } return out } -// EligiblePoolForIP returns the first pool that (a) contains the given IP and -// (b) is itself contained within at least one VLAN subnet. Returns nil when -// no pool qualifies. -func EligiblePoolForIP(pools []IPPool, ip string, vlanSubnets []string) *IPPool { +// L3EligiblePoolForIP returns the first L3-eligible pool that contains the +// given IP. Returns nil when no pool qualifies. L3 eligibility is intrinsic +// to the pool (no VLAN-subnet containment required). +func L3EligiblePoolForIP(pools []IPPool, ip string) *IPPool { parsedIP := net.ParseIP(ip) if parsedIP == nil { return nil } for i := range pools { p := &pools[i] - if !ipInCIDR(parsedIP, p.CIDR) { + if !isL3Eligible(p) { continue } - if !poolContainedInAnyVLANSubnet(p.CIDR, vlanSubnets) { + if !ipInCIDR(parsedIP, p.CIDR) { continue } return p @@ -89,12 +159,52 @@ func EligiblePoolForIP(pools []IPPool, ip string, vlanSubnets []string) *IPPool return nil } -func ipInCIDR(ip net.IP, cidr string) bool { - _, n, err := net.ParseCIDR(cidr) - if err != nil { - return false +// L2WorkloadEligiblePools returns the subset of pools usable for the L2-attach +// path (Case C) — attach via a named Network CR. A pool is L2Workload-eligible +// when it is not disabled, its allowedUses contains "L2Workload", and its CIDR +// is fully contained in at least one of the matched VLAN's subnets. +func L2WorkloadEligiblePools(pools []IPPool, vlanSubnets []string) []IPPool { + out := make([]IPPool, 0, len(pools)) + for i := range pools { + p := &pools[i] + if p.Disabled { + continue + } + if !containsAllowedUse(p, AllowedUseL2Workload) { + continue + } + if !poolContainedInAnyVLANSubnet(p.CIDR, vlanSubnets) { + continue + } + out = append(out, pools[i]) } - return n.Contains(ip) + return out +} + +// L2WorkloadEligiblePoolForIP returns the first L2Workload-eligible pool that +// contains the given IP. Returns nil when no pool qualifies. +func L2WorkloadEligiblePoolForIP(pools []IPPool, ip string, vlanSubnets []string) *IPPool { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return nil + } + for i := range pools { + p := &pools[i] + if p.Disabled { + continue + } + if !containsAllowedUse(p, AllowedUseL2Workload) { + continue + } + if !ipInCIDR(parsedIP, p.CIDR) { + continue + } + if !poolContainedInAnyVLANSubnet(p.CIDR, vlanSubnets) { + continue + } + return p + } + return nil } // poolContainedInAnyVLANSubnet reports whether the pool CIDR is fully diff --git a/pkg/lib/client/calico/ippool_test.go b/pkg/lib/client/calico/ippool_test.go index 6c9f2fd0ba..6af0e8b82e 100644 --- a/pkg/lib/client/calico/ippool_test.go +++ b/pkg/lib/client/calico/ippool_test.go @@ -17,6 +17,24 @@ func makeIPPool(name, cidr string) *unstructured.Unstructured { return u } +// makeIPPoolWithFields builds an IPPool with optional disabled/allowedUses. +// allowedUsesPresent distinguishes "field absent" (nil) from "field present +// but empty" ([]) — the parser handles these differently. +func makeIPPoolWithFields(name, cidr string, disabled bool, allowedUsesPresent bool, allowedUses []string) *unstructured.Unstructured { + u := makeIPPool(name, cidr) + if disabled { + _ = unstructured.SetNestedField(u.Object, true, "spec", "disabled") + } + if allowedUsesPresent { + ifaces := make([]interface{}, len(allowedUses)) + for i, v := range allowedUses { + ifaces[i] = v + } + _ = unstructured.SetNestedSlice(u.Object, ifaces, "spec", "allowedUses") + } + return u +} + func newFakeClientWithIPPools(objs ...runtime.Object) *fake.ClientBuilder { scheme := runtime.NewScheme() scheme.AddKnownTypeWithName(IPPoolGVK, &unstructured.Unstructured{}) @@ -65,141 +83,159 @@ func TestListIPPools_Multiple(t *testing.T) { } } -func TestHasEligiblePool(t *testing.T) { +func TestListIPPools_ParsesDisabledAndAllowedUses(t *testing.T) { + c := newFakeClientWithIPPools( + makeIPPool("plain", "10.100.0.0/24"), + makeIPPoolWithFields("disabled-pool", "10.101.0.0/24", true, false, nil), + makeIPPoolWithFields("explicit-empty-uses", "10.102.0.0/24", false, true, []string{}), + makeIPPoolWithFields("workload-only", "10.103.0.0/24", false, true, []string{"Workload"}), + makeIPPoolWithFields("l2-only", "10.104.0.0/24", false, true, []string{"L2Workload"}), + makeIPPoolWithFields("mixed-uses", "10.105.0.0/24", false, true, []string{"Workload", "L2Workload"}), + ).Build() + + pools, err := ListIPPools(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + byName := map[string]IPPool{} + for _, p := range pools { + byName[p.Name] = p + } + + // Absent field → nil; present-but-empty → []string{}; populated → slice. + if got := byName["plain"]; got.Disabled || got.AllowedUses != nil { + t.Errorf("plain pool: Disabled=%v, AllowedUses=%v (want false, nil)", got.Disabled, got.AllowedUses) + } + if got := byName["disabled-pool"]; !got.Disabled { + t.Errorf("disabled-pool: Disabled=%v (want true)", got.Disabled) + } + if got := byName["explicit-empty-uses"]; got.AllowedUses == nil || len(got.AllowedUses) != 0 { + t.Errorf("explicit-empty-uses: AllowedUses=%v (want non-nil empty slice)", got.AllowedUses) + } + if got := byName["workload-only"]; len(got.AllowedUses) != 1 || got.AllowedUses[0] != "Workload" { + t.Errorf("workload-only: AllowedUses=%v (want [Workload])", got.AllowedUses) + } + if got := byName["l2-only"]; len(got.AllowedUses) != 1 || got.AllowedUses[0] != AllowedUseL2Workload { + t.Errorf("l2-only: AllowedUses=%v (want [L2Workload])", got.AllowedUses) + } + if got := byName["mixed-uses"]; len(got.AllowedUses) != 2 { + t.Errorf("mixed-uses: AllowedUses=%v (want 2 entries)", got.AllowedUses) + } +} + +func TestL3EligiblePools(t *testing.T) { tests := []struct { - name string - pools []IPPool - vlanSubnets []string - want bool + name string + pool IPPool + want bool // whether the pool should appear in L3EligiblePools output }{ - { - name: "PoolContainedInVLANSubnet", - pools: []IPPool{{CIDR: "10.100.0.0/24"}}, - vlanSubnets: []string{"10.100.0.0/24"}, - want: true, - }, - { - name: "PoolStrictlyContainedInVLANSubnet", - pools: []IPPool{{CIDR: "10.100.0.128/25"}}, - vlanSubnets: []string{"10.100.0.0/24"}, - want: true, - }, - { - name: "PoolLargerThanVLANSubnet", - pools: []IPPool{{CIDR: "10.0.0.0/8"}}, - vlanSubnets: []string{"10.100.0.0/24"}, - want: false, - }, - { - name: "PoolOnDifferentNetwork", - pools: []IPPool{{CIDR: "10.244.0.0/16"}}, - vlanSubnets: []string{"10.100.0.0/24"}, - want: false, - }, - { - name: "AtLeastOnePoolMatches", - pools: []IPPool{{CIDR: "10.244.0.0/16"}, {CIDR: "10.100.0.0/24"}}, - vlanSubnets: []string{"10.100.0.0/24"}, - want: true, - }, - { - name: "MultipleVLANSubnets", - pools: []IPPool{{CIDR: "10.200.0.0/24"}}, - vlanSubnets: []string{"10.100.0.0/24", "10.200.0.0/24"}, - want: true, - }, - { - name: "NoPools", - pools: nil, - vlanSubnets: []string{"10.100.0.0/24"}, - want: false, - }, + {"AbsentAllowedUses_NotDisabled", IPPool{Name: "p", CIDR: "10.0.0.0/8", AllowedUses: nil}, true}, + {"ExplicitEmptyAllowedUses", IPPool{Name: "p", CIDR: "10.0.0.0/8", AllowedUses: []string{}}, false}, + {"WorkloadOnly", IPPool{Name: "p", CIDR: "10.0.0.0/8", AllowedUses: []string{AllowedUseWorkload}}, true}, + {"TunnelOnly", IPPool{Name: "p", CIDR: "10.0.0.0/8", AllowedUses: []string{"Tunnel"}}, false}, + {"LoadBalancerOnly", IPPool{Name: "p", CIDR: "10.0.0.0/8", AllowedUses: []string{"LoadBalancer"}}, false}, + {"L2WorkloadOnly", IPPool{Name: "p", CIDR: "10.0.0.0/8", AllowedUses: []string{AllowedUseL2Workload}}, false}, + {"WorkloadPlusL2Workload", IPPool{Name: "p", CIDR: "10.0.0.0/8", AllowedUses: []string{AllowedUseWorkload, AllowedUseL2Workload}}, true}, + {"Disabled_AbsentUses", IPPool{Name: "p", CIDR: "10.0.0.0/8", Disabled: true, AllowedUses: nil}, false}, + {"Disabled_WorkloadOnly", IPPool{Name: "p", CIDR: "10.0.0.0/8", Disabled: true, AllowedUses: []string{"Workload"}}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := HasEligiblePool(tt.pools, tt.vlanSubnets); got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) + out := L3EligiblePools([]IPPool{tt.pool}) + got := len(out) == 1 + if got != tt.want { + t.Errorf("got eligible=%v, want %v (pool=%+v)", got, tt.want, tt.pool) } }) } } -func TestEligiblePoolForIP(t *testing.T) { +func TestL3EligiblePoolForIP(t *testing.T) { pools := []IPPool{ - {Name: "default-ipv4-ippool", CIDR: "10.244.0.0/16"}, // cluster default, not in VLAN - {Name: "vlan100-pool", CIDR: "10.100.0.0/24"}, // matches VLAN 100 subnet exactly - {Name: "vlan100-subpool", CIDR: "10.100.0.128/25"}, // contained within VLAN 100 subnet - {Name: "vlan200-pool", CIDR: "10.200.0.0/24"}, // matches VLAN 200 subnet + {Name: "default-pool", CIDR: "10.244.0.0/16"}, // L3-eligible (nil allowedUses) + {Name: "disabled-pool", CIDR: "10.100.0.0/24", Disabled: true}, // disabled + {Name: "l2-only-pool", CIDR: "10.200.0.0/24", AllowedUses: []string{"L2Workload"}}, // not L3-eligible + } + tests := []struct { + name string + ip string + wantPool string + }{ + {"IP in L3-eligible pool", "10.244.5.1", "default-pool"}, + {"IP in disabled pool only", "10.100.0.5", ""}, + {"IP in L2-only pool", "10.200.0.5", ""}, + {"IP outside all pools", "192.168.1.1", ""}, + {"Invalid IP", "not-an-ip", ""}, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := L3EligiblePoolForIP(pools, tt.ip) + if tt.wantPool == "" { + if got != nil { + t.Errorf("got %+v, want nil", got) + } + return + } + if got == nil || got.Name != tt.wantPool { + t.Errorf("got %v, want pool %q", got, tt.wantPool) + } + }) + } +} +func TestL2WorkloadEligiblePools(t *testing.T) { + vlanSubnets := []string{"10.100.0.0/24"} tests := []struct { - name string - ip string - vlanSubnets []string - wantPool string // empty means expect nil + name string + pool IPPool + want bool }{ - { - name: "IPInExactlyMatchingPool", - ip: "10.100.0.5", - vlanSubnets: []string{"10.100.0.0/24"}, - wantPool: "vlan100-pool", - }, - { - name: "IPInSubpoolWithinVLAN", - ip: "10.100.0.200", - vlanSubnets: []string{"10.100.0.0/24"}, - // Either vlan100-pool or vlan100-subpool covers it; first match wins. - wantPool: "vlan100-pool", - }, - { - name: "IPNotInVLANSubnet", - ip: "10.244.5.1", // in cluster default pool but VLAN list excludes it - vlanSubnets: []string{"10.100.0.0/24"}, - wantPool: "", - }, - { - name: "PoolNotContainedInVLAN", - ip: "10.100.0.5", - vlanSubnets: []string{"10.100.0.0/26"}, // VLAN is smaller than vlan100-pool - wantPool: "", - }, - { - name: "MultipleVLANSubnets", - ip: "10.200.0.5", - vlanSubnets: []string{"10.100.0.0/24", "10.200.0.0/24"}, - wantPool: "vlan200-pool", - }, - { - name: "InvalidIP", - ip: "not-an-ip", - vlanSubnets: []string{"10.100.0.0/24"}, - wantPool: "", - }, - { - name: "NoPools", - ip: "10.100.0.5", - vlanSubnets: []string{"10.100.0.0/24"}, - wantPool: "", // pools list is empty in this branch by override below - }, + {"L2Workload + contained CIDR", IPPool{Name: "p", CIDR: "10.100.0.0/24", AllowedUses: []string{"L2Workload"}}, true}, + {"L2Workload mixed + contained", IPPool{Name: "p", CIDR: "10.100.0.0/25", AllowedUses: []string{"Workload", "L2Workload"}}, true}, + {"L2Workload but CIDR outside subnet", IPPool{Name: "p", CIDR: "10.200.0.0/24", AllowedUses: []string{"L2Workload"}}, false}, + {"L2Workload but pool wider than subnet", IPPool{Name: "p", CIDR: "10.0.0.0/8", AllowedUses: []string{"L2Workload"}}, false}, + {"Workload only", IPPool{Name: "p", CIDR: "10.100.0.0/24", AllowedUses: []string{"Workload"}}, false}, + {"Absent AllowedUses", IPPool{Name: "p", CIDR: "10.100.0.0/24"}, false}, + {"Disabled L2Workload", IPPool{Name: "p", CIDR: "10.100.0.0/24", Disabled: true, AllowedUses: []string{"L2Workload"}}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - poolList := pools - if tt.name == "NoPools" { - poolList = nil + out := L2WorkloadEligiblePools([]IPPool{tt.pool}, vlanSubnets) + got := len(out) == 1 + if got != tt.want { + t.Errorf("got eligible=%v, want %v (pool=%+v)", got, tt.want, tt.pool) } - got := EligiblePoolForIP(poolList, tt.ip, tt.vlanSubnets) + }) + } +} + +func TestL2WorkloadEligiblePoolForIP(t *testing.T) { + pools := []IPPool{ + {Name: "l2-vlan100", CIDR: "10.100.0.0/24", AllowedUses: []string{"L2Workload"}}, + {Name: "workload-vlan100", CIDR: "10.100.0.0/24", AllowedUses: []string{"Workload"}}, + {Name: "l2-disabled", CIDR: "10.101.0.0/24", Disabled: true, AllowedUses: []string{"L2Workload"}}, + } + vlanSubnets := []string{"10.100.0.0/24"} + tests := []struct { + name string + ip string + wantPool string + }{ + {"L2 pool covers IP", "10.100.0.5", "l2-vlan100"}, + {"Only Workload covers (not L2)", "10.100.0.5", "l2-vlan100"}, // l2-vlan100 wins by order + {"IP outside all L2-eligible", "10.101.0.5", ""}, // l2-disabled is disabled + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := L2WorkloadEligiblePoolForIP(pools, tt.ip, vlanSubnets) if tt.wantPool == "" { if got != nil { - t.Errorf("got pool %+v, want nil", got) + t.Errorf("got %+v, want nil", got) } return } - if got == nil { - t.Fatalf("got nil pool, want %q", tt.wantPool) - } - if got.Name != tt.wantPool { - t.Errorf("got pool %q, want %q", got.Name, tt.wantPool) + if got == nil || got.Name != tt.wantPool { + t.Errorf("got %v, want pool %q", got, tt.wantPool) } }) } diff --git a/pkg/lib/client/calico/network.go b/pkg/lib/client/calico/network.go index c7e1334e03..d970b4a6b3 100644 --- a/pkg/lib/client/calico/network.go +++ b/pkg/lib/client/calico/network.go @@ -29,9 +29,14 @@ type L2BridgeSpec struct { } // Network is a thin parsed view of projectcalico.org/v3 Network. +// +// spec is a strict one-of: an l2Bridge Network carries VLANs and is the type +// identity preservation supports; a vrf Network is routed (L3, no VLANs). +// IsVRF only classifies the Network — the VRF spec itself is not modelled. type Network struct { Name string L2Bridge *L2BridgeSpec // nil when the Network has no l2Bridge spec + IsVRF bool // true when the Network has a vrf spec } // GetNetwork fetches projectcalico.org/v3 Network/name from the destination @@ -48,6 +53,14 @@ func GetNetwork(ctx context.Context, c client.Client, name string) (*Network, er func parseNetwork(u *unstructured.Unstructured) (*Network, error) { n := &Network{Name: u.GetName()} + // Presence-only check: a vrf spec marks the Network as a VRF (routed) + // network, which the validators reject as unsupported. + _, isVRF, err := unstructured.NestedMap(u.Object, "spec", "vrf") + if err != nil { + return nil, fmt.Errorf("parse spec.vrf: %w", err) + } + n.IsVRF = isVRF + l2Bridge, found, err := unstructured.NestedMap(u.Object, "spec", "l2Bridge") if err != nil { return nil, fmt.Errorf("parse spec.l2Bridge: %w", err) diff --git a/pkg/lib/client/calico/network_test.go b/pkg/lib/client/calico/network_test.go index f51a1be50f..9c832f8ef2 100644 --- a/pkg/lib/client/calico/network_test.go +++ b/pkg/lib/client/calico/network_test.go @@ -75,6 +75,30 @@ func TestGetNetwork_NoL2Bridge(t *testing.T) { } } +func TestGetNetwork_VRF(t *testing.T) { + // A VRF (routed, L3) Network: classified via IsVRF, no l2Bridge parsed. + nw := makeNetwork("routed-net", map[string]interface{}{ + "vrf": map[string]interface{}{ + "hostConfig": []interface{}{ + map[string]interface{}{"nodeSelector": "all()"}, + }, + }, + }) + cb, _ := newFakeClientWith(nw) + c := cb.Build() + + got, err := GetNetwork(context.Background(), c, "routed-net") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got.IsVRF { + t.Error("IsVRF = false, want true") + } + if got.L2Bridge != nil { + t.Errorf("L2Bridge = %+v, want nil", got.L2Bridge) + } +} + func TestGetNetwork_SingleVLAN(t *testing.T) { nw := makeNetwork("vlan100", map[string]interface{}{ "l2Bridge": map[string]interface{}{ @@ -95,6 +119,9 @@ func TestGetNetwork_SingleVLAN(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if got.IsVRF { + t.Error("IsVRF = true, want false") + } if got.L2Bridge == nil { t.Fatal("L2Bridge = nil, want non-nil") } diff --git a/pkg/provider/ec2/controller/validator/noop.go b/pkg/provider/ec2/controller/validator/noop.go index 3da54d4d59..8649d9af4e 100644 --- a/pkg/provider/ec2/controller/validator/noop.go +++ b/pkg/provider/ec2/controller/validator/noop.go @@ -97,3 +97,26 @@ func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidati func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } + +// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry +// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — +// the feature is not supported on this provider in this release. +func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { + if r.Plan.Referenced.Map.Network == nil { + return planbase.CalicoPrimaryValidationResult{}, nil + } + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { + if pair.Destination.Calico != nil { + return planbase.CalicoPrimaryValidationResult{ + Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, + }, nil + } + } + return planbase.CalicoPrimaryValidationResult{}, nil +} + +// CalicoPrimaryIssues returns nil. The plan-level rejection in +// ValidateCalicoPrimary short-circuits before per-VM dispatch. +func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { + return nil, nil +} From 5c054ad4b869c5825ea467e8fba99e2d7b290448 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Thu, 16 Jul 2026 09:53:06 +0100 Subject: [PATCH 05/11] Accept VRF networks as multus destinations, with viability validation A NetworkMap multus entry may now point at a NAD referencing a Calico VRF network. The scoped MAC/IP identity annotations apply unchanged; preserved static IPs are validated against enabled Workload IPPools (VRFs have no VLANs or subnets). VRF networks remain unsupported on the pod-primary entry: a VRF-only primary would sever cluster DNS, the API server, and kubelet probes. Plans referencing a VRF network are checked for viability up front, since the platform validates little of it. Criticals: a non-nftables dataplane, kernel-reserved routing tables, and provable route-table collisions (with another VRF network on overlapping nodes, or with the FelixConfiguration's explicit routeTableRanges). Warnings: hostConfig coverage limited to selected nodes, unprovable route-table collisions, a VLAN named against a VRF network, missing ipv4_pools pinning on plans that assign fresh IPs, hostConfig entries without host interfaces, and no BGPPeer bound to the network (required for cross-node reachability in Local mode). RBAC gains read access to bgppeers. Co-Authored-By: Claude Fable 5 Signed-off-by: Alex O'Regan --- operator/.downstream_manifests | 1 + operator/.upstream_manifests | 1 + .../config/rbac/forklift-controller_role.yaml | 4 +- .../plan/adapter/base/calico_validation.go | 28 +- pkg/controller/plan/adapter/base/doc.go | 69 +- .../plan/adapter/vsphere/builder_test.go | 10 + .../plan/adapter/vsphere/validator.go | 332 +++++++- .../plan/adapter/vsphere/validator_test.go | 727 +++++++++++++++++- pkg/controller/plan/validation.go | 23 +- pkg/controller/provider/model/ocp/model.go | 24 + .../provider/model/ocp/model_test.go | 47 +- pkg/lib/client/calico/bgppeer.go | 49 ++ pkg/lib/client/calico/bgppeer_test.go | 108 +++ pkg/lib/client/calico/felixconfig.go | 100 ++- pkg/lib/client/calico/felixconfig_test.go | 126 +++ pkg/lib/client/calico/network.go | 125 ++- pkg/lib/client/calico/network_test.go | 186 ++++- 17 files changed, 1876 insertions(+), 84 deletions(-) create mode 100644 pkg/lib/client/calico/bgppeer.go create mode 100644 pkg/lib/client/calico/bgppeer_test.go diff --git a/operator/.downstream_manifests b/operator/.downstream_manifests index 1b4c0af1bb..c7ac1af002 100644 --- a/operator/.downstream_manifests +++ b/operator/.downstream_manifests @@ -11252,6 +11252,7 @@ rules: - networks - ippools - felixconfigurations + - bgppeers verbs: - get - list diff --git a/operator/.upstream_manifests b/operator/.upstream_manifests index 97afa6d0ed..274b6fc3bd 100644 --- a/operator/.upstream_manifests +++ b/operator/.upstream_manifests @@ -11252,6 +11252,7 @@ rules: - networks - ippools - felixconfigurations + - bgppeers verbs: - get - list diff --git a/operator/config/rbac/forklift-controller_role.yaml b/operator/config/rbac/forklift-controller_role.yaml index 8b1f5ca6fb..2d110522a7 100644 --- a/operator/config/rbac/forklift-controller_role.yaml +++ b/operator/config/rbac/forklift-controller_role.yaml @@ -249,13 +249,15 @@ rules: - watch # The Plan validator reads Calico Network and IPPool resources on the # destination cluster to validate L2-bridge NAD destinations and per-VM -# static IPs. +# static IPs, and BGPPeer resources to check that a VRF network's routes +# are distributed across nodes. - apiGroups: - projectcalico.org resources: - networks - ippools - felixconfigurations + - bgppeers verbs: - get - list diff --git a/pkg/controller/plan/adapter/base/calico_validation.go b/pkg/controller/plan/adapter/base/calico_validation.go index eafebed329..75f59fc1c8 100644 --- a/pkg/controller/plan/adapter/base/calico_validation.go +++ b/pkg/controller/plan/adapter/base/calico_validation.go @@ -10,12 +10,24 @@ import ( // Calico-referencing NAD after all resource-level validations have passed. // Per-VM checks read from this directly instead of re-fetching Network and // IPPool objects for every VM. +// +// The struct accommodates both Calico network flavours: +// - l2Bridge (IsVRF false): VLAN is the matched l2Bridge VLAN entry +// (subnets non-empty) and EligiblePools is the L2Workload-restricted +// pool set scoped to those subnets. +// - vrf (IsVRF true): routed L3 — the Network carries no VLANs or +// subnets, so VLAN is zero-value and EligiblePools is the L3-eligible +// (enabled, Workload-allowed) pool set. type ResolvedCalicoNAD struct { // Network is the Calico Network CR name referenced by the NAD. Network string - // VLAN is the resolved l2Bridge VLAN entry (subnets non-empty). + // IsVRF marks the referenced Network as a vrf (routed L3) network. + IsVRF bool + // VLAN is the resolved l2Bridge VLAN entry (zero-value when IsVRF). VLAN calicoclient.VLANEntry - // EligiblePools are the IPPools whose CIDRs overlap any subnet in VLAN. + // EligiblePools are the IPPools the per-VM IP check runs against: + // L2Workload pools within the VLAN's subnets for l2Bridge networks, + // L3-eligible pools for vrf networks. EligiblePools []calicoclient.IPPool } @@ -30,11 +42,23 @@ type CalicoValidationCache struct { // CalicoNADIssue is a resource-level Calico failure tied to a specific NAD // rather than a VM. Surfaced by ValidateCalicoNADs and rendered into the // plan-level CalicoNetworkInvalid condition. +// +// All fields are comparable types, so the struct compares with == (tests +// rely on that; the per-VM CalicoIssue type is additionally used as a map +// key, and this type stays parallel to it). type CalicoNADIssue struct { NAD types.NamespacedName Kind CalicoIssueKind Network string VLAN uint16 + // RouteTable is the offending kernel route table index for the VRF + // route-table issue kinds (VRFRouteTableReserved / VRFRouteTableConflict + // / VRFRouteTablePossibleConflict); zero otherwise. + RouteTable int64 + // ConflictsWith names the other VRF Network CR sharing RouteTable. + // Empty on a VRFRouteTableConflict means the index collides with the + // FelixConfiguration routeTableRanges rather than another Network. + ConflictsWith string } // CalicoValidationResult is the output of ValidateCalicoNADs: diff --git a/pkg/controller/plan/adapter/base/doc.go b/pkg/controller/plan/adapter/base/doc.go index 75dfa1b367..e4ec3bfe72 100644 --- a/pkg/controller/plan/adapter/base/doc.go +++ b/pkg/controller/plan/adapter/base/doc.go @@ -350,10 +350,68 @@ const ( // Network but the CRD cannot be queried — distinct from a missing CR, // which is CalicoIssueNetworkNotFound. CalicoIssueNetworkCRDAbsent CalicoIssueKind = "NetworkCRDAbsent" - // CalicoIssueNetworkTypeUnsupported the referenced Network CR is not an - // l2Bridge network (e.g. a VRF network). Only l2Bridge networks are - // supported for identity preservation today. - CalicoIssueNetworkTypeUnsupported CalicoIssueKind = "NetworkTypeUnsupported" + // CalicoIssueVRFVlanIgnored the NAD names a VLAN but the referenced + // Network is a VRF (routed L3) network. VLANs apply only to l2Bridge + // networks, so the value is ignored. Warn-level — validation of the + // reference continues. + CalicoIssueVRFVlanIgnored CalicoIssueKind = "VRFVlanIgnored" + // CalicoIssueVRFNodeScoped every spec.vrf.hostConfig entry on the + // referenced VRF Network carries a nodeSelector, so the network exists + // only on nodes those selectors match; a VM scheduled onto any other + // node fails to start. Warn-level: the selectors may well cover every + // node, but evaluating Calico's selector grammar is deliberately out + // of scope, so scoped-only hostConfig is reported as a caution rather + // than proven incomplete coverage. + CalicoIssueVRFNodeScoped CalicoIssueKind = "VRFNodeScoped" + // CalicoIssueVRFRouteTableReserved a hostConfig entry on the referenced + // VRF Network claims kernel route table 253, 254 or 255 — the kernel's + // own default/main/local tables, which a VRF must never take over. + CalicoIssueVRFRouteTableReserved CalicoIssueKind = "VRFRouteTableReserved" + // CalicoIssueVRFRouteTableConflict a routeTableIndex on the referenced + // VRF Network is provably claimed elsewhere: another VRF Network uses + // it with at least one of the two entries unscoped (an all-nodes entry + // overlaps any node set), or it falls inside an explicit + // FelixConfiguration routeTableRanges range. Calico documents such + // conflicts as able to cause network outages. + CalicoIssueVRFRouteTableConflict CalicoIssueKind = "VRFRouteTableConflict" + // CalicoIssueVRFRouteTablePossibleConflict another VRF Network claims + // the same routeTableIndex, but both entries carry nodeSelectors, so + // whether they land on the same node depends on which nodes the + // selectors match — unprovable without selector evaluation. Warn-level. + CalicoIssueVRFRouteTablePossibleConflict CalicoIssueKind = "VRFRouteTablePossibleConflict" + // CalicoIssueVRFDataplaneNotNftables a NAD references a VRF network but + // the destination Calico install is not running the nftables dataplane. + // VRF networking is supported only on nftables — BPF and iptables + // installs cannot honour it — so FelixConfiguration "default" must have + // nftablesMode Enabled and bpfEnabled off. nftablesMode "Auto" is also + // reported: the mode Felix actually picks cannot be verified from here. + // Emitted once per plan. + CalicoIssueVRFDataplaneNotNftables CalicoIssueKind = "VRFDataplaneNotNftables" + // CalicoIssueVRFPoolNotPinned the NAD references a VRF network but its + // IPAM config pins no ipv4_pools, and the plan does not preserve static + // IPs. Calico's IPAM is VRF-unaware; the documented convention pins the + // VRF's dedicated IPPool via ipam.ipv4_pools in the NAD. Without the + // pin, a freshly assigned address comes from whichever pool IPAM + // selects — likely one the VRF's tenant fabric cannot route back to. + // Warn-level, per NAD (the pin is a NAD property, not a Network one). + // Deliberately not emitted when the plan preserves static IPs: the + // addresses are then explicit via ipAddrs and already validated against + // pools per-VM; the risk exists only for freshly-assigned addresses. + CalicoIssueVRFPoolNotPinned CalicoIssueKind = "VRFPoolNotPinned" + // CalicoIssueVRFNoBGPPeer no BGPPeer with spec.network naming the + // referenced VRF Network exists. VRF networks ship with + // inClusterMode: Local only — Felix programs a node's own pod routes; + // routes to pods on other nodes exist only when such a BGPPeer + // distributes them. Without one, VMs placed on different nodes silently + // cannot reach each other inside the VRF. Warn-level, once per + // referenced Network. Also emitted when the BGPPeer kind is unknown to + // the apiserver: no peer can be bound in that state either. + CalicoIssueVRFNoBGPPeer CalicoIssueKind = "VRFNoBGPPeer" + // CalicoIssueVRFNoHostInterfaces a spec.vrf.hostConfig entry on the + // referenced VRF Network names no hostInterfaces. Pods (and thus VMs) + // on the nodes that entry matches have no path off their node in the + // VRF. Warn-level, once per referenced Network. + CalicoIssueVRFNoHostInterfaces CalicoIssueKind = "VRFNoHostInterfaces" // CalicoIssueNetworkHasNoL2Bridge Network CR existed but had no L2Bridge field spec'd. CalicoIssueNetworkHasNoL2Bridge CalicoIssueKind = "NetworkHasNoL2Bridge" // CalicoIssueNetworkHasNoVLANs Network CR's L2Bridge had an empty vlans list (no VLAN to select). @@ -470,7 +528,8 @@ type CalicoIssue struct { Kind CalicoIssueKind // Network is the Calico Network CR name reference. Network string - // VLAN is the resolved l2Bridge.vlans[].vlan.id (always non-zero). + // VLAN is the resolved l2Bridge.vlans[].vlan.id (zero when the NAD's + // Network is a VRF network — VRF networks carry no VLANs). VLAN uint16 // IP is the source VM IP. IP string diff --git a/pkg/controller/plan/adapter/vsphere/builder_test.go b/pkg/controller/plan/adapter/vsphere/builder_test.go index 9024b0b961..1f7848392b 100644 --- a/pkg/controller/plan/adapter/vsphere/builder_test.go +++ b/pkg/controller/plan/adapter/vsphere/builder_test.go @@ -1286,6 +1286,16 @@ var _ = Describe("vSphere builder", func() { Expect(annotations).NotTo(HaveKey(ipsAnnKey)) }) + It("emits MAC and IP annotations for a Calico VRF NAD (network, no vlan)", func() { + // The builder keys off the NAD's Calico Network reference alone; + // a VRF network (which never carries a vlan) gets the same scoped + // identity annotations as an l2Bridge one. + annotations, err := buildAndCall(`{"type":"calico","network":"vrf-red"}`, true) + Expect(err).NotTo(HaveOccurred()) + Expect(annotations).To(HaveKeyWithValue(hwAnnKey, nicMAC)) + Expect(annotations).To(HaveKeyWithValue(ipsAnnKey, fmt.Sprintf(`["%s"]`, nicIP))) + }) + It("emits nothing for a Calico L3 NAD (no network field)", func() { annotations, err := buildAndCall(`{"type":"calico"}`, true) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index dc56d9945a..b6877c6e9c 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -738,11 +738,21 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) // cache for downstream per-VM checks (see CalicoVMIssues). // // Resource-level short-circuit ordering: the Network is fetched and -// classified first, so a missing Network or an unsupported network type -// (e.g. VRF) is reported before any VLAN handling; failure to resolve a -// VLAN entry prevents the IPPool check; failure of the IPPool check -// excludes the NAD from the cache entirely. The BPF-dataplane check runs -// once per plan, on the first NAD that resolves to an l2Bridge network. +// classified first — a VRF (routed L3) network takes its own acceptance +// branch before any VLAN handling, since VLANs don't apply to it. On the +// l2Bridge path, failure to resolve a VLAN entry prevents the IPPool +// check; failure of the IPPool check excludes the NAD from the cache +// entirely. The BPF-dataplane check runs once per plan, on the first NAD +// that resolves to an l2Bridge network; VRF references never trigger it. +// +// The VRF branch runs its own viability checks — node coverage and +// host-interface coverage of the hostConfig entries, route-table safety +// against other VRF Networks and the FelixConfiguration, a BGPPeer bound +// to the Network for cross-node routes, and the nftables-dataplane +// requirement — once per referenced Network, plus a per-NAD check that the +// NAD pins the VRF's IPPool via ipam.ipv4_pools. Like the BPF issue on the +// l2Bridge path, these are cluster/CR-level findings: the NAD itself stays +// cached so per-VM checks still run. func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValidationResult, error) { result := planbase.CalicoValidationResult{ Cache: &planbase.CalicoValidationCache{ @@ -758,6 +768,53 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid poolsLoaded := false bpfChecked := false + // VRF viability state. The checks run once per referenced VRF Network + // (several NADs may reference the same Network CR); the Network list, + // the BGPPeer-bound network set, FelixConfiguration facts, and the + // dataplane verdict are shared across the whole plan. + vrfChecked := map[string]bool{} + vrfDataplaneChecked := false + var vrfNetworks []calicoclient.Network + vrfNetworksLoaded := false + var bgpPeerNetworks map[string]bool + bgpPeerNetworksLoaded := false + var felixFacts *calicoclient.FelixConfig + + // loadFelix reads the "default" FelixConfiguration's dataplane facts + // once per plan, lazily. A missing FelixConfiguration (or unknown kind) + // yields the zero-value facts; transient GET errors propagate. + loadFelix := func(key k8stypes.NamespacedName) (*calicoclient.FelixConfig, error) { + if felixFacts != nil { + return felixFacts, nil + } + fc, err := calicoclient.GetFelixConfig(context.TODO(), c) + if err != nil { + return nil, liberr.Wrap(err, "nad", key.String()) + } + felixFacts = fc + return fc, nil + } + + // loadPools lists IPPools once per plan, lazily, on the first NAD that + // needs them. IPPool CRD absent (with the Network CRD present — an + // unusual install) yields an empty pool set rather than hard-erroring + // the reconcile; the pool checks then report against an empty set. + loadPools := func(key k8stypes.NamespacedName) (err error) { + if poolsLoaded { + return + } + pools, err = calicoclient.ListIPPools(context.TODO(), c) + if err != nil { + if !meta.IsNoMatchError(err) { + return liberr.Wrap(err, "nad", key.String()) + } + pools = nil + err = nil + } + poolsLoaded = true + return + } + for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { if pair.Destination.Type != planbase.Multus { continue @@ -819,12 +876,125 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String(), "network", cfg.Network) } } - // Classify the Network before any VLAN handling: a non-l2Bridge - // network (e.g. a VRF network) legitimately carries no VLAN, so the - // VLANRequired / VLAN-matching checks below would mislead. + // Classify the Network before any VLAN handling. A VRF network is + // routed L3 — no VLANs, no subnets — so none of the l2Bridge checks + // below (BPF dataplane, VLAN resolution, L2Workload pools) apply. + // Calico's manual IP assignment still requires the preserved IP to + // fall inside an enabled Workload-allowed pool, so the L3-eligible + // pool set is cached for the per-VM check. if nw.IsVRF { - issueBase.Kind = planbase.CalicoIssueNetworkTypeUnsupported - result.Issues = append(result.Issues, issueBase) + if cfg.VLAN != 0 { + // The NAD names a VLAN, but VLANs apply only to l2Bridge + // networks; the value is ignored. Informational, not + // blocking. + ib := issueBase + ib.Kind = planbase.CalicoIssueVRFVlanIgnored + result.Warnings = append(result.Warnings, ib) + } + // Pool pinning, per NAD (the pin is a NAD property; two NADs on + // the same Network can differ): Calico's IPAM is VRF-unaware, so + // the documented convention pins the VRF's dedicated IPPool in + // the NAD's IPAM config ("ipam": {"ipv4_pools": [...]}). Without + // the pin, a freshly assigned address comes from whichever pool + // IPAM selects, which the VRF's tenant fabric may not route. + // Deliberately skipped when the plan preserves static IPs: the + // addresses are then explicit via ipAddrs and already validated + // against pools per-VM — the risk exists only for + // freshly-assigned addresses. + if len(cfg.IPv4Pools) == 0 && !r.Plan.Spec.PreserveStaticIPs { + ib := issueBase + ib.Kind = planbase.CalicoIssueVRFPoolNotPinned + result.Warnings = append(result.Warnings, ib) + } + // VRF viability checks, once per referenced Network. Issues + // attach to the first NAD referencing the Network. + if !vrfChecked[cfg.Network] { + vrfChecked[cfg.Network] = true + + // Node coverage: an entry without a nodeSelector applies to + // every node; when every entry is scoped, the VRF exists + // only on matching nodes and a VM scheduled anywhere else + // fails to start. Whether the scoped selectors jointly + // cover every node is deliberately not evaluated — that + // would mean interpreting Calico's selector grammar, and a + // wrong "covered" verdict is worse than a cautious warning. + if !vrfHasAllNodesEntry(nw.VRFHostConfig) { + ib := issueBase + ib.Kind = planbase.CalicoIssueVRFNodeScoped + result.Warnings = append(result.Warnings, ib) + } + + // Host-interface coverage: an entry without hostInterfaces + // leaves pods on its nodes with no path off the node in the + // VRF, so one entry lacking them is enough to warn. + if vrfHasEntryWithoutHostInterfaces(nw.VRFHostConfig) { + ib := issueBase + ib.Kind = planbase.CalicoIssueVRFNoHostInterfaces + result.Warnings = append(result.Warnings, ib) + } + + // Cross-node routes: a VRF network ships with local routes + // only; routes to pods on other nodes exist only when a + // BGPPeer with spec.network naming this Network distributes + // them. The bound-network set is listed once per plan, + // lazily. An unknown BGPPeer kind yields the empty set — + // no peer bound — so the warning still fires. + if !bgpPeerNetworksLoaded { + bgpPeerNetworksLoaded = true + bgpPeerNetworks, err = calicoclient.ListBGPPeerNetworks(context.TODO(), c) + if err != nil { + return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + } + } + if !bgpPeerNetworks[cfg.Network] { + ib := issueBase + ib.Kind = planbase.CalicoIssueVRFNoBGPPeer + result.Warnings = append(result.Warnings, ib) + } + + // Route-table safety: scan every Network CR once per plan, + // then check this Network's routeTableIndex values against + // the kernel-reserved tables, the other VRF Networks, and + // the FelixConfiguration routeTableRanges. + if !vrfNetworksLoaded { + vrfNetworksLoaded = true + vrfNetworks, err = calicoclient.ListNetworks(context.TODO(), c) + if err != nil { + return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + } + } + felix, ferr := loadFelix(key) + if ferr != nil { + return planbase.CalicoValidationResult{}, ferr + } + criticals, warns := vrfRouteTableIssues(issueBase, nw, vrfNetworks, felix) + result.Issues = append(result.Issues, criticals...) + result.Warnings = append(result.Warnings, warns...) + + // Dataplane, once per plan: VRF networking runs only on the + // nftables dataplane — BPF and iptables are unsupported — + // so only an explicit nftablesMode Enabled with BPF off + // passes. "Auto" leaves the choice to Felix's host + // detection, which cannot be verified from here, so it is + // treated as failing with the same remedy: set + // FelixConfiguration nftablesMode: Enabled. + if !vrfDataplaneChecked { + vrfDataplaneChecked = true + if felix.BPFEnabled || felix.NftablesMode != calicoclient.NftablesModeEnabled { + ib := issueBase + ib.Kind = planbase.CalicoIssueVRFDataplaneNotNftables + result.Issues = append(result.Issues, ib) + } + } + } + if err = loadPools(key); err != nil { + return planbase.CalicoValidationResult{}, err + } + result.Cache.NADs[key] = &planbase.ResolvedCalicoNAD{ + Network: cfg.Network, + IsVRF: true, + EligiblePools: calicoclient.L3EligiblePools(pools), + } continue } if nw.L2Bridge == nil { @@ -864,19 +1034,8 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid continue } - if !poolsLoaded { - pools, err = calicoclient.ListIPPools(context.TODO(), c) - if err != nil { - // IPPool CRD absent (with the Network CRD present — an - // unusual install) means no pool can ever satisfy the - // VLAN's subnets; fall through to the no-pool issue below - // rather than hard-erroring the reconcile. - if !meta.IsNoMatchError(err) { - return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) - } - pools = nil - } - poolsLoaded = true + if err = loadPools(key); err != nil { + return planbase.CalicoValidationResult{}, err } eligible := calicoclient.L2WorkloadEligiblePools(pools, entry.Subnets) if len(eligible) == 0 { @@ -901,8 +1060,9 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid // plan level via CalicoNetworkInvalid. // // Issues are deduplicated by {Kind, Network, VLAN, IP}, so two NICs -// hitting the same failure mode yield a single issue. IPNotInSubnet -// short-circuits IPNotInIPPool for the same IP. +// hitting the same failure mode yield a single issue. On the l2Bridge +// path, IPNotInSubnet short-circuits IPNotInIPPool for the same IP; a +// VRF-backed NAD has no subnets, so only the IPPool check runs there. func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { if !r.Plan.Spec.PreserveStaticIPs { return nil, nil @@ -961,6 +1121,16 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati for _, ip := range ips { perIP := issueBase perIP.IP = ip + // A VRF network is routed L3 — there are no VLAN subnets to + // check; manual IP assignment still requires an enabled + // Workload-allowed pool covering the IP. + if resolved.IsVRF { + if calicoclient.L3EligiblePoolForIP(resolved.EligiblePools, ip) == nil { + perIP.Kind = planbase.CalicoIssueIPNotInIPPool + emit(perIP) + } + continue + } if !ipInAnySubnet(ip, resolved.VLAN.Subnets) { perIP.Kind = planbase.CalicoIssueIPNotInSubnet emit(perIP) @@ -1298,6 +1468,118 @@ func (r *Validator) buildNICResolver(nics []vsphere.NIC) ([]string, map[string][ return nicKeys, pairsBySource, nil } +// vrfReservedRouteTables are the kernel's own route tables — 253 (default), +// 254 (main), 255 (local) — which a VRF must never claim. +var vrfReservedRouteTables = map[int64]bool{253: true, 254: true, 255: true} + +// vrfHasAllNodesEntry reports whether any spec.vrf.hostConfig entry has an +// empty (or absent) nodeSelector — such an entry applies to every node, so +// the VRF network is guaranteed to exist wherever a VM lands. +func vrfHasAllNodesEntry(entries []calicoclient.VRFHostEntry) bool { + for _, e := range entries { + if e.NodeSelector == "" { + return true + } + } + return false +} + +// vrfHasEntryWithoutHostInterfaces reports whether any spec.vrf.hostConfig +// entry names no host interfaces. Such an entry gives pods on its nodes no +// path off the node inside the VRF, so its VMs are unreachable beyond their +// own node. +func vrfHasEntryWithoutHostInterfaces(entries []calicoclient.VRFHostEntry) bool { + for _, e := range entries { + if !e.HasHostInterfaces { + return true + } + } + return false +} + +// vrfRouteTableIssues checks the referenced VRF Network's routeTableIndex +// values for reserved-table use and for collisions with other VRF Networks +// and with the FelixConfiguration routeTableRanges. issueBase supplies the +// NAD/Network attribution; each finding carries the offending table index. +// +// A collision with another VRF Network is provable (Critical) when at least +// one of the two entries carrying the index has no nodeSelector — an +// all-nodes entry overlaps any other node set. When both entries are +// selector-scoped, the overlap depends on which nodes the selectors match; +// selector evaluation is deliberately out of scope, so those pairs are +// reported as possible conflicts (Warn). Entries of the same Network sharing +// an index are legitimate — same VRF, same table — and never reported. +// +// The FelixConfiguration sub-check runs only when spec.routeTableRanges is +// explicitly set. When absent, Felix falls back to version-dependent +// defaults that are not modelled here — guessing them would risk false +// Criticals against tables Felix never touches. +func vrfRouteTableIssues(issueBase planbase.CalicoNADIssue, nw *calicoclient.Network, all []calicoclient.Network, felix *calicoclient.FelixConfig) (criticals, warnings []planbase.CalicoNADIssue) { + // Distinct indexes in entry order; per index, remember whether any + // entry carrying it applies to all nodes. + var indexes []int64 + seen := map[int64]bool{} + allNodes := map[int64]bool{} + for _, e := range nw.VRFHostConfig { + if !seen[e.RouteTableIndex] { + seen[e.RouteTableIndex] = true + indexes = append(indexes, e.RouteTableIndex) + } + if e.NodeSelector == "" { + allNodes[e.RouteTableIndex] = true + } + } + + for _, idx := range indexes { + if vrfReservedRouteTables[idx] { + ib := issueBase + ib.Kind = planbase.CalicoIssueVRFRouteTableReserved + ib.RouteTable = idx + criticals = append(criticals, ib) + } + for _, rng := range felix.RouteTableRanges { + if idx >= rng.Min && idx <= rng.Max { + ib := issueBase + ib.Kind = planbase.CalicoIssueVRFRouteTableConflict + ib.RouteTable = idx + criticals = append(criticals, ib) + break + } + } + } + + for i := range all { + other := &all[i] + if other.Name == nw.Name || !other.IsVRF { + continue + } + otherHas := map[int64]bool{} + otherAllNodes := map[int64]bool{} + for _, o := range other.VRFHostConfig { + otherHas[o.RouteTableIndex] = true + if o.NodeSelector == "" { + otherAllNodes[o.RouteTableIndex] = true + } + } + for _, idx := range indexes { + if !otherHas[idx] { + continue + } + ib := issueBase + ib.RouteTable = idx + ib.ConflictsWith = other.Name + if allNodes[idx] || otherAllNodes[idx] { + ib.Kind = planbase.CalicoIssueVRFRouteTableConflict + criticals = append(criticals, ib) + } else { + ib.Kind = planbase.CalicoIssueVRFRouteTablePossibleConflict + warnings = append(warnings, ib) + } + } + } + return +} + // resolveVLANEntry returns the l2Bridge.vlans[] entry matched by nadVLAN. // Callers reject a zero nadVLAN before reaching here (a Network reference // requires an explicit VLAN), so nadVLAN is always non-zero. When no entry diff --git a/pkg/controller/plan/adapter/vsphere/validator_test.go b/pkg/controller/plan/adapter/vsphere/validator_test.go index 3fd8c2baba..245017084d 100644 --- a/pkg/controller/plan/adapter/vsphere/validator_test.go +++ b/pkg/controller/plan/adapter/vsphere/validator_test.go @@ -45,6 +45,28 @@ func makeFelixConfiguration(bpfEnabled bool) *unstructured.Unstructured { return u } +// makeNftablesFelixConfiguration builds the cluster-wide "default" +// FelixConfiguration with spec.nftablesMode set as given and bpfEnabled +// unset (BPF off). "Enabled" describes the only cluster flavour VRF +// networking supports. +func makeNftablesFelixConfiguration(mode string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(calicoclient.FelixConfigurationGVK) + u.SetName("default") + _ = unstructured.SetNestedField(u.Object, mode, "spec", "nftablesMode") + return u +} + +// withRouteTableRanges sets spec.routeTableRanges on a FelixConfiguration. +func withRouteTableRanges(u *unstructured.Unstructured, ranges ...[2]int64) *unstructured.Unstructured { + entries := make([]interface{}, len(ranges)) + for i, r := range ranges { + entries[i] = map[string]interface{}{"min": r[0], "max": r[1]} + } + _ = unstructured.SetNestedSlice(u.Object, entries, "spec", "routeTableRanges") + return u +} + // withDefaultFelix appends a BPF-enabled "default" FelixConfiguration unless // objs already carries a FelixConfiguration. l2Bridge networks are only valid // on a BPF dataplane, so the Calico setup helpers install one by default; @@ -773,14 +795,53 @@ var _ = Describe("vsphere validation tests", func() { }, }, } - // VRF (routed, L3) Network spec — a real network type, but not one - // identity preservation supports. - vrfSpec := map[string]interface{}{ - "vrf": map[string]interface{}{ - "hostConfig": []interface{}{ - map[string]interface{}{"nodeSelector": "all()"}, - }, - }, + // VRF (routed, L3) Network helpers. vrfHostEntry builds one + // spec.vrf.hostConfig entry; an empty selector omits the + // nodeSelector field, so the entry applies to every node. The entry + // names a host interface, keeping the VRFNoHostInterfaces check + // quiet — vrfHostEntryNoInterfaces builds the offending shape. + vrfHostEntry := func(selector string, routeTableIndex int64) map[string]interface{} { + e := map[string]interface{}{ + "routeTableIndex": routeTableIndex, + "hostInterfaces": []interface{}{map[string]interface{}{"name": "eth1"}}, + } + if selector != "" { + e["nodeSelector"] = selector + } + return e + } + vrfHostEntryNoInterfaces := func(selector string, routeTableIndex int64) map[string]interface{} { + e := vrfHostEntry(selector, routeTableIndex) + delete(e, "hostInterfaces") + return e + } + makeVRFNetworkNamed := func(name string, entries ...map[string]interface{}) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(calicoclient.NetworkGVK) + u.SetName(name) + list := make([]interface{}, len(entries)) + for i, e := range entries { + list[i] = e + } + _ = unstructured.SetNestedField(u.Object, map[string]interface{}{ + "vrf": map[string]interface{}{"hostConfig": list}, + }, "spec") + return u + } + makeVRFNetwork := func(entries ...map[string]interface{}) *unstructured.Unstructured { + return makeVRFNetworkNamed(netName, entries...) + } + // makeBGPPeer builds a projectcalico.org/v3 BGPPeer whose + // spec.network binds it to the named Network — the binding that + // distributes a VRF network's routes across nodes. VRF fixtures + // that must stay warning-free include one bound to their Network; + // the VRFNoBGPPeer specs leave it out (or bind it elsewhere). + makeBGPPeer := func(name, network string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(calicoclient.BGPPeerGVK) + u.SetName(name) + _ = unstructured.SetNestedField(u.Object, network, "spec", "network") + return u } // setup builds a Validator + fake client. nicIP is the NIC's guest IP, @@ -796,6 +857,8 @@ var _ = Describe("vsphere validation tests", func() { scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK.GroupVersion().WithKind("IPPoolList"), &unstructured.UnstructuredList{}) scheme.AddKnownTypeWithName(calicoclient.FelixConfigurationGVK, &unstructured.Unstructured{}) scheme.AddKnownTypeWithName(calicoclient.FelixConfigurationGVK.GroupVersion().WithKind("FelixConfigurationList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(calicoclient.BGPPeerGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.BGPPeerGVK.GroupVersion().WithKind("BGPPeerList"), &unstructured.UnstructuredList{}) c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(withDefaultFelix(k8sObjs)...).Build() vm := model.VM{ @@ -944,29 +1007,595 @@ var _ = Describe("vsphere validation tests", func() { Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkNotFound)) }) - It("emits NetworkTypeUnsupported when the referenced Network is a VRF network", func() { - // A VRF NAD legitimately carries no vlan; the misleading - // VLANRequired must not fire. The reference stops at - // classification and is not cached. + It("accepts a VRF network reference and caches the L3-eligible pools", func() { + // A VRF network is routed L3 — no VLAN to resolve, and the + // BPF-dataplane requirement is l2Bridge-only. The cluster runs + // the nftables dataplane (bpfEnabled unset → off), which the + // VRF dataplane check requires; if the l2Bridge BPF check ran + // on this plan it would report DataplaneNotBPF, so the empty + // issue list also proves that check never runs for a VRF-only + // plan. The all-nodes hostConfig entry (with a host + // interface), unique route table, bound BGPPeer, and the + // preserve-IPs plan (no pool pin needed) keep every viability + // check quiet: fully clean result. v, c, _ := setup("10.100.0.5", true, - makeCalicoNAD(0), makeNetwork(vrfSpec), + makeCalicoNAD(0), makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) - Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkTypeUnsupported)) - Expect(result.Cache.NADs).To(BeEmpty()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(BeEmpty()) + entry := result.Cache.NADs[k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}] + Expect(entry).NotTo(BeNil()) + Expect(entry.IsVRF).To(BeTrue()) + Expect(entry.VLAN).To(Equal(calicoclient.VLANEntry{})) + Expect(entry.EligiblePools).To(HaveLen(1)) }) - It("emits NetworkTypeUnsupported even when the NAD sets a vlan on a VRF network", func() { - // Same root cause — the network type is wrong; a VLAN check - // against a VRF network would mislead. + It("warns VRFVlanIgnored when the NAD sets a vlan on a VRF network", func() { + // VLANs apply only to l2Bridge networks — the stray vlan is + // surfaced as a warning; the reference stays valid and cached. + // The bound BGPPeer, interface-carrying all-nodes entry, and + // preserve-IPs plan keep the other VRF warnings out. v, c, _ := setup("10.100.0.5", true, - makeCalicoNAD(100), makeNetwork(vrfSpec), + makeCalicoNAD(100), makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) - Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkTypeUnsupported)) - Expect(result.Cache.NADs).To(BeEmpty()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, + Kind: planbase.CalicoIssueVRFVlanIgnored, + Network: netName, + VLAN: 100, + })) + entry := result.Cache.NADs[k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}] + Expect(entry).NotTo(BeNil()) + Expect(entry.IsVRF).To(BeTrue()) + }) + + Describe("VRF pool pinning", func() { + nadKey := k8stypes.NamespacedName{Namespace: nadNS, Name: nadName} + + // makeCalicoNADPinned is makeCalicoNAD with the NAD's IPAM + // config pinning one IPPool via ipv4_pools, and without a + // vlan (VRF NADs carry none). + makeCalicoNADPinned := func(pool string) *k8snet.NetworkAttachmentDefinition { + cfg := fmt.Sprintf(`{"type":"calico","network":"%s","ipam":{"type":"calico-ipam","ipv4_pools":[%q]}}`, netName, pool) + return &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: nadName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: cfg}, + } + } + + It("warns VRFPoolNotPinned when the plan assigns fresh IPs and the NAD pins no ipv4_pools", func() { + // Calico's IPAM is VRF-unaware: with no ipv4_pools pin and + // the plan assigning fresh addresses (preserveStaticIPs + // false), the NIC's address comes from whichever pool IPAM + // selects. The bound BGPPeer, interface-carrying all-nodes + // entry, and nftables dataplane keep every other warning + // out, isolating the pool warning. + v, c, _ := setup("10.100.0.5", false, + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFPoolNotPinned, + Network: netName, + })) + // Warn-class: the NAD stays cached for per-VM checks. + Expect(result.Cache.NADs).To(HaveLen(1)) + }) + + It("does not warn when the plan preserves static IPs", func() { + // Deliberate: with preserveStaticIPs the addresses are + // explicit via ipAddrs and already validated against pools + // per-VM — the flat-pool risk exists only for freshly + // assigned addresses. Same fixture as above except the + // plan preserves. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(BeEmpty()) + }) + + It("does not warn when the NAD pins ipv4_pools", func() { + // Fresh assignment, but the NAD pins the VRF's pool — the + // convention the warning asks for. + v, c, _ := setup("10.100.0.5", false, + makeCalicoNADPinned("default-pool"), + makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(BeEmpty()) + }) + + It("warns per NAD, not per Network", func() { + // The pin lives in each NAD's IPAM config, so two unpinned + // NADs referencing the same VRF Network warn once each — + // unlike the per-Network viability checks, which dedupe. + secondNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "calico-nad-2", Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{ + Config: fmt.Sprintf(`{"type":"calico","network":"%s"}`, netName), + }, + } + v, c, _ := setup("10.100.0.5", false, + makeCalicoNAD(0), secondNAD, + makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + v.Plan.Referenced.Map.Network.Spec.Map = append( + v.Plan.Referenced.Map.Network.Spec.Map, + v1beta1.NetworkPair{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-2"}}, + Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: "calico-nad-2", + }, + }, + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf( + planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFPoolNotPinned, + Network: netName, + }, + planbase.CalicoNADIssue{ + NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: "calico-nad-2"}, + Kind: planbase.CalicoIssueVRFPoolNotPinned, + Network: netName, + }, + )) + }) + }) + + Describe("VRF viability", func() { + nadKey := k8stypes.NamespacedName{Namespace: nadNS, Name: nadName} + + It("warns VRFNodeScoped when every hostConfig entry is node-scoped", func() { + // Both entries carry a nodeSelector. Whether the two + // selectors jointly cover every node is deliberately not + // evaluated (Calico selector grammar), so scoped-only + // hostConfig is reported as a warning, not a Critical. + // The bound BGPPeer keeps VRFNoBGPPeer out; the entries + // carry host interfaces. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork( + vrfHostEntry("rack == 'a'", 101), + vrfHostEntry("rack == 'b'", 102), + ), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFNodeScoped, + Network: netName, + })) + }) + + It("runs the viability checks once per referenced Network", func() { + // Two NADs referencing the same VRF Network: the checks + // dedupe per Network name, so the scoped-only hostConfig + // yields exactly one warning, attached to the first NAD. + secondNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "calico-nad-2", Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{ + Config: fmt.Sprintf(`{"type":"calico","network":"%s"}`, netName), + }, + } + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), secondNAD, + makeVRFNetwork(vrfHostEntry("rack == 'a'", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + v.Plan.Referenced.Map.Network.Spec.Map = append( + v.Plan.Referenced.Map.Network.Spec.Map, + v1beta1.NetworkPair{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-2"}}, + Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: "calico-nad-2", + }, + }, + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(nadIssueKinds(result.Warnings)).To(ConsistOf(planbase.CalicoIssueVRFNodeScoped)) + Expect(result.Cache.NADs).To(HaveLen(2)) + }) + + It("emits VRFRouteTableReserved when a hostConfig entry claims a kernel table", func() { + // 254 is the kernel's main table; a VRF must never own it. + // The bound BGPPeer keeps the warning list empty. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 254)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFRouteTableReserved, + Network: netName, + RouteTable: 254, + })) + Expect(result.Warnings).To(BeEmpty()) + }) + + It("emits VRFRouteTableConflict when another VRF Network shares the index via an all-nodes entry", func() { + // The referenced Network claims table 101 on every node; + // any other Network claiming 101 anywhere provably + // overlaps it. The other Network need not be referenced by + // the plan — the collision scan covers every Network CR. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 101)), + makeVRFNetworkNamed("other-vrf", vrfHostEntry("rack == 'b'", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFRouteTableConflict, + Network: netName, + RouteTable: 101, + ConflictsWith: "other-vrf", + })) + Expect(result.Warnings).To(BeEmpty()) + }) + + It("warns VRFRouteTablePossibleConflict when both sharing entries are node-scoped", func() { + // Both entries carrying table 101 are selector-scoped, so + // whether they ever land on the same node depends on what + // the selectors match — unprovable without evaluating + // them. The all-nodes entry on table 102 keeps the + // NodeScoped warning out of the picture. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork( + vrfHostEntry("rack == 'a'", 101), + vrfHostEntry("", 102), + ), + makeVRFNetworkNamed("other-vrf", vrfHostEntry("rack == 'b'", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFRouteTablePossibleConflict, + Network: netName, + RouteTable: 101, + ConflictsWith: "other-vrf", + })) + }) + + It("does not report entries of the same Network sharing an index", func() { + // Two hostConfig entries of one Network on the same table + // are legitimate — same VRF, same table. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork( + vrfHostEntry("", 101), + vrfHostEntry("rack == 'a'", 101), + ), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(BeEmpty()) + }) + + It("emits VRFRouteTableConflict when the index falls inside explicit FelixConfiguration routeTableRanges", func() { + felix := withRouteTableRanges(makeNftablesFelixConfiguration("Enabled"), [2]int64{100, 200}) + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 150)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + felix, + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + // ConflictsWith stays empty: the collision is with the + // FelixConfiguration, not another Network. + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFRouteTableConflict, + Network: netName, + RouteTable: 150, + })) + Expect(result.Warnings).To(BeEmpty()) + }) + + It("skips the FelixConfiguration sub-check when routeTableRanges is absent", func() { + // With the field absent, Felix falls back to + // version-dependent defaults that the validator + // deliberately does not guess — table 42 would sit inside + // a typical default range, and no issue may be raised. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 42)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(BeEmpty()) + }) + + It("warns VRFNoBGPPeer when no BGPPeer names the network", func() { + // VRF networks ship with local routes only; without a + // BGPPeer whose spec.network names this Network, no + // cross-node routes exist. No BGPPeer at all here. The + // preserve-IPs plan and interface-carrying all-nodes entry + // keep the other warnings out. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFNoBGPPeer, + Network: netName, + })) + // Warn-class: the NAD stays cached for per-VM checks. + Expect(result.Cache.NADs).To(HaveLen(1)) + }) + + It("warns VRFNoBGPPeer when the only BGPPeer names a different network", func() { + // A peer exists, but it distributes routes for another + // Network — this VRF still has no cross-node routes. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("other-peer", "other-vrf"), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFNoBGPPeer, + Network: netName, + })) + }) + + It("still warns VRFNoBGPPeer when the BGPPeer kind is unknown to the API server", func() { + // Older Calico installs don't ship the BGPPeer CRD (or its + // spec.network field). Absence of evidence is not a bound + // peer: with no peer able to name the network, the warning + // must still fire. The scheme deliberately omits the + // BGPPeer kinds; the interceptor turns the BGPPeer List + // into the NoKindMatchError a real API server would return + // and passes every other call through. + v, _, _ := setup("10.100.0.5", true) + scheme := runtime.NewScheme() + _ = k8snet.AddToScheme(scheme) + scheme.AddKnownTypeWithName(calicoclient.NetworkGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.NetworkGVK.GroupVersion().WithKind("NetworkList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.IPPoolGVK.GroupVersion().WithKind("IPPoolList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(calicoclient.FelixConfigurationGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(calicoclient.FelixConfigurationGVK.GroupVersion().WithKind("FelixConfigurationList"), &unstructured.UnstructuredList{}) + bgpPeerGK := calicoclient.BGPPeerGVK.GroupKind() + c := fake.NewClientBuilder().WithScheme(scheme). + WithRuntimeObjects( + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + ). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(ctx context.Context, inner client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + if list.GetObjectKind().GroupVersionKind() == calicoclient.BGPPeerGVK.GroupVersion().WithKind("BGPPeerList") { + return &meta.NoKindMatchError{GroupKind: bgpPeerGK} + } + return inner.List(ctx, list, opts...) + }, + }).Build() + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFNoBGPPeer, + Network: netName, + })) + }) + + It("warns VRFNoHostInterfaces when a hostConfig entry names no host interfaces", func() { + // The scoped entry names no hostInterfaces: VMs on its + // nodes have no path off the node inside the VRF, so one + // such entry warns for the whole Network. The all-nodes + // entry (with an interface) keeps NodeScoped quiet, the + // shared table within one Network is legitimate, and the + // bound BGPPeer keeps VRFNoBGPPeer out. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork( + vrfHostEntry("", 101), + vrfHostEntryNoInterfaces("rack == 'a'", 101), + ), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Issues).To(BeEmpty()) + Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFNoHostInterfaces, + Network: netName, + })) + // Warn-class: the NAD stays cached for per-VM checks. + Expect(result.Cache.NADs).To(HaveLen(1)) + }) + + DescribeTable("emits VRFDataplaneNotNftables when the dataplane is not nftables", + func(felix *unstructured.Unstructured) { + objs := []runtime.Object{ + makeCalicoNAD(0), + makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeBGPPeer("vrf-peer", netName), + } + if felix == nil { + // Only a per-node FelixConfiguration, so the + // "default" one is missing (and the setup helper's + // auto-injection is suppressed): Felix runs on + // built-in defaults, which are not nftables. + perNode := makeFelixConfiguration(true) + perNode.SetName("node.worker-1") + felix = perNode + } + v, c, _ := setup("10.100.0.5", true, append(objs, felix)...) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVRFDataplaneNotNftables)) + // The finding is cluster-scoped; the NAD itself is + // valid and stays cached so per-VM checks still run. + Expect(result.Cache.NADs).To(HaveLen(1)) + }, + Entry("bpfEnabled true", makeFelixConfiguration(true)), + Entry("nftablesMode absent (Felix default: Disabled)", makeFelixConfiguration(false)), + Entry("nftablesMode Disabled", makeNftablesFelixConfiguration("Disabled")), + // "Auto" leaves the dataplane to Felix's host detection, + // which cannot be verified from here — treated as failing. + Entry("nftablesMode Auto (indeterminate)", makeNftablesFelixConfiguration("Auto")), + Entry("default FelixConfiguration missing", nil), + ) + }) + + Describe("mixed l2Bridge + VRF plans", func() { + // An l2Bridge network requires the BPF dataplane; a VRF + // network requires nftables. No FelixConfiguration satisfies + // both, so the two network types are mutually exclusive per + // cluster — a plan referencing both kinds of NAD always draws + // at least one dataplane Critical, naming whichever side the + // cluster cannot run. + const vrfNetName = "vrf-net" + const vrfNADName = "vrf-nad" + + mixedSetup := func(felix *unstructured.Unstructured) (*Validator, client.Client) { + vrfNAD := &k8snet.NetworkAttachmentDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: vrfNADName, Namespace: nadNS}, + Spec: k8snet.NetworkAttachmentDefinitionSpec{ + Config: fmt.Sprintf(`{"type":"calico","network":"%s"}`, vrfNetName), + }, + } + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(100), makeNetwork(l2Single), + makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), + vrfNAD, makeVRFNetworkNamed(vrfNetName, vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.200.0.0/24", "Workload"), + felix, + makeBGPPeer("vrf-peer", vrfNetName), + ) + v.Plan.Referenced.Map.Network.Spec.Map = append( + v.Plan.Referenced.Map.Network.Spec.Map, + v1beta1.NetworkPair{ + Source: v1beta1.NetworkSourceRef{Ref: ref.Ref{ID: "src-2"}}, + Destination: v1beta1.DestinationNetwork{ + Type: planbase.Multus, Namespace: nadNS, Name: vrfNADName, + }, + }, + ) + return v, c + } + + It("reports only VRFDataplaneNotNftables on a BPF cluster", func() { + // BPF satisfies the l2Bridge side and fails the VRF side. + v, c := mixedSetup(makeFelixConfiguration(true)) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVRFDataplaneNotNftables)) + Expect(result.Cache.NADs).To(HaveLen(2)) + }) + + It("reports only DataplaneNotBPF on an nftables cluster", func() { + // nftables satisfies the VRF side and fails the l2Bridge + // side. + v, c := mixedSetup(makeNftablesFelixConfiguration("Enabled")) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueDataplaneNotBPF)) + Expect(result.Cache.NADs).To(HaveLen(2)) + }) + + It("reports both dataplane Criticals on a cluster running neither BPF nor nftables", func() { + // An iptables cluster (bpfEnabled false, nftablesMode + // unset) can honour neither network type; both sides + // report, each naming its own remedy. + v, c := mixedSetup(makeFelixConfiguration(false)) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf( + planbase.CalicoIssueDataplaneNotBPF, + planbase.CalicoIssueVRFDataplaneNotNftables, + )) + Expect(result.Cache.NADs).To(HaveLen(2)) + }) }) It("does not emit a dataplane issue when FelixConfiguration has bpfEnabled true", func() { @@ -1383,6 +2012,58 @@ var _ = Describe("vsphere validation tests", func() { Expect(issues[0].Kind).To(Equal(planbase.CalicoIssueTooManyIPs)) }) + It("emits IPNotInIPPool for a VRF-backed NAD when no eligible pool covers the IP", func() { + // A VRF network has no subnets, so the subnet check is skipped + // — the preserved IP is validated directly against the + // L3-eligible pools. Here the only pool misses the IP. + v, c, vmRef := setup("10.100.0.5", true, + makeCalicoNAD(0), makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("other-pool", "10.200.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(ConsistOf(planbase.CalicoIssue{ + Kind: planbase.CalicoIssueIPNotInIPPool, Network: netName, IP: "10.100.0.5", + })) + }) + + It("emits TooManyIPs when a VRF-backed NIC carries more than one IPv4", func() { + // Calico's ipAddrs annotation still caps at one IPv4 per + // interface on a VRF network. + v, c, vmRef := setup("10.100.0.5", true, + makeCalicoNAD(0), makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + ) + vm := v.Source.Inventory.(*mockInventory).vm + vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "10.100.0.6", DeviceConfigId: 4001}) + v.Source.Inventory.(*mockInventory).vm = vm + + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(HaveLen(1)) + Expect(issues[0].Kind).To(Equal(planbase.CalicoIssueTooManyIPs)) + }) + + It("returns no issues for a VRF-backed NAD when preserveStaticIPs is false", func() { + // The IP is outside every pool, but preservation is off. + v, c, vmRef := setup("10.100.0.5", false, + makeCalicoNAD(0), makeVRFNetwork(vrfHostEntry("", 101)), + makeIPPool("other-pool", "10.200.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + ) + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) + issues, err := v.CalicoVMIssues(vmRef, result.Cache) + Expect(err).NotTo(HaveOccurred()) + Expect(issues).To(BeEmpty()) + }) + It("deduplicates identical per-NIC IP issues", func() { // Two NICs on the same source network, two NetworkMap entries each // pointing at a distinct NAD; both NADs reference the same Calico @@ -1492,11 +2173,11 @@ var _ = Describe("vsphere validation tests", func() { }, } // VRF (routed, L3) Network spec — a real network type, but not one - // identity preservation supports. + // the calico-primary path supports. vrfSpec := map[string]interface{}{ "vrf": map[string]interface{}{ "hostConfig": []interface{}{ - map[string]interface{}{"nodeSelector": "all()"}, + map[string]interface{}{"routeTableIndex": int64(101)}, }, }, } diff --git a/pkg/controller/plan/validation.go b/pkg/controller/plan/validation.go index 37255d0faa..2b24860768 100644 --- a/pkg/controller/plan/validation.go +++ b/pkg/controller/plan/validation.go @@ -2164,8 +2164,27 @@ func calicoNADIssueDetail(i planbase.CalicoNADIssue) string { return fmt.Sprintf("%s (NetworkNotFound network=%q)", i.NAD.String(), i.Network) case planbase.CalicoIssueNetworkCRDAbsent: return fmt.Sprintf("%s (NetworkCRDAbsent network=%q: destination Calico install does not ship the projectcalico.org/v3 Network CRD)", i.NAD.String(), i.Network) - case planbase.CalicoIssueNetworkTypeUnsupported: - return fmt.Sprintf("%s (NetworkTypeUnsupported network=%q: the referenced Network is not an L2 bridge network; only l2Bridge networks are supported)", i.NAD.String(), i.Network) + case planbase.CalicoIssueVRFVlanIgnored: + return fmt.Sprintf("%s (VRFVlanIgnored network=%q vlan=%d: the referenced Network is a VRF (routed) network; VLANs apply only to l2Bridge networks and the vlan value is ignored)", i.NAD.String(), i.Network, i.VLAN) + case planbase.CalicoIssueVRFNodeScoped: + return fmt.Sprintf("%s (VRFNodeScoped network=%q: every hostConfig entry carries a nodeSelector, so the network exists only on matching nodes; VMs scheduled onto any other node will fail to start — add a hostConfig entry without a nodeSelector to cover all nodes)", i.NAD.String(), i.Network) + case planbase.CalicoIssueVRFRouteTableReserved: + return fmt.Sprintf("%s (VRFRouteTableReserved network=%q table=%d: route tables 253, 254 and 255 are reserved by the kernel; choose a different routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable) + case planbase.CalicoIssueVRFRouteTableConflict: + if i.ConflictsWith != "" { + return fmt.Sprintf("%s (VRFRouteTableConflict network=%q table=%d: route table %d is also claimed by VRF Network %q on an overlapping set of nodes, which can result in network outages; give each VRF Network a unique routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable, i.RouteTable, i.ConflictsWith) + } + return fmt.Sprintf("%s (VRFRouteTableConflict network=%q table=%d: route table %d falls inside the FelixConfiguration routeTableRanges, which Calico reserves for its own routes; choose a routeTableIndex outside those ranges)", i.NAD.String(), i.Network, i.RouteTable, i.RouteTable) + case planbase.CalicoIssueVRFRouteTablePossibleConflict: + return fmt.Sprintf("%s (VRFRouteTablePossibleConflict network=%q table=%d: VRF Network %q also uses route table %d; both entries are node-scoped, so the overlap cannot be ruled out — verify the two selectors never match the same node, or give each network a unique routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable, i.ConflictsWith, i.RouteTable) + case planbase.CalicoIssueVRFDataplaneNotNftables: + return fmt.Sprintf("%s (VRFDataplaneNotNftables: VRF networking requires the nftables dataplane; set nftablesMode: Enabled (and leave bpfEnabled off) in the default FelixConfiguration)", i.NAD.String()) + case planbase.CalicoIssueVRFPoolNotPinned: + return fmt.Sprintf("%s (VRFPoolNotPinned network=%q: the NAD does not pin an IPPool, so each VM's address will come from whichever pool Calico's IPAM selects and the VRF's network may not be able to route it; pin the VRF's IPPool via ipv4_pools in the NAD's IPAM config)", i.NAD.String(), i.Network) + case planbase.CalicoIssueVRFNoBGPPeer: + return fmt.Sprintf("%s (VRFNoBGPPeer network=%q: cross-node reachability in this VRF requires a BGPPeer whose spec.network names it; VMs placed on different nodes will not reach each other until one exists)", i.NAD.String(), i.Network) + case planbase.CalicoIssueVRFNoHostInterfaces: + return fmt.Sprintf("%s (VRFNoHostInterfaces network=%q: a hostConfig entry names no hostInterfaces, so VMs on the nodes that entry matches are unreachable beyond their own node; name at least one host interface in every hostConfig entry)", i.NAD.String(), i.Network) case planbase.CalicoIssueDataplaneNotBPF: return fmt.Sprintf("%s (DataplaneNotBPF: the destination Calico install is not running the BPF dataplane; L2 networks require FelixConfiguration bpfEnabled: true)", i.NAD.String()) case planbase.CalicoIssueNetworkHasNoL2Bridge: diff --git a/pkg/controller/provider/model/ocp/model.go b/pkg/controller/provider/model/ocp/model.go index 9349c3312d..15e9178a32 100644 --- a/pkg/controller/provider/model/ocp/model.go +++ b/pkg/controller/provider/model/ocp/model.go @@ -228,6 +228,30 @@ type NetworkConfig struct { Network string `json:"network,omitempty"` // 802.1Q VLAN ID (1-4094) for Calico CNI. Zero means unspecified. VLAN uint16 `json:"vlan,omitempty"` + // IPPools (names or CIDRs) the NAD pins address assignment to, from + // ipam.ipv4_pools in the CNI config. Nil when the ipam block or the + // field is absent. Flattened out of the nested ipam block by + // UnmarshalJSON, so it carries no JSON tag of its own. + IPv4Pools []string `json:"-"` +} + +// UnmarshalJSON decodes the flat NetworkConfig fields as usual and +// additionally flattens ipam.ipv4_pools into IPv4Pools. +func (m *NetworkConfig) UnmarshalJSON(data []byte) error { + // The alias sheds NetworkConfig's methods so the inner Unmarshal does + // not recurse into this one. + type alias NetworkConfig + aux := struct { + *alias + IPAM struct { + IPv4Pools []string `json:"ipv4_pools"` + } `json:"ipam"` + }{alias: (*alias)(m)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + m.IPv4Pools = aux.IPAM.IPv4Pools + return nil } func (m *NetworkConfig) IsUnsupportedUdn() bool { diff --git a/pkg/controller/provider/model/ocp/model_test.go b/pkg/controller/provider/model/ocp/model_test.go index 0eafb5a500..4f2563daa5 100644 --- a/pkg/controller/provider/model/ocp/model_test.go +++ b/pkg/controller/provider/model/ocp/model_test.go @@ -2,16 +2,18 @@ package ocp import ( "encoding/json" + "reflect" "testing" ) func TestNetworkConfig_UnmarshalCalico(t *testing.T) { tests := []struct { - name string - input string - wantType NadType - wantNetwork string - wantVLAN uint16 + name string + input string + wantType NadType + wantNetwork string + wantVLAN uint16 + wantIPv4Pools []string }{ { name: "CalicoL2WithExplicitVLAN", @@ -63,6 +65,38 @@ func TestNetworkConfig_UnmarshalCalico(t *testing.T) { wantNetwork: "", wantVLAN: 0, }, + { + // ipam.ipv4_pools pins address assignment to specific pools; + // both pool names and CIDRs are legal entries. + name: "CalicoIPAMWithPinnedPools", + input: `{"type":"calico","network":"vrf-red","ipam":{"type":"calico-ipam","ipv4_pools":["vrf-red-pool","10.66.0.0/24"]}}`, + wantType: CalicoCNIType, + wantNetwork: "vrf-red", + wantIPv4Pools: []string{"vrf-red-pool", "10.66.0.0/24"}, + }, + { + // ipam block present, ipv4_pools absent → nil (no pin). + name: "CalicoIPAMWithoutPinnedPools", + input: `{"type":"calico","network":"vrf-red","ipam":{"type":"calico-ipam"}}`, + wantType: CalicoCNIType, + wantNetwork: "vrf-red", + }, + { + // An explicitly empty list still means "no pin"; callers key off + // len(IPv4Pools) == 0. + name: "CalicoIPAMEmptyPinnedPools", + input: `{"type":"calico","network":"vrf-red","ipam":{"type":"calico-ipam","ipv4_pools":[]}}`, + wantType: CalicoCNIType, + wantNetwork: "vrf-red", + wantIPv4Pools: []string{}, + }, + { + // A non-calico IPAM has no ipv4_pools convention → nil. + name: "CalicoWithNonCalicoIPAM", + input: `{"type":"calico","network":"vrf-red","ipam":{"type":"dhcp"}}`, + wantType: CalicoCNIType, + wantNetwork: "vrf-red", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -79,6 +113,9 @@ func TestNetworkConfig_UnmarshalCalico(t *testing.T) { if cfg.VLAN != tt.wantVLAN { t.Errorf("VLAN = %d, want %d", cfg.VLAN, tt.wantVLAN) } + if !reflect.DeepEqual(cfg.IPv4Pools, tt.wantIPv4Pools) { + t.Errorf("IPv4Pools = %#v, want %#v", cfg.IPv4Pools, tt.wantIPv4Pools) + } }) } } diff --git a/pkg/lib/client/calico/bgppeer.go b/pkg/lib/client/calico/bgppeer.go new file mode 100644 index 0000000000..6d71616692 --- /dev/null +++ b/pkg/lib/client/calico/bgppeer.go @@ -0,0 +1,49 @@ +package calico + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// BGPPeerGVK is the GroupVersionKind of projectcalico.org/v3 BGPPeer. +var BGPPeerGVK = schema.GroupVersionKind{ + Group: "projectcalico.org", + Version: "v3", + Kind: "BGPPeer", +} + +// ListBGPPeerNetworks lists every projectcalico.org/v3 BGPPeer on the +// cluster (the CR is cluster-scoped) and returns the set of spec.network +// values found across them — the Network CRs whose routes at least one +// peer distributes. Peers without the field are skipped. +// +// When the API server does not know the BGPPeer kind at all (older Calico +// installs lack the CRD), the empty set is returned with a nil error: no +// peer can be bound to any Network in that state, which is exactly what +// the empty set reports. Any other list failure propagates. +func ListBGPPeerNetworks(ctx context.Context, c client.Client) (map[string]bool, error) { + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(BGPPeerGVK.GroupVersion().WithKind("BGPPeerList")) + if err := c.List(ctx, ul); err != nil { + if meta.IsNoMatchError(err) { + return map[string]bool{}, nil + } + return nil, err + } + networks := map[string]bool{} + for i := range ul.Items { + network, _, err := unstructured.NestedString(ul.Items[i].Object, "spec", "network") + if err != nil { + return nil, fmt.Errorf("bgppeer %q: parse spec.network: %w", ul.Items[i].GetName(), err) + } + if network != "" { + networks[network] = true + } + } + return networks, nil +} diff --git a/pkg/lib/client/calico/bgppeer_test.go b/pkg/lib/client/calico/bgppeer_test.go new file mode 100644 index 0000000000..9dda42baaa --- /dev/null +++ b/pkg/lib/client/calico/bgppeer_test.go @@ -0,0 +1,108 @@ +package calico + +import ( + "context" + "errors" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +// makeBGPPeer builds an unstructured projectcalico.org/v3 BGPPeer. An empty +// network leaves spec.network unset — the shape of a peer that predates the +// field or peers with the whole cluster rather than a Network. +func makeBGPPeer(name, network string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(BGPPeerGVK) + u.SetName(name) + _ = unstructured.SetNestedField(u.Object, int64(64512), "spec", "asNumber") + if network != "" { + _ = unstructured.SetNestedField(u.Object, network, "spec", "network") + } + return u +} + +func newFakeClientWithBGPPeers(objs ...runtime.Object) client.Client { + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(BGPPeerGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(BGPPeerGVK.GroupVersion().WithKind("BGPPeerList"), &unstructured.UnstructuredList{}) + return fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build() +} + +func TestListBGPPeerNetworks_Empty(t *testing.T) { + c := newFakeClientWithBGPPeers() + got, err := ListBGPPeerNetworks(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("got %v, want empty set", got) + } +} + +func TestListBGPPeerNetworks_Mixed(t *testing.T) { + // Peers with spec.network contribute their Network name; peers without + // the field are skipped. Two peers naming the same Network dedupe. + c := newFakeClientWithBGPPeers( + makeBGPPeer("vrf-red-peer", "vrf-red"), + makeBGPPeer("vrf-red-peer-2", "vrf-red"), + makeBGPPeer("vrf-blue-peer", "vrf-blue"), + makeBGPPeer("global-peer", ""), + ) + got, err := ListBGPPeerNetworks(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := map[string]bool{"vrf-red": true, "vrf-blue": true} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestListBGPPeerNetworks_KindAbsent(t *testing.T) { + // The API server does not know the BGPPeer kind (older install, no + // CRD). Reported as the empty set — no peer bound anywhere — not as an + // error. + c := fake.NewClientBuilder(). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(ctx context.Context, _ client.WithWatch, _ client.ObjectList, _ ...client.ListOption) error { + return &meta.NoKindMatchError{GroupKind: BGPPeerGVK.GroupKind()} + }, + }).Build() + got, err := ListBGPPeerNetworks(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("got %v, want empty set", got) + } +} + +func TestListBGPPeerNetworks_ListError(t *testing.T) { + // Any list failure other than kind-absent must propagate. + boom := errors.New("connection refused") + c := fake.NewClientBuilder(). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(ctx context.Context, _ client.WithWatch, _ client.ObjectList, _ ...client.ListOption) error { + return boom + }, + }).Build() + if _, err := ListBGPPeerNetworks(context.Background(), c); !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } +} + +func TestListBGPPeerNetworks_BadNetworkType(t *testing.T) { + peer := makeBGPPeer("bad-peer", "") + _ = unstructured.SetNestedField(peer.Object, int64(7), "spec", "network") + c := newFakeClientWithBGPPeers(peer) + if _, err := ListBGPPeerNetworks(context.Background(), c); err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/pkg/lib/client/calico/felixconfig.go b/pkg/lib/client/calico/felixconfig.go index 4e05735dd0..b492cadaab 100644 --- a/pkg/lib/client/calico/felixconfig.go +++ b/pkg/lib/client/calico/felixconfig.go @@ -27,27 +27,101 @@ const felixConfigurationName = "default" // canonical name turns out to differ, correcting this constant is the fix. const bpfEnabledField = "bpfEnabled" -// GetBPFEnabled reports whether the destination Calico install runs the BPF -// dataplane, by reading spec.bpfEnabled of the cluster-scoped -// FelixConfiguration named "default". Felix defaults to the non-BPF -// dataplane, so "not enabled" is reported when the field is false or absent, -// when no "default" FelixConfiguration exists, and when the API server does -// not know the FelixConfiguration kind at all. Any other GET failure is -// returned as an error. -func GetBPFEnabled(ctx context.Context, c client.Client) (bool, error) { +// nftablesModeField is the FelixConfiguration spec field that switches Felix +// to the nftables dataplane ("Disabled" | "Auto" | "Enabled"; Felix treats an +// absent field as Disabled). Single-constant field name, like bpfEnabledField. +const nftablesModeField = "nftablesMode" + +// routeTableRangesField is the FelixConfiguration spec field listing the +// kernel route table ranges Calico claims for its own route programming. +// Single-constant field name, like bpfEnabledField. +const routeTableRangesField = "routeTableRanges" + +// NftablesModeEnabled is the spec.nftablesMode value that runs Felix on the +// nftables dataplane — the only dataplane VRF networking supports. +const NftablesModeEnabled = "Enabled" + +// RouteTableRange is a parsed entry of FelixConfiguration +// spec.routeTableRanges. +type RouteTableRange struct { + Min int64 + Max int64 +} + +// FelixConfig holds the dataplane facts the plan validators read from the +// cluster-wide "default" FelixConfiguration. +type FelixConfig struct { + // BPFEnabled reports spec.bpfEnabled (false when absent — Felix + // defaults to the non-BPF dataplane). + BPFEnabled bool + // NftablesMode reports spec.nftablesMode verbatim; empty when absent + // (Felix then treats the mode as Disabled). + NftablesMode string + // RouteTableRanges reports spec.routeTableRanges; nil when the field + // is absent — callers must not substitute Felix's built-in defaults. + RouteTableRanges []RouteTableRange +} + +// GetFelixConfig reads the cluster-scoped FelixConfiguration named "default" +// and returns its dataplane facts. When no "default" FelixConfiguration +// exists, or the API server does not know the FelixConfiguration kind at +// all, Felix runs on its built-in defaults and the zero-value FelixConfig is +// returned. Any other GET failure is returned as an error. +func GetFelixConfig(ctx context.Context, c client.Client) (*FelixConfig, error) { + fc := &FelixConfig{} u := &unstructured.Unstructured{} u.SetGroupVersionKind(FelixConfigurationGVK) err := c.Get(ctx, client.ObjectKey{Name: felixConfigurationName}, u) switch { case err == nil: case meta.IsNoMatchError(err) || k8serr.IsNotFound(err): - return false, nil + return fc, nil default: - return false, err + return nil, err + } + fc.BPFEnabled, _, err = unstructured.NestedBool(u.Object, "spec", bpfEnabledField) + if err != nil { + return nil, fmt.Errorf("parse spec.%s: %w", bpfEnabledField, err) + } + fc.NftablesMode, _, err = unstructured.NestedString(u.Object, "spec", nftablesModeField) + if err != nil { + return nil, fmt.Errorf("parse spec.%s: %w", nftablesModeField, err) } - enabled, _, err := unstructured.NestedBool(u.Object, "spec", bpfEnabledField) + rangesRaw, found, err := unstructured.NestedSlice(u.Object, "spec", routeTableRangesField) if err != nil { - return false, fmt.Errorf("parse spec.%s: %w", bpfEnabledField, err) + return nil, fmt.Errorf("parse spec.%s: %w", routeTableRangesField, err) + } + if found { + fc.RouteTableRanges = make([]RouteTableRange, 0, len(rangesRaw)) + for i, r := range rangesRaw { + m, ok := r.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("spec.%s[%d]: not an object", routeTableRangesField, i) + } + var rng RouteTableRange + if rng.Min, ok = asInt64(m["min"]); !ok { + return nil, fmt.Errorf("spec.%s[%d].min: not an integer (%v)", routeTableRangesField, i, m["min"]) + } + if rng.Max, ok = asInt64(m["max"]); !ok { + return nil, fmt.Errorf("spec.%s[%d].max: not an integer (%v)", routeTableRangesField, i, m["max"]) + } + fc.RouteTableRanges = append(fc.RouteTableRanges, rng) + } + } + return fc, nil +} + +// GetBPFEnabled reports whether the destination Calico install runs the BPF +// dataplane, by reading spec.bpfEnabled of the cluster-scoped +// FelixConfiguration named "default". Felix defaults to the non-BPF +// dataplane, so "not enabled" is reported when the field is false or absent, +// when no "default" FelixConfiguration exists, and when the API server does +// not know the FelixConfiguration kind at all. Any other GET failure is +// returned as an error. +func GetBPFEnabled(ctx context.Context, c client.Client) (bool, error) { + fc, err := GetFelixConfig(ctx, c) + if err != nil { + return false, err } - return enabled, nil + return fc.BPFEnabled, nil } diff --git a/pkg/lib/client/calico/felixconfig_test.go b/pkg/lib/client/calico/felixconfig_test.go index 5f4c0a6867..8fbe17e20e 100644 --- a/pkg/lib/client/calico/felixconfig_test.go +++ b/pkg/lib/client/calico/felixconfig_test.go @@ -3,6 +3,7 @@ package calico import ( "context" "errors" + "reflect" "testing" "k8s.io/apimachinery/pkg/api/meta" @@ -105,6 +106,131 @@ func TestGetBPFEnabled_KindAbsent(t *testing.T) { } } +func TestGetFelixConfig_NftablesEnabled(t *testing.T) { + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "nftablesMode": "Enabled", + })) + got, err := GetFelixConfig(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.BPFEnabled { + t.Error("BPFEnabled = true, want false") + } + if got.NftablesMode != NftablesModeEnabled { + t.Errorf("NftablesMode = %q, want %q", got.NftablesMode, NftablesModeEnabled) + } + if got.RouteTableRanges != nil { + t.Errorf("RouteTableRanges = %+v, want nil", got.RouteTableRanges) + } +} + +func TestGetFelixConfig_NftablesAuto(t *testing.T) { + // "Auto" is reported verbatim — deciding what it means is the + // validator's job. + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "nftablesMode": "Auto", + })) + got, err := GetFelixConfig(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.NftablesMode != "Auto" { + t.Errorf("NftablesMode = %q, want Auto", got.NftablesMode) + } +} + +func TestGetFelixConfig_FieldsAbsent(t *testing.T) { + // bpfEnabled absent → false; nftablesMode absent → empty (Felix treats + // it as Disabled); routeTableRanges absent → nil, not a guessed default. + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "logSeverityScreen": "Info", + })) + got, err := GetFelixConfig(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.BPFEnabled { + t.Error("BPFEnabled = true, want false") + } + if got.NftablesMode != "" { + t.Errorf("NftablesMode = %q, want empty", got.NftablesMode) + } + if got.RouteTableRanges != nil { + t.Errorf("RouteTableRanges = %+v, want nil", got.RouteTableRanges) + } +} + +func TestGetFelixConfig_RouteTableRanges(t *testing.T) { + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "bpfEnabled": true, + "nftablesMode": "Disabled", + "routeTableRanges": []interface{}{ + map[string]interface{}{"min": int64(1), "max": int64(250)}, + map[string]interface{}{"min": int64(300), "max": int64(400)}, + }, + })) + got, err := GetFelixConfig(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got.BPFEnabled { + t.Error("BPFEnabled = false, want true") + } + if got.NftablesMode != "Disabled" { + t.Errorf("NftablesMode = %q, want Disabled", got.NftablesMode) + } + want := []RouteTableRange{{Min: 1, Max: 250}, {Min: 300, Max: 400}} + if !reflect.DeepEqual(got.RouteTableRanges, want) { + t.Errorf("RouteTableRanges = %+v, want %+v", got.RouteTableRanges, want) + } +} + +func TestGetFelixConfig_DefaultNotFound(t *testing.T) { + // No "default" FelixConfiguration: Felix runs on built-in defaults — + // reported as the zero-value facts, not an error. + perNode := makeFelixConfiguration(map[string]interface{}{"bpfEnabled": true}) + perNode.SetName("node.worker-1") + c := newFelixFakeClientWith(perNode) + got, err := GetFelixConfig(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.BPFEnabled || got.NftablesMode != "" || got.RouteTableRanges != nil { + t.Errorf("got %+v, want zero-value facts", got) + } +} + +func TestGetFelixConfig_BadRouteTableRange(t *testing.T) { + cases := []struct { + name string + ranges []interface{} + }{ + { + name: "entry not an object", + ranges: []interface{}{"1-250"}, + }, + { + name: "missing min", + ranges: []interface{}{map[string]interface{}{"max": int64(250)}}, + }, + { + name: "non-integer max", + ranges: []interface{}{map[string]interface{}{"min": int64(1), "max": "250"}}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ + "routeTableRanges": tc.ranges, + })) + if _, err := GetFelixConfig(context.Background(), c); err == nil { + t.Fatal("expected error, got nil") + } + }) + } +} + func TestGetBPFEnabled_BadFieldType(t *testing.T) { c := newFelixFakeClientWith(makeFelixConfiguration(map[string]interface{}{ "bpfEnabled": "yes", diff --git a/pkg/lib/client/calico/network.go b/pkg/lib/client/calico/network.go index d970b4a6b3..19822c1533 100644 --- a/pkg/lib/client/calico/network.go +++ b/pkg/lib/client/calico/network.go @@ -28,15 +28,34 @@ type L2BridgeSpec struct { VLANs []VLANEntry } +// VRFHostEntry is a parsed entry of Network.spec.vrf.hostConfig — the +// per-node-set VRF placement the viability checks inspect. staticRoutes +// is deliberately not modelled; no check reads it. +type VRFHostEntry struct { + // NodeSelector is the Calico node selector choosing which nodes the + // entry applies to. Empty means the field was absent or empty — the + // entry then applies to every node. + NodeSelector string + // RouteTableIndex is the kernel route table the VRF owns on matching + // nodes. + RouteTableIndex int64 + // HasHostInterfaces reports whether the entry names at least one host + // interface (hostInterfaces non-empty). Only presence is modelled: the + // checks care that an off-node path exists, not which interface it is. + HasHostInterfaces bool +} + // Network is a thin parsed view of projectcalico.org/v3 Network. // // spec is a strict one-of: an l2Bridge Network carries VLANs and is the type // identity preservation supports; a vrf Network is routed (L3, no VLANs). -// IsVRF only classifies the Network — the VRF spec itself is not modelled. +// For a vrf Network, VRFHostConfig carries the hostConfig entries the VRF +// viability checks need; the rest of the VRF spec is not modelled. type Network struct { - Name string - L2Bridge *L2BridgeSpec // nil when the Network has no l2Bridge spec - IsVRF bool // true when the Network has a vrf spec + Name string + L2Bridge *L2BridgeSpec // nil when the Network has no l2Bridge spec + IsVRF bool // true when the Network has a vrf spec + VRFHostConfig []VRFHostEntry // parsed spec.vrf.hostConfig (IsVRF only) } // GetNetwork fetches projectcalico.org/v3 Network/name from the destination @@ -50,16 +69,42 @@ func GetNetwork(ctx context.Context, c client.Client, name string) (*Network, er return parseNetwork(u) } +// ListNetworks lists every projectcalico.org/v3 Network CR on the cluster, +// parsed into the same view GetNetwork returns. The VRF viability checks use +// it to scan for route-table collisions across Network CRs. +func ListNetworks(ctx context.Context, c client.Client) ([]Network, error) { + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(NetworkGVK.GroupVersion().WithKind("NetworkList")) + if err := c.List(ctx, ul); err != nil { + return nil, err + } + networks := make([]Network, 0, len(ul.Items)) + for i := range ul.Items { + n, err := parseNetwork(&ul.Items[i]) + if err != nil { + return nil, fmt.Errorf("network %q: %w", ul.Items[i].GetName(), err) + } + networks = append(networks, *n) + } + return networks, nil +} + func parseNetwork(u *unstructured.Unstructured) (*Network, error) { n := &Network{Name: u.GetName()} - // Presence-only check: a vrf spec marks the Network as a VRF (routed) - // network, which the validators reject as unsupported. - _, isVRF, err := unstructured.NestedMap(u.Object, "spec", "vrf") + // A vrf spec marks the Network as a VRF (routed) network; its + // hostConfig entries feed the VRF viability checks. + vrfMap, isVRF, err := unstructured.NestedMap(u.Object, "spec", "vrf") if err != nil { return nil, fmt.Errorf("parse spec.vrf: %w", err) } n.IsVRF = isVRF + if isVRF { + n.VRFHostConfig, err = parseVRFHostConfig(vrfMap) + if err != nil { + return nil, err + } + } l2Bridge, found, err := unstructured.NestedMap(u.Object, "spec", "l2Bridge") if err != nil { @@ -91,6 +136,72 @@ func parseNetwork(u *unstructured.Unstructured) (*Network, error) { return n, nil } +// parseVRFHostConfig parses spec.vrf.hostConfig into the minimal entry view +// the VRF viability checks need: nodeSelector, routeTableIndex and whether +// hostInterfaces names anything. routeTableIndex is required by the API, so +// a missing or non-integer value is a parse error; nodeSelector is optional +// (absent means all nodes). hostInterfaces entries come in two API vintages +// — objects with a name field, or plain strings — so only the list's +// non-emptiness is read; the elements themselves are never interpreted. +func parseVRFHostConfig(vrfMap map[string]interface{}) ([]VRFHostEntry, error) { + entriesRaw, found, err := unstructured.NestedSlice(vrfMap, "hostConfig") + if err != nil { + return nil, fmt.Errorf("parse spec.vrf.hostConfig: %w", err) + } + if !found { + return nil, nil + } + entries := make([]VRFHostEntry, 0, len(entriesRaw)) + for i, e := range entriesRaw { + m, ok := e.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("spec.vrf.hostConfig[%d]: not an object", i) + } + selector, _, err := unstructured.NestedString(m, "nodeSelector") + if err != nil { + return nil, fmt.Errorf("spec.vrf.hostConfig[%d].nodeSelector: %w", i, err) + } + idxRaw, found, err := unstructured.NestedFieldNoCopy(m, "routeTableIndex") + if err != nil { + return nil, fmt.Errorf("spec.vrf.hostConfig[%d].routeTableIndex: %w", i, err) + } + if !found { + return nil, fmt.Errorf("spec.vrf.hostConfig[%d].routeTableIndex: missing", i) + } + index, ok := asInt64(idxRaw) + if !ok { + return nil, fmt.Errorf("spec.vrf.hostConfig[%d].routeTableIndex: not an integer (%v)", i, idxRaw) + } + ifaces, _, err := unstructured.NestedSlice(m, "hostInterfaces") + if err != nil { + return nil, fmt.Errorf("spec.vrf.hostConfig[%d].hostInterfaces: %w", i, err) + } + entries = append(entries, VRFHostEntry{ + NodeSelector: selector, + RouteTableIndex: index, + HasHostInterfaces: len(ifaces) > 0, + }) + } + return entries, nil +} + +// asInt64 coerces an unstructured numeric field to int64. JSON decoding +// yields int64 for integers, but some encoders produce float64; any other +// type, and any float with a fractional part, reports false. +func asInt64(v interface{}) (int64, bool) { + switch n := v.(type) { + case int64: + return n, true + case float64: + i := int64(n) + if float64(i) != n { + return 0, false + } + return i, true + } + return 0, false +} + func parseVLANEntry(m map[string]interface{}, idx int) (VLANEntry, error) { entry := VLANEntry{} diff --git a/pkg/lib/client/calico/network_test.go b/pkg/lib/client/calico/network_test.go index 9c832f8ef2..826f62d231 100644 --- a/pkg/lib/client/calico/network_test.go +++ b/pkg/lib/client/calico/network_test.go @@ -2,6 +2,7 @@ package calico import ( "context" + "reflect" "testing" k8serr "k8s.io/apimachinery/pkg/api/errors" @@ -80,7 +81,7 @@ func TestGetNetwork_VRF(t *testing.T) { nw := makeNetwork("routed-net", map[string]interface{}{ "vrf": map[string]interface{}{ "hostConfig": []interface{}{ - map[string]interface{}{"nodeSelector": "all()"}, + map[string]interface{}{"nodeSelector": "all()", "routeTableIndex": int64(101)}, }, }, }) @@ -97,6 +98,189 @@ func TestGetNetwork_VRF(t *testing.T) { if got.L2Bridge != nil { t.Errorf("L2Bridge = %+v, want nil", got.L2Bridge) } + want := []VRFHostEntry{{NodeSelector: "all()", RouteTableIndex: 101}} + if !reflect.DeepEqual(got.VRFHostConfig, want) { + t.Errorf("VRFHostConfig = %+v, want %+v", got.VRFHostConfig, want) + } +} + +func TestGetNetwork_VRFHostConfigEntries(t *testing.T) { + // Multi-entry hostConfig: a scoped entry, an entry with the selector + // absent (matches all nodes → empty string), and an entry with an + // explicitly empty selector (same meaning). + nw := makeNetwork("routed-net", map[string]interface{}{ + "vrf": map[string]interface{}{ + "hostConfig": []interface{}{ + map[string]interface{}{"nodeSelector": "rack == 'a'", "routeTableIndex": int64(101)}, + map[string]interface{}{"routeTableIndex": int64(102)}, + map[string]interface{}{"nodeSelector": "", "routeTableIndex": int64(103)}, + }, + }, + }) + cb, _ := newFakeClientWith(nw) + c := cb.Build() + + got, err := GetNetwork(context.Background(), c, "routed-net") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []VRFHostEntry{ + {NodeSelector: "rack == 'a'", RouteTableIndex: 101}, + {NodeSelector: "", RouteTableIndex: 102}, + {NodeSelector: "", RouteTableIndex: 103}, + } + if !reflect.DeepEqual(got.VRFHostConfig, want) { + t.Errorf("VRFHostConfig = %+v, want %+v", got.VRFHostConfig, want) + } +} + +func TestGetNetwork_VRFHostInterfaces(t *testing.T) { + // hostInterfaces comes in two API vintages — objects with a name field, + // or plain strings. Any non-empty list counts; empty or absent does not. + nw := makeNetwork("routed-net", map[string]interface{}{ + "vrf": map[string]interface{}{ + "hostConfig": []interface{}{ + map[string]interface{}{ + "routeTableIndex": int64(101), + "hostInterfaces": []interface{}{map[string]interface{}{"name": "eth1"}}, + }, + map[string]interface{}{ + "routeTableIndex": int64(102), + "hostInterfaces": []interface{}{"eth1", "eth2"}, + }, + map[string]interface{}{ + "routeTableIndex": int64(103), + "hostInterfaces": []interface{}{}, + }, + map[string]interface{}{ + "routeTableIndex": int64(104), + }, + }, + }, + }) + cb, _ := newFakeClientWith(nw) + c := cb.Build() + + got, err := GetNetwork(context.Background(), c, "routed-net") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []VRFHostEntry{ + {RouteTableIndex: 101, HasHostInterfaces: true}, + {RouteTableIndex: 102, HasHostInterfaces: true}, + {RouteTableIndex: 103}, + {RouteTableIndex: 104}, + } + if !reflect.DeepEqual(got.VRFHostConfig, want) { + t.Errorf("VRFHostConfig = %+v, want %+v", got.VRFHostConfig, want) + } +} + +func TestGetNetwork_VRFBadHostConfig(t *testing.T) { + cases := []struct { + name string + entry map[string]interface{} + }{ + { + name: "missing routeTableIndex", + entry: map[string]interface{}{"nodeSelector": "all()"}, + }, + { + name: "non-integer routeTableIndex", + entry: map[string]interface{}{"routeTableIndex": 1.5}, + }, + { + name: "string routeTableIndex", + entry: map[string]interface{}{"routeTableIndex": "101"}, + }, + { + name: "hostInterfaces not a list", + entry: map[string]interface{}{ + "routeTableIndex": int64(101), + "hostInterfaces": "eth1", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + nw := makeNetwork("bad-vrf", map[string]interface{}{ + "vrf": map[string]interface{}{ + "hostConfig": []interface{}{tc.entry}, + }, + }) + cb, _ := newFakeClientWith(nw) + c := cb.Build() + + if _, err := GetNetwork(context.Background(), c, "bad-vrf"); err == nil { + t.Fatal("expected error, got nil") + } + }) + } +} + +func TestListNetworks(t *testing.T) { + l2 := makeNetwork("vlan-net", map[string]interface{}{ + "l2Bridge": map[string]interface{}{ + "vlans": []interface{}{ + map[string]interface{}{ + "vlan": map[string]interface{}{"id": int64(100)}, + "subnets": []interface{}{map[string]interface{}{"cidr": "10.100.0.0/24"}}, + }, + }, + }, + }) + vrfA := makeNetwork("vrf-a", map[string]interface{}{ + "vrf": map[string]interface{}{ + "hostConfig": []interface{}{ + map[string]interface{}{"routeTableIndex": int64(101)}, + }, + }, + }) + vrfB := makeNetwork("vrf-b", map[string]interface{}{ + "vrf": map[string]interface{}{ + "hostConfig": []interface{}{ + map[string]interface{}{"nodeSelector": "rack == 'b'", "routeTableIndex": int64(102)}, + }, + }, + }) + cb, _ := newFakeClientWith(l2, vrfA, vrfB) + c := cb.Build() + + got, err := ListNetworks(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + byName := map[string]Network{} + for _, n := range got { + byName[n.Name] = n + } + if n := byName["vlan-net"]; n.IsVRF || n.L2Bridge == nil || len(n.L2Bridge.VLANs) != 1 { + t.Errorf("vlan-net parsed as %+v, want one-VLAN l2Bridge network", n) + } + if n := byName["vrf-a"]; !n.IsVRF || + !reflect.DeepEqual(n.VRFHostConfig, []VRFHostEntry{{RouteTableIndex: 101}}) { + t.Errorf("vrf-a parsed as %+v, want all-nodes VRF on table 101", n) + } + if n := byName["vrf-b"]; !n.IsVRF || + !reflect.DeepEqual(n.VRFHostConfig, []VRFHostEntry{{NodeSelector: "rack == 'b'", RouteTableIndex: 102}}) { + t.Errorf("vrf-b parsed as %+v, want scoped VRF on table 102", n) + } +} + +func TestListNetworks_Empty(t *testing.T) { + cb, _ := newFakeClientWith() + c := cb.Build() + + got, err := ListNetworks(context.Background(), c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("len = %d, want 0", len(got)) + } } func TestGetNetwork_SingleVLAN(t *testing.T) { From 75a0de530fe2ef1eb80bbf4a8fc41a795eba25bb Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Thu, 16 Jul 2026 12:13:01 +0100 Subject: [PATCH 06/11] Cleanup overly-verbose comments Signed-off-by: Alex O'Regan --- pkg/controller/plan/adapter/base/calico.go | 30 ++-- .../plan/adapter/base/calico_validation.go | 6 +- pkg/controller/plan/adapter/base/doc.go | 18 +-- .../plan/adapter/hyperv/validator.go | 4 +- .../plan/adapter/nutanix/validator.go | 4 +- pkg/controller/plan/adapter/ocp/validator.go | 4 +- .../plan/adapter/openstack/validator.go | 4 +- .../plan/adapter/ovfbase/validator.go | 4 +- .../plan/adapter/ovirt/validator.go | 4 +- .../plan/adapter/vsphere/builder.go | 5 +- .../plan/adapter/vsphere/validator.go | 151 +++--------------- .../plan/adapter/vsphere/validator_test.go | 12 +- pkg/controller/provider/model/ocp/model.go | 2 +- pkg/lib/client/calico/bgppeer.go | 8 +- pkg/lib/client/calico/felixconfig.go | 10 +- pkg/lib/client/calico/ippool.go | 7 +- pkg/lib/client/calico/network.go | 10 +- pkg/provider/ec2/controller/validator/noop.go | 4 +- 18 files changed, 81 insertions(+), 206 deletions(-) diff --git a/pkg/controller/plan/adapter/base/calico.go b/pkg/controller/plan/adapter/base/calico.go index e34bbcc3a8..f7e44b89f6 100644 --- a/pkg/controller/plan/adapter/base/calico.go +++ b/pkg/controller/plan/adapter/base/calico.go @@ -18,14 +18,11 @@ const ( CalicoAnnHwAddrFmt = "cni.projectcalico.org/%s.hwAddr" CalicoAnnIPsFmt = "cni.projectcalico.org/%s.ipAddrs" - // Unscoped (Calico primary-NIC path) annotation keys. Applied to the - // VM's pod template once, addressing the pod's primary interface - // (Calico's default). The Networks annotation pulls in an L2 attach via - // a named projectcalico.org/v3 Network CR; absence means default L3 - // IPAM. The Vlan annotation selects the 802.1Q VLAN within that - // Network; every Network reference requires an explicit VLAN (a zero - // VLAN means "not set" and Plan validation rejects it whenever a - // network is named), so Vlan is always written alongside Networks. + // Unscoped (Calico primary-NIC path) annotation keys, addressing the + // pod's primary interface. Networks names a projectcalico.org/v3 + // Network CR for L2 attach (absent means default L3 IPAM); Vlan + // selects the 802.1Q VLAN within it and is always written alongside + // Networks. CalicoAnnPrimaryHwAddr = "cni.projectcalico.org/hwAddr" CalicoAnnPrimaryIPs = "cni.projectcalico.org/ipAddrs" CalicoAnnPrimaryNetwork = "cni.projectcalico.org/networks" @@ -34,15 +31,13 @@ const ( // AnnAllowPodBridgeNetworkLiveMigration is KubeVirt's opt-in for live // migration of VMs whose pod-network interface uses Bridge binding. // Calico-primary VMs are bridge-bound, so without this annotation they - // cannot live-migrate — and Calico's IP persistence across live - // migration is a headline capability of the L2 feature. + // cannot live-migrate. AnnAllowPodBridgeNetworkLiveMigration = "kubevirt.io/allow-pod-bridge-network-live-migration" ) // SetCalicoMAC writes the cni.projectcalico.org/.hwAddr annotation // onto m. No-op when mac is empty (inventory tolerates NICs without a MAC; -// an empty hwAddr annotation would fail the pod at CNI ADD). Lazy-inits -// Annotations when nil. +// an empty hwAddr annotation would fail the pod at CNI ADD). func SetCalicoMAC(m *meta.ObjectMeta, ifname, mac string) { if mac == "" { return @@ -55,7 +50,6 @@ func SetCalicoMAC(m *meta.ObjectMeta, ifname, mac string) { // SetCalicoStaticIPs JSON-marshals ips and writes the // cni.projectcalico.org/.ipAddrs annotation. No-op when ips is empty. -// Lazy-inits Annotations when nil. func SetCalicoStaticIPs(m *meta.ObjectMeta, ifname string, ips []string) error { if len(ips) == 0 { return nil @@ -90,7 +84,7 @@ type CalicoPrimaryParams struct { } // SetCalicoPrimaryMAC writes the unscoped cni.projectcalico.org/hwAddr -// annotation. No-op when mac is empty. Lazy-inits Annotations when nil. +// annotation. No-op when mac is empty. func SetCalicoPrimaryMAC(m *meta.ObjectMeta, mac string) { if mac == "" { return @@ -103,7 +97,6 @@ func SetCalicoPrimaryMAC(m *meta.ObjectMeta, mac string) { // SetCalicoPrimaryStaticIPs JSON-marshals ips and writes the unscoped // cni.projectcalico.org/ipAddrs annotation. No-op when ips is empty. -// Lazy-inits Annotations when nil. func SetCalicoPrimaryStaticIPs(m *meta.ObjectMeta, ips []string) error { if len(ips) == 0 { return nil @@ -121,7 +114,7 @@ func SetCalicoPrimaryStaticIPs(m *meta.ObjectMeta, ips []string) error { // SetCalicoPrimaryNetwork writes the cni.projectcalico.org/networks // annotation with the named Calico Network CR. No-op when network is empty -// (default L3 IPAM). Lazy-inits Annotations when nil. +// (default L3 IPAM). func SetCalicoPrimaryNetwork(m *meta.ObjectMeta, network string) { if network == "" { return @@ -133,10 +126,7 @@ func SetCalicoPrimaryNetwork(m *meta.ObjectMeta, network string) { } // SetCalicoPrimaryVlan writes the cni.projectcalico.org/vlan annotation as a -// decimal 802.1Q VLAN ID. No-op when vlan is zero — zero means "not set", -// and Plan validation rejects it whenever a Network is named, so a named -// Network is always accompanied by this annotation. Lazy-inits Annotations -// when nil. +// decimal 802.1Q VLAN ID. No-op when vlan is zero ("not set"). func SetCalicoPrimaryVlan(m *meta.ObjectMeta, vlan uint16) { if vlan == 0 { return diff --git a/pkg/controller/plan/adapter/base/calico_validation.go b/pkg/controller/plan/adapter/base/calico_validation.go index 75f59fc1c8..233aeb6e9d 100644 --- a/pkg/controller/plan/adapter/base/calico_validation.go +++ b/pkg/controller/plan/adapter/base/calico_validation.go @@ -43,9 +43,9 @@ type CalicoValidationCache struct { // rather than a VM. Surfaced by ValidateCalicoNADs and rendered into the // plan-level CalicoNetworkInvalid condition. // -// All fields are comparable types, so the struct compares with == (tests -// rely on that; the per-VM CalicoIssue type is additionally used as a map -// key, and this type stays parallel to it). +// Fields must stay comparable (no slices/maps): the struct is compared +// with == and stays parallel to the per-VM CalicoIssue, which keys a +// dedup map. type CalicoNADIssue struct { NAD types.NamespacedName Kind CalicoIssueKind diff --git a/pkg/controller/plan/adapter/base/doc.go b/pkg/controller/plan/adapter/base/doc.go index e4ec3bfe72..c073da534f 100644 --- a/pkg/controller/plan/adapter/base/doc.go +++ b/pkg/controller/plan/adapter/base/doc.go @@ -329,8 +329,8 @@ type Validator interface { // // When IP preservation is on but the VM has no findable IPv4 IPs // (IPv6-only or no GuestNetworks reported), no per-VM issue is emitted - // — the builder will likewise emit no ipAddrs annotation. Both - // behaviours are correct: preservation is best-effort. + // and the builder emits no ipAddrs annotation — preservation is + // best-effort. CalicoPrimaryIssues(vmRef ref.Ref, cache *CalicoPrimaryValidationCache) ([]CalicoPrimaryIssue, error) } @@ -346,9 +346,9 @@ const ( CalicoIssueNetworkNotFound CalicoIssueKind = "NetworkNotFound" // CalicoIssueNetworkCRDAbsent the destination cluster does not have the // projectcalico.org/v3 Network CRD installed (the Calico install is too - // old or doesn't ship the L2 feature). The NAD references a Calico - // Network but the CRD cannot be queried — distinct from a missing CR, - // which is CalicoIssueNetworkNotFound. + // old or does not ship the Network resource). The NAD references a + // Calico Network but the CRD cannot be queried — distinct from a missing + // CR, which is CalicoIssueNetworkNotFound. CalicoIssueNetworkCRDAbsent CalicoIssueKind = "NetworkCRDAbsent" // CalicoIssueVRFVlanIgnored the NAD names a VLAN but the referenced // Network is a VRF (routed L3) network. VLANs apply only to l2Bridge @@ -462,8 +462,8 @@ const ( // CalicoIssuePrimaryNetworkCRDAbsent the destination cluster has Calico // (IPPool CRD present) but the projectcalico.org/v3 Network CRD is not // installed. The user requested L2 attach via calico.network, but the - // install does not ship the L2 feature. Case A (calico.network == "") - // continues to work in this state and is not blocked. + // install does not ship the Network resource. Case A (calico.network + // == "") continues to work in this state and is not blocked. CalicoIssuePrimaryNetworkCRDAbsent CalicoIssueKind = "PrimaryNetworkCRDAbsent" // CalicoIssuePrimaryConflictsWithUDN indicates the destination namespace // is labelled for a UDN primary network — incompatible with the calico @@ -473,8 +473,8 @@ const ( // Network CR does not exist on the destination cluster. CalicoIssuePrimaryNetworkNotFound CalicoIssueKind = "PrimaryNetworkNotFound" // CalicoIssuePrimaryNetworkTypeUnsupported the named Network CR is not - // an l2Bridge network (e.g. a VRF network). Only l2Bridge networks are - // supported for identity preservation today. + // an l2Bridge network (e.g. a VRF network). Only l2Bridge networks can + // back the primary NIC; a VRF network attaches via a multus NAD instead. CalicoIssuePrimaryNetworkTypeUnsupported CalicoIssueKind = "PrimaryNetworkTypeUnsupported" // CalicoIssuePrimaryNetworkHasNoL2Bridge the named Network CR has no // l2Bridge spec — incompatible with L2 attach. diff --git a/pkg/controller/plan/adapter/hyperv/validator.go b/pkg/controller/plan/adapter/hyperv/validator.go index e9c7f6f12c..58cb18c0c7 100644 --- a/pkg/controller/plan/adapter/hyperv/validator.go +++ b/pkg/controller/plan/adapter/hyperv/validator.go @@ -315,8 +315,8 @@ func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrima return planbase.CalicoPrimaryValidationResult{}, nil } -// CalicoPrimaryIssues returns nil. The plan-level rejection in -// ValidateCalicoPrimary short-circuits before per-VM dispatch. +// CalicoPrimaryIssues returns nil; any calico-flagged entry was already +// rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { return nil, nil } diff --git a/pkg/controller/plan/adapter/nutanix/validator.go b/pkg/controller/plan/adapter/nutanix/validator.go index 402f11c13e..7f352c2768 100644 --- a/pkg/controller/plan/adapter/nutanix/validator.go +++ b/pkg/controller/plan/adapter/nutanix/validator.go @@ -132,8 +132,8 @@ func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrima return planbase.CalicoPrimaryValidationResult{}, nil } -// CalicoPrimaryIssues returns nil. The plan-level rejection in -// ValidateCalicoPrimary short-circuits before per-VM dispatch. +// CalicoPrimaryIssues returns nil; any calico-flagged entry was already +// rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { return nil, nil } diff --git a/pkg/controller/plan/adapter/ocp/validator.go b/pkg/controller/plan/adapter/ocp/validator.go index 9918dc4fa8..035a6d549c 100644 --- a/pkg/controller/plan/adapter/ocp/validator.go +++ b/pkg/controller/plan/adapter/ocp/validator.go @@ -435,8 +435,8 @@ func (r *Validator) ValidateCalicoPrimary(_ k8sclient.Client) (planbase.CalicoPr return planbase.CalicoPrimaryValidationResult{}, nil } -// CalicoPrimaryIssues returns nil. The plan-level rejection in -// ValidateCalicoPrimary short-circuits before per-VM dispatch. +// CalicoPrimaryIssues returns nil; any calico-flagged entry was already +// rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { return nil, nil } diff --git a/pkg/controller/plan/adapter/openstack/validator.go b/pkg/controller/plan/adapter/openstack/validator.go index 61ed31cce8..b749d13bfa 100644 --- a/pkg/controller/plan/adapter/openstack/validator.go +++ b/pkg/controller/plan/adapter/openstack/validator.go @@ -420,8 +420,8 @@ func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrima return planbase.CalicoPrimaryValidationResult{}, nil } -// CalicoPrimaryIssues returns nil. The plan-level rejection in -// ValidateCalicoPrimary short-circuits before per-VM dispatch. +// CalicoPrimaryIssues returns nil; any calico-flagged entry was already +// rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { return nil, nil } diff --git a/pkg/controller/plan/adapter/ovfbase/validator.go b/pkg/controller/plan/adapter/ovfbase/validator.go index 3caaac5138..937983f868 100644 --- a/pkg/controller/plan/adapter/ovfbase/validator.go +++ b/pkg/controller/plan/adapter/ovfbase/validator.go @@ -252,8 +252,8 @@ func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrima return planbase.CalicoPrimaryValidationResult{}, nil } -// CalicoPrimaryIssues returns nil. The plan-level rejection in -// ValidateCalicoPrimary short-circuits before per-VM dispatch. +// CalicoPrimaryIssues returns nil; any calico-flagged entry was already +// rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { return nil, nil } diff --git a/pkg/controller/plan/adapter/ovirt/validator.go b/pkg/controller/plan/adapter/ovirt/validator.go index 827cd7ddfd..3b0a7d1511 100644 --- a/pkg/controller/plan/adapter/ovirt/validator.go +++ b/pkg/controller/plan/adapter/ovirt/validator.go @@ -329,8 +329,8 @@ func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrima return planbase.CalicoPrimaryValidationResult{}, nil } -// CalicoPrimaryIssues returns nil. The plan-level rejection in -// ValidateCalicoPrimary short-circuits before per-VM dispatch. +// CalicoPrimaryIssues returns nil; any calico-flagged entry was already +// rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { return nil, nil } diff --git a/pkg/controller/plan/adapter/vsphere/builder.go b/pkg/controller/plan/adapter/vsphere/builder.go index 8ecc634fce..7ede39b594 100644 --- a/pkg/controller/plan/adapter/vsphere/builder.go +++ b/pkg/controller/plan/adapter/vsphere/builder.go @@ -919,7 +919,6 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err var calicoPrimaryNetwork string var calicoPrimaryVlan uint16 - // cache for network configs to avoid duplicate GETs. nadCache := map[k8stypes.NamespacedName]*ocpmodel.NetworkConfig{} numNetworks := 0 @@ -981,9 +980,7 @@ func (r *Builder) mapNetworks(vm *model.VM, object *cnv.VirtualMachineSpec) (err // user opted into identity preservation: Bridge binding // always, MAC always, IPs when the Plan preserves static // IPs, Network + Vlan annotations when the user named a - // projectcalico.org/v3 Network CR. Plan validation rejects - // a network entry without an explicit VLAN, so a named - // Network always arrives here with a non-zero Vlan. + // projectcalico.org/v3 Network CR. kInterface.Bridge = &cnv.InterfaceBridge{} calicoPrimary = true calicoPrimaryMAC = nic.MAC diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index b6877c6e9c..809c21ae25 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -734,25 +734,9 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) // ValidateCalicoNADs walks every Multus destination in the plan's network // map, fetches each Calico-referencing NAD, and validates the NAD/Network/ -// IPPool resources. NADs that pass all checks are recorded in the returned -// cache for downstream per-VM checks (see CalicoVMIssues). -// -// Resource-level short-circuit ordering: the Network is fetched and -// classified first — a VRF (routed L3) network takes its own acceptance -// branch before any VLAN handling, since VLANs don't apply to it. On the -// l2Bridge path, failure to resolve a VLAN entry prevents the IPPool -// check; failure of the IPPool check excludes the NAD from the cache -// entirely. The BPF-dataplane check runs once per plan, on the first NAD -// that resolves to an l2Bridge network; VRF references never trigger it. -// -// The VRF branch runs its own viability checks — node coverage and -// host-interface coverage of the hostConfig entries, route-table safety -// against other VRF Networks and the FelixConfiguration, a BGPPeer bound -// to the Network for cross-node routes, and the nftables-dataplane -// requirement — once per referenced Network, plus a per-NAD check that the -// NAD pins the VRF's IPPool via ipam.ipv4_pools. Like the BPF issue on the -// l2Bridge path, these are cluster/CR-level findings: the NAD itself stays -// cached so per-VM checks still run. +// IPPool resources. Classifying the referenced Network routes each NAD to +// the VRF pipeline (routed L3, no VLANs) or the l2Bridge pipeline (VLAN +// mandatory). func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValidationResult, error) { result := planbase.CalicoValidationResult{ Cache: &planbase.CalicoValidationCache{ @@ -768,10 +752,6 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid poolsLoaded := false bpfChecked := false - // VRF viability state. The checks run once per referenced VRF Network - // (several NADs may reference the same Network CR); the Network list, - // the BGPPeer-bound network set, FelixConfiguration facts, and the - // dataplane verdict are shared across the whole plan. vrfChecked := map[string]bool{} vrfDataplaneChecked := false var vrfNetworks []calicoclient.Network @@ -780,9 +760,6 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid bgpPeerNetworksLoaded := false var felixFacts *calicoclient.FelixConfig - // loadFelix reads the "default" FelixConfiguration's dataplane facts - // once per plan, lazily. A missing FelixConfiguration (or unknown kind) - // yields the zero-value facts; transient GET errors propagate. loadFelix := func(key k8stypes.NamespacedName) (*calicoclient.FelixConfig, error) { if felixFacts != nil { return felixFacts, nil @@ -795,10 +772,6 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid return fc, nil } - // loadPools lists IPPools once per plan, lazily, on the first NAD that - // needs them. IPPool CRD absent (with the Network CRD present — an - // unusual install) yields an empty pool set rather than hard-erroring - // the reconcile; the pool checks then report against an empty set. loadPools := func(key k8stypes.NamespacedName) (err error) { if poolsLoaded { return @@ -841,10 +814,12 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid continue } nadCfgs[key] = cfg + // type:calico without a "network" field is Calico's legacy L3 IPAM - // mode. Forklift's identity preservation only applies to the L2 - // path; warn the user that MAC/IP annotations will not be emitted - // for NICs mapped here. + // mode. Identity preservation on secondary NICs relies on the + // network-scoped annotations that ship with the Network resource, so + // it applies only to NADs that reference one (l2Bridge or VRF); warn + // that MAC/IP annotations will not be emitted for NICs mapped here. if cfg.Type == ocpmodel.CalicoCNIType && cfg.Network == "" { result.Warnings = append(result.Warnings, planbase.CalicoNADIssue{ NAD: key, @@ -862,9 +837,6 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid if err != nil { switch { case meta.IsNoMatchError(err): - // Network kind unknown to the apiserver — Calico is - // present but the install doesn't ship the Network CRD - // (no L2 feature). NAD refers to it, can't honour. issueBase.Kind = planbase.CalicoIssueNetworkCRDAbsent result.Issues = append(result.Issues, issueBase) continue @@ -876,69 +848,35 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String(), "network", cfg.Network) } } - // Classify the Network before any VLAN handling. A VRF network is - // routed L3 — no VLANs, no subnets — so none of the l2Bridge checks - // below (BPF dataplane, VLAN resolution, L2Workload pools) apply. - // Calico's manual IP assignment still requires the preserved IP to - // fall inside an enabled Workload-allowed pool, so the L3-eligible - // pool set is cached for the per-VM check. + + // Classify the Network before any VLAN handling (VRF / l2Bridge). if nw.IsVRF { if cfg.VLAN != 0 { - // The NAD names a VLAN, but VLANs apply only to l2Bridge - // networks; the value is ignored. Informational, not - // blocking. ib := issueBase ib.Kind = planbase.CalicoIssueVRFVlanIgnored result.Warnings = append(result.Warnings, ib) } - // Pool pinning, per NAD (the pin is a NAD property; two NADs on - // the same Network can differ): Calico's IPAM is VRF-unaware, so - // the documented convention pins the VRF's dedicated IPPool in - // the NAD's IPAM config ("ipam": {"ipv4_pools": [...]}). Without - // the pin, a freshly assigned address comes from whichever pool - // IPAM selects, which the VRF's tenant fabric may not route. - // Deliberately skipped when the plan preserves static IPs: the - // addresses are then explicit via ipAddrs and already validated - // against pools per-VM — the risk exists only for - // freshly-assigned addresses. + if len(cfg.IPv4Pools) == 0 && !r.Plan.Spec.PreserveStaticIPs { ib := issueBase ib.Kind = planbase.CalicoIssueVRFPoolNotPinned result.Warnings = append(result.Warnings, ib) } - // VRF viability checks, once per referenced Network. Issues - // attach to the first NAD referencing the Network. if !vrfChecked[cfg.Network] { vrfChecked[cfg.Network] = true - // Node coverage: an entry without a nodeSelector applies to - // every node; when every entry is scoped, the VRF exists - // only on matching nodes and a VM scheduled anywhere else - // fails to start. Whether the scoped selectors jointly - // cover every node is deliberately not evaluated — that - // would mean interpreting Calico's selector grammar, and a - // wrong "covered" verdict is worse than a cautious warning. if !vrfHasAllNodesEntry(nw.VRFHostConfig) { ib := issueBase ib.Kind = planbase.CalicoIssueVRFNodeScoped result.Warnings = append(result.Warnings, ib) } - // Host-interface coverage: an entry without hostInterfaces - // leaves pods on its nodes with no path off the node in the - // VRF, so one entry lacking them is enough to warn. if vrfHasEntryWithoutHostInterfaces(nw.VRFHostConfig) { ib := issueBase ib.Kind = planbase.CalicoIssueVRFNoHostInterfaces result.Warnings = append(result.Warnings, ib) } - // Cross-node routes: a VRF network ships with local routes - // only; routes to pods on other nodes exist only when a - // BGPPeer with spec.network naming this Network distributes - // them. The bound-network set is listed once per plan, - // lazily. An unknown BGPPeer kind yields the empty set — - // no peer bound — so the warning still fires. if !bgpPeerNetworksLoaded { bgpPeerNetworksLoaded = true bgpPeerNetworks, err = calicoclient.ListBGPPeerNetworks(context.TODO(), c) @@ -952,10 +890,6 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid result.Warnings = append(result.Warnings, ib) } - // Route-table safety: scan every Network CR once per plan, - // then check this Network's routeTableIndex values against - // the kernel-reserved tables, the other VRF Networks, and - // the FelixConfiguration routeTableRanges. if !vrfNetworksLoaded { vrfNetworksLoaded = true vrfNetworks, err = calicoclient.ListNetworks(context.TODO(), c) @@ -971,13 +905,7 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid result.Issues = append(result.Issues, criticals...) result.Warnings = append(result.Warnings, warns...) - // Dataplane, once per plan: VRF networking runs only on the - // nftables dataplane — BPF and iptables are unsupported — - // so only an explicit nftablesMode Enabled with BPF off - // passes. "Auto" leaves the choice to Felix's host - // detection, which cannot be verified from here, so it is - // treated as failing with the same remedy: set - // FelixConfiguration nftablesMode: Enabled. + // VRF networks only supported on NFTables dataplane. if !vrfDataplaneChecked { vrfDataplaneChecked = true if felix.BPFEnabled || felix.NftablesMode != calicoclient.NftablesModeEnabled { @@ -1003,9 +931,7 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid continue } - // L2 networks require the BPF dataplane. Checked once per plan, on - // the first NAD that resolves to an l2Bridge network; the issue is - // plan-scoped and does not block the remaining per-NAD checks. + // L2 networks require the BPF dataplane. if !bpfChecked { bpfChecked = true bpfEnabled, err := calicoclient.GetBPFEnabled(context.TODO(), c) @@ -1019,8 +945,6 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid } } - // A Network reference requires an explicit VLAN. Forklift does not - // auto-select, not even for a single-VLAN Network. if cfg.VLAN == 0 { issueBase.Kind = planbase.CalicoIssueVLANRequired result.Issues = append(result.Issues, issueBase) @@ -1054,15 +978,10 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid } // CalicoVMIssues returns per-VM Calico issues for vmRef using the cache -// from ValidateCalicoNADs. Per-NIC checks fire only when -// plan.Spec.PreserveStaticIPs is true. NICs whose mapped NAD is not in the -// cache are silently skipped: the NAD's failure was already reported at -// plan level via CalicoNetworkInvalid. +// from ValidateCalicoNADs. // // Issues are deduplicated by {Kind, Network, VLAN, IP}, so two NICs -// hitting the same failure mode yield a single issue. On the l2Bridge -// path, IPNotInSubnet short-circuits IPNotInIPPool for the same IP; a -// VRF-backed NAD has no subnets, so only the IPPool check runs there. +// hitting the same failure mode yield a single issue. func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { if !r.Plan.Spec.PreserveStaticIPs { return nil, nil @@ -1108,9 +1027,6 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati } issueBase := planbase.CalicoIssue{Network: resolved.Network, VLAN: resolved.VLAN.VID} ips := findInterfaceIps(vm, nic) - // Calico's ipAddrs annotation accepts at most one IPv4 per - // interface; a NIC with more can't be represented and would fail - // the pod at CNI ADD. if len(ips) > 1 { multi := issueBase multi.Kind = planbase.CalicoIssueTooManyIPs @@ -1121,9 +1037,6 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati for _, ip := range ips { perIP := issueBase perIP.IP = ip - // A VRF network is routed L3 — there are no VLAN subnets to - // check; manual IP assignment still requires an enabled - // Workload-allowed pool covering the IP. if resolved.IsVRF { if calicoclient.L3EligiblePoolForIP(resolved.EligiblePools, ip) == nil { perIP.Kind = planbase.CalicoIssueIPNotInIPPool @@ -1147,22 +1060,19 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati // ValidateCalicoPrimary validates the (at most one) calico-flagged // NetworkMap entry — a type: pod destination carrying the calico field. -// Returns plan-level issues (CRD presence, UDN conflict, -// Network/VLAN/IPPool resolution, field misplacement) plus a cache consumed -// by CalicoPrimaryIssues. // // Precondition: Plan.Referenced.Map.Network is populated by the dispatcher -// before this is called. With a nil NetworkMap, returns an empty result and -// a non-nil cache with Primary == nil. +// before this is called. // // The implementation runs the L3 IPPool list once (catches "Calico CRDs // absent" via meta.IsNoMatchError), then dispatches on case: // - Case A (calico.network == ""): L3 IPAM — filter to L3-eligible // pools; per-VM check validates IP fit. // - Case C (calico.network != ""): GetNetwork → network classification -// (non-l2Bridge types, e.g. VRF, are unsupported) → BPF-dataplane check -// → VLAN is mandatory (VLANRequired if absent) → VLAN entry → -// L2Workload pool filter scoped to the matched VLAN's subnet(s). +// (VRF networks are rejected here: they attach via multus NADs, not +// the primary NIC) → BPF-dataplane check → VLAN is mandatory +// (VLANRequired if absent) → VLAN entry → L2Workload pool filter +// scoped to the matched VLAN's subnet(s). func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPrimaryValidationResult, error) { result := planbase.CalicoPrimaryValidationResult{ Cache: &planbase.CalicoPrimaryValidationCache{}, @@ -1197,7 +1107,6 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr } } - // More than one calico-flagged pod entry in the map. if len(calicoEntries) > 1 { result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ Kind: planbase.CalicoIssuePrimaryFieldsMisplaced, @@ -1213,13 +1122,6 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr calico := entry.Destination.Calico issueBase := planbase.CalicoPrimaryIssue{Network: calico.Network, VLAN: calico.Vlan} - // Bridge binding is always on for calico-flagged mappings, so a - // DHCP-configured guest will pick up the Calico-assigned IP via the - // veth. A guest with a static in-guest IP, on the other hand, will - // keep that IP, which Calico can drop traffic from if it differs from - // the assigned address. Emit a Warn-class issue so the user sees the - // trade-off — no behavioural gate; preservation is the user's - // responsibility. if !r.Plan.Spec.PreserveStaticIPs { result.Warnings = append(result.Warnings, planbase.CalicoPrimaryIssue{ Kind: planbase.CalicoIssuePrimaryStaticIPsNotPreserved, @@ -1238,7 +1140,6 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) } - // UDN conflict: target namespace is labelled for UDN primary network. if r.Plan.DestinationHasUdnNetwork(c) { ib := issueBase ib.Kind = planbase.CalicoIssuePrimaryConflictsWithUDN @@ -1260,11 +1161,6 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr if err != nil { switch { case meta.IsNoMatchError(err): - // The Network kind is unknown to the apiserver — Calico is - // installed (IPPool present) but its install does not ship - // the L2 feature. User requested calico.network; can't honour. - // Case A (calico.network == "") would have short-circuited - // earlier without reaching this branch. ib := issueBase ib.Kind = planbase.CalicoIssuePrimaryNetworkCRDAbsent result.Issues = append(result.Issues, ib) @@ -1348,8 +1244,8 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr // the cache from ValidateCalicoPrimary. Per-NIC checks fire only when // plan.Spec.PreserveStaticIPs is true. When IP preservation is on but the // VM has no findable IPv4 IPs (IPv6-only or no GuestNetworks reported), no -// per-VM issue is emitted — the builder will likewise emit no ipAddrs -// annotation. Both behaviours are correct: preservation is best-effort. +// per-VM issue is emitted and the builder emits no ipAddrs annotation — +// preservation is best-effort. // // Issues are deduplicated by the full CalicoPrimaryIssue value (VMRef is the // same across one per-VM invocation, so dedup naturally applies within VM). @@ -1393,9 +1289,6 @@ func (r *Validator) CalicoPrimaryIssues(vmRef ref.Ref, cache *planbase.CalicoPri } issueBase := planbase.CalicoPrimaryIssue{VMRef: vmRef, Network: primary.Network, VLAN: primary.VLAN.VID} ips := findInterfaceIps(vm, nic) - // Calico's ipAddrs annotation accepts at most one IPv4 per - // interface; a NIC with more can't be represented and would fail - // the pod at CNI ADD. if len(ips) > 1 { multi := issueBase multi.Kind = planbase.CalicoIssuePrimaryTooManyIPs diff --git a/pkg/controller/plan/adapter/vsphere/validator_test.go b/pkg/controller/plan/adapter/vsphere/validator_test.go index 245017084d..ffdda87f02 100644 --- a/pkg/controller/plan/adapter/vsphere/validator_test.go +++ b/pkg/controller/plan/adapter/vsphere/validator_test.go @@ -1820,9 +1820,9 @@ var _ = Describe("vsphere validation tests", func() { It("populates Issues and Warnings independently when both classes coexist", func() { // Two NADs in the same NetworkMap: the original (L2 Calico, but // the referenced Network CR is absent — Critical NetworkNotFound) - // and a second L3-mode NAD (Warn NADMissingNetwork). Asserts the - // dispatcher's independence claim: both slices populated, items - // disjoint, no cross-contamination between Critical and Warn. + // and a second L3-mode NAD (Warn NADMissingNetwork). Both slices + // populate independently: items disjoint, no cross-contamination + // between Critical and Warn. const ( l3NADName = "calico-nad-l3" l3SrcID = "src-l3" @@ -2416,9 +2416,9 @@ var _ = Describe("vsphere validation tests", func() { }) It("emits PrimaryNetworkCRDAbsent when calico.network is set but Network CRD missing (IPPool present)", func() { - // Calico installed (IPPool CRD present) but L2 feature not - // shipped (Network CRD absent). User asked for L2 attach; - // can't honour. Case A (no CalicoNetwork) would pass. + // Calico installed (IPPool CRD present) but the install does + // not ship the Network CRD. User asked for L2 attach; can't + // honour. Case A (no CalicoNetwork) would pass. dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} v, _, _ := setupPrimary("10.100.0.5", false, dest, nil, true) networkGK := calicoclient.NetworkGVK.GroupKind() diff --git a/pkg/controller/provider/model/ocp/model.go b/pkg/controller/provider/model/ocp/model.go index 15e9178a32..20fce20591 100644 --- a/pkg/controller/provider/model/ocp/model.go +++ b/pkg/controller/provider/model/ocp/model.go @@ -210,7 +210,7 @@ const ( CalicoCNIType NadType = "calico" ) -// NetworkConfig represents the structure of the OVN-Kubernetes, or Calico CNI configuration JSON. +// NetworkConfig represents the structure of the OVN-Kubernetes or Calico CNI configuration JSON. // The `json:"..."` tags are used by the encoding/json package to map the JSON keys // to the struct fields during marshalling and unmarshalling. type NetworkConfig struct { diff --git a/pkg/lib/client/calico/bgppeer.go b/pkg/lib/client/calico/bgppeer.go index 6d71616692..a8fff7adb9 100644 --- a/pkg/lib/client/calico/bgppeer.go +++ b/pkg/lib/client/calico/bgppeer.go @@ -20,12 +20,12 @@ var BGPPeerGVK = schema.GroupVersionKind{ // ListBGPPeerNetworks lists every projectcalico.org/v3 BGPPeer on the // cluster (the CR is cluster-scoped) and returns the set of spec.network // values found across them — the Network CRs whose routes at least one -// peer distributes. Peers without the field are skipped. +// peer distributes. // // When the API server does not know the BGPPeer kind at all (older Calico -// installs lack the CRD), the empty set is returned with a nil error: no -// peer can be bound to any Network in that state, which is exactly what -// the empty set reports. Any other list failure propagates. +// installs lack the CRD), no peer can be bound to any Network, so the +// empty set is returned with a nil error. Any other list failure +// propagates. func ListBGPPeerNetworks(ctx context.Context, c client.Client) (map[string]bool, error) { ul := &unstructured.UnstructuredList{} ul.SetGroupVersionKind(BGPPeerGVK.GroupVersion().WithKind("BGPPeerList")) diff --git a/pkg/lib/client/calico/felixconfig.go b/pkg/lib/client/calico/felixconfig.go index b492cadaab..95ad224137 100644 --- a/pkg/lib/client/calico/felixconfig.go +++ b/pkg/lib/client/calico/felixconfig.go @@ -112,12 +112,10 @@ func GetFelixConfig(ctx context.Context, c client.Client) (*FelixConfig, error) } // GetBPFEnabled reports whether the destination Calico install runs the BPF -// dataplane, by reading spec.bpfEnabled of the cluster-scoped -// FelixConfiguration named "default". Felix defaults to the non-BPF -// dataplane, so "not enabled" is reported when the field is false or absent, -// when no "default" FelixConfiguration exists, and when the API server does -// not know the FelixConfiguration kind at all. Any other GET failure is -// returned as an error. +// dataplane, by reading spec.bpfEnabled via GetFelixConfig. Felix defaults +// to the non-BPF dataplane, so "not enabled" is reported when the field, +// the "default" FelixConfiguration, or the FelixConfiguration kind itself +// is absent. Any other GET failure is returned as an error. func GetBPFEnabled(ctx context.Context, c client.Client) (bool, error) { fc, err := GetFelixConfig(ctx, c) if err != nil { diff --git a/pkg/lib/client/calico/ippool.go b/pkg/lib/client/calico/ippool.go index 74d8c9e378..0f5e97107e 100644 --- a/pkg/lib/client/calico/ippool.go +++ b/pkg/lib/client/calico/ippool.go @@ -111,10 +111,9 @@ func isL3Eligible(p *IPPool) bool { return containsAllowedUse(p, AllowedUseWorkload) } -// containsAllowedUse reports whether the pool's AllowedUses contains the named -// use. Returns false for both nil and explicit-empty AllowedUses — neither -// includes any specific use (the Calico-default nil expands to -// ["Workload","Tunnel"], which does not include L2Workload). +// containsAllowedUse reports whether AllowedUses explicitly lists the named +// use. A nil slice (field absent) lists nothing — callers that must honour +// Calico's default-when-absent check nil before calling (see isL3Eligible). func containsAllowedUse(p *IPPool, use string) bool { for _, u := range p.AllowedUses { if u == use { diff --git a/pkg/lib/client/calico/network.go b/pkg/lib/client/calico/network.go index 19822c1533..6bb5481fc5 100644 --- a/pkg/lib/client/calico/network.go +++ b/pkg/lib/client/calico/network.go @@ -47,10 +47,10 @@ type VRFHostEntry struct { // Network is a thin parsed view of projectcalico.org/v3 Network. // -// spec is a strict one-of: an l2Bridge Network carries VLANs and is the type -// identity preservation supports; a vrf Network is routed (L3, no VLANs). -// For a vrf Network, VRFHostConfig carries the hostConfig entries the VRF -// viability checks need; the rest of the VRF spec is not modelled. +// spec is a strict one-of: an l2Bridge Network carries VLANs; a vrf Network +// is routed (L3, no VLANs). For a vrf Network, VRFHostConfig carries the +// hostConfig entries the VRF viability checks need; the rest of the VRF +// spec is not modelled. type Network struct { Name string L2Bridge *L2BridgeSpec // nil when the Network has no l2Bridge spec @@ -92,8 +92,6 @@ func ListNetworks(ctx context.Context, c client.Client) ([]Network, error) { func parseNetwork(u *unstructured.Unstructured) (*Network, error) { n := &Network{Name: u.GetName()} - // A vrf spec marks the Network as a VRF (routed) network; its - // hostConfig entries feed the VRF viability checks. vrfMap, isVRF, err := unstructured.NestedMap(u.Object, "spec", "vrf") if err != nil { return nil, fmt.Errorf("parse spec.vrf: %w", err) diff --git a/pkg/provider/ec2/controller/validator/noop.go b/pkg/provider/ec2/controller/validator/noop.go index 8649d9af4e..aaefa502e9 100644 --- a/pkg/provider/ec2/controller/validator/noop.go +++ b/pkg/provider/ec2/controller/validator/noop.go @@ -115,8 +115,8 @@ func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrima return planbase.CalicoPrimaryValidationResult{}, nil } -// CalicoPrimaryIssues returns nil. The plan-level rejection in -// ValidateCalicoPrimary short-circuits before per-VM dispatch. +// CalicoPrimaryIssues returns nil; any calico-flagged entry was already +// rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { return nil, nil } From 412622dbdfbc658182cc66d02e705792922aac1c Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Thu, 16 Jul 2026 13:32:55 +0100 Subject: [PATCH 07/11] Fix stale condition wording and a duplicate IPPool test case PrimaryNetworkTypeUnsupported no longer claims only l2Bridge networks are supported - VRF networks attach via multus NADs instead. The "Only Workload covers (not L2)" test case now uses an IP that no L2 pool covers. Co-Authored-By: Claude Fable 5 Signed-off-by: Alex O'Regan --- pkg/controller/plan/validation.go | 2 +- pkg/lib/client/calico/ippool_test.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/controller/plan/validation.go b/pkg/controller/plan/validation.go index 2b24860768..421e111d09 100644 --- a/pkg/controller/plan/validation.go +++ b/pkg/controller/plan/validation.go @@ -2125,7 +2125,7 @@ func calicoPrimaryIssueDetail(i planbase.CalicoPrimaryIssue) string { case planbase.CalicoIssuePrimaryNetworkNotFound: return fmt.Sprintf("%s(PrimaryNetworkNotFound network=%q)", prefix, i.Network) case planbase.CalicoIssuePrimaryNetworkTypeUnsupported: - return fmt.Sprintf("%s(PrimaryNetworkTypeUnsupported network=%q: the referenced Network is not an L2 bridge network; only l2Bridge networks are supported)", prefix, i.Network) + return fmt.Sprintf("%s(PrimaryNetworkTypeUnsupported network=%q: the referenced Network is not an L2 bridge network; only l2Bridge networks can back the primary NIC — a VRF network attaches via a multus NAD instead)", prefix, i.Network) case planbase.CalicoIssuePrimaryDataplaneNotBPF: return fmt.Sprintf("%s(PrimaryDataplaneNotBPF network=%q: the destination Calico install is not running the BPF dataplane; L2 networks require FelixConfiguration bpfEnabled: true)", prefix, i.Network) case planbase.CalicoIssuePrimaryNetworkHasNoL2Bridge: diff --git a/pkg/lib/client/calico/ippool_test.go b/pkg/lib/client/calico/ippool_test.go index 6af0e8b82e..000b80403c 100644 --- a/pkg/lib/client/calico/ippool_test.go +++ b/pkg/lib/client/calico/ippool_test.go @@ -213,17 +213,18 @@ func TestL2WorkloadEligiblePoolForIP(t *testing.T) { pools := []IPPool{ {Name: "l2-vlan100", CIDR: "10.100.0.0/24", AllowedUses: []string{"L2Workload"}}, {Name: "workload-vlan100", CIDR: "10.100.0.0/24", AllowedUses: []string{"Workload"}}, + {Name: "workload-vlan102", CIDR: "10.102.0.0/24", AllowedUses: []string{"Workload"}}, {Name: "l2-disabled", CIDR: "10.101.0.0/24", Disabled: true, AllowedUses: []string{"L2Workload"}}, } - vlanSubnets := []string{"10.100.0.0/24"} + vlanSubnets := []string{"10.100.0.0/24", "10.102.0.0/24"} tests := []struct { name string ip string wantPool string }{ {"L2 pool covers IP", "10.100.0.5", "l2-vlan100"}, - {"Only Workload covers (not L2)", "10.100.0.5", "l2-vlan100"}, // l2-vlan100 wins by order - {"IP outside all L2-eligible", "10.101.0.5", ""}, // l2-disabled is disabled + {"Only Workload covers (not L2)", "10.102.0.5", ""}, + {"IP outside all L2-eligible", "10.101.0.5", ""}, // l2-disabled is disabled } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 667fbfcdc8ce55364293de88b8df6fe31a14be93 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Thu, 16 Jul 2026 14:11:12 +0100 Subject: [PATCH 08/11] More code-comment housekeeping Signed-off-by: Alex O'Regan --- .../plan/adapter/vsphere/validator.go | 61 ++++++------------- pkg/lib/client/calico/ippool.go | 4 +- 2 files changed, 21 insertions(+), 44 deletions(-) diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index 809c21ae25..0528069a77 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -734,9 +734,7 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) // ValidateCalicoNADs walks every Multus destination in the plan's network // map, fetches each Calico-referencing NAD, and validates the NAD/Network/ -// IPPool resources. Classifying the referenced Network routes each NAD to -// the VRF pipeline (routed L3, no VLANs) or the l2Bridge pipeline (VLAN -// mandatory). +// IPPool resources. func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValidationResult, error) { result := planbase.CalicoValidationResult{ Cache: &planbase.CalicoValidationCache{ @@ -760,6 +758,8 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid bgpPeerNetworksLoaded := false var felixFacts *calicoclient.FelixConfig + // Felix is the per-node daemon which programs the dataplane + // based on k8s config. loadFelix := func(key k8stypes.NamespacedName) (*calicoclient.FelixConfig, error) { if felixFacts != nil { return felixFacts, nil @@ -772,6 +772,8 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid return fc, nil } + // IPPools are the Calico CRD describing available IP CIDRs + // and their permitted uses. loadPools := func(key k8stypes.NamespacedName) (err error) { if poolsLoaded { return @@ -788,6 +790,10 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid return } + // For each map item, try fetching a corresponding NAD. + // Search for NAD's whose config has `type:calico`, ignore the rest. + // The presence of a Calico `Network` CR implies per-NIC addressing + // is possible for L2 and L3. for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { if pair.Destination.Type != planbase.Multus { continue @@ -865,6 +871,9 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid if !vrfChecked[cfg.Network] { vrfChecked[cfg.Network] = true + // Without a way to know ahead-of-time where a VM will land, and without parsing + // the entire Calico Selector to figure out which nodes the VRF config applies to, + // it becomes possible to schedule VMs on nodes without VRF, i.e. leaving them broken. if !vrfHasAllNodesEntry(nw.VRFHostConfig) { ib := issueBase ib.Kind = planbase.CalicoIssueVRFNodeScoped @@ -1063,16 +1072,6 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati // // Precondition: Plan.Referenced.Map.Network is populated by the dispatcher // before this is called. -// -// The implementation runs the L3 IPPool list once (catches "Calico CRDs -// absent" via meta.IsNoMatchError), then dispatches on case: -// - Case A (calico.network == ""): L3 IPAM — filter to L3-eligible -// pools; per-VM check validates IP fit. -// - Case C (calico.network != ""): GetNetwork → network classification -// (VRF networks are rejected here: they attach via multus NADs, not -// the primary NIC) → BPF-dataplane check → VLAN is mandatory -// (VLANRequired if absent) → VLAN entry → L2Workload pool filter -// scoped to the matched VLAN's subnet(s). func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPrimaryValidationResult, error) { result := planbase.CalicoPrimaryValidationResult{ Cache: &planbase.CalicoPrimaryValidationCache{}, @@ -1081,14 +1080,13 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr return result, nil } - // Pass 1: classify entries, surface field-misplacement issues. + // Classify entries (VRF or L2Bridge), surface field-misplacement issues. var calicoEntries []api.NetworkPair for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { dest := pair.Destination if dest.Calico == nil { continue } - // The calico block qualifies the pod (primary) attachment only. if dest.Type != planbase.Pod { result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ Kind: planbase.CalicoIssuePrimaryFieldsMisplaced, @@ -1116,8 +1114,6 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr return result, nil } - // First (and, if well-configured, only) calico pod entry drives the - // cache. entry := calicoEntries[0] calico := entry.Destination.Calico issueBase := planbase.CalicoPrimaryIssue{Network: calico.Network, VLAN: calico.Vlan} @@ -1128,7 +1124,6 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr }) } - // CRD presence check via ListIPPools. meta.IsNoMatchError → CRDs absent. pools, err := calicoclient.ListIPPools(context.TODO(), c) if err != nil { if meta.IsNoMatchError(err) { @@ -1147,7 +1142,6 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr return result, nil } - // Case A: implicit L3 IPAM. Cache L3-eligible pools for per-VM check. if calico.Network == "" { result.Cache.Primary = &planbase.ResolvedCalicoPrimary{ Source: entry.Source.Ref, @@ -1156,7 +1150,6 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr return result, nil } - // Case C: L2 attach via named Network CR. nw, err := calicoclient.GetNetwork(context.TODO(), c, calico.Network) if err != nil { switch { @@ -1178,9 +1171,9 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) } } - // Classify the Network before any VLAN handling: a non-l2Bridge network - // (e.g. a VRF network) legitimately carries no VLAN, so the VLANRequired - // / VLAN-matching checks below would mislead. + + // A non-l2Bridge network (e.g. a VRF network) carries no VLAN, so + // the VLANRequired / VLAN-matching checks below would mislead. if nw.IsVRF { ib := issueBase ib.Kind = planbase.CalicoIssuePrimaryNetworkTypeUnsupported @@ -1194,8 +1187,7 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr return result, nil } - // L2 networks require the BPF dataplane. The issue is plan-scoped and - // does not block the remaining checks. + // L2 networks require the BPF dataplane. bpfEnabled, err := calicoclient.GetBPFEnabled(context.TODO(), c) if err != nil { return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) @@ -1206,8 +1198,7 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr result.Issues = append(result.Issues, ib) } - // A Network reference requires an explicit VLAN. Forklift does not - // auto-select, not even for a single-VLAN Network. + // A Network reference requires an explicit VLAN. if calico.Vlan == 0 { ib := issueBase ib.Kind = planbase.CalicoIssuePrimaryVLANRequired @@ -1241,14 +1232,7 @@ func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPr } // CalicoPrimaryIssues returns per-VM Calico-primary issues for vmRef using -// the cache from ValidateCalicoPrimary. Per-NIC checks fire only when -// plan.Spec.PreserveStaticIPs is true. When IP preservation is on but the -// VM has no findable IPv4 IPs (IPv6-only or no GuestNetworks reported), no -// per-VM issue is emitted and the builder emits no ipAddrs annotation — -// preservation is best-effort. -// -// Issues are deduplicated by the full CalicoPrimaryIssue value (VMRef is the -// same across one per-VM invocation, so dedup naturally applies within VM). +// the cache from ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(vmRef ref.Ref, cache *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { if !r.Plan.Spec.PreserveStaticIPs { return nil, nil @@ -1282,8 +1266,6 @@ func (r *Validator) CalicoPrimaryIssues(vmRef ref.Ref, cache *planbase.CalicoPri for i, nic := range vm.NICs { pair, allocated := planbase.AllocateNetwork(nadPool, pairsBySource[nicKeys[i]]) - // Only the calico-flagged pod entry's NIC is the primary candidate; - // multus-mapped NICs are checked on the NAD path (CalicoVMIssues). if !allocated || pair.Destination.Type != planbase.Pod || pair.Destination.Calico == nil { continue } @@ -1474,11 +1456,6 @@ func vrfRouteTableIssues(issueBase planbase.CalicoNADIssue, nw *calicoclient.Net } // resolveVLANEntry returns the l2Bridge.vlans[] entry matched by nadVLAN. -// Callers reject a zero nadVLAN before reaching here (a Network reference -// requires an explicit VLAN), so nadVLAN is always non-zero. When no entry -// matches, returns nil entry plus a non-empty CalicoIssueKind describing the -// failure: NetworkHasNoVLANs (vlans list is empty) or VLANNotInNetwork (the -// requested vlan is absent from the Network's entries). func resolveVLANEntry(vlans []calicoclient.VLANEntry, nadVLAN uint16) (*calicoclient.VLANEntry, planbase.CalicoIssueKind) { if len(vlans) == 0 { return nil, planbase.CalicoIssueNetworkHasNoVLANs diff --git a/pkg/lib/client/calico/ippool.go b/pkg/lib/client/calico/ippool.go index 0f5e97107e..588a34da09 100644 --- a/pkg/lib/client/calico/ippool.go +++ b/pkg/lib/client/calico/ippool.go @@ -159,7 +159,7 @@ func L3EligiblePoolForIP(pools []IPPool, ip string) *IPPool { } // L2WorkloadEligiblePools returns the subset of pools usable for the L2-attach -// path (Case C) — attach via a named Network CR. A pool is L2Workload-eligible +// path (attach via a named Network CR). A pool is L2Workload-eligible // when it is not disabled, its allowedUses contains "L2Workload", and its CIDR // is fully contained in at least one of the matched VLAN's subnets. func L2WorkloadEligiblePools(pools []IPPool, vlanSubnets []string) []IPPool { @@ -207,7 +207,7 @@ func L2WorkloadEligiblePoolForIP(pools []IPPool, ip string, vlanSubnets []string } // poolContainedInAnyVLANSubnet reports whether the pool CIDR is fully -// contained within one of the VLAN subnets — i.e., every address in the pool +// contained within one of the VLAN subnets, i.e., every address in the pool // also belongs to the VLAN subnet. func poolContainedInAnyVLANSubnet(poolCIDR string, vlanSubnets []string) bool { _, poolNet, err := net.ParseCIDR(poolCIDR) From 396887f72844820f3e6985fc3a2b000bbb8b6492 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Thu, 16 Jul 2026 14:42:40 +0100 Subject: [PATCH 09/11] Escalate VRFNodeScoped to Critical when the plan sets no VM placement An empty hostConfig nodeSelector is the canonical all-nodes form, so all-scoped entries mean a node subset. Without targetNodeSelector or targetAffinity on the plan, VMs may schedule onto uncovered nodes and fail at CNI ADD - nondeterministic, so Critical. With placement set, the new VRFPlacementUnverified warning notes the pin cannot be verified against the network's Calico selectors. Co-Authored-By: Claude Fable 5 Signed-off-by: Alex O'Regan --- pkg/controller/plan/adapter/base/doc.go | 25 +++++----- .../plan/adapter/vsphere/validator.go | 20 ++++++-- .../plan/adapter/vsphere/validator_test.go | 46 ++++++++++++++----- pkg/controller/plan/validation.go | 4 +- 4 files changed, 66 insertions(+), 29 deletions(-) diff --git a/pkg/controller/plan/adapter/base/doc.go b/pkg/controller/plan/adapter/base/doc.go index c073da534f..8a97d99361 100644 --- a/pkg/controller/plan/adapter/base/doc.go +++ b/pkg/controller/plan/adapter/base/doc.go @@ -346,23 +346,24 @@ const ( CalicoIssueNetworkNotFound CalicoIssueKind = "NetworkNotFound" // CalicoIssueNetworkCRDAbsent the destination cluster does not have the // projectcalico.org/v3 Network CRD installed (the Calico install is too - // old or does not ship the Network resource). The NAD references a - // Calico Network but the CRD cannot be queried — distinct from a missing - // CR, which is CalicoIssueNetworkNotFound. + // old or does not ship the Network resource). CalicoIssueNetworkCRDAbsent CalicoIssueKind = "NetworkCRDAbsent" // CalicoIssueVRFVlanIgnored the NAD names a VLAN but the referenced - // Network is a VRF (routed L3) network. VLANs apply only to l2Bridge - // networks, so the value is ignored. Warn-level — validation of the - // reference continues. + // Network is a VRF (routed L3) network. CalicoIssueVRFVlanIgnored CalicoIssueKind = "VRFVlanIgnored" // CalicoIssueVRFNodeScoped every spec.vrf.hostConfig entry on the - // referenced VRF Network carries a nodeSelector, so the network exists - // only on nodes those selectors match; a VM scheduled onto any other - // node fails to start. Warn-level: the selectors may well cover every - // node, but evaluating Calico's selector grammar is deliberately out - // of scope, so scoped-only hostConfig is reported as a caution rather - // than proven incomplete coverage. + // referenced VRF Network carries a nodeSelector (an empty selector is + // the canonical all-nodes form, so this means a node subset) and the + // plan sets neither targetNodeSelector nor targetAffinity. A VM + // scheduled onto an uncovered node fails to start, so with placement + // uncontrolled the migration outcome is nondeterministic. Critical. CalicoIssueVRFNodeScoped CalicoIssueKind = "VRFNodeScoped" + // CalicoIssueVRFPlacementUnverified as VRFNodeScoped, but the plan + // constrains VM placement (targetNodeSelector/targetAffinity). + // Warn-level: Forklift does not evaluate Calico's selector grammar, + // so it cannot verify the plan's placement keeps VMs on nodes the + // network's selectors cover. + CalicoIssueVRFPlacementUnverified CalicoIssueKind = "VRFPlacementUnverified" // CalicoIssueVRFRouteTableReserved a hostConfig entry on the referenced // VRF Network claims kernel route table 253, 254 or 255 — the kernel's // own default/main/local tables, which a VRF must never take over. diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index 0528069a77..87550d177e 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -871,13 +871,23 @@ func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValid if !vrfChecked[cfg.Network] { vrfChecked[cfg.Network] = true - // Without a way to know ahead-of-time where a VM will land, and without parsing - // the entire Calico Selector to figure out which nodes the VRF config applies to, - // it becomes possible to schedule VMs on nodes without VRF, i.e. leaving them broken. + // An empty nodeSelector is the canonical all-nodes form, so + // all-scoped hostConfig means a node subset (Calico selectors + // are not parsed). A VM scheduled onto an uncovered node + // fails at CNI ADD, so with no placement constraints on the + // plan the outcome is a scheduling lottery: Critical. When + // the plan pins placement (targetNodeSelector/targetAffinity) + // the user has taken control; Warn that the pin cannot be + // verified against the network's selectors. if !vrfHasAllNodesEntry(nw.VRFHostConfig) { ib := issueBase - ib.Kind = planbase.CalicoIssueVRFNodeScoped - result.Warnings = append(result.Warnings, ib) + if len(r.Plan.Spec.TargetNodeSelector) > 0 || r.Plan.Spec.TargetAffinity != nil { + ib.Kind = planbase.CalicoIssueVRFPlacementUnverified + result.Warnings = append(result.Warnings, ib) + } else { + ib.Kind = planbase.CalicoIssueVRFNodeScoped + result.Issues = append(result.Issues, ib) + } } if vrfHasEntryWithoutHostInterfaces(nw.VRFHostConfig) { diff --git a/pkg/controller/plan/adapter/vsphere/validator_test.go b/pkg/controller/plan/adapter/vsphere/validator_test.go index ffdda87f02..9dcbac1185 100644 --- a/pkg/controller/plan/adapter/vsphere/validator_test.go +++ b/pkg/controller/plan/adapter/vsphere/validator_test.go @@ -1182,13 +1182,12 @@ var _ = Describe("vsphere validation tests", func() { Describe("VRF viability", func() { nadKey := k8stypes.NamespacedName{Namespace: nadNS, Name: nadName} - It("warns VRFNodeScoped when every hostConfig entry is node-scoped", func() { - // Both entries carry a nodeSelector. Whether the two - // selectors jointly cover every node is deliberately not - // evaluated (Calico selector grammar), so scoped-only - // hostConfig is reported as a warning, not a Critical. - // The bound BGPPeer keeps VRFNoBGPPeer out; the entries - // carry host interfaces. + It("emits VRFNodeScoped as Critical when every entry is node-scoped and the plan sets no placement", func() { + // Both entries carry a nodeSelector (empty is the + // canonical all-nodes form, so this is a node subset) + // and the plan pins nothing: VMs may land on uncovered + // nodes and fail at CNI ADD. The bound BGPPeer keeps + // VRFNoBGPPeer out; the entries carry host interfaces. v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(0), makeVRFNetwork( @@ -1201,10 +1200,35 @@ var _ = Describe("vsphere validation tests", func() { ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) + Expect(result.Warnings).To(BeEmpty()) + Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ + NAD: nadKey, + Kind: planbase.CalicoIssueVRFNodeScoped, + Network: netName, + })) + }) + + It("downgrades to VRFPlacementUnverified when the plan constrains VM placement", func() { + // Same node-scoped hostConfig, but the plan pins VMs via + // targetNodeSelector: the user took control of placement, + // which Forklift cannot verify against Calico selectors. + v, c, _ := setup("10.100.0.5", true, + makeCalicoNAD(0), + makeVRFNetwork( + vrfHostEntry("rack == 'a'", 101), + vrfHostEntry("rack == 'b'", 102), + ), + makeIPPool("default-pool", "10.100.0.0/24", "Workload"), + makeNftablesFelixConfiguration("Enabled"), + makeBGPPeer("vrf-peer", netName), + ) + v.Plan.Spec.TargetNodeSelector = map[string]string{"rack": "a"} + result, err := v.ValidateCalicoNADs(c) + Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ NAD: nadKey, - Kind: planbase.CalicoIssueVRFNodeScoped, + Kind: planbase.CalicoIssueVRFPlacementUnverified, Network: netName, })) }) @@ -1212,7 +1236,7 @@ var _ = Describe("vsphere validation tests", func() { It("runs the viability checks once per referenced Network", func() { // Two NADs referencing the same VRF Network: the checks // dedupe per Network name, so the scoped-only hostConfig - // yields exactly one warning, attached to the first NAD. + // yields exactly one issue, attached to the first NAD. secondNAD := &k8snet.NetworkAttachmentDefinition{ ObjectMeta: metav1.ObjectMeta{Name: "calico-nad-2", Namespace: nadNS}, Spec: k8snet.NetworkAttachmentDefinitionSpec{ @@ -1237,8 +1261,8 @@ var _ = Describe("vsphere validation tests", func() { ) result, err := v.ValidateCalicoNADs(c) Expect(err).NotTo(HaveOccurred()) - Expect(result.Issues).To(BeEmpty()) - Expect(nadIssueKinds(result.Warnings)).To(ConsistOf(planbase.CalicoIssueVRFNodeScoped)) + Expect(result.Warnings).To(BeEmpty()) + Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVRFNodeScoped)) Expect(result.Cache.NADs).To(HaveLen(2)) }) diff --git a/pkg/controller/plan/validation.go b/pkg/controller/plan/validation.go index 421e111d09..847be4f75a 100644 --- a/pkg/controller/plan/validation.go +++ b/pkg/controller/plan/validation.go @@ -2167,7 +2167,9 @@ func calicoNADIssueDetail(i planbase.CalicoNADIssue) string { case planbase.CalicoIssueVRFVlanIgnored: return fmt.Sprintf("%s (VRFVlanIgnored network=%q vlan=%d: the referenced Network is a VRF (routed) network; VLANs apply only to l2Bridge networks and the vlan value is ignored)", i.NAD.String(), i.Network, i.VLAN) case planbase.CalicoIssueVRFNodeScoped: - return fmt.Sprintf("%s (VRFNodeScoped network=%q: every hostConfig entry carries a nodeSelector, so the network exists only on matching nodes; VMs scheduled onto any other node will fail to start — add a hostConfig entry without a nodeSelector to cover all nodes)", i.NAD.String(), i.Network) + return fmt.Sprintf("%s (VRFNodeScoped network=%q: every hostConfig entry carries a nodeSelector, so the network exists only on matching nodes, and the plan does not constrain VM placement; VMs may schedule onto uncovered nodes and fail to start — set the plan's targetNodeSelector or targetAffinity to keep VMs on covered nodes, or add a hostConfig entry without a nodeSelector)", i.NAD.String(), i.Network) + case planbase.CalicoIssueVRFPlacementUnverified: + return fmt.Sprintf("%s (VRFPlacementUnverified network=%q: the network exists only on nodes matching its hostConfig selectors; the plan constrains VM placement, but Forklift cannot verify that placement keeps VMs on covered nodes)", i.NAD.String(), i.Network) case planbase.CalicoIssueVRFRouteTableReserved: return fmt.Sprintf("%s (VRFRouteTableReserved network=%q table=%d: route tables 253, 254 and 255 are reserved by the kernel; choose a different routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable) case planbase.CalicoIssueVRFRouteTableConflict: From c979cb1ddcd97630fb4e6d0c899e8ca06d3e82e8 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Thu, 16 Jul 2026 15:25:40 +0100 Subject: [PATCH 10/11] Comment housekeeping Co-Authored-By: Claude Fable 5 Signed-off-by: Alex O'Regan --- .../plan/adapter/base/calico_validation.go | 20 +++++++++---------- pkg/controller/plan/adapter/base/doc.go | 12 +++++------ .../plan/adapter/vsphere/builder_test.go | 4 ++-- .../plan/adapter/vsphere/validator.go | 4 ++-- .../plan/adapter/vsphere/validator_test.go | 18 ++++++++--------- pkg/lib/client/calico/ippool.go | 4 ++-- 6 files changed, 29 insertions(+), 33 deletions(-) diff --git a/pkg/controller/plan/adapter/base/calico_validation.go b/pkg/controller/plan/adapter/base/calico_validation.go index 233aeb6e9d..ab8d66f0da 100644 --- a/pkg/controller/plan/adapter/base/calico_validation.go +++ b/pkg/controller/plan/adapter/base/calico_validation.go @@ -33,8 +33,8 @@ type ResolvedCalicoNAD struct { // CalicoValidationCache holds resolved state for every Calico-referencing // NAD that passed plan-level validation. NADs with any resource-level issue -// are absent from the map: per-VM checks treat that as "skip silently — the -// failure is already surfaced at plan level". +// are absent from the map: per-VM checks treat that as "skip silently", since +// the failure is already surfaced at plan level. type CalicoValidationCache struct { NADs map[types.NamespacedName]*ResolvedCalicoNAD } @@ -78,19 +78,19 @@ type CalicoValidationResult struct { // every VM. // // The struct accommodates both Calico-primary cases: -// - Case A (calico.network == ""): implicit L3 IPAM. Network/VLAN are zero; +// - non-L2 (calico.network == ""): implicit L3 IPAM. Network/VLAN are zero; // L3EligiblePools is the pool set the per-VM check uses to validate IP fit. -// - Case C (calico.network != ""): L2 attach via named Calico Network CR. +// - L2-attach (calico.network != ""): attach via named Calico Network CR. // Network/VLAN are populated; L2EligiblePools is the L2Workload-restricted // pool set whose CIDR is contained in the matched VLAN's subnet(s). type ResolvedCalicoPrimary struct { - // Network is the named Calico Network CR (empty for Case A). + // Network is the named Calico Network CR (empty in the non-L2 case). Network string - // VLAN is the resolved l2Bridge VLAN entry (zero-value for Case A). + // VLAN is the resolved l2Bridge VLAN entry (zero-value in the non-L2 case). VLAN calicoclient.VLANEntry - // L2EligiblePools is the L2Workload-restricted pool set for Case C. + // L2EligiblePools is the L2Workload-restricted pool set for the L2-attach case. L2EligiblePools []calicoclient.IPPool - // L3EligiblePools is the L3-eligible pool set for Case A. + // L3EligiblePools is the L3-eligible pool set for the non-L2 case. L3EligiblePools []calicoclient.IPPool // Source is the NetworkMap entry's source ref — used by per-VM dispatch // to identify which NIC source maps to the calico primary entry. @@ -111,9 +111,7 @@ type CalicoPrimaryValidationCache struct { // for per-VM issues. // // All fields are comparable types, so the struct can be used directly as a -// map key for dedup. Per-VM dispatch uses CalicoPrimaryIssue as the dedup key -// — each per-VM invocation only sees one VMRef, so dedup-within-VM is the -// natural behaviour. +// map key for dedup. CalicoPrimaryIssue is the dedup key. type CalicoPrimaryIssue struct { VMRef ref.Ref Kind CalicoIssueKind diff --git a/pkg/controller/plan/adapter/base/doc.go b/pkg/controller/plan/adapter/base/doc.go index 8a97d99361..76af98b09f 100644 --- a/pkg/controller/plan/adapter/base/doc.go +++ b/pkg/controller/plan/adapter/base/doc.go @@ -463,7 +463,7 @@ const ( // CalicoIssuePrimaryNetworkCRDAbsent the destination cluster has Calico // (IPPool CRD present) but the projectcalico.org/v3 Network CRD is not // installed. The user requested L2 attach via calico.network, but the - // install does not ship the Network resource. Case A (calico.network + // install does not ship the Network resource. The non-L2 case (calico.network // == "") continues to work in this state and is not blocked. CalicoIssuePrimaryNetworkCRDAbsent CalicoIssueKind = "PrimaryNetworkCRDAbsent" // CalicoIssuePrimaryConflictsWithUDN indicates the destination namespace @@ -485,16 +485,15 @@ const ( CalicoIssuePrimaryNetworkHasNoVLANs CalicoIssueKind = "PrimaryNetworkHasNoVLANs" // CalicoIssuePrimaryVLANRequired calico.network is set but calico.vlan // is not. A VLAN must be stated explicitly whenever a Network is - // selected; Forklift does not auto-select, not even for a single-VLAN - // Network. + // selected. CalicoIssuePrimaryVLANRequired CalicoIssueKind = "PrimaryVLANRequired" // CalicoIssuePrimaryVLANNotInNetwork the user-specified calico.vlan does // not match any vlan.id in the named Network CR. CalicoIssuePrimaryVLANNotInNetwork CalicoIssueKind = "PrimaryVLANNotInNetwork" // CalicoIssuePrimaryNoEligibleIPPool no IPPool covers either the L3 - // allocation (Case A) or the matched VLAN's subnet (Case C). + // allocation (non-L2 case) or the matched VLAN's subnet (L2-attach case). CalicoIssuePrimaryNoEligibleIPPool CalicoIssueKind = "PrimaryNoEligibleIPPool" - // CalicoIssuePrimaryIPNotInSubnet (Case C only) the source NIC IP + // CalicoIssuePrimaryIPNotInSubnet (L2-attach case only) the source NIC IP // falls outside the matched VLAN's subnets. CalicoIssuePrimaryIPNotInSubnet CalicoIssueKind = "PrimaryIPNotInSubnet" // CalicoIssuePrimaryTooManyIPs the calico-mapped NIC carries more than @@ -511,8 +510,7 @@ const ( // NetworkMap entry exists while Plan.Spec.PreserveStaticIPs is false. // Bridge binding is still enabled (Calico requires it for L2 attach), // so DHCP-configured guests will pick up the Calico-assigned IP via - // the veth. Static-IP-configured guests can have a divergent in-guest - // IP, with associated Calico drops. Warn-class — informational only. + // the veth. Static-IP-configured guests would have a diver. Warn-class — informational only. CalicoIssuePrimaryStaticIPsNotPreserved CalicoIssueKind = "PrimaryStaticIPsNotPreserved" // CalicoIssuePrimaryDataplaneNotBPF calico.network resolved to an // l2Bridge network but the destination Calico install is not running diff --git a/pkg/controller/plan/adapter/vsphere/builder_test.go b/pkg/controller/plan/adapter/vsphere/builder_test.go index 1f7848392b..bcec4ac667 100644 --- a/pkg/controller/plan/adapter/vsphere/builder_test.go +++ b/pkg/controller/plan/adapter/vsphere/builder_test.go @@ -1497,7 +1497,7 @@ var _ = Describe("vSphere builder", func() { return spec, err } - It("Case A no preserveStaticIPs: MAC only, Bridge binding, no IP/Network", func() { + It("non-L2 without preserveStaticIPs: MAC only, Bridge binding, no IP/Network", func() { spec, err := runPrimary(v1beta1.DestinationNetwork{Type: "pod", Calico: &v1beta1.CalicoDestination{}}, false, nil, nil, nil) Expect(err).NotTo(HaveOccurred()) ann := spec.Template.ObjectMeta.Annotations @@ -1523,7 +1523,7 @@ var _ = Describe("vSphere builder", func() { Expect(ann).NotTo(HaveKey("kubevirt.io/allow-pod-bridge-network-live-migration")) }) - It("Case A with preserveStaticIPs: MAC + IPs", func() { + It("non-L2 with preserveStaticIPs: MAC + IPs", func() { spec, err := runPrimary(v1beta1.DestinationNetwork{Type: "pod", Calico: &v1beta1.CalicoDestination{}}, true, nil, nil, nil) Expect(err).NotTo(HaveOccurred()) ann := spec.Template.ObjectMeta.Annotations diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index 87550d177e..d4c7e16e2d 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -1292,14 +1292,14 @@ func (r *Validator) CalicoPrimaryIssues(vmRef ref.Ref, cache *planbase.CalicoPri perIP := issueBase perIP.IP = ip if primary.Network == "" { - // Case A: implicit L3 IPAM. Pool must cover IP. + // Non-L2 case: implicit L3 IPAM; the pool must cover the IP. if calicoclient.L3EligiblePoolForIP(primary.L3EligiblePools, ip) == nil { perIP.Kind = planbase.CalicoIssuePrimaryNoEligibleIPPool emit(perIP) } continue } - // Case C: IP must be in matched VLAN subnet AND covered by an + // L2-attach case: IP must be in the matched VLAN subnet AND covered by an // L2Workload pool. if !ipInAnySubnet(ip, primary.VLAN.Subnets) { perIP.Kind = planbase.CalicoIssuePrimaryIPNotInSubnet diff --git a/pkg/controller/plan/adapter/vsphere/validator_test.go b/pkg/controller/plan/adapter/vsphere/validator_test.go index 9dcbac1185..82152c8234 100644 --- a/pkg/controller/plan/adapter/vsphere/validator_test.go +++ b/pkg/controller/plan/adapter/vsphere/validator_test.go @@ -2280,7 +2280,7 @@ var _ = Describe("vsphere validation tests", func() { Expect(result.Cache.Primary).To(BeNil()) }) - It("happy path Case A (implicit L3 IPAM) — populates L3EligiblePools, no issues", func() { + It("happy path non-L2 (implicit L3 IPAM) — populates L3EligiblePools, no issues", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), @@ -2293,7 +2293,7 @@ var _ = Describe("vsphere validation tests", func() { Expect(result.Cache.Primary.L3EligiblePools).To(HaveLen(1)) }) - It("happy path Case C (single-VLAN Network, explicit VLAN)", func() { + It("happy path L2-attach (single-VLAN Network, explicit VLAN)", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, makeNetwork(l2Single), @@ -2308,7 +2308,7 @@ var _ = Describe("vsphere validation tests", func() { Expect(result.Cache.Primary.L2EligiblePools).To(HaveLen(1)) }) - It("happy path Case C (multi-VLAN Network, explicit VLAN)", func() { + It("happy path L2-attach (multi-VLAN Network, explicit VLAN)", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 200}} v, c, _ := setupPrimary("10.200.0.5", false, dest, nil, true, makeNetwork(l2Multi), @@ -2442,7 +2442,7 @@ var _ = Describe("vsphere validation tests", func() { It("emits PrimaryNetworkCRDAbsent when calico.network is set but Network CRD missing (IPPool present)", func() { // Calico installed (IPPool CRD present) but the install does // not ship the Network CRD. User asked for L2 attach; can't - // honour. Case A (no CalicoNetwork) would pass. + // honour. The non-L2 case (no calico.network) would pass. dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} v, _, _ := setupPrimary("10.100.0.5", false, dest, nil, true) networkGK := calicoclient.NetworkGVK.GroupKind() @@ -2589,8 +2589,8 @@ var _ = Describe("vsphere validation tests", func() { Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryDataplaneNotBPF)) }) - It("does not run the dataplane check for Case A (no calico.network)", func() { - // Case A is plain L3 IPAM — no l2Bridge network is engaged, so + It("does not run the dataplane check for the non-L2 case (no calico.network)", func() { + // The non-L2 case is plain L3 IPAM — no l2Bridge network is engaged, so // a non-BPF dataplane is fine. dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, @@ -2676,7 +2676,7 @@ var _ = Describe("vsphere validation tests", func() { Expect(issues).To(BeEmpty()) }) - It("Case A: emits NoEligibleIPPool when no L3 pool covers the source IP", func() { + It("non-L2: emits NoEligibleIPPool when no L3 pool covers the source IP", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}} v, c, vmRef := setupPrimary("192.168.1.5", true, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), @@ -2690,7 +2690,7 @@ var _ = Describe("vsphere validation tests", func() { })) }) - It("Case C: emits IPNotInSubnet when source IP is outside the VLAN subnet", func() { + It("L2-attach: emits IPNotInSubnet when source IP is outside the VLAN subnet", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} v, c, vmRef := setupPrimary("192.168.1.5", true, dest, nil, true, makeNetwork(l2Single), @@ -2706,7 +2706,7 @@ var _ = Describe("vsphere validation tests", func() { })) }) - It("Case C: emits NoEligibleIPPool when no L2Workload pool covers the source IP", func() { + It("L2-attach: emits NoEligibleIPPool when no L2Workload pool covers the source IP", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} v, c, vmRef := setupPrimary("10.100.0.5", true, dest, nil, true, makeNetwork(l2Single), diff --git a/pkg/lib/client/calico/ippool.go b/pkg/lib/client/calico/ippool.go index 588a34da09..b1e477523a 100644 --- a/pkg/lib/client/calico/ippool.go +++ b/pkg/lib/client/calico/ippool.go @@ -95,7 +95,7 @@ func ipInCIDR(ip net.IP, cidr string) bool { } // isL3Eligible reports whether a pool can serve as a source of IP allocations -// for a Calico-primary pod without L2 attach (Case A — implicit L3 IPAM). +// for a Calico-primary pod in the non-L2 case (calico.network unset — implicit L3 IPAM). // A nil AllowedUses means the field was absent and the Calico default // ["Workload","Tunnel"] applies (workload-assignable). A non-nil slice is // eligible iff it contains "Workload" — Calico IPAM only assigns workload @@ -123,7 +123,7 @@ func containsAllowedUse(p *IPPool, use string) bool { return false } -// L3EligiblePools returns the subset of pools usable for Case A — implicit +// L3EligiblePools returns the subset of pools usable for the non-L2 case — implicit // L3 IPAM — Calico-primary attach. A pool is L3-eligible when it is not // disabled and its allowedUses contains "Workload" (or is absent, implying // the Calico default ["Workload","Tunnel"]). From 47e9b2e5ff71bb2675da83d8da22339dba499711 Mon Sep 17 00:00:00 2001 From: Alex O'Regan Date: Thu, 13 Aug 2026 10:17:30 +0100 Subject: [PATCH 11/11] Move map-scoped Calico validation to the NetworkMap controller Calico NAD and primary-entry validation now runs in the NetworkMap reconciler against the destination cluster and surfaces conditions on the NetworkMap; Critical ones block the map's Ready condition, which already blocks plans. The plan controller calls the same shared functions to rebuild the per-VM cache and keeps only the plan-scoped checks: VRF placement, pool pinning, the preserveStaticIPs warning, the UDN-namespace conflict, and the per-VM issues. ValidateCalicoNADs and ValidateCalicoPrimary are removed from the adapter Validator interface along with every provider stub; the non-vSphere rejection of the calico block moves to the NetworkMap controller. Signed-off-by: Alex O'Regan --- pkg/controller/map/network/calico.go | 124 ++++ pkg/controller/map/network/calico_test.go | 83 +++ pkg/controller/map/network/validation.go | 8 +- .../plan/adapter/base/calico_conditions.go | 102 +++ .../adapter/base/calico_map_validation.go | 696 ++++++++++++++++++ .../plan/adapter/base/calico_validation.go | 9 + pkg/controller/plan/adapter/base/doc.go | 31 +- .../plan/adapter/hyperv/validator.go | 23 - .../hyperv/validator_calico_primary_test.go | 41 -- .../plan/adapter/nutanix/validator.go | 23 - .../nutanix/validator_calico_primary_test.go | 41 -- pkg/controller/plan/adapter/ocp/validator.go | 23 - .../ocp/validator_calico_primary_test.go | 41 -- .../plan/adapter/openstack/validator.go | 23 - .../validator_calico_primary_test.go | 41 -- .../plan/adapter/ovfbase/validator.go | 23 - .../ovfbase/validator_calico_primary_test.go | 41 -- .../plan/adapter/ovirt/validator.go | 23 - .../ovirt/validator_calico_primary_test.go | 41 -- .../plan/adapter/vsphere/validator.go | 570 -------------- .../plan/adapter/vsphere/validator_test.go | 231 +++--- pkg/controller/plan/validation.go | 239 ++---- pkg/provider/ec2/controller/validator/noop.go | 22 - 23 files changed, 1214 insertions(+), 1285 deletions(-) create mode 100644 pkg/controller/map/network/calico.go create mode 100644 pkg/controller/map/network/calico_test.go create mode 100644 pkg/controller/plan/adapter/base/calico_conditions.go create mode 100644 pkg/controller/plan/adapter/base/calico_map_validation.go delete mode 100644 pkg/controller/plan/adapter/hyperv/validator_calico_primary_test.go delete mode 100644 pkg/controller/plan/adapter/nutanix/validator_calico_primary_test.go delete mode 100644 pkg/controller/plan/adapter/ocp/validator_calico_primary_test.go delete mode 100644 pkg/controller/plan/adapter/openstack/validator_calico_primary_test.go delete mode 100644 pkg/controller/plan/adapter/ovfbase/validator_calico_primary_test.go delete mode 100644 pkg/controller/plan/adapter/ovirt/validator_calico_primary_test.go diff --git a/pkg/controller/map/network/calico.go b/pkg/controller/map/network/calico.go new file mode 100644 index 0000000000..9520991a79 --- /dev/null +++ b/pkg/controller/map/network/calico.go @@ -0,0 +1,124 @@ +package network + +import ( + "context" + + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" + ocp "github.com/kubev2v/forklift/pkg/lib/client/openshift" + liberr "github.com/kubev2v/forklift/pkg/lib/error" + core "k8s.io/api/core/v1" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Condition types for the Calico destination checks. Same vocabulary as the +// plan-level conditions: map-scoped issues surface here, plan-scoped issues +// (placement, IP preservation, UDN conflict, per-VM) surface on the Plan. +const ( + CalicoNetworkInvalid = "CalicoNetworkInvalid" + CalicoNetworkWarning = "CalicoNetworkWarning" + CalicoPrimaryInvalid = "CalicoPrimaryInvalid" + CalicoPrimaryWarning = "CalicoPrimaryWarning" +) + +// validateCalico is the Calico leg of validateDestination: it validates the +// map's Calico destinations against the destination cluster — the (at most +// one) calico-flagged pod entry and every multus entry whose NAD references +// a Calico Network. Issues become NetworkMap conditions; Critical ones block +// Ready, which in turn blocks plans referencing the map. The two flags come +// from validateDestination's walk over the entries. +func (r *Reconciler) validateCalico(mp *api.NetworkMap, hasCalicoBlock, hasMultus bool) error { + src := mp.Referenced.Provider.Source + dst := mp.Referenced.Provider.Destination + if src == nil || dst == nil { + return nil + } + pairs := mp.Spec.Map + + // The calico block is honoured only for vSphere sources in this + // release. Reject the explicit opt-in on other providers; NADs are + // not probed for them (no behaviour existed to preserve). + if src.Type() != api.VSphere { + if hasCalicoBlock { + if cond, ok := planbase.BuildCalicoPrimaryCondition( + CalicoPrimaryInvalid, Critical, + "The Calico primary-network mapping is invalid", + []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, + ); ok { + mp.Status.SetCondition(cond) + } + } + return nil + } + + // Probe the destination only when an entry can engage Calico. + if !hasCalicoBlock && !hasMultus { + return nil + } + + dstClient, err := r.destinationClient(dst) + if err != nil { + return liberr.Wrap(err) + } + + nadResult, err := planbase.ValidateCalicoNADs(context.TODO(), dstClient, pairs, r.Log) + if err != nil { + return liberr.Wrap(err) + } + primaryResult, err := planbase.ValidateCalicoPrimary(context.TODO(), dstClient, pairs, r.Log) + if err != nil { + return liberr.Wrap(err) + } + + if cond, ok := planbase.BuildCalicoNADCondition( + CalicoNetworkInvalid, Critical, + "One or more Calico Network destinations are invalid", + nadResult.Issues, + ); ok { + mp.Status.SetCondition(cond) + } + if cond, ok := planbase.BuildCalicoNADCondition( + CalicoNetworkWarning, Warn, + "One or more Calico NADs will not receive identity preservation", + nadResult.Warnings, + ); ok { + mp.Status.SetCondition(cond) + } + if cond, ok := planbase.BuildCalicoPrimaryCondition( + CalicoPrimaryInvalid, Critical, + "The Calico primary-network mapping is invalid", + primaryResult.Issues, + ); ok { + mp.Status.SetCondition(cond) + } + if cond, ok := planbase.BuildCalicoPrimaryCondition( + CalicoPrimaryWarning, Warn, + "The Calico primary-network mapping has informational warnings", + primaryResult.Warnings, + ); ok { + mp.Status.SetCondition(cond) + } + return nil +} + +// destinationClient builds a client for the destination cluster from the +// destination provider: its secret-referenced credentials for a remote +// cluster, or the in-cluster configuration for the host provider. +func (r *Reconciler) destinationClient(provider *api.Provider) (k8sclient.Client, error) { + if provider.IsHost() { + return ocp.Client(provider, nil) + } + ref := provider.Spec.Secret + secret := &core.Secret{} + err := r.Get( + context.TODO(), + k8sclient.ObjectKey{ + Namespace: ref.Namespace, + Name: ref.Name, + }, + secret) + if err != nil { + return nil, liberr.Wrap(err) + } + return ocp.Client(provider, secret) +} diff --git a/pkg/controller/map/network/calico_test.go b/pkg/controller/map/network/calico_test.go new file mode 100644 index 0000000000..88e510c4c3 --- /dev/null +++ b/pkg/controller/map/network/calico_test.go @@ -0,0 +1,83 @@ +package network + +import ( + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("NetworkMap Calico validation", func() { + makeMap := func(srcType api.ProviderType, pairs ...api.NetworkPair) *api.NetworkMap { + src := &api.Provider{ + ObjectMeta: meta.ObjectMeta{Name: "src", Namespace: "test"}, + Spec: api.ProviderSpec{Type: &srcType}, + } + dstType := api.OpenShift + dst := &api.Provider{ + ObjectMeta: meta.ObjectMeta{Name: "dst", Namespace: "test"}, + Spec: api.ProviderSpec{Type: &dstType}, + } + mp := &api.NetworkMap{ + ObjectMeta: meta.ObjectMeta{Name: "test-map", Namespace: "test"}, + Spec: api.NetworkMapSpec{Map: pairs}, + } + mp.Referenced.Provider.Source = src + mp.Referenced.Provider.Destination = dst + return mp + } + calicoPodPair := api.NetworkPair{ + Destination: api.DestinationNetwork{ + Type: Pod, + Calico: &api.CalicoDestination{}, + }, + } + podPair := api.NetworkPair{ + Destination: api.DestinationNetwork{Type: Pod}, + } + + It("rejects the calico opt-in on a non-vSphere source", func() { + mp := makeMap(api.OVirt, calicoPodPair) + r := &Reconciler{} + Expect(r.validateCalico(mp, planbase.HasCalicoPodEntry(mp.Spec.Map), false)).To(Succeed()) + cond := mp.Status.FindCondition(CalicoPrimaryInvalid) + Expect(cond).NotTo(BeNil()) + Expect(cond.Category).To(Equal(Critical)) + Expect(cond.Message).To(ContainSubstring("PrimaryProviderUnsupported")) + }) + + It("sets no condition on a non-vSphere source without the calico opt-in", func() { + mp := makeMap(api.OVirt, podPair) + r := &Reconciler{} + Expect(r.validateCalico(mp, planbase.HasCalicoPodEntry(mp.Spec.Map), false)).To(Succeed()) + Expect(mp.Status.HasCondition(CalicoPrimaryInvalid)).To(BeFalse()) + }) + + It("skips the destination probe when no entry can engage Calico", func() { + // A vSphere-source map with neither a calico block nor a multus + // entry returns before building the destination client — a nil + // Reconciler client would panic otherwise. + mp := makeMap(api.VSphere, podPair) + r := &Reconciler{} + Expect(r.validateCalico(mp, planbase.HasCalicoPodEntry(mp.Spec.Map), false)).To(Succeed()) + Expect(mp.Status.HasCondition(CalicoPrimaryInvalid)).To(BeFalse()) + Expect(mp.Status.HasCondition(CalicoNetworkInvalid)).To(BeFalse()) + }) + + It("is a no-op when providers are not referenced yet", func() { + mp := &api.NetworkMap{Spec: api.NetworkMapSpec{Map: []api.NetworkPair{calicoPodPair}}} + r := &Reconciler{} + Expect(r.validateCalico(mp, planbase.HasCalicoPodEntry(mp.Spec.Map), false)).To(Succeed()) + Expect(mp.Status.HasCondition(CalicoPrimaryInvalid)).To(BeFalse()) + }) + + It("keeps the condition vocabulary aligned with the plan controller", func() { + // The plan controller folds per-VM issues into conditions of the + // same names; a rename on either side would fork the user-facing + // vocabulary. + Expect(CalicoPrimaryInvalid).To(Equal("CalicoPrimaryInvalid")) + Expect(CalicoNetworkInvalid).To(Equal("CalicoNetworkInvalid")) + var _ = planbase.CalicoIssuePrimaryProviderUnsupported + }) +}) diff --git a/pkg/controller/map/network/validation.go b/pkg/controller/map/network/validation.go index 691450a925..a9c46a2e46 100644 --- a/pkg/controller/map/network/validation.go +++ b/pkg/controller/map/network/validation.go @@ -155,6 +155,8 @@ func (r *Reconciler) validateDestination(mp *api.NetworkMap) (err error) { ambiguous := []string{} networkIPModeInvalid := []string{} networkIPModeHasCritical := false + hasCalicoBlock := false + hasMultus := false next: for _, entry := range list { if entry.Destination.Type == Ignored && entry.NetworkIPMode != "" { @@ -164,10 +166,14 @@ next: if entry.Destination.Type == Pod && entry.NetworkIPMode == api.NetworkIPModePreserve { networkIPModeInvalid = append(networkIPModeInvalid, entry.Source.String()) } + if entry.Destination.Calico != nil { + hasCalicoBlock = true + } switch entry.Destination.Type { case Ignored, Pod: continue next case Multus: + hasMultus = true if entry.Destination.Namespace == "" { ambiguous = append( ambiguous, @@ -229,5 +235,5 @@ next: }) } - return + return r.validateCalico(mp, hasCalicoBlock, hasMultus) } diff --git a/pkg/controller/plan/adapter/base/calico_conditions.go b/pkg/controller/plan/adapter/base/calico_conditions.go new file mode 100644 index 0000000000..8169859ad1 --- /dev/null +++ b/pkg/controller/plan/adapter/base/calico_conditions.go @@ -0,0 +1,102 @@ +package base + +import "fmt" + +// CalicoPrimaryIssueDetail formats a per-issue detail phrase for the +// CalicoPrimaryInvalid / CalicoPrimaryWarning Message. Per-VM +// issues carry a VMRef prefix; plan-level issues do not. +func CalicoPrimaryIssueDetail(i CalicoPrimaryIssue) string { + prefix := "" + if !i.VMRef.NotSet() { + prefix = i.VMRef.String() + " " + } + switch i.Kind { + case CalicoIssuePrimaryProviderUnsupported: + return fmt.Sprintf("%s(PrimaryProviderUnsupported: feature is vSphere-only in this release)", prefix) + case CalicoIssuePrimaryUnsupported: + return fmt.Sprintf("%s(PrimaryUnsupported: Calico is not installed on the destination — projectcalico.org/v3 IPPool CRD absent)", prefix) + case CalicoIssuePrimaryNetworkCRDAbsent: + return fmt.Sprintf("%s(PrimaryNetworkCRDAbsent network=%q: destination Calico install does not ship the projectcalico.org/v3 Network CRD; remove calico.network or upgrade Calico)", prefix, i.Network) + case CalicoIssuePrimaryConflictsWithUDN: + return fmt.Sprintf("%s(PrimaryConflictsWithUDN: target namespace is labelled for a UDN primary network)", prefix) + case CalicoIssuePrimaryNetworkNotFound: + return fmt.Sprintf("%s(PrimaryNetworkNotFound network=%q)", prefix, i.Network) + case CalicoIssuePrimaryNetworkTypeUnsupported: + return fmt.Sprintf("%s(PrimaryNetworkTypeUnsupported network=%q: the referenced Network is not an L2 bridge network; only l2Bridge networks can back the primary NIC — a VRF network attaches via a multus NAD instead)", prefix, i.Network) + case CalicoIssuePrimaryDataplaneNotBPF: + return fmt.Sprintf("%s(PrimaryDataplaneNotBPF network=%q: the destination Calico install is not running the BPF dataplane; L2 networks require FelixConfiguration bpfEnabled: true)", prefix, i.Network) + case CalicoIssuePrimaryNetworkHasNoL2Bridge: + return fmt.Sprintf("%s(PrimaryNetworkHasNoL2Bridge network=%q)", prefix, i.Network) + case CalicoIssuePrimaryNetworkHasNoVLANs: + return fmt.Sprintf("%s(PrimaryNetworkHasNoVLANs network=%q)", prefix, i.Network) + case CalicoIssuePrimaryVLANRequired: + return fmt.Sprintf("%s(PrimaryVLANRequired network=%q: calico.network is set but calico.vlan is not; an explicit VLAN is required)", prefix, i.Network) + case CalicoIssuePrimaryVLANNotInNetwork: + return fmt.Sprintf("%s(PrimaryVLANNotInNetwork network=%q vlan=%d)", prefix, i.Network, i.VLAN) + case CalicoIssuePrimaryNoEligibleIPPool: + if i.IP != "" { + return fmt.Sprintf("%s(PrimaryNoEligibleIPPool ip=%s network=%q vlan=%d)", prefix, i.IP, i.Network, i.VLAN) + } + return fmt.Sprintf("%s(PrimaryNoEligibleIPPool network=%q vlan=%d)", prefix, i.Network, i.VLAN) + case CalicoIssuePrimaryIPNotInSubnet: + return fmt.Sprintf("%s(PrimaryIPNotInSubnet ip=%s network=%q vlan=%d)", prefix, i.IP, i.Network, i.VLAN) + case CalicoIssuePrimaryTooManyIPs: + return fmt.Sprintf("%s(PrimaryTooManyIPs ips=%s: Calico static IP preservation supports at most one IPv4 per interface)", prefix, i.IP) + case CalicoIssuePrimaryFieldsMisplaced: + return fmt.Sprintf("%s(PrimaryFieldsMisplaced: calico block set on a non-pod entry, calico.vlan without calico.network, or multiple calico-flagged entries)", prefix) + case CalicoIssuePrimaryStaticIPsNotPreserved: + return fmt.Sprintf("%s(PrimaryStaticIPsNotPreserved: preserveStaticIPs is false; DHCP-configured guests will pick up the Calico-assigned IP via the veth, static-IP guests may have a divergent in-guest IP)", prefix) + } + return fmt.Sprintf("%s(%s)", prefix, i.Kind) +} + +// CalicoNADIssueDetail formats a per-NAD detail phrase for the CalicoNetworkInvalid condition's Message: e.g. +// "default/foo (NetworkNotFound network=\"calico-vlan\")". +func CalicoNADIssueDetail(i CalicoNADIssue) string { + switch i.Kind { + case CalicoIssueNADUnreadable: + return fmt.Sprintf("%s (NADUnreadable)", i.NAD.String()) + case CalicoIssueNetworkNotFound: + return fmt.Sprintf("%s (NetworkNotFound network=%q)", i.NAD.String(), i.Network) + case CalicoIssueNetworkCRDAbsent: + return fmt.Sprintf("%s (NetworkCRDAbsent network=%q: destination Calico install does not ship the projectcalico.org/v3 Network CRD)", i.NAD.String(), i.Network) + case CalicoIssueVRFVlanIgnored: + return fmt.Sprintf("%s (VRFVlanIgnored network=%q vlan=%d: the referenced Network is a VRF (routed) network; VLANs apply only to l2Bridge networks and the vlan value is ignored)", i.NAD.String(), i.Network, i.VLAN) + case CalicoIssueVRFNodeScoped: + return fmt.Sprintf("%s (VRFNodeScoped network=%q: every hostConfig entry carries a nodeSelector, so the network exists only on matching nodes, and the plan does not constrain VM placement; VMs may schedule onto uncovered nodes and fail to start — set the plan's targetNodeSelector or targetAffinity to keep VMs on covered nodes, or add a hostConfig entry without a nodeSelector)", i.NAD.String(), i.Network) + case CalicoIssueVRFPlacementUnverified: + return fmt.Sprintf("%s (VRFPlacementUnverified network=%q: the network exists only on nodes matching its hostConfig selectors; the plan constrains VM placement, but Forklift cannot verify that placement keeps VMs on covered nodes)", i.NAD.String(), i.Network) + case CalicoIssueVRFRouteTableReserved: + return fmt.Sprintf("%s (VRFRouteTableReserved network=%q table=%d: route tables 253, 254 and 255 are reserved by the kernel; choose a different routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable) + case CalicoIssueVRFRouteTableConflict: + if i.ConflictsWith != "" { + return fmt.Sprintf("%s (VRFRouteTableConflict network=%q table=%d: route table %d is also claimed by VRF Network %q on an overlapping set of nodes, which can result in network outages; give each VRF Network a unique routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable, i.RouteTable, i.ConflictsWith) + } + return fmt.Sprintf("%s (VRFRouteTableConflict network=%q table=%d: route table %d falls inside the FelixConfiguration routeTableRanges, which Calico reserves for its own routes; choose a routeTableIndex outside those ranges)", i.NAD.String(), i.Network, i.RouteTable, i.RouteTable) + case CalicoIssueVRFRouteTablePossibleConflict: + return fmt.Sprintf("%s (VRFRouteTablePossibleConflict network=%q table=%d: VRF Network %q also uses route table %d; both entries are node-scoped, so the overlap cannot be ruled out — verify the two selectors never match the same node, or give each network a unique routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable, i.ConflictsWith, i.RouteTable) + case CalicoIssueVRFDataplaneNotNftables: + return fmt.Sprintf("%s (VRFDataplaneNotNftables: VRF networking requires the nftables dataplane; set nftablesMode: Enabled (and leave bpfEnabled off) in the default FelixConfiguration)", i.NAD.String()) + case CalicoIssueVRFPoolNotPinned: + return fmt.Sprintf("%s (VRFPoolNotPinned network=%q: the NAD does not pin an IPPool, so each VM's address will come from whichever pool Calico's IPAM selects and the VRF's network may not be able to route it; pin the VRF's IPPool via ipv4_pools in the NAD's IPAM config)", i.NAD.String(), i.Network) + case CalicoIssueVRFNoBGPPeer: + return fmt.Sprintf("%s (VRFNoBGPPeer network=%q: cross-node reachability in this VRF requires a BGPPeer whose spec.network names it; VMs placed on different nodes will not reach each other until one exists)", i.NAD.String(), i.Network) + case CalicoIssueVRFNoHostInterfaces: + return fmt.Sprintf("%s (VRFNoHostInterfaces network=%q: a hostConfig entry names no hostInterfaces, so VMs on the nodes that entry matches are unreachable beyond their own node; name at least one host interface in every hostConfig entry)", i.NAD.String(), i.Network) + case CalicoIssueDataplaneNotBPF: + return fmt.Sprintf("%s (DataplaneNotBPF: the destination Calico install is not running the BPF dataplane; L2 networks require FelixConfiguration bpfEnabled: true)", i.NAD.String()) + case CalicoIssueNetworkHasNoL2Bridge: + return fmt.Sprintf("%s (NetworkHasNoL2Bridge network=%q)", i.NAD.String(), i.Network) + case CalicoIssueNetworkHasNoVLANs: + return fmt.Sprintf("%s (NetworkHasNoVLANs network=%q)", i.NAD.String(), i.Network) + case CalicoIssueVLANNotInNetwork: + return fmt.Sprintf("%s (VLANNotInNetwork network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) + case CalicoIssueVLANRequired: + return fmt.Sprintf("%s (VLANRequired network=%q: the NAD references a Calico Network but names no VLAN; an explicit VLAN is required)", i.NAD.String(), i.Network) + case CalicoIssueVLANHasNoIPPool: + return fmt.Sprintf("%s (VLANHasNoIPPool network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) + case CalicoIssueNADMissingNetwork: + return fmt.Sprintf("%s (NADMissingNetwork: type=calico without 'network' field; MAC/IP preservation not applied)", i.NAD.String()) + } + return fmt.Sprintf("%s (%s)", i.NAD.String(), i.Kind) +} diff --git a/pkg/controller/plan/adapter/base/calico_map_validation.go b/pkg/controller/plan/adapter/base/calico_map_validation.go new file mode 100644 index 0000000000..28e380f35d --- /dev/null +++ b/pkg/controller/plan/adapter/base/calico_map_validation.go @@ -0,0 +1,696 @@ +package base + +import ( + "context" + "fmt" + "sort" + "strings" + + api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" + ocpmodel "github.com/kubev2v/forklift/pkg/controller/provider/model/ocp" + calicoclient "github.com/kubev2v/forklift/pkg/lib/client/calico" + libcnd "github.com/kubev2v/forklift/pkg/lib/condition" + liberr "github.com/kubev2v/forklift/pkg/lib/error" + "github.com/kubev2v/forklift/pkg/lib/logging" + k8serr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + k8stypes "k8s.io/apimachinery/pkg/types" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ValidateCalicoNADs walks every Multus destination in a NetworkMap's +// entries, fetches each Calico-referencing NAD from the destination cluster, +// and validates the NAD/Network/IPPool resources. It evaluates only +// map-scoped concerns — checks that need Plan state (placement, IP +// preservation) are evaluated separately by CalicoNADPlanIssues over the +// returned cache. Called by both the NetworkMap controller (to surface map +// conditions) and the Plan controller (to build the per-VM cache). +func ValidateCalicoNADs(ctx context.Context, c k8sclient.Client, pairs []api.NetworkPair, log logging.LevelLogger) (CalicoValidationResult, error) { + result := CalicoValidationResult{ + Cache: &CalicoValidationCache{ + NADs: map[k8stypes.NamespacedName]*ResolvedCalicoNAD{}, + }, + } + if len(pairs) == 0 { + return result, nil + } + + nadCfgs := map[k8stypes.NamespacedName]*ocpmodel.NetworkConfig{} + var pools []calicoclient.IPPool + poolsLoaded := false + bpfChecked := false + + vrfChecked := map[string]bool{} + vrfDataplaneChecked := false + var vrfNetworks []calicoclient.Network + vrfNetworksLoaded := false + var bgpPeerNetworks map[string]bool + bgpPeerNetworksLoaded := false + var felixFacts *calicoclient.FelixConfig + + // Felix is the per-node daemon which programs the dataplane + // based on k8s config. + loadFelix := func(key k8stypes.NamespacedName) (*calicoclient.FelixConfig, error) { + if felixFacts != nil { + return felixFacts, nil + } + fc, err := calicoclient.GetFelixConfig(ctx, c) + if err != nil { + return nil, liberr.Wrap(err, "nad", key.String()) + } + felixFacts = fc + return fc, nil + } + + // IPPools are the Calico CRD describing available IP CIDRs + // and their permitted uses. + loadPools := func(key k8stypes.NamespacedName) (err error) { + if poolsLoaded { + return + } + pools, err = calicoclient.ListIPPools(ctx, c) + if err != nil { + if !meta.IsNoMatchError(err) { + return liberr.Wrap(err, "nad", key.String()) + } + pools = nil + err = nil + } + poolsLoaded = true + return + } + + // For each map item, try fetching a corresponding NAD. + // Search for NAD's whose config has `type:calico`, ignore the rest. + // The presence of a Calico `Network` CR implies per-NIC addressing + // is possible for L2 and L3. + for _, pair := range pairs { + if pair.Destination.Type != Multus { + continue + } + key := k8stypes.NamespacedName{ + Namespace: pair.Destination.Namespace, + Name: pair.Destination.Name, + } + if _, dup := nadCfgs[key]; dup { + continue + } + + cfg, err := FetchAndParseNAD(ctx, c, key.Namespace, key.Name) + if err != nil { + nadCfgs[key] = nil + if log != nil { + log.Error(err, "Calico NAD: failed to fetch/parse", + "namespace", key.Namespace, "name", key.Name) + } + result.Issues = append(result.Issues, CalicoNADIssue{ + NAD: key, + Kind: CalicoIssueNADUnreadable, + }) + continue + } + nadCfgs[key] = cfg + + // type:calico without a "network" field is Calico's legacy L3 IPAM + // mode. Identity preservation on secondary NICs relies on the + // network-scoped annotations that ship with the Network resource, so + // it applies only to NADs that reference one (l2Bridge or VRF); warn + // that MAC/IP annotations will not be emitted for NICs mapped here. + if cfg.Type == ocpmodel.CalicoCNIType && cfg.Network == "" { + result.Warnings = append(result.Warnings, CalicoNADIssue{ + NAD: key, + Kind: CalicoIssueNADMissingNetwork, + }) + continue + } + if !cfg.ReferencesCalicoNetwork() { + continue + } + + issueBase := CalicoNADIssue{NAD: key, Network: cfg.Network, VLAN: cfg.VLAN} + + nw, err := calicoclient.GetNetwork(ctx, c, cfg.Network) + if err != nil { + switch { + case meta.IsNoMatchError(err): + issueBase.Kind = CalicoIssueNetworkCRDAbsent + result.Issues = append(result.Issues, issueBase) + continue + case k8serr.IsNotFound(err): + issueBase.Kind = CalicoIssueNetworkNotFound + result.Issues = append(result.Issues, issueBase) + continue + default: + return CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String(), "network", cfg.Network) + } + } + + // Classify the Network before any VLAN handling (VRF / l2Bridge). + if nw.IsVRF { + if cfg.VLAN != 0 { + ib := issueBase + ib.Kind = CalicoIssueVRFVlanIgnored + result.Warnings = append(result.Warnings, ib) + } + + if !vrfChecked[cfg.Network] { + vrfChecked[cfg.Network] = true + + if vrfHasEntryWithoutHostInterfaces(nw.VRFHostConfig) { + ib := issueBase + ib.Kind = CalicoIssueVRFNoHostInterfaces + result.Warnings = append(result.Warnings, ib) + } + + if !bgpPeerNetworksLoaded { + bgpPeerNetworksLoaded = true + bgpPeerNetworks, err = calicoclient.ListBGPPeerNetworks(ctx, c) + if err != nil { + return CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + } + } + if !bgpPeerNetworks[cfg.Network] { + ib := issueBase + ib.Kind = CalicoIssueVRFNoBGPPeer + result.Warnings = append(result.Warnings, ib) + } + + if !vrfNetworksLoaded { + vrfNetworksLoaded = true + vrfNetworks, err = calicoclient.ListNetworks(ctx, c) + if err != nil { + return CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + } + } + felix, ferr := loadFelix(key) + if ferr != nil { + return CalicoValidationResult{}, ferr + } + criticals, warns := vrfRouteTableIssues(issueBase, nw, vrfNetworks, felix) + result.Issues = append(result.Issues, criticals...) + result.Warnings = append(result.Warnings, warns...) + + // VRF networks only supported on NFTables dataplane. + if !vrfDataplaneChecked { + vrfDataplaneChecked = true + if felix.BPFEnabled || felix.NftablesMode != calicoclient.NftablesModeEnabled { + ib := issueBase + ib.Kind = CalicoIssueVRFDataplaneNotNftables + result.Issues = append(result.Issues, ib) + } + } + } + if err = loadPools(key); err != nil { + return CalicoValidationResult{}, err + } + result.Cache.NADs[key] = &ResolvedCalicoNAD{ + Network: cfg.Network, + IsVRF: true, + EligiblePools: calicoclient.L3EligiblePools(pools), + VRFCoversAllNodes: vrfHasAllNodesEntry(nw.VRFHostConfig), + VRFPoolsPinned: len(cfg.IPv4Pools) > 0, + } + continue + } + if nw.L2Bridge == nil { + issueBase.Kind = CalicoIssueNetworkHasNoL2Bridge + result.Issues = append(result.Issues, issueBase) + continue + } + + // L2 networks require the BPF dataplane. + if !bpfChecked { + bpfChecked = true + bpfEnabled, err := calicoclient.GetBPFEnabled(ctx, c) + if err != nil { + return CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) + } + if !bpfEnabled { + ib := issueBase + ib.Kind = CalicoIssueDataplaneNotBPF + result.Issues = append(result.Issues, ib) + } + } + + if cfg.VLAN == 0 { + issueBase.Kind = CalicoIssueVLANRequired + result.Issues = append(result.Issues, issueBase) + continue + } + + entry, vlanIssueKind := resolveVLANEntry(nw.L2Bridge.VLANs, cfg.VLAN) + if vlanIssueKind != "" { + issueBase.Kind = vlanIssueKind + result.Issues = append(result.Issues, issueBase) + continue + } + + if err = loadPools(key); err != nil { + return CalicoValidationResult{}, err + } + eligible := calicoclient.L2WorkloadEligiblePools(pools, entry.Subnets) + if len(eligible) == 0 { + issueBase.Kind = CalicoIssueVLANHasNoIPPool + result.Issues = append(result.Issues, issueBase) + continue + } + + result.Cache.NADs[key] = &ResolvedCalicoNAD{ + Network: cfg.Network, + VLAN: *entry, + EligiblePools: eligible, + } + } + return result, nil +} + +// CalicoNADPlanIssues evaluates the plan-scoped concerns for the cached VRF +// NADs: pool pinning (depends on the Plan's preserveStaticIPs) and node +// coverage (depends on whether the Plan constrains VM placement). Returns +// Critical issues and warnings for the plan-level Calico conditions. +func CalicoNADPlanIssues(cache *CalicoValidationCache, preserveStaticIPs, planHasPlacement bool) (criticals, warnings []CalicoNADIssue) { + if cache == nil { + return + } + keys := make([]k8stypes.NamespacedName, 0, len(cache.NADs)) + for key := range cache.NADs { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) + + coverageChecked := map[string]bool{} + for _, key := range keys { + resolved := cache.NADs[key] + if !resolved.IsVRF { + continue + } + issueBase := CalicoNADIssue{NAD: key, Network: resolved.Network} + + if !resolved.VRFPoolsPinned && !preserveStaticIPs { + ib := issueBase + ib.Kind = CalicoIssueVRFPoolNotPinned + warnings = append(warnings, ib) + } + + // An empty nodeSelector is the canonical all-nodes form, so + // all-scoped hostConfig means a node subset (Calico selectors + // are not parsed). A VM scheduled onto an uncovered node + // fails at CNI ADD, so with no placement constraints on the + // plan the outcome is a scheduling lottery: Critical. When + // the plan pins placement (targetNodeSelector/targetAffinity) + // the user has taken control; Warn that the pin cannot be + // verified against the network's selectors. + if !coverageChecked[resolved.Network] { + coverageChecked[resolved.Network] = true + if !resolved.VRFCoversAllNodes { + ib := issueBase + if planHasPlacement { + ib.Kind = CalicoIssueVRFPlacementUnverified + warnings = append(warnings, ib) + } else { + ib.Kind = CalicoIssueVRFNodeScoped + criticals = append(criticals, ib) + } + } + } + } + return +} + +// ValidateCalicoPrimary validates the (at most one) calico-flagged +// NetworkMap entry — a type: pod destination carrying the calico field. +// It evaluates only map-scoped concerns — the Plan controller separately +// evaluates the preserveStaticIPs warning and the UDN-namespace conflict. +// Called by both the NetworkMap controller and the Plan controller. +func ValidateCalicoPrimary(ctx context.Context, c k8sclient.Client, pairs []api.NetworkPair, log logging.LevelLogger) (CalicoPrimaryValidationResult, error) { + result := CalicoPrimaryValidationResult{ + Cache: &CalicoPrimaryValidationCache{}, + } + if len(pairs) == 0 { + return result, nil + } + + // Classify entries (VRF or L2Bridge), surface field-misplacement issues. + var calicoEntries []api.NetworkPair + for _, pair := range pairs { + dest := pair.Destination + if dest.Calico == nil { + continue + } + if dest.Type != Pod { + result.Issues = append(result.Issues, CalicoPrimaryIssue{ + Kind: CalicoIssuePrimaryFieldsMisplaced, + Network: dest.Calico.Network, + VLAN: dest.Calico.Vlan, + }) + continue + } + calicoEntries = append(calicoEntries, pair) + // vlan-without-network is a field-placement error within the block. + if dest.Calico.Network == "" && dest.Calico.Vlan != 0 { + result.Issues = append(result.Issues, CalicoPrimaryIssue{ + Kind: CalicoIssuePrimaryFieldsMisplaced, + VLAN: dest.Calico.Vlan, + }) + } + } + + if len(calicoEntries) > 1 { + result.Issues = append(result.Issues, CalicoPrimaryIssue{ + Kind: CalicoIssuePrimaryFieldsMisplaced, + }) + } + if len(calicoEntries) == 0 { + return result, nil + } + + entry := calicoEntries[0] + calico := entry.Destination.Calico + issueBase := CalicoPrimaryIssue{Network: calico.Network, VLAN: calico.Vlan} + + pools, err := calicoclient.ListIPPools(ctx, c) + if err != nil { + if meta.IsNoMatchError(err) { + ib := issueBase + ib.Kind = CalicoIssuePrimaryUnsupported + result.Issues = append(result.Issues, ib) + return result, nil + } + return CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) + } + + if calico.Network == "" { + result.Cache.Primary = &ResolvedCalicoPrimary{ + Source: entry.Source.Ref, + L3EligiblePools: calicoclient.L3EligiblePools(pools), + } + return result, nil + } + + nw, err := calicoclient.GetNetwork(ctx, c, calico.Network) + if err != nil { + switch { + case meta.IsNoMatchError(err): + ib := issueBase + ib.Kind = CalicoIssuePrimaryNetworkCRDAbsent + result.Issues = append(result.Issues, ib) + return result, nil + case k8serr.IsNotFound(err): + ib := issueBase + ib.Kind = CalicoIssuePrimaryNetworkNotFound + result.Issues = append(result.Issues, ib) + return result, nil + default: + if log != nil { + log.Error(err, "Calico-primary: failed to fetch Network", + "network", calico.Network) + } + return CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) + } + } + + // A non-l2Bridge network (e.g. a VRF network) carries no VLAN, so + // the VLANRequired / VLAN-matching checks below would mislead. + if nw.IsVRF { + ib := issueBase + ib.Kind = CalicoIssuePrimaryNetworkTypeUnsupported + result.Issues = append(result.Issues, ib) + return result, nil + } + if nw.L2Bridge == nil { + ib := issueBase + ib.Kind = CalicoIssuePrimaryNetworkHasNoL2Bridge + result.Issues = append(result.Issues, ib) + return result, nil + } + + // L2 networks require the BPF dataplane. + bpfEnabled, err := calicoclient.GetBPFEnabled(ctx, c) + if err != nil { + return CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) + } + if !bpfEnabled { + ib := issueBase + ib.Kind = CalicoIssuePrimaryDataplaneNotBPF + result.Issues = append(result.Issues, ib) + } + + // A Network reference requires an explicit VLAN. + if calico.Vlan == 0 { + ib := issueBase + ib.Kind = CalicoIssuePrimaryVLANRequired + result.Issues = append(result.Issues, ib) + return result, nil + } + + vlanEntry, vlanIssueKind := resolveVLANEntry(nw.L2Bridge.VLANs, calico.Vlan) + if vlanIssueKind != "" { + ib := issueBase + ib.Kind = translateVLANIssueKindToPrimary(vlanIssueKind) + result.Issues = append(result.Issues, ib) + return result, nil + } + + l2Pools := calicoclient.L2WorkloadEligiblePools(pools, vlanEntry.Subnets) + if len(l2Pools) == 0 { + ib := issueBase + ib.Kind = CalicoIssuePrimaryNoEligibleIPPool + result.Issues = append(result.Issues, ib) + return result, nil + } + + result.Cache.Primary = &ResolvedCalicoPrimary{ + Network: calico.Network, + VLAN: *vlanEntry, + L2EligiblePools: l2Pools, + Source: entry.Source.Ref, + } + return result, nil +} + +// HasCalicoPodEntry reports whether any entry is a type: pod destination +// carrying the calico opt-in block. +func HasCalicoPodEntry(pairs []api.NetworkPair) bool { + for _, pair := range pairs { + if pair.Destination.Type == Pod && pair.Destination.Calico != nil { + return true + } + } + return false +} + +// translateVLANIssueKindToPrimary converts the secondary-NAD-path VLAN issue +// kinds returned by resolveVLANEntry into the Calico-primary equivalents. +// The shared resolver returns the NAD-path kinds; the primary path emits its +// own kinds so users can disambiguate primary vs secondary failures in the +// condition. +func translateVLANIssueKindToPrimary(k CalicoIssueKind) CalicoIssueKind { + switch k { + case CalicoIssueNetworkHasNoVLANs: + return CalicoIssuePrimaryNetworkHasNoVLANs + case CalicoIssueVLANNotInNetwork: + return CalicoIssuePrimaryVLANNotInNetwork + } + return k +} + +// vrfReservedRouteTables are the kernel's own route tables — 253 (default), +// 254 (main), 255 (local) — which a VRF must never claim. +var vrfReservedRouteTables = map[int64]bool{253: true, 254: true, 255: true} + +// vrfHasAllNodesEntry reports whether any spec.vrf.hostConfig entry has an +// empty (or absent) nodeSelector — such an entry applies to every node, so +// the VRF network is guaranteed to exist wherever a VM lands. +func vrfHasAllNodesEntry(entries []calicoclient.VRFHostEntry) bool { + for _, e := range entries { + if e.NodeSelector == "" { + return true + } + } + return false +} + +// vrfHasEntryWithoutHostInterfaces reports whether any spec.vrf.hostConfig +// entry names no host interfaces. Such an entry gives pods on its nodes no +// path off the node inside the VRF, so its VMs are unreachable beyond their +// own node. +func vrfHasEntryWithoutHostInterfaces(entries []calicoclient.VRFHostEntry) bool { + for _, e := range entries { + if !e.HasHostInterfaces { + return true + } + } + return false +} + +// vrfRouteTableIssues checks the referenced VRF Network's routeTableIndex +// values for reserved-table use and for collisions with other VRF Networks +// and with the FelixConfiguration routeTableRanges. issueBase supplies the +// NAD/Network attribution; each finding carries the offending table index. +// +// A collision with another VRF Network is provable (Critical) when at least +// one of the two entries carrying the index has no nodeSelector — an +// all-nodes entry overlaps any other node set. When both entries are +// selector-scoped, the overlap depends on which nodes the selectors match; +// selector evaluation is deliberately out of scope, so those pairs are +// reported as possible conflicts (Warn). Entries of the same Network sharing +// an index are legitimate — same VRF, same table — and never reported. +// +// The FelixConfiguration sub-check runs only when spec.routeTableRanges is +// explicitly set. When absent, Felix falls back to version-dependent +// defaults that are not modelled here — guessing them would risk false +// Criticals against tables Felix never touches. +func vrfRouteTableIssues(issueBase CalicoNADIssue, nw *calicoclient.Network, all []calicoclient.Network, felix *calicoclient.FelixConfig) (criticals, warnings []CalicoNADIssue) { + // Distinct indexes in entry order; per index, remember whether any + // entry carrying it applies to all nodes. + var indexes []int64 + seen := map[int64]bool{} + allNodes := map[int64]bool{} + for _, e := range nw.VRFHostConfig { + if !seen[e.RouteTableIndex] { + seen[e.RouteTableIndex] = true + indexes = append(indexes, e.RouteTableIndex) + } + if e.NodeSelector == "" { + allNodes[e.RouteTableIndex] = true + } + } + + for _, idx := range indexes { + if vrfReservedRouteTables[idx] { + ib := issueBase + ib.Kind = CalicoIssueVRFRouteTableReserved + ib.RouteTable = idx + criticals = append(criticals, ib) + } + for _, rng := range felix.RouteTableRanges { + if idx >= rng.Min && idx <= rng.Max { + ib := issueBase + ib.Kind = CalicoIssueVRFRouteTableConflict + ib.RouteTable = idx + criticals = append(criticals, ib) + break + } + } + } + + for i := range all { + other := &all[i] + if other.Name == nw.Name || !other.IsVRF { + continue + } + otherHas := map[int64]bool{} + otherAllNodes := map[int64]bool{} + for _, o := range other.VRFHostConfig { + otherHas[o.RouteTableIndex] = true + if o.NodeSelector == "" { + otherAllNodes[o.RouteTableIndex] = true + } + } + for _, idx := range indexes { + if !otherHas[idx] { + continue + } + ib := issueBase + ib.RouteTable = idx + ib.ConflictsWith = other.Name + if allNodes[idx] || otherAllNodes[idx] { + ib.Kind = CalicoIssueVRFRouteTableConflict + criticals = append(criticals, ib) + } else { + ib.Kind = CalicoIssueVRFRouteTablePossibleConflict + warnings = append(warnings, ib) + } + } + } + return +} + +// resolveVLANEntry returns the l2Bridge.vlans[] entry matched by nadVLAN. +func resolveVLANEntry(vlans []calicoclient.VLANEntry, nadVLAN uint16) (*calicoclient.VLANEntry, CalicoIssueKind) { + if len(vlans) == 0 { + return nil, CalicoIssueNetworkHasNoVLANs + } + for i := range vlans { + if vlans[i].VID == nadVLAN { + return &vlans[i], "" + } + } + return nil, CalicoIssueVLANNotInNetwork +} + +// BuildCalicoNADCondition assembles a Calico NAD condition from a slice of +// per-NAD issues. Items are deduplicated by NAD reference; Message +// concatenates a per-issue detail phrase for each (including duplicates, so +// distinct kinds against the same NAD are both visible). Returns ok=false +// when issues is empty so callers can skip SetCondition. Used for both +// NetworkMap and Plan conditions. +func BuildCalicoNADCondition(condType, category, baseMsg string, issues []CalicoNADIssue) (libcnd.Condition, bool) { + if len(issues) == 0 { + return libcnd.Condition{}, false + } + cond := libcnd.Condition{ + Type: condType, + Status: libcnd.True, + Reason: calicoConditionReason, + Category: category, + Items: []string{}, + } + details := make([]string, 0, len(issues)) + seen := map[string]bool{} + for _, issue := range issues { + ref := issue.NAD.String() + if !seen[ref] { + seen[ref] = true + cond.Items = append(cond.Items, ref) + } + details = append(details, CalicoNADIssueDetail(issue)) + } + cond.Message = fmt.Sprintf("%s: %s.", baseMsg, strings.Join(details, "; ")) + return cond, true +} + +// BuildCalicoPrimaryCondition assembles a Calico-primary condition from a +// slice of issues. Items deduplicate by issue ref (per-VM VMRef when set, or +// a "(plan)" synthetic identifier for map/plan-level issues). Message +// concatenates a per-issue detail phrase. Returns ok=false when issues is +// empty so callers can skip SetCondition. Used for both NetworkMap and Plan +// conditions. +func BuildCalicoPrimaryCondition(condType, category, baseMsg string, issues []CalicoPrimaryIssue) (libcnd.Condition, bool) { + if len(issues) == 0 { + return libcnd.Condition{}, false + } + cond := libcnd.Condition{ + Type: condType, + Status: libcnd.True, + Reason: calicoConditionReason, + Category: category, + Items: []string{}, + } + details := make([]string, 0, len(issues)) + seen := map[string]bool{} + for _, issue := range issues { + item := calicoPrimaryIssueItem(issue) + if !seen[item] { + seen[item] = true + cond.Items = append(cond.Items, item) + } + details = append(details, CalicoPrimaryIssueDetail(issue)) + } + cond.Message = fmt.Sprintf("%s: %s.", baseMsg, strings.Join(details, "; ")) + return cond, true +} + +// calicoConditionReason is the Reason set on every Calico condition. +const calicoConditionReason = "NotValid" + +// calicoPrimaryIssueItem returns the Items entry for an issue. Per-VM issues +// use the VMRef; plan-level issues use a synthetic "(plan)" identifier since +// there is no resource-specific ref to attach (the offending NetworkMap entry +// is plan-scoped). +func calicoPrimaryIssueItem(i CalicoPrimaryIssue) string { + if !i.VMRef.NotSet() { + return i.VMRef.String() + } + return "(plan)" +} diff --git a/pkg/controller/plan/adapter/base/calico_validation.go b/pkg/controller/plan/adapter/base/calico_validation.go index ab8d66f0da..f89745b60c 100644 --- a/pkg/controller/plan/adapter/base/calico_validation.go +++ b/pkg/controller/plan/adapter/base/calico_validation.go @@ -29,6 +29,15 @@ type ResolvedCalicoNAD struct { // L2Workload pools within the VLAN's subnets for l2Bridge networks, // L3-eligible pools for vrf networks. EligiblePools []calicoclient.IPPool + // VRFCoversAllNodes reports whether any of the VRF Network's hostConfig + // entries applies to every node (empty nodeSelector). Input to the + // plan-scoped placement check (CalicoNADPlanIssues); meaningful only + // when IsVRF. + VRFCoversAllNodes bool + // VRFPoolsPinned reports whether the NAD's IPAM config pins one or more + // IPPools (ipv4_pools). Input to the plan-scoped pool-pinning warning + // (CalicoNADPlanIssues); meaningful only when IsVRF. + VRFPoolsPinned bool } // CalicoValidationCache holds resolved state for every Calico-referencing diff --git a/pkg/controller/plan/adapter/base/doc.go b/pkg/controller/plan/adapter/base/doc.go index 76af98b09f..2188574eed 100644 --- a/pkg/controller/plan/adapter/base/doc.go +++ b/pkg/controller/plan/adapter/base/doc.go @@ -300,32 +300,17 @@ type Validator interface { GuestToolsInstalled(vmRef ref.Ref) (ok bool, err error) // Validate that VM does not need to collapse any snapshots into a single base file ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) - // ValidateCalicoNADs validates every Calico-referencing NAD in the - // plan's network map. Issues are NAD-scoped (network/IPPool config); - // the returned cache is consumed by CalicoVMIssues. - ValidateCalicoNADs(client client.Client) (CalicoValidationResult, error) // CalicoVMIssues returns per-VM Calico issues (IP membership in subnet - // / IPPool). Reads only from the cache produced by ValidateCalicoNADs; - // VMs whose mapped NAD failed plan-level validation are skipped here - // — their failure is already reported via CalicoNetworkInvalid. + // / IPPool). Reads only from the cache produced by the shared + // ValidateCalicoNADs; VMs whose mapped NAD failed validation are + // skipped here — their failure is already reported via + // CalicoNetworkInvalid. CalicoVMIssues(vmRef ref.Ref, cache *CalicoValidationCache) ([]CalicoIssue, error) - // ValidateCalicoPrimary validates the (at most one) calico-flagged - // NetworkMap entry — a type: pod destination carrying the calico - // field. Returns plan-level issues (CRD presence, UDN conflict, - // Network/VLAN/IPPool resolution) plus a cache consumed by - // CalicoPrimaryIssues. On non-vSphere providers, returns a single - // CalicoIssuePrimaryProviderUnsupported issue when any calico-flagged - // entry is present. - // - // Precondition: Plan.Referenced.Map.Network is populated by the - // dispatcher before this is called. With a nil NetworkMap, returns an - // empty result and a non-nil cache with Primary == nil. - ValidateCalicoPrimary(client client.Client) (CalicoPrimaryValidationResult, error) // CalicoPrimaryIssues returns per-VM issues for the calico-flagged // primary NIC (IP membership in IPPool / VLAN subnet, gated on - // PreserveStaticIPs). Reads only from the cache produced by - // ValidateCalicoPrimary; when the cache is nil or Primary is nil (plan - // failed, or no calico-flagged entry exists), returns nil. + // PreserveStaticIPs). Reads only from the cache produced by the shared + // ValidateCalicoPrimary; when the cache is nil or Primary is nil + // (validation failed, or no calico-flagged entry exists), returns nil. // // When IP preservation is on but the VM has no findable IPv4 IPs // (IPv6-only or no GuestNetworks reported), no per-VM issue is emitted @@ -446,7 +431,7 @@ const ( // networks require the BPF dataplane. Emitted once per plan. CalicoIssueDataplaneNotBPF CalicoIssueKind = "DataplaneNotBPF" - // Calico-primary IssueKinds. Used by Validator.ValidateCalicoPrimary + // Calico-primary IssueKinds. Used by the shared ValidateCalicoPrimary // and Validator.CalicoPrimaryIssues for the calico-flagged NetworkMap // path (type: pod destinations carrying the calico field). The Primary // prefix disambiguates from the NAD-path kinds above. diff --git a/pkg/controller/plan/adapter/hyperv/validator.go b/pkg/controller/plan/adapter/hyperv/validator.go index 58cb18c0c7..32df185e83 100644 --- a/pkg/controller/plan/adapter/hyperv/validator.go +++ b/pkg/controller/plan/adapter/hyperv/validator.go @@ -286,35 +286,12 @@ func (r *Validator) ConsolidationNeeded(_ ref.Ref) (bool, error) { return false, nil } -// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a -// target for Calico-Network IP/MAC preservation today. -func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { - return planbase.CalicoValidationResult{}, nil -} - // CalicoVMIssues returns no issues. Non-vSphere providers aren't a target // for Calico-Network IP/MAC preservation today. func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } -// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry -// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — -// the feature is not supported on this provider in this release. -func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { - if r.Plan.Referenced.Map.Network == nil { - return planbase.CalicoPrimaryValidationResult{}, nil - } - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - if pair.Destination.Calico != nil { - return planbase.CalicoPrimaryValidationResult{ - Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, - }, nil - } - } - return planbase.CalicoPrimaryValidationResult{}, nil -} - // CalicoPrimaryIssues returns nil; any calico-flagged entry was already // rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { diff --git a/pkg/controller/plan/adapter/hyperv/validator_calico_primary_test.go b/pkg/controller/plan/adapter/hyperv/validator_calico_primary_test.go deleted file mode 100644 index 107e2055df..0000000000 --- a/pkg/controller/plan/adapter/hyperv/validator_calico_primary_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package hyperv - -import ( - "testing" - - api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" - planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" - plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" -) - -func newValidatorWithMap(pairs []api.NetworkPair) *Validator { - plan := &api.Plan{} - plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} - return &Validator{Context: &plancontext.Context{Plan: plan}} -} - -func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { - t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) - } -} - -func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 0 { - t.Errorf("expected no issues, got %+v", result.Issues) - } -} diff --git a/pkg/controller/plan/adapter/nutanix/validator.go b/pkg/controller/plan/adapter/nutanix/validator.go index 7f352c2768..a1450d6731 100644 --- a/pkg/controller/plan/adapter/nutanix/validator.go +++ b/pkg/controller/plan/adapter/nutanix/validator.go @@ -103,35 +103,12 @@ func (r *Validator) ConsolidationNeeded(_ ref.Ref) (bool, error) { return false, nil } -// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a -// target for Calico-Network IP/MAC preservation today. -func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { - return planbase.CalicoValidationResult{}, nil -} - // CalicoVMIssues returns no issues. Non-vSphere providers aren't a target // for Calico-Network IP/MAC preservation today. func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } -// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry -// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — -// the feature is not supported on this provider in this release. -func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { - if r.Plan.Referenced.Map.Network == nil { - return planbase.CalicoPrimaryValidationResult{}, nil - } - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - if pair.Destination.Calico != nil { - return planbase.CalicoPrimaryValidationResult{ - Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, - }, nil - } - } - return planbase.CalicoPrimaryValidationResult{}, nil -} - // CalicoPrimaryIssues returns nil; any calico-flagged entry was already // rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { diff --git a/pkg/controller/plan/adapter/nutanix/validator_calico_primary_test.go b/pkg/controller/plan/adapter/nutanix/validator_calico_primary_test.go deleted file mode 100644 index 77f5f65235..0000000000 --- a/pkg/controller/plan/adapter/nutanix/validator_calico_primary_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package nutanix - -import ( - "testing" - - api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" - planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" - plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" -) - -func newValidatorWithMap(pairs []api.NetworkPair) *Validator { - plan := &api.Plan{} - plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} - return &Validator{Context: &plancontext.Context{Plan: plan}} -} - -func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { - t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) - } -} - -func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 0 { - t.Errorf("expected no issues, got %+v", result.Issues) - } -} diff --git a/pkg/controller/plan/adapter/ocp/validator.go b/pkg/controller/plan/adapter/ocp/validator.go index 035a6d549c..04d1ce1ecf 100644 --- a/pkg/controller/plan/adapter/ocp/validator.go +++ b/pkg/controller/plan/adapter/ocp/validator.go @@ -406,35 +406,12 @@ func (r *Validator) PVCNameTemplate(vmRef ref.Ref, pvcNameTemplate string) (ok b return true, nil } -// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a -// target for Calico-Network IP/MAC preservation today. -func (r *Validator) ValidateCalicoNADs(_ k8sclient.Client) (planbase.CalicoValidationResult, error) { - return planbase.CalicoValidationResult{}, nil -} - // CalicoVMIssues returns no issues. Non-vSphere providers aren't a target // for Calico-Network IP/MAC preservation today. func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } -// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry -// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — -// the feature is not supported on this provider in this release. -func (r *Validator) ValidateCalicoPrimary(_ k8sclient.Client) (planbase.CalicoPrimaryValidationResult, error) { - if r.Plan.Referenced.Map.Network == nil { - return planbase.CalicoPrimaryValidationResult{}, nil - } - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - if pair.Destination.Calico != nil { - return planbase.CalicoPrimaryValidationResult{ - Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, - }, nil - } - } - return planbase.CalicoPrimaryValidationResult{}, nil -} - // CalicoPrimaryIssues returns nil; any calico-flagged entry was already // rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { diff --git a/pkg/controller/plan/adapter/ocp/validator_calico_primary_test.go b/pkg/controller/plan/adapter/ocp/validator_calico_primary_test.go deleted file mode 100644 index 5f873bd028..0000000000 --- a/pkg/controller/plan/adapter/ocp/validator_calico_primary_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package ocp - -import ( - "testing" - - api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" - planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" - plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" -) - -func newValidatorWithMap(pairs []api.NetworkPair) *Validator { - plan := &api.Plan{} - plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} - return &Validator{Context: &plancontext.Context{Plan: plan}} -} - -func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { - t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) - } -} - -func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 0 { - t.Errorf("expected no issues, got %+v", result.Issues) - } -} diff --git a/pkg/controller/plan/adapter/openstack/validator.go b/pkg/controller/plan/adapter/openstack/validator.go index b749d13bfa..ffb495c6b1 100644 --- a/pkg/controller/plan/adapter/openstack/validator.go +++ b/pkg/controller/plan/adapter/openstack/validator.go @@ -391,35 +391,12 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) return } -// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a -// target for Calico-Network IP/MAC preservation today. -func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { - return planbase.CalicoValidationResult{}, nil -} - // CalicoVMIssues returns no issues. Non-vSphere providers aren't a target // for Calico-Network IP/MAC preservation today. func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } -// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry -// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — -// the feature is not supported on this provider in this release. -func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { - if r.Plan.Referenced.Map.Network == nil { - return planbase.CalicoPrimaryValidationResult{}, nil - } - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - if pair.Destination.Calico != nil { - return planbase.CalicoPrimaryValidationResult{ - Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, - }, nil - } - } - return planbase.CalicoPrimaryValidationResult{}, nil -} - // CalicoPrimaryIssues returns nil; any calico-flagged entry was already // rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { diff --git a/pkg/controller/plan/adapter/openstack/validator_calico_primary_test.go b/pkg/controller/plan/adapter/openstack/validator_calico_primary_test.go deleted file mode 100644 index 8d66affe76..0000000000 --- a/pkg/controller/plan/adapter/openstack/validator_calico_primary_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package openstack - -import ( - "testing" - - api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" - planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" - plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" -) - -func newValidatorWithMap(pairs []api.NetworkPair) *Validator { - plan := &api.Plan{} - plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} - return &Validator{Context: &plancontext.Context{Plan: plan}} -} - -func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { - t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) - } -} - -func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 0 { - t.Errorf("expected no issues, got %+v", result.Issues) - } -} diff --git a/pkg/controller/plan/adapter/ovfbase/validator.go b/pkg/controller/plan/adapter/ovfbase/validator.go index 937983f868..386d145fba 100644 --- a/pkg/controller/plan/adapter/ovfbase/validator.go +++ b/pkg/controller/plan/adapter/ovfbase/validator.go @@ -223,35 +223,12 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) return } -// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a -// target for Calico-Network IP/MAC preservation today. -func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { - return planbase.CalicoValidationResult{}, nil -} - // CalicoVMIssues returns no issues. Non-vSphere providers aren't a target // for Calico-Network IP/MAC preservation today. func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } -// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry -// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — -// the feature is not supported on this provider in this release. -func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { - if r.Plan.Referenced.Map.Network == nil { - return planbase.CalicoPrimaryValidationResult{}, nil - } - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - if pair.Destination.Calico != nil { - return planbase.CalicoPrimaryValidationResult{ - Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, - }, nil - } - } - return planbase.CalicoPrimaryValidationResult{}, nil -} - // CalicoPrimaryIssues returns nil; any calico-flagged entry was already // rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { diff --git a/pkg/controller/plan/adapter/ovfbase/validator_calico_primary_test.go b/pkg/controller/plan/adapter/ovfbase/validator_calico_primary_test.go deleted file mode 100644 index 129aa8c5a1..0000000000 --- a/pkg/controller/plan/adapter/ovfbase/validator_calico_primary_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package ovfbase - -import ( - "testing" - - api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" - planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" - plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" -) - -func newValidatorWithMap(pairs []api.NetworkPair) *Validator { - plan := &api.Plan{} - plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} - return &Validator{Context: &plancontext.Context{Plan: plan}} -} - -func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { - t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) - } -} - -func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 0 { - t.Errorf("expected no issues, got %+v", result.Issues) - } -} diff --git a/pkg/controller/plan/adapter/ovirt/validator.go b/pkg/controller/plan/adapter/ovirt/validator.go index 3b0a7d1511..9b85028313 100644 --- a/pkg/controller/plan/adapter/ovirt/validator.go +++ b/pkg/controller/plan/adapter/ovirt/validator.go @@ -300,35 +300,12 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) return } -// ValidateCalicoNADs returns empty results. Non-vSphere providers aren't a -// target for Calico-Network IP/MAC preservation today. -func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { - return planbase.CalicoValidationResult{}, nil -} - // CalicoVMIssues returns no issues. Non-vSphere providers aren't a target // for Calico-Network IP/MAC preservation today. func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } -// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry -// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — -// the feature is not supported on this provider in this release. -func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { - if r.Plan.Referenced.Map.Network == nil { - return planbase.CalicoPrimaryValidationResult{}, nil - } - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - if pair.Destination.Calico != nil { - return planbase.CalicoPrimaryValidationResult{ - Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, - }, nil - } - } - return planbase.CalicoPrimaryValidationResult{}, nil -} - // CalicoPrimaryIssues returns nil; any calico-flagged entry was already // rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { diff --git a/pkg/controller/plan/adapter/ovirt/validator_calico_primary_test.go b/pkg/controller/plan/adapter/ovirt/validator_calico_primary_test.go deleted file mode 100644 index 5d8a2f65f6..0000000000 --- a/pkg/controller/plan/adapter/ovirt/validator_calico_primary_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package ovirt - -import ( - "testing" - - api "github.com/kubev2v/forklift/pkg/apis/forklift/v1beta1" - planbase "github.com/kubev2v/forklift/pkg/controller/plan/adapter/base" - plancontext "github.com/kubev2v/forklift/pkg/controller/plan/context" -) - -func newValidatorWithMap(pairs []api.NetworkPair) *Validator { - plan := &api.Plan{} - plan.Referenced.Map.Network = &api.NetworkMap{Spec: api.NetworkMapSpec{Map: pairs}} - return &Validator{Context: &plancontext.Context{Plan: plan}} -} - -func TestValidateCalicoPrimary_RejectsCalicoEntry(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod, Calico: &api.CalicoDestination{}}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 1 || result.Issues[0].Kind != planbase.CalicoIssuePrimaryProviderUnsupported { - t.Errorf("expected one PrimaryProviderUnsupported issue, got %+v", result.Issues) - } -} - -func TestValidateCalicoPrimary_AllowsNonCalicoMap(t *testing.T) { - v := newValidatorWithMap([]api.NetworkPair{{ - Destination: api.DestinationNetwork{Type: planbase.Pod}, - }}) - result, err := v.ValidateCalicoPrimary(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Issues) != 0 { - t.Errorf("expected no issues, got %+v", result.Issues) - } -} diff --git a/pkg/controller/plan/adapter/vsphere/validator.go b/pkg/controller/plan/adapter/vsphere/validator.go index d4c7e16e2d..6b865499b2 100644 --- a/pkg/controller/plan/adapter/vsphere/validator.go +++ b/pkg/controller/plan/adapter/vsphere/validator.go @@ -22,8 +22,6 @@ import ( liberr "github.com/kubev2v/forklift/pkg/lib/error" "github.com/vmware/govmomi/vim25/types" core "k8s.io/api/core/v1" - k8serr "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" k8stypes "k8s.io/apimachinery/pkg/types" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -732,270 +730,6 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (needed bool, err error) return vm.ConsolidationNeeded, nil } -// ValidateCalicoNADs walks every Multus destination in the plan's network -// map, fetches each Calico-referencing NAD, and validates the NAD/Network/ -// IPPool resources. -func (r *Validator) ValidateCalicoNADs(c k8sclient.Client) (planbase.CalicoValidationResult, error) { - result := planbase.CalicoValidationResult{ - Cache: &planbase.CalicoValidationCache{ - NADs: map[k8stypes.NamespacedName]*planbase.ResolvedCalicoNAD{}, - }, - } - if r.Plan.Referenced.Map.Network == nil { - return result, nil - } - - nadCfgs := map[k8stypes.NamespacedName]*ocpmodel.NetworkConfig{} - var pools []calicoclient.IPPool - poolsLoaded := false - bpfChecked := false - - vrfChecked := map[string]bool{} - vrfDataplaneChecked := false - var vrfNetworks []calicoclient.Network - vrfNetworksLoaded := false - var bgpPeerNetworks map[string]bool - bgpPeerNetworksLoaded := false - var felixFacts *calicoclient.FelixConfig - - // Felix is the per-node daemon which programs the dataplane - // based on k8s config. - loadFelix := func(key k8stypes.NamespacedName) (*calicoclient.FelixConfig, error) { - if felixFacts != nil { - return felixFacts, nil - } - fc, err := calicoclient.GetFelixConfig(context.TODO(), c) - if err != nil { - return nil, liberr.Wrap(err, "nad", key.String()) - } - felixFacts = fc - return fc, nil - } - - // IPPools are the Calico CRD describing available IP CIDRs - // and their permitted uses. - loadPools := func(key k8stypes.NamespacedName) (err error) { - if poolsLoaded { - return - } - pools, err = calicoclient.ListIPPools(context.TODO(), c) - if err != nil { - if !meta.IsNoMatchError(err) { - return liberr.Wrap(err, "nad", key.String()) - } - pools = nil - err = nil - } - poolsLoaded = true - return - } - - // For each map item, try fetching a corresponding NAD. - // Search for NAD's whose config has `type:calico`, ignore the rest. - // The presence of a Calico `Network` CR implies per-NIC addressing - // is possible for L2 and L3. - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - if pair.Destination.Type != planbase.Multus { - continue - } - key := k8stypes.NamespacedName{ - Namespace: pair.Destination.Namespace, - Name: pair.Destination.Name, - } - if _, dup := nadCfgs[key]; dup { - continue - } - - cfg, err := planbase.FetchAndParseNAD(context.TODO(), c, key.Namespace, key.Name) - if err != nil { - nadCfgs[key] = nil - if r.Log != nil { - r.Log.Error(err, "Calico NAD: failed to fetch/parse", - "namespace", key.Namespace, "name", key.Name) - } - result.Issues = append(result.Issues, planbase.CalicoNADIssue{ - NAD: key, - Kind: planbase.CalicoIssueNADUnreadable, - }) - continue - } - nadCfgs[key] = cfg - - // type:calico without a "network" field is Calico's legacy L3 IPAM - // mode. Identity preservation on secondary NICs relies on the - // network-scoped annotations that ship with the Network resource, so - // it applies only to NADs that reference one (l2Bridge or VRF); warn - // that MAC/IP annotations will not be emitted for NICs mapped here. - if cfg.Type == ocpmodel.CalicoCNIType && cfg.Network == "" { - result.Warnings = append(result.Warnings, planbase.CalicoNADIssue{ - NAD: key, - Kind: planbase.CalicoIssueNADMissingNetwork, - }) - continue - } - if !cfg.ReferencesCalicoNetwork() { - continue - } - - issueBase := planbase.CalicoNADIssue{NAD: key, Network: cfg.Network, VLAN: cfg.VLAN} - - nw, err := calicoclient.GetNetwork(context.TODO(), c, cfg.Network) - if err != nil { - switch { - case meta.IsNoMatchError(err): - issueBase.Kind = planbase.CalicoIssueNetworkCRDAbsent - result.Issues = append(result.Issues, issueBase) - continue - case k8serr.IsNotFound(err): - issueBase.Kind = planbase.CalicoIssueNetworkNotFound - result.Issues = append(result.Issues, issueBase) - continue - default: - return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String(), "network", cfg.Network) - } - } - - // Classify the Network before any VLAN handling (VRF / l2Bridge). - if nw.IsVRF { - if cfg.VLAN != 0 { - ib := issueBase - ib.Kind = planbase.CalicoIssueVRFVlanIgnored - result.Warnings = append(result.Warnings, ib) - } - - if len(cfg.IPv4Pools) == 0 && !r.Plan.Spec.PreserveStaticIPs { - ib := issueBase - ib.Kind = planbase.CalicoIssueVRFPoolNotPinned - result.Warnings = append(result.Warnings, ib) - } - if !vrfChecked[cfg.Network] { - vrfChecked[cfg.Network] = true - - // An empty nodeSelector is the canonical all-nodes form, so - // all-scoped hostConfig means a node subset (Calico selectors - // are not parsed). A VM scheduled onto an uncovered node - // fails at CNI ADD, so with no placement constraints on the - // plan the outcome is a scheduling lottery: Critical. When - // the plan pins placement (targetNodeSelector/targetAffinity) - // the user has taken control; Warn that the pin cannot be - // verified against the network's selectors. - if !vrfHasAllNodesEntry(nw.VRFHostConfig) { - ib := issueBase - if len(r.Plan.Spec.TargetNodeSelector) > 0 || r.Plan.Spec.TargetAffinity != nil { - ib.Kind = planbase.CalicoIssueVRFPlacementUnverified - result.Warnings = append(result.Warnings, ib) - } else { - ib.Kind = planbase.CalicoIssueVRFNodeScoped - result.Issues = append(result.Issues, ib) - } - } - - if vrfHasEntryWithoutHostInterfaces(nw.VRFHostConfig) { - ib := issueBase - ib.Kind = planbase.CalicoIssueVRFNoHostInterfaces - result.Warnings = append(result.Warnings, ib) - } - - if !bgpPeerNetworksLoaded { - bgpPeerNetworksLoaded = true - bgpPeerNetworks, err = calicoclient.ListBGPPeerNetworks(context.TODO(), c) - if err != nil { - return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) - } - } - if !bgpPeerNetworks[cfg.Network] { - ib := issueBase - ib.Kind = planbase.CalicoIssueVRFNoBGPPeer - result.Warnings = append(result.Warnings, ib) - } - - if !vrfNetworksLoaded { - vrfNetworksLoaded = true - vrfNetworks, err = calicoclient.ListNetworks(context.TODO(), c) - if err != nil { - return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) - } - } - felix, ferr := loadFelix(key) - if ferr != nil { - return planbase.CalicoValidationResult{}, ferr - } - criticals, warns := vrfRouteTableIssues(issueBase, nw, vrfNetworks, felix) - result.Issues = append(result.Issues, criticals...) - result.Warnings = append(result.Warnings, warns...) - - // VRF networks only supported on NFTables dataplane. - if !vrfDataplaneChecked { - vrfDataplaneChecked = true - if felix.BPFEnabled || felix.NftablesMode != calicoclient.NftablesModeEnabled { - ib := issueBase - ib.Kind = planbase.CalicoIssueVRFDataplaneNotNftables - result.Issues = append(result.Issues, ib) - } - } - } - if err = loadPools(key); err != nil { - return planbase.CalicoValidationResult{}, err - } - result.Cache.NADs[key] = &planbase.ResolvedCalicoNAD{ - Network: cfg.Network, - IsVRF: true, - EligiblePools: calicoclient.L3EligiblePools(pools), - } - continue - } - if nw.L2Bridge == nil { - issueBase.Kind = planbase.CalicoIssueNetworkHasNoL2Bridge - result.Issues = append(result.Issues, issueBase) - continue - } - - // L2 networks require the BPF dataplane. - if !bpfChecked { - bpfChecked = true - bpfEnabled, err := calicoclient.GetBPFEnabled(context.TODO(), c) - if err != nil { - return planbase.CalicoValidationResult{}, liberr.Wrap(err, "nad", key.String()) - } - if !bpfEnabled { - ib := issueBase - ib.Kind = planbase.CalicoIssueDataplaneNotBPF - result.Issues = append(result.Issues, ib) - } - } - - if cfg.VLAN == 0 { - issueBase.Kind = planbase.CalicoIssueVLANRequired - result.Issues = append(result.Issues, issueBase) - continue - } - - entry, vlanIssueKind := resolveVLANEntry(nw.L2Bridge.VLANs, cfg.VLAN) - if vlanIssueKind != "" { - issueBase.Kind = vlanIssueKind - result.Issues = append(result.Issues, issueBase) - continue - } - - if err = loadPools(key); err != nil { - return planbase.CalicoValidationResult{}, err - } - eligible := calicoclient.L2WorkloadEligiblePools(pools, entry.Subnets) - if len(eligible) == 0 { - issueBase.Kind = planbase.CalicoIssueVLANHasNoIPPool - result.Issues = append(result.Issues, issueBase) - continue - } - - result.Cache.NADs[key] = &planbase.ResolvedCalicoNAD{ - Network: cfg.Network, - VLAN: *entry, - EligiblePools: eligible, - } - } - return result, nil -} - // CalicoVMIssues returns per-VM Calico issues for vmRef using the cache // from ValidateCalicoNADs. // @@ -1077,170 +811,6 @@ func (r *Validator) CalicoVMIssues(vmRef ref.Ref, cache *planbase.CalicoValidati return issues, nil } -// ValidateCalicoPrimary validates the (at most one) calico-flagged -// NetworkMap entry — a type: pod destination carrying the calico field. -// -// Precondition: Plan.Referenced.Map.Network is populated by the dispatcher -// before this is called. -func (r *Validator) ValidateCalicoPrimary(c k8sclient.Client) (planbase.CalicoPrimaryValidationResult, error) { - result := planbase.CalicoPrimaryValidationResult{ - Cache: &planbase.CalicoPrimaryValidationCache{}, - } - if r.Plan.Referenced.Map.Network == nil { - return result, nil - } - - // Classify entries (VRF or L2Bridge), surface field-misplacement issues. - var calicoEntries []api.NetworkPair - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - dest := pair.Destination - if dest.Calico == nil { - continue - } - if dest.Type != planbase.Pod { - result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ - Kind: planbase.CalicoIssuePrimaryFieldsMisplaced, - Network: dest.Calico.Network, - VLAN: dest.Calico.Vlan, - }) - continue - } - calicoEntries = append(calicoEntries, pair) - // vlan-without-network is a field-placement error within the block. - if dest.Calico.Network == "" && dest.Calico.Vlan != 0 { - result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ - Kind: planbase.CalicoIssuePrimaryFieldsMisplaced, - VLAN: dest.Calico.Vlan, - }) - } - } - - if len(calicoEntries) > 1 { - result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ - Kind: planbase.CalicoIssuePrimaryFieldsMisplaced, - }) - } - if len(calicoEntries) == 0 { - return result, nil - } - - entry := calicoEntries[0] - calico := entry.Destination.Calico - issueBase := planbase.CalicoPrimaryIssue{Network: calico.Network, VLAN: calico.Vlan} - - if !r.Plan.Spec.PreserveStaticIPs { - result.Warnings = append(result.Warnings, planbase.CalicoPrimaryIssue{ - Kind: planbase.CalicoIssuePrimaryStaticIPsNotPreserved, - }) - } - - pools, err := calicoclient.ListIPPools(context.TODO(), c) - if err != nil { - if meta.IsNoMatchError(err) { - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryUnsupported - result.Issues = append(result.Issues, ib) - return result, nil - } - return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) - } - - if r.Plan.DestinationHasUdnNetwork(c) { - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryConflictsWithUDN - result.Issues = append(result.Issues, ib) - return result, nil - } - - if calico.Network == "" { - result.Cache.Primary = &planbase.ResolvedCalicoPrimary{ - Source: entry.Source.Ref, - L3EligiblePools: calicoclient.L3EligiblePools(pools), - } - return result, nil - } - - nw, err := calicoclient.GetNetwork(context.TODO(), c, calico.Network) - if err != nil { - switch { - case meta.IsNoMatchError(err): - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryNetworkCRDAbsent - result.Issues = append(result.Issues, ib) - return result, nil - case k8serr.IsNotFound(err): - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryNetworkNotFound - result.Issues = append(result.Issues, ib) - return result, nil - default: - if r.Log != nil { - r.Log.Error(err, "Calico-primary: failed to fetch Network", - "network", calico.Network) - } - return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) - } - } - - // A non-l2Bridge network (e.g. a VRF network) carries no VLAN, so - // the VLANRequired / VLAN-matching checks below would mislead. - if nw.IsVRF { - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryNetworkTypeUnsupported - result.Issues = append(result.Issues, ib) - return result, nil - } - if nw.L2Bridge == nil { - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryNetworkHasNoL2Bridge - result.Issues = append(result.Issues, ib) - return result, nil - } - - // L2 networks require the BPF dataplane. - bpfEnabled, err := calicoclient.GetBPFEnabled(context.TODO(), c) - if err != nil { - return planbase.CalicoPrimaryValidationResult{}, liberr.Wrap(err, "network", calico.Network) - } - if !bpfEnabled { - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryDataplaneNotBPF - result.Issues = append(result.Issues, ib) - } - - // A Network reference requires an explicit VLAN. - if calico.Vlan == 0 { - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryVLANRequired - result.Issues = append(result.Issues, ib) - return result, nil - } - - vlanEntry, vlanIssueKind := resolveVLANEntry(nw.L2Bridge.VLANs, calico.Vlan) - if vlanIssueKind != "" { - ib := issueBase - ib.Kind = translateVLANIssueKindToPrimary(vlanIssueKind) - result.Issues = append(result.Issues, ib) - return result, nil - } - - l2Pools := calicoclient.L2WorkloadEligiblePools(pools, vlanEntry.Subnets) - if len(l2Pools) == 0 { - ib := issueBase - ib.Kind = planbase.CalicoIssuePrimaryNoEligibleIPPool - result.Issues = append(result.Issues, ib) - return result, nil - } - - result.Cache.Primary = &planbase.ResolvedCalicoPrimary{ - Network: calico.Network, - VLAN: *vlanEntry, - L2EligiblePools: l2Pools, - Source: entry.Source.Ref, - } - return result, nil -} - // CalicoPrimaryIssues returns per-VM Calico-primary issues for vmRef using // the cache from ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(vmRef ref.Ref, cache *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) { @@ -1315,21 +885,6 @@ func (r *Validator) CalicoPrimaryIssues(vmRef ref.Ref, cache *planbase.CalicoPri return issues, nil } -// translateVLANIssueKindToPrimary converts the secondary-NAD-path VLAN issue -// kinds returned by resolveVLANEntry into the Calico-primary equivalents. -// The shared resolver returns the NAD-path kinds; the primary path emits its -// own kinds so users can disambiguate primary vs secondary failures in the -// Plan condition. -func translateVLANIssueKindToPrimary(k planbase.CalicoIssueKind) planbase.CalicoIssueKind { - switch k { - case planbase.CalicoIssueNetworkHasNoVLANs: - return planbase.CalicoIssuePrimaryNetworkHasNoVLANs - case planbase.CalicoIssueVLANNotInNetwork: - return planbase.CalicoIssuePrimaryVLANNotInNetwork - } - return k -} - // buildNICResolver indexes the NetworkMap pairs by source-network ID and Key // so a per-NIC lookup returns every candidate destination. Mirrors the // Builder's resolver so the Validator validates exactly what the Builder @@ -1353,131 +908,6 @@ func (r *Validator) buildNICResolver(nics []vsphere.NIC) ([]string, map[string][ return nicKeys, pairsBySource, nil } -// vrfReservedRouteTables are the kernel's own route tables — 253 (default), -// 254 (main), 255 (local) — which a VRF must never claim. -var vrfReservedRouteTables = map[int64]bool{253: true, 254: true, 255: true} - -// vrfHasAllNodesEntry reports whether any spec.vrf.hostConfig entry has an -// empty (or absent) nodeSelector — such an entry applies to every node, so -// the VRF network is guaranteed to exist wherever a VM lands. -func vrfHasAllNodesEntry(entries []calicoclient.VRFHostEntry) bool { - for _, e := range entries { - if e.NodeSelector == "" { - return true - } - } - return false -} - -// vrfHasEntryWithoutHostInterfaces reports whether any spec.vrf.hostConfig -// entry names no host interfaces. Such an entry gives pods on its nodes no -// path off the node inside the VRF, so its VMs are unreachable beyond their -// own node. -func vrfHasEntryWithoutHostInterfaces(entries []calicoclient.VRFHostEntry) bool { - for _, e := range entries { - if !e.HasHostInterfaces { - return true - } - } - return false -} - -// vrfRouteTableIssues checks the referenced VRF Network's routeTableIndex -// values for reserved-table use and for collisions with other VRF Networks -// and with the FelixConfiguration routeTableRanges. issueBase supplies the -// NAD/Network attribution; each finding carries the offending table index. -// -// A collision with another VRF Network is provable (Critical) when at least -// one of the two entries carrying the index has no nodeSelector — an -// all-nodes entry overlaps any other node set. When both entries are -// selector-scoped, the overlap depends on which nodes the selectors match; -// selector evaluation is deliberately out of scope, so those pairs are -// reported as possible conflicts (Warn). Entries of the same Network sharing -// an index are legitimate — same VRF, same table — and never reported. -// -// The FelixConfiguration sub-check runs only when spec.routeTableRanges is -// explicitly set. When absent, Felix falls back to version-dependent -// defaults that are not modelled here — guessing them would risk false -// Criticals against tables Felix never touches. -func vrfRouteTableIssues(issueBase planbase.CalicoNADIssue, nw *calicoclient.Network, all []calicoclient.Network, felix *calicoclient.FelixConfig) (criticals, warnings []planbase.CalicoNADIssue) { - // Distinct indexes in entry order; per index, remember whether any - // entry carrying it applies to all nodes. - var indexes []int64 - seen := map[int64]bool{} - allNodes := map[int64]bool{} - for _, e := range nw.VRFHostConfig { - if !seen[e.RouteTableIndex] { - seen[e.RouteTableIndex] = true - indexes = append(indexes, e.RouteTableIndex) - } - if e.NodeSelector == "" { - allNodes[e.RouteTableIndex] = true - } - } - - for _, idx := range indexes { - if vrfReservedRouteTables[idx] { - ib := issueBase - ib.Kind = planbase.CalicoIssueVRFRouteTableReserved - ib.RouteTable = idx - criticals = append(criticals, ib) - } - for _, rng := range felix.RouteTableRanges { - if idx >= rng.Min && idx <= rng.Max { - ib := issueBase - ib.Kind = planbase.CalicoIssueVRFRouteTableConflict - ib.RouteTable = idx - criticals = append(criticals, ib) - break - } - } - } - - for i := range all { - other := &all[i] - if other.Name == nw.Name || !other.IsVRF { - continue - } - otherHas := map[int64]bool{} - otherAllNodes := map[int64]bool{} - for _, o := range other.VRFHostConfig { - otherHas[o.RouteTableIndex] = true - if o.NodeSelector == "" { - otherAllNodes[o.RouteTableIndex] = true - } - } - for _, idx := range indexes { - if !otherHas[idx] { - continue - } - ib := issueBase - ib.RouteTable = idx - ib.ConflictsWith = other.Name - if allNodes[idx] || otherAllNodes[idx] { - ib.Kind = planbase.CalicoIssueVRFRouteTableConflict - criticals = append(criticals, ib) - } else { - ib.Kind = planbase.CalicoIssueVRFRouteTablePossibleConflict - warnings = append(warnings, ib) - } - } - } - return -} - -// resolveVLANEntry returns the l2Bridge.vlans[] entry matched by nadVLAN. -func resolveVLANEntry(vlans []calicoclient.VLANEntry, nadVLAN uint16) (*calicoclient.VLANEntry, planbase.CalicoIssueKind) { - if len(vlans) == 0 { - return nil, planbase.CalicoIssueNetworkHasNoVLANs - } - for i := range vlans { - if vlans[i].VID == nadVLAN { - return &vlans[i], "" - } - } - return nil, planbase.CalicoIssueVLANNotInNetwork -} - func ipInAnySubnet(ip string, subnets []string) bool { parsed := net.ParseIP(ip) if parsed == nil { diff --git a/pkg/controller/plan/adapter/vsphere/validator_test.go b/pkg/controller/plan/adapter/vsphere/validator_test.go index 82152c8234..6d300ca9f1 100644 --- a/pkg/controller/plan/adapter/vsphere/validator_test.go +++ b/pkg/controller/plan/adapter/vsphere/validator_test.go @@ -33,6 +33,55 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/interceptor" ) +// validateCalicoNADsForTest adapts the old Validator-method call shape to the +// shared map-scoped function, mirroring how the controllers invoke it. +func validateCalicoNADsForTest(v *Validator, c client.Client) (planbase.CalicoValidationResult, error) { + var pairs []v1beta1.NetworkPair + if v.Plan.Referenced.Map.Network != nil { + pairs = v.Plan.Referenced.Map.Network.Spec.Map + } + result, err := planbase.ValidateCalicoNADs(context.TODO(), c, pairs, v.Log) + if err != nil { + return result, err + } + // Fold in the plan-scoped issues the way the Plan controller does, so + // the specs assert the combined semantics. + planHasPlacement := len(v.Plan.Spec.TargetNodeSelector) > 0 || v.Plan.Spec.TargetAffinity != nil + criticals, warnings := planbase.CalicoNADPlanIssues(result.Cache, v.Plan.Spec.PreserveStaticIPs, planHasPlacement) + result.Issues = append(result.Issues, criticals...) + result.Warnings = append(result.Warnings, warnings...) + return result, nil +} + +// validateCalicoPrimaryForTest adapts the old Validator-method call shape to +// the shared map-scoped function. +func validateCalicoPrimaryForTest(v *Validator, c client.Client) (planbase.CalicoPrimaryValidationResult, error) { + var pairs []v1beta1.NetworkPair + if v.Plan.Referenced.Map.Network != nil { + pairs = v.Plan.Referenced.Map.Network.Spec.Map + } + result, err := planbase.ValidateCalicoPrimary(context.TODO(), c, pairs, v.Log) + if err != nil { + return result, err + } + // Fold in the plan-scoped issues the way the Plan controller does. + if planbase.HasCalicoPodEntry(pairs) && !v.Plan.Spec.PreserveStaticIPs { + result.Warnings = append(result.Warnings, planbase.CalicoPrimaryIssue{ + Kind: planbase.CalicoIssuePrimaryStaticIPsNotPreserved, + }) + } + if result.Cache != nil && result.Cache.Primary != nil && v.Plan.DestinationHasUdnNetwork(c) { + primary := result.Cache.Primary + result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ + Kind: planbase.CalicoIssuePrimaryConflictsWithUDN, + Network: primary.Network, + VLAN: primary.VLAN.VID, + }) + result.Cache.Primary = nil + } + return result, nil +} + var ErrNotImplemented = errors.New("not implemented") // makeFelixConfiguration builds the cluster-wide "default" FelixConfiguration @@ -903,7 +952,7 @@ var _ = Describe("vsphere validation tests", func() { It("returns empty results when NetworkMap is nil", func() { v, c, _ := setup("10.100.0.5", true) v.Plan.Referenced.Map.Network = nil - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache).NotTo(BeNil()) @@ -915,7 +964,7 @@ var _ = Describe("vsphere validation tests", func() { makeCalicoNAD(100), makeNetwork(l2Single), makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.NADs).To(HaveLen(1)) @@ -950,7 +999,7 @@ var _ = Describe("vsphere validation tests", func() { return inner.Get(ctx, key, obj, opts...) }, }).Build() - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, @@ -963,7 +1012,7 @@ var _ = Describe("vsphere validation tests", func() { It("emits NetworkNotFound when the referenced Network is missing", func() { v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(100)) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, @@ -978,7 +1027,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(100), makeNetwork(map[string]interface{}{}), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkHasNoL2Bridge)) Expect(result.Cache.NADs).To(BeEmpty()) @@ -991,7 +1040,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(0), makeNetwork(l2Multi), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANRequired)) }) @@ -1002,7 +1051,7 @@ var _ = Describe("vsphere validation tests", func() { // asking for a vlan first would send the user chasing the // wrong fix. v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(0)) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkNotFound)) }) @@ -1024,7 +1073,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(BeEmpty()) @@ -1046,7 +1095,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1088,7 +1137,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1113,7 +1162,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(BeEmpty()) @@ -1129,7 +1178,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(BeEmpty()) @@ -1161,7 +1210,7 @@ var _ = Describe("vsphere validation tests", func() { }, }, ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf( @@ -1198,7 +1247,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Warnings).To(BeEmpty()) Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1223,7 +1272,7 @@ var _ = Describe("vsphere validation tests", func() { makeBGPPeer("vrf-peer", netName), ) v.Plan.Spec.TargetNodeSelector = map[string]string{"rack": "a"} - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1259,7 +1308,7 @@ var _ = Describe("vsphere validation tests", func() { }, }, ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Warnings).To(BeEmpty()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVRFNodeScoped)) @@ -1276,7 +1325,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ NAD: nadKey, @@ -1300,7 +1349,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ NAD: nadKey, @@ -1329,7 +1378,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1354,7 +1403,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(BeEmpty()) @@ -1369,7 +1418,7 @@ var _ = Describe("vsphere validation tests", func() { felix, makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) // ConflictsWith stays empty: the collision is with the // FelixConfiguration, not another Network. @@ -1394,7 +1443,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(BeEmpty()) @@ -1412,7 +1461,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("default-pool", "10.100.0.0/24", "Workload"), makeNftablesFelixConfiguration("Enabled"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1434,7 +1483,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("other-peer", "other-vrf"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1477,7 +1526,7 @@ var _ = Describe("vsphere validation tests", func() { return inner.List(ctx, list, opts...) }, }).Build() - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1504,7 +1553,7 @@ var _ = Describe("vsphere validation tests", func() { makeNftablesFelixConfiguration("Enabled"), makeBGPPeer("vrf-peer", netName), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1534,7 +1583,7 @@ var _ = Describe("vsphere validation tests", func() { felix = perNode } v, c, _ := setup("10.100.0.5", true, append(objs, felix)...) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVRFDataplaneNotNftables)) // The finding is cluster-scoped; the NAD itself is @@ -1591,7 +1640,7 @@ var _ = Describe("vsphere validation tests", func() { It("reports only VRFDataplaneNotNftables on a BPF cluster", func() { // BPF satisfies the l2Bridge side and fails the VRF side. v, c := mixedSetup(makeFelixConfiguration(true)) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVRFDataplaneNotNftables)) Expect(result.Cache.NADs).To(HaveLen(2)) @@ -1601,7 +1650,7 @@ var _ = Describe("vsphere validation tests", func() { // nftables satisfies the VRF side and fails the l2Bridge // side. v, c := mixedSetup(makeNftablesFelixConfiguration("Enabled")) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueDataplaneNotBPF)) Expect(result.Cache.NADs).To(HaveLen(2)) @@ -1612,7 +1661,7 @@ var _ = Describe("vsphere validation tests", func() { // unset) can honour neither network type; both sides // report, each naming its own remedy. v, c := mixedSetup(makeFelixConfiguration(false)) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf( planbase.CalicoIssueDataplaneNotBPF, @@ -1628,7 +1677,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), makeFelixConfiguration(true), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.NADs).To(HaveLen(1)) @@ -1640,7 +1689,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), makeFelixConfiguration(false), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueDataplaneNotBPF)) // The issue is plan-scoped; the NAD's own configuration is @@ -1659,7 +1708,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), perNode, ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueDataplaneNotBPF)) }) @@ -1687,7 +1736,7 @@ var _ = Describe("vsphere validation tests", func() { }, }, ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueDataplaneNotBPF)) Expect(result.Cache.NADs).To(HaveLen(2)) @@ -1704,7 +1753,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(100), makeNetwork(emptyVLANs), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueNetworkHasNoVLANs)) }) @@ -1713,7 +1762,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setup("10.100.0.5", true, makeCalicoNAD(999), makeNetwork(l2Single), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANNotInNetwork)) }) @@ -1723,7 +1772,7 @@ var _ = Describe("vsphere validation tests", func() { makeCalicoNAD(100), makeNetwork(l2Single), makeIPPool("cluster-default", "10.0.0.0/8", "L2Workload"), // pool too large ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANHasNoIPPool)) }) @@ -1735,7 +1784,7 @@ var _ = Describe("vsphere validation tests", func() { makeCalicoNAD(100), makeNetwork(l2Single), makeDisabledIPPool("vlan100-disabled", "10.100.0.0/24", "L2Workload"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANHasNoIPPool)) }) @@ -1748,7 +1797,7 @@ var _ = Describe("vsphere validation tests", func() { makeCalicoNAD(100), makeNetwork(l2Single), makeIPPool("vlan100-workload-only", "10.100.0.0/24", "Workload"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(nadIssueKinds(result.Issues)).To(ConsistOf(planbase.CalicoIssueVLANHasNoIPPool)) }) @@ -1767,7 +1816,7 @@ var _ = Describe("vsphere validation tests", func() { }, }, ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(HaveLen(1)) Expect(result.Issues[0].Kind).To(Equal(planbase.CalicoIssueNetworkNotFound)) @@ -1779,7 +1828,7 @@ var _ = Describe("vsphere validation tests", func() { // the NotFound — it should soft-fail and surface a NADUnreadable // issue so the plan validation pass can complete. v, c, _ := setup("10.100.0.5", true) // no NAD in the client - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, @@ -1794,7 +1843,7 @@ var _ = Describe("vsphere validation tests", func() { Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: `{not-valid-json`}, } v, c, _ := setup("10.100.0.5", true, badNAD) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, @@ -1814,7 +1863,7 @@ var _ = Describe("vsphere validation tests", func() { } v, c, _ := setup("10.100.0.5", true, ovnNAD) v.Plan.Referenced.Map.Network.Spec.Map[0].Destination.Name = "ovn-nad" - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.NADs).To(BeEmpty()) @@ -1831,7 +1880,7 @@ var _ = Describe("vsphere validation tests", func() { Spec: k8snet.NetworkAttachmentDefinitionSpec{Config: `{"type":"calico"}`}, } v, c, _ := setup("10.100.0.5", true, l3NAD) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Warnings).To(ConsistOf(planbase.CalicoNADIssue{ @@ -1868,7 +1917,7 @@ var _ = Describe("vsphere validation tests", func() { }, }, ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(ConsistOf(planbase.CalicoNADIssue{ NAD: k8stypes.NamespacedName{Namespace: nadNS, Name: nadName}, @@ -1939,7 +1988,7 @@ var _ = Describe("vsphere validation tests", func() { inv.vm.NICs = append(inv.vm.NICs, vsphere.NIC{Network: vsphere.Ref{ID: healthySrcID}, DeviceKey: 4002}) inv.vm.GuestNetworks = append(inv.vm.GuestNetworks, vsphere.GuestNetwork{IP: "192.168.1.5", DeviceConfigId: 4002}) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(HaveLen(1)) Expect(result.Issues[0].NAD).To(Equal(k8stypes.NamespacedName{Namespace: nadNS, Name: nadName})) @@ -1967,7 +2016,7 @@ var _ = Describe("vsphere validation tests", func() { makeCalicoNAD(100), makeNetwork(l2Single), makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -1981,7 +2030,7 @@ var _ = Describe("vsphere validation tests", func() { makeCalicoNAD(100), makeNetwork(l2Single), makeIPPool("vlan100-upper", "10.100.0.128/25", "L2Workload"), // covers VLAN but not 10.100.0.5 ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -1996,7 +2045,7 @@ var _ = Describe("vsphere validation tests", func() { makeCalicoNAD(100), makeNetwork(l2Single), makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2009,7 +2058,7 @@ var _ = Describe("vsphere validation tests", func() { // issues for that NAD — the failure is already reported at plan // level. v, c, vmRef := setup("10.100.0.5", true, makeCalicoNAD(100)) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Cache.NADs).To(BeEmpty()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) @@ -2028,7 +2077,7 @@ var _ = Describe("vsphere validation tests", func() { vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "10.100.0.6", DeviceConfigId: 4001}) v.Source.Inventory.(*mockInventory).vm = vm - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2045,7 +2094,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("other-pool", "10.200.0.0/24", "Workload"), makeNftablesFelixConfiguration("Enabled"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2066,7 +2115,7 @@ var _ = Describe("vsphere validation tests", func() { vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "10.100.0.6", DeviceConfigId: 4001}) v.Source.Inventory.(*mockInventory).vm = vm - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2081,7 +2130,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("other-pool", "10.200.0.0/24", "Workload"), makeNftablesFelixConfiguration("Enabled"), ) - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2123,7 +2172,7 @@ var _ = Describe("vsphere validation tests", func() { }}, } - result, err := v.ValidateCalicoNADs(c) + result, err := validateCalicoNADsForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoVMIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2264,7 +2313,7 @@ var _ = Describe("vsphere validation tests", func() { It("returns empty results when NetworkMap is nil", func() { v, c, _ := setupPrimary("10.100.0.5", false, v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{}}, nil, true) v.Plan.Referenced.Map.Network = nil - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache).NotTo(BeNil()) @@ -2274,7 +2323,7 @@ var _ = Describe("vsphere validation tests", func() { It("returns empty results when NetworkMap has no calico entries", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod} v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.Primary).To(BeNil()) @@ -2285,7 +2334,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.Primary).NotTo(BeNil()) @@ -2299,7 +2348,7 @@ var _ = Describe("vsphere validation tests", func() { makeNetwork(l2Single), makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.Primary).NotTo(BeNil()) @@ -2314,7 +2363,7 @@ var _ = Describe("vsphere validation tests", func() { makeNetwork(l2Multi), makeIPPool("vlan200-pool", "10.200.0.0/24", "L2Workload"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.Primary.VLAN.VID).To(Equal(uint16(200))) @@ -2338,7 +2387,7 @@ var _ = Describe("vsphere validation tests", func() { return nil }, }).Build() - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryUnsupported)) Expect(result.Cache.Primary).To(BeNil()) @@ -2356,7 +2405,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, makeUDNNamespace(), udnNAD, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryConflictsWithUDN)) }) @@ -2364,7 +2413,7 @@ var _ = Describe("vsphere validation tests", func() { It("emits PrimaryFieldsMisplaced when the calico block is set on a non-pod entry", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Multus, Calico: &v1beta1.CalicoDestination{Network: "leaked"}} v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryFieldsMisplaced)) }) @@ -2374,7 +2423,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ContainElement(planbase.CalicoIssuePrimaryFieldsMisplaced)) }) @@ -2391,7 +2440,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.244.0.5", false, dest, extra, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.Primary).NotTo(BeNil()) @@ -2405,7 +2454,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.244.0.5", false, dest, extra, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ContainElement(planbase.CalicoIssuePrimaryFieldsMisplaced)) }) @@ -2416,7 +2465,7 @@ var _ = Describe("vsphere validation tests", func() { Calico: &v1beta1.CalicoDestination{}, } v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryFieldsMisplaced)) // A multus block never seeds the primary cache. @@ -2426,7 +2475,7 @@ var _ = Describe("vsphere validation tests", func() { It("emits PrimaryFieldsMisplaced when the calico block is set on an ignored entry", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Ignored, Calico: &v1beta1.CalicoDestination{}} v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryFieldsMisplaced)) }) @@ -2434,7 +2483,7 @@ var _ = Describe("vsphere validation tests", func() { It("emits PrimaryNetworkNotFound when calico.network names a missing CR", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: "missing", Vlan: 100}} v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkNotFound)) }) @@ -2461,7 +2510,7 @@ var _ = Describe("vsphere validation tests", func() { return nil }, }).Build() - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkCRDAbsent)) }) @@ -2471,7 +2520,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, makeNetwork(map[string]interface{}{}), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkHasNoL2Bridge)) }) @@ -2482,7 +2531,7 @@ var _ = Describe("vsphere validation tests", func() { } dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 100}} v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, makeNetwork(emptyVLANs)) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkHasNoVLANs)) }) @@ -2496,7 +2545,7 @@ var _ = Describe("vsphere validation tests", func() { makeNetwork(l2Single), makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryVLANRequired)) }) @@ -2510,7 +2559,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkNotFound)) }) @@ -2524,7 +2573,7 @@ var _ = Describe("vsphere validation tests", func() { makeNetwork(vrfSpec), makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkTypeUnsupported)) Expect(result.Cache.Primary).To(BeNil()) @@ -2538,7 +2587,7 @@ var _ = Describe("vsphere validation tests", func() { makeNetwork(vrfSpec), makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNetworkTypeUnsupported)) Expect(result.Cache.Primary).To(BeNil()) @@ -2551,7 +2600,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), makeFelixConfiguration(true), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.Primary).NotTo(BeNil()) @@ -2564,7 +2613,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), makeFelixConfiguration(false), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryDataplaneNotBPF)) // The issue is plan-scoped; the mapping itself is valid and @@ -2584,7 +2633,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), perNode, ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryDataplaneNotBPF)) }) @@ -2597,7 +2646,7 @@ var _ = Describe("vsphere validation tests", func() { makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), makeFelixConfiguration(false), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Issues).To(BeEmpty()) Expect(result.Cache.Primary).NotTo(BeNil()) @@ -2606,7 +2655,7 @@ var _ = Describe("vsphere validation tests", func() { It("emits PrimaryVLANNotInNetwork when calico.vlan is absent from the Network's VLAN list", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod, Calico: &v1beta1.CalicoDestination{Network: netName, Vlan: 999}} v, c, _ := setupPrimary("10.100.0.5", false, dest, nil, true, makeNetwork(l2Single)) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryVLANNotInNetwork)) }) @@ -2617,7 +2666,7 @@ var _ = Describe("vsphere validation tests", func() { makeNetwork(l2Single), makeIPPool("workload-only", "10.100.0.0/24", "Workload"), // missing L2Workload ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Issues)).To(ConsistOf(planbase.CalicoIssuePrimaryNoEligibleIPPool)) }) @@ -2627,7 +2676,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.244.0.5", false, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(kinds(result.Warnings)).To(ConsistOf(planbase.CalicoIssuePrimaryStaticIPsNotPreserved)) Expect(result.Issues).To(BeEmpty()) @@ -2639,7 +2688,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, _ := setupPrimary("10.244.0.5", true, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) Expect(result.Warnings).To(BeEmpty()) }) @@ -2651,7 +2700,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, vmRef := setupPrimary("10.244.0.5", false, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2669,7 +2718,7 @@ var _ = Describe("vsphere validation tests", func() { It("returns nil when cache.Primary is nil (plan-level failed or no calico entry)", func() { dest := v1beta1.DestinationNetwork{Type: planbase.Pod} v, c, vmRef := setupPrimary("10.244.0.5", true, dest, nil, true) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2681,7 +2730,7 @@ var _ = Describe("vsphere validation tests", func() { v, c, vmRef := setupPrimary("192.168.1.5", true, dest, nil, true, makeIPPool("default-ipv4-ippool", "10.244.0.0/16"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2696,7 +2745,7 @@ var _ = Describe("vsphere validation tests", func() { makeNetwork(l2Single), makeIPPool("vlan100-pool", "10.100.0.0/24", "L2Workload"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2713,7 +2762,7 @@ var _ = Describe("vsphere validation tests", func() { // pool covers VLAN subnet but excludes 10.100.0.5 makeIPPool("vlan100-upper", "10.100.0.128/25", "L2Workload"), ) - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2737,7 +2786,7 @@ var _ = Describe("vsphere validation tests", func() { vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "192.168.1.5", DeviceConfigId: 4002}) v.Source.Inventory.(*mockInventory).vm = vm - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) @@ -2758,7 +2807,7 @@ var _ = Describe("vsphere validation tests", func() { vm.GuestNetworks = append(vm.GuestNetworks, vsphere.GuestNetwork{IP: "10.244.0.6", DeviceConfigId: 4001}) v.Source.Inventory.(*mockInventory).vm = vm - result, err := v.ValidateCalicoPrimary(c) + result, err := validateCalicoPrimaryForTest(v, c) Expect(err).NotTo(HaveOccurred()) issues, err := v.CalicoPrimaryIssues(vmRef, result.Cache) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/controller/plan/validation.go b/pkg/controller/plan/validation.go index 847be4f75a..cf950db391 100644 --- a/pkg/controller/plan/validation.go +++ b/pkg/controller/plan/validation.go @@ -563,32 +563,35 @@ func (r *Reconciler) validateUserDefinedNetwork(ctx *plancontext.Context) (err e // references. Healthy NADs are returned in a cache for per-VM checks. func (r *Reconciler) validateCalicoNetwork(ctx *plancontext.Context) (*planbase.CalicoValidationCache, error) { provider := ctx.Plan.Referenced.Provider.Source - if provider == nil { + if provider == nil || provider.Type() != api.VSphere { return nil, nil } - pAdapter, err := adapter.New(provider) - if err != nil { - return nil, err - } - validator, err := pAdapter.Validator(ctx) - if err != nil { - return nil, err + if ctx.Plan.Referenced.Map.Network == nil { + return nil, nil } - result, err := validator.ValidateCalicoNADs(ctx.Destination.Client) + // Map-scoped issues are surfaced as NetworkMap conditions by the + // NetworkMap controller; this pass only rebuilds the cache the per-VM + // checks read from and evaluates the plan-scoped concerns. + result, err := planbase.ValidateCalicoNADs( + context.TODO(), ctx.Destination.Client, + ctx.Plan.Referenced.Map.Network.Spec.Map, r.Log) if err != nil { return nil, err } - if cond, ok := buildCalicoNADCondition( + planHasPlacement := len(ctx.Plan.Spec.TargetNodeSelector) > 0 || ctx.Plan.Spec.TargetAffinity != nil + criticals, warnings := planbase.CalicoNADPlanIssues( + result.Cache, ctx.Plan.Spec.PreserveStaticIPs, planHasPlacement) + if cond, ok := planbase.BuildCalicoNADCondition( CalicoNetworkInvalid, api.CategoryCritical, "One or more Calico Network destinations are invalid", - result.Issues, + criticals, ); ok { ctx.Plan.Status.SetCondition(cond) } - if cond, ok := buildCalicoNADCondition( + if cond, ok := planbase.BuildCalicoNADCondition( CalicoNetworkWarning, api.CategoryWarn, "One or more Calico NADs will not receive identity preservation", - result.Warnings, + warnings, ); ok { ctx.Plan.Status.SetCondition(cond) } @@ -603,22 +606,41 @@ func (r *Reconciler) validateCalicoNetwork(ctx *plancontext.Context) (*planbase. // so both layers fold into a single condition. func (r *Reconciler) validateCalicoPrimary(ctx *plancontext.Context) (*planbase.CalicoPrimaryValidationResult, error) { provider := ctx.Plan.Referenced.Provider.Source - if provider == nil { + if provider == nil || provider.Type() != api.VSphere { return &planbase.CalicoPrimaryValidationResult{}, nil } - pAdapter, err := adapter.New(provider) - if err != nil { - return nil, err + if ctx.Plan.Referenced.Map.Network == nil { + return &planbase.CalicoPrimaryValidationResult{}, nil } - validator, err := pAdapter.Validator(ctx) + pairs := ctx.Plan.Referenced.Map.Network.Spec.Map + // Map-scoped issues are surfaced as NetworkMap conditions by the + // NetworkMap controller; this pass only rebuilds the cache the per-VM + // checks read from and evaluates the plan-scoped concerns. + result, err := planbase.ValidateCalicoPrimary( + context.TODO(), ctx.Destination.Client, pairs, r.Log) if err != nil { return nil, err } - result, err := validator.ValidateCalicoPrimary(ctx.Destination.Client) - if err != nil { - return nil, err + result.Issues = nil + result.Warnings = nil + if planbase.HasCalicoPodEntry(pairs) && !ctx.Plan.Spec.PreserveStaticIPs { + result.Warnings = append(result.Warnings, planbase.CalicoPrimaryIssue{ + Kind: planbase.CalicoIssuePrimaryStaticIPsNotPreserved, + }) } - if cond, ok := buildCalicoPrimaryCondition( + // The UDN conflict depends on the plan's target namespace, so it stays a + // plan-level Critical; it also invalidates the cache so per-VM checks + // and the deferred CalicoPrimaryInvalid fold treat the entry as failed. + if result.Cache != nil && result.Cache.Primary != nil && ctx.Plan.DestinationHasUdnNetwork(ctx.Destination.Client) { + primary := result.Cache.Primary + result.Issues = append(result.Issues, planbase.CalicoPrimaryIssue{ + Kind: planbase.CalicoIssuePrimaryConflictsWithUDN, + Network: primary.Network, + VLAN: primary.VLAN.VID, + }) + result.Cache.Primary = nil + } + if cond, ok := planbase.BuildCalicoPrimaryCondition( CalicoPrimaryWarning, api.CategoryWarn, "The Calico primary-network mapping has informational warnings", result.Warnings, @@ -628,77 +650,6 @@ func (r *Reconciler) validateCalicoPrimary(ctx *plancontext.Context) (*planbase. return &result, nil } -// buildCalicoPrimaryCondition assembles a plan-level Calico-primary condition -// from a slice of issues. Items deduplicate by issue ref (per-VM VMRef when -// set, or a "(plan)" synthetic identifier for plan-level issues). Message -// concatenates a per-issue detail phrase. Returns ok=false when issues is -// empty so callers can skip SetCondition. -func buildCalicoPrimaryCondition(condType, category, baseMsg string, issues []planbase.CalicoPrimaryIssue) (libcnd.Condition, bool) { - if len(issues) == 0 { - return libcnd.Condition{}, false - } - cond := libcnd.Condition{ - Type: condType, - Status: True, - Reason: NotValid, - Category: category, - Items: []string{}, - } - details := make([]string, 0, len(issues)) - seen := map[string]bool{} - for _, issue := range issues { - item := calicoPrimaryIssueItem(issue) - if !seen[item] { - seen[item] = true - cond.Items = append(cond.Items, item) - } - details = append(details, calicoPrimaryIssueDetail(issue)) - } - cond.Message = fmt.Sprintf("%s: %s.", baseMsg, strings.Join(details, "; ")) - return cond, true -} - -// calicoPrimaryIssueItem returns the Items entry for an issue. Per-VM issues -// use the VMRef; plan-level issues use a synthetic "(plan)" identifier since -// there is no resource-specific ref to attach (the offending NetworkMap entry -// is plan-scoped). -func calicoPrimaryIssueItem(i planbase.CalicoPrimaryIssue) string { - if !i.VMRef.NotSet() { - return i.VMRef.String() - } - return "(plan)" -} - -// buildCalicoNADCondition assembles a plan-level Calico NAD condition from a -// slice of per-NAD issues. Items are deduplicated by NAD reference; Message -// concatenates a per-issue detail phrase for each (including duplicates, so -// distinct kinds against the same NAD are both visible). Returns ok=false -// when issues is empty so callers can skip SetCondition. -func buildCalicoNADCondition(condType, category, baseMsg string, issues []planbase.CalicoNADIssue) (libcnd.Condition, bool) { - if len(issues) == 0 { - return libcnd.Condition{}, false - } - cond := libcnd.Condition{ - Type: condType, - Status: True, - Reason: NotValid, - Category: category, - Items: []string{}, - } - details := make([]string, 0, len(issues)) - seen := map[string]bool{} - for _, issue := range issues { - ref := issue.NAD.String() - if !seen[ref] { - seen[ref] = true - cond.Items = append(cond.Items, ref) - } - details = append(details, calicoNADIssueDetail(issue)) - } - cond.Message = fmt.Sprintf("%s: %s.", baseMsg, strings.Join(details, "; ")) - return cond, true -} - func (r *Reconciler) getDestinationNamespaceNads(ctx *plancontext.Context) (*k8snet.NetworkAttachmentDefinitionList, error) { nadList := &k8snet.NetworkAttachmentDefinitionList{} listOpts := []client.ListOption{ @@ -1663,7 +1614,7 @@ func (r *Reconciler) validateVM(plan *api.Plan, ctx *plancontext.Context, calico // CalicoPrimaryInvalid carries both plan-level (from validateCalicoPrimary) // and per-VM (collected during this validateVM pass) issues. if calicoPrimaryResult != nil { - if cond, ok := buildCalicoPrimaryCondition( + if cond, ok := planbase.BuildCalicoPrimaryCondition( CalicoPrimaryInvalid, api.CategoryCritical, "The Calico primary-network mapping is not valid", calicoPrimaryResult.Issues, @@ -2105,106 +2056,6 @@ func (r *Reconciler) validateVddkImage(plan *api.Plan) (err error) { return } -// calicoPrimaryIssueDetail formats a per-issue detail phrase for the -// plan-level CalicoPrimaryInvalid / CalicoPrimaryWarning Message. Per-VM -// issues carry a VMRef prefix; plan-level issues do not. -func calicoPrimaryIssueDetail(i planbase.CalicoPrimaryIssue) string { - prefix := "" - if !i.VMRef.NotSet() { - prefix = i.VMRef.String() + " " - } - switch i.Kind { - case planbase.CalicoIssuePrimaryProviderUnsupported: - return fmt.Sprintf("%s(PrimaryProviderUnsupported: feature is vSphere-only in this release)", prefix) - case planbase.CalicoIssuePrimaryUnsupported: - return fmt.Sprintf("%s(PrimaryUnsupported: Calico is not installed on the destination — projectcalico.org/v3 IPPool CRD absent)", prefix) - case planbase.CalicoIssuePrimaryNetworkCRDAbsent: - return fmt.Sprintf("%s(PrimaryNetworkCRDAbsent network=%q: destination Calico install does not ship the projectcalico.org/v3 Network CRD; remove calico.network or upgrade Calico)", prefix, i.Network) - case planbase.CalicoIssuePrimaryConflictsWithUDN: - return fmt.Sprintf("%s(PrimaryConflictsWithUDN: target namespace is labelled for a UDN primary network)", prefix) - case planbase.CalicoIssuePrimaryNetworkNotFound: - return fmt.Sprintf("%s(PrimaryNetworkNotFound network=%q)", prefix, i.Network) - case planbase.CalicoIssuePrimaryNetworkTypeUnsupported: - return fmt.Sprintf("%s(PrimaryNetworkTypeUnsupported network=%q: the referenced Network is not an L2 bridge network; only l2Bridge networks can back the primary NIC — a VRF network attaches via a multus NAD instead)", prefix, i.Network) - case planbase.CalicoIssuePrimaryDataplaneNotBPF: - return fmt.Sprintf("%s(PrimaryDataplaneNotBPF network=%q: the destination Calico install is not running the BPF dataplane; L2 networks require FelixConfiguration bpfEnabled: true)", prefix, i.Network) - case planbase.CalicoIssuePrimaryNetworkHasNoL2Bridge: - return fmt.Sprintf("%s(PrimaryNetworkHasNoL2Bridge network=%q)", prefix, i.Network) - case planbase.CalicoIssuePrimaryNetworkHasNoVLANs: - return fmt.Sprintf("%s(PrimaryNetworkHasNoVLANs network=%q)", prefix, i.Network) - case planbase.CalicoIssuePrimaryVLANRequired: - return fmt.Sprintf("%s(PrimaryVLANRequired network=%q: calico.network is set but calico.vlan is not; an explicit VLAN is required)", prefix, i.Network) - case planbase.CalicoIssuePrimaryVLANNotInNetwork: - return fmt.Sprintf("%s(PrimaryVLANNotInNetwork network=%q vlan=%d)", prefix, i.Network, i.VLAN) - case planbase.CalicoIssuePrimaryNoEligibleIPPool: - if i.IP != "" { - return fmt.Sprintf("%s(PrimaryNoEligibleIPPool ip=%s network=%q vlan=%d)", prefix, i.IP, i.Network, i.VLAN) - } - return fmt.Sprintf("%s(PrimaryNoEligibleIPPool network=%q vlan=%d)", prefix, i.Network, i.VLAN) - case planbase.CalicoIssuePrimaryIPNotInSubnet: - return fmt.Sprintf("%s(PrimaryIPNotInSubnet ip=%s network=%q vlan=%d)", prefix, i.IP, i.Network, i.VLAN) - case planbase.CalicoIssuePrimaryTooManyIPs: - return fmt.Sprintf("%s(PrimaryTooManyIPs ips=%s: Calico static IP preservation supports at most one IPv4 per interface)", prefix, i.IP) - case planbase.CalicoIssuePrimaryFieldsMisplaced: - return fmt.Sprintf("%s(PrimaryFieldsMisplaced: calico block set on a non-pod entry, calico.vlan without calico.network, or multiple calico-flagged entries)", prefix) - case planbase.CalicoIssuePrimaryStaticIPsNotPreserved: - return fmt.Sprintf("%s(PrimaryStaticIPsNotPreserved: preserveStaticIPs is false; DHCP-configured guests will pick up the Calico-assigned IP via the veth, static-IP guests may have a divergent in-guest IP)", prefix) - } - return fmt.Sprintf("%s(%s)", prefix, i.Kind) -} - -// calicoNADIssueDetail formats a per-NAD detail phrase for the plan-level -// CalicoNetworkInvalid condition's Message: e.g. -// "default/foo (NetworkNotFound network=\"calico-vlan\")". -func calicoNADIssueDetail(i planbase.CalicoNADIssue) string { - switch i.Kind { - case planbase.CalicoIssueNADUnreadable: - return fmt.Sprintf("%s (NADUnreadable)", i.NAD.String()) - case planbase.CalicoIssueNetworkNotFound: - return fmt.Sprintf("%s (NetworkNotFound network=%q)", i.NAD.String(), i.Network) - case planbase.CalicoIssueNetworkCRDAbsent: - return fmt.Sprintf("%s (NetworkCRDAbsent network=%q: destination Calico install does not ship the projectcalico.org/v3 Network CRD)", i.NAD.String(), i.Network) - case planbase.CalicoIssueVRFVlanIgnored: - return fmt.Sprintf("%s (VRFVlanIgnored network=%q vlan=%d: the referenced Network is a VRF (routed) network; VLANs apply only to l2Bridge networks and the vlan value is ignored)", i.NAD.String(), i.Network, i.VLAN) - case planbase.CalicoIssueVRFNodeScoped: - return fmt.Sprintf("%s (VRFNodeScoped network=%q: every hostConfig entry carries a nodeSelector, so the network exists only on matching nodes, and the plan does not constrain VM placement; VMs may schedule onto uncovered nodes and fail to start — set the plan's targetNodeSelector or targetAffinity to keep VMs on covered nodes, or add a hostConfig entry without a nodeSelector)", i.NAD.String(), i.Network) - case planbase.CalicoIssueVRFPlacementUnverified: - return fmt.Sprintf("%s (VRFPlacementUnverified network=%q: the network exists only on nodes matching its hostConfig selectors; the plan constrains VM placement, but Forklift cannot verify that placement keeps VMs on covered nodes)", i.NAD.String(), i.Network) - case planbase.CalicoIssueVRFRouteTableReserved: - return fmt.Sprintf("%s (VRFRouteTableReserved network=%q table=%d: route tables 253, 254 and 255 are reserved by the kernel; choose a different routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable) - case planbase.CalicoIssueVRFRouteTableConflict: - if i.ConflictsWith != "" { - return fmt.Sprintf("%s (VRFRouteTableConflict network=%q table=%d: route table %d is also claimed by VRF Network %q on an overlapping set of nodes, which can result in network outages; give each VRF Network a unique routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable, i.RouteTable, i.ConflictsWith) - } - return fmt.Sprintf("%s (VRFRouteTableConflict network=%q table=%d: route table %d falls inside the FelixConfiguration routeTableRanges, which Calico reserves for its own routes; choose a routeTableIndex outside those ranges)", i.NAD.String(), i.Network, i.RouteTable, i.RouteTable) - case planbase.CalicoIssueVRFRouteTablePossibleConflict: - return fmt.Sprintf("%s (VRFRouteTablePossibleConflict network=%q table=%d: VRF Network %q also uses route table %d; both entries are node-scoped, so the overlap cannot be ruled out — verify the two selectors never match the same node, or give each network a unique routeTableIndex)", i.NAD.String(), i.Network, i.RouteTable, i.ConflictsWith, i.RouteTable) - case planbase.CalicoIssueVRFDataplaneNotNftables: - return fmt.Sprintf("%s (VRFDataplaneNotNftables: VRF networking requires the nftables dataplane; set nftablesMode: Enabled (and leave bpfEnabled off) in the default FelixConfiguration)", i.NAD.String()) - case planbase.CalicoIssueVRFPoolNotPinned: - return fmt.Sprintf("%s (VRFPoolNotPinned network=%q: the NAD does not pin an IPPool, so each VM's address will come from whichever pool Calico's IPAM selects and the VRF's network may not be able to route it; pin the VRF's IPPool via ipv4_pools in the NAD's IPAM config)", i.NAD.String(), i.Network) - case planbase.CalicoIssueVRFNoBGPPeer: - return fmt.Sprintf("%s (VRFNoBGPPeer network=%q: cross-node reachability in this VRF requires a BGPPeer whose spec.network names it; VMs placed on different nodes will not reach each other until one exists)", i.NAD.String(), i.Network) - case planbase.CalicoIssueVRFNoHostInterfaces: - return fmt.Sprintf("%s (VRFNoHostInterfaces network=%q: a hostConfig entry names no hostInterfaces, so VMs on the nodes that entry matches are unreachable beyond their own node; name at least one host interface in every hostConfig entry)", i.NAD.String(), i.Network) - case planbase.CalicoIssueDataplaneNotBPF: - return fmt.Sprintf("%s (DataplaneNotBPF: the destination Calico install is not running the BPF dataplane; L2 networks require FelixConfiguration bpfEnabled: true)", i.NAD.String()) - case planbase.CalicoIssueNetworkHasNoL2Bridge: - return fmt.Sprintf("%s (NetworkHasNoL2Bridge network=%q)", i.NAD.String(), i.Network) - case planbase.CalicoIssueNetworkHasNoVLANs: - return fmt.Sprintf("%s (NetworkHasNoVLANs network=%q)", i.NAD.String(), i.Network) - case planbase.CalicoIssueVLANNotInNetwork: - return fmt.Sprintf("%s (VLANNotInNetwork network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) - case planbase.CalicoIssueVLANRequired: - return fmt.Sprintf("%s (VLANRequired network=%q: the NAD references a Calico Network but names no VLAN; an explicit VLAN is required)", i.NAD.String(), i.Network) - case planbase.CalicoIssueVLANHasNoIPPool: - return fmt.Sprintf("%s (VLANHasNoIPPool network=%q vlan=%d)", i.NAD.String(), i.Network, i.VLAN) - case planbase.CalicoIssueNADMissingNetwork: - return fmt.Sprintf("%s (NADMissingNetwork: type=calico without 'network' field; MAC/IP preservation not applied)", i.NAD.String()) - } - return fmt.Sprintf("%s (%s)", i.NAD.String(), i.Kind) -} - func missingStaticIPsMessage(plan *api.Plan) string { guestTools := "guest tools" source := plan.Provider.Source diff --git a/pkg/provider/ec2/controller/validator/noop.go b/pkg/provider/ec2/controller/validator/noop.go index aaefa502e9..3312eb8563 100644 --- a/pkg/provider/ec2/controller/validator/noop.go +++ b/pkg/provider/ec2/controller/validator/noop.go @@ -88,33 +88,11 @@ func (r *Validator) ConsolidationNeeded(vmRef ref.Ref) (bool, error) { return false, nil } -// ValidateCalicoNADs returns empty results (not applicable for EC2). -func (r *Validator) ValidateCalicoNADs(_ client.Client) (planbase.CalicoValidationResult, error) { - return planbase.CalicoValidationResult{}, nil -} - // CalicoVMIssues returns no issues (not applicable for EC2). func (r *Validator) CalicoVMIssues(_ ref.Ref, _ *planbase.CalicoValidationCache) ([]planbase.CalicoIssue, error) { return nil, nil } -// ValidateCalicoPrimary scans the NetworkMap. If any calico-flagged entry -// exists, returns a single CalicoIssuePrimaryProviderUnsupported issue — -// the feature is not supported on this provider in this release. -func (r *Validator) ValidateCalicoPrimary(_ client.Client) (planbase.CalicoPrimaryValidationResult, error) { - if r.Plan.Referenced.Map.Network == nil { - return planbase.CalicoPrimaryValidationResult{}, nil - } - for _, pair := range r.Plan.Referenced.Map.Network.Spec.Map { - if pair.Destination.Calico != nil { - return planbase.CalicoPrimaryValidationResult{ - Issues: []planbase.CalicoPrimaryIssue{{Kind: planbase.CalicoIssuePrimaryProviderUnsupported}}, - }, nil - } - } - return planbase.CalicoPrimaryValidationResult{}, nil -} - // CalicoPrimaryIssues returns nil; any calico-flagged entry was already // rejected at plan level by ValidateCalicoPrimary. func (r *Validator) CalicoPrimaryIssues(_ ref.Ref, _ *planbase.CalicoPrimaryValidationCache) ([]planbase.CalicoPrimaryIssue, error) {