Files
ubicloud/spec/lib/kubernetes/client_spec.rb
Eren Başak b10f563446 Add kubernetes cluster and nodepool upgrades
This patch implements the upgrade logic for kubernetes clusters.

Upgrade is done sequentially, node-by-node, in the order of creation time.
Upgrade operation starts with the CP nodes.

Nodepools need to "incr_upgrade"d separately to start their upgrade process.

For upgrading a node, it creates a new node with upgraded version of k8s, adds it to
the cluster and drains/removes the corresponding old node. There's no actual relation
between the old node and new node in reality.

Co-authored-by: Eren Başak <eren@ubicloud.com>
Co-authored-by: mohi-kalantari <mohi.kalantari1@gmail.com>
2025-05-22 11:55:47 +02:00

337 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 "version" do
it "runs a version command on kubectl" do
expect(session).to receive(:exec!).with("sudo kubectl --kubeconfig=/etc/kubernetes/admin.conf version --client").and_return("Client Version: v1.33.0\nKustomize Version: v5.6.0")
expect(kubernetes_client.version).to eq("v1.33")
end
end
describe "delete_node" do
it "deletes a node" do
expect(session).to receive(:exec!).with("sudo kubectl --kubeconfig=/etc/kubernetes/admin.conf delete node asdf")
kubernetes_client.delete_node("asdf")
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