Skip to content
This repository was archived by the owner on Oct 27, 2025. It is now read-only.

Commit 55df372

Browse files
committed
chore(etcd): add tests
Signed-off-by: 90DY <forward@90dy.ltd>
1 parent d61201a commit 55df372

File tree

10 files changed

+216
-5
lines changed

10 files changed

+216
-5
lines changed

.github/workflows/apply.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ jobs:
3838

3939
- name: Apply changes
4040
# Hide logs for public repositories
41-
run: make apply 1>/dev/null 2>/dev/null
41+
run: make apply # 1>/dev/null
4242

4343
- name: Tests changes
44-
run: make test 1>/dev/null 2>/dev/null
44+
run: make test # 1>/dev/null
4545

4646
- name: Bump version and push tag
4747
id: tag_version

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ ENV DOMAIN_NAME=
44

55
VOLUME /inventory
66

7-
ENTRYPOINT [ "ansible-playbook", "--private-key=/inventory/${DOMAIN_NAME}-private.key", "-i=/inventory/inventory.ini", "--forks=10" ]
7+
ENTRYPOINT [ "ansible-playbook", "--private-key=/inventory/${DOMAIN_NAME}-private.key", "-i=/inventory/inventory.ini", "--forks=50" ]
88

99
CMD [ "cluster.yml" ]

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ build:
1212
.PHONY: apply
1313
apply: ## Apply the kubernetes cluster
1414
apply: generate ${DOMAIN_NAME}-private.key build
15+
${docker-run} -v .:/inventory kubespray upgrade-cluster.yml
16+
17+
.PHONY: install
18+
install: ## Install the kubernetes cluster
19+
install: generate ${DOMAIN_NAME}-private.key build
1520
${docker-run} -v .:/inventory kubespray cluster.yml
21+
@make generate # Regenerate kubeconfig.yml
1622

1723
.PHONY: reset
1824
reset: ## Reset the kubernetes cluster

tests/etcd_test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* ETCD Health Test
3+
*
4+
* This test verifies that the etcd cluster is healthy by:
5+
* 1. Checking if the etcd cluster has a leader
6+
* 2. Verifying all members are healthy
7+
* 3. Checking for any active alarms
8+
* 4. Checking response times
9+
*/
10+
11+
import { etcds } from "../helpers.ts";
12+
import * as process from "node:process";
13+
14+
// Types
15+
type TestResult = {
16+
success: boolean;
17+
message: string;
18+
details?: string;
19+
};
20+
21+
/**
22+
* Run an SSH command on an etcd node and return the output
23+
*/
24+
async function sshCommand(node: (typeof etcds)[0], command: string): Promise<string> {
25+
const cmd = new Deno.Command("ssh", {
26+
args: [
27+
"-o",
28+
"ConnectTimeout=5",
29+
"-o",
30+
"StrictHostKeyChecking=no",
31+
`root@${node.publicIp}`,
32+
`-i`,
33+
`${process.env.DOMAIN_NAME}-private.key`,
34+
command,
35+
],
36+
});
37+
38+
const { stdout, stderr, success } = await cmd.output();
39+
40+
if (!success) {
41+
throw new Error(`SSH command failed: ${new TextDecoder().decode(stderr)}`);
42+
}
43+
44+
return new TextDecoder().decode(stdout).trim();
45+
}
46+
47+
/**
48+
* Run an etcdctl command and return the output
49+
*/
50+
async function etcdctl(args: string[]): Promise<string> {
51+
const etcdNode = etcds[0];
52+
const command = `ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/ssl/etcd/ssl/ca.pem --cert=/etc/ssl/etcd/ssl/member-${
53+
etcdNode.name
54+
}.pem --key=/etc/ssl/etcd/ssl/member-${etcdNode.name}-key.pem ${args.join(" ")}`;
55+
56+
return await sshCommand(etcdNode, command);
57+
}
58+
59+
/**
60+
* Check etcd cluster health
61+
*/
62+
async function checkEtcdHealth(): Promise<TestResult> {
63+
64+
try {
65+
const output = await etcdctl(["endpoint", "health", "--cluster"]);
66+
67+
// Check if we have any endpoints and if they're all healthy
68+
const healthLines = output.split("\n").filter((line) => line.trim() !== "");
69+
const hasEndpoints = healthLines.length > 0;
70+
const allHealthy = hasEndpoints && healthLines.every((line) => line.includes("is healthy"));
71+
72+
let message;
73+
if (!hasEndpoints) {
74+
message = "No etcd endpoints found";
75+
} else if (allHealthy) {
76+
message = `All ${healthLines.length} etcd endpoints are healthy`;
77+
} else {
78+
const healthyCount = healthLines.filter((line) => line.includes("is healthy")).length;
79+
message = `${healthyCount}/${healthLines.length} etcd endpoints are healthy`;
80+
}
81+
82+
return {
83+
success: allHealthy,
84+
message,
85+
details: output,
86+
};
87+
} catch (error: unknown) {
88+
return {
89+
success: false,
90+
message: `Failed to check etcd health: ${error instanceof Error ? error.message : String(error)}`,
91+
};
92+
}
93+
}
94+
95+
/**
96+
* Check etcd member list
97+
*/
98+
async function checkEtcdMembers(): Promise<TestResult> {
99+
try {
100+
const output = await etcdctl(["member", "list", "-w", "table"]);
101+
const memberLines = output.split("\n").filter((line) => line.includes("started"));
102+
103+
// We expect at least 1 member, and ideally 3 for high availability
104+
const minExpectedMembers = 1;
105+
const idealMembers = 3;
106+
107+
// Success if we have at least the minimum number of members
108+
const success = memberLines.length >= minExpectedMembers;
109+
110+
let message;
111+
if (memberLines.length >= idealMembers) {
112+
message = `Found ${memberLines.length} etcd members - cluster has high availability`;
113+
} else if (success) {
114+
message = `Found ${memberLines.length} etcd members - cluster is functional but lacks high availability`;
115+
} else {
116+
message = `Expected at least ${minExpectedMembers} etcd members, but found ${memberLines.length}`;
117+
}
118+
119+
return {
120+
success,
121+
message,
122+
details: output,
123+
};
124+
} catch (error: unknown) {
125+
return {
126+
success: false,
127+
message: `Failed to check etcd members: ${error instanceof Error ? error.message : String(error)}`,
128+
};
129+
}
130+
}
131+
132+
/**
133+
* Check for etcd alarms
134+
*/
135+
async function checkEtcdAlarms(): Promise<TestResult> {
136+
try {
137+
const output = await etcdctl(["alarm", "list"]);
138+
const noAlarms = output.trim() === "" || output.includes("memberID:0 alarm:NONE");
139+
140+
return {
141+
success: noAlarms,
142+
message: noAlarms ? "No etcd alarms found" : "Active etcd alarms found",
143+
details: output || "No alarms",
144+
};
145+
} catch (error: unknown) {
146+
return {
147+
success: false,
148+
message: `Failed to check etcd alarms: ${error instanceof Error ? error.message : String(error)}`,
149+
};
150+
}
151+
}
152+
153+
/**
154+
* Check etcd response time
155+
*/
156+
async function checkEtcdResponseTime(): Promise<TestResult> {
157+
try {
158+
const start = performance.now();
159+
await etcdctl(["get", "--prefix", "--limit=1", "/"]);
160+
const end = performance.now();
161+
const responseTime = end - start;
162+
const isGood = responseTime < 1000;
163+
const isAcceptable = responseTime < 3000;
164+
165+
return {
166+
success: isAcceptable,
167+
message: `Etcd response time is ${isGood ? "good" : isAcceptable ? "acceptable" : "slow"}: ${responseTime.toFixed(
168+
2
169+
)}ms`,
170+
};
171+
} catch (error: unknown) {
172+
return {
173+
success: false,
174+
message: `Failed to check etcd response time: ${error instanceof Error ? error.message : String(error)}`,
175+
};
176+
}
177+
}
178+
179+
Deno.test({
180+
name: "Check etcd cluster is healthy",
181+
async fn() {
182+
await checkEtcdHealth();
183+
},
184+
});
185+
186+
Deno.test({
187+
name: "Check etcd member list",
188+
async fn() {
189+
await checkEtcdMembers();
190+
},
191+
});
192+
193+
Deno.test({
194+
name: "Check etcd alarms ",
195+
async fn() {
196+
await checkEtcdAlarms();
197+
},
198+
});
199+
200+
Deno.test({
201+
name: "Check etcd response time",
202+
async fn() {
203+
await checkEtcdResponseTime();
204+
}
205+
});
File renamed without changes.
File renamed without changes.
File renamed without changes.

tests/fixtures/03-ingress.yaml.ts renamed to tests/fixtures/ingress-test/03-ingress.yaml.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ingressTestHost } from "../ingress_test.ts"
1+
import { ingressTestHost } from "../../ingress_test.ts"
22

33
const yaml = String.raw;
44

tests/ingress_test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const ingressTestHost = `ingress-test.${domainName}`;
1616

1717
// Constants
1818
const NAMESPACE = "ingress-test";
19-
const FIXTURES_DIR = "./tests/fixtures";
19+
const FIXTURES_DIR = "./tests/fixtures/ingress-test";
2020
const TIMEOUT_MS = 120000; // 2 minutes
2121

2222
// Types

0 commit comments

Comments
 (0)