Files
ubicloud/spec/lib/kubernetes/client_spec.rb
mohi-kalantari 0714873694 Deduplicate service ports by 'port' value, keeping first occurrence
Previously customers were able to create multiple LoadBalancer services
with duplicate ports which would cause issue for us.

The proper way to do it is installing a webhook inside the clustere and
not allow the creation of the duplicate ports.

As a quick fix for now, we would sort all LoadBalancer services based on
the creationTimestamp and use the first occurance and ignore the other ones.
2025-04-02 22:00:31 +01:00

323 lines
12 KiB
Ruby

# frozen_string_literal: true
RSpec.describe Kubernetes::Client do
let(:project) { Project.create(name: "test") }
let(:private_subnet) { PrivateSubnet.create(project_id: project.id, name: "test", location_id: Location::HETZNER_FSN1_ID, net6: "fe80::/64", net4: "192.168.0.0/24") }
let(:kubernetes_cluster) {
KubernetesCluster.create(
name: "test",
version: "v1.32",
cp_node_count: 3,
private_subnet_id: private_subnet.id,
location_id: Location::HETZNER_FSN1_ID,
project_id: project.id,
target_node_size: "standard-2"
)
}
let(:session) { instance_double(Net::SSH::Connection::Session) }
let(:kubernetes_client) { described_class.new(kubernetes_cluster, session) }
describe "service_deleted?" do
it "detects deleted service" do
svc = {
"metadata" => {
"deletionTimestamp" => "asdf"
}
}
expect(kubernetes_client.service_deleted?(svc)).to be(true)
end
it "detects not deleted service" do
svc = {
"metadata" => {}
}
expect(kubernetes_client.service_deleted?(svc)).to be(false)
end
end
describe "lb_desired_ports" do
it "returns desired ports sorted by creationTimestamp" do
svc_list = [
{
"metadata" => {"name" => "svc-b", "namespace" => "default", "creationTimestamp" => "2024-01-03T00:00:00Z"},
"spec" => {"ports" => [{"port" => 80, "nodePort" => 31942}, {"port" => 443, "nodePort" => 33212}]}
},
{
"metadata" => {"name" => "svc-a", "namespace" => "default", "creationTimestamp" => "2024-01-01T00:00:00Z"},
"spec" => {"ports" => [{"port" => 800, "nodePort" => 32942}]}
}
]
expect(kubernetes_client.lb_desired_ports(svc_list)).to eq([[800, 32942], [80, 31942], [443, 33212]])
end
it "keeps first occurrence of duplicate ports based on creationTimestamp" do
svc_list = [
{
"metadata" => {"name" => "svc-newer", "namespace" => "default", "creationTimestamp" => "2024-01-02T00:00:00Z"},
"spec" => {"ports" => [{"port" => 443, "nodePort" => 30123}]}
},
{
"metadata" => {"name" => "svc-older", "namespace" => "default", "creationTimestamp" => "2024-01-01T00:00:00Z"},
"spec" => {"ports" => [{"port" => 443, "nodePort" => 32003}]}
}
]
expect(kubernetes_client.lb_desired_ports(svc_list)).to eq([[443, 32003]])
end
it "returns empty list if no services have ports" do
svc_list = [
{"metadata" => {"name" => "svc0", "namespace" => "ns", "creationTimestamp" => "2024-01-01T00:00:00Z"}, "spec" => {}},
{"metadata" => {"name" => "svc1", "namespace" => "ns", "creationTimestamp" => "2024-01-02T00:00:00Z"}, "spec" => {"ports" => nil}}
]
expect(kubernetes_client.lb_desired_ports(svc_list)).to eq([])
end
it "ignores duplicate ports within the same service" do
svc_list = [
{
"metadata" => {"name" => "svc0", "namespace" => "ns", "creationTimestamp" => "2024-01-01T00:00:00Z"},
"spec" => {
"ports" => [
{"port" => 1234, "nodePort" => 30001},
{"port" => 1234, "nodePort" => 30002}
]
}
}
]
expect(kubernetes_client.lb_desired_ports(svc_list)).to eq([[1234, 30001]])
end
end
describe "load_balancer_hostname_missing?" do
it "returns false when hostname is present" do
svc = {
"status" => {
"loadBalancer" => {
"ingress" => [
{"hostname" => "asdf.com"}
]
}
}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(false)
end
it "returns true when ingress is an empty hash" do
svc = {
"status" => {
"loadBalancer" => {
"ingress" => {}
}
}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(true)
end
it "returns true when ingress is nil" do
svc = {
"status" => {
"loadBalancer" => {
"ingress" => nil
}
}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(true)
end
it "returns true when ingress is an empty array" do
svc = {
"status" => {
"loadBalancer" => {
"ingress" => []
}
}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(true)
end
it "returns true when hostname key is missing" do
svc = {
"status" => {
"loadBalancer" => {
"ingress" => [{}]
}
}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(true)
end
it "returns true when hostname is nil" do
svc = {
"status" => {
"loadBalancer" => {
"ingress" => [{"hostname" => nil}]
}
}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(true)
end
it "returns true when hostname is empty string" do
svc = {
"status" => {
"loadBalancer" => {
"ingress" => [{"hostname" => ""}]
}
}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(true)
end
it "returns false when hostname is present in the first ingress even if others are missing" do
svc = {
"status" => {
"loadBalancer" => {
"ingress" => [
{"hostname" => "example.com"},
{"hostname" => nil}
]
}
}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(false)
end
it "returns true when loadBalancer key is missing" do
svc = {
"status" => {}
}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(true)
end
it "returns true when status key is missing" do
svc = {}
expect(kubernetes_client.load_balancer_hostname_missing?(svc)).to be(true)
end
end
describe "kubectl" do
it "runs kubectl command in the right format" do
expect(session).to receive(:exec!).with("sudo kubectl --kubeconfig=/etc/kubernetes/admin.conf get nodes")
kubernetes_client.kubectl("get nodes")
end
end
describe "set_load_balancer_hostname" do
it "calls kubectl function with right inputs" do
svc = {
"metadata" => {
"namespace" => "default",
"name" => "test-svc"
}
}
expect(kubernetes_client).to receive(:kubectl).with("-n default patch service test-svc --type=merge -p '{\"status\":{\"loadBalancer\":{\"ingress\":[{\"hostname\":\"asdf.com\"}]}}}' --subresource=status")
kubernetes_client.set_load_balancer_hostname(svc, "asdf.com")
end
end
describe "any_lb_services_modified?" do
before do
@lb = Prog::Vnet::LoadBalancerNexus.assemble(private_subnet.id, name: kubernetes_cluster.services_load_balancer_name, src_port: 80, dst_port: 8000).subject
@response = {
"items" => ["metadata" => {"name" => "svc", "namespace" => "default", "creationTimestamp" => "2024-01-03T00:00:00Z"}]
}.to_json
allow(kubernetes_client).to receive(:kubectl).with("get service --all-namespaces --field-selector spec.type=LoadBalancer -ojson").and_return(@response)
end
it "returns true early since there are no LoadBalancer services but there is a port" do
response = {
"items" => []
}.to_json
expect(kubernetes_client).to receive(:kubectl).with("get service --all-namespaces --field-selector spec.type=LoadBalancer -ojson").and_return(response)
expect(kubernetes_client.any_lb_services_modified?).to be(true)
end
it "determines lb_service is modified because vm_diff is not empty" do
expect(kubernetes_cluster).to receive(:vm_diff_for_lb).and_return([[instance_double(Vm)], []])
expect(kubernetes_client.any_lb_services_modified?).to be(true)
expect(kubernetes_cluster).to receive(:vm_diff_for_lb).and_return([[], [instance_double(Vm)]])
expect(kubernetes_client.any_lb_services_modified?).to be(true)
end
it "determines lb_service is modified because port_diff is not empty" do
allow(kubernetes_cluster).to receive(:vm_diff_for_lb).and_return([[], []])
expect(kubernetes_cluster).to receive(:port_diff_for_lb).and_return([[], [instance_double(LoadBalancerPort)]])
expect(kubernetes_client.any_lb_services_modified?).to be(true)
expect(kubernetes_cluster).to receive(:port_diff_for_lb).and_return([[instance_double(LoadBalancerPort)], []])
expect(kubernetes_client.any_lb_services_modified?).to be(true)
end
it "determintes the modification because hostname is not set" do
response = {
"items" => [
{
"metadata" => {"name" => "svc", "namespace" => "default", "creationTimestamp" => "2024-01-03T00:00:00Z"},
"status" => {
"loadBalancer" => {
"ingress" => [
{}
]
}
}
}
]
}.to_json
expect(kubernetes_client).to receive(:kubectl).with("get service --all-namespaces --field-selector spec.type=LoadBalancer -ojson").and_return(response)
allow(kubernetes_cluster).to receive_messages(
vm_diff_for_lb: [[], []],
port_diff_for_lb: [[], []]
)
expect(kubernetes_client.any_lb_services_modified?).to be(true)
end
end
describe "sync_kubernetes_services" do
before do
@lb = Prog::Vnet::LoadBalancerNexus.assemble(private_subnet.id, name: kubernetes_cluster.services_load_balancer_name, src_port: 443, dst_port: 8443).subject
@response = {
"items" => [
"metadata" => {"name" => "svc", "namespace" => "default", "creationTimestamp" => "2024-01-03T00:00:00Z"}
]
}.to_json
allow(kubernetes_client).to receive(:kubectl).with("get service --all-namespaces --field-selector spec.type=LoadBalancer -ojson").and_return(@response)
end
it "reconciles with pre existing lb with not ready loadbalancer" do
@lb.strand.update(label: "not waiting")
missing_port = [80, 8000]
missing_vm = create_vm
extra_vm = create_vm
allow(kubernetes_client).to receive(:lb_desired_ports).and_return([[30122, 80]])
allow(kubernetes_cluster).to receive_messages(
vm_diff_for_lb: [[extra_vm], [missing_vm]],
port_diff_for_lb: [[@lb.ports.first], [missing_port]]
)
expect(kubernetes_client).not_to receive(:set_load_balancer_hostname)
kubernetes_client.sync_kubernetes_services
end
it "reconciles with pre existing lb with ready loadbalancer" do
missing_port = [80, 8000]
missing_vm = create_vm
extra_vm = create_vm
allow(kubernetes_client).to receive(:lb_desired_ports).and_return([[30122, 80]])
allow(kubernetes_cluster).to receive_messages(
vm_diff_for_lb: [[extra_vm], [missing_vm]],
port_diff_for_lb: [[@lb.ports.first], [missing_port]]
)
expect(kubernetes_client).to receive(:set_load_balancer_hostname)
kubernetes_client.sync_kubernetes_services
end
it "raises error with non existing lb" do
kubernetes_client = described_class.new(instance_double(KubernetesCluster, services_load_balancer_name: "random_name"), instance_double(Net::SSH::Connection::Session))
allow(kubernetes_client).to receive(:kubectl).with("get service --all-namespaces --field-selector spec.type=LoadBalancer -ojson").and_return({"items" => [{}]}.to_json)
expect { kubernetes_client.sync_kubernetes_services }.to raise_error("services load balancer does not exist.")
end
end
end