Author SHA1 Message Date
josh 1a26a28876 feat(chart): release 1.2.6 with lb source ranges
Release Chart / release (push) Successful in 5s
deploy / build (push) Successful in 2m29s
2026-08-04 20:30:49 +03:30
josh d2b1a21a07 fix(lb): honor lb owner node annotation
deploy / build (push) Successful in 3m51s
2026-08-03 03:08:49 +03:30
josh 8718d27fc7 feat(lb): honor lb vip mask from api
deploy / build (push) Successful in 2m40s
Release Chart / release (push) Successful in 5s
2026-08-01 21:04:58 +03:30
josh f991c2fe15 feat: update
Release Chart / release (push) Successful in 5s
deploy / build (push) Successful in 2m29s
2026-08-01 12:26:59 +03:30
8 changed files with 173 additions and 43 deletions
-4
View File
@@ -1,4 +0,0 @@
owner: KubelanCloud
git-repo: kks-provider-plugin
pages-branch: gh-pages
pages-index-path: index.yaml
+7 -8
View File
@@ -1,18 +1,17 @@
apiVersion: v2
name: kks-provider-plugin
description: Combined Kloud provider plugin chart (LoadBalancer + CSI) for kks clusters
description: Combined Kloude provider plugin chart (LoadBalancer + CSI) for kks clusters
type: application
version: 1.1.2
appVersion: "1.1.0"
version: 1.2.6
appVersion: "1.2.0"
kubeVersion: ">=1.28.0-0"
home: https://github.com/KubelanCloud/kks-provider-plugin
home: https://git.kloude.ir/kloude/kks-provider-plugin
sources:
- https://github.com/KubelanCloud/kks-provider-plugin
- https://git.kloude.ir/kloude/kks-provider-plugin
keywords:
- provider
- loadbalancer
- csi
- storage
- kloud
- kloude
maintainers:
- name: Kloud Team
- name: Kloude Tech Team
+1 -1
View File
@@ -6,7 +6,7 @@ nameOverride: ""
fullnameOverride: ""
image:
repository: registry.kloude.ir/kks/kubelancloud/kks-provider-plugin
repository: registry.kloude.ir/kloude-public/kks-provider-plugin:latest
tag: "latest"
pullPolicy: IfNotPresent
+2
View File
@@ -3,7 +3,9 @@ package kloudlb
const (
Finalizer = "lb.kloude.ir/finalizer"
AnnotationIP = "lb.kloude.ir/ip"
AnnotationVIP = "lb.kloude.ir/vip"
AnnotationLBID = "lb.kloude.ir/id"
AnnotationNode = "lb.kloude.ir/node"
Namespace = "kube-system"
)
+55 -3
View File
@@ -3,6 +3,7 @@ package controller
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
@@ -14,9 +15,9 @@ import (
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
corelisters "k8s.io/client-go/listers/core/v1"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/leaderelection"
"k8s.io/client-go/tools/leaderelection/resourcelock"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/util/workqueue"
"github.com/KubelanCloud/kks-provider-plugin/pkg/kloudlb"
@@ -173,20 +174,30 @@ func (c *Controller) sync(ctx context.Context, key string) error {
Namespace: namespace,
Name: name,
Ports: ports,
LoadBalancerSourceRanges: serviceLoadBalancerSourceRanges(svc),
})
if err != nil {
return err
}
vip := strings.TrimSpace(lb.IP)
ingressIP, err := vipIngressIP(vip)
if err != nil {
return err
}
patch := svc.DeepCopy()
if patch.Annotations == nil {
patch.Annotations = map[string]string{}
}
patch.Annotations[kloudlb.AnnotationIP] = lb.IP
patch.Annotations[kloudlb.AnnotationIP] = ingressIP
patch.Annotations[kloudlb.AnnotationVIP] = vip
patch.Annotations[kloudlb.AnnotationLBID] = lb.ID
if node := strings.TrimSpace(lb.Node); node != "" {
patch.Annotations[kloudlb.AnnotationNode] = node
}
patch.Status = corev1.ServiceStatus{
LoadBalancer: corev1.LoadBalancerStatus{
Ingress: []corev1.LoadBalancerIngress{{IP: lb.IP}},
Ingress: []corev1.LoadBalancerIngress{{IP: ingressIP}},
},
}
@@ -194,6 +205,29 @@ func (c *Controller) sync(ctx context.Context, key string) error {
return err
}
func vipIngressIP(vip string) (string, error) {
vip = strings.TrimSpace(vip)
if vip == "" {
return "", fmt.Errorf("load balancer vip is empty")
}
if strings.Contains(vip, "/") {
ip, _, err := net.ParseCIDR(vip)
if err != nil {
return "", fmt.Errorf("invalid load balancer vip %q: %w", vip, err)
}
ipv4 := ip.To4()
if ipv4 == nil {
return "", fmt.Errorf("load balancer vip %q is not ipv4", vip)
}
return ipv4.String(), nil
}
ip := net.ParseIP(vip)
if ip == nil || ip.To4() == nil {
return "", fmt.Errorf("invalid load balancer vip %q", vip)
}
return ip.String(), nil
}
func (c *Controller) finalize(ctx context.Context, svc *corev1.Service) error {
if !containsString(svc.Finalizers, kloudlb.Finalizer) {
return nil
@@ -246,6 +280,24 @@ func servicePortsToAllocateRules(svc *corev1.Service) ([]provisioner.AllocatePor
return out, nil
}
func serviceLoadBalancerSourceRanges(svc *corev1.Service) []string {
if svc == nil || len(svc.Spec.LoadBalancerSourceRanges) == 0 {
return nil
}
out := make([]string, 0, len(svc.Spec.LoadBalancerSourceRanges))
for _, item := range svc.Spec.LoadBalancerSourceRanges {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
out = append(out, trimmed)
}
if len(out) == 0 {
return nil
}
return out
}
func containsString(items []string, target string) bool {
for _, item := range items {
if item == target {
+17
View File
@@ -45,3 +45,20 @@ func TestServicePortsToAllocateRulesRejectsNoPorts(t *testing.T) {
t.Fatal("expected error for empty ports")
}
}
func TestServiceLoadBalancerSourceRanges(t *testing.T) {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "web"},
Spec: corev1.ServiceSpec{
LoadBalancerSourceRanges: []string{"203.0.113.0/24", " 198.51.100.8/32 ", ""},
},
}
ranges := serviceLoadBalancerSourceRanges(svc)
if len(ranges) != 2 {
t.Fatalf("ranges count = %d, want 2", len(ranges))
}
if ranges[0] != "203.0.113.0/24" || ranges[1] != "198.51.100.8/32" {
t.Fatalf("unexpected ranges: %#v", ranges)
}
}
+83 -21
View File
@@ -5,11 +5,12 @@ import (
"fmt"
"net"
"os"
"strings"
"sync"
"time"
corev1 "k8s.io/api/core/v1"
coordinationv1 "k8s.io/api/coordination/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
@@ -141,26 +142,42 @@ func (s *Speaker) sync(ctx context.Context, key string) error {
return err
}
ip := serviceExternalIP(svc)
if ip == "" || svc.DeletionTimestamp != nil {
s.stopLeading(key, ip)
vip := serviceExternalVIP(svc)
if vip == "" || svc.DeletionTimestamp != nil {
s.stopLeading(key, vip)
return nil
}
if owner := serviceOwnerNode(svc); owner != "" && owner != s.nodeName {
s.stopLeading(key, vip)
return nil
}
leaseName := leaseNameFor(key)
leader, err := s.acquireLease(ctx, leaseName, ip)
leader, err := s.acquireLease(ctx, leaseName, vip)
if err != nil {
return err
}
if leader != s.nodeName {
s.stopLeading(key, ip)
s.stopLeading(key, vip)
return nil
}
return s.ensureVIP(ip)
return s.ensureVIP(vip)
}
func serviceExternalIP(svc *corev1.Service) string {
func serviceOwnerNode(svc *corev1.Service) string {
if svc == nil || svc.Annotations == nil {
return ""
}
return strings.TrimSpace(svc.Annotations[kloudlb.AnnotationNode])
}
func serviceExternalVIP(svc *corev1.Service) string {
if svc.Annotations != nil {
if vip := strings.TrimSpace(svc.Annotations[kloudlb.AnnotationVIP]); vip != "" {
return vip
}
}
for _, ing := range svc.Status.LoadBalancer.Ingress {
if ing.IP != "" {
return ing.IP
@@ -287,17 +304,15 @@ func addVIP(ifaceName, ip string) error {
if err != nil {
return fmt.Errorf("lookup interface %s: %w", ifaceName, err)
}
parsed := net.ParseIP(ip)
if parsed == nil {
return fmt.Errorf("invalid vip %q", ip)
}
addr := &netlink.Addr{
IPNet: &net.IPNet{IP: parsed.To4(), Mask: net.CIDRMask(32, 32)},
ipNet, err := vipToIPNet(link, ip)
if err != nil {
return err
}
addr := &netlink.Addr{IPNet: ipNet}
if err := netlink.AddrAdd(link, addr); err != nil {
return fmt.Errorf("add vip %s on %s: %w", ip, ifaceName, err)
}
return sendGratuitousARP(link, parsed.To4())
return sendGratuitousARP(link, ipNet.IP.To4())
}
func removeVIP(ifaceName, ip string) error {
@@ -305,16 +320,63 @@ func removeVIP(ifaceName, ip string) error {
if err != nil {
return err
}
parsed := net.ParseIP(ip)
if parsed == nil {
return fmt.Errorf("invalid vip %q", ip)
}
addr := &netlink.Addr{
IPNet: &net.IPNet{IP: parsed.To4(), Mask: net.CIDRMask(32, 32)},
ipNet, err := vipToIPNet(link, ip)
if err != nil {
return err
}
addr := &netlink.Addr{IPNet: ipNet}
return netlink.AddrDel(link, addr)
}
func vipToIPNet(link netlink.Link, vip string) (*net.IPNet, error) {
vip = strings.TrimSpace(vip)
if vip == "" {
return nil, fmt.Errorf("vip is required")
}
if strings.Contains(vip, "/") {
ip, ipNet, err := net.ParseCIDR(vip)
if err != nil {
return nil, fmt.Errorf("invalid vip %q: %w", vip, err)
}
ipv4 := ip.To4()
if ipv4 == nil {
return nil, fmt.Errorf("vip %q is not ipv4", vip)
}
return &net.IPNet{IP: ipv4, Mask: ipNet.Mask}, nil
}
ip := net.ParseIP(vip)
if ip == nil || ip.To4() == nil {
return nil, fmt.Errorf("invalid vip %q", vip)
}
mask, err := interfaceIPv4Mask(link)
if err != nil {
return nil, err
}
return &net.IPNet{IP: ip.To4(), Mask: mask}, nil
}
func interfaceIPv4Mask(link netlink.Link) (net.IPMask, error) {
addrs, err := netlink.AddrList(link, 0)
if err != nil {
return nil, fmt.Errorf("list ipv4 addresses on %s: %w", link.Attrs().Name, err)
}
for _, addr := range addrs {
if addr.IPNet == nil || addr.IPNet.IP == nil || addr.IPNet.Mask == nil {
continue
}
ip := addr.IPNet.IP.To4()
if ip == nil {
continue
}
if !ip.IsGlobalUnicast() {
continue
}
return addr.IPNet.Mask, nil
}
return nil, fmt.Errorf("cannot infer ipv4 mask from interface %s", link.Attrs().Name)
}
func sendGratuitousARP(link netlink.Link, ip net.IP) error {
if ip == nil {
return fmt.Errorf("ip is required")
+2
View File
@@ -5,12 +5,14 @@ type LoadBalancer struct {
IP string `json:"ip"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Node string `json:"node,omitempty"`
}
type AllocateRequest struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Ports []AllocatePortRule `json:"ports,omitempty"`
LoadBalancerSourceRanges []string `json:"loadBalancerSourceRanges,omitempty"`
}
type AllocatePortRule struct {