mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/kube-vip/kube-vip.git
synced 2026-09-20 08:03:47 +08:00
Compare commits
923 Commits
v0.8.2
...
b514ae2733
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b514ae2733 | ||
|
|
26eab74f3e | ||
|
|
44a67bc901 | ||
|
|
7df2e5dd46 | ||
|
|
ba8ffde3c8 | ||
|
|
65a6a6f8f2 | ||
|
|
94ff8495f4 | ||
|
|
52021d9232 | ||
|
|
776f0b18fa | ||
|
|
86ba1d09af | ||
|
|
6512709032 | ||
|
|
1196748efc | ||
|
|
d557211b55 | ||
|
|
e4eb5e8e7f | ||
|
|
a7b068f9d2 | ||
|
|
a6bf5280d4 | ||
|
|
61a52e8f2a | ||
|
|
7e2dd0d262 | ||
|
|
7a085cb3ce | ||
|
|
1f5c135fd1 | ||
|
|
b4771c5319 | ||
|
|
3771ccee29 | ||
|
|
ee64dceb36 | ||
|
|
38fabeba9e | ||
|
|
7ef1899567 | ||
|
|
637c3da47e | ||
|
|
e666a0cdd1 | ||
|
|
9a2142c028 | ||
|
|
0606e9477c | ||
|
|
8a968618bf | ||
|
|
5cd138a85b | ||
|
|
246a786fe2 | ||
|
|
6ee3024bc6 | ||
|
|
2bc2df53fc | ||
|
|
618904dea3 | ||
|
|
b116d5a469 | ||
|
|
6cbf5aaeda | ||
|
|
dc453f07fc | ||
|
|
14b2f51aba | ||
|
|
01e8fbc3e3 | ||
|
|
4504649c91 | ||
|
|
47f4e75183 | ||
|
|
f589a18bd9 | ||
|
|
d79f3ddb52 | ||
|
|
1b25ce7d0e | ||
|
|
96c4406d63 | ||
|
|
a51944b89d | ||
|
|
a36dc36947 | ||
|
|
47b546073a | ||
|
|
2b126dceed | ||
|
|
110d34b844 | ||
|
|
5388fed92b | ||
|
|
487859e76f | ||
|
|
0e57deef3d | ||
|
|
b25badd185 | ||
|
|
77fa726c99 | ||
|
|
920a0182cb | ||
|
|
8d043de910 | ||
|
|
b0b12cfc13 | ||
|
|
b034c81bef | ||
|
|
ca9640227a | ||
|
|
5276f0123f | ||
|
|
c2e5be6d6a | ||
|
|
a8293f66e4 | ||
|
|
9d72f43f62 | ||
|
|
3c3096d89d | ||
|
|
5d5c893501 | ||
|
|
d417a0c8e8 | ||
|
|
825ffdb20c | ||
|
|
fe3a379f2d | ||
|
|
c42a207c26 | ||
|
|
d6e5753464 | ||
|
|
5131b92810 | ||
|
|
e1fd9ac3e3 | ||
|
|
1831a05525 | ||
|
|
19198d47fa | ||
|
|
1af388ff9f | ||
|
|
d1ff5f2952 | ||
|
|
54881a117c | ||
|
|
426a409a5d | ||
|
|
5871bec56c | ||
|
|
b7f3379514 | ||
|
|
a15745c442 | ||
|
|
b684eed5a4 | ||
|
|
0bdd6a9015 | ||
|
|
b864abf27d | ||
|
|
6fa38027e2 | ||
|
|
b478234d29 | ||
|
|
03241ee27f | ||
|
|
6e8b391685 | ||
|
|
e5ff483a23 | ||
|
|
bee5cfe4a2 | ||
|
|
81d54050c7 | ||
|
|
5e9fcf642c | ||
|
|
83797e4da1 | ||
|
|
ed28c49f48 | ||
|
|
42ba1fefb2 | ||
|
|
988eb0994a | ||
|
|
90a3892271 | ||
|
|
c553663654 | ||
|
|
b6151a4454 | ||
|
|
6f69d4511f | ||
|
|
5e2220fd4d | ||
|
|
4e13a81af0 | ||
|
|
a19500b116 | ||
|
|
85a8c94ac5 | ||
|
|
150983ddd0 | ||
|
|
7911dcf3b9 | ||
|
|
9748f6366c | ||
|
|
ea916c5a31 | ||
|
|
6376d89fea | ||
|
|
5b2a62a10b | ||
|
|
202589cd33 | ||
|
|
0040633d89 | ||
|
|
dd022d89bb | ||
|
|
5b01e0aba7 | ||
|
|
7ce55caffa | ||
|
|
85f1c90bcf | ||
|
|
60cea74703 | ||
|
|
530c602152 | ||
|
|
4577f5bbe2 | ||
|
|
2572482658 | ||
|
|
15a8ca3881 | ||
|
|
7f0069a58c | ||
|
|
cfd86de936 | ||
|
|
9ff88eba50 | ||
|
|
fdbad81da2 | ||
|
|
d1fa3a20ec | ||
|
|
1e81d048b7 | ||
|
|
bedbba70a7 | ||
|
|
4f32829ab0 | ||
|
|
649f5f08e8 | ||
|
|
49d815775f | ||
|
|
68123d30dc | ||
|
|
c3ba4a8b64 | ||
|
|
39300b8513 | ||
|
|
d90db3ed5b | ||
|
|
972e0fd611 | ||
|
|
02260149f1 | ||
|
|
793766265b | ||
|
|
00e0282719 | ||
|
|
6c94ecce64 | ||
|
|
4f8f43a412 | ||
|
|
df84b047b9 | ||
|
|
0d248ba40f | ||
|
|
172d53fde8 | ||
|
|
bf29c32e56 | ||
|
|
da7df32d64 | ||
|
|
0d5bdd81d2 | ||
|
|
bd3764f51b | ||
|
|
8c8490746a | ||
|
|
147cdd7d45 | ||
|
|
8e0ed4f68a | ||
|
|
fd6006bb8b | ||
|
|
dfcd6cdf9e | ||
|
|
a918a20f81 | ||
|
|
2e4b92a2ed | ||
|
|
899a3e5fe8 | ||
|
|
3294dccbc2 | ||
|
|
7a92d97866 | ||
|
|
5fd466abc7 | ||
|
|
035164300c | ||
|
|
c13730d1b5 | ||
|
|
4235833c70 | ||
|
|
453e2d7a53 | ||
|
|
b440187e2d | ||
|
|
60c786b537 | ||
|
|
eace4b2cc9 | ||
|
|
719950614b | ||
|
|
2ec9c9283e | ||
|
|
b4e8760612 | ||
|
|
18fd79aa5d | ||
|
|
4b66e20ad0 | ||
|
|
ec1d1af7bc | ||
|
|
4c36ffdfbc | ||
|
|
250d668d61 | ||
|
|
a4be8cd56a | ||
|
|
f5463fb956 | ||
|
|
42a216a6e5 | ||
|
|
50babb8c2c | ||
|
|
c84fb65538 | ||
|
|
8f8bd0291a | ||
|
|
8ae99df6e1 | ||
|
|
f8fdcf8c46 | ||
|
|
ef20a3fa97 | ||
|
|
3a97e9d91b | ||
|
|
d72cabeb11 | ||
|
|
549677c5c6 | ||
|
|
be0a7ddbec | ||
|
|
5f7fded6de | ||
|
|
69f9f2db32 | ||
|
|
dfeffa75d9 | ||
|
|
5a5f6f780e | ||
|
|
2699ce3833 | ||
|
|
f2c350a4ec | ||
|
|
8a277c0e76 | ||
|
|
3a30addb22 | ||
|
|
980011ce48 | ||
|
|
4708b07343 | ||
|
|
52c964f085 | ||
|
|
fcd3eec73e | ||
|
|
12928dc0e3 | ||
|
|
13c6b5ebb7 | ||
|
|
fd924e47de | ||
|
|
21f44e1cf6 | ||
|
|
f8402e86bf | ||
|
|
3de813f7b0 | ||
|
|
3b9cbc9a53 | ||
|
|
c45a3e5c99 | ||
|
|
6eff71b135 | ||
|
|
0a35e11038 | ||
|
|
23b68a4f50 | ||
|
|
7b76191604 | ||
|
|
77123591e0 | ||
|
|
1753a02cbe | ||
|
|
be80c3e875 | ||
|
|
8397945d0d | ||
|
|
3642d9390b | ||
|
|
807b148be6 | ||
|
|
de90154825 | ||
|
|
ec8f631938 | ||
|
|
29d8b53dc4 | ||
|
|
22bdfd50d1 | ||
|
|
b822be6a52 | ||
|
|
5431ec48ad | ||
|
|
38578894b6 | ||
|
|
43fe97938e | ||
|
|
5080b82fa0 | ||
|
|
faa14bce23 | ||
|
|
db5297f958 | ||
|
|
69a1d2baa5 | ||
|
|
59e8df5e80 | ||
|
|
e2a0e815fe | ||
|
|
7bc7083351 | ||
|
|
a362e26f1a | ||
|
|
cd4782eeda | ||
|
|
2e0ffc0122 | ||
|
|
a7c3565be2 | ||
|
|
af467f29fa | ||
|
|
523d1c464a | ||
|
|
e74368b08e | ||
|
|
9b0630b006 | ||
|
|
2424c56760 | ||
|
|
7cedfbaf22 | ||
|
|
8ed4e233cb | ||
|
|
e6b4175cca | ||
|
|
800c026f59 | ||
|
|
00de295105 | ||
|
|
7400eb2a59 | ||
|
|
326a18a4ff | ||
|
|
a189f15c30 | ||
|
|
c82268d5dd | ||
|
|
1e68553535 | ||
|
|
74221d806e | ||
|
|
f345729b6b | ||
|
|
72cc8c19cd | ||
|
|
339e7cccad | ||
|
|
00da316fe8 | ||
|
|
f4f8fb4269 | ||
|
|
8455a19b0c | ||
|
|
cc9f51d644 | ||
|
|
b88dc05edf | ||
|
|
d4d65f128a | ||
|
|
014935fb03 | ||
|
|
bf98730fae | ||
|
|
e7a844afef | ||
|
|
3ecf6421b1 | ||
|
|
44f2b837b1 | ||
|
|
d293cc6f63 | ||
|
|
1d4757eb18 | ||
|
|
a1317bb3d1 | ||
|
|
2e611a2654 | ||
|
|
a524a6d34b | ||
|
|
dcd3236925 | ||
|
|
7357b4ca57 | ||
|
|
fed932bb4b | ||
|
|
616e586227 | ||
|
|
b3cb3c00d6 | ||
|
|
c017109eab | ||
|
|
0dc7f606ae | ||
|
|
50c1080b7c | ||
|
|
1ad2be3f0b | ||
|
|
17512aca2e | ||
|
|
40cc4c9a45 | ||
|
|
c5d49246fd | ||
|
|
c217f816cf | ||
|
|
16836f2765 | ||
|
|
9be6520bfe | ||
|
|
193bba1ee0 | ||
|
|
5d63692160 | ||
|
|
b8052ba0e6 | ||
|
|
cd7c84a8a2 | ||
|
|
70eb4c4f8b | ||
|
|
a8ede3518f | ||
|
|
5e2421cde6 | ||
|
|
d7882e9453 | ||
|
|
edeac48c40 | ||
|
|
54d8e14f54 | ||
|
|
7d33c747ff | ||
|
|
cd87a8e9a0 | ||
|
|
3ee885df42 | ||
|
|
8a63df2462 | ||
|
|
15b77ac243 | ||
|
|
9ccf9cf928 | ||
|
|
e475ac92ee | ||
|
|
0fc31c62c7 | ||
|
|
93329a5467 | ||
|
|
04becb6b9e | ||
|
|
1824ccec78 | ||
|
|
11bdf4e66a | ||
|
|
adfc1d20fc | ||
|
|
f1065a4a8c | ||
|
|
50993b63f1 | ||
|
|
4108a8b32a | ||
|
|
e6658ff32f | ||
|
|
4bb5103f47 | ||
|
|
e3961d7404 | ||
|
|
b10375824f | ||
|
|
1981efc95b | ||
|
|
7cd2b00cee | ||
|
|
ba334acf7a | ||
|
|
3a387b87c5 | ||
|
|
ca47abfc3a | ||
|
|
dcd8fe0392 | ||
|
|
4e0de5277d | ||
|
|
897a1fe6d8 | ||
|
|
25f215e38d | ||
|
|
52d7c23db7 | ||
|
|
64880b62ab | ||
|
|
494c48bf21 | ||
|
|
ff93e58cfd | ||
|
|
857891f695 | ||
|
|
377153bc52 | ||
|
|
a46f0e76c5 | ||
|
|
51725463d6 | ||
|
|
001c467e36 | ||
|
|
ff33ebe6ab | ||
|
|
97fb2bd711 | ||
|
|
e36ed5a189 | ||
|
|
342709a63e | ||
|
|
e61453ace2 | ||
|
|
d62dd9313a | ||
|
|
eb18c59519 | ||
|
|
5f11053080 | ||
|
|
7caf5f656a | ||
|
|
88efcc7bc2 | ||
|
|
abb4741d1f | ||
|
|
81cc332fd8 | ||
|
|
19b63cdbf6 | ||
|
|
61f5dc9cf9 | ||
|
|
bed8c4ccec | ||
|
|
c74b4e4de6 | ||
|
|
ae36ce1f04 | ||
|
|
490163171a | ||
|
|
afc35f335b | ||
|
|
239bd3b047 | ||
|
|
df9c190248 | ||
|
|
208c55fbfa | ||
|
|
0f32c712c3 | ||
|
|
09947db639 | ||
|
|
12085ab747 | ||
|
|
089bc2e217 | ||
|
|
edca162f8f | ||
|
|
1240cff958 | ||
|
|
e2ac746260 | ||
|
|
463e4408f6 | ||
|
|
3b6c40cc4b | ||
|
|
e679ba206d | ||
|
|
9cea0e8b8d | ||
|
|
6fc134258d | ||
|
|
9be058291f | ||
|
|
0addea441e | ||
|
|
cc741d523f | ||
|
|
9917b46396 | ||
|
|
bc973361e9 | ||
|
|
f0db910b8d | ||
|
|
dab1bb6201 | ||
|
|
f9f5d75183 | ||
|
|
686431af94 | ||
|
|
e0520d6864 | ||
|
|
374f858ead | ||
|
|
e07c86b9a0 | ||
|
|
4efa98c676 | ||
|
|
ff40ff06a3 | ||
|
|
8b90925a69 | ||
|
|
d9e1fcd288 | ||
|
|
0c30654282 | ||
|
|
265094df3b | ||
|
|
b85f411502 | ||
|
|
671046751e | ||
|
|
8e539558f7 | ||
|
|
f81e42eab7 | ||
|
|
6eec9451bf | ||
|
|
5b109de522 | ||
|
|
92a7987f31 | ||
|
|
058beca51b | ||
|
|
ec899d1723 | ||
|
|
9365d21e59 | ||
|
|
e148794d66 | ||
|
|
50da0c19d1 | ||
|
|
16b369575b | ||
|
|
dd8feefb96 | ||
|
|
5818a6c661 | ||
|
|
33c8bc08ac | ||
|
|
4b802feb88 | ||
|
|
4e5b12bf9f | ||
|
|
fda288134c | ||
|
|
9ddb91386a | ||
|
|
3519e638db | ||
|
|
60afc05a15 | ||
|
|
309917507a | ||
|
|
caa46fdcbc | ||
|
|
545199246d | ||
|
|
531e3fd6c6 | ||
|
|
93a2fbec34 | ||
|
|
a2042d46e1 | ||
|
|
3ce54c4f97 | ||
|
|
42393bf5fc | ||
|
|
7e671624b0 | ||
|
|
9953699239 | ||
|
|
3a5dad57ba | ||
|
|
ba7a71bc5f | ||
|
|
40d15fed9c | ||
|
|
fad5176f6a | ||
|
|
28bec598f5 | ||
|
|
200d0d960c | ||
|
|
6e0f2132ca | ||
|
|
19152ccd15 | ||
|
|
741d4a63e0 | ||
|
|
badd66c4e8 | ||
|
|
fbb0717cb4 | ||
|
|
414ba0c6a6 | ||
|
|
c4054fa86d | ||
|
|
5bfec6e426 | ||
|
|
075639996e | ||
|
|
d90b7ae20e | ||
|
|
b200e9a3f7 | ||
|
|
e6c48839a7 | ||
|
|
53a86b6164 | ||
|
|
1988a69d1b | ||
|
|
3b97bb7360 | ||
|
|
09800c9be6 | ||
|
|
c8e0a72be6 | ||
|
|
002a83fa65 | ||
|
|
9ad84e3ae6 | ||
|
|
ad50b9c3ef | ||
|
|
d98a6ef660 | ||
|
|
c83a8cea2f | ||
|
|
08966e9bed | ||
|
|
6bf0e37282 | ||
|
|
ea1d6136be | ||
|
|
ff3e85c6a6 | ||
|
|
38a6fdb457 | ||
|
|
dfd1b1da19 | ||
|
|
aecc0264b0 | ||
|
|
a35849d4f8 | ||
|
|
4c3a2d57ee | ||
|
|
48c99ca320 | ||
|
|
83d1a9e455 | ||
|
|
11649c9e79 | ||
|
|
b31de5ca61 | ||
|
|
18544a5d54 | ||
|
|
83d2092fec | ||
|
|
099d8f759b | ||
|
|
e99fa71ed6 | ||
|
|
6c9c5af373 | ||
|
|
509eeea1d4 | ||
|
|
c00a61f45e | ||
|
|
0ea24655eb | ||
|
|
288cd9a8b0 | ||
|
|
2c00d2bc05 | ||
|
|
5cf899c88c | ||
|
|
16fa1bcc26 | ||
|
|
a67ef25c15 | ||
|
|
c82738633c | ||
|
|
11e419595b | ||
|
|
1db99a10dc | ||
|
|
f51f3276b5 | ||
|
|
199bc43c5c | ||
|
|
16afc9c1d4 | ||
|
|
76156b3f3b | ||
|
|
5a1e8c1a3f | ||
|
|
2d0f0734c4 | ||
|
|
42b97175e3 | ||
|
|
eec091af23 | ||
|
|
6f4f870800 | ||
|
|
3a5a59ae64 | ||
|
|
e8484fa1f3 | ||
|
|
55ccb8cd87 | ||
|
|
09edf341ab | ||
|
|
8380e4f07e | ||
|
|
4a07466467 | ||
|
|
cbdc86ac8f | ||
|
|
7ea39fa7b5 | ||
|
|
7eed2a33dc | ||
|
|
95bb7b9a85 | ||
|
|
2762fb624c | ||
|
|
3924a57168 | ||
|
|
8750b3331c | ||
|
|
be9415fef1 | ||
|
|
df13a69e26 | ||
|
|
6b60780d6c | ||
|
|
9786aa9446 | ||
|
|
71ca2614d2 | ||
|
|
3d171a937e | ||
|
|
2663a1b222 | ||
|
|
51ebcc40f0 | ||
|
|
95c45b0b32 | ||
|
|
a3c5be3242 | ||
|
|
c5c920f341 | ||
|
|
2e2951b35b | ||
|
|
4b741e767a | ||
|
|
66adbd4abb | ||
|
|
c9e4e7aea1 | ||
|
|
56a441f700 | ||
|
|
f9951bac77 | ||
|
|
d7a66ce20f | ||
|
|
73d9ce7f44 | ||
|
|
14ff1b9fec | ||
|
|
9a9c5998d8 | ||
|
|
edb9dcb626 | ||
|
|
89559b97af | ||
|
|
cc1d9ac16d | ||
|
|
1d9454c61b | ||
|
|
8409073e7a | ||
|
|
6d419f32bc | ||
|
|
9d68054e9a | ||
|
|
5dcfb8742f | ||
|
|
7e6f70b027 | ||
|
|
3e10aa85d0 | ||
|
|
16b9f6767e | ||
|
|
d8a9727ff5 | ||
|
|
016a899e60 | ||
|
|
68b39a83e0 | ||
|
|
93dabff000 | ||
|
|
630be48010 | ||
|
|
ea78d291ce | ||
|
|
2074be2939 | ||
|
|
7946c17c00 | ||
|
|
a0295d6a2f | ||
|
|
d70068b1a0 | ||
|
|
89baba07f5 | ||
|
|
95995500bc | ||
|
|
b1183e8a93 | ||
|
|
101f722110 | ||
|
|
3f390120c1 | ||
|
|
7baa8a3141 | ||
|
|
6435581674 | ||
|
|
7eb730c0ef | ||
|
|
f6a7aeb130 | ||
|
|
29296a9dc2 | ||
|
|
4d4a2f0ee1 | ||
|
|
e4e398bfcf | ||
|
|
8bd2c26a8f | ||
|
|
bd8f30d67d | ||
|
|
a3a429b2b9 | ||
|
|
4f7ce8a1c8 | ||
|
|
d2ecf22edd | ||
|
|
3963172e49 | ||
|
|
80c6b0bde4 | ||
|
|
4f59df38f3 | ||
|
|
1a3e6c9d5f | ||
|
|
0635ec9e01 | ||
|
|
3fff60a64a | ||
|
|
f05f0469cc | ||
|
|
00337a756b | ||
|
|
d3473b5d68 | ||
|
|
889d442288 | ||
|
|
b2c04c9058 | ||
|
|
0889ebed7d | ||
|
|
d1430e79e2 | ||
|
|
31eca367ab | ||
|
|
bdd353d0fd | ||
|
|
4deb0592f6 | ||
|
|
d8877072d4 | ||
|
|
89a8dc7de1 | ||
|
|
704c346f5e | ||
|
|
65061c5cd9 | ||
|
|
32233918b4 | ||
|
|
76169da60f | ||
|
|
9bcf1413f0 | ||
|
|
8e428e875f | ||
|
|
2fbecc25e5 | ||
|
|
02e77271d0 | ||
|
|
3d61888e58 | ||
|
|
efe75f491b | ||
|
|
000c139004 | ||
|
|
c39b84f0a9 | ||
|
|
ee958addaa | ||
|
|
8fe53351f8 | ||
|
|
bc9d860d83 | ||
|
|
332a23e543 | ||
|
|
b20713b50f | ||
|
|
94e96581ef | ||
|
|
61be6d0b6a | ||
|
|
be22805a7d | ||
|
|
25f6253286 | ||
|
|
f3e9fb6ea9 | ||
|
|
0f3dda02c4 | ||
|
|
a2873b5465 | ||
|
|
202d45e5ab | ||
|
|
f5e4612c03 | ||
|
|
de888c501c | ||
|
|
47bc83c248 | ||
|
|
1cf637c569 | ||
|
|
ae2571e241 | ||
|
|
86f5e9b8b2 | ||
|
|
ce61ff085a | ||
|
|
b816e154cf | ||
|
|
ac1238c337 | ||
|
|
10dbf2c0ef | ||
|
|
cf68f8639c | ||
|
|
b114c11b0f | ||
|
|
42b7a8152b | ||
|
|
f7821c7fb3 | ||
|
|
42478905d0 | ||
|
|
8d55bd3b63 | ||
|
|
16247fc3a3 | ||
|
|
b56b80cd30 | ||
|
|
ba25e0e583 | ||
|
|
8f1fe355fc | ||
|
|
649d9bf0ef | ||
|
|
a5108a69aa | ||
|
|
c74a496299 | ||
|
|
b74c274466 | ||
|
|
56b3867e57 | ||
|
|
66d237bfbc | ||
|
|
68071b214e | ||
|
|
a82ca5576b | ||
|
|
81dd386b4e | ||
|
|
b61a74396d | ||
|
|
105fbc522a | ||
|
|
2b52c39242 | ||
|
|
25d39bca09 | ||
|
|
644226321e | ||
|
|
3928dda541 | ||
|
|
d9a7f413a6 | ||
|
|
eb300bb634 | ||
|
|
c30fd9e7be | ||
|
|
2fc969b848 | ||
|
|
47884088ec | ||
|
|
70e1212396 | ||
|
|
d947c2abcc | ||
|
|
e2efb64aea | ||
|
|
01279d45e3 | ||
|
|
f2a7cad218 | ||
|
|
98163341d3 | ||
|
|
a71d361d15 | ||
|
|
cf24ad835d | ||
|
|
0c04088b16 | ||
|
|
22489ad095 | ||
|
|
1fa3da45fa | ||
|
|
d497df3767 | ||
|
|
bdd3c5c191 | ||
|
|
5b41db2246 | ||
|
|
1eb35774a5 | ||
|
|
7d7036fae9 | ||
|
|
1a4bf13819 | ||
|
|
3e225bf51f | ||
|
|
d6837cbe7d | ||
|
|
40994e0464 | ||
|
|
4e18ad189d | ||
|
|
a0ed07913e | ||
|
|
3d9ca62031 | ||
|
|
e0f4520437 | ||
|
|
3a5ebd184d | ||
|
|
a7d19c15f1 | ||
|
|
9822d92bed | ||
|
|
4d8b7750ae | ||
|
|
3bcf783020 | ||
|
|
1b3a7bb5de | ||
|
|
0d5ac98209 | ||
|
|
e2472e509b | ||
|
|
08388496bc | ||
|
|
f4eab023e8 | ||
|
|
c5e854f323 | ||
|
|
e7b9439161 | ||
|
|
555ca2b830 | ||
|
|
271f21f203 | ||
|
|
de54fcbd11 | ||
|
|
46cad395e1 | ||
|
|
3466947f69 | ||
|
|
8696f80525 | ||
|
|
51527d3e6c | ||
|
|
c8a9189bf6 | ||
|
|
896b0983e4 | ||
|
|
d55b124251 | ||
|
|
72b6e22d90 | ||
|
|
3272bc1f8b | ||
|
|
1e754703e8 | ||
|
|
119424bb9b | ||
|
|
d8926ebea5 | ||
|
|
6b1a0a7ea8 | ||
|
|
c92d01b957 | ||
|
|
d6c6d8e529 | ||
|
|
1d086b2d5c | ||
|
|
e5d967dcf4 | ||
|
|
21a5533936 | ||
|
|
d21fec6c7d | ||
|
|
9c4ae86937 | ||
|
|
9c67660b94 | ||
|
|
96d020b6ad | ||
|
|
bc42c3e2c3 | ||
|
|
ea45dafcf3 | ||
|
|
3e3fd21c16 | ||
|
|
70b83664a9 | ||
|
|
9650a00157 | ||
|
|
f851ddbcaf | ||
|
|
a5ee4d969b | ||
|
|
2dfd46decb | ||
|
|
6752dc0fdd | ||
|
|
c7174d3c94 | ||
|
|
d33ba7e22e | ||
|
|
9b552a4d29 | ||
|
|
5fc04a24f8 | ||
|
|
122b18a81d | ||
|
|
c9bc005abe | ||
|
|
ee0f0668ab | ||
|
|
826bb6fc8a | ||
|
|
b9aa99a208 | ||
|
|
2b0aa825b5 | ||
|
|
24b1524aee | ||
|
|
cf7c7f2f25 | ||
|
|
c082688e67 | ||
|
|
18bfe6a8d2 | ||
|
|
c367434798 | ||
|
|
f1ca914e92 | ||
|
|
2763c7e417 | ||
|
|
f9f0004d3f | ||
|
|
958eaefb40 | ||
|
|
3752695000 | ||
|
|
d6eb7c4f26 | ||
|
|
b88769e109 | ||
|
|
eddbcf0801 | ||
|
|
886f183108 | ||
|
|
aba1d53699 | ||
|
|
d05530e030 | ||
|
|
7d52dbbbd5 | ||
|
|
53185bd58d | ||
|
|
8997c4b1a8 | ||
|
|
ce4665bbec | ||
|
|
e1ecca42c5 | ||
|
|
c6cb548763 | ||
|
|
b52c90f865 | ||
|
|
2ceba7f5ad | ||
|
|
c3121a97f1 | ||
|
|
b7cc63bfd6 | ||
|
|
3a4d859457 | ||
|
|
964b248108 | ||
|
|
b6e87418a2 | ||
|
|
473fd6f4f8 | ||
|
|
c49afdb44d | ||
|
|
f0182fcb0c | ||
|
|
7424df98a5 | ||
|
|
b4c4cfd365 | ||
|
|
7878be3847 | ||
|
|
1dc75f9f21 | ||
|
|
cbe4cd150e | ||
|
|
99e1fb3fc6 | ||
|
|
e1481db95c | ||
|
|
efc7ba5646 | ||
|
|
90552b73dc | ||
|
|
81c0b4faf5 | ||
|
|
95be514b8f | ||
|
|
d289efc862 | ||
|
|
e388b2d1c9 | ||
|
|
7ca0d79250 | ||
|
|
20b375770c | ||
|
|
6b3ada8402 | ||
|
|
c12bb65b4b | ||
|
|
2e0dddba60 | ||
|
|
8e6767c606 | ||
|
|
419051c762 | ||
|
|
9e432d1178 | ||
|
|
0832a7227c | ||
|
|
3b16caa38c | ||
|
|
1d4e57a754 | ||
|
|
c36585ce98 | ||
|
|
af1280be1a | ||
|
|
c01fb8fca7 | ||
|
|
1d6d860153 | ||
|
|
2136e69a82 | ||
|
|
cb438d289b | ||
|
|
ded08ddf5c | ||
|
|
79b24875ec | ||
|
|
b423cbdb89 | ||
|
|
600c1db24b | ||
|
|
b2979be25c | ||
|
|
462b511b9f | ||
|
|
19e660d4a6 | ||
|
|
cfa11d1a88 | ||
|
|
bf283f1252 | ||
|
|
839b860eee | ||
|
|
e134e5682e | ||
|
|
ebc4cdccb9 | ||
|
|
baed70dbfc | ||
|
|
b8aff1d348 | ||
|
|
aa5f0cc267 | ||
|
|
316c3bbdd4 | ||
|
|
dbc02485d9 | ||
|
|
6442ce26fc | ||
|
|
ccd1137606 | ||
|
|
96d79774b5 | ||
|
|
829a1fced6 | ||
|
|
0c5dd3c890 | ||
|
|
51aad755c3 | ||
|
|
635950e329 | ||
|
|
7eb93bec0b | ||
|
|
cf8384fb1f | ||
|
|
6da951056c | ||
|
|
a50e476d7e | ||
|
|
329e0940da | ||
|
|
7a43646e62 | ||
|
|
dd8f2d13d2 | ||
|
|
f119c890a1 | ||
|
|
714b80225f | ||
|
|
49297980b0 | ||
|
|
e7beb9c9c3 | ||
|
|
5e66a62c32 | ||
|
|
fbbf83d9d0 | ||
|
|
7e244084e1 | ||
|
|
089ad123b6 | ||
|
|
fdc50efc86 | ||
|
|
7644cb720e | ||
|
|
299c9e1ebb | ||
|
|
b82b733dde | ||
|
|
e339d12b40 | ||
|
|
3ea17b134d | ||
|
|
ea410e7490 | ||
|
|
430efae598 | ||
|
|
c96cdb6cd2 | ||
|
|
ffe2e9c808 | ||
|
|
545f9a4a47 | ||
|
|
2555dd8101 | ||
|
|
f7b4ab5b42 | ||
|
|
4c70d87381 | ||
|
|
894e56458f | ||
|
|
de5659e7b2 | ||
|
|
e4b0b2a71f | ||
|
|
dd06c3fb82 | ||
|
|
8ee952cf3e | ||
|
|
57bcea9646 | ||
|
|
907696a4a3 | ||
|
|
78cdf8d5b6 | ||
|
|
5a7edbe825 | ||
|
|
de8300a40a | ||
|
|
73e6ade0ea | ||
|
|
4def2c784e | ||
|
|
1c8ce223d7 | ||
|
|
02d00bf99c | ||
|
|
4433243fa6 | ||
|
|
a8ae976bb2 | ||
|
|
1559b21cfb | ||
|
|
f55aed4382 | ||
|
|
cc12fcb0f6 | ||
|
|
95dfc47e47 | ||
|
|
0bf38f57f7 | ||
|
|
7f4116417c | ||
|
|
5763400c15 | ||
|
|
6b136efff3 | ||
|
|
1f089fe71c | ||
|
|
1bc26b5827 | ||
|
|
40674a12b6 | ||
|
|
ef2f6ebaa0 | ||
|
|
625030dfb6 | ||
|
|
779fc5641f | ||
|
|
971e794639 | ||
|
|
89c6002f92 | ||
|
|
6b0e45da96 | ||
|
|
7bde7a4845 | ||
|
|
eda0bd69b3 | ||
|
|
2a56bdb0c8 | ||
|
|
de0375610d | ||
|
|
382024104a | ||
|
|
e896b55c25 | ||
|
|
53ce87755a | ||
|
|
750f78164f | ||
|
|
bf6cb8e39e | ||
|
|
f58e110057 | ||
|
|
98db46c817 | ||
|
|
da9b440f1e | ||
|
|
a18039373d | ||
|
|
a9753fafd2 | ||
|
|
8aa0a6b9e0 | ||
|
|
8f16021e4c | ||
|
|
456d52d507 | ||
|
|
4da24fb64f | ||
|
|
c27044b521 | ||
|
|
810cf89f97 | ||
|
|
d65af2c84f | ||
|
|
d30fda1d9d | ||
|
|
b6d4aa1632 | ||
|
|
b024d04d91 | ||
|
|
6cdc7a86c4 | ||
|
|
c6f1c740fd | ||
|
|
95c43472e9 | ||
|
|
c8a222e244 | ||
|
|
37abb44a64 | ||
|
|
b4e6725a1a | ||
|
|
b6410d9b4d | ||
|
|
4bdfeffc96 | ||
|
|
a32804dfee | ||
|
|
b24ce18d55 | ||
|
|
5512a08aac | ||
|
|
f438367d6e | ||
|
|
62a724122c | ||
|
|
fb86cb8687 | ||
|
|
29a0dc6c07 | ||
|
|
82f3c6a491 | ||
|
|
fbb2746d07 | ||
|
|
185d8bc484 | ||
|
|
db88185c40 | ||
|
|
611cb0288f | ||
|
|
b18bdd0fb9 | ||
|
|
8ef0d459c0 | ||
|
|
41bc78bc8f | ||
|
|
05984b9860 | ||
|
|
82b1e8536b | ||
|
|
425cb92dee | ||
|
|
9470edbea9 | ||
|
|
a5d6846608 | ||
|
|
87fd49ac98 | ||
|
|
f8af9c37af |
13
.github/suggestion-comment.md
vendored
Normal file
13
.github/suggestion-comment.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
I'll help you add a suggestion. Unfortunately, I can't directly add a suggestion to an existing comment through the API. However, here's what I recommend:
|
||||
|
||||
**Option 1: Reply with a suggestion**
|
||||
Create a new comment with a suggested fix:
|
||||
|
||||
```suggestion
|
||||
failed to get an IPv6 address after %d attempt(s), giving up, error: %s
|
||||
```
|
||||
|
||||
**Option 2: Edit your existing comment**
|
||||
Update your comment to include the suggestion details pointing out that line 284 in the error message says "IPv4" but should say "IPv6" since this is the DHCPv6Client.
|
||||
|
||||
Would you like me to create a new reply comment with the suggestion instead?
|
||||
4
.github/workflows/anchore-syft.yml
vendored
4
.github/workflows/anchore-syft.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
- name: Anchore SBOM Action
|
||||
uses: anchore/sbom-action@v0.16.0
|
||||
uses: anchore/sbom-action@v0.24.2
|
||||
with:
|
||||
format: cyclonedx-json
|
||||
|
||||
88
.github/workflows/ci-pull-request.yaml
vendored
Normal file
88
.github/workflows/ci-pull-request.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
name: For each PR
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: E2E tests
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
GINKGO_PROCS: ${{ matrix.ginkgo-procs }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- mode: arp
|
||||
ginkgo-procs: 4
|
||||
- mode: rt
|
||||
ginkgo-procs: 4
|
||||
- mode: bgp
|
||||
ginkgo-procs: 4
|
||||
steps:
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d-%H-%M')" >> "$GITHUB_OUTPUT"
|
||||
- name: Ensure fs wont cause issues
|
||||
run: sudo sysctl fs.inotify.max_user_instances=8192 && sudo sysctl fs.inotify.max_user_watches=524288
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build image locally
|
||||
run: make dockerx86Local
|
||||
- name: Run Manifest generation tests
|
||||
run: make manifest-test
|
||||
if: matrix.mode == 'arp'
|
||||
- name: Run ARP mode tests
|
||||
run: DOCKER_API_VERSION=1.48 E2E_KEEP_LOGS=true GINKGO_ARGS="--output-dir=/tmp --json-report=kube-vip-test-report-arp.json" make e2e-tests-arp
|
||||
if: matrix.mode == 'arp'
|
||||
- name: Run RT mode tests
|
||||
run: DOCKER_API_VERSION=1.48 E2E_KEEP_LOGS=true GINKGO_ARGS="--output-dir=/tmp --json-report=kube-vip-test-report-rt.json" make e2e-tests-rt
|
||||
if: matrix.mode == 'rt'
|
||||
- name: Get GoBGP binaries
|
||||
run: make get-gobgp
|
||||
if: matrix.mode == 'bgp'
|
||||
- name: Run BGP mode tests
|
||||
run: sudo -E PATH=$PATH DOCKER_API_VERSION=1.48 E2E_KEEP_LOGS=true GINKGO_ARGS="--output-dir=/tmp --json-report=kube-vip-test-report-bgp.json" make e2e-tests-bgp
|
||||
if: matrix.mode == 'bgp'
|
||||
- name: Change log directory permissions
|
||||
run: sudo chmod -R 755 /tmp/kube-vip-test*
|
||||
if: matrix.mode == 'bgp' && always()
|
||||
- name: Save logs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-test-logs-${{ matrix.mode }}-${{ steps.date.outputs.date }}
|
||||
path: /tmp/kube-vip-test*
|
||||
if: always()
|
||||
service-e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: E2E service tests
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d-%H-%M')" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build image with iptables
|
||||
run: make dockerx86ActionIPTables
|
||||
- name: Run tests
|
||||
run: DOCKER_API_VERSION=1.48 DOCKERTAG=action E2E_KEEP_LOGS=true make service-tests
|
||||
- name: Save logs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: services-test-logs-${{ steps.date.outputs.date }}
|
||||
path: /tmp/kube-vip-services*
|
||||
if: always()
|
||||
99
.github/workflows/ci.yaml
vendored
99
.github/workflows/ci.yaml
vendored
@@ -1,99 +1,88 @@
|
||||
name: For each commit and PR
|
||||
name: For each commit
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
env:
|
||||
GO_VERSION: "1.21"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validation:
|
||||
runs-on: ubuntu-latest
|
||||
name: Checks and linters
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Init
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential golint && sudo sysctl fs.inotify.max_user_instances=8192 && sudo sysctl fs.inotify.max_user_watches=524288
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential && sudo sysctl fs.inotify.max_user_instances=8192 && sudo sysctl fs.inotify.max_user_watches=524288
|
||||
- name: Install golangci-lint
|
||||
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.12
|
||||
install-only: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Verify gofmt
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "The following files are not gofmt-formatted:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
- name: All checks
|
||||
run: make check
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit tests
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run tests
|
||||
run: make unit-tests
|
||||
- name: Run privileged network tests
|
||||
run: |
|
||||
sudo -E env PATH="$PATH" KUBE_VIP_REQUIRE_NETNS=1 go test -race ./pkg/services ./pkg/vip ./pkg/instance \
|
||||
-run 'TestRecover|TestServiceAddressRetained|TestRetainControlPlaneVIPs|TestAddressProtocol|TestKubeVIPAddressProtocol|TestCleanupKubeVIPAddresses|TestMonitorDefaultInterfaceReturnsErrorWhenTestLinkIsSetDown|TestCleanupLinkAttachmentsOnlyDeletesOwnedVLAN'
|
||||
integration-tests:
|
||||
name: Integration tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run tests
|
||||
run: make integration-tests
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: E2E ARP tests
|
||||
steps:
|
||||
- name: Ensure fs wont cause issues
|
||||
run: sudo sysctl fs.inotify.max_user_instances=8192 && sudo sysctl fs.inotify.max_user_watches=524288
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build image locally
|
||||
run: make dockerx86Local
|
||||
- name: Run Manifest generation tests
|
||||
run: make manifest-test
|
||||
- name: Run Control plane tests
|
||||
run: make e2e-tests
|
||||
- name: Run Control plane tests v1.29.0 onwards
|
||||
run: make e2e-tests129
|
||||
service-e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: E2E service tests
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build image with iptables
|
||||
run: make dockerx86ActionIPTables
|
||||
- name: Run tests
|
||||
run: DOCKERTAG=action make service-tests
|
||||
run: make integration-tests
|
||||
image-vul-check:
|
||||
runs-on: ubuntu-latest
|
||||
name: Image vulnerability scan
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Build image with iptables
|
||||
run: make dockerx86ActionIPTables
|
||||
uses: actions/checkout@v7
|
||||
- name: Build image
|
||||
run: make dockerx86Action
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: 'plndr/kube-vip:action'
|
||||
format: 'table'
|
||||
exit-code: '1'
|
||||
image-ref: "plndr/kube-vip:action"
|
||||
format: "table"
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
vuln-type: 'os,library'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
vuln-type: "os,library"
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
10
.github/workflows/codeql-analysis.yml
vendored
10
.github/workflows/codeql-analysis.yml
vendored
@@ -38,16 +38,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -72,4 +72,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
14
.github/workflows/main.yaml
vendored
14
.github/workflows/main.yaml
vendored
@@ -11,25 +11,25 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build standard version
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
ghcr.io/kube-vip/kube-vip:${{ github.ref_name }}
|
||||
- name: Build iptables version
|
||||
id: docker_build_iptables
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile_iptables
|
||||
|
||||
111
.github/workflows/nightly-e2e.yaml
vendored
Normal file
111
.github/workflows/nightly-e2e.yaml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Nightly e2e
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit tests with coverage
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Run tests
|
||||
run: make unit-tests
|
||||
- name: Summarize coverage
|
||||
if: always()
|
||||
run: |
|
||||
if test -f coverage.out; then
|
||||
echo "### Unit coverage" >> "$GITHUB_STEP_SUMMARY"
|
||||
go tool cover -func=coverage.out | tail -1 >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "### Unit coverage: report missing" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unit-coverage
|
||||
path: coverage.out
|
||||
if-no-files-found: error
|
||||
if: always()
|
||||
etcd-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
name: Etcd E2E tests
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Ensure fs wont cause issues
|
||||
run: sudo sysctl fs.inotify.max_user_instances=8192 && sudo sysctl fs.inotify.max_user_watches=524288
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build image locally
|
||||
run: make dockerx86Local
|
||||
- name: Prepare Etcd artifacts
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p /tmp/kube-vip-etcd-artifacts
|
||||
: > /tmp/kube-vip-etcd-artifacts/suite.log
|
||||
printf '[]\n' > /tmp/kube-vip-etcd-artifacts/report.json
|
||||
- name: Run Etcd tests
|
||||
id: etcd
|
||||
# Scheduled failures are tolerated only during the initial stabilization window.
|
||||
# The enforcement step below makes manual runs and later schedules blocking.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
set -o pipefail
|
||||
DOCKER_API_VERSION=1.48 E2E_KEEP_LOGS=true \
|
||||
GINKGO_ARGS="--json-report=report.json --output-dir=/tmp/kube-vip-etcd-artifacts" \
|
||||
make e2e-tests-etcd 2>&1 | tee /tmp/kube-vip-etcd-artifacts/suite.log
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
|
||||
exit "$exit_code"
|
||||
- name: Summarize Etcd suite
|
||||
if: always()
|
||||
env:
|
||||
OUTCOME: ${{ steps.etcd.outcome }}
|
||||
EXIT_CODE: ${{ steps.etcd.outputs.exit_code }}
|
||||
run: |
|
||||
echo "### Etcd E2E result: ${OUTCOME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
printf '{"outcome":"%s","exit_code":%s,"event":"%s","cutoff":"2026-10-01"}\n' \
|
||||
"${OUTCOME:-skipped}" "${EXIT_CODE:-null}" "$GITHUB_EVENT_NAME" \
|
||||
> /tmp/kube-vip-etcd-artifacts/result.json
|
||||
- name: Save logs
|
||||
uses: actions/upload-artifact@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: etcd-e2e-logs
|
||||
path: |
|
||||
/tmp/kube-vip-etcd-artifacts
|
||||
/tmp/kube-vip-test*
|
||||
if-no-files-found: warn
|
||||
if: always()
|
||||
- name: Enforce Etcd result
|
||||
if: always()
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
OUTCOME: ${{ steps.etcd.outcome }}
|
||||
run: |
|
||||
if test "$OUTCOME" = success; then
|
||||
exit 0
|
||||
fi
|
||||
if test "$EVENT_NAME" = schedule && test "$(date -u +%Y-%m-%d)" \< 2026-10-01; then
|
||||
echo "::warning::etcd e2e suite outcome was ${OUTCOME:-skipped} during stabilization through 2026-09-30"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::etcd e2e suite outcome was ${OUTCOME:-skipped}; see the etcd-e2e-logs artifact"
|
||||
exit 1
|
||||
22
.github/workflows/release.yaml
vendored
22
.github/workflows/release.yaml
vendored
@@ -11,29 +11,36 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Generate Metadata
|
||||
uses: docker/metadata-action@v6.2.0
|
||||
id: metadata
|
||||
with:
|
||||
labels: |
|
||||
org.opencontainers.image.documentation=https://kube-vip.io/docs/
|
||||
- name: Build and push main branch
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
tags: >-
|
||||
plndr/kube-vip:${{ github.ref_name }},
|
||||
plndr/kube-vip:latest,
|
||||
@@ -41,12 +48,13 @@ jobs:
|
||||
ghcr.io/kube-vip/kube-vip:latest
|
||||
- name: Build iptables version and push main branch
|
||||
id: docker_build_iptables
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile_iptables
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
tags: >-
|
||||
plndr/kube-vip-iptables:${{ github.ref_name }},
|
||||
plndr/kube-vip-iptables:latest,
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,5 +3,7 @@ kube-vip
|
||||
.vscode
|
||||
bin
|
||||
testing/e2e/etcd/certs
|
||||
coverage.out
|
||||
pkg/etcd/etcd.pid
|
||||
pkg/etcd/etcd-data
|
||||
testing/e2e/e2e.test
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
run:
|
||||
timeout: 10m
|
||||
|
||||
version: "2"
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- gofmt
|
||||
- goimports
|
||||
- revive
|
||||
- gosec
|
||||
- misspell
|
||||
- unconvert
|
||||
- unparam
|
||||
- bodyclose
|
||||
- gosec
|
||||
- misspell
|
||||
- unconvert
|
||||
- unparam
|
||||
settings:
|
||||
misspell:
|
||||
ignore-rules:
|
||||
- creater
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
# Disable QF1008 to retain embedded fields for better readability.
|
||||
- "-QF1008"
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
62
CHANGELOG.md
Normal file
62
CHANGELOG.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Propagate `bgp_attach_ip_to_interface` into per-service config so it attaches BGP-mode Service VIPs to the interface as configured.
|
||||
- Add a configurable kube-vip instance name and use it to isolate internal nftables egress tables, persist table ownership on Services, and migrate per-Service chains without affecting other deployments. Fixes #1634.
|
||||
- Retry on 403 Forbidden and 401 Unauthorized in `ServicesWatcher` at startup with exponential backoff. Fixes #1464.
|
||||
- Reintroduce BGP config via node annotations. Fixes #1488.
|
||||
- Fail fast in runtime `manager` and `service` paths when legacy `vip_address` is used without `vip_subnet` in control-plane ARP, BGP, or Routing Table mode.
|
||||
- Cancel the mode context on init or configuration failure before waiting on goroutines during shutdown.
|
||||
|
||||
|
||||
### Added
|
||||
- Configurable control-plane health check for BGP mode without leader election
|
||||
- Polls a configurable HTTP(S) endpoint (e.g. `https://localhost:6443/livez`) to verify the exposed service is healthy (usually the local kube-apiserver)
|
||||
- Withdraws the BGP route after a configurable number of consecutive failures, removing the unhealthy node from the ECMP set
|
||||
- Re-announces the route automatically once the endpoint recovers
|
||||
- Gracefully withdraws the route on shutdown (SIGTERM)
|
||||
- Supports custom CA certificates for TLS verification
|
||||
- Configuration via environment variables or CLI flags:
|
||||
- `control_plane_health_check_address` / `--controlPlaneHealthCheckAddress`: URL to poll
|
||||
- `control_plane_health_check_period_seconds` / `--controlPlaneHealthCheckPeriodSeconds`: interval between checks (default: 5)
|
||||
- `control_plane_health_check_timeout_seconds` / `--controlPlaneHealthCheckTimeoutSeconds`: per-request timeout (default: 3)
|
||||
- `control_plane_health_check_failure_threshold` / `--controlPlaneHealthCheckFailureThreshold`: consecutive failures before withdrawal (default: 3)
|
||||
- `control_plane_health_check_ca_path` / `--controlPlaneHealthCheckCAPath`: CA cert for HTTPS verification
|
||||
- SIGUSR1 signal handler for runtime configuration dumps (#1301)
|
||||
- Send SIGUSR1 to kube-vip process to dump current configuration to stdout
|
||||
- Configuration dump includes:
|
||||
- Basic configuration (VIP, interface, port, namespace settings)
|
||||
- BGP configuration (enabled status, AS number, router ID, peers)
|
||||
- ARP/NDP configuration (enabled status, broadcast rate)
|
||||
- Services configuration (enabled status, load balancer settings)
|
||||
- Network interfaces status
|
||||
- Leader election configuration (type, lease details)
|
||||
- Runtime statistics (load balancer, Prometheus, health check settings)
|
||||
- Output format: Human-readable plaintext via fmt.Printf()
|
||||
- Thread-safe implementation using mutex protection
|
||||
- Non-disruptive: Process continues running after configuration dump
|
||||
- Added comprehensive unit tests for all dump methods
|
||||
- Added E2E tests for signal handling
|
||||
- Opt-in support for endpointless `LoadBalancer` services with `externalTrafficPolicy: Cluster`
|
||||
- Annotation: `kube-vip.io/allow-reconcile-without-endpoints: "true"`
|
||||
- Starts service handling path for opted-in endpointless Cluster services while preserving default endpoint-gated behavior for non-opt-in services and `Local` policy
|
||||
- Added endpoint behavior tests and README usage documentation
|
||||
- Added support in ipoib interfaces in ARP mode. Fixes #694
|
||||
|
||||
### Changed
|
||||
- BGP mode now honours `enable_leader_election` for services: a single global services leader advertises the service VIPs instead of every node advertising them. Deployments that enabled `enable_leader_election` for the control plane and relied on ECMP/multipath for services must unset it (or switch to `enable_service_election`) to keep the previous datapath. kube-vip logs a warning on startup when this path is taken.
|
||||
- Updated signal handlers in manager_arp.go, manager_bgp.go, manager_wireguard.go, and manager_table.go to use switch statement pattern for handling multiple signals (SIGUSR1, SIGINT, SIGTERM)
|
||||
- wireguard.go now manages a complete wireguard interface on the current network namespace
|
||||
- manager_wireguard.go uses the new wireguard.go implementation
|
||||
|
||||
## [v1.0.1] - Previous Release
|
||||
|
||||
### Previous changes
|
||||
- See git history for changes prior to CHANGELOG.md introduction
|
||||
@@ -113,8 +113,8 @@ and *merged* sorts of commits.
|
||||
To make it easier for reviewers to review your PR, consider the following:
|
||||
|
||||
1. Follow the golang [coding conventions](https://github.com/golang/go/wiki/CodeReviewComments).
|
||||
2. Format your code with `make golangci-fix`; if the [linters](ci/README.md) flag an issue that
|
||||
cannot be fixed automatically, an error message will be displayed so you can address the issue.
|
||||
2. Format your code with `make simplify` to automatically fix formatting issues.
|
||||
2. Lint your code with `make check`; if the linters flag an issue that cannot be fixed automatically, an error message will be displayed so you can address the issue.
|
||||
3. Follow [git commit](https://chris.beams.io/posts/git-commit/) guidelines.
|
||||
4. Follow [logging](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md) guidelines.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:experimental
|
||||
|
||||
FROM golang:1.22.4-alpine3.20 as dev
|
||||
FROM golang:1.27.1-alpine3.23 as dev
|
||||
RUN apk add --no-cache git ca-certificates make
|
||||
RUN adduser -D appuser
|
||||
COPY . /src/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:experimental
|
||||
|
||||
FROM golang:1.22.4-alpine3.20 as dev
|
||||
FROM golang:1.27.1-alpine3.23 as dev
|
||||
RUN apk add --no-cache git make
|
||||
RUN adduser -D appuser
|
||||
COPY . /src/
|
||||
@@ -11,7 +11,7 @@ RUN --mount=type=cache,sharing=locked,id=gomod,target=/go/pkg/mod/cache \
|
||||
--mount=type=cache,sharing=locked,id=goroot,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux make build
|
||||
|
||||
FROM alpine:3.20.0
|
||||
FROM alpine:3.24.1
|
||||
# Update pkgs and add iptables
|
||||
RUN apk upgrade && \
|
||||
apk add --no-cache iptables iptables-legacy
|
||||
|
||||
100
Makefile
100
Makefile
@@ -5,7 +5,7 @@ TARGET := kube-vip
|
||||
.DEFAULT_GOAL := $(TARGET)
|
||||
|
||||
# These will be provided to the target
|
||||
VERSION := v0.8.2
|
||||
VERSION := v1.2.3
|
||||
|
||||
BUILD := `git rev-parse HEAD`
|
||||
|
||||
@@ -15,9 +15,15 @@ TARGETOS=linux
|
||||
# Use linker flags to provide version/build settings to the target
|
||||
LDFLAGS=-ldflags "-s -w -X=main.Version=$(VERSION) -X=main.Build=$(BUILD) -extldflags -static"
|
||||
DOCKERTAG ?= $(VERSION)
|
||||
REPOSITORY ?= plndr
|
||||
REPOSITORY ?= docker.io/plndr
|
||||
GO_VERSION := $(word 2,$(shell grep '^go ' go.mod))
|
||||
K8S_VERSION ?= v1.35.0
|
||||
GINKGO_ARGS ?=
|
||||
GINKGO_PROCS ?=
|
||||
GINKGO_PARALLEL := $(if $(GINKGO_PROCS),--procs=$(GINKGO_PROCS),-p)
|
||||
BUILDX_CACHE_FLAGS ?=
|
||||
|
||||
.PHONY: all build clean install uninstall fmt simplify check run e2e-tests
|
||||
.PHONY: all build clean install uninstall simplify check run e2e-tests unit-tests integration-tests unit-tests-docker integration-tests-docker e2e-tests-etcd
|
||||
|
||||
all: check install
|
||||
|
||||
@@ -37,9 +43,6 @@ install:
|
||||
uninstall: clean
|
||||
@rm -f $$(which ${TARGET})
|
||||
|
||||
fmt:
|
||||
@gofmt -l -w ./...
|
||||
|
||||
demo:
|
||||
@cd demo
|
||||
@docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le,linux/s390x --push -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) .
|
||||
@@ -59,6 +62,11 @@ dockerx86Iptables:
|
||||
@docker buildx build --platform linux/amd64 -f ./Dockerfile_iptables --push -t $(REPOSITORY)/$(TARGET):dev .
|
||||
@echo New single x86 Architecture Docker image created
|
||||
|
||||
dockerx86IptablesLocal:
|
||||
@-rm ./kube-vip
|
||||
@docker buildx build --platform linux/amd64 -f ./Dockerfile_iptables -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) .
|
||||
@echo New single x86 Architecture Docker image created
|
||||
|
||||
dockerx86:
|
||||
@-rm ./kube-vip
|
||||
@docker buildx build --platform linux/amd64 --push -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) .
|
||||
@@ -73,17 +81,17 @@ docker:
|
||||
# This will build a local docker image (x86 only), use make dockerLocal for all architectures
|
||||
dockerx86Local:
|
||||
@-rm ./kube-vip
|
||||
@docker buildx build --platform linux/amd64 --load -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) .
|
||||
@docker buildx build --platform linux/amd64 --load -t $(REPOSITORY)/$(TARGET):$(DOCKERTAG) $(BUILDX_CACHE_FLAGS) .
|
||||
@echo New Multi Architecture Docker image created
|
||||
|
||||
dockerx86Action:
|
||||
@-rm ./kube-vip
|
||||
@docker buildx build --platform linux/amd64 --load -t $(REPOSITORY)/$(TARGET):action .
|
||||
@docker buildx build --platform linux/amd64 --load -t $(REPOSITORY)/$(TARGET):action $(BUILDX_CACHE_FLAGS) .
|
||||
@echo New Multi Architecture Docker image created
|
||||
|
||||
dockerx86ActionIPTables:
|
||||
@-rm ./kube-vip
|
||||
@docker buildx build --platform linux/amd64 -f ./Dockerfile_iptables --load -t $(REPOSITORY)/$(TARGET):action .
|
||||
@docker buildx build --platform linux/amd64 -f ./Dockerfile_iptables --load -t $(REPOSITORY)/$(TARGET):action $(BUILDX_CACHE_FLAGS) .
|
||||
@echo New Multi Architecture Docker image created
|
||||
|
||||
dockerLocal:
|
||||
@@ -92,12 +100,12 @@ dockerLocal:
|
||||
@echo New Multi Architecture Docker image created
|
||||
|
||||
simplify:
|
||||
@gofmt -s -l -w ./...
|
||||
@gofmt -s -l -w *.go pkg cmd
|
||||
|
||||
check:
|
||||
go mod tidy
|
||||
test -z "$(git status --porcelain)"
|
||||
test -z $(shell gofmt -l main.go | tee /dev/stderr) || echo "[WARN] Fix formatting issues with 'make fmt'"
|
||||
test -z $(shell gofmt -l *.go pkg cmd) || echo "[WARN] Fix formatting issues with 'make simplify'"
|
||||
golangci-lint run
|
||||
go vet ./...
|
||||
|
||||
@@ -107,37 +115,49 @@ run: install
|
||||
manifests:
|
||||
@make build
|
||||
@mkdir -p ./docs/manifests/$(VERSION)/
|
||||
@./kube-vip manifest pod --interface eth0 --vip 192.168.0.1 --arp --leaderElection --controlplane --services > ./docs/manifests/$(VERSION)/kube-vip-arp.yaml
|
||||
@./kube-vip manifest pod --interface eth0 --vip 192.168.0.1 --arp --leaderElection --controlplane --services --enableLoadBalancer > ./docs/manifests/$(VERSION)/kube-vip-arp-lb.yaml
|
||||
@./kube-vip manifest pod --interface eth0 --vip 192.168.0.1 --bgp --controlplane --services > ./docs/manifests/$(VERSION)/kube-vip-bgp.yaml
|
||||
@./kube-vip manifest daemonset --interface eth0 --vip 192.168.0.1 --arp --leaderElection --controlplane --services --inCluster > ./docs/manifests/$(VERSION)/kube-vip-arp-ds.yaml
|
||||
@./kube-vip manifest daemonset --interface eth0 --vip 192.168.0.1 --arp --leaderElection --controlplane --services --inCluster --enableLoadBalancer > ./docs/manifests/$(VERSION)/kube-vip-arp-ds-lb.yaml
|
||||
@./kube-vip manifest daemonset --interface eth0 --vip 192.168.0.1 --bgp --leaderElection --controlplane --services --inCluster > ./docs/manifests/$(VERSION)/kube-vip-bgp-ds.yaml
|
||||
@./kube-vip manifest daemonset --interface eth0 --vip 192.168.0.1 --bgp --leaderElection --controlplane --services --inCluster --provider-config /etc/cloud-sa/cloud-sa.json > ./docs/manifests/$(VERSION)/kube-vip-bgp-em-ds.yaml
|
||||
@./kube-vip manifest pod --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --leaderElection --controlplane --services > ./docs/manifests/$(VERSION)/kube-vip-arp.yaml
|
||||
@./kube-vip manifest pod --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --leaderElection --controlplane --services --enableLoadBalancer > ./docs/manifests/$(VERSION)/kube-vip-arp-lb.yaml
|
||||
@./kube-vip manifest pod --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --bgp --controlplane --services > ./docs/manifests/$(VERSION)/kube-vip-bgp.yaml
|
||||
@./kube-vip manifest daemonset --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --leaderElection --controlplane --services --inCluster > ./docs/manifests/$(VERSION)/kube-vip-arp-ds.yaml
|
||||
@./kube-vip manifest daemonset --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --leaderElection --controlplane --services --inCluster --enableLoadBalancer > ./docs/manifests/$(VERSION)/kube-vip-arp-ds-lb.yaml
|
||||
@./kube-vip manifest daemonset --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --bgp --leaderElection --controlplane --services --inCluster > ./docs/manifests/$(VERSION)/kube-vip-bgp-ds.yaml
|
||||
@./kube-vip manifest daemonset --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --bgp --leaderElection --controlplane --services --inCluster > ./docs/manifests/$(VERSION)/kube-vip-bgp-em-ds.yaml
|
||||
@-rm ./kube-vip
|
||||
|
||||
manifest-test:
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest pod --interface eth0 --vip 192.168.0.1 --arp --leaderElection --controlplane --services
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest pod --interface eth0 --vip 192.168.0.1 --arp --leaderElection --controlplane --services --enableLoadBalancer
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest pod --interface eth0 --vip 192.168.0.1 --bgp --controlplane --services
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest daemonset --interface eth0 --vip 192.168.0.1 --arp --leaderElection --controlplane --services --inCluster
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest daemonset --interface eth0 --vip 192.168.0.1 --arp --leaderElection --controlplane --services --inCluster --enableLoadBalancer
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest daemonset --interface eth0 --vip 192.168.0.1 --bgp --leaderElection --controlplane --services --inCluster
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest pod --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --leaderElection --controlplane --services
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest pod --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --leaderElection --controlplane --services --enableLoadBalancer
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest pod --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --bgp --controlplane --services
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest daemonset --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --leaderElection --controlplane --services --inCluster
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest daemonset --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --leaderElection --controlplane --services --inCluster --enableLoadBalancer
|
||||
docker run $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest daemonset --interface eth0 --vip 192.168.0.1 --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --bgp --leaderElection --controlplane --services --inCluster
|
||||
|
||||
unit-tests:
|
||||
go test ./...
|
||||
go test -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
unit-tests-docker:
|
||||
docker run --rm -w /kube-vip -v $$(pwd):/kube-vip -v kube-vip-gomod-cache:/go/pkg/mod -v kube-vip-gobuild-cache:/root/.cache/go-build golang:$(GO_VERSION) sh -c "make unit-tests; status=$$?; chmod 666 coverage.out 2>/dev/null || true; exit $$status"
|
||||
|
||||
integration-tests:
|
||||
go test -tags=integration,e2e -v ./pkg/etcd
|
||||
|
||||
e2e-tests:
|
||||
E2E_IMAGE_PATH=$(REPOSITORY)/$(TARGET):$(DOCKERTAG) go run github.com/onsi/ginkgo/v2/ginkgo --tags=e2e -v -p ./testing/e2e ./testing/e2e/etcd
|
||||
e2e-tests-arp: get-whoami
|
||||
GOMAXPROCS=4 TEST_MODE=arp K8S_IMAGE_PATH=kindest/node:$(K8S_VERSION) E2E_IMAGE_PATH=$(REPOSITORY)/$(TARGET):$(DOCKERTAG) go run github.com/onsi/ginkgo/v2/ginkgo --tags=e2e -v $(GINKGO_PARALLEL) $(GINKGO_ARGS) ./testing/e2e
|
||||
|
||||
e2e-tests129:
|
||||
V129=true K8S_IMAGE_PATH=kindest/node:v1.29.0 E2E_IMAGE_PATH=$(REPOSITORY)/$(TARGET):$(DOCKERTAG) go run github.com/onsi/ginkgo/v2/ginkgo --tags=e2e -v -p ./testing/e2e
|
||||
e2e-tests-rt: get-whoami
|
||||
GOMAXPROCS=4 TEST_MODE=rt K8S_IMAGE_PATH=kindest/node:$(K8S_VERSION) E2E_IMAGE_PATH=$(REPOSITORY)/$(TARGET):$(DOCKERTAG) go run github.com/onsi/ginkgo/v2/ginkgo --tags=e2e -v $(GINKGO_PARALLEL) $(GINKGO_ARGS) ./testing/e2e
|
||||
|
||||
e2e-tests-bgp: get-whoami get-gobgp
|
||||
GOMAXPROCS=4 TEST_MODE=bgp K8S_IMAGE_PATH=kindest/node:$(K8S_VERSION) E2E_IMAGE_PATH=$(REPOSITORY)/$(TARGET):$(DOCKERTAG) go run github.com/onsi/ginkgo/v2/ginkgo --tags=e2e -v $(GINKGO_PARALLEL) $(GINKGO_ARGS) ./testing/e2e
|
||||
|
||||
e2e-tests-etcd: get-whoami
|
||||
GOMAXPROCS=4 K8S_IMAGE_PATH=kindest/node:$(K8S_VERSION) E2E_IMAGE_PATH=$(REPOSITORY)/$(TARGET):$(DOCKERTAG) go run github.com/onsi/ginkgo/v2/ginkgo --tags=e2e -v $(GINKGO_PARALLEL) $(GINKGO_ARGS) ./testing/e2e/etcd
|
||||
|
||||
e2e-tests: e2e-tests-arp e2e-tests-rt e2e-tests-bgp
|
||||
|
||||
service-tests:
|
||||
E2E_IMAGE_PATH=$(REPOSITORY)/$(TARGET):$(DOCKERTAG) go run ./testing/e2e/services -Services
|
||||
$(MAKE) -C testing/e2e/e2e dockerLocal
|
||||
E2E_IMAGE_PATH=$(REPOSITORY)/$(TARGET):$(DOCKERTAG) go run ./testing/services -Services -simple -deployments -leaderActive -leaderFailover -localDeploy -electionFaults -egress -egressIPv6 -dualStack -egressInternal
|
||||
|
||||
trivy: dockerx86ActionIPTables
|
||||
docker run -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.47.0 \
|
||||
@@ -149,3 +169,23 @@ trivy: dockerx86ActionIPTables
|
||||
--severity 'CRITICAL,HIGH' \
|
||||
$(REPOSITORY)/$(TARGET):action
|
||||
|
||||
kind-quick:
|
||||
echo "Standing up your cluster"
|
||||
kind create cluster --config ./testing/kind/kind.yaml --name kube-vip
|
||||
kubectl apply -f https://kube-vip.io/manifests/rbac.yaml
|
||||
kubectl create configmap --namespace kube-system kubevip --from-literal range-global=172.18.100.10-172.18.100.30
|
||||
kubectl apply -f https://raw.githubusercontent.com/kube-vip/kube-vip-cloud-provider/main/manifest/kube-vip-cloud-controller.yaml
|
||||
kind load docker-image --name kube-vip $(REPOSITORY)/$(TARGET):$(DOCKERTAG)
|
||||
docker run --network host --rm $(REPOSITORY)/$(TARGET):$(DOCKERTAG) manifest daemonset --services --inCluster --image "$(REPOSITORY)/$(TARGET):$(DOCKERTAG)" --arp --servicesElection --interface eth0 | kubectl apply -f -
|
||||
|
||||
kind-reload:
|
||||
kind load docker-image $(REPOSITORY)/$(TARGET):$(DOCKERTAG) --name services
|
||||
kubectl rollout restart -n kube-system daemonset/kube-vip-ds
|
||||
|
||||
get-gobgp:
|
||||
mkdir -p bin
|
||||
wget -nc --directory-prefix=bin https://github.com/osrg/gobgp/releases/download/v4.6.0/gobgp_4.6.0_linux_amd64.tar.gz
|
||||
tar -xvzf bin/gobgp_4.6.0_linux_amd64.tar.gz -C bin
|
||||
|
||||
get-whoami:
|
||||
docker pull ghcr.io/traefik/whoami:v1.11
|
||||
|
||||
74
README.md
74
README.md
@@ -4,7 +4,7 @@ High Availability and Load-Balancing
|
||||
|
||||

|
||||
|
||||
[](https://github.com/kube-vip/kube-vip/actions/workflows/main.yaml)
|
||||
[](https://github.com/kube-vip/kube-vip/actions/workflows/main.yaml) [](https://insights.linuxfoundation.org/project/kube-vip) [&message=212&color=0094FF&logo=linuxfoundation&logoColor=white&style=flat)](https://insights.linuxfoundation.org/project/kube-vip)
|
||||
|
||||
## Overview
|
||||
Kubernetes Virtual IP and Load-Balancer for both control plane and Kubernetes services
|
||||
@@ -18,6 +18,8 @@ The idea behind `kube-vip` is a small self-contained Highly-Available option for
|
||||
|
||||
**NOTE:** All documentation of both usage and architecture are now available at [https://kube-vip.io](https://kube-vip.io).
|
||||
|
||||
For upgrading an existing install in place (static Pod or DaemonSet), see the [upgrade guide](https://kube-vip.io/docs/upgrade/).
|
||||
|
||||
## Features
|
||||
|
||||
Kube-Vip was originally created to provide a HA solution for the Kubernetes control plane, over time it has evolved to incorporate that same functionality into Kubernetes service type [load-balancers](https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer).
|
||||
@@ -32,6 +34,7 @@ Kube-Vip was originally created to provide a HA solution for the Kubernetes cont
|
||||
- Service LoadBalancer address pools per namespace or global
|
||||
- Service LoadBalancer address via (existing network DHCP)
|
||||
- Service LoadBalancer address exposure to gateway via UPNP
|
||||
- Egress! Kube-vip will utilise a service loadbalancer as both the ingress and **egress** for a pod.
|
||||
- ... manifest generation, vendor API integrations and many more...
|
||||
|
||||
## Why?
|
||||
@@ -57,14 +60,81 @@ All of these would require a separate level of configuration and in some infrast
|
||||
|
||||
## Troubleshooting and Feedback
|
||||
|
||||
### SELinux and IPVS kernel modules
|
||||
|
||||
When using IPVS load balancing on nodes with SELinux enforcing, kube-vip may be
|
||||
blocked from requesting kernel modules from inside the container. Symptoms can
|
||||
include the kube-vip pod entering `Error` or `CrashLoopBackOff`, logs that show
|
||||
`ensure IPVS kernel modules are loaded`, or audit denials for `module_request`
|
||||
from `container_t`.
|
||||
|
||||
Load the required IPVS modules on every node that can run kube-vip before
|
||||
deploying it:
|
||||
|
||||
```shell
|
||||
sudo modprobe ip_vs
|
||||
sudo modprobe ip_vs_rr
|
||||
```
|
||||
|
||||
To persist this across reboots, add the modules to a file such as
|
||||
`/etc/modules-load.d/kube-vip-ipvs.conf`:
|
||||
|
||||
```text
|
||||
ip_vs
|
||||
ip_vs_rr
|
||||
```
|
||||
|
||||
Preloading only the required modules is preferred to enabling the SELinux
|
||||
`domain_kernel_load_modules` boolean for containers.
|
||||
|
||||
### Gateway API `LoadBalancer` services with no endpoints
|
||||
|
||||
Some Gateway API controllers create `LoadBalancer` services that intentionally have no Endpoints/EndpointSlices backends.
|
||||
|
||||
If you want kube-vip to reconcile such a service, opt in with:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
kube-vip.io/allow-reconcile-without-endpoints: "true"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Cluster
|
||||
```
|
||||
|
||||
Scope:
|
||||
- Works only with `externalTrafficPolicy: Cluster`
|
||||
- No effect for `Local`
|
||||
- Default endpoint-gated behavior remains unchanged for services without this annotation
|
||||
|
||||
Please raise issues on the GitHub repository and as mentioned check the documentation at [https://kube-vip.io](https://kube-vip.io/).
|
||||
|
||||
## Community Tools
|
||||
|
||||
- **[KubeStellar Console — Guided kube-vip Install](https://console.kubestellar.io/missions/install-kube-vip)** — A step-by-step guided installation experience for kube-vip with pre-flight checks, validation, troubleshooting, and rollback support.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thanks for taking the time to join our community and start contributing! We welcome pull requests. Feel free to dig through the [issues](https://github.com/kube-vip/kube-vip/issues) and jump in.
|
||||
|
||||
:warning: This project has issue compiling on MacOS, please compile it on linux distribution
|
||||
|
||||
Additionally it is now relatively easy and quick to develop with [skaffold](https://skaffold.dev/), and the `skaffold.yaml` exists within the root folder of the gir repository.
|
||||
|
||||
### Set up a kind development environment
|
||||
|
||||
1. `kind create cluster --config ./testing/kind.yaml`
|
||||
2. `kubectl apply -f https://kube-vip.io/manifests/rbac.yaml`
|
||||
3. Create a load balancer range `configMap` from the kind cluster
|
||||
4. Apply the CCM manifest
|
||||
5. Start skaffold with `skaffold dev`
|
||||
6. Start developing and see changes applied in real-time.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#kube-vip/kube-vip&Date)
|
||||
[](https://star-history.dera.page/#kube-vip/kube-vip&type=date)
|
||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2Fkube-vip%2Fkube-vip?ref=badge_shield)
|
||||
|
||||
|
||||
## License
|
||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2Fkube-vip%2Fkube-vip?ref=badge_large)
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -22,38 +23,73 @@ func init() {
|
||||
var kubeKubeadm = &cobra.Command{
|
||||
Use: "kubeadm",
|
||||
Short: "Kubeadm functions",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Long: `This command group provides utilities for generating static Pod manifests specifically tailored for the kubeadm bootstrapping process.
|
||||
It contains two subcommands:
|
||||
- init: Generates a manifest to be used during 'kubeadm init' on the first control-plane node.
|
||||
- join: Generates a manifest to be used during 'kubeadm join' for additional control-plane nodes.
|
||||
|
||||
The generated YAML manifest should be saved to the kubeadm static Pod directory (typically /etc/kubernetes/manifests/) so that kubeadm launches the kube-vip static Pod automatically.`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
// TODO - A load of text detailing what's actually happening
|
||||
},
|
||||
}
|
||||
|
||||
var kubeKubeadmInit = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "kube-vip init",
|
||||
Long: "The \"init\" subcommand will generate the Kubernetes manifest that will be started by kubeadm through the kubeadm init process",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLevel(log.Level(logLevel))
|
||||
Long: `The 'init' subcommand generates a Kubernetes Pod manifest that kubeadm will start as a static Pod during the cluster initialisation phase.
|
||||
|
||||
This manifest runs kube-vip on the first control-plane node to advertise the Virtual IP (VIP) for the API server. The VIP is typically configured using ARP (Layer 2) or BGP (dynamic routing).
|
||||
|
||||
Required flags for this command:
|
||||
--interface : The network interface to bind the VIP to (e.g., eth0).
|
||||
--vip or --address : The Virtual IP address or DNS name to use.
|
||||
|
||||
Example:
|
||||
kube-vip kubeadm init --interface eth0 --vip 192.168.1.100 --controlplane
|
||||
|
||||
The output YAML should be written to the kubeadm manifests directory, e.g.:
|
||||
kube-vip kubeadm init ... > /etc/kubernetes/manifests/kube-vip.yaml`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
|
||||
initConfig.LoadBalancers = append(initConfig.LoadBalancers, initLoadBalancer)
|
||||
// TODO - A load of text detailing what's actually happening
|
||||
err := kubevip.ParseEnvironment(&initConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing environment from config: %v", err)
|
||||
log.Error("parsing environment", "err", err)
|
||||
return
|
||||
}
|
||||
if err := initConfig.Validate(); err != nil {
|
||||
log.Error("validating configuration", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO - check for certain things VIP/interfaces
|
||||
if initConfig.Interface == "" {
|
||||
_ = cmd.Help()
|
||||
log.Fatalln("No interface is specified for kube-vip to bind to")
|
||||
log.Error("No interface is specified for kube-vip to bind to")
|
||||
return
|
||||
}
|
||||
|
||||
if initConfig.VIP == "" && initConfig.Address == "" {
|
||||
_ = cmd.Help()
|
||||
log.Fatalln("No address is specified for kube-vip to expose services on")
|
||||
log.Error("No address is specified for kube-vip to expose services on")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := kubevip.GeneratePodManifestFromConfig(&initConfig, Release.Version, inCluster)
|
||||
// Ensure there is an address to generate the CIDR from
|
||||
if initConfig.VIPSubnet == "" && initConfig.Address != "" {
|
||||
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
|
||||
if err != nil {
|
||||
log.Error("generating VIPSubnet", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := kubevip.GeneratePodManifestFromConfig(&initConfig, image, Release.Version, inCluster)
|
||||
if err != nil {
|
||||
log.Error("unable to create manifest", "err", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(cfg) // output manifest to stdout
|
||||
},
|
||||
}
|
||||
@@ -61,33 +97,63 @@ var kubeKubeadmInit = &cobra.Command{
|
||||
var kubeKubeadmJoin = &cobra.Command{
|
||||
Use: "join",
|
||||
Short: "kube-vip join",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLevel(log.Level(logLevel))
|
||||
Long: `The 'join' subcommand generates a Kubernetes Pod manifest for additional control-plane nodes joining an existing cluster via 'kubeadm join'.
|
||||
|
||||
It functions identically to the 'init' subcommand, but is intended for secondary control-plane nodes. It validates that the kubeconfig file (specified by --config, defaulting to /etc/kubernetes/admin.conf) exists on the node to ensure the node can authenticate with the cluster.
|
||||
|
||||
Required flags for this command:
|
||||
--interface : The network interface to bind the VIP to.
|
||||
--vip or --address : The Virtual IP address or DNS name (must match the VIP used during 'init').
|
||||
|
||||
Example:
|
||||
kube-vip kubeadm join --interface eth0 --vip 192.168.1.100
|
||||
|
||||
The output YAML should be saved to the kubeadm manifests directory on the joining node.`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
|
||||
initConfig.LoadBalancers = append(initConfig.LoadBalancers, initLoadBalancer)
|
||||
// TODO - A load of text detailing what's actually happening
|
||||
err := kubevip.ParseEnvironment(&initConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing environment from config: %v", err)
|
||||
log.Error("parsing environment", "err", err)
|
||||
return
|
||||
}
|
||||
if err := initConfig.Validate(); err != nil {
|
||||
log.Error("validating configuration", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO - check for certain things VIP/interfaces
|
||||
if initConfig.Interface == "" {
|
||||
_ = cmd.Help()
|
||||
log.Fatalln("No interface is specified for kube-vip to bind to")
|
||||
log.Error("No interface is specified for kube-vip to bind to")
|
||||
return
|
||||
}
|
||||
|
||||
if initConfig.VIP == "" && initConfig.Address == "" {
|
||||
_ = cmd.Help()
|
||||
log.Fatalln("No address is specified for kube-vip to expose services on")
|
||||
log.Error("No address is specified for kube-vip to expose services on")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(kubeConfigPath); os.IsNotExist(err) {
|
||||
log.Fatalf("Unable to find file [%s]", kubeConfigPath)
|
||||
log.Error("kubeConfig not found", "Path", kubeConfigPath)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := kubevip.GeneratePodManifestFromConfig(&initConfig, Release.Version, inCluster)
|
||||
// Ensure there is an address to generate the CIDR from
|
||||
if initConfig.VIPSubnet == "" && initConfig.Address != "" {
|
||||
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
|
||||
if err != nil {
|
||||
log.Error("generating VIPSubnet", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := kubevip.GeneratePodManifestFromConfig(&initConfig, image, Release.Version, inCluster)
|
||||
if err != nil {
|
||||
log.Error("unable to create manifest", "err", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(cfg) // output manifest to stdout
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,26 +2,27 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// manifests will eventually deprecate the kubeadm set of subcommands
|
||||
// manifests will be used to generate:
|
||||
// - Pod spec manifest, mainly used for a static pod (kubeadm)
|
||||
// - Daemonset manifest, mainly used to run kube-vip as a deamonset within Kubernetes (k3s/rke)
|
||||
// - RBAC manifest, used to generate the RBAC permissions for kube-vip
|
||||
|
||||
// var inCluster bool
|
||||
var taint bool
|
||||
var taint, role, rolebinding bool
|
||||
|
||||
func init() {
|
||||
kubeManifest.PersistentFlags().BoolVar(&inCluster, "inCluster", false, "Use the incluster token to authenticate to Kubernetes")
|
||||
kubeManifest.PersistentFlags().StringVar(&image, "image", "ghcr.io/kube-vip/kube-vip", "Define a hardcoded image with or without tag for the manifest")
|
||||
kubeManifestDaemon.PersistentFlags().BoolVar(&taint, "taint", false, "Taint the manifest for only running on control planes")
|
||||
kubeManifestRbac.PersistentFlags().BoolVar(&role, "role", false, "Generate only a Role inside the serviceNamespace access")
|
||||
kubeManifestRbac.PersistentFlags().BoolVar(&rolebinding, "rolebinding", false, "Generate only a RoleBinding for namespaced access")
|
||||
|
||||
kubeManifest.AddCommand(kubeManifestPod)
|
||||
kubeManifest.AddCommand(kubeManifestDaemon)
|
||||
@@ -31,41 +32,71 @@ func init() {
|
||||
var kubeManifest = &cobra.Command{
|
||||
Use: "manifest",
|
||||
Short: "Manifest functions",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Long: `This command group provides flexible manifest generation for deploying kube-vip in various Kubernetes environments.
|
||||
|
||||
Unlike the 'kubeadm' subcommands, which are tightly coupled to kubeadm's static Pod requirements, these generators produce standard Kubernetes manifests (Pod, DaemonSet, RBAC) that can be used with any Kubernetes distribution (e.g., k3s, RKE, or vanilla Kubernetes).
|
||||
|
||||
Subcommands:
|
||||
pod : Generates a standalone Pod manifest (similar to a static pod).
|
||||
daemonset : Generates a DaemonSet manifest to run kube-vip on selected nodes.
|
||||
rbac : Generates the necessary ServiceAccount, Role/ClusterRole, and Binding manifests.
|
||||
|
||||
All output is written to stdout as YAML, typically piped to 'kubectl apply -f -' or saved to a file.`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
// TODO - A load of text detailing what's actually happening
|
||||
},
|
||||
}
|
||||
|
||||
var kubeManifestPod = &cobra.Command{
|
||||
Use: "pod",
|
||||
Short: "Generate a Pod Manifest",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Long: `Generate a standalone Pod manifest for kube-vip.
|
||||
|
||||
This is ideal for environments that do not use DaemonSets or where you want to run kube-vip as a static Pod (similar to the 'kubeadm' subcommand, but without kubeadm-specific assumptions). It includes all the necessary container specifications, volumes, and environment variables derived from the provided flags.
|
||||
|
||||
Key flags:
|
||||
--interface : Network interface for the VIP.
|
||||
--vip or --address : The Virtual IP address or DNS name.
|
||||
--image : Override the container image (default: ghcr.io/kube-vip/kube-vip).
|
||||
|
||||
The manifest is generated based on the current configuration flags set on the root command.
|
||||
|
||||
Example:
|
||||
kube-vip manifest pod --interface eth0 --vip 10.0.0.100 --controlplane | kubectl apply -f -`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
var err error
|
||||
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLevel(log.Level(logLevel))
|
||||
initConfig.LoadBalancers = append(initConfig.LoadBalancers, initLoadBalancer)
|
||||
// TODO - A load of text detailing what's actually happening
|
||||
if err := kubevip.ParseEnvironment(&initConfig); err != nil {
|
||||
log.Fatalf("Error parsing environment from config: %v", err)
|
||||
log.Error("parsing environment", "err", err)
|
||||
return
|
||||
}
|
||||
if err := initConfig.Validate(); err != nil {
|
||||
log.Error("validating configuration", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The control plane has a requirement for a VIP being specified
|
||||
if initConfig.EnableControlPlane && (initConfig.VIP == "" && initConfig.Address == "" && !initConfig.DDNS) {
|
||||
_ = cmd.Help()
|
||||
log.Fatalln("No address is specified for kube-vip to expose services on")
|
||||
log.Error("no address is specified for kube-vip to expose services on")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure there is an address to generate the CIDR from
|
||||
if initConfig.VIPCIDR == "" && initConfig.Address != "" {
|
||||
initConfig.VIPCIDR, err = generateCidrRange(initConfig.Address)
|
||||
if initConfig.VIPSubnet == "" && initConfig.Address != "" {
|
||||
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
log.Error("config parse", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg := kubevip.GeneratePodManifestFromConfig(&initConfig, Release.Version, inCluster)
|
||||
cfg, err := kubevip.GeneratePodManifestFromConfig(&initConfig, image, Release.Version, inCluster)
|
||||
if err != nil {
|
||||
log.Error("unable to create manifest", "err", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(cfg) // output manifest to stdout
|
||||
},
|
||||
}
|
||||
@@ -73,34 +104,50 @@ var kubeManifestPod = &cobra.Command{
|
||||
var kubeManifestDaemon = &cobra.Command{
|
||||
Use: "daemonset",
|
||||
Short: "Generate a Daemonset Manifest",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Long: `Generate a DaemonSet manifest to run kube-vip across multiple nodes.
|
||||
|
||||
This is the recommended deployment method for production clusters running kube-vip as a service. It ensures that kube-vip runs on all control-plane nodes (or selected nodes via tolerations) and can handle both control-plane HA and service load-balancing.
|
||||
|
||||
Flags specific to this subcommand:
|
||||
--taint : Adds a toleration to the DaemonSet so that pods are scheduled only on nodes with the control-plane taint (node-role.kubernetes.io/control-plane:NoSchedule). This is essential for control-plane-only deployments.
|
||||
|
||||
All other standard kube-vip flags (--interface, --vip, --enableARP, --enableBGP, etc.) are respected and embedded into the DaemonSet pod template.
|
||||
|
||||
Example:
|
||||
kube-vip manifest daemonset --interface eth0 --vip 192.168.1.100 --controlplane --taint | kubectl apply -f -`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
var err error
|
||||
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLevel(log.Level(logLevel))
|
||||
initConfig.LoadBalancers = append(initConfig.LoadBalancers, initLoadBalancer)
|
||||
// TODO - A load of text detailing what's actually happening
|
||||
if err := kubevip.ParseEnvironment(&initConfig); err != nil {
|
||||
log.Fatalf("error parsing environment config: %v", err)
|
||||
log.Error("parsing environment", "err", err)
|
||||
return
|
||||
}
|
||||
if err := initConfig.Validate(); err != nil {
|
||||
log.Error("validating configuration", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO - check for certain things VIP/interfaces
|
||||
|
||||
// The control plane has a requirement for a VIP being specified
|
||||
if initConfig.EnableControlPlane && (initConfig.VIP == "" && initConfig.Address == "" && !initConfig.DDNS) {
|
||||
_ = cmd.Help()
|
||||
log.Fatalln("No address is specified for kube-vip to expose services on")
|
||||
log.Error("no address is specified for kube-vip to expose services on")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure there is an address to generate the CIDR from
|
||||
if initConfig.VIPCIDR == "" && initConfig.Address != "" {
|
||||
initConfig.VIPCIDR, err = generateCidrRange(initConfig.Address)
|
||||
if initConfig.VIPSubnet == "" && initConfig.Address != "" {
|
||||
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
log.Error("config parse", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg := kubevip.GenerateDaemonsetManifestFromConfig(&initConfig, Release.Version, inCluster, taint)
|
||||
cfg, err := kubevip.GenerateDaemonsetManifestFromConfig(&initConfig, image, Release.Version, inCluster, taint)
|
||||
if err != nil {
|
||||
log.Error("unable to create manifest", "err", err)
|
||||
return
|
||||
}
|
||||
fmt.Println(cfg) // output manifest to stdout
|
||||
},
|
||||
}
|
||||
@@ -108,54 +155,58 @@ var kubeManifestDaemon = &cobra.Command{
|
||||
var kubeManifestRbac = &cobra.Command{
|
||||
Use: "rbac",
|
||||
Short: "Generate an RBAC Manifest",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var err error
|
||||
Long: `Generate the RBAC (Role-Based Access Control) manifests required for kube-vip to interact with the Kubernetes API.
|
||||
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLevel(log.Level(logLevel))
|
||||
kube-vip needs permissions to watch services, endpoints, configmaps, and manage leader election leases. This command outputs the minimum required ServiceAccount, Role (or ClusterRole), and the corresponding binding.
|
||||
|
||||
Flags:
|
||||
--role : If true, generates a namespaced Role instead of a ClusterRole. The namespace is taken from the root --namespace flag (default: kube-system).
|
||||
--rolebinding : If true, generates a RoleBinding (if --role is also true). If --role is false, a ClusterRoleBinding is generated automatically.
|
||||
|
||||
The output is a multi-document YAML (separated by '---'). It is safe to apply directly:
|
||||
kube-vip manifest rbac --role --rolebinding | kubectl apply -f -
|
||||
|
||||
Without --role, it generates a ClusterRole and ClusterRoleBinding, which is the default behaviour and suitable for most cluster-wide deployments.`,
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
initConfig.LoadBalancers = append(initConfig.LoadBalancers, initLoadBalancer)
|
||||
// TODO - A load of text detailing what's actually happening
|
||||
if err := kubevip.ParseEnvironment(&initConfig); err != nil {
|
||||
log.Fatalf("Error parsing environment from config: %v", err)
|
||||
log.Error("parsing environment", "err", err)
|
||||
return
|
||||
}
|
||||
if err := initConfig.Validate(); err != nil {
|
||||
log.Error("validating configuration", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The control plane has a requirement for a VIP being specified
|
||||
if initConfig.EnableControlPlane && (initConfig.VIP == "" && initConfig.Address == "" && !initConfig.DDNS) {
|
||||
_ = cmd.Help()
|
||||
log.Fatalln("No address is specified for kube-vip to expose services on")
|
||||
log.Error("no address is specified for kube-vip to expose services on")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure there is an address to generate the CIDR from
|
||||
if initConfig.VIPCIDR == "" && initConfig.Address != "" {
|
||||
initConfig.VIPCIDR, err = generateCidrRange(initConfig.Address)
|
||||
if initConfig.VIPSubnet == "" && initConfig.Address != "" {
|
||||
var err error
|
||||
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
log.Error("generating VIPSubnet", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
saCfg := kubevip.GenerateSA(&initConfig)
|
||||
roleCfg := kubevip.GenerateRole(&initConfig, role)
|
||||
if role {
|
||||
rolebinding = true
|
||||
}
|
||||
roleBindingCfg := kubevip.GenerateRoleBinding(rolebinding, saCfg, roleCfg)
|
||||
|
||||
cfg := kubevip.GenerateSA()
|
||||
b, _ := yaml.Marshal(cfg)
|
||||
fmt.Println(string(b)) // output manifest to stdout
|
||||
// Output the YAML manifests to stdout
|
||||
fmt.Println("---") // Separator for YAML documents
|
||||
fmt.Println(kubevip.TransformApplyObjectToManifest(saCfg))
|
||||
fmt.Println("---") // Separator for YAML documents
|
||||
fmt.Println(kubevip.TransformApplyObjectToManifest(roleCfg))
|
||||
fmt.Println("---") // Separator for YAML documents
|
||||
fmt.Println(kubevip.TransformApplyObjectToManifest(roleBindingCfg))
|
||||
},
|
||||
}
|
||||
|
||||
func generateCidrRange(address string) (string, error) {
|
||||
var cidrs []string
|
||||
|
||||
addresses := strings.Split(address, ",")
|
||||
for _, a := range addresses {
|
||||
ip := net.ParseIP(a)
|
||||
|
||||
if ip == nil {
|
||||
return "", fmt.Errorf("invalid IP address: %s from [%s]", a, address)
|
||||
}
|
||||
|
||||
if ip.To4() != nil {
|
||||
cidrs = append(cidrs, "32")
|
||||
} else {
|
||||
cidrs = append(cidrs, "128")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(cidrs, ","), nil
|
||||
}
|
||||
|
||||
376
cmd/kube-vip.go
376
cmd/kube-vip.go
@@ -3,35 +3,39 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
log "log/slog"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/equinixmetal"
|
||||
"github.com/kube-vip/kube-vip/pkg/debouncer"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/manager"
|
||||
"github.com/kube-vip/kube-vip/pkg/metrics"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
)
|
||||
|
||||
// Is an option to set the image
|
||||
var image string
|
||||
|
||||
// Is kube-vip running within cluster
|
||||
var inCluster bool
|
||||
|
||||
// ConfigMap name within a Kubernetes cluster
|
||||
var configMap string
|
||||
|
||||
// Configure the level of logging
|
||||
var logLevel uint32
|
||||
|
||||
// Provider Config
|
||||
var providerConfig string
|
||||
|
||||
// Points to a kubernetes configuration file
|
||||
var kubeConfigPath string
|
||||
|
||||
@@ -48,29 +52,33 @@ var (
|
||||
)
|
||||
|
||||
var kubeVipCmd = &cobra.Command{
|
||||
Use: "kube-vip",
|
||||
Short: "This is a server for providing a Virtual IP and load-balancer for the Kubernetes control-plane",
|
||||
Use: "kube-vip",
|
||||
Short: "This is a server for providing a Virtual IP and load-balancer for the Kubernetes control-plane",
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Basic flags
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.Interface, "interface", "", "Name of the interface to bind to")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.ServicesInterface, "serviceInterface", "", "Name of the interface to bind to (for services)")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.AllowInterfaceNotUp, "allowInterfaceNotUp", false, "Allow kube-vip to start even if the interface is not up")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.VIP, "vip", "", "The Virtual IP address")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.VIPSubnet, "vipSubnet", "", "The Virtual IP address subnet e.g. /32 /24 /8 etc..")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.VIPSubnet, "vipSubnet", "", "The Virtual IP address subnet e.g. /32 /24 /8 etc.. (Default to 32 for IPv4 and 128 for IPv6)")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.NodeName, "nodeName", "", "Name to be used for lease holder. Must be unique for each node/instance")
|
||||
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.VIPCIDR, "cidr", "", "The CIDR range for the virtual IP address. Default to 32 for IPv4 and 128 for IPv6") // todo: deprecate
|
||||
|
||||
// VIP flags
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.Address, "address", "", "an address (IP or DNS name) to use as a VIP")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.Port, "port", 6443, "Port for the VIP")
|
||||
kubeVipCmd.PersistentFlags().Uint16Var(&initConfig.Port, "port", 6443, "Port for the VIP")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableARP, "arp", false, "Enable Arp for VIP changes")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableWireguard, "wireguard", false, "Enable Wireguard for services VIPs")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableRoutingTable, "table", false, "Enable Routing Table for services VIPs")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.PreserveVIPOnLeadershipLoss, "preserveVipOnLeadershipLoss", false, "Preserve ARP VIP addresses on interface when leadership is lost (default: false for backward compatibility)")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.LoseLeadership, "loseLeadership", false, "Lose leadership when VIP interface goes down")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.LoseLeadershipTimeoutSeconds, "loseLeadershiptTimeoutSeconds", 30, "Timeout before re-electing a leader when the VIP interface is down")
|
||||
|
||||
// LoadBalancer flags
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableLoadBalancer, "enableLoadBalancer", false, "enable loadbalancing on the VIP with IPVS")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.LoadBalancerPort, "lbPort", 6443, "loadbalancer port for the VIP")
|
||||
kubeVipCmd.PersistentFlags().Uint16Var(&initConfig.LoadBalancerPort, "lbPort", 6443, "loadbalancer port for the VIP")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.LoadBalancerForwardingMethod, "lbForwardingMethod", "local", "loadbalancer forwarding method")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.DDNS, "ddns", false, "use Dynamic DNS + DHCP to allocate VIP for address")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.MirrorDestInterface, "mirrorDestInterface", "", "network interface where all traffic that traverses the service interface will be mirrored to. Source interface will use default interface is servicesInterface is not set.")
|
||||
@@ -79,19 +87,13 @@ func init() {
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableLeaderElection, "leaderElection", false, "Use the Kubernetes leader election mechanism for clustering")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.LeaderElectionType, "leaderElectionType", "kubernetes", "Defines the backend to run the leader election: kubernetes or etcd. Defaults to kubernetes.")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.LeaseName, "leaseName", "plndr-cp-lock", "Name of the lease that is used for leader election")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.LeaseDuration, "leaseDuration", 5, "Length of time (in seconds) a Kubernetes leader lease can be held for")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.RenewDeadline, "leaseRenewDuration", 3, "Length of time (in seconds) a Kubernetes leader can attempt to renew its lease")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.RetryPeriod, "leaseRetry", 1, "Length of time (in seconds) the LeaderElector clients should wait between tries of actions")
|
||||
|
||||
// Equinix Metal flags
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableMetal, "metal", false, "This will use the Equinix Metal API (requires the token ENV) to update the EIP <-> VIP")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.MetalAPIKey, "metalKey", "", "The API token for authenticating with the Equinix Metal API")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.MetalProject, "metalProject", "", "The name of project already created within Equinix Metal")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.MetalProjectID, "metalProjectID", "", "The ID of project already created within Equinix Metal")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.ProviderConfig, "provider-config", "", "The path to a provider configuration")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.LeaseDuration, "leaseDuration", 15, "Length of time (in seconds) a Kubernetes leader lease can be held for")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.RenewDeadline, "leaseRenewDuration", 10, "Length of time (in seconds) a Kubernetes leader can attempt to renew its lease")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.RetryPeriod, "leaseRetry", 2, "Length of time (in seconds) the LeaderElector clients should wait between tries of actions")
|
||||
|
||||
// BGP flags
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableBGP, "bgp", false, "This will enable BGP support within kube-vip")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.BGPAttachIPToInterface, "bgpAttachIPToInterface", false, "Assign BGP service VIPs to the configured interface")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.BGPConfig.RouterID, "bgpRouterID", "", "The routerID for the bgp server")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.BGPConfig.SourceIF, "sourceIF", "", "The source interface for bgp peering (not to be used with sourceIP)")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.BGPConfig.SourceIP, "sourceIP", "", "The source address for bgp peering (not to be used with sourceIF)")
|
||||
@@ -105,18 +107,28 @@ func init() {
|
||||
kubeVipCmd.PersistentFlags().StringSliceVar(&initConfig.BGPPeers, "bgppeers", []string{}, "Comma separated BGP Peer, format: address:as:password:multihop")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.Annotations, "annotations", "", "Set Node annotations prefix for parsing")
|
||||
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.BGPConfig.Zebra.Enabled, "zebra", false, "This will enable Zebra support within kube-vip")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.BGPConfig.Zebra.URL, "zebraUrl", "unix:/var/run/frr/zserv.api", "Path to the unix domain socket for connecting to Zebra daemon")
|
||||
kubeVipCmd.PersistentFlags().Uint32Var(&initConfig.BGPConfig.Zebra.Version, "zebraVersion", 6, "Zebra API Version")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.BGPConfig.Zebra.SoftwareName, "zebraSoftwareName", "frr8.3", "Software Name for Zebra")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.ControlPlaneHealthCheck.Address, "controlPlaneHealthCheckAddress", "", "URL to poll for the control-plane health check when using BGP without leader election")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.ControlPlaneHealthCheck.PeriodSeconds, "controlPlaneHealthCheckPeriodSeconds", 5, "Seconds between control-plane health checks")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.ControlPlaneHealthCheck.TimeoutSeconds, "controlPlaneHealthCheckTimeoutSeconds", 3, "Timeout for each control-plane health check request")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.ControlPlaneHealthCheck.FailureThreshold, "controlPlaneHealthCheckFailureThreshold", 3, "Consecutive control-plane health check failures before withdrawing the BGP route")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.ControlPlaneHealthCheck.CAPath, "controlPlaneHealthCheckCAPath", "", "Path to CA certificate for TLS verification when the control-plane health check URL is HTTPS")
|
||||
|
||||
// Namespace for kube-vip
|
||||
kubeVipCmd.PersistentFlags().StringVarP(&initConfig.Namespace, "namespace", "n", "kube-system", "The namespace for the configmap defined within the cluster")
|
||||
|
||||
// Manage logging
|
||||
kubeVipCmd.PersistentFlags().Uint32Var(&logLevel, "log", 4, "Set the level of logging")
|
||||
kubeVipCmd.PersistentFlags().Int32Var(&initConfig.Logging, "log", 0, "Set the level of logging")
|
||||
|
||||
// Service flags
|
||||
kubeVipService.Flags().StringVarP(&configMap, "configMap", "c", "plndr", "The configuration map defined within the cluster")
|
||||
|
||||
// Routing Table flags
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.RoutingTableID, "tableID", 198, "The routing table used for all table entries")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.RoutingTableType, "tableType", 0, "The type of route that will be added to the routing table")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.RoutingTableType, "tableType", unix.RTN_UNICAST, "The type of route that will be added to the routing table")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.RoutingProtocol, "routingProtocol", 248, "The routing protocol value used to create routes")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.CleanRoutingTable, "cleanRoutingTable", false, "Clean routing table of redundant routes on start")
|
||||
|
||||
@@ -127,15 +139,22 @@ func init() {
|
||||
|
||||
// Extended behaviour flags
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableServicesElection, "servicesElection", false, "Enable leader election per kubernetes service")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.LoadBalancerClassOnly, "lbClassOnly", false, "Enable load balancing only for services with LoadBalancerClass \"kube-vip.io/kube-vip-class\"")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.LoadBalancerClassName, "lbClassName", "kube-vip.io/kube-vip-class", "Name of load balancer class for kube-VIP, defaults to \"kube-vip.io/kube-vip-class\"")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.LoadBalancerClassOnly, "lbClassOnly", false, fmt.Sprintf("Enable load balancing only for services with LoadBalancerClass %q", kubevip.LBClassName))
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.LoadBalancerClassName, "lbClassName", kubevip.LBClassName, fmt.Sprintf("Name of load balancer class for kube-VIP, defaults to %q", kubevip.LBClassName))
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.LoadBalancerClassLegacyHandling, "lbClassNameLegacyHandling", true, "Use legacy LoadBalancer class name handling (e.g. accepting services both with empty and non-empty class)")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableServiceSecurity, "onlyAllowTrafficServicePorts", false, "Only allow traffic to service ports, others will be dropped, defaults to false")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableNodeLabeling, "enableNodeLabeling", false, "Enable leader node labeling with \"kube-vip.io/has-ip=<VIP address>\", defaults to false")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableNodeLabeling, "enableNodeLabeling", false, fmt.Sprintf("Enable leader node labeling with %q, defaults to false", kubevip.HasIP))
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.ServicesLeaseName, "servicesLeaseName", "plndr-svcs-lock", "Name of the lease that is used for leader election for services (in arp mode)")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.DNSMode, "dnsMode", "first", "Name of the mode that DNS lookup will be performed (first, ipv4, ipv6, dual)")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.DHCPMode, "dhcpMode", "", "Mode DHCP resolving will use to obtain IP addresses (ipv4, ipv6, dual)")
|
||||
kubeVipCmd.PersistentFlags().UintVar(&initConfig.DHCPBackoffAttempts, "dhcpBackoffAttempts", kubevip.DefaultDHCPBackoffAttempts,
|
||||
fmt.Sprintf("number of times DHCP client will try to obtain an IP address (defaults to: %d, 0 for unlimited retries)", kubevip.DefaultDHCPBackoffAttempts))
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.DisableServiceUpdates, "disableServiceUpdates", false, "If true, kube-vip will process services as usual, but will not update service's Status.LoadBalancer.Ingress slice")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableEndpointSlices, "enableEndpointSlices", false, "If enabled, kube-vip will only advertise services, but will use EndpointSlices instead of endpoints to get IPs of Pods")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EnableEndpoints, "enableEndpoints", false, "If enabled, kube-vip will only advertise services, but will use the (deprecated since v1.33) endpoints for IP addresses")
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.LoInterfaceGlobalScope, "loInterfaceGlobalScope", false, "If true, kube-vip will set global scope when using the lo interface, otherwise a host scope will be used by default")
|
||||
kubeVipCmd.PersistentFlags().IntVar(&initConfig.HealthCheckPort, "healthCheckPort", 0, "If set to non-zero (> 1024), then this is the port that the healthcheck will listen on")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.DebounceTime, "debounceTime", debouncer.DefaultTime,
|
||||
"Configures the time that the event debouncer will wait for the events arrival (default 0s - debouncer disabled, enable with min. 200ms)")
|
||||
|
||||
// Prometheus HTTP Server
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.PrometheusHTTPServer, "prometheusHTTPServer", ":2112", "Host and port used to expose Prometheus metrics via an HTTP server")
|
||||
@@ -147,29 +166,44 @@ func init() {
|
||||
kubeVipCmd.PersistentFlags().StringSliceVar(&initConfig.Etcd.Endpoints, "etcdEndpoints", nil, "Etcd member endpoints")
|
||||
|
||||
// Kubernetes client specific flags
|
||||
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.K8sConfigFile, "k8sConfigPath", "/etc/kubernetes/admin.conf", "Path to the configuration file used with the Kubernetes client")
|
||||
|
||||
// Configuration file flag
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.ConfigFile, "config-file", "", "Path to a JSON/YAML configuration file to load settings from")
|
||||
kubeVipCmd.PersistentFlags().StringVar(&initConfig.InstanceName, "instanceName", "", "Unique name for this kube-vip instance (currently used to isolate nftables egress tables)")
|
||||
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.EgressWithNftables, "egressWithNftables", true, "Use nftables-based egress implementation")
|
||||
|
||||
kubeVipCmd.PersistentFlags().BoolVar(&initConfig.PerServiceElectionOnDemand, "perServiceElectionOnDemand", false, "Allow kube-vip to use per-service election for annotated services")
|
||||
|
||||
kubeVipCmd.AddCommand(kubeKubeadm)
|
||||
kubeVipCmd.AddCommand(kubeManifest)
|
||||
kubeVipCmd.AddCommand(kubeVipManager)
|
||||
kubeVipCmd.AddCommand(kubeVipSample)
|
||||
kubeVipCmd.AddCommand(kubeVipService)
|
||||
kubeVipCmd.AddCommand(kubeVipVersion)
|
||||
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLogLoggerLevel(log.Level(initConfig.Logging))
|
||||
}
|
||||
|
||||
// Execute - starts the command parsing process
|
||||
func Execute() {
|
||||
if err := kubeVipCmd.Execute(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
func Execute() int {
|
||||
cmd, err := kubeVipCmd.ExecuteC()
|
||||
if err != nil {
|
||||
log.Error("command failed", "err", err)
|
||||
if cmd == kubeVipCmd {
|
||||
_ = cmd.Usage()
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var kubeVipVersion = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Version and Release information about the Kubernetes Virtual IP Server",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(cmd *cobra.Command, args []string) { //nolint TODO
|
||||
fmt.Printf("Kube-VIP Release Information\n")
|
||||
fmt.Printf("Version: %s\n", Release.Version)
|
||||
fmt.Printf("Build: %s\n", Release.Build)
|
||||
@@ -179,7 +213,7 @@ var kubeVipVersion = &cobra.Command{
|
||||
var kubeVipSample = &cobra.Command{
|
||||
Use: "sample",
|
||||
Short: "Generate a Sample configuration",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(cmd *cobra.Command, args []string) { //nolint TODO
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
@@ -187,18 +221,36 @@ var kubeVipSample = &cobra.Command{
|
||||
var kubeVipService = &cobra.Command{
|
||||
Use: "service",
|
||||
Short: "Start the Virtual IP / Load balancer as a service within a Kubernetes cluster",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLevel(log.Level(logLevel))
|
||||
RunE: func(cmd *cobra.Command, args []string) error { //nolint TODO
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
// parse environment variables, these will overwrite anything loaded or flags
|
||||
err := kubevip.ParseEnvironment(&initConfig)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
// Load configuration from file if specified (lowest priority)
|
||||
if initConfig.ConfigFile != "" {
|
||||
err := kubevip.MergeConfigFromFile(&initConfig, initConfig.ConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parse environment variables, these will overwrite anything loaded from config file
|
||||
err := kubevip.ParseEnvironment(&initConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing environment: %w", err)
|
||||
}
|
||||
if err := initConfig.Validate(); err != nil {
|
||||
return fmt.Errorf("validating configuration: %w", err)
|
||||
}
|
||||
|
||||
// Change RTN_UNSPEC to default type
|
||||
if initConfig.RoutingProtocol == unix.RTN_UNSPEC {
|
||||
initConfig.RoutingProtocol = unix.RTN_UNICAST
|
||||
}
|
||||
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLogLoggerLevel(log.Level(initConfig.Logging))
|
||||
|
||||
if err := initConfig.CheckInterface(); err != nil {
|
||||
log.Fatalln(err)
|
||||
return fmt.Errorf("checking interface: %w", err)
|
||||
}
|
||||
|
||||
// User Environment variables as an option to make manifest clearer
|
||||
@@ -207,68 +259,153 @@ var kubeVipService = &cobra.Command{
|
||||
configMap = envConfigMap
|
||||
}
|
||||
|
||||
// Legacy vip_address requires vip_subnet for control-plane ARP, BGP, and Routing Table modes.
|
||||
if initConfig.EnableControlPlane &&
|
||||
(initConfig.EnableARP || initConfig.EnableBGP || initConfig.EnableRoutingTable) {
|
||||
if err := initConfig.CheckSubnetExists(); err != nil {
|
||||
return fmt.Errorf("checking subnet exists if vip_address defined: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure there is an address to generate the CIDR from
|
||||
if initConfig.VIPSubnet == "" && initConfig.Address != "" {
|
||||
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating CIDR: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
// Define the new service manager
|
||||
mgr, err := manager.New(configMap, &initConfig)
|
||||
mgr, err := manager.New(ctx, configMap, &initConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
return fmt.Errorf("new manager: %w", err)
|
||||
}
|
||||
|
||||
// Start the service manager, this will watch the config Map and construct kube-vip services for it
|
||||
err = mgr.Start()
|
||||
err = mgr.Start(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
return fmt.Errorf("manager start: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var kubeVipManager = &cobra.Command{
|
||||
Use: "manager",
|
||||
Short: "Start the kube-vip manager",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// parse environment variables, these will overwrite anything loaded or flags
|
||||
RunE: func(cmd *cobra.Command, args []string) error { //nolint TODO
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
// Load configuration from file if specified (lowest priority)
|
||||
if initConfig.ConfigFile != "" {
|
||||
err := kubevip.MergeConfigFromFile(&initConfig, initConfig.ConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parse environment variables, these will overwrite anything loaded from config file
|
||||
err := kubevip.ParseEnvironment(&initConfig)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
return fmt.Errorf("parsing environment: %w", err)
|
||||
}
|
||||
if err := initConfig.Validate(); err != nil {
|
||||
return fmt.Errorf("validating configuration: %w", err)
|
||||
}
|
||||
|
||||
// Change RTN_UNSPEC to default type
|
||||
if initConfig.RoutingProtocol == unix.RTN_UNSPEC {
|
||||
initConfig.RoutingProtocol = unix.RTN_UNICAST
|
||||
}
|
||||
|
||||
// Set the logging level for all subsequent functions
|
||||
log.SetLevel(log.Level(initConfig.Logging))
|
||||
log.SetLogLoggerLevel(log.Level(initConfig.Logging))
|
||||
|
||||
// Legacy vip_address requires vip_subnet for control-plane ARP, BGP, and Routing Table modes.
|
||||
if initConfig.EnableControlPlane &&
|
||||
(initConfig.EnableARP || initConfig.EnableBGP || initConfig.EnableRoutingTable) {
|
||||
if err := initConfig.CheckSubnetExists(); err != nil {
|
||||
return fmt.Errorf("checking subnet exists if vip_address defined: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure there is an address to generate the CIDR from
|
||||
if initConfig.VIPSubnet == "" && initConfig.Address != "" {
|
||||
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating CIDR: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Welome messages
|
||||
log.Infof("Starting kube-vip.io [%s]", Release.Version)
|
||||
log.Debugf("Build kube-vip.io [%s]", Release.Build)
|
||||
log.Info("kube-vip.io", "version", Release.Version, "build", Release.Build)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
defer wg.Wait()
|
||||
|
||||
// create main manager context
|
||||
ctx, cancel := context.WithCancelCause(cmd.Context())
|
||||
defer cancel(nil)
|
||||
|
||||
// start prometheus server
|
||||
if initConfig.PrometheusHTTPServer != "" {
|
||||
go servePrometheusHTTPServer(cmd.Context(), PrometheusHTTPServerConfig{
|
||||
Addr: initConfig.PrometheusHTTPServer,
|
||||
wg.Go(func() {
|
||||
servePrometheusHTTPServer(ctx, PrometheusHTTPServerConfig{
|
||||
Addr: initConfig.PrometheusHTTPServer,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Determine the kube-vip mode
|
||||
var mode string
|
||||
var (
|
||||
mode string
|
||||
modesEnabled int
|
||||
)
|
||||
if initConfig.EnableARP {
|
||||
mode = "ARP"
|
||||
modesEnabled++
|
||||
}
|
||||
|
||||
if initConfig.EnableBGP {
|
||||
mode = "BGP"
|
||||
modesEnabled++
|
||||
}
|
||||
|
||||
if initConfig.EnableWireguard {
|
||||
mode = "Wireguard"
|
||||
modesEnabled++
|
||||
}
|
||||
|
||||
if initConfig.EnableRoutingTable {
|
||||
mode = "Routing Table"
|
||||
modesEnabled++
|
||||
}
|
||||
|
||||
if mode == "" {
|
||||
return fmt.Errorf("no valid kube-vip mode detected, ensure a supported mode is configured")
|
||||
}
|
||||
|
||||
if modesEnabled > 1 {
|
||||
return fmt.Errorf("multiple kube-vip modes detected, ensure only one mode is configured")
|
||||
}
|
||||
|
||||
// Provide configuration to output/logging
|
||||
log.Infof("namespace [%s], Mode: [%s], Features(s): Control Plane:[%t], Services:[%t]", initConfig.Namespace, mode, initConfig.EnableControlPlane, initConfig.EnableServices)
|
||||
log.Info("starting", "namespace", initConfig.Namespace, "Mode", mode, "Control Plane", initConfig.EnableControlPlane, "Services", initConfig.EnableServices)
|
||||
|
||||
// End if nothing is enabled
|
||||
if !initConfig.EnableServices && !initConfig.EnableControlPlane {
|
||||
log.Fatalln("no features are enabled")
|
||||
return fmt.Errorf("no features are enabled")
|
||||
}
|
||||
|
||||
if !initConfig.EnableARP && strings.Contains(initConfig.VIPSubnet, kubevip.Auto) {
|
||||
return fmt.Errorf("auto subnet discovery cannot be used outside ARP mode")
|
||||
}
|
||||
|
||||
if strings.Contains(initConfig.VIPSubnet, kubevip.Auto) && initConfig.Address != "" {
|
||||
return fmt.Errorf("auto subnet discovery cannot be used if VIP address was provided")
|
||||
}
|
||||
|
||||
// If we're using wireguard then all traffic goes through the wg0 interface
|
||||
@@ -278,48 +415,49 @@ var kubeVipManager = &cobra.Command{
|
||||
initConfig.Interface = "wg0"
|
||||
}
|
||||
|
||||
log.Infof("configuring Wireguard networking")
|
||||
log.Info("configuring Wireguard networking")
|
||||
l, err := netlink.LinkByName(initConfig.Interface)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Link not found") {
|
||||
log.Warnf("interface \"%s\" doesn't exist, attempting to create wireguard interface", initConfig.Interface)
|
||||
log.Warn("attempting to create wireguard interface", "interface not found", initConfig.Interface)
|
||||
err = netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: initConfig.Interface}})
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
return fmt.Errorf("adding link: %w", err)
|
||||
}
|
||||
l, err = netlink.LinkByName(initConfig.Interface)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
return fmt.Errorf("finding link: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
err = netlink.LinkSetUp(l)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
return fmt.Errorf("setting link UP: %w", err)
|
||||
}
|
||||
|
||||
} else { // if we're not using Wireguard then we'll need to use an actual interface
|
||||
// Check if the interface needs auto-detecting
|
||||
if initConfig.Interface == "" {
|
||||
log.Infof("No interface is specified for VIP in config, auto-detecting default Interface")
|
||||
log.Info("No interface is specified for VIP in config, auto-detecting default Interface")
|
||||
defaultIF, err := vip.GetDefaultGatewayInterface()
|
||||
if err != nil {
|
||||
_ = cmd.Help()
|
||||
log.Fatalf("unable to detect default interface -> [%v]", err)
|
||||
return fmt.Errorf("detecting interface: %w", err)
|
||||
}
|
||||
initConfig.Interface = defaultIF.Name
|
||||
log.Infof("kube-vip will bind to interface [%s]", initConfig.Interface)
|
||||
log.Info("kube-vip bind", "interface", initConfig.Interface)
|
||||
|
||||
go func() {
|
||||
if err := vip.MonitorDefaultInterface(context.TODO(), defaultIF); err != nil {
|
||||
log.Fatalf("crash: %s", err.Error())
|
||||
wg.Go(func() {
|
||||
if err := vip.MonitorDefaultInterface(ctx, defaultIF); err != nil {
|
||||
log.Error("interface monitor", "err", err)
|
||||
cancel(err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
// Perform a check on th state of the interface
|
||||
// Perform a check on the state of the interface
|
||||
if err := initConfig.CheckInterface(); err != nil {
|
||||
log.Fatalln(err)
|
||||
return fmt.Errorf("checking interface: %w", err)
|
||||
}
|
||||
|
||||
// User Environment variables as an option to make manifest clearer
|
||||
@@ -328,31 +466,21 @@ var kubeVipManager = &cobra.Command{
|
||||
configMap = envConfigMap
|
||||
}
|
||||
|
||||
// If Equinix Metal is enabled and there is a provider configuration passed
|
||||
if initConfig.EnableMetal {
|
||||
if providerConfig != "" {
|
||||
providerAPI, providerProject, err := equinixmetal.GetPacketConfig(providerConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
initConfig.MetalAPIKey = providerAPI
|
||||
initConfig.MetalProject = providerProject
|
||||
}
|
||||
}
|
||||
|
||||
// Define the new service manager
|
||||
mgr, err := manager.New(configMap, &initConfig)
|
||||
mgr, err := manager.New(ctx, configMap, &initConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("configuring new Manager error -> %v", err)
|
||||
return fmt.Errorf("new manager: %w", err)
|
||||
}
|
||||
|
||||
prometheus.MustRegister(mgr.PrometheusCollector()...)
|
||||
metrics.RegisterPrometheusMetrics()
|
||||
metrics.BuildInfo.WithLabelValues(Release.Version, Release.Build, initConfig.NodeName)
|
||||
|
||||
// Start the service manager, this will watch the config Map and construct kube-vip services for it
|
||||
err = mgr.Start()
|
||||
err = mgr.Start(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("starting new Manager error -> %v", err)
|
||||
return fmt.Errorf("start manager: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -366,7 +494,7 @@ func servePrometheusHTTPServer(ctx context.Context, config PrometheusHTTPServerC
|
||||
var err error
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { //nolint TODO
|
||||
_, _ = w.Write([]byte(`<html>
|
||||
<head><title>kube-vip</title></head>
|
||||
<body>
|
||||
@@ -382,28 +510,70 @@ func servePrometheusHTTPServer(ctx context.Context, config PrometheusHTTPServerC
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err = srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen:%+s\n", err)
|
||||
}
|
||||
}()
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
log.Printf("prometheus HTTP server started")
|
||||
wg.Go(func() {
|
||||
if err = srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("prometheus HTTP server", "err", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
log.Info("prometheus HTTP server started")
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
log.Printf("prometheus HTTP server stopped")
|
||||
|
||||
// create prometheus shutdown context (independent of other contexts)
|
||||
ctxShutDown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer func() {
|
||||
cancel()
|
||||
}()
|
||||
|
||||
if err = srv.Shutdown(ctxShutDown); err != nil {
|
||||
log.Fatalf("server Shutdown Failed:%+s", err)
|
||||
log.Error("shutting down prometheus HTTP server", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err == http.ErrServerClosed {
|
||||
err = nil
|
||||
}
|
||||
|
||||
log.Info("prometheus HTTP server stopped")
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func GenerateCidrRange(address string, dnsMode string) (string, error) {
|
||||
var cidrs []string
|
||||
|
||||
addresses := strings.Split(address, ",")
|
||||
for _, a := range addresses {
|
||||
ip := net.ParseIP(a)
|
||||
if ip == nil {
|
||||
// we probably are a DNS name
|
||||
ips, err := utils.LookupHost(a, dnsMode, true)
|
||||
if len(ips) == 0 || err != nil {
|
||||
return "", fmt.Errorf("invalid IP address: %s from [%s], %v", a, address, err)
|
||||
}
|
||||
for _, addr := range ips {
|
||||
ip = net.ParseIP(addr)
|
||||
if ip.To4() != nil {
|
||||
cidrs = append(cidrs, strconv.Itoa(vip.DefaultMaskIPv4))
|
||||
} else {
|
||||
cidrs = append(cidrs, strconv.Itoa(vip.DefaultMaskIPv6))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ip.To4() != nil {
|
||||
cidrs = append(cidrs, strconv.Itoa(vip.DefaultMaskIPv4))
|
||||
} else {
|
||||
cidrs = append(cidrs, strconv.Itoa(vip.DefaultMaskIPv6))
|
||||
}
|
||||
}
|
||||
}
|
||||
// compact as DNS could have a lot of addresses
|
||||
slices.Sort(cidrs)
|
||||
cidrs = slices.Compact(cidrs)
|
||||
slices.Reverse(cidrs)
|
||||
return strings.Join(cidrs, ","), nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func main() {
|
||||
var errorOccurred bool
|
||||
for {
|
||||
p := make([]byte, 2048)
|
||||
conn, err := net.Dial("udp", fmt.Sprintf("%s:%d", *address, *port))
|
||||
conn, err := net.Dial("udp", net.JoinHostPort(*address, fmt.Sprint(port)))
|
||||
if err != nil {
|
||||
if !errorOccurred {
|
||||
errorTime = time.Now()
|
||||
|
||||
@@ -60,7 +60,7 @@ func main() {
|
||||
fmt.Println("error: ", err)
|
||||
}
|
||||
|
||||
ServerConn.WriteTo(buf[0:n])
|
||||
ServerConn.WriteTo(buf[0:n], addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: kube-vip-cluster
|
||||
name: kube-vip-cluster
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: kube-vip-cluster
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: kube-vip-cluster
|
||||
spec:
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: "app"
|
||||
operator: In
|
||||
values:
|
||||
- kube-vip-cluster
|
||||
topologyKey: "kubernetes.io/hostname"
|
||||
containers:
|
||||
- image: ghcr.io/kube-vip/kube-vip:0.3.7
|
||||
imagePullPolicy: Always
|
||||
name: kube-vip
|
||||
command:
|
||||
- /kube-vip
|
||||
- service
|
||||
- --configMap
|
||||
- plndr-configmap
|
||||
- --arp
|
||||
- --interface
|
||||
- ens192
|
||||
- --log
|
||||
- "5"
|
||||
resources: {}
|
||||
securityContext:
|
||||
capabilities:
|
||||
add:
|
||||
- NET_ADMIN
|
||||
hostNetwork: true
|
||||
status: {}
|
||||
---
|
||||
kind: Role
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: lease-access
|
||||
rules:
|
||||
- apiGroups: ["coordination.k8s.io"]
|
||||
resources: ["leases"]
|
||||
verbs: ["get", "create", "update", "list", "put"]
|
||||
- apiGroups: [""]
|
||||
resources: ["configMap"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
kind: RoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: lease-access
|
||||
subjects:
|
||||
- kind: User
|
||||
name: system:serviceaccount:default:default
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: lease-access
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -1,83 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: vip
|
||||
---
|
||||
kind: Role
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: vip-role
|
||||
rules:
|
||||
- apiGroups: ["coordination.k8s.io"]
|
||||
resources: ["leases"]
|
||||
verbs: ["get", "create", "update", "list", "put"]
|
||||
- apiGroups: [""]
|
||||
resources: ["configmaps", "endpoints"]
|
||||
verbs: ["watch", "get"]
|
||||
---
|
||||
kind: RoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: vip-role-bind
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: vip
|
||||
apiGroup: ""
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: vip-role
|
||||
apiGroup: ""
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: kube-vip-cluster
|
||||
name: kube-vip-cluster
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: kube-vip-cluster
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: kube-vip-cluster
|
||||
spec:
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: "app"
|
||||
operator: In
|
||||
values:
|
||||
- kube-vip-cluster
|
||||
topologyKey: "kubernetes.io/hostname"
|
||||
containers:
|
||||
- image: ghcr.io/kube-vip/kube-vip:0.3.7
|
||||
imagePullPolicy: Always
|
||||
name: kube-vip
|
||||
command:
|
||||
- /kube-vip
|
||||
- service
|
||||
env:
|
||||
- name: vip_interface
|
||||
value: "ens192"
|
||||
- name: vip_configmap
|
||||
value: "plndr"
|
||||
- name: vip_arp
|
||||
value: "true"
|
||||
- name: vip_loglevel
|
||||
value: "5"
|
||||
resources: {}
|
||||
securityContext:
|
||||
capabilities:
|
||||
add:
|
||||
- NET_ADMIN
|
||||
hostNetwork: true
|
||||
serviceAccountName: vip
|
||||
status: {}
|
||||
@@ -1,83 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: vip
|
||||
---
|
||||
kind: Role
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: vip-role
|
||||
rules:
|
||||
- apiGroups: ["coordination.k8s.io"]
|
||||
resources: ["leases"]
|
||||
verbs: ["get", "create", "update", "list", "put"]
|
||||
- apiGroups: [""]
|
||||
resources: ["configmaps", "endpoints"]
|
||||
verbs: ["watch", "get"]
|
||||
---
|
||||
kind: RoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: vip-role-bind
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: vip
|
||||
apiGroup: ""
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: vip-role
|
||||
apiGroup: ""
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: kube-vip-cluster
|
||||
name: kube-vip-cluster
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: kube-vip-cluster
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: kube-vip-cluster
|
||||
spec:
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: "app"
|
||||
operator: In
|
||||
values:
|
||||
- kube-vip-cluster
|
||||
topologyKey: "kubernetes.io/hostname"
|
||||
containers:
|
||||
- image: plndr/kube-vip:0.1.4
|
||||
imagePullPolicy: Always
|
||||
name: kube-vip
|
||||
command:
|
||||
- /kube-vip
|
||||
- service
|
||||
env:
|
||||
- name: vip_interface
|
||||
value: "ens192"
|
||||
- name: vip_configmap
|
||||
value: "plndr"
|
||||
- name: vip_arp
|
||||
value: "true"
|
||||
- name: vip_loglevel
|
||||
value: "5"
|
||||
resources: {}
|
||||
securityContext:
|
||||
capabilities:
|
||||
add:
|
||||
- NET_ADMIN
|
||||
hostNetwork: true
|
||||
serviceAccountName: vip
|
||||
status: {}
|
||||
@@ -1,55 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: kube-vip-ds
|
||||
namespace: kube-system
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
name: kube-vip-ds
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
name: kube-vip-ds
|
||||
spec:
|
||||
containers:
|
||||
- args:
|
||||
- manager
|
||||
env:
|
||||
- name: vip_arp
|
||||
value: "true"
|
||||
- name: vip_interface
|
||||
value: eth0
|
||||
- name: port
|
||||
value: "6443"
|
||||
- name: vip_cidr
|
||||
value: "32"
|
||||
- name: svc_enable
|
||||
value: "true"
|
||||
- name: vip_startleader
|
||||
value: "false"
|
||||
- name: vip_addpeerstolb
|
||||
value: "true"
|
||||
- name: vip_localpeer
|
||||
value: ip-172-20-40-207:172.20.40.207:10000
|
||||
- name: vip_address
|
||||
image: plndr/kube-vip:v0.3.5
|
||||
imagePullPolicy: Always
|
||||
name: kube-vip
|
||||
resources: {}
|
||||
securityContext:
|
||||
capabilities:
|
||||
add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
- SYS_TIME
|
||||
hostNetwork: true
|
||||
serviceAccountName: kube-vip
|
||||
updateStrategy: {}
|
||||
status:
|
||||
currentNumberScheduled: 0
|
||||
desiredNumberScheduled: 0
|
||||
numberMisscheduled: 0
|
||||
numberReady: 0
|
||||
239
go.mod
239
go.mod
@@ -1,133 +1,154 @@
|
||||
module github.com/kube-vip/kube-vip
|
||||
|
||||
go 1.21
|
||||
|
||||
toolchain go1.21.3
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/cloudflare/ipvs v0.10.1
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
github.com/florianl/go-conntrack v0.4.0
|
||||
github.com/golang/protobuf v1.5.4
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/insomniacslk/dhcp v0.0.0-20230731140434-0f9eb93a696c
|
||||
github.com/cloudflare/ipvs v0.12.0
|
||||
github.com/containernetworking/plugins v1.9.1
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/florianl/go-conntrack v0.7.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/go-containerregistry v0.22.1
|
||||
github.com/google/nftables v0.3.0
|
||||
github.com/gookit/slog v0.7.1
|
||||
github.com/huin/goupnp v1.3.0
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
|
||||
github.com/jpillora/backoff v1.0.0
|
||||
github.com/kamhlos/upnp v0.0.0-20210324072331-5661950dff08
|
||||
github.com/mdlayher/ndp v1.0.1
|
||||
github.com/onsi/ginkgo/v2 v2.19.0
|
||||
github.com/onsi/gomega v1.33.1
|
||||
github.com/osrg/gobgp/v3 v3.27.0
|
||||
github.com/packethost/packngo v0.31.0
|
||||
github.com/mdlayher/ndp v1.1.0
|
||||
github.com/onsi/ginkgo/v2 v2.32.2
|
||||
github.com/onsi/gomega v1.43.0
|
||||
github.com/osrg/gobgp/v4 v4.9.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/vishvananda/netlink v1.2.1-beta.2
|
||||
go.etcd.io/etcd/api/v3 v3.5.13
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.13
|
||||
go.etcd.io/etcd/client/v3 v3.5.13
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8
|
||||
golang.org/x/sys v0.20.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
k8s.io/api v0.29.1
|
||||
k8s.io/apimachinery v0.29.3
|
||||
k8s.io/client-go v0.29.1
|
||||
k8s.io/klog/v2 v2.120.1
|
||||
sigs.k8s.io/kind v0.22.0
|
||||
sigs.k8s.io/yaml v1.4.0
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/vishvananda/netlink v1.3.2-0.20260830232854-cf01b55a4a4b
|
||||
github.com/vishvananda/netns v0.0.5
|
||||
go.etcd.io/etcd/api/v3 v3.7.1
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.7.1
|
||||
go.etcd.io/etcd/client/v3 v3.7.1
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
google.golang.org/grpc v1.83.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/api v0.36.4
|
||||
k8s.io/apimachinery v0.36.4
|
||||
k8s.io/client-go v0.36.4
|
||||
k8s.io/klog/v2 v2.140.0
|
||||
sigs.k8s.io/kind v0.33.0
|
||||
sigs.k8s.io/yaml v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.3.2 // indirect
|
||||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
al.essio.dev/pkg/shellescape v1.5.1 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/coreos/go-semver v0.3.0 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/coreos/go-semver v0.3.1 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/eapache/channels v1.1.0 // indirect
|
||||
github.com/eapache/queue v1.1.0 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.22.3 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gaissmai/bart v0.26.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/gnostic-models v0.6.8 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect
|
||||
github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/imdario/mergo v0.3.12 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gookit/color v1.6.1 // indirect
|
||||
github.com/gookit/goutil v0.7.6 // indirect
|
||||
github.com/gookit/gsr v0.1.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/k-sone/critbitgo v1.4.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
github.com/mdlayher/packet v1.1.2 // indirect
|
||||
github.com/mdlayher/socket v0.4.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/morikuni/aec v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml v1.9.4 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.18 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/spf13/afero v1.9.5 // indirect
|
||||
github.com/spf13/cast v1.5.1 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.16.0 // indirect
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/tj/go-spin v1.1.0 // indirect
|
||||
github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/xlab/c-for-go v0.0.0-20230906092656-a1822f0a09c1 // indirect
|
||||
github.com/xlab/pkgconfig v0.0.0-20170226114623-cea12a0fd245 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/oauth2 v0.16.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/term v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.21.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect
|
||||
google.golang.org/grpc v1.59.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/segmentio/fasthash v1.0.3 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.20.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/mod v0.39.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
|
||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
|
||||
modernc.org/cc/v4 v4.1.0 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.0.1 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
|
||||
)
|
||||
|
||||
4
main.go
4
main.go
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/kube-vip/kube-vip/cmd"
|
||||
)
|
||||
|
||||
@@ -14,5 +16,5 @@ func main() {
|
||||
|
||||
cmd.Release.Version = Version
|
||||
cmd.Release.Build = Build
|
||||
cmd.Execute()
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
|
||||
296
pkg/arp/arp.go
Normal file
296
pkg/arp/arp.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package arp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
log "log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
const linkSubscriptionBuffer = 64
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
instances map[string]*Instance
|
||||
config *kubevip.Config
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
network vip.Network
|
||||
ndp *vip.NdpResponder
|
||||
counter int
|
||||
}
|
||||
|
||||
func NewManager(config *kubevip.Config) *Manager {
|
||||
if config.ArpBroadcastRate < 500 {
|
||||
log.Warn("[ARP manager] arp broadcast rate is too low", "rate (ms)", config.ArpBroadcastRate, "setting to (ms)", "3000")
|
||||
config.ArpBroadcastRate = 3000
|
||||
}
|
||||
return &Manager{
|
||||
instances: make(map[string]*Instance),
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func NewInstance(network vip.Network, ndp *vip.NdpResponder) *Instance {
|
||||
return &Instance{
|
||||
ndp: ndp,
|
||||
network: network,
|
||||
counter: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Instance) Name() string {
|
||||
return i.network.ARPName()
|
||||
}
|
||||
|
||||
func (m *Manager) Insert(instance *Instance) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
existing := m.instances[instance.Name()]
|
||||
if existing == nil {
|
||||
m.instances[instance.Name()] = instance
|
||||
log.Info("[ARP manager] inserting ARP/NDP instance", "name", instance.Name())
|
||||
return
|
||||
}
|
||||
existing.counter++
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(instance *Instance) {
|
||||
m.RemoveWithIPDelete(instance, true)
|
||||
}
|
||||
|
||||
// RemoveOnLeadershipLoss removes an ARP instance when leadership is lost
|
||||
func (m *Manager) RemoveOnLeadershipLoss(instance *Instance) {
|
||||
// Use the inverse of PreserveVIPOnLeadershipLoss to decide whether to delete the IP
|
||||
// If preserve is true, don't delete IP (deleteIP = false)
|
||||
// If preserve is false, delete IP (deleteIP = true), This is the legacy behavior
|
||||
deleteIP := !m.config.PreserveVIPOnLeadershipLoss
|
||||
m.RemoveWithIPDelete(instance, deleteIP)
|
||||
}
|
||||
|
||||
func (m *Manager) RemoveWithIPDelete(instance *Instance, deleteIP bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
i := m.instances[instance.Name()]
|
||||
if i != nil {
|
||||
i.counter--
|
||||
if i.counter == 0 {
|
||||
log.Info("[ARP manager] removing ARP/NDP instance", "name", instance.Name())
|
||||
if deleteIP {
|
||||
if _, err := instance.network.DeleteIP(); err != nil {
|
||||
log.Error("failed to delete IP", "address", instance.network.IP(), "err", err)
|
||||
}
|
||||
}
|
||||
delete(m.instances, instance.Name())
|
||||
}
|
||||
} else {
|
||||
log.Warn("[ARP manager] unable to remove the instance - instance not found", "name", instance.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Count(name string) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
i := m.instances[name]
|
||||
if i != nil {
|
||||
return i.counter
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Manager) StartAdvertisement(ctx context.Context, killFunc func()) {
|
||||
if m.config.LoseLeadership {
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
|
||||
log.Info("[ARP manager] starting watching network device", "interface", m.config.Interface)
|
||||
|
||||
duration := time.Duration(m.config.LoseLeadershipTimeoutSeconds) * time.Second
|
||||
timeout := time.NewTimer(duration)
|
||||
timeout.Stop()
|
||||
|
||||
wg.Go(func() {
|
||||
select {
|
||||
case <-timeout.C:
|
||||
killFunc()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
wg.Go(func() {
|
||||
if err := watch(ctx, m.config.Interface, func(s netlink.LinkOperState) {
|
||||
if isUp(s) {
|
||||
timeout.Stop()
|
||||
return
|
||||
}
|
||||
timeout.Reset(duration)
|
||||
}); err != nil {
|
||||
log.Error("[ARP manager] stopped watching interface", "err", err)
|
||||
killFunc()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
log.Info("[ARP manager] starting ARP/NDP advertisement")
|
||||
|
||||
ticker := time.NewTicker(time.Duration(m.config.ArpBroadcastRate) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): // if cancel() execute
|
||||
return
|
||||
case <-ticker.C: // send gratuitous ARP/NDP on each tick
|
||||
m.advertiseAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) advertiseAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, instance := range m.instances {
|
||||
if instance.counter > 0 {
|
||||
ensureIPAndSendGratuitous(instance)
|
||||
} else if _, err := instance.network.DeleteIP(); err != nil {
|
||||
log.Error("[ARP manager] failed to delete IP", "address", instance.network.IP(), "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureIPAndSendGratuitous - adds IP to the interface if missing, and send
|
||||
// either a gratuitous ARP or gratuitous NDP. Re-adds the interface if it is IPv6
|
||||
// and in a dadfailed state.
|
||||
func ensureIPAndSendGratuitous(instance *Instance) {
|
||||
iface := instance.network.Interface()
|
||||
ipString := instance.network.IP()
|
||||
|
||||
// Check if IP is dadfailed
|
||||
if instance.network.IsDADFAIL() {
|
||||
log.Warn("IP address is in dadfailed state, removing config", "ip", ipString, "interface", iface)
|
||||
deleted, err := instance.network.DeleteIP()
|
||||
if err != nil {
|
||||
log.Warn(err.Error())
|
||||
}
|
||||
if deleted {
|
||||
log.Info("deleted and recreating address with NODAD flag to skip DAD", "IP", ipString, "interface", iface)
|
||||
// Re-add immediately without DAD check since we're recovering from DADFAILED
|
||||
// The AddIP function will set IFA_F_NODAD flag for IPv6 addresses when skipDAD=true
|
||||
if _, err := instance.network.AddIP(false, true); err != nil {
|
||||
log.Error("failed to recreate address after DADFAILED", "IP", ipString, "interface", iface, "err", err)
|
||||
} else {
|
||||
log.Info("successfully recreated address after DADFAILED recovery", "IP", ipString, "interface", iface)
|
||||
}
|
||||
}
|
||||
// Return early after DADFAILED recovery to avoid double IP addition
|
||||
return
|
||||
}
|
||||
|
||||
// Normal case: add IP with precheck and normal DAD process
|
||||
if added, err := instance.network.AddIP(true, false); err != nil {
|
||||
log.Warn(err.Error())
|
||||
} else if added {
|
||||
log.Warn("Re-applied the VIP configuration", "ip", ipString, "interface", iface)
|
||||
}
|
||||
|
||||
if utils.IsIPv6(ipString) {
|
||||
// Gratuitous NDP, will broadcast new MAC <-> IPv6 address
|
||||
if instance.ndp == nil {
|
||||
log.Error("NDP responder was not created")
|
||||
} else {
|
||||
err := instance.ndp.SendGratuitous(ipString)
|
||||
if err != nil {
|
||||
log.Warn(err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Gratuitous ARP, will broadcast to new MAC <-> IPv4 address
|
||||
err := vip.ARPSendGratuitous(ipString, iface)
|
||||
if err != nil {
|
||||
log.Warn(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watch subscribing to the network interface events and calls handler
|
||||
func watch(ctx context.Context, interfaceName string, operStateHandler func(netlink.LinkOperState)) error {
|
||||
ifname, err := netlink.LinkByName(interfaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to watch interface %q: %w", interfaceName, err)
|
||||
}
|
||||
|
||||
// verify if this interface is physical device
|
||||
if _, ok := ifname.(*netlink.Device); !ok {
|
||||
return fmt.Errorf("interface %s is not physical, ignoring", interfaceName)
|
||||
}
|
||||
|
||||
// The subscription is buffered and drained on exit: netlink parks its reader
|
||||
// goroutine on an unread send, which closing done alone does not release.
|
||||
events := make(chan netlink.LinkUpdate, linkSubscriptionBuffer)
|
||||
done := make(chan struct{})
|
||||
|
||||
if err := netlink.LinkSubscribe(events, done); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to the interface events: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
close(done)
|
||||
drainLinkUpdates(events)
|
||||
}()
|
||||
|
||||
// handle initial state
|
||||
operStateHandler(ifname.Attrs().OperState)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
return fmt.Errorf("interface events channel closed")
|
||||
}
|
||||
|
||||
attrs := event.Attrs()
|
||||
// LinkSubscribe captures events for all network devices found
|
||||
// so we only care about vip interface
|
||||
if ifname.Attrs().Name != attrs.Name {
|
||||
continue
|
||||
}
|
||||
log.Debug("handling device change", "state", attrs.OperState)
|
||||
operStateHandler(attrs.OperState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isUp(operState netlink.LinkOperState) bool {
|
||||
return operState == netlink.OperUp
|
||||
}
|
||||
|
||||
// drainLinkUpdates releases a netlink sender that is parked on an unread update
|
||||
// so its goroutine can observe the closed subscription and exit.
|
||||
func drainLinkUpdates(events <-chan netlink.LinkUpdate) {
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
case <-timer.C:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
119
pkg/arp/arp_test.go
Normal file
119
pkg/arp/arp_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package arp
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/vishvananda/netlink"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func TestDrainLinkUpdatesReleasesParkedSender(t *testing.T) {
|
||||
events := make(chan netlink.LinkUpdate)
|
||||
sent := make(chan struct{})
|
||||
go func() {
|
||||
events <- netlink.LinkUpdate{}
|
||||
close(sent)
|
||||
}()
|
||||
|
||||
drainLinkUpdates(events)
|
||||
|
||||
select {
|
||||
case <-sent:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("netlink sender is still parked on an unread link update")
|
||||
}
|
||||
}
|
||||
|
||||
// stubNetwork is a minimal vip.Network implementation; only ARPName matters here.
|
||||
type stubNetwork struct {
|
||||
name string
|
||||
deleteStarted chan struct{}
|
||||
releaseDelete chan struct{}
|
||||
}
|
||||
|
||||
func (s *stubNetwork) AddIP(bool, bool, ...int) (bool, error) { return false, nil }
|
||||
func (s *stubNetwork) AddRoute(bool) (bool, error) { return false, nil }
|
||||
func (s *stubNetwork) ReplaceRoute() error { return nil }
|
||||
func (s *stubNetwork) DeleteIP() (bool, error) {
|
||||
if s.deleteStarted != nil {
|
||||
close(s.deleteStarted)
|
||||
<-s.releaseDelete
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
func (s *stubNetwork) DeleteRoute() error { return nil }
|
||||
func (s *stubNetwork) UpdateRoutes() (bool, error) { return false, nil }
|
||||
func (s *stubNetwork) IsSet() (*netlink.Addr, error) { return nil, nil }
|
||||
func (s *stubNetwork) IP() string { return "" }
|
||||
func (s *stubNetwork) CIDR() string { return "" }
|
||||
func (s *stubNetwork) IPisLinkLocal() bool { return false }
|
||||
func (s *stubNetwork) PrepareRoute() *netlink.Route { return nil }
|
||||
func (s *stubNetwork) RouteHash() string { return "" }
|
||||
func (s *stubNetwork) SetIP(string) error { return nil }
|
||||
func (s *stubNetwork) SetServicePorts(*v1.Service) {}
|
||||
func (s *stubNetwork) Interface() string { return "eth0" }
|
||||
func (s *stubNetwork) IsDADFAIL() bool { return false }
|
||||
func (s *stubNetwork) IsDNS() bool { return false }
|
||||
func (s *stubNetwork) IsDDNS() bool { return false }
|
||||
func (s *stubNetwork) DDNSHostName() string { return "" }
|
||||
func (s *stubNetwork) DNSName() string { return "" }
|
||||
func (s *stubNetwork) SetMask(string) error { return nil }
|
||||
func (s *stubNetwork) SetHasEndpoints(bool) {}
|
||||
func (s *stubNetwork) HasEndpoints() bool { return false }
|
||||
func (s *stubNetwork) ARPName() string { return s.name }
|
||||
func (s *stubNetwork) GetPossibleSubnets() string { return "" }
|
||||
func (s *stubNetwork) DHCPFamily() string { return "" }
|
||||
func (s *stubNetwork) IPVSMark() uint32 { return 0 }
|
||||
|
||||
// TestManagerInsertConcurrentFirstRegistrationsDoNotLoseClaims guards the
|
||||
// get-then-store race: two never-before-seen instances for the same ARP name
|
||||
// registering concurrently must both be counted, not just the last writer.
|
||||
func TestManagerInsertConcurrentFirstRegistrationsDoNotLoseClaims(t *testing.T) {
|
||||
m := NewManager(&kubevip.Config{ArpBroadcastRate: 3000})
|
||||
const concurrent = 8
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range concurrent {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
m.Insert(NewInstance(&stubNetwork{name: "shared"}, nil))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := m.Count("shared"); got != concurrent {
|
||||
t.Fatalf("Count() = %d, want %d claims registered", got, concurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerInsertDoesNotJoinEntryBeingRemoved(t *testing.T) {
|
||||
m := NewManager(&kubevip.Config{ArpBroadcastRate: 3000})
|
||||
deleteStarted := make(chan struct{})
|
||||
releaseDelete := make(chan struct{})
|
||||
first := NewInstance(&stubNetwork{name: "shared", deleteStarted: deleteStarted, releaseDelete: releaseDelete}, nil)
|
||||
m.Insert(first)
|
||||
|
||||
removeDone := make(chan struct{})
|
||||
go func() {
|
||||
m.Remove(first)
|
||||
close(removeDone)
|
||||
}()
|
||||
<-deleteStarted
|
||||
|
||||
insertDone := make(chan struct{})
|
||||
go func() {
|
||||
m.Insert(NewInstance(&stubNetwork{name: "shared"}, nil))
|
||||
close(insertDone)
|
||||
}()
|
||||
close(releaseDelete)
|
||||
<-removeDone
|
||||
<-insertDone
|
||||
|
||||
if got := m.Count("shared"); got != 1 {
|
||||
t.Fatalf("Count() = %d, want replacement claim registered", got)
|
||||
}
|
||||
}
|
||||
107
pkg/backend/backend.go
Normal file
107
pkg/backend/backend.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/k8s"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Addr string
|
||||
Port uint16
|
||||
IsLocal bool
|
||||
}
|
||||
|
||||
type Map map[Entry]bool
|
||||
|
||||
// kubeConfigPath is an explicitly configured kubeconfig used by Check when
|
||||
// set; static pod deployments configure it since neither admin.conf nor
|
||||
// in-cluster config are available there.
|
||||
var (
|
||||
kubeConfigPath string
|
||||
pathMtx sync.Mutex
|
||||
)
|
||||
|
||||
// SetKubeConfigPath configures the kubeconfig used by backend health checks.
|
||||
func SetKubeConfigPath(path string) {
|
||||
pathMtx.Lock()
|
||||
defer pathMtx.Unlock()
|
||||
kubeConfigPath = path
|
||||
}
|
||||
|
||||
func (e *Entry) Check() bool {
|
||||
var client *kubernetes.Clientset
|
||||
var err error
|
||||
var config *rest.Config
|
||||
|
||||
adminConfigPath := "/etc/kubernetes/admin.conf"
|
||||
// TODO: add one more switch case of homeConfigPath if there is such scenario in future
|
||||
// homeConfigPath := filepath.Join(os.Getenv("HOME"), ".kube", "config")
|
||||
|
||||
var k8sAddr string
|
||||
if utils.IsIPv6(e.Addr) {
|
||||
k8sAddr = fmt.Sprintf("[%s]:%v", e.Addr, e.Port)
|
||||
} else {
|
||||
k8sAddr = fmt.Sprintf("%s:%v", e.Addr, e.Port)
|
||||
}
|
||||
|
||||
switch {
|
||||
case kubeConfigPath != "" && utils.FileExists(kubeConfigPath):
|
||||
config, err = k8s.NewRestConfig(kubeConfigPath, false, k8sAddr)
|
||||
if err != nil {
|
||||
log.Error("create k8s REST config", "path", kubeConfigPath, "err", err)
|
||||
return false
|
||||
}
|
||||
case utils.FileExists(adminConfigPath):
|
||||
config, err = k8s.NewRestConfig(adminConfigPath, false, k8sAddr)
|
||||
if err != nil {
|
||||
log.Error("create k8s REST config", "path", adminConfigPath, "err", err)
|
||||
return false
|
||||
}
|
||||
default:
|
||||
config, err = k8s.NewRestConfig("", true, k8sAddr)
|
||||
if err != nil {
|
||||
log.Error("create k8s REST config", "err", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
client, err = k8s.NewClientset(config)
|
||||
if err != nil {
|
||||
log.Error("create k8s client", "err", err)
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.DiscoveryClient.ServerVersion()
|
||||
if err != nil {
|
||||
log.Error("discover k8s version", "err", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func Watch(ctx context.Context, interval int, tickAction func()) {
|
||||
if interval <= 0 {
|
||||
interval = 5
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(time.Second * time.Duration(interval))
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
tickAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,46 +3,80 @@ package bgp
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
log "log/slog"
|
||||
"net"
|
||||
|
||||
api "github.com/osrg/gobgp/v3/api"
|
||||
"github.com/osrg/gobgp/v4/pkg/apiutil"
|
||||
)
|
||||
|
||||
// AddHost will update peers of a host
|
||||
func (b *Server) AddHost(addr string) (err error) {
|
||||
ip, _, err := net.ParseCIDR(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
func (b *Server) AddHost(ctx context.Context, addr string, object string) error {
|
||||
b.mtx.Lock()
|
||||
defer b.mtx.Unlock()
|
||||
|
||||
objects, exists := b.tracker[addr]
|
||||
|
||||
if !exists {
|
||||
b.tracker[addr] = make(map[string]bool)
|
||||
objects = b.tracker[addr]
|
||||
|
||||
ip, _, err := net.ParseCIDR(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p := b.getPath(ip)
|
||||
if p == nil {
|
||||
return fmt.Errorf("failed to get path for %v", ip)
|
||||
}
|
||||
|
||||
if _, err := b.s.AddPath(apiutil.AddPathRequest{
|
||||
Paths: []*apiutil.Path{p},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("[BGP] added host", "addr", addr, "cnt", len(objects)+1, "object", object)
|
||||
}
|
||||
|
||||
p := b.getPath(ip)
|
||||
if p == nil {
|
||||
return fmt.Errorf("failed to get path for %v", ip)
|
||||
}
|
||||
objects[object] = true
|
||||
|
||||
_, err = b.s.AddPath(context.Background(), &api.AddPathRequest{
|
||||
Path: p,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// DelHost will inform peers to remove a host
|
||||
func (b *Server) DelHost(addr string) (err error) {
|
||||
func (b *Server) DelHost(ctx context.Context, addr string, object string) error {
|
||||
b.mtx.Lock()
|
||||
defer b.mtx.Unlock()
|
||||
|
||||
objects, exists := b.tracker[addr]
|
||||
if !exists {
|
||||
log.Debug("[BGP] deleting host - nothing to delete", "addr", addr, "object", object)
|
||||
return nil
|
||||
}
|
||||
|
||||
ip, _, err := net.ParseCIDR(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := b.getPath(ip)
|
||||
if p == nil {
|
||||
return
|
||||
|
||||
delete(objects, object)
|
||||
|
||||
if len(objects) == 0 {
|
||||
p := b.getPath(ip)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := b.s.DeletePath(apiutil.DeletePathRequest{
|
||||
Paths: []*apiutil.Path{p},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(b.tracker, addr)
|
||||
log.Debug("[BGP] deleted host", "addr", addr, "cnt", len(objects), "object", object)
|
||||
} else {
|
||||
log.Debug("[BGP] deleting from tracker only", "addr", addr, "object", object)
|
||||
}
|
||||
|
||||
return b.s.DeletePath(context.Background(), &api.DeletePathRequest{
|
||||
Path: p,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
386
pkg/bgp/peers.go
386
pkg/bgp/peers.go
@@ -3,22 +3,39 @@ package bgp
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
log "log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang/protobuf/ptypes" //nolint
|
||||
"github.com/golang/protobuf/ptypes/any"
|
||||
api "github.com/osrg/gobgp/v3/api"
|
||||
"github.com/jpillora/backoff"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
api "github.com/osrg/gobgp/v4/api"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/osrg/gobgp/v4/pkg/apiutil"
|
||||
"github.com/osrg/gobgp/v4/pkg/config/oc"
|
||||
bgp "github.com/osrg/gobgp/v4/pkg/packet/bgp"
|
||||
"github.com/osrg/gobgp/v4/pkg/server"
|
||||
)
|
||||
|
||||
const defaultBGPPort uint32 = 179
|
||||
|
||||
// AddPeer will add peers to the BGP configuration
|
||||
func (b *Server) AddPeer(peer Peer) (err error) {
|
||||
func (b *Server) AddPeer(ctx context.Context, peer kubevip.BGPPeer) (err error) {
|
||||
remotePort := defaultBGPPort
|
||||
if peer.Port != 0 {
|
||||
remotePort = uint32(peer.Port)
|
||||
}
|
||||
|
||||
p := &api.Peer{
|
||||
Conf: &api.PeerConf{
|
||||
NeighborAddress: peer.Address,
|
||||
PeerAsn: peer.AS,
|
||||
AuthPassword: peer.Password,
|
||||
NeighborAddress: peer.Address,
|
||||
PeerAsn: peer.AS,
|
||||
NeighborInterface: peer.Interface,
|
||||
AuthPassword: peer.Password,
|
||||
},
|
||||
|
||||
Timers: &api.Timers{
|
||||
@@ -38,10 +55,104 @@ func (b *Server) AddPeer(peer Peer) (err error) {
|
||||
Transport: &api.Transport{
|
||||
MtuDiscovery: true,
|
||||
RemoteAddress: peer.Address,
|
||||
RemotePort: uint32(179),
|
||||
RemotePort: remotePort,
|
||||
},
|
||||
}
|
||||
|
||||
if peer.BFDEnabled {
|
||||
p.Bfd = &api.BfdPeerConfig{
|
||||
Enabled: true,
|
||||
DesiredMinimumTxInterval: peer.BFDTransmitInterval,
|
||||
RequiredMinimumReceive: peer.BFDReceiveInterval,
|
||||
DetectionMultiplier: peer.BFDDetectMultiplier,
|
||||
Port: 3784, // TODO: Should this be configurable??
|
||||
}
|
||||
}
|
||||
|
||||
if peer.Interface != "" {
|
||||
neighborAddress, err := getIPv6LinkLocalNeighborAddress(ctx, peer.Interface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get link-local address of interface %s: %w", peer.Interface, err)
|
||||
}
|
||||
|
||||
p.State = &api.PeerState{
|
||||
NeighborAddress: neighborAddress,
|
||||
}
|
||||
}
|
||||
|
||||
mpBGP := b.c.MpbgpNexthop
|
||||
|
||||
if peer.MpbgpNexthop != "" {
|
||||
mpBGP = peer.MpbgpNexthop
|
||||
}
|
||||
|
||||
if mpBGP != "" {
|
||||
ipv4Address, ipv6Address, err := peer.FindMpbgpAddresses(p, b.c)
|
||||
if err != nil {
|
||||
log.Error("failed to get MP-BGP addresses, will not us MP-BGP for this host", "error", err)
|
||||
b.setPeerSource(p)
|
||||
} else {
|
||||
p.AfiSafis = []*api.AfiSafi{
|
||||
{
|
||||
Config: &api.AfiSafiConfig{
|
||||
Family: &api.Family{
|
||||
Afi: api.Family_AFI_IP,
|
||||
Safi: api.Family_SAFI_UNICAST,
|
||||
},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Config: &api.AfiSafiConfig{
|
||||
Family: &api.Family{
|
||||
Afi: api.Family_AFI_IP6,
|
||||
Safi: api.Family_SAFI_UNICAST,
|
||||
},
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
peer.SetMpbgpOptions(b.c)
|
||||
|
||||
mask := strconv.Itoa(vip.DefaultMaskIPv6)
|
||||
address := ipv4Address
|
||||
family := api.Family_AFI_IP
|
||||
if utils.IsIPv4(p.Conf.NeighborAddress) {
|
||||
mask = strconv.Itoa(vip.DefaultMaskIPv4)
|
||||
address = ipv6Address
|
||||
family = api.Family_AFI_IP6
|
||||
}
|
||||
|
||||
err = b.s.AddDefinedSet(ctx, &api.AddDefinedSetRequest{
|
||||
DefinedSet: &api.DefinedSet{
|
||||
DefinedType: api.DefinedType_DEFINED_TYPE_NEIGHBOR,
|
||||
Name: fmt.Sprintf("peer-%s", p.Conf.NeighborAddress),
|
||||
List: []string{fmt.Sprintf("%s/%s", p.Conf.NeighborAddress, mask)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add defined set: %v", err)
|
||||
}
|
||||
|
||||
if address != "" {
|
||||
if err := insertPolicy(ctx, b.s, address, p, family); err != nil {
|
||||
return fmt.Errorf("failed to add policy: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b.setPeerSource(p)
|
||||
}
|
||||
|
||||
if err := b.s.AddPeer(ctx, &api.AddPeerRequest{Peer: p}); err != nil {
|
||||
return fmt.Errorf("failed to add peer: %v", err)
|
||||
}
|
||||
log.Info("[BGP]", "peer", p.Conf.NeighborAddress, "AS", p.Conf.PeerAsn, "BFD", p.Bfd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Server) setPeerSource(p *api.Peer) {
|
||||
if b.c.SourceIP != "" {
|
||||
p.Transport.LocalAddress = b.c.SourceIP
|
||||
}
|
||||
@@ -49,127 +160,168 @@ func (b *Server) AddPeer(peer Peer) (err error) {
|
||||
if b.c.SourceIF != "" {
|
||||
p.Transport.BindInterface = b.c.SourceIF
|
||||
}
|
||||
|
||||
return b.s.AddPeer(context.Background(), &api.AddPeerRequest{
|
||||
Peer: p,
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Server) getPath(ip net.IP) (path *api.Path) {
|
||||
func (b *Server) getPath(ip net.IP) *apiutil.Path {
|
||||
isV6 := ip.To4() == nil
|
||||
|
||||
//nolint
|
||||
originAttr, _ := ptypes.MarshalAny(&api.OriginAttribute{
|
||||
Origin: 0,
|
||||
})
|
||||
|
||||
if !isV6 {
|
||||
//nolint
|
||||
nlri, _ := ptypes.MarshalAny(&api.IPAddressPrefix{
|
||||
Prefix: ip.String(),
|
||||
PrefixLen: 32,
|
||||
})
|
||||
|
||||
//nolint
|
||||
nhAttr, _ := ptypes.MarshalAny(&api.NextHopAttribute{
|
||||
NextHop: "0.0.0.0", // gobgp will fill this
|
||||
})
|
||||
|
||||
path = &api.Path{
|
||||
Family: &api.Family{
|
||||
Afi: api.Family_AFI_IP,
|
||||
Safi: api.Family_SAFI_UNICAST,
|
||||
},
|
||||
Nlri: nlri,
|
||||
Pattrs: []*any.Any{originAttr, nhAttr},
|
||||
}
|
||||
} else {
|
||||
//nolint
|
||||
nlri, _ := ptypes.MarshalAny(&api.IPAddressPrefix{
|
||||
Prefix: ip.String(),
|
||||
PrefixLen: 128,
|
||||
})
|
||||
|
||||
v6Family := &api.Family{
|
||||
Afi: api.Family_AFI_IP6,
|
||||
Safi: api.Family_SAFI_UNICAST,
|
||||
}
|
||||
|
||||
//nolint
|
||||
mpAttr, _ := ptypes.MarshalAny(&api.MpReachNLRIAttribute{
|
||||
Family: v6Family,
|
||||
NextHops: []string{"::"}, // gobgp will fill this
|
||||
Nlris: []*any.Any{nlri},
|
||||
})
|
||||
|
||||
path = &api.Path{
|
||||
Family: v6Family,
|
||||
Nlri: nlri,
|
||||
Pattrs: []*any.Any{originAttr, mpAttr},
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ParseBGPPeerConfig - take a string and parses it into an array of peers
|
||||
func ParseBGPPeerConfig(config string) (bgpPeers []Peer, err error) {
|
||||
peers := strings.Split(config, ",")
|
||||
if len(peers) == 0 {
|
||||
return nil, fmt.Errorf("No BGP Peer configurations found")
|
||||
}
|
||||
|
||||
for x := range peers {
|
||||
peerStr := peers[x]
|
||||
if peerStr == "" {
|
||||
continue
|
||||
}
|
||||
isV6Peer := peerStr[0] == '['
|
||||
|
||||
address := ""
|
||||
if isV6Peer {
|
||||
addressEndPos := strings.IndexByte(peerStr, ']')
|
||||
if addressEndPos == -1 {
|
||||
return nil, fmt.Errorf("no matching ] found for IPv6 BGP Peer")
|
||||
}
|
||||
address = peerStr[1:addressEndPos]
|
||||
peerStr = peerStr[addressEndPos+1:]
|
||||
}
|
||||
|
||||
peer := strings.Split(peerStr, ":")
|
||||
if len(peer) < 2 {
|
||||
return nil, fmt.Errorf("mandatory peering params <host>:<AS> incomplete")
|
||||
}
|
||||
|
||||
if !isV6Peer {
|
||||
address = peer[0]
|
||||
}
|
||||
|
||||
ASNumber, err := strconv.ParseUint(peer[1], 10, 32)
|
||||
prefix, err := bgp.NewIPAddrPrefix(netip.MustParsePrefix(
|
||||
fmt.Sprintf("%s/%d", ip.String(), vip.DefaultMaskIPv4),
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BGP Peer AS format error [%s]", peer[1])
|
||||
return nil
|
||||
}
|
||||
|
||||
password := ""
|
||||
if len(peer) >= 3 {
|
||||
password = peer[2]
|
||||
nh, err := bgp.NewPathAttributeNextHop(netip.MustParseAddr("0.0.0.0"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
multiHop := false
|
||||
if len(peer) >= 4 {
|
||||
multiHop, err = strconv.ParseBool(peer[3])
|
||||
return &apiutil.Path{
|
||||
Family: bgp.RF_IPv4_UC,
|
||||
Nlri: prefix,
|
||||
Attrs: []bgp.PathAttributeInterface{
|
||||
bgp.NewPathAttributeOrigin(0),
|
||||
nh,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
prefix, err := bgp.NewIPAddrPrefix(netip.MustParsePrefix(
|
||||
fmt.Sprintf("%s/%d", ip.String(), vip.DefaultMaskIPv6),
|
||||
))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
mpReach, err := bgp.NewPathAttributeMpReachNLRI(
|
||||
bgp.RF_IPv6_UC,
|
||||
[]bgp.PathNLRI{{NLRI: prefix}},
|
||||
netip.MustParseAddr("::"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &apiutil.Path{
|
||||
Family: bgp.RF_IPv6_UC,
|
||||
Nlri: prefix,
|
||||
Attrs: []bgp.PathAttributeInterface{
|
||||
bgp.NewPathAttributeOrigin(0),
|
||||
mpReach,
|
||||
},
|
||||
}
|
||||
}
|
||||
func insertPolicy(ctx context.Context, s *server.BgpServer, address string, p *api.Peer, family api.Family_Afi) error {
|
||||
familyType := "v4"
|
||||
if family == api.Family_AFI_IP6 {
|
||||
familyType = "v6"
|
||||
}
|
||||
|
||||
setName := fmt.Sprintf("peer-%s", p.Conf.NeighborAddress)
|
||||
policyName := fmt.Sprintf("%s-%s", setName, familyType)
|
||||
|
||||
policy := &api.Policy{
|
||||
Name: policyName,
|
||||
Statements: []*api.Statement{
|
||||
{
|
||||
Conditions: &api.Conditions{
|
||||
AfiSafiIn: []*api.Family{
|
||||
{
|
||||
Afi: family,
|
||||
Safi: api.Family_SAFI_UNICAST,
|
||||
},
|
||||
},
|
||||
NeighborSet: &api.MatchSet{
|
||||
Type: api.MatchSet_TYPE_ANY,
|
||||
Name: setName,
|
||||
},
|
||||
},
|
||||
Actions: &api.Actions{
|
||||
RouteAction: api.RouteAction_ROUTE_ACTION_ACCEPT,
|
||||
Nexthop: &api.NexthopAction{
|
||||
Address: address,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Conditions: &api.Conditions{
|
||||
NeighborSet: &api.MatchSet{
|
||||
Type: api.MatchSet_TYPE_ANY,
|
||||
Name: setName,
|
||||
},
|
||||
},
|
||||
Actions: &api.Actions{
|
||||
RouteAction: api.RouteAction_ROUTE_ACTION_ACCEPT,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.AddPolicy(ctx, &api.AddPolicyRequest{
|
||||
Policy: policy,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add policy: %w", err)
|
||||
}
|
||||
|
||||
err = s.AddPolicyAssignment(ctx, &api.AddPolicyAssignmentRequest{
|
||||
Assignment: &api.PolicyAssignment{
|
||||
Name: "global",
|
||||
Direction: api.PolicyDirection_POLICY_DIRECTION_EXPORT,
|
||||
Policies: []*api.Policy{
|
||||
{
|
||||
Name: policy.Name,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add policy assignment: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getIPv6LinkLocalNeighborAddress(ctx context.Context, peerInterface string) (string, error) {
|
||||
neighCtx, neighCancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer neighCancel()
|
||||
|
||||
bo := backoff.Backoff{
|
||||
Factor: 2,
|
||||
Jitter: true,
|
||||
Min: 1 * time.Second,
|
||||
Max: 5 * time.Second,
|
||||
}
|
||||
|
||||
maxAttempts := 20.0
|
||||
|
||||
var err error
|
||||
for {
|
||||
select {
|
||||
case <-neighCtx.Done():
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BGP MultiHop format error (true/false) [%s]", peer[1])
|
||||
return "", fmt.Errorf("failed to get link-local address of interface %s: %w", peerInterface, err)
|
||||
}
|
||||
return "", fmt.Errorf("failed to get link-local address of interface %s: %w", peerInterface, neighCtx.Err())
|
||||
default:
|
||||
dur := bo.Duration()
|
||||
var neighborAddress string
|
||||
neighborAddress, err = oc.GetIPv6LinkLocalNeighborAddress(peerInterface)
|
||||
if err != nil && bo.Attempt() >= maxAttempts {
|
||||
return "", fmt.Errorf("failed to get link-local address of interface %s: %w", peerInterface, err)
|
||||
}
|
||||
if neighborAddress != "" {
|
||||
return neighborAddress, nil
|
||||
}
|
||||
t := time.NewTimer(dur)
|
||||
select {
|
||||
case <-neighCtx.Done():
|
||||
t.Stop()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
peerConfig := Peer{
|
||||
Address: address,
|
||||
AS: uint32(ASNumber),
|
||||
Password: password,
|
||||
MultiHop: multiHop,
|
||||
}
|
||||
|
||||
bgpPeers = append(bgpPeers, peerConfig)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
146
pkg/bgp/peers_config_test.go
Normal file
146
pkg/bgp/peers_config_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package bgp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
api "github.com/osrg/gobgp/v4/api"
|
||||
gobgp "github.com/osrg/gobgp/v4/pkg/server"
|
||||
)
|
||||
|
||||
func TestAddPeerConfiguresTransportOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
newServer func(*testing.T) *Server
|
||||
peer kubevip.BGPPeer
|
||||
wantPort uint32
|
||||
wantLocalAddr string
|
||||
wantInterface string
|
||||
}{
|
||||
{
|
||||
name: "configured remote port",
|
||||
newServer: func(t *testing.T) *Server {
|
||||
return newStartedTestBGPServer(t, kubevip.BGPConfig{
|
||||
AS: 65000,
|
||||
RouterID: "192.0.2.1",
|
||||
Peers: []kubevip.BGPPeer{{Address: "192.0.2.10", AS: 65001}},
|
||||
})
|
||||
},
|
||||
peer: kubevip.BGPPeer{Address: "192.0.2.10", AS: 65001, Port: 180},
|
||||
wantPort: 180,
|
||||
},
|
||||
{
|
||||
name: "configured source interface after MP-BGP fallback",
|
||||
newServer: func(t *testing.T) *Server {
|
||||
return newPeerTestServer(t, kubevip.BGPConfig{
|
||||
AS: 65000,
|
||||
RouterID: "192.0.2.1",
|
||||
SourceIF: "lo",
|
||||
MpbgpNexthop: "fixed",
|
||||
Peers: []kubevip.BGPPeer{{Address: "192.0.2.20", AS: 65001}},
|
||||
MpbgpIPv4: "",
|
||||
MpbgpIPv6: "",
|
||||
})
|
||||
},
|
||||
peer: kubevip.BGPPeer{Address: "192.0.2.20", AS: 65001},
|
||||
wantInterface: "lo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := tt.newServer(t)
|
||||
if err := server.AddPeer(context.Background(), tt.peer); err != nil {
|
||||
t.Fatalf("AddPeer() error = %v", err)
|
||||
}
|
||||
|
||||
peer := listTestPeer(t, server, tt.peer.Address)
|
||||
if peer.GetTransport() == nil {
|
||||
t.Fatal("configured peer has no transport")
|
||||
}
|
||||
if tt.wantPort != 0 && peer.GetTransport().GetRemotePort() != tt.wantPort {
|
||||
t.Fatalf("remote port = %d, want %d", peer.GetTransport().GetRemotePort(), tt.wantPort)
|
||||
}
|
||||
if tt.wantLocalAddr != "" && peer.GetTransport().GetLocalAddress() != tt.wantLocalAddr {
|
||||
t.Fatalf("local address = %q, want %q", peer.GetTransport().GetLocalAddress(), tt.wantLocalAddr)
|
||||
}
|
||||
if tt.wantInterface != "" && peer.GetTransport().GetBindInterface() != tt.wantInterface {
|
||||
t.Fatalf("bind interface = %q, want %q", peer.GetTransport().GetBindInterface(), tt.wantInterface)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newStartedTestBGPServer(t *testing.T, config kubevip.BGPConfig) *Server {
|
||||
t.Helper()
|
||||
|
||||
server, err := NewBGPServer(config, log.LevelError)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBGPServer() error = %v", err)
|
||||
}
|
||||
|
||||
go server.s.Serve()
|
||||
if err := server.s.StartBgp(context.Background(), &api.StartBgpRequest{
|
||||
Global: &api.Global{
|
||||
Asn: config.AS,
|
||||
RouterId: config.RouterID,
|
||||
ListenPort: -1,
|
||||
},
|
||||
}); err != nil {
|
||||
server.s.Stop()
|
||||
t.Fatalf("StartBgp() error = %v", err)
|
||||
}
|
||||
t.Cleanup(server.s.Stop)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func listTestPeer(t *testing.T, server *Server, address string) *api.Peer {
|
||||
t.Helper()
|
||||
|
||||
var got *api.Peer
|
||||
if err := server.s.ListPeer(context.Background(), &api.ListPeerRequest{Address: address}, func(peer *api.Peer) {
|
||||
got = peer
|
||||
}); err != nil {
|
||||
t.Fatalf("ListPeer() error = %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("ListPeer() returned no peer for %s", address)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func newPeerTestServer(t *testing.T, cfg kubevip.BGPConfig) *Server {
|
||||
t.Helper()
|
||||
raw := startEmbeddedRawBGP(t)
|
||||
return &Server{s: raw, c: &cfg, tracker: make(map[string]map[string]bool)}
|
||||
}
|
||||
|
||||
func startEmbeddedRawBGP(t *testing.T) *gobgp.BgpServer {
|
||||
t.Helper()
|
||||
raw := gobgp.NewBgpServer()
|
||||
go raw.Serve()
|
||||
if err := raw.StartBgp(context.Background(), &api.StartBgpRequest{
|
||||
Global: &api.Global{
|
||||
Asn: 65000,
|
||||
RouterId: "192.0.2.1",
|
||||
ListenPort: -1,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("starting embedded BGP server: %v", err)
|
||||
}
|
||||
var stopOnce sync.Once
|
||||
t.Cleanup(func() {
|
||||
stopOnce.Do(func() {
|
||||
if err := raw.StopBgp(context.Background(), &api.StopBgpRequest{}); err != nil {
|
||||
t.Logf("stopping embedded BGP server: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
return raw
|
||||
}
|
||||
@@ -3,56 +3,94 @@ package bgp
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
api "github.com/osrg/gobgp/v3/api"
|
||||
gobgp "github.com/osrg/gobgp/v3/pkg/server"
|
||||
log "github.com/sirupsen/logrus"
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
api "github.com/osrg/gobgp/v4/api"
|
||||
"github.com/osrg/gobgp/v4/pkg/apiutil"
|
||||
bgp "github.com/osrg/gobgp/v4/pkg/packet/bgp"
|
||||
gobgp "github.com/osrg/gobgp/v4/pkg/server"
|
||||
)
|
||||
|
||||
type BGPManager interface {
|
||||
AddHost(ctx context.Context, addr string, object string) error
|
||||
DelHost(ctx context.Context, addr string, object string) error
|
||||
}
|
||||
|
||||
// Server manages a server object
|
||||
type Server struct {
|
||||
s *gobgp.BgpServer
|
||||
c *kubevip.BGPConfig
|
||||
mtx sync.Mutex
|
||||
tracker map[string]map[string]bool
|
||||
}
|
||||
|
||||
// NewBGPServer takes a configuration and returns a running BGP server instance
|
||||
func NewBGPServer(c *Config, peerStateChangeCallback func(*api.WatchEventResponse_PeerEvent)) (b *Server, err error) {
|
||||
func NewBGPServer(c kubevip.BGPConfig, logLevel log.Level) (b *Server, err error) {
|
||||
if c.AS == 0 {
|
||||
return nil, fmt.Errorf("You need to provide AS")
|
||||
return nil, fmt.Errorf("you need to provide AS")
|
||||
}
|
||||
|
||||
if c.SourceIP != "" && c.SourceIF != "" {
|
||||
return nil, fmt.Errorf("SourceIP and SourceIF are mutually exclusive")
|
||||
return nil, fmt.Errorf("sourceIP and SourceIF are mutually exclusive")
|
||||
}
|
||||
|
||||
if len(c.Peers) == 0 {
|
||||
return nil, fmt.Errorf("You need to provide at least one peer")
|
||||
return nil, fmt.Errorf("you need to provide at least one peer")
|
||||
}
|
||||
bgpLogger := log.Default()
|
||||
lvl := &log.LevelVar{}
|
||||
lvl.Set(logLevel)
|
||||
|
||||
b = &Server{
|
||||
s: gobgp.NewBgpServer(),
|
||||
c: c,
|
||||
s: gobgp.NewBgpServer(gobgp.LoggerOption(bgpLogger, lvl)),
|
||||
c: &c,
|
||||
tracker: make(map[string]map[string]bool),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start starts the BGP server
|
||||
func (b *Server) Start(ctx context.Context, peerStateChangeCallback func(*apiutil.WatchEventMessage_PeerEvent)) (err error) {
|
||||
go b.s.Serve()
|
||||
|
||||
if err = b.s.StartBgp(context.Background(), &api.StartBgpRequest{
|
||||
if err = b.s.StartBgp(ctx, &api.StartBgpRequest{
|
||||
Global: &api.Global{
|
||||
Asn: c.AS,
|
||||
RouterId: c.RouterID,
|
||||
Asn: b.c.AS,
|
||||
RouterId: b.c.RouterID,
|
||||
ListenPort: -1,
|
||||
},
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = b.s.WatchEvent(context.Background(), &api.WatchEventRequest{Peer: &api.WatchEventRequest_Peer{}}, func(r *api.WatchEventResponse) {
|
||||
if p := r.GetPeer(); p != nil && p.Type == api.WatchEventResponse_PeerEvent_STATE {
|
||||
log.Infof("[BGP] %s", p.String())
|
||||
if err = b.s.WatchEvent(ctx, gobgp.WatchEventMessageCallbacks{
|
||||
OnPeerUpdate: func(p *apiutil.WatchEventMessage_PeerEvent, _ time.Time) {
|
||||
log.Info("[BGP]", "peer", fmt.Sprintf("%+v", p))
|
||||
if peerStateChangeCallback != nil {
|
||||
peerStateChangeCallback(p)
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
},
|
||||
}, gobgp.WatchPeer()); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, p := range c.Peers {
|
||||
if err = b.AddPeer(p); err != nil {
|
||||
for _, p := range b.c.Peers {
|
||||
if err = b.AddPeer(ctx, p); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if b.c.Zebra.Enabled {
|
||||
if err = b.s.EnableZebra(ctx, &api.EnableZebraRequest{
|
||||
Url: b.c.Zebra.URL,
|
||||
Version: b.c.Zebra.Version,
|
||||
SoftwareName: b.c.Zebra.SoftwareName,
|
||||
}); err != nil {
|
||||
log.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -62,7 +100,51 @@ func NewBGPServer(c *Config, peerStateChangeCallback func(*api.WatchEventRespons
|
||||
|
||||
// Close will stop a running BGP Server
|
||||
func (b *Server) Close() error {
|
||||
ctx, cf := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cf()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return b.s.StopBgp(ctx, &api.StopBgpRequest{})
|
||||
}
|
||||
|
||||
// ListAdvertisedRoutes retrieves all active routes inside GoBGP's local RIB.
|
||||
// It queries the GLOBAL table type to find routes that kube-vip has requested GoBGP to advertise.
|
||||
func (b *Server) ListAdvertisedRoutes(ctx context.Context, isIPv6 bool) ([]*api.Destination, error) {
|
||||
afi := bgp.AFI_IP
|
||||
|
||||
if isIPv6 {
|
||||
afi = bgp.AFI_IP6
|
||||
}
|
||||
|
||||
family := bgp.NewFamily(uint16(afi), bgp.SAFI_UNICAST)
|
||||
|
||||
var destinations []*api.Destination
|
||||
|
||||
req := apiutil.ListPathRequest{
|
||||
TableType: api.TableType_TABLE_TYPE_GLOBAL,
|
||||
Family: family,
|
||||
}
|
||||
|
||||
// GoBGP's embedded server API uses a callback function to stream results
|
||||
// locally without requiring a gRPC client stream setup.
|
||||
err := b.s.ListPath(req, func(prefix bgp.NLRI, paths []*apiutil.Path) {
|
||||
var newPaths []*api.Path
|
||||
for _, p := range paths {
|
||||
np, err := apiutil.NewPath(p.Family, p.Nlri, p.Withdrawal, p.Attrs, time.Unix(p.Age, 0))
|
||||
if err != nil {
|
||||
log.Error("failed to create BGP path details", "err", err)
|
||||
continue
|
||||
}
|
||||
newPaths = append(newPaths, np)
|
||||
}
|
||||
d := &api.Destination{
|
||||
Prefix: prefix.String(),
|
||||
Paths: newPaths,
|
||||
}
|
||||
destinations = append(destinations, d)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract local RIB: %w", err)
|
||||
}
|
||||
|
||||
return destinations, nil
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package bgp
|
||||
|
||||
import gobgp "github.com/osrg/gobgp/v3/pkg/server"
|
||||
|
||||
// Peer defines a BGP Peer
|
||||
type Peer struct {
|
||||
Address string
|
||||
AS uint32
|
||||
Password string
|
||||
MultiHop bool
|
||||
}
|
||||
|
||||
// Config defines the BGP server configuration
|
||||
type Config struct {
|
||||
AS uint32
|
||||
RouterID string
|
||||
SourceIP string
|
||||
SourceIF string
|
||||
|
||||
HoldTime uint64
|
||||
KeepaliveInterval uint64
|
||||
|
||||
Peers []Peer
|
||||
}
|
||||
|
||||
// Server manages a server object
|
||||
type Server struct {
|
||||
s *gobgp.BgpServer
|
||||
c *Config
|
||||
}
|
||||
@@ -1,56 +1,96 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/arp"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/networkinterface"
|
||||
"github.com/kube-vip/kube-vip/pkg/node"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
)
|
||||
|
||||
// Cluster - The Cluster object manages the state of the cluster for a particular node
|
||||
type Cluster struct {
|
||||
stop chan bool
|
||||
completed chan bool
|
||||
once sync.Once
|
||||
Network []vip.Network
|
||||
stop chan struct{}
|
||||
stopMu sync.Mutex
|
||||
service *servicesWorker
|
||||
Network []vip.Network
|
||||
arpMgr *arp.Manager
|
||||
routeMgr *route.Manager
|
||||
nodeLabelMgr node.Labeler
|
||||
labelAdded bool
|
||||
healthCheckHTTPClient *http.Client
|
||||
}
|
||||
|
||||
type servicesWorker struct {
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
stopping bool
|
||||
preserveVIPs map[string]struct{}
|
||||
}
|
||||
|
||||
// InitCluster - Will attempt to initialise all of the required settings for the cluster
|
||||
func InitCluster(c *kubevip.Config, disableVIP bool) (*Cluster, error) {
|
||||
func InitCluster(c *kubevip.Config, disableVIP bool, intfMgr *networkinterface.Manager, arpMgr *arp.Manager,
|
||||
routeMgr *route.Manager, nodeLabelMgr node.Labeler) (*Cluster, error) {
|
||||
var networks []vip.Network
|
||||
var healthCheckHTTPClient *http.Client
|
||||
var err error
|
||||
|
||||
if !disableVIP {
|
||||
// Start the Virtual IP Networking configuration
|
||||
networks, err = startNetworking(c)
|
||||
networks, err = startNetworking(c, intfMgr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Initialise the Cluster structure
|
||||
newCluster := &Cluster{
|
||||
Network: networks,
|
||||
|
||||
if c.ControlPlaneHealthCheck.Address != "" {
|
||||
healthCheckHTTPClient, err = newHealthCheckHTTPClient(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initializing BGP health check client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("init enable service security: %t", c.EnableServiceSecurity)
|
||||
// Initialise the Cluster structure
|
||||
newCluster := &Cluster{
|
||||
Network: networks,
|
||||
arpMgr: arpMgr,
|
||||
stop: make(chan struct{}),
|
||||
routeMgr: routeMgr,
|
||||
nodeLabelMgr: nodeLabelMgr,
|
||||
healthCheckHTTPClient: healthCheckHTTPClient,
|
||||
}
|
||||
|
||||
log.Debug("service security", "enabled", c.EnableServiceSecurity)
|
||||
|
||||
return newCluster, nil
|
||||
}
|
||||
|
||||
func startNetworking(c *kubevip.Config) ([]vip.Network, error) {
|
||||
func startNetworking(c *kubevip.Config, intfMgr *networkinterface.Manager) ([]vip.Network, error) {
|
||||
address := c.VIP
|
||||
|
||||
if c.Address != "" {
|
||||
address = c.Address
|
||||
}
|
||||
|
||||
addresses := vip.GetIPs(address)
|
||||
addresses := vip.Split(address)
|
||||
|
||||
networks := []vip.Network{}
|
||||
for _, addr := range addresses {
|
||||
network, err := vip.NewConfig(addr, c.Interface, c.VIPSubnet, c.DDNS, c.RoutingTableID, c.RoutingTableType, c.RoutingProtocol, c.DNSMode, c.LoadBalancerForwardingMethod, c.IptablesBackend)
|
||||
network, err := vip.NewConfig(addr, c.Interface, c.LoInterfaceGlobalScope, c.VIPSubnet, c.DDNS, c.DHCPMode,
|
||||
c.RequireDualStack, c.IsDualStack, c.RoutingTableID, c.RoutingTableType, c.RoutingProtocol, c.DNSMode,
|
||||
c.LoadBalancerForwardingMethod, c.IptablesBackend, c.EnableLoadBalancer, c.LoadBalancerPort,
|
||||
c.EnableServiceSecurity, intfMgr, c.EgressWithNftables, c.SkipDAD)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,13 +102,187 @@ func startNetworking(c *kubevip.Config) ([]vip.Network, error) {
|
||||
|
||||
// Stop - Will stop the Cluster and release VIP if needed
|
||||
func (cluster *Cluster) Stop() {
|
||||
// Close the stop channel, which will shut down the VIP (if needed)
|
||||
if cluster.stop != nil {
|
||||
cluster.once.Do(func() { // Ensure that the close channel can only ever be called once
|
||||
close(cluster.stop)
|
||||
})
|
||||
cluster.stopMu.Lock()
|
||||
defer cluster.stopMu.Unlock()
|
||||
if cluster.service != nil {
|
||||
workers := cluster.service
|
||||
if workers.stopping {
|
||||
return
|
||||
}
|
||||
workers.stopping = true
|
||||
cluster.stop = make(chan struct{})
|
||||
close(workers.stop)
|
||||
return
|
||||
}
|
||||
stop := cluster.stop
|
||||
cluster.stop = make(chan struct{})
|
||||
close(stop)
|
||||
}
|
||||
|
||||
// StopAndWait signals the current Service worker generation and waits until it
|
||||
// has finished its datapath cleanup.
|
||||
func (cluster *Cluster) StopAndWait() {
|
||||
cluster.stopAndWait(nil)
|
||||
}
|
||||
|
||||
// StopAndWaitPreserving stops the current Service worker generation while
|
||||
// preserving the supplied VIPs for another Service that shares the same lease.
|
||||
func (cluster *Cluster) StopAndWaitPreserving(addresses ...string) {
|
||||
preserve := make(map[string]struct{}, len(addresses))
|
||||
for _, address := range addresses {
|
||||
preserve[address] = struct{}{}
|
||||
}
|
||||
cluster.stopAndWait(preserve)
|
||||
}
|
||||
|
||||
func (cluster *Cluster) stopAndWait(preserveVIPs map[string]struct{}) {
|
||||
workers, signal := cluster.prepareServiceStop(preserveVIPs)
|
||||
if workers == nil {
|
||||
return
|
||||
}
|
||||
if signal {
|
||||
close(workers.stop)
|
||||
}
|
||||
<-workers.done
|
||||
}
|
||||
|
||||
func (cluster *Cluster) prepareServiceStop(preserveVIPs map[string]struct{}) (*servicesWorker, bool) {
|
||||
cluster.stopMu.Lock()
|
||||
defer cluster.stopMu.Unlock()
|
||||
workers := cluster.service
|
||||
if workers == nil {
|
||||
return nil, false
|
||||
}
|
||||
if workers.stopping {
|
||||
workers.preserveVIPs = mergeVIPs(workers.preserveVIPs, preserveVIPs)
|
||||
return workers, false
|
||||
}
|
||||
workers.stopping = true
|
||||
workers.preserveVIPs = preserveVIPs
|
||||
cluster.stop = make(chan struct{})
|
||||
return workers, true
|
||||
}
|
||||
|
||||
func (cluster *Cluster) startServicesWorker() (<-chan struct{}, chan struct{}, error) {
|
||||
cluster.stopMu.Lock()
|
||||
defer cluster.stopMu.Unlock()
|
||||
if cluster.service != nil {
|
||||
return nil, nil, fmt.Errorf("load balancer workers already running")
|
||||
}
|
||||
workers := &servicesWorker{stop: cluster.stop, done: make(chan struct{})}
|
||||
cluster.service = workers
|
||||
return workers.stop, workers.done, nil
|
||||
}
|
||||
|
||||
func (cluster *Cluster) preserveServiceVIP(done chan struct{}, address string) bool {
|
||||
cluster.stopMu.Lock()
|
||||
defer cluster.stopMu.Unlock()
|
||||
if cluster.service == nil || cluster.service.done != done {
|
||||
return false
|
||||
}
|
||||
_, preserve := cluster.service.preserveVIPs[address]
|
||||
return preserve
|
||||
}
|
||||
|
||||
func mergeVIPs(existing, addresses map[string]struct{}) map[string]struct{} {
|
||||
if len(addresses) == 0 {
|
||||
return existing
|
||||
}
|
||||
if existing == nil {
|
||||
existing = make(map[string]struct{}, len(addresses))
|
||||
}
|
||||
for address := range addresses {
|
||||
existing[address] = struct{}{}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func (cluster *Cluster) finishServicesWorker(done chan struct{}) {
|
||||
cluster.stopMu.Lock()
|
||||
defer cluster.stopMu.Unlock()
|
||||
if cluster.service == nil || cluster.service.done != done {
|
||||
return
|
||||
}
|
||||
cluster.service = nil
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (cluster *Cluster) StopChannel() <-chan struct{} {
|
||||
cluster.stopMu.Lock()
|
||||
defer cluster.stopMu.Unlock()
|
||||
return cluster.stop
|
||||
}
|
||||
|
||||
func newHealthCheckHTTPClient(c *kubevip.Config) (*http.Client, error) {
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected default HTTP transport type %T", http.DefaultTransport)
|
||||
}
|
||||
|
||||
// Wait until the completed channel is closed, signallign all shutdown tasks completed
|
||||
<-cluster.completed
|
||||
transport := defaultTransport.Clone()
|
||||
if c.ControlPlaneHealthCheck.CAPath != "" {
|
||||
caCert, err := os.ReadFile(c.ControlPlaneHealthCheck.CAPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading health check CA cert %q: %w", c.ControlPlaneHealthCheck.CAPath, err)
|
||||
}
|
||||
|
||||
rootCAs, err := x509.SystemCertPool()
|
||||
if err != nil || rootCAs == nil {
|
||||
rootCAs = x509.NewCertPool()
|
||||
}
|
||||
if !rootCAs.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("health check CA cert %q contains no valid certificates", c.ControlPlaneHealthCheck.CAPath)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if transport.TLSClientConfig != nil {
|
||||
tlsConfig = transport.TLSClientConfig.Clone()
|
||||
}
|
||||
tlsConfig.RootCAs = rootCAs
|
||||
transport.TLSClientConfig = tlsConfig
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: time.Duration(c.ControlPlaneHealthCheck.TimeoutSeconds) * time.Second,
|
||||
Transport: transport,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cleanupVIPs releases the control plane VIPs after leadership was lost.
|
||||
// Nothing waits for the control plane layer2Update goroutine to observe the
|
||||
// cancelled context, so this caller usually still holds its own ARP claim and
|
||||
// has to delete the address itself.
|
||||
func (cluster *Cluster) cleanupVIPs(c *kubevip.Config) {
|
||||
for i := range cluster.Network {
|
||||
cluster.cleanupVIP(c, cluster.Network[i], 1)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupServiceVIPs releases the service VIPs once the services worker has
|
||||
// drained. layer2Update already removed this instance's own claim by then, so
|
||||
// any remaining claim belongs to another service sharing the VIP.
|
||||
func (cluster *Cluster) cleanupServiceVIPs(c *kubevip.Config, done chan struct{}) {
|
||||
for i := range cluster.Network {
|
||||
if cluster.preserveServiceVIP(done, cluster.Network[i].IP()) {
|
||||
continue
|
||||
}
|
||||
cluster.cleanupVIP(c, cluster.Network[i], 0)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupVIP deletes the VIP unless somebody else still advertises it.
|
||||
// ownClaims is the number of ARP claims the caller may still hold itself.
|
||||
func (cluster *Cluster) cleanupVIP(c *kubevip.Config, network vip.Network, ownClaims int) {
|
||||
if c.EnableARP && cluster.arpMgr.Count(network.ARPName()) > ownClaims {
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("[VIP] Deleting VIP", "ip", network.IP())
|
||||
deleted, err := network.DeleteIP()
|
||||
if err != nil {
|
||||
log.Warn(err.Error())
|
||||
}
|
||||
if deleted {
|
||||
log.Info("deleted address", "IP", network.IP(), "interface", network.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
)
|
||||
@@ -12,16 +13,14 @@ import (
|
||||
// during runtime if IP changes, startDDNS don't have to do reconfigure because
|
||||
// dnsUpdater already have the functionality to keep trying resolve the IP
|
||||
// and update the VIP configuration if it changes
|
||||
func (cluster *Cluster) StartDDNS(ctx context.Context) error {
|
||||
for i := range cluster.Network {
|
||||
ddnsMgr := vip.NewDDNSManager(ctx, cluster.Network[i])
|
||||
ip, err := ddnsMgr.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = cluster.Network[i].SetIP(ip); err != nil {
|
||||
return err
|
||||
}
|
||||
func (cluster *Cluster) StartDDNS(ctx context.Context, network vip.Network, backoffAttempts uint, wg *sync.WaitGroup) error {
|
||||
ddnsMgr := vip.NewDDNSManager(network, backoffAttempts)
|
||||
ip, err := ddnsMgr.Start(ctx, wg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = network.SetIP(ip); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,370 +3,167 @@ package cluster
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
"sync"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/equinixmetal"
|
||||
"github.com/kube-vip/kube-vip/pkg/etcd"
|
||||
"github.com/kube-vip/kube-vip/pkg/k8s"
|
||||
"github.com/kube-vip/kube-vip/pkg/election"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/loadbalancer"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
|
||||
"github.com/packethost/packngo"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
clientv3 "go.etcd.io/etcd/client/v3"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
"k8s.io/client-go/tools/leaderelection"
|
||||
"k8s.io/client-go/tools/leaderelection/resourcelock"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
log "log/slog"
|
||||
)
|
||||
|
||||
// Manager degines the manager of the load-balancing services
|
||||
type Manager struct {
|
||||
KubernetesClient *kubernetes.Clientset
|
||||
// This channel is used to signal a shutdown
|
||||
SignalChan chan os.Signal
|
||||
|
||||
EtcdClient *clientv3.Client
|
||||
}
|
||||
|
||||
// NewManager will create a new managing object
|
||||
func NewManager(path string, inCluster bool, port int) (*Manager, error) {
|
||||
var hostname string
|
||||
|
||||
// If inCluster is set then it will likely have started as a static pod or won't have the
|
||||
// VIP up before trying to connect to the API server, we set the API endpoint to this machine to
|
||||
// ensure connectivity. Else if the path passed is empty and not running in the cluster,
|
||||
// attempt to look for a kubeconfig in the default HOME dir.
|
||||
|
||||
hostname = fmt.Sprintf("kubernetes:%v", port)
|
||||
|
||||
if len(path) == 0 && !inCluster {
|
||||
path = filepath.Join(os.Getenv("HOME"), ".kube", "config")
|
||||
|
||||
// We modify the config so that we can always speak to the correct host
|
||||
id, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hostname = fmt.Sprintf("%s:%v", id, port)
|
||||
}
|
||||
|
||||
clientset, err := k8s.NewClientset(path, inCluster, hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating a new k8s clientset: %v", err)
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
KubernetesClient: clientset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartCluster - Begins a running instance of the Leader Election cluster
|
||||
func (cluster *Cluster) StartCluster(c *kubevip.Config, sm *Manager, bgpServer *bgp.Server) error {
|
||||
var err error
|
||||
func (cluster *Cluster) StartCluster(ctx context.Context, c *kubevip.Config,
|
||||
em *election.Manager, bgpServer *bgp.Server, leaseMgr *lease.Manager, killFunc func()) error {
|
||||
|
||||
log.Infof("Beginning cluster membership, namespace [%s], lock name [%s], id [%s]", c.Namespace, c.LeaseName, c.NodeName)
|
||||
ns, leaseName := lease.NamespaceName(c.LeaseName, c)
|
||||
|
||||
// use a Go context so we can tell the leaderelection code when we
|
||||
// want to step down
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
leaseID := lease.NewID(c.LeaderElectionType, ns, leaseName)
|
||||
|
||||
// use a Go context so we can tell the arp loop code when we
|
||||
// want to step down
|
||||
ctxArp, cancelArp := context.WithCancel(context.Background())
|
||||
defer cancelArp()
|
||||
log.Info("cluster membership", "namespace", leaseID.Namespace(), "lock", leaseID.Name(), "id", c.NodeName)
|
||||
|
||||
// use a Go context so we can tell the dns loop code when we
|
||||
// want to step down
|
||||
ctxDNS, cancelDNS := context.WithCancel(context.Background())
|
||||
defer cancelDNS()
|
||||
objectName := lease.ObjectName(leaseID, "cp")
|
||||
objLease, _ := leaseMgr.Acquire(context.Background(), leaseID, objectName)
|
||||
defer leaseMgr.Delete(leaseID, objectName, objLease)
|
||||
|
||||
// listen for interrupts or the Linux SIGTERM signal and cancel
|
||||
// our context, which the leader election code will observe and
|
||||
// step down
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
// Add Notification for Userland interrupt
|
||||
signal.Notify(signalChan, syscall.SIGINT)
|
||||
wg := sync.WaitGroup{}
|
||||
defer wg.Wait()
|
||||
|
||||
// Add Notification for SIGTERM (sent from Kubernetes)
|
||||
signal.Notify(signalChan, syscall.SIGTERM)
|
||||
electionCtx, cancelElection := objLease.NewElectionContext(ctx)
|
||||
defer cancelElection()
|
||||
|
||||
go func() {
|
||||
<-signalChan
|
||||
log.Info("Received termination, signaling cluster shutdown")
|
||||
// Cancel the context, which will in turn cancel the leadership
|
||||
cancel()
|
||||
// Cancel the arp context, which will in turn stop any broadcasts
|
||||
}()
|
||||
stop := cluster.StopChannel()
|
||||
wg.Go(func() {
|
||||
select {
|
||||
case <-stop:
|
||||
cancelElection()
|
||||
case <-electionCtx.Done():
|
||||
}
|
||||
})
|
||||
|
||||
// (attempt to) Remove the virtual IP, in case it already exists
|
||||
|
||||
for i := range cluster.Network {
|
||||
err = cluster.Network[i].DeleteIP()
|
||||
deleted, err := cluster.Network[i].DeleteIP()
|
||||
if err != nil {
|
||||
log.Errorf("could not delete virtualIP: %v", err)
|
||||
log.Error("could not delete virtualIP", "err", err)
|
||||
}
|
||||
if deleted {
|
||||
log.Info("deleted address", "IP", cluster.Network[i].IP(), "interface", cluster.Network[i].Interface())
|
||||
}
|
||||
}
|
||||
|
||||
// Defer a function to check if the bgpServer has been created and if so attempt to close it
|
||||
defer func() {
|
||||
if bgpServer != nil {
|
||||
bgpServer.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// If Equinix Metal is enabled then we can begin our preparation work
|
||||
var packetClient *packngo.Client
|
||||
if c.EnableMetal {
|
||||
if c.ProviderConfig != "" {
|
||||
key, project, err := equinixmetal.GetPacketConfig(c.ProviderConfig)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
} else {
|
||||
// Set the environment variable with the key for the project
|
||||
os.Setenv("PACKET_AUTH_TOKEN", key)
|
||||
// Update the configuration with the project key
|
||||
c.MetalProjectID = project
|
||||
}
|
||||
}
|
||||
packetClient, err = packngo.NewClient()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
// We're using Equinix Metal with BGP, populate the Peer information from the API
|
||||
if c.EnableBGP {
|
||||
log.Infoln("Looking up the BGP configuration from Equinix Metal")
|
||||
err = equinixmetal.BGPLookup(packetClient, c)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.EnableBGP && bgpServer == nil {
|
||||
// Lets start BGP
|
||||
log.Info("Starting the BGP server to advertise VIP routes to VGP peers")
|
||||
bgpServer, err = bgp.NewBGPServer(&c.BGPConfig, nil)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
run := &runConfig{
|
||||
config: c,
|
||||
leaseID: c.NodeName,
|
||||
sm: sm,
|
||||
onStartedLeading: func(ctx context.Context) {
|
||||
// As we're leading lets start the vip service
|
||||
err := cluster.vipService(ctxArp, ctxDNS, c, sm, bgpServer, packetClient)
|
||||
if err != nil {
|
||||
log.Errorf("Error starting the VIP service on the leader [%s]", err)
|
||||
}
|
||||
},
|
||||
onStoppedLeading: func() {
|
||||
// we can do cleanup here
|
||||
log.Info("This node is becoming a follower within the cluster")
|
||||
|
||||
// Stop the dns context
|
||||
cancelDNS()
|
||||
// Stop the Arp context if it is running
|
||||
cancelArp()
|
||||
|
||||
// Stop the BGP server
|
||||
if bgpServer != nil {
|
||||
err := bgpServer.Close()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
for {
|
||||
if !objLease.BeginElection() {
|
||||
log.Debug("this election was already done, shared lease", "lease", leaseName)
|
||||
leaderGeneration, elected := objLease.WaitForLeaderGeneration(electionCtx)
|
||||
if !elected {
|
||||
if electionCtx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
// The runner that owned this shared lease's election ended it
|
||||
// without ever being elected; take over the campaign ourselves
|
||||
// instead of leaving the lease without an active runner.
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range cluster.Network {
|
||||
err := cluster.Network[i].DeleteIP()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
}
|
||||
leaderCtx, cancelLeader := context.WithCancel(electionCtx)
|
||||
leaderWG := sync.WaitGroup{}
|
||||
leaderWG.Go(func() {
|
||||
cluster.OnStartedLeading(leaderCtx, c, em, bgpServer, killFunc, true)
|
||||
})
|
||||
|
||||
log.Fatal("lost leadership, restarting kube-vip")
|
||||
log.Debug("cluster waiting for shared election to finish", "lease", leaseName)
|
||||
objLease.WaitForElectionEndAfter(electionCtx, leaderGeneration)
|
||||
cancelLeader()
|
||||
leaderWG.Wait()
|
||||
|
||||
cluster.OnStoppedLeading(c, bgpServer)
|
||||
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
defer objLease.ElectionStopped()
|
||||
|
||||
run := &election.RunConfig{
|
||||
Config: c,
|
||||
LeaseID: leaseID,
|
||||
LeaseAnnotations: c.LeaseAnnotations,
|
||||
VIPs: controlPlaneElectionVIPs(c),
|
||||
Mgr: em,
|
||||
OnStartedLeading: func(ctx context.Context) {
|
||||
objLease.ElectionStarted()
|
||||
cluster.OnStartedLeading(ctx, c, em, bgpServer, killFunc, false)
|
||||
},
|
||||
onNewLeader: func(identity string) {
|
||||
// we're notified when new leader elected
|
||||
log.Infof("Node [%s] is assuming leadership of the cluster", identity)
|
||||
OnStoppedLeading: func() {
|
||||
objLease.ElectionStopped()
|
||||
cluster.OnStoppedLeading(c, bgpServer)
|
||||
},
|
||||
OnNewLeader: func(identity string) {
|
||||
cluster.OnNewLeader(identity, c)
|
||||
},
|
||||
}
|
||||
|
||||
switch c.LeaderElectionType {
|
||||
case "kubernetes", "":
|
||||
cluster.runKubernetesLeaderElectionOrDie(ctx, run)
|
||||
case "etcd":
|
||||
cluster.runEtcdLeaderElectionOrDie(ctx, run)
|
||||
default:
|
||||
log.Info(fmt.Sprintf("LeaderElectionMode %s not supported, exiting", c.LeaderElectionType))
|
||||
if err := election.RunOrDie(electionCtx, run, c); err != nil {
|
||||
cluster.Stop()
|
||||
return fmt.Errorf("leaderelection failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type runConfig struct {
|
||||
config *kubevip.Config
|
||||
leaseID string
|
||||
sm *Manager
|
||||
|
||||
// onStartedLeading is called when this member starts leading.
|
||||
onStartedLeading func(context.Context)
|
||||
// onStoppedLeading is called when this member stops leading.
|
||||
onStoppedLeading func()
|
||||
// onNewLeader is called when the client observes a leader that is
|
||||
// not the previously observed leader. This includes the first observed
|
||||
// leader when the client starts.
|
||||
onNewLeader func(identity string)
|
||||
}
|
||||
|
||||
func (cluster *Cluster) runKubernetesLeaderElectionOrDie(ctx context.Context, run *runConfig) {
|
||||
// we use the Lease lock type since edits to Leases are less common
|
||||
// and fewer objects in the cluster watch "all Leases".
|
||||
lock := &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{
|
||||
Name: run.config.LeaseName,
|
||||
Namespace: run.config.Namespace,
|
||||
Annotations: run.config.LeaseAnnotations,
|
||||
},
|
||||
Client: run.sm.KubernetesClient.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{
|
||||
Identity: run.leaseID,
|
||||
},
|
||||
func controlPlaneElectionVIPs(config *kubevip.Config) []string {
|
||||
configured := config.VIP
|
||||
if config.Address != "" {
|
||||
configured = config.Address
|
||||
}
|
||||
|
||||
// start the leader election code loop
|
||||
leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
|
||||
Lock: lock,
|
||||
// IMPORTANT: you MUST ensure that any code you have that
|
||||
// is protected by the lease must terminate **before**
|
||||
// you call cancel. Otherwise, you could have a background
|
||||
// loop still running and another process could
|
||||
// get elected before your background loop finished, violating
|
||||
// the stated goal of the lease.
|
||||
ReleaseOnCancel: true,
|
||||
LeaseDuration: time.Duration(run.config.LeaseDuration) * time.Second,
|
||||
RenewDeadline: time.Duration(run.config.RenewDeadline) * time.Second,
|
||||
RetryPeriod: time.Duration(run.config.RetryPeriod) * time.Second,
|
||||
Callbacks: leaderelection.LeaderCallbacks{
|
||||
OnStartedLeading: run.onStartedLeading,
|
||||
OnStoppedLeading: run.onStoppedLeading,
|
||||
OnNewLeader: run.onNewLeader,
|
||||
},
|
||||
})
|
||||
return vip.Split(configured)
|
||||
}
|
||||
|
||||
func (cluster *Cluster) runEtcdLeaderElectionOrDie(ctx context.Context, run *runConfig) {
|
||||
etcd.RunElectionOrDie(ctx, &etcd.LeaderElectionConfig{
|
||||
EtcdConfig: etcd.ClientConfig{Client: run.sm.EtcdClient},
|
||||
Name: run.config.LeaseName,
|
||||
MemberID: run.leaseID,
|
||||
LeaseDurationSeconds: int64(run.config.LeaseDuration),
|
||||
Callbacks: etcd.LeaderCallbacks{
|
||||
OnStartedLeading: run.onStartedLeading,
|
||||
OnStoppedLeading: run.onStoppedLeading,
|
||||
OnNewLeader: run.onNewLeader,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (sm *Manager) NodeWatcher(lb *loadbalancer.IPVSLoadBalancer, port int) error {
|
||||
// Use a restartable watcher, as this should help in the event of etcd or timeout issues
|
||||
log.Infof("Kube-Vip is watching nodes for control-plane labels")
|
||||
|
||||
listOptions := metav1.ListOptions{
|
||||
LabelSelector: "node-role.kubernetes.io/control-plane",
|
||||
func (cluster *Cluster) OnStartedLeading(ctx context.Context, c *kubevip.Config,
|
||||
em *election.Manager, bgpServer *bgp.Server, killFunc func(), _ bool) {
|
||||
labels := generateLabelsFromConfig(c.Address, kubevip.HasIP)
|
||||
if err := cluster.nodeLabelMgr.AddLabel(labels); err != nil {
|
||||
log.Error("error adding label to node", "err", err)
|
||||
}
|
||||
cluster.labelAdded = true
|
||||
|
||||
rw, err := watchtools.NewRetryWatcher("1", &cache.ListWatch{
|
||||
WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
|
||||
return sm.KubernetesClient.CoreV1().Nodes().Watch(context.Background(), listOptions)
|
||||
},
|
||||
})
|
||||
// As we're leading lets start the vip service
|
||||
err := cluster.StartVipService(ctx, c, em, bgpServer, killFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating label watcher: %s", err.Error())
|
||||
log.Error("starting VIP service on leader", "err", err)
|
||||
killFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func (cluster *Cluster) OnStoppedLeading(c *kubevip.Config, bgpServer *bgp.Server) {
|
||||
// we can do cleanup here
|
||||
log.Info("This node is becoming a follower within the cluster")
|
||||
|
||||
if cluster.labelAdded {
|
||||
labels := generateLabelsFromConfig(c.Address, kubevip.HasIP)
|
||||
if err := cluster.nodeLabelMgr.RemoveLabel(labels); err != nil {
|
||||
log.Error("error removing label from node", "err", err)
|
||||
}
|
||||
cluster.labelAdded = false
|
||||
}
|
||||
|
||||
cluster.cleanupVIPs(c)
|
||||
|
||||
log.Error("lost leadership, restarting kube-vip")
|
||||
}
|
||||
|
||||
func (cluster *Cluster) OnNewLeader(identity string, c *kubevip.Config) {
|
||||
// we're notified when new leader elected
|
||||
log.Info("New leader", "leader", identity)
|
||||
}
|
||||
|
||||
func generateLabelsFromConfig(addr, labelKey string) map[string]string {
|
||||
return map[string]string{
|
||||
labelKey: utils.SanitizeIPForLabel(addr),
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-sm.SignalChan
|
||||
log.Info("Received termination, signaling shutdown")
|
||||
// Cancel the context
|
||||
rw.Stop()
|
||||
}()
|
||||
|
||||
ch := rw.ResultChan()
|
||||
// defer rw.Stop()
|
||||
|
||||
for event := range ch {
|
||||
// We need to inspect the event and get ResourceVersion out of it
|
||||
switch event.Type {
|
||||
case watch.Added, watch.Modified:
|
||||
node, ok := event.Object.(*v1.Node)
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to parse Kubernetes Node from Annotation watcher")
|
||||
}
|
||||
// Find the node IP address (this isn't foolproof)
|
||||
for x := range node.Status.Addresses {
|
||||
if node.Status.Addresses[x].Type == v1.NodeInternalIP {
|
||||
err = lb.AddBackend(node.Status.Addresses[x].Address, port)
|
||||
if err != nil {
|
||||
log.Errorf("add IPVS backend [%v]", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
case watch.Deleted:
|
||||
node, ok := event.Object.(*v1.Node)
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to parse Kubernetes Node from Annotation watcher")
|
||||
}
|
||||
|
||||
// Find the node IP address (this isn't foolproof)
|
||||
for x := range node.Status.Addresses {
|
||||
if node.Status.Addresses[x].Type == v1.NodeInternalIP {
|
||||
err = lb.RemoveBackend(node.Status.Addresses[x].Address, port)
|
||||
if err != nil {
|
||||
log.Errorf("Del IPVS backend [%v]", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("Node [%s] has been deleted", node.Name)
|
||||
|
||||
case watch.Bookmark:
|
||||
// Un-used
|
||||
case watch.Error:
|
||||
log.Error("Error attempting to watch Kubernetes Nodes")
|
||||
|
||||
// This round trip allows us to handle unstructured status
|
||||
errObject := apierrors.FromObject(event.Object)
|
||||
statusErr, ok := errObject.(*apierrors.StatusError)
|
||||
if !ok {
|
||||
log.Errorf(spew.Sprintf("Received an error which is not *metav1.Status but %#+v", event.Object))
|
||||
}
|
||||
|
||||
status := statusErr.ErrStatus
|
||||
log.Errorf("%v", status)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
log.Infoln("Exiting Node watcher")
|
||||
return nil
|
||||
}
|
||||
|
||||
196
pkg/cluster/cluster_internal_test.go
Normal file
196
pkg/cluster/cluster_internal_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/arp"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
"github.com/vishvananda/netlink"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func TestControlPlaneElectionVIPsPreservesConfigOrder(t *testing.T) {
|
||||
config := &kubevip.Config{Address: "2001:db8::10,192.0.2.10"}
|
||||
want := []string{"2001:db8::10", "192.0.2.10"}
|
||||
if got := controlPlaneElectionVIPs(config); !slices.Equal(got, want) {
|
||||
t.Fatalf("controlPlaneElectionVIPs() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingLabeler struct {
|
||||
added chan struct{}
|
||||
removed chan struct{}
|
||||
}
|
||||
|
||||
func (l *recordingLabeler) AddLabel(map[string]string) error {
|
||||
l.added <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *recordingLabeler) RemoveLabel(map[string]string) error {
|
||||
l.removed <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubNetwork is a minimal vip.Network implementation for exercising
|
||||
// cleanupVIP without a real interface.
|
||||
type stubNetwork struct {
|
||||
ip string
|
||||
deleteIPCalls int
|
||||
}
|
||||
|
||||
func (s *stubNetwork) AddIP(bool, bool, ...int) (bool, error) { return false, nil }
|
||||
func (s *stubNetwork) AddRoute(bool) (bool, error) { return false, nil }
|
||||
func (s *stubNetwork) ReplaceRoute() error { return nil }
|
||||
func (s *stubNetwork) DeleteIP() (bool, error) { s.deleteIPCalls++; return true, nil }
|
||||
func (s *stubNetwork) DeleteRoute() error { return nil }
|
||||
func (s *stubNetwork) UpdateRoutes() (bool, error) { return false, nil }
|
||||
func (s *stubNetwork) IsSet() (*netlink.Addr, error) { return nil, nil }
|
||||
func (s *stubNetwork) IP() string { return s.ip }
|
||||
func (s *stubNetwork) CIDR() string { return s.ip + "/32" }
|
||||
func (s *stubNetwork) IPisLinkLocal() bool { return false }
|
||||
func (s *stubNetwork) PrepareRoute() *netlink.Route { return nil }
|
||||
func (s *stubNetwork) RouteHash() string { return "" }
|
||||
func (s *stubNetwork) SetIP(string) error { return nil }
|
||||
func (s *stubNetwork) SetServicePorts(*v1.Service) {}
|
||||
func (s *stubNetwork) Interface() string { return "eth0" }
|
||||
func (s *stubNetwork) IsDADFAIL() bool { return false }
|
||||
func (s *stubNetwork) IsDNS() bool { return false }
|
||||
func (s *stubNetwork) IsDDNS() bool { return false }
|
||||
func (s *stubNetwork) DDNSHostName() string { return "" }
|
||||
func (s *stubNetwork) DNSName() string { return "" }
|
||||
func (s *stubNetwork) SetMask(string) error { return nil }
|
||||
func (s *stubNetwork) SetHasEndpoints(bool) {}
|
||||
func (s *stubNetwork) HasEndpoints() bool { return false }
|
||||
func (s *stubNetwork) ARPName() string { return "shared-vip" }
|
||||
func (s *stubNetwork) GetPossibleSubnets() string { return "" }
|
||||
func (s *stubNetwork) DHCPFamily() string { return "" }
|
||||
func (s *stubNetwork) IPVSMark() uint32 { return 0 }
|
||||
|
||||
// TestCleanupVIPRetainsSharedVIPWithOneSiblingLeft reproduces the off-by-one:
|
||||
// layer2Update already removes its own ARP claim before cleanupVIP runs, so a
|
||||
// single remaining sibling must still block deletion.
|
||||
func TestCleanupVIPRetainsSharedVIPWithOneSiblingLeft(t *testing.T) {
|
||||
arpMgr := arp.NewManager(&kubevip.Config{ArpBroadcastRate: 3000})
|
||||
netA := &stubNetwork{ip: "192.0.2.10"}
|
||||
netB := &stubNetwork{ip: "192.0.2.10"}
|
||||
|
||||
instA := arp.NewInstance(netA, nil)
|
||||
instB := arp.NewInstance(netB, nil)
|
||||
arpMgr.Insert(instA)
|
||||
arpMgr.Insert(instB)
|
||||
|
||||
// Cluster A's layer2Update goroutine ends first and drops its own claim,
|
||||
// leaving only sibling B registered.
|
||||
arpMgr.Remove(instA)
|
||||
|
||||
c := &Cluster{arpMgr: arpMgr}
|
||||
c.cleanupVIP(&kubevip.Config{EnableARP: true}, netA, 0)
|
||||
|
||||
if netA.deleteIPCalls != 0 {
|
||||
t.Fatalf("cleanupVIP deleted the shared VIP while a sibling was still registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupVIPsDeletesControlPlaneVIPHoldingItsOwnARPClaim covers the
|
||||
// leadership loss path: OnStoppedLeading runs concurrently with the control
|
||||
// plane layer2Update goroutine, so the VIP's only ARP claim is still the
|
||||
// caller's own and the address must still be removed before the process exits.
|
||||
func TestCleanupVIPsDeletesControlPlaneVIPHoldingItsOwnARPClaim(t *testing.T) {
|
||||
arpMgr := arp.NewManager(&kubevip.Config{ArpBroadcastRate: 3000})
|
||||
network := &stubNetwork{ip: "2001:db8::10"}
|
||||
arpMgr.Insert(arp.NewInstance(network, nil))
|
||||
|
||||
c := &Cluster{arpMgr: arpMgr, Network: []vip.Network{network}}
|
||||
c.cleanupVIPs(&kubevip.Config{EnableARP: true})
|
||||
|
||||
if network.deleteIPCalls != 1 {
|
||||
t.Fatalf("cleanupVIPs made %d DeleteIP calls, want 1", network.deleteIPCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlaneFollowsSharedServiceElection(t *testing.T) {
|
||||
config := &kubevip.Config{KubernetesLeaderElection: kubevip.KubernetesLeaderElection{LeaseName: "default/shared"}}
|
||||
leaseID := lease.NewID(config.LeaderElectionType, "default", "shared")
|
||||
leaseMgr := lease.NewManager()
|
||||
sharedLease, _ := leaseMgr.Acquire(context.Background(), leaseID, "service")
|
||||
if !sharedLease.BeginElection() {
|
||||
t.Fatal("Service election did not start")
|
||||
}
|
||||
sharedLease.ElectionStarted()
|
||||
|
||||
labels := &recordingLabeler{added: make(chan struct{}, 1), removed: make(chan struct{}, 1)}
|
||||
cluster := &Cluster{stop: make(chan struct{}), nodeLabelMgr: labels}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cluster.StartCluster(context.Background(), config, nil, nil, leaseMgr, func() {})
|
||||
}()
|
||||
select {
|
||||
case <-labels.added:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("control plane did not activate under the shared Service election")
|
||||
}
|
||||
|
||||
sharedLease.ElectionStopped()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("shared control-plane follower returned an error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("control plane did not stop after shared Service leadership ended")
|
||||
}
|
||||
select {
|
||||
case <-labels.removed:
|
||||
default:
|
||||
t.Fatal("control-plane label was not removed after shared leadership ended")
|
||||
}
|
||||
if sharedLease.Ctx.Err() != nil || leaseMgr.Get(leaseID) != sharedLease {
|
||||
t.Fatal("control-plane cleanup cancelled the surviving Service lease")
|
||||
}
|
||||
leaseMgr.Delete(leaseID, "service", sharedLease)
|
||||
}
|
||||
|
||||
func TestStopAndWaitPreservingUpgradesInProgressStop(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
service := &Cluster{
|
||||
stop: make(chan struct{}),
|
||||
service: &servicesWorker{
|
||||
stop: make(chan struct{}),
|
||||
done: done,
|
||||
stopping: true,
|
||||
},
|
||||
}
|
||||
|
||||
returned := make(chan struct{})
|
||||
go func() {
|
||||
service.StopAndWaitPreserving("192.0.2.10")
|
||||
close(returned)
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
service.stopMu.Lock()
|
||||
_, preserving := service.service.preserveVIPs["192.0.2.10"]
|
||||
service.stopMu.Unlock()
|
||||
if preserving {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("preserving stop did not update the in-progress worker shutdown")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
service.finishServicesWorker(done)
|
||||
select {
|
||||
case <-returned:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("preserving stop did not return after worker cleanup completed")
|
||||
}
|
||||
}
|
||||
32
pkg/cluster/cluster_stop_test.go
Normal file
32
pkg/cluster/cluster_stop_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStopConcurrentDoesNotRaceOrPanic(t *testing.T) {
|
||||
c := &Cluster{stop: make(chan struct{})}
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
var panics atomic.Int64
|
||||
|
||||
for range 128 {
|
||||
wg.Go(func() {
|
||||
<-start
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
panics.Add(1)
|
||||
}
|
||||
}()
|
||||
c.Stop()
|
||||
})
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if got := panics.Load(); got != 0 {
|
||||
t.Fatalf("concurrent Stop panicked %d time(s)", got)
|
||||
}
|
||||
}
|
||||
125
pkg/cluster/cluster_test.go
Normal file
125
pkg/cluster/cluster_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package cluster_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/cluster"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
)
|
||||
|
||||
func TestInitCluster_HealthCheckClientNoCA(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
ControlPlaneHealthCheck: kubevip.HealthCheck{
|
||||
Address: "http://localhost:6443/livez",
|
||||
TimeoutSeconds: 5,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := cluster.InitCluster(cfg, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCluster_HealthCheckClientValidCA(t *testing.T) {
|
||||
t.Parallel()
|
||||
caPEM := generateTestCACert(t)
|
||||
caFile := filepath.Join(t.TempDir(), "ca.crt")
|
||||
if err := os.WriteFile(caFile, caPEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
ControlPlaneHealthCheck: kubevip.HealthCheck{
|
||||
Address: "https://localhost:6443/livez",
|
||||
TimeoutSeconds: 3,
|
||||
CAPath: caFile,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := cluster.InitCluster(cfg, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCluster_HealthCheckClientInvalidCAPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
ControlPlaneHealthCheck: kubevip.HealthCheck{
|
||||
Address: "https://localhost:6443/livez",
|
||||
CAPath: "/nonexistent/ca.crt",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := cluster.InitCluster(cfg, true, nil, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid CA path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reading health check CA cert") {
|
||||
t.Errorf("expected error about reading CA cert, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCluster_HealthCheckClientInvalidCAContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
caFile := filepath.Join(t.TempDir(), "bad-ca.crt")
|
||||
if err := os.WriteFile(caFile, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
ControlPlaneHealthCheck: kubevip.HealthCheck{
|
||||
Address: "https://localhost:6443/livez",
|
||||
CAPath: caFile,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := cluster.InitCluster(cfg, true, nil, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid CA content")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "contains no valid certificates") {
|
||||
t.Errorf("expected error about invalid certificates, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// generateTestCACert creates a self-signed CA certificate in PEM format for testing.
|
||||
func generateTestCACert(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
}
|
||||
@@ -2,290 +2,625 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/arp"
|
||||
"github.com/kube-vip/kube-vip/pkg/backend"
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/equinixmetal"
|
||||
"github.com/kube-vip/kube-vip/pkg/election"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/loadbalancer"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
"github.com/packethost/packngo"
|
||||
log "github.com/sirupsen/logrus"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
func (cluster *Cluster) vipService(ctxArp, ctxDNS context.Context, c *kubevip.Config, sm *Manager, bgpServer *bgp.Server, packetClient *packngo.Client) error {
|
||||
func (cluster *Cluster) StartVipService(ctx context.Context, c *kubevip.Config, em *election.Manager, bgpServer bgp.BGPManager, killFunc func()) error {
|
||||
|
||||
var err error
|
||||
|
||||
// listen for interrupts or the Linux SIGTERM signal and cancel
|
||||
// our context, which the leader election code will observe and
|
||||
// step down
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
// Add Notification for Userland interrupt
|
||||
signal.Notify(signalChan, syscall.SIGINT)
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
|
||||
// Add Notification for SIGTERM (sent from Kubernetes)
|
||||
signal.Notify(signalChan, syscall.SIGTERM)
|
||||
wg.Go(func() {
|
||||
<-ctx.Done()
|
||||
killFunc()
|
||||
})
|
||||
|
||||
loadbalancers := []*loadbalancer.IPVSLoadBalancer{}
|
||||
|
||||
for i := range cluster.Network {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
network := cluster.Network[i]
|
||||
|
||||
if cluster.Network[i].IsDDNS() {
|
||||
if err := cluster.StartDDNS(ctxDNS); err != nil {
|
||||
log.Error(err)
|
||||
if network.IsDDNS() {
|
||||
if err := cluster.StartDDNS(ctx, cluster.Network[i], c.DHCPBackoffAttempts, &wg); err != nil {
|
||||
log.Error("failed to start DDNS", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := network.SetMask(c.VIPSubnet); err != nil {
|
||||
killFunc()
|
||||
return fmt.Errorf("failed to set mask for subnet %q: %w", c.VIPSubnet, err)
|
||||
}
|
||||
|
||||
// start the dns updater if address is dns
|
||||
if cluster.Network[i].IsDNS() {
|
||||
log.Infof("starting the DNS updater for the address %s", cluster.Network[i].DNSName())
|
||||
ipUpdater := vip.NewIPUpdater(cluster.Network[i])
|
||||
ipUpdater.Run(ctxDNS)
|
||||
if network.IsDNS() {
|
||||
log.Info("starting the DNS updater", "address", network.DNSName())
|
||||
ipUpdater := vip.NewIPUpdater(network)
|
||||
wg.Go(func() {
|
||||
ipUpdater.Run(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
err = cluster.Network[i].AddIP()
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
if c.EnableMetal {
|
||||
// We're not using Equinix Metal with BGP
|
||||
if !c.EnableBGP {
|
||||
// Attempt to attach the EIP in the standard manner
|
||||
log.Debugf("Attaching the Equinix Metal EIP through the API to this host")
|
||||
err = equinixmetal.AttachEIP(packetClient, c, c.NodeName)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
if !c.EnableRoutingTable {
|
||||
// Normal VIP addition, use skipDAD=false for normal DAD process
|
||||
if _, err = network.AddIP(false, false); err != nil {
|
||||
log.Error("failed to add IP", "address", network.IP(), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.EnableBGP {
|
||||
// Lets advertise the VIP over BGP, the host needs to be passed using CIDR notation
|
||||
cidrVip := fmt.Sprintf("%s/%s", cluster.Network[i].IP(), c.VIPCIDR)
|
||||
log.Debugf("Attempting to advertise the address [%s] over BGP", cidrVip)
|
||||
|
||||
err = bgpServer.AddHost(cidrVip)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
if c.ControlPlaneHealthCheck.Address != "" {
|
||||
// The health check loop owns route advertisement/withdrawal when configured.
|
||||
wg.Go(func() {
|
||||
cluster.bgpHealthCheckLoop(ctx, c, bgpServer, network.CIDR())
|
||||
})
|
||||
} else {
|
||||
// Lets advertise the VIP over BGP, the host needs to be passed using CIDR notation.
|
||||
log.Debug("Attempting to advertise over BGP", "address", network.CIDR())
|
||||
err = bgpServer.AddHost(ctx, network.CIDR(), c.NodeName)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.EnableLoadBalancer {
|
||||
|
||||
log.Infof("Starting IPVS LoadBalancer")
|
||||
|
||||
lb, err := loadbalancer.NewIPVSLB(cluster.Network[i].IP(), c.LoadBalancerPort, c.LoadBalancerForwardingMethod, c.BackendHealthCheckInterval)
|
||||
lb, err := loadbalancer.NewIPVSLB(ctx, network, c.LoadBalancerPort, c.LoadBalancerForwardingMethod,
|
||||
c.BackendHealthCheckInterval, c.EgressWithNftables, killFunc, &wg)
|
||||
if err != nil {
|
||||
log.Errorf("Error creating IPVS LoadBalancer [%s]", err)
|
||||
killFunc()
|
||||
return fmt.Errorf("creating IPVS LoadBalancer: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = sm.NodeWatcher(lb, c.Port)
|
||||
if err != nil {
|
||||
log.Errorf("Error watching node labels [%s]", err)
|
||||
wg.Go(func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
err = em.NodeWatcher(ctx, lb, c.Port)
|
||||
if err != nil {
|
||||
log.Error("Error watching node labels", "err", err)
|
||||
if utils.IsPanicError(err) {
|
||||
killFunc()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Shutdown function that will wait on this signal, unless we call it ourselves
|
||||
go func() {
|
||||
<-signalChan
|
||||
err = lb.RemoveIPVSLB()
|
||||
if err != nil {
|
||||
log.Errorf("Error stopping IPVS LoadBalancer [%s]", err)
|
||||
}
|
||||
log.Info("Stopping IPVS LoadBalancer")
|
||||
}()
|
||||
})
|
||||
|
||||
loadbalancers = append(loadbalancers, lb)
|
||||
}
|
||||
|
||||
if c.EnableARP {
|
||||
// ctxArp, cancelArp = context.WithCancel(context.Background())
|
||||
|
||||
go func(ctx context.Context) {
|
||||
ipString := cluster.Network[i].IP()
|
||||
isIPv6 := vip.IsIPv6(ipString)
|
||||
|
||||
var ndp *vip.NdpResponder
|
||||
if isIPv6 {
|
||||
ndp, err = vip.NewNDPResponder(cluster.Network[i].Interface())
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create new NDP Responder")
|
||||
}
|
||||
}
|
||||
|
||||
if ndp != nil {
|
||||
defer ndp.Close()
|
||||
}
|
||||
log.Infof("Gratuitous Arp broadcast will repeat every 3 seconds for [%s/%s]", ipString, cluster.Network[i].Interface())
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): // if cancel() execute
|
||||
return
|
||||
default:
|
||||
cluster.ensureIPAndSendGratuitous(cluster.Network[i].Interface(), ndp)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}(ctxArp)
|
||||
wg.Go(func() {
|
||||
cluster.layer2Update(ctx, network, c)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if c.EnableRoutingTable {
|
||||
err = cluster.Network[i].AddRoute()
|
||||
if c.EnableLoadBalancer {
|
||||
// Shutdown function that will wait on this signal, unless we call it ourselves
|
||||
<-ctx.Done()
|
||||
for _, lb := range loadbalancers {
|
||||
err = lb.RemoveIPVSLB()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
log.Error("Error stopping IPVS LoadBalancer", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.EnableRoutingTable {
|
||||
backendMapV4 := backend.Map{}
|
||||
backendMapV6 := backend.Map{}
|
||||
// only check localhost
|
||||
|
||||
// An explicitly configured Kubernetes API address (static-pod
|
||||
// deployments point it at the local API server, whose loopback
|
||||
// listener is often the only certificate-valid local endpoint)
|
||||
// takes precedence over the Node object's addresses: the check
|
||||
// answers "is the local API server healthy" for every VIP family,
|
||||
// regardless of the transport family of the override itself.
|
||||
if entry := kubernetesAddrBackendEntry(c.KubernetesAddr, c.Port); entry != nil {
|
||||
log.Info("using configured Kubernetes address for backend health checks", "address", c.KubernetesAddr)
|
||||
backendMapV4[*entry] = false
|
||||
backendMapV6[*entry] = false
|
||||
} else {
|
||||
ips := []string{}
|
||||
if c.NodeName != "" {
|
||||
if ips, err = getNodeIPs(ctx, c.NodeName, em.KubernetesClient); err != nil && !apierrors.IsNotFound(err) {
|
||||
log.Error("failed to get IP of control-plane node", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
if !utils.IsIPv6(cluster.Network[0].IP()) {
|
||||
ips = append(ips, "127.0.0.1")
|
||||
} else {
|
||||
ips = append(ips, "::1")
|
||||
}
|
||||
|
||||
log.Info("no IP address found for node - will fallback to use localhost address", "addresses", ips)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
entry := backend.Entry{Addr: ip, Port: c.Port}
|
||||
if !utils.IsIPv6(ip) {
|
||||
backendMapV4[entry] = false
|
||||
} else {
|
||||
backendMapV6[entry] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backend.SetKubeConfigPath(c.K8sConfigFile)
|
||||
backend.Watch(ctx, c.BackendHealthCheckInterval, func() {
|
||||
for i := range cluster.Network {
|
||||
network := cluster.Network[i]
|
||||
networkIP := network.IP()
|
||||
isNetworkV6 := utils.IsIPv6(networkIP)
|
||||
log.Debug("current ip to process", "ip", networkIP)
|
||||
|
||||
backendMap := &backendMapV4
|
||||
if isNetworkV6 {
|
||||
backendMap = &backendMapV6
|
||||
}
|
||||
|
||||
for entry := range *backendMap {
|
||||
log.Debug("entry.Check() for entry", "entry", entry)
|
||||
var healthy bool
|
||||
if c.ControlPlaneHealthCheck.Address != "" {
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, c.ControlPlaneHealthCheck.Address, nil)
|
||||
if reqErr != nil {
|
||||
log.Error("create health check request", "err", reqErr)
|
||||
} else if resp, doErr := cluster.healthCheckHTTPClient.Do(req); doErr != nil {
|
||||
log.Error("health check request failed", "url", c.ControlPlaneHealthCheck.Address, "err", doErr)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
healthy = resp.StatusCode == http.StatusOK
|
||||
if !healthy {
|
||||
log.Warn("health check returned non-200 status", "url", c.ControlPlaneHealthCheck.Address, "status", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
healthy = entry.Check()
|
||||
}
|
||||
if healthy {
|
||||
log.Debug("entry.Check() true")
|
||||
// Normal VIP addition with precheck, use skipDAD=false for normal DAD process
|
||||
_, err = network.AddIP(true, false)
|
||||
if err != nil {
|
||||
log.Error("error adding address", "err", err)
|
||||
}
|
||||
if !(*backendMap)[entry] {
|
||||
log.Info("added backend", "ip", network.IP())
|
||||
}
|
||||
|
||||
err = cluster.routeMgr.Add(c.NodeName, network, true, false)
|
||||
if err != nil && !errors.Is(err, fs.ErrExist) && !errors.Is(err, syscall.ESRCH) {
|
||||
log.Warn(err.Error())
|
||||
} else if err == nil && !(*backendMap)[entry] {
|
||||
log.Info("added route", "route", network.PrepareRoute())
|
||||
} else if err == nil || errors.Is(err, fs.ErrExist) {
|
||||
// Re-assert the route on every healthy cycle: routing daemons
|
||||
// (e.g. zebra) can miss the single netlink event for the route,
|
||||
// leaving it unadvertised even though it exists in the kernel.
|
||||
// RouteReplace is idempotent and regenerates that event.
|
||||
if replaceErr := network.ReplaceRoute(); replaceErr != nil {
|
||||
log.Warn("re-asserting route", "err", replaceErr)
|
||||
} else {
|
||||
log.Debug("re-asserted route", "route", network.PrepareRoute())
|
||||
}
|
||||
}
|
||||
|
||||
(*backendMap)[entry] = true
|
||||
break
|
||||
}
|
||||
(*backendMap)[entry] = false
|
||||
}
|
||||
|
||||
deleteAddress := true
|
||||
for entry := range *backendMap {
|
||||
if (*backendMap)[entry] {
|
||||
deleteAddress = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if deleteAddress {
|
||||
err = cluster.routeMgr.Delete(c.NodeName, network)
|
||||
if err != nil {
|
||||
log.Warn("deleting route", "err", err)
|
||||
}
|
||||
|
||||
deleted, err := network.DeleteIP()
|
||||
if err != nil {
|
||||
log.Error("error deleting IP", "err", err)
|
||||
killFunc()
|
||||
return
|
||||
}
|
||||
if deleted {
|
||||
log.Info("deleted address", "IP", network.IP(), "interface", network.Interface())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if c.EnableBGP {
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cluster *Cluster) bgpHealthCheck(ctx context.Context, c *kubevip.Config) (bool, error) {
|
||||
statusCode := 0
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ControlPlaneHealthCheck.Address, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("building request %v: %w", req, err)
|
||||
} else {
|
||||
resp, err := cluster.healthCheckHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking control-plane: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
statusCode = resp.StatusCode
|
||||
}
|
||||
healthy := statusCode == http.StatusOK
|
||||
if !healthy {
|
||||
return healthy, fmt.Errorf("wrong status code: %d", statusCode)
|
||||
}
|
||||
return healthy, nil
|
||||
}
|
||||
|
||||
func (cluster *Cluster) bgpHealthCheckLoop(ctx context.Context, c *kubevip.Config, bgpServer bgp.BGPManager, vipCIDR string) {
|
||||
period := time.Duration(c.ControlPlaneHealthCheck.PeriodSeconds) * time.Second
|
||||
|
||||
consecutiveFailures := 0
|
||||
routeAnnounced := false
|
||||
ticker := time.NewTicker(period)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Info("Starting BGP health check",
|
||||
"address", c.ControlPlaneHealthCheck.Address,
|
||||
"cidr", vipCIDR,
|
||||
"period", period,
|
||||
"timeout", cluster.healthCheckHTTPClient.Timeout,
|
||||
"threshold", c.ControlPlaneHealthCheck.FailureThreshold,
|
||||
)
|
||||
|
||||
for {
|
||||
healthy, healthErr := cluster.bgpHealthCheck(ctx, c)
|
||||
if healthy {
|
||||
consecutiveFailures = 0
|
||||
if !routeAnnounced {
|
||||
log.Info("BGP health check passed, announcing route", "cidr", vipCIDR)
|
||||
if err := bgpServer.AddHost(ctx, vipCIDR, c.NodeName); err != nil {
|
||||
log.Error("BGP health check: failed to announce route", "cidr", vipCIDR, "err", err)
|
||||
} else {
|
||||
routeAnnounced = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consecutiveFailures++
|
||||
if healthErr != nil {
|
||||
log.Warn("BGP health check failed", "address", c.ControlPlaneHealthCheck.Address, "consecutive", consecutiveFailures, "err", healthErr)
|
||||
}
|
||||
if consecutiveFailures >= c.ControlPlaneHealthCheck.FailureThreshold && routeAnnounced {
|
||||
log.Warn("BGP health check threshold reached, withdrawing route", "failureThreshold", c.ControlPlaneHealthCheck.FailureThreshold, "cidr", vipCIDR)
|
||||
if err := bgpServer.DelHost(ctx, vipCIDR, c.NodeName); err != nil {
|
||||
log.Error("BGP health check: failed to withdraw route", "cidr", vipCIDR, "err", err)
|
||||
} else {
|
||||
routeAnnounced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if routeAnnounced {
|
||||
if err := bgpServer.DelHost(ctx, vipCIDR, c.NodeName); err != nil {
|
||||
log.Error("BGP health check: failed to withdraw route", "cidr", vipCIDR, "err", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// kubernetesAddrBackendEntry converts an explicitly configured Kubernetes
|
||||
// API address override (config.KubernetesAddr, e.g. "https://127.0.0.1:6443"
|
||||
// on static-pod deployments) into a backend health-check entry. Returns nil
|
||||
// when no usable override is configured.
|
||||
func kubernetesAddrBackendEntry(kubernetesAddr string, defaultPort uint16) *backend.Entry {
|
||||
if kubernetesAddr == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(kubernetesAddr)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return nil
|
||||
}
|
||||
port := defaultPort
|
||||
if p := u.Port(); p != "" {
|
||||
if parsed, err := strconv.ParseUint(p, 10, 16); err == nil {
|
||||
port = uint16(parsed)
|
||||
}
|
||||
}
|
||||
return &backend.Entry{Addr: u.Hostname(), Port: port}
|
||||
}
|
||||
|
||||
func getNodeIPs(ctx context.Context, nodename string, client *kubernetes.Clientset) ([]string, error) {
|
||||
node, err := client.CoreV1().Nodes().Get(ctx, nodename, metav1.GetOptions{})
|
||||
if err != nil && !apierrors.IsNotFound(err) {
|
||||
return []string{}, fmt.Errorf("failed to get data about '%s' node: %w", nodename, err)
|
||||
}
|
||||
ips := []string{}
|
||||
for _, addr := range node.Status.Addresses {
|
||||
if addr.Type == corev1.NodeInternalIP {
|
||||
ips = append(ips, addr.Address)
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// StartLoadBalancerService will start a VIP instance and leave it for kube-proxy to handle
|
||||
func (cluster *Cluster) StartLoadBalancerService(c *kubevip.Config, bgp *bgp.Server) {
|
||||
func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip.Config, bgp bgp.BGPManager, name string, wg *sync.WaitGroup) error {
|
||||
// use a Go context so we can tell the arp loop code when we
|
||||
// want to step down
|
||||
//nolint
|
||||
ctxArp, cancelArp := context.WithCancel(context.Background())
|
||||
|
||||
cluster.stop = make(chan bool, 1)
|
||||
cluster.completed = make(chan bool, 1)
|
||||
lbCtx, lbCancel := context.WithCancel(ctx)
|
||||
var lbWg sync.WaitGroup
|
||||
stop, done, err := cluster.startServicesWorker()
|
||||
if err != nil {
|
||||
lbCancel()
|
||||
return err
|
||||
}
|
||||
type startedNetwork struct {
|
||||
network vip.Network
|
||||
routeAdded bool
|
||||
ipAdded bool
|
||||
bgpAdded bool
|
||||
}
|
||||
startedNetworks := make([]startedNetwork, 0, len(cluster.Network))
|
||||
servicesWorkerStarted := false
|
||||
defer func() {
|
||||
if !servicesWorkerStarted {
|
||||
lbCancel()
|
||||
lbWg.Wait()
|
||||
cleanupCtx := context.WithoutCancel(ctx)
|
||||
for index := len(startedNetworks) - 1; index >= 0; index-- {
|
||||
started := startedNetworks[index]
|
||||
if started.bgpAdded && bgp != nil {
|
||||
if err := bgp.DelHost(cleanupCtx, started.network.CIDR(), name); err != nil {
|
||||
log.Warn("failed to withdraw BGP host after startup failure", "address", started.network.CIDR(), "err", err)
|
||||
}
|
||||
}
|
||||
if started.routeAdded && cluster.routeMgr != nil {
|
||||
if err := cluster.routeMgr.Delete(name, started.network); err != nil {
|
||||
log.Warn("failed to delete route after startup failure", "address", started.network.CIDR(), "err", err)
|
||||
}
|
||||
}
|
||||
if started.ipAdded {
|
||||
if _, err := started.network.DeleteIP(); err != nil {
|
||||
log.Warn("failed to delete VIP after startup failure", "address", started.network.IP(), "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
cluster.finishServicesWorker(done)
|
||||
}
|
||||
}()
|
||||
|
||||
for i := range cluster.Network {
|
||||
network := cluster.Network[i]
|
||||
|
||||
err := network.DeleteIP()
|
||||
if err != nil {
|
||||
log.Warnf("Attempted to clean existing VIP => %v", err)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
network := cluster.Network[i]
|
||||
startedNetworks = append(startedNetworks, startedNetwork{network: network})
|
||||
started := &startedNetworks[len(startedNetworks)-1]
|
||||
|
||||
if network.IsDDNS() {
|
||||
ddnsReady := make(chan struct{})
|
||||
lbWg.Go(func() {
|
||||
// start the DDNS if requested
|
||||
log.Debug("(svcs) start DDNS", "name", network.DNSName())
|
||||
if err := cluster.StartDDNS(lbCtx, cluster.Network[i], c.DHCPBackoffAttempts, &lbWg); err != nil {
|
||||
log.Error("failed to start DDNS", "err", err)
|
||||
}
|
||||
|
||||
close(ddnsReady)
|
||||
<-lbCtx.Done()
|
||||
})
|
||||
<-ddnsReady
|
||||
}
|
||||
|
||||
log.Debug("current ip to process", "ip", network.IP(), "mask", c.VIPSubnet)
|
||||
if err := network.SetMask(c.VIPSubnet); err != nil {
|
||||
log.Error("failed to set mask", "subnet", c.VIPSubnet, "err", err)
|
||||
lbCancel()
|
||||
return utils.WrapPanicError(err, "failed to set mask for subnet %q", c.VIPSubnet)
|
||||
}
|
||||
existing, err := network.IsSet()
|
||||
if err != nil {
|
||||
lbCancel()
|
||||
return fmt.Errorf("check existing VIP %q: %w", network.IP(), err)
|
||||
}
|
||||
log.Debug("config flags", "enable_routing_table", c.EnableRoutingTable, "enable_leader_election", c.EnableLeaderElection, "enable_services_election", c.EnableServicesElection)
|
||||
|
||||
if c.EnableRoutingTable && (c.EnableLeaderElection || c.EnableServicesElection) {
|
||||
err = network.AddRoute()
|
||||
err = cluster.routeMgr.Add(name, network, false, false)
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
log.Warn(err.Error())
|
||||
} else {
|
||||
started.routeAdded = true
|
||||
log.Info("successful add Route")
|
||||
}
|
||||
} else if !c.EnableRoutingTable {
|
||||
err = network.AddIP()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
|
||||
if shouldAddServiceIP(c) {
|
||||
// Normal VIP addition, use skipDAD=false for normal DAD process
|
||||
// Note: When WireGuard is enabled, the VIP is added to the tunnel interface
|
||||
// instead of lo, so we skip adding it here.
|
||||
added, addErr := network.AddIP(false, false)
|
||||
started.ipAdded = existing == nil && added
|
||||
if addErr != nil {
|
||||
log.Warn(addErr.Error())
|
||||
} else {
|
||||
log.Info("successful add IP", "address", network.IP())
|
||||
}
|
||||
}
|
||||
|
||||
if c.EnableARP {
|
||||
// ctxArp, cancelArp = context.WithCancel(context.Background())
|
||||
|
||||
ipString := network.IP()
|
||||
var ndp *vip.NdpResponder
|
||||
if vip.IsIPv6(ipString) {
|
||||
ndp, err = vip.NewNDPResponder(network.Interface())
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create new NDP Responder")
|
||||
}
|
||||
}
|
||||
go func(ctx context.Context) {
|
||||
if ndp != nil {
|
||||
defer ndp.Close()
|
||||
}
|
||||
log.Debugf("(svcs) broadcasting ARP update for %s via %s, every %dms", ipString, network.Interface(), c.ArpBroadcastRate)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): // if cancel() execute
|
||||
log.Debugf("(svcs) ending ARP update for %s via %s, every %dms", ipString, network.Interface(), c.ArpBroadcastRate)
|
||||
return
|
||||
default:
|
||||
cluster.ensureIPAndSendGratuitous(network.Interface(), ndp)
|
||||
}
|
||||
if c.ArpBroadcastRate < 500 {
|
||||
log.Errorf("arp broadcast rate is [%d], this shouldn't be lower that 300ms (defaulting to 3000)", c.ArpBroadcastRate)
|
||||
c.ArpBroadcastRate = 3000
|
||||
}
|
||||
time.Sleep(time.Duration(c.ArpBroadcastRate) * time.Millisecond)
|
||||
}
|
||||
}(ctxArp)
|
||||
lbWg.Go(func() {
|
||||
cluster.layer2Update(lbCtx, network, c)
|
||||
})
|
||||
}
|
||||
|
||||
if c.EnableBGP && (c.EnableLeaderElection || c.EnableServicesElection) {
|
||||
// Lets advertise the VIP over BGP, the host needs to be passed using CIDR notation
|
||||
cidrVip := fmt.Sprintf("%s/%s", network.IP(), c.VIPCIDR)
|
||||
log.Debugf("(svcs) attempting to advertise the address [%s] over BGP", cidrVip)
|
||||
err = bgp.AddHost(cidrVip)
|
||||
log.Debug("(svcs) attempting to advertise over BGP", "address", network.CIDR())
|
||||
err = bgp.AddHost(lbCtx, network.CIDR(), name)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
log.Error(err.Error())
|
||||
} else {
|
||||
started.bgpAdded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-cluster.stop
|
||||
// Stop the Arp context if it is running
|
||||
cancelArp()
|
||||
wg.Go(func() {
|
||||
defer cluster.finishServicesWorker(done)
|
||||
for i := range cluster.Network {
|
||||
network := cluster.Network[i]
|
||||
|
||||
log.Info("[LOADBALANCER] Stopping load balancers")
|
||||
// start the dns updater if address is dns
|
||||
if network.IsDNS() {
|
||||
log.Info("(svcs) starting the DNS updater", "address", network.DNSName(), "ip", network.IP())
|
||||
ipUpdater := vip.NewIPUpdater(network)
|
||||
wg.Go(func() {
|
||||
ipUpdater.Run(lbCtx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-stop:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
// Stop the loadbalancer context if it is running
|
||||
lbCancel()
|
||||
|
||||
lbWg.Wait() // wait for all cluster ARP/NDP to be finished
|
||||
|
||||
log.Info("[LOADBALANCER] Stopping load balancers", "name", name)
|
||||
|
||||
if c.EnableRoutingTable {
|
||||
for i := range cluster.Network {
|
||||
log.Infof("[VIP] Deleting Route for Virtual IP [%s]", cluster.Network[i].IP())
|
||||
if err := cluster.Network[i].DeleteRoute(); err != nil {
|
||||
log.Warnf("%v", err)
|
||||
if err := cluster.routeMgr.Delete(name, cluster.Network[i]); err != nil {
|
||||
log.Warn(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
close(cluster.completed)
|
||||
return
|
||||
}
|
||||
for i := range cluster.Network {
|
||||
log.Infof("[VIP] Releasing the Virtual IP [%s]", cluster.Network[i].IP())
|
||||
if err := cluster.Network[i].DeleteIP(); err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
close(cluster.completed)
|
||||
}()
|
||||
cluster.cleanupServiceVIPs(c, done)
|
||||
})
|
||||
servicesWorkerStarted = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureIPAndSendGratuitous - adds IP to the interface if missing, and send
|
||||
// either a gratuitous ARP or gratuitous NDP. Re-adds the interface if it is IPv6
|
||||
// and in a dadfailed state.
|
||||
func (cluster *Cluster) ensureIPAndSendGratuitous(iface string, ndp *vip.NdpResponder) {
|
||||
for i := range cluster.Network {
|
||||
ipString := cluster.Network[i].IP()
|
||||
isIPv6 := vip.IsIPv6(ipString)
|
||||
// Check if IP is dadfailed
|
||||
if cluster.Network[i].IsDADFAIL() {
|
||||
log.Warnf("IP address is in dadfailed state, removing [%s] from interface [%s]", ipString, iface)
|
||||
err := cluster.Network[i].DeleteIP()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
}
|
||||
func shouldAddServiceIP(c *kubevip.Config) bool {
|
||||
return !c.EnableRoutingTable && (!c.EnableBGP || c.BGPAttachIPToInterface) && !c.EnableWireguard
|
||||
}
|
||||
|
||||
// Ensure the address exists on the interface before attempting to ARP
|
||||
set, err := cluster.Network[i].IsSet()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
if !set {
|
||||
log.Warnf("Re-applying the VIP configuration [%s] to the interface [%s]", ipString, iface)
|
||||
err = cluster.Network[i].AddIP()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if isIPv6 {
|
||||
// Gratuitous NDP, will broadcast new MAC <-> IPv6 address
|
||||
err := ndp.SendGratuitous(ipString)
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
// Layer2Update, handles the creation of the
|
||||
func (cluster *Cluster) layer2Update(ctx context.Context, network vip.Network, c *kubevip.Config) {
|
||||
var ndp *vip.NdpResponder
|
||||
var err error
|
||||
ipString := network.IP()
|
||||
if utils.IsIPv6(ipString) {
|
||||
if network.IPisLinkLocal() {
|
||||
log.Error("layer2 is link-local can't use NDP", "address", ipString)
|
||||
} else {
|
||||
// Gratuitous ARP, will broadcast to new MAC <-> IPv4 address
|
||||
err := vip.ARPSendGratuitous(ipString, iface)
|
||||
ndp, err = waitNDPResponder(ctx, network.Interface())
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
log.Error("failed to create new NDP Responder", "error", err)
|
||||
} else {
|
||||
if ndp != nil {
|
||||
defer ndp.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("layer 2 broadcaster starting", "IP", network.IP(), "device", network.Interface())
|
||||
log.Debug("layer 2 update", "ip", ipString, "interface", network.Interface(), "ms", c.ArpBroadcastRate)
|
||||
|
||||
arpInstance := arp.NewInstance(network, ndp)
|
||||
cluster.arpMgr.Insert(arpInstance)
|
||||
|
||||
<-ctx.Done() // if cancel() execute
|
||||
log.Debug("ending layer 2 update", "ip", ipString, "interface", network.Interface(), "ms", c.ArpBroadcastRate)
|
||||
cluster.arpMgr.RemoveOnLeadershipLoss(arpInstance)
|
||||
}
|
||||
|
||||
func waitNDPResponder(ctx context.Context, ifaceName string) (*vip.NdpResponder, error) {
|
||||
ndp, err := vip.NewNDPResponder(ifaceName)
|
||||
if err != nil && strings.Contains(err.Error(), "no such device") {
|
||||
log.Warn("unable to create NDP responder at first try", "interface", ifaceName, "err", err)
|
||||
ndpCreateCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ndpCreateCtx.Done():
|
||||
return nil, fmt.Errorf("failed to create NDP responder for interface %q: %w", ifaceName, ndpCreateCtx.Err())
|
||||
case <-ticker.C:
|
||||
ndp, err = vip.NewNDPResponder(ifaceName)
|
||||
if err != nil {
|
||||
log.Warn("unable to create NDP responder on retry", "interface", ifaceName, "err", err)
|
||||
} else {
|
||||
return ndp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("unable to create NDP responder for interface %q: %w", ifaceName, err)
|
||||
}
|
||||
return ndp, nil
|
||||
}
|
||||
|
||||
55
pkg/cluster/service_config_test.go
Normal file
55
pkg/cluster/service_config_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
)
|
||||
|
||||
func TestShouldAddServiceIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *kubevip.Config
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "BGP default does not attach IP",
|
||||
config: &kubevip.Config{EnableBGP: true},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "BGP opt-in attaches IP",
|
||||
config: &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
BGPAttachIPToInterface: true,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "routing table takes precedence",
|
||||
config: &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
BGPAttachIPToInterface: true,
|
||||
EnableRoutingTable: true,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "WireGuard takes precedence",
|
||||
config: &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
BGPAttachIPToInterface: true,
|
||||
EnableWireguard: true,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldAddServiceIP(tt.config); got != tt.want {
|
||||
t.Fatalf("shouldAddServiceIP() = %t, want %t", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
61
pkg/cluster/service_internal_test.go
Normal file
61
pkg/cluster/service_internal_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKubernetesAddrBackendEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
addr string
|
||||
port uint16
|
||||
wantAddr string
|
||||
wantPort uint16
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "explicit v4 loopback with port",
|
||||
addr: "https://127.0.0.1:6443",
|
||||
port: 9999,
|
||||
wantAddr: "127.0.0.1",
|
||||
wantPort: 6443,
|
||||
},
|
||||
{
|
||||
name: "hostname without port falls back to config port",
|
||||
addr: "https://localhost",
|
||||
port: 6443,
|
||||
wantAddr: "localhost",
|
||||
wantPort: 6443,
|
||||
},
|
||||
{
|
||||
name: "empty override",
|
||||
addr: "",
|
||||
port: 6443,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "garbage override",
|
||||
addr: "://not-a-url",
|
||||
port: 6443,
|
||||
wantNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := kubernetesAddrBackendEntry(tc.addr, tc.port)
|
||||
if tc.wantNil {
|
||||
if entry != nil {
|
||||
t.Fatalf("expected nil entry, got %+v", entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected an entry, got nil")
|
||||
}
|
||||
if entry.Addr != tc.wantAddr || entry.Port != tc.wantPort {
|
||||
t.Fatalf("got %+v, want addr %q port %d", entry, tc.wantAddr, tc.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
548
pkg/cluster/service_test.go
Normal file
548
pkg/cluster/service_test.go
Normal file
@@ -0,0 +1,548 @@
|
||||
package cluster_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/cluster"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
"github.com/vishvananda/netlink"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
const testCIDR = "10.0.0.34/32"
|
||||
|
||||
func TestBGPHealthCheckLoop_AnnouncesOnHealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusOK)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
startVipService(t, newBGPConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be announced")
|
||||
}
|
||||
|
||||
func TestServicesWorkerStopAndWaitDrainsBeforeRestart(t *testing.T) {
|
||||
config := &kubevip.Config{}
|
||||
serviceCluster, err := cluster.InitCluster(config, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCluster() error = %v", err)
|
||||
}
|
||||
var workers sync.WaitGroup
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
|
||||
t.Fatalf("first StartLoadBalancerService() error = %v", err)
|
||||
}
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err == nil {
|
||||
t.Fatal("second StartLoadBalancerService() started while the first workers were active")
|
||||
}
|
||||
|
||||
serviceCluster.StopAndWait()
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
|
||||
t.Fatalf("StartLoadBalancerService() after StopAndWait error = %v", err)
|
||||
}
|
||||
serviceCluster.StopAndWait()
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func TestServicesWorkerStopAndWaitPreservingDrainsBeforeRestart(t *testing.T) {
|
||||
config := &kubevip.Config{}
|
||||
serviceCluster, err := cluster.InitCluster(config, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCluster() error = %v", err)
|
||||
}
|
||||
var workers sync.WaitGroup
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
|
||||
t.Fatalf("StartLoadBalancerService() error = %v", err)
|
||||
}
|
||||
serviceCluster.StopAndWait()
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
|
||||
t.Fatalf("StartLoadBalancerService() after preserving stop error = %v", err)
|
||||
}
|
||||
serviceCluster.StopAndWait()
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func TestStartLoadBalancerServiceRollsBackEarlierNetwork(t *testing.T) {
|
||||
first := &mockNetwork{ip: "192.0.2.10", cidr: "192.0.2.10/32"}
|
||||
second := &mockNetwork{ip: "192.0.2.11", cidr: "192.0.2.11/32", setMaskErr: errors.New("set mask")}
|
||||
serviceCluster, err := cluster.InitCluster(&kubevip.Config{}, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCluster() error = %v", err)
|
||||
}
|
||||
serviceCluster.Network = []vip.Network{first, second}
|
||||
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), &kubevip.Config{VIPSubnet: "32"}, nil, "service", &sync.WaitGroup{}); err == nil {
|
||||
t.Fatal("StartLoadBalancerService() error = nil, want second-network failure")
|
||||
}
|
||||
first.mu.Lock()
|
||||
addCalls, deleteCalls, present := first.addIPCalls, first.deleteIPCalls, first.present
|
||||
first.mu.Unlock()
|
||||
if addCalls != 1 || deleteCalls != 1 || present {
|
||||
t.Fatalf("first network rollback = add %d, delete %d, present %t; want 1, 1, false", addCalls, deleteCalls, present)
|
||||
}
|
||||
serviceCluster.StopAndWait()
|
||||
}
|
||||
|
||||
func TestStartLoadBalancerServiceRollbackPreservesExistingVIP(t *testing.T) {
|
||||
first := &mockNetwork{ip: "192.0.2.10", cidr: "192.0.2.10/32", present: true}
|
||||
second := &mockNetwork{ip: "192.0.2.11", cidr: "192.0.2.11/32", setMaskErr: errors.New("set mask")}
|
||||
serviceCluster, err := cluster.InitCluster(&kubevip.Config{}, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCluster() error = %v", err)
|
||||
}
|
||||
serviceCluster.Network = []vip.Network{first, second}
|
||||
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), &kubevip.Config{VIPSubnet: "32"}, nil, "service", &sync.WaitGroup{}); err == nil {
|
||||
t.Fatal("StartLoadBalancerService() error = nil, want second-network failure")
|
||||
}
|
||||
first.mu.Lock()
|
||||
addCalls, deleteCalls, present := first.addIPCalls, first.deleteIPCalls, first.present
|
||||
first.mu.Unlock()
|
||||
if addCalls != 1 || deleteCalls != 0 || !present {
|
||||
t.Fatalf("existing VIP rollback = add %d, delete %d, present %t; want 1, 0, true", addCalls, deleteCalls, present)
|
||||
}
|
||||
serviceCluster.StopAndWait()
|
||||
}
|
||||
|
||||
func TestStartLoadBalancerServiceCancelledContextDoesNotConfigureVIP(t *testing.T) {
|
||||
network := &mockNetwork{ip: "192.0.2.10", cidr: "192.0.2.10/32"}
|
||||
serviceCluster, err := cluster.InitCluster(&kubevip.Config{}, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCluster() error = %v", err)
|
||||
}
|
||||
serviceCluster.Network = []vip.Network{network}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err = serviceCluster.StartLoadBalancerService(ctx, &kubevip.Config{VIPSubnet: "32"}, nil, "service", &sync.WaitGroup{})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("StartLoadBalancerService() error = %v, want context.Canceled", err)
|
||||
}
|
||||
network.mu.Lock()
|
||||
addCalls := network.addIPCalls
|
||||
network.mu.Unlock()
|
||||
if addCalls != 0 {
|
||||
t.Fatalf("AddIP calls = %d, want 0 after context cancellation", addCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBGPHealthCheckLoop_NoAnnouncementUntilHealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusInternalServerError)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
startVipService(t, newBGPConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
|
||||
expectConsistently(t, func() bool { return !bgpManager.isAnnounced() },
|
||||
2*time.Second, "route should not be announced while unhealthy")
|
||||
|
||||
healthcheck.setStatus(http.StatusOK)
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be announced after recovery")
|
||||
}
|
||||
|
||||
func TestBGPHealthCheckLoop_WithdrawsAfterThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusOK)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
cfg := newBGPConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg.ControlPlaneHealthCheck.FailureThreshold = 3
|
||||
startVipService(t, cfg, bgpManager)
|
||||
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be announced")
|
||||
|
||||
healthcheck.setStatus(http.StatusServiceUnavailable)
|
||||
|
||||
expectConsistently(t, func() bool { return bgpManager.isAnnounced() },
|
||||
1500*time.Millisecond, "route should stay announced before threshold is reached")
|
||||
|
||||
expectEventually(t, func() bool { return !bgpManager.isAnnounced() },
|
||||
"route should be withdrawn after threshold")
|
||||
}
|
||||
|
||||
func TestBGPHealthCheckLoop_ReAnnouncesOnRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusOK)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
cfg := newBGPConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg.ControlPlaneHealthCheck.FailureThreshold = 1
|
||||
startVipService(t, cfg, bgpManager)
|
||||
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be announced")
|
||||
|
||||
healthcheck.setStatus(http.StatusServiceUnavailable)
|
||||
expectEventually(t, func() bool { return !bgpManager.isAnnounced() },
|
||||
"route should be withdrawn")
|
||||
|
||||
healthcheck.setStatus(http.StatusOK)
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be re-announced")
|
||||
}
|
||||
|
||||
func TestBGPHealthCheckLoop_StopsOnContextCancel(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusOK)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
cancelContext, vipServiceDone := startVipService(t, newBGPConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be announced")
|
||||
|
||||
cancelContext()
|
||||
|
||||
select {
|
||||
case <-vipServiceDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("vipService did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBGPHealthCheckLoop_RetriesAddHostOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusOK)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
bgpManager.setAddErr(errTestAddHost)
|
||||
startVipService(t, newBGPConfig(healthcheck.server.URL, healthcheck.caPath), bgpManager)
|
||||
|
||||
expectConsistently(t, func() bool { return !bgpManager.isAnnounced() },
|
||||
2*time.Second, "route should not be announced while AddHost errors")
|
||||
|
||||
bgpManager.setAddErr(nil)
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be announced after clearing AddHost error")
|
||||
}
|
||||
|
||||
func TestBGPHealthCheckLoop_RetriesDelHostOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusOK)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
bgpManager := newMockBGPRouteManager()
|
||||
cfg := newBGPConfig(healthcheck.server.URL, healthcheck.caPath)
|
||||
cfg.ControlPlaneHealthCheck.FailureThreshold = 1
|
||||
startVipService(t, cfg, bgpManager)
|
||||
|
||||
expectEventually(t, func() bool { return bgpManager.isAnnounced() },
|
||||
"route should be announced")
|
||||
|
||||
bgpManager.setDelErr(errTestDelHost)
|
||||
healthcheck.setStatus(http.StatusServiceUnavailable)
|
||||
|
||||
expectConsistently(t, func() bool { return bgpManager.isAnnounced() },
|
||||
1500*time.Millisecond, "route should stay announced while DelHost errors")
|
||||
|
||||
bgpManager.setDelErr(nil)
|
||||
expectEventually(t, func() bool { return !bgpManager.isAnnounced() },
|
||||
"route should be withdrawn after clearing DelHost error")
|
||||
}
|
||||
|
||||
func TestRoutingTableHealthCheck_AddsVIPWhenHealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusOK)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
network := &mockNetwork{ip: "10.0.0.1", cidr: testCIDR}
|
||||
startRoutingTableVipService(t, newRoutingTableConfig(healthcheck.server.URL, healthcheck.caPath), network)
|
||||
|
||||
expectEventually(t, network.isPresent,
|
||||
"VIP should be added while health check is healthy")
|
||||
}
|
||||
|
||||
func TestRoutingTableHealthCheck_RemovesVIPWhenUnhealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
healthcheck := newTestHealthServer(t, http.StatusOK)
|
||||
t.Cleanup(healthcheck.server.Close)
|
||||
|
||||
network := &mockNetwork{ip: "10.0.0.1", cidr: testCIDR}
|
||||
startRoutingTableVipService(t, newRoutingTableConfig(healthcheck.server.URL, healthcheck.caPath), network)
|
||||
|
||||
expectEventually(t, network.isPresent,
|
||||
"VIP should be added while health check is healthy")
|
||||
|
||||
healthcheck.setStatus(http.StatusServiceUnavailable)
|
||||
expectEventually(t, func() bool { return !network.isPresent() },
|
||||
"VIP should be removed once health check becomes unhealthy")
|
||||
}
|
||||
|
||||
var (
|
||||
errTestAddHost = &testError{msg: "mock AddHost error"}
|
||||
errTestDelHost = &testError{msg: "mock DelHost error"}
|
||||
)
|
||||
|
||||
type testError struct{ msg string }
|
||||
|
||||
func (e *testError) Error() string { return e.msg }
|
||||
|
||||
// startVipService launches vipService in a goroutine with a mock network and
|
||||
// registers a cleanup to cancel the context and wait for it to finish.
|
||||
// Uses InitCluster so the real code parses certs for the BGP health check client.
|
||||
func startVipService(t *testing.T, cfg *kubevip.Config, bgpServer bgp.BGPManager) (context.CancelFunc, <-chan struct{}) {
|
||||
t.Helper()
|
||||
c, err := cluster.InitCluster(cfg, true, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCluster: %v", err)
|
||||
}
|
||||
c.Network = []vip.Network{&mockNetwork{ip: "10.0.0.1", cidr: testCIDR}}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
_ = c.StartVipService(ctx, cfg, nil, bgpServer, func() {})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
<-done
|
||||
})
|
||||
|
||||
return cancel, done
|
||||
}
|
||||
|
||||
// startRoutingTableVipService launches vipService in routing-table mode with a
|
||||
// mock network and a real route.Manager (which only drives the mock network's
|
||||
// route methods, so no netlink calls happen). Registers cleanup to stop it.
|
||||
func startRoutingTableVipService(t *testing.T, cfg *kubevip.Config, network *mockNetwork) {
|
||||
t.Helper()
|
||||
|
||||
c, err := cluster.InitCluster(cfg, true, nil, nil, route.NewManager(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCluster: %v", err)
|
||||
}
|
||||
c.Network = []vip.Network{network}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
_ = c.StartVipService(ctx, cfg, nil, nil, func() {})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
<-done
|
||||
})
|
||||
}
|
||||
|
||||
func newRoutingTableConfig(url, caPath string) *kubevip.Config {
|
||||
cfg := newBGPConfig(url, caPath)
|
||||
cfg.EnableBGP = false
|
||||
cfg.EnableRoutingTable = true
|
||||
cfg.BackendHealthCheckInterval = 1
|
||||
return cfg
|
||||
}
|
||||
|
||||
func newBGPConfig(url, caPath string) *kubevip.Config {
|
||||
return &kubevip.Config{
|
||||
EnableBGP: true,
|
||||
ControlPlaneHealthCheck: kubevip.HealthCheck{
|
||||
Address: url,
|
||||
CAPath: caPath,
|
||||
PeriodSeconds: 1,
|
||||
TimeoutSeconds: 2,
|
||||
FailureThreshold: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mockBGPRouteManager tracks announced addresses as a set.
|
||||
// AddHost adds, DelHost removes. Errors prevent state changes.
|
||||
type mockBGPRouteManager struct {
|
||||
mu sync.Mutex
|
||||
announced map[string]bool
|
||||
addErr error
|
||||
delErr error
|
||||
}
|
||||
|
||||
func newMockBGPRouteManager() *mockBGPRouteManager {
|
||||
return &mockBGPRouteManager{announced: make(map[string]bool)}
|
||||
}
|
||||
|
||||
func (m *mockBGPRouteManager) AddHost(_ context.Context, addr string, _ string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.addErr != nil {
|
||||
return m.addErr
|
||||
}
|
||||
m.announced[addr] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockBGPRouteManager) DelHost(_ context.Context, addr string, _ string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.delErr != nil {
|
||||
return m.delErr
|
||||
}
|
||||
delete(m.announced, addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockBGPRouteManager) isAnnounced() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.announced[testCIDR]
|
||||
}
|
||||
|
||||
func (m *mockBGPRouteManager) setAddErr(err error) {
|
||||
m.mu.Lock()
|
||||
m.addErr = err
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *mockBGPRouteManager) setDelErr(err error) {
|
||||
m.mu.Lock()
|
||||
m.delErr = err
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// mockNetwork implements vip.Network with no-op operations.
|
||||
type mockNetwork struct {
|
||||
ip string
|
||||
cidr string
|
||||
|
||||
mu sync.Mutex
|
||||
present bool
|
||||
setMaskErr error
|
||||
addIPCalls int
|
||||
deleteIPCalls int
|
||||
}
|
||||
|
||||
func (m *mockNetwork) AddIP(bool, bool, ...int) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.addIPCalls++
|
||||
m.present = true
|
||||
return true, nil
|
||||
}
|
||||
func (m *mockNetwork) DeleteIP() (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.deleteIPCalls++
|
||||
deleted := m.present
|
||||
m.present = false
|
||||
return deleted, nil
|
||||
}
|
||||
func (m *mockNetwork) isPresent() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.present
|
||||
}
|
||||
func (m *mockNetwork) AddRoute(bool) (bool, error) { return false, nil }
|
||||
func (m *mockNetwork) ReplaceRoute() error { return nil }
|
||||
func (m *mockNetwork) DeleteRoute() error { return nil }
|
||||
func (m *mockNetwork) UpdateRoutes() (bool, error) { return false, nil }
|
||||
func (m *mockNetwork) IsSet() (*netlink.Addr, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.present {
|
||||
return &netlink.Addr{}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockNetwork) IP() string { return m.ip }
|
||||
func (m *mockNetwork) CIDR() string { return m.cidr }
|
||||
func (m *mockNetwork) IPisLinkLocal() bool { return false }
|
||||
func (m *mockNetwork) PrepareRoute() *netlink.Route { return nil }
|
||||
func (m *mockNetwork) RouteHash() string { return "" }
|
||||
func (m *mockNetwork) SetIP(string) error { return nil }
|
||||
func (m *mockNetwork) SetServicePorts(*corev1.Service) {}
|
||||
func (m *mockNetwork) Interface() string { return "eth0" }
|
||||
func (m *mockNetwork) IsDADFAIL() bool { return false }
|
||||
func (m *mockNetwork) IsDNS() bool { return false }
|
||||
func (m *mockNetwork) IsDDNS() bool { return false }
|
||||
func (m *mockNetwork) DDNSHostName() string { return "" }
|
||||
func (m *mockNetwork) DNSName() string { return "" }
|
||||
func (m *mockNetwork) SetMask(string) error { return m.setMaskErr }
|
||||
func (m *mockNetwork) SetHasEndpoints(bool) {}
|
||||
func (m *mockNetwork) HasEndpoints() bool { return false }
|
||||
func (m *mockNetwork) ARPName() string { return "" }
|
||||
func (m *mockNetwork) GetPossibleSubnets() string { return "" }
|
||||
func (m *mockNetwork) DHCPFamily() string { return "" }
|
||||
func (m *mockNetwork) IPVSMark() uint32 { return 0 }
|
||||
|
||||
// testHealthServer wraps an HTTPS httptest.Server with an atomic status code.
|
||||
// caPath is the path to the server's CA cert for client verification.
|
||||
type testHealthServer struct {
|
||||
server *httptest.Server
|
||||
statusCode atomic.Int64
|
||||
caPath string
|
||||
}
|
||||
|
||||
func newTestHealthServer(t *testing.T, status int) *testHealthServer {
|
||||
t.Helper()
|
||||
healthcheck := &testHealthServer{}
|
||||
healthcheck.statusCode.Store(int64(status))
|
||||
healthcheck.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(int(healthcheck.statusCode.Load()))
|
||||
}))
|
||||
|
||||
cert := healthcheck.server.Certificate()
|
||||
if cert == nil {
|
||||
t.Fatal("TLS server has no certificate")
|
||||
}
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
|
||||
caFile := filepath.Join(t.TempDir(), "ca.crt")
|
||||
if err := os.WriteFile(caFile, caPEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
healthcheck.caPath = caFile
|
||||
return healthcheck
|
||||
}
|
||||
|
||||
func (ths *testHealthServer) setStatus(code int) {
|
||||
ths.statusCode.Store(int64(code))
|
||||
}
|
||||
|
||||
// expectConsistently continuously checks that condition remains true for the given duration.
|
||||
// Fails immediately if the condition becomes false at any point.
|
||||
func expectConsistently(t *testing.T, condition func() bool, duration time.Duration, msg string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(duration)
|
||||
for time.Now().Before(deadline) {
|
||||
if !condition() {
|
||||
t.Fatalf("condition violated: %s", msg)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// expectEventually polls condition until it returns true or 5s timeout is reached.
|
||||
func expectEventually(t *testing.T, condition func() bool, msg string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if condition() {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timeout: %s", msg)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/packethost/packngo"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
)
|
||||
|
||||
// StartSingleNode will start a single node cluster
|
||||
func (cluster *Cluster) StartSingleNode(c *kubevip.Config, disableVIP bool) error {
|
||||
// Start kube-vip as a single node server
|
||||
|
||||
// TODO - Split all this code out as a separate function
|
||||
log.Infoln("Starting kube-vip as a single node cluster")
|
||||
|
||||
log.Info("This node is assuming leadership of the cluster")
|
||||
|
||||
cluster.stop = make(chan bool, 1)
|
||||
cluster.completed = make(chan bool, 1)
|
||||
|
||||
for i := range cluster.Network {
|
||||
if !disableVIP {
|
||||
err := cluster.Network[i].DeleteIP()
|
||||
if err != nil {
|
||||
log.Warnf("Attempted to clean existing VIP => %v", err)
|
||||
}
|
||||
|
||||
err = cluster.Network[i].AddIP()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if c.EnableARP {
|
||||
// Gratuitous ARP, will broadcast to new MAC <-> IP
|
||||
err := vip.ARPSendGratuitous(cluster.Network[i].IP(), c.Interface)
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-cluster.stop
|
||||
|
||||
if !disableVIP {
|
||||
for i := range cluster.Network {
|
||||
log.Infof("[VIP] Releasing the Virtual IP [%s]", cluster.Network[i].IP())
|
||||
err := cluster.Network[i].DeleteIP()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
close(cluster.completed)
|
||||
}()
|
||||
log.Infoln("Started Load Balancer and Virtual IP")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cluster *Cluster) StartVipService(c *kubevip.Config, sm *Manager, bgp *bgp.Server, packetClient *packngo.Client) error {
|
||||
// use a Go context so we can tell the arp loop code when we
|
||||
// want to step down
|
||||
ctxArp, cancelArp := context.WithCancel(context.Background())
|
||||
defer cancelArp()
|
||||
|
||||
// use a Go context so we can tell the dns loop code when we
|
||||
// want to step down
|
||||
ctxDNS, cancelDNS := context.WithCancel(context.Background())
|
||||
defer cancelDNS()
|
||||
|
||||
return cluster.vipService(ctxArp, ctxDNS, c, sm, bgp, packetClient)
|
||||
}
|
||||
277
pkg/debouncer/debouncer.go
Normal file
277
pkg/debouncer/debouncer.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package debouncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
log "log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTime = "0s"
|
||||
minimalTime = time.Millisecond * 200
|
||||
)
|
||||
|
||||
type debouncer struct {
|
||||
input <-chan watch.Event
|
||||
output chan watch.Event
|
||||
stopChan chan any
|
||||
stopOnce sync.Once
|
||||
// events holds event per namespace
|
||||
namespaces sync.Map
|
||||
debounceTime time.Duration
|
||||
}
|
||||
|
||||
type ns struct {
|
||||
sync.Map
|
||||
cnt atomic.Int64
|
||||
}
|
||||
|
||||
func (n *ns) get(name string) (*object, bool) {
|
||||
value, exists := n.Load(name)
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
i, ok := value.(*object)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
func (n *ns) add(name string, output chan<- watch.Event) *object {
|
||||
i := newObject(output)
|
||||
n.Store(name, i)
|
||||
n.cnt.Add(1)
|
||||
return i
|
||||
}
|
||||
|
||||
func (n *ns) del(name string, object *object) {
|
||||
if n.CompareAndDelete(name, object) {
|
||||
n.cnt.Add(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func New(input <-chan watch.Event, debounceTime string) (*debouncer, error) {
|
||||
dt, err := time.ParseDuration(debounceTime)
|
||||
if err != nil {
|
||||
// debouncer was configured with invalid unparsable value, return error
|
||||
return nil, fmt.Errorf("failed to parse debounce time configuration: %w", err)
|
||||
}
|
||||
if dt < minimalTime {
|
||||
if dt > 0 {
|
||||
log.Warn("configured debounce time is less than the minimal threshold of 200ms, debouncer will remain disabled", "config value", dt.String())
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return &debouncer{
|
||||
input: input,
|
||||
output: make(chan watch.Event),
|
||||
stopChan: make(chan any),
|
||||
debounceTime: dt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *debouncer) Start(ctx context.Context) error {
|
||||
wg := sync.WaitGroup{}
|
||||
debouncerCtx, cancel := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
cancel()
|
||||
wg.Wait()
|
||||
close(d.output)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-debouncerCtx.Done():
|
||||
// return if debouncer context was cancelled
|
||||
return nil
|
||||
case <-d.stopChan:
|
||||
// return if Stop() was called
|
||||
return nil
|
||||
case tmp := <-d.input:
|
||||
// event has no type, probably error
|
||||
if tmp.Type == "" {
|
||||
return fmt.Errorf("get undefined object (input channel probably closed)")
|
||||
}
|
||||
|
||||
var namespace, name string
|
||||
|
||||
// type switch event object
|
||||
switch v := tmp.Object.(type) {
|
||||
case *discoveryv1.EndpointSlice:
|
||||
namespace = v.Namespace
|
||||
name = v.Name
|
||||
case *v1.Endpoints: //nolint:staticcheck
|
||||
namespace = v.Namespace
|
||||
name = v.Name
|
||||
case *v1.Service:
|
||||
namespace = v.Namespace
|
||||
name = v.Name
|
||||
default:
|
||||
return fmt.Errorf("objects of type %T are not supported", v)
|
||||
}
|
||||
|
||||
processEvent:
|
||||
for {
|
||||
eventNs, exists := d.getNs(namespace)
|
||||
if !exists {
|
||||
// if not, create new map for the namespace
|
||||
eventNs = d.addNs(namespace)
|
||||
}
|
||||
|
||||
// check if the object was previously reconciled
|
||||
eventObject, exists := eventNs.get(name)
|
||||
|
||||
// if not and the event is not of type 'Deleted', create new object
|
||||
if !exists && tmp.Type != watch.Deleted {
|
||||
eventObject = eventNs.add(name, d.output)
|
||||
|
||||
workerObject := eventObject
|
||||
workerNs := eventNs
|
||||
workerName := name
|
||||
workerNamespace := namespace
|
||||
workerObject.onStop = func() {
|
||||
// Remove the object before its worker can become receiver-less.
|
||||
workerNs.del(workerName, workerObject)
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
// start deboucing events for this object
|
||||
workerObject.start(debouncerCtx, d.debounceTime)
|
||||
// if debouncer for the object ended - e.g. object was deleted - clean the map of objects
|
||||
workerNs.del(workerName, workerObject)
|
||||
// if namespace is empty, delete the namespace map
|
||||
if workerNs.cnt.Load() == 0 {
|
||||
d.delNs(workerNamespace, workerNs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if eventObject == nil {
|
||||
break processEvent
|
||||
}
|
||||
|
||||
// pass the watch event to the debouncer object
|
||||
select {
|
||||
case eventObject.input <- tmp:
|
||||
break processEvent
|
||||
case <-eventObject.stopChan:
|
||||
// The object stopped after the map lookup. Retry the event
|
||||
// against the newly-created object instead of dropping it.
|
||||
continue processEvent
|
||||
case <-debouncerCtx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *debouncer) Stop() {
|
||||
d.stopOnce.Do(func() {
|
||||
close(d.stopChan)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *debouncer) Output() chan watch.Event {
|
||||
return d.output
|
||||
}
|
||||
|
||||
func (d *debouncer) getNs(namespace string) (*ns, bool) {
|
||||
value, exists := d.namespaces.Load(namespace)
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
n, ok := value.(*ns)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func (d *debouncer) addNs(namespace string) *ns {
|
||||
n := ns{}
|
||||
d.namespaces.Store(namespace, &n)
|
||||
return &n
|
||||
}
|
||||
|
||||
func (d *debouncer) delNs(namespace string, ns *ns) {
|
||||
d.namespaces.CompareAndDelete(namespace, ns)
|
||||
}
|
||||
|
||||
type object struct {
|
||||
input chan watch.Event
|
||||
output chan<- watch.Event
|
||||
stopChan chan any
|
||||
stopOnce sync.Once
|
||||
onStop func()
|
||||
}
|
||||
|
||||
func newObject(output chan<- watch.Event) *object {
|
||||
return &object{
|
||||
input: make(chan watch.Event),
|
||||
output: output,
|
||||
stopChan: make(chan any),
|
||||
}
|
||||
}
|
||||
|
||||
func (o *object) start(ctx context.Context, debounceTime time.Duration) {
|
||||
t := time.NewTicker(debounceTime)
|
||||
|
||||
var last *watch.Event
|
||||
|
||||
defer func() {
|
||||
if last != nil {
|
||||
o.output <- *last
|
||||
last = nil
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// if context is done, return
|
||||
return
|
||||
case <-o.stopChan:
|
||||
// return if Stop() was called
|
||||
return
|
||||
case tmp := <-o.input:
|
||||
// if last event is known, but an event of another type arrived,
|
||||
// send out the previous event
|
||||
if last != nil && last.Type != tmp.Type {
|
||||
o.output <- *last
|
||||
}
|
||||
// save current event as the last event
|
||||
last = &tmp
|
||||
// reset the ticker to wait for more events
|
||||
t.Reset(debounceTime)
|
||||
case <-t.C:
|
||||
if last != nil {
|
||||
// on tick, if we have an event, send it out
|
||||
o.output <- *last
|
||||
// if the event is of type 'Deleted', stop the debouncer for the object
|
||||
if last.Type == watch.Deleted {
|
||||
o.stop()
|
||||
}
|
||||
// reset last known event, so it won't be send out twice
|
||||
last = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *object) stop() {
|
||||
o.stopOnce.Do(func() {
|
||||
if o.onStop != nil {
|
||||
o.onStop()
|
||||
}
|
||||
close(o.stopChan)
|
||||
})
|
||||
}
|
||||
162
pkg/debouncer/debouncer_deadlock_test.go
Normal file
162
pkg/debouncer/debouncer_deadlock_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package debouncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
)
|
||||
|
||||
func TestStartReturnsWhenCancellationInterruptsObjectForwarding(t *testing.T) {
|
||||
input := make(chan watch.Event)
|
||||
d := &debouncer{
|
||||
input: input,
|
||||
output: make(chan watch.Event),
|
||||
stopChan: make(chan any),
|
||||
debounceTime: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
// Leave the object without a receiver. This is the state reached when its
|
||||
// worker exits on context cancellation just before Start forwards an event.
|
||||
ns := d.addNs("default")
|
||||
ns.add("example", d.output)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- d.Start(ctx) }()
|
||||
|
||||
event := watch.Event{
|
||||
Type: watch.Modified,
|
||||
Object: &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "example", Namespace: "default",
|
||||
}},
|
||||
}
|
||||
sent := make(chan struct{})
|
||||
go func() {
|
||||
input <- event
|
||||
close(sent)
|
||||
}()
|
||||
select {
|
||||
case <-sent:
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
cancel()
|
||||
t.Fatal("debouncer did not receive the test event")
|
||||
}
|
||||
cancel()
|
||||
|
||||
// Nothing ever receives from the object, so Start can only return by
|
||||
// abandoning the blocked forward when the context is cancelled. Receiving
|
||||
// here instead would make the forward succeed and the assertion racy.
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("debouncer remained blocked forwarding an event after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRecreatesObjectAfterDeletionWithoutCancellation(t *testing.T) {
|
||||
previousProcs := runtime.GOMAXPROCS(1)
|
||||
t.Cleanup(func() { runtime.GOMAXPROCS(previousProcs) })
|
||||
|
||||
input := make(chan watch.Event)
|
||||
d, err := New(input, "200ms")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create debouncer: %s", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- d.Start(ctx) }()
|
||||
|
||||
service := func(eventType watch.EventType, resourceVersion string) watch.Event {
|
||||
return watch.Event{
|
||||
Type: eventType,
|
||||
Object: &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "example",
|
||||
Namespace: "default",
|
||||
ResourceVersion: resourceVersion,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
send := func(event watch.Event) {
|
||||
t.Helper()
|
||||
select {
|
||||
case input <- event:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("debouncer did not receive the test event")
|
||||
}
|
||||
}
|
||||
|
||||
send(service(watch.Added, "initial"))
|
||||
select {
|
||||
case event := <-d.output:
|
||||
if event.Type != watch.Added {
|
||||
t.Fatalf("expected initial Added event, got %s", event.Type)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("debouncer did not emit the initial event")
|
||||
}
|
||||
|
||||
eventNs, exists := d.getNs("default")
|
||||
if !exists {
|
||||
t.Fatal("debouncer did not create the namespace map")
|
||||
}
|
||||
oldObject, exists := eventNs.get("example")
|
||||
if !exists {
|
||||
t.Fatal("debouncer did not create the object")
|
||||
}
|
||||
|
||||
send(service(watch.Deleted, "deleted"))
|
||||
select {
|
||||
case event := <-d.output:
|
||||
if event.Type != watch.Deleted {
|
||||
t.Fatalf("expected Deleted event, got %s", event.Type)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("debouncer did not emit the Deleted event")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-oldObject.stopChan:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("object did not self-terminate")
|
||||
}
|
||||
if _, exists := eventNs.get("example"); exists {
|
||||
t.Fatal("self-terminated object remained in the namespace map")
|
||||
}
|
||||
|
||||
send(service(watch.Modified, "fresh"))
|
||||
select {
|
||||
case event := <-d.output:
|
||||
if event.Type != watch.Modified {
|
||||
t.Fatalf("expected fresh Modified event, got %s", event.Type)
|
||||
}
|
||||
service, ok := event.Object.(*v1.Service)
|
||||
if !ok {
|
||||
t.Fatalf("expected a Service event, got %T", event.Object)
|
||||
}
|
||||
if service.ResourceVersion != "fresh" {
|
||||
t.Fatalf("expected the fresh event, got resource version %q", service.ResourceVersion)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("debouncer did not process the fresh event after object deletion")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("debouncer returned an error: %s", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("debouncer did not stop")
|
||||
}
|
||||
}
|
||||
394
pkg/debouncer/debouncer_test.go
Normal file
394
pkg/debouncer/debouncer_test.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package debouncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
)
|
||||
|
||||
func TestTimeSetting(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
configured string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "configured proper value 10s",
|
||||
configured: "10s",
|
||||
expected: "10s",
|
||||
},
|
||||
{
|
||||
name: "configured value less than 200ms",
|
||||
configured: "0s",
|
||||
expected: "disabled",
|
||||
},
|
||||
{
|
||||
name: "configured proper value 1s",
|
||||
configured: "1s",
|
||||
expected: "1s",
|
||||
},
|
||||
{
|
||||
name: "configured proper value 1500ms",
|
||||
configured: "1500ms",
|
||||
expected: "1.5s",
|
||||
},
|
||||
{
|
||||
name: "configured to value greater than 0s but lower than 200ms",
|
||||
configured: "150ms",
|
||||
expected: "disabled",
|
||||
},
|
||||
{
|
||||
name: "configured invalid value that cannot be parsed",
|
||||
configured: "invalid",
|
||||
expected: "error",
|
||||
},
|
||||
{
|
||||
name: "configured negative value",
|
||||
configured: "-1s",
|
||||
expected: "disabled",
|
||||
},
|
||||
}
|
||||
|
||||
input := make(chan watch.Event)
|
||||
defer close(input)
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d, err := New(input, tc.configured)
|
||||
|
||||
switch tc.expected {
|
||||
case "disabled":
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create debouncer with debounce time %q", tc.configured)
|
||||
}
|
||||
if d != nil {
|
||||
t.Fatalf("debouncer was created but should be disabled for value %q", tc.configured)
|
||||
}
|
||||
case "error":
|
||||
if err == nil {
|
||||
t.Fatalf("debouncer was created but should error for value %q", tc.configured)
|
||||
}
|
||||
default:
|
||||
if d == nil {
|
||||
t.Fatalf("debouncer was not created for value %q", tc.configured)
|
||||
}
|
||||
|
||||
if d.debounceTime.String() != tc.expected {
|
||||
t.Fatalf("invalid debounce time %q was configured instead of expected %q", d.debounceTime.String(), tc.expected)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartStop(t *testing.T) {
|
||||
t.Run("Run and stop the debouncer without issues", func(t *testing.T) {
|
||||
input := make(chan watch.Event)
|
||||
defer close(input)
|
||||
|
||||
expected := "200ms"
|
||||
|
||||
d, err := New(input, expected)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create debouncer with debounce time %q", expected)
|
||||
}
|
||||
|
||||
if d.debounceTime.String() != expected {
|
||||
t.Fatalf("invalid debounce time %q was configured instead of expected %q", d.debounceTime.String(), expected)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
wg.Go(func() {
|
||||
if err := d.Start(ctx); err != nil {
|
||||
t.Fatalf("debouncer error: %s", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
cancel()
|
||||
|
||||
timedOut := waitTimeout(&wg, time.Second*3)
|
||||
|
||||
if timedOut {
|
||||
t.Fatal("debouncer was not closed before timeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDebouncing(t *testing.T) {
|
||||
tcs := []string{"endpointslices", "endpoints", "services"}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(fmt.Sprintf("Get the newest event as the only one when using %s", tc), func(t *testing.T) {
|
||||
expected := "500ms"
|
||||
|
||||
fw := watch.NewFake()
|
||||
defer fw.Stop()
|
||||
|
||||
d, err := New(fw.ResultChan(), expected)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create debouncer with debounce time %q", expected)
|
||||
}
|
||||
|
||||
if d.debounceTime.String() != expected {
|
||||
t.Fatalf("invalid debounce time %q was configured instead of expected %q", d.debounceTime.String(), expected)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
wg.Go(func() {
|
||||
if err := d.Start(ctx); err != nil {
|
||||
t.Fatalf("debouncer error: %s", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
numOfUpdates := 100
|
||||
|
||||
switch tc {
|
||||
case "endpointslices":
|
||||
epslice := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "test",
|
||||
},
|
||||
Endpoints: make([]discoveryv1.Endpoint, 1),
|
||||
}
|
||||
|
||||
addrEpslices := []string{}
|
||||
|
||||
for i := range numOfUpdates {
|
||||
addrEpslices = append(addrEpslices, strconv.Itoa(i))
|
||||
epslice.Endpoints[0].Addresses = addrEpslices
|
||||
fw.Add(epslice)
|
||||
}
|
||||
case "endpoints":
|
||||
ep := &v1.Endpoints{ //nolint:staticcheck
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "test",
|
||||
},
|
||||
Subsets: make([]v1.EndpointSubset, 1), //nolint:staticcheck
|
||||
}
|
||||
|
||||
addrEp := []v1.EndpointAddress{}
|
||||
|
||||
for i := range numOfUpdates {
|
||||
addrEp = append(addrEp, v1.EndpointAddress{IP: strconv.Itoa(i)})
|
||||
ep.Subsets[0].Addresses = addrEp
|
||||
fw.Add(ep)
|
||||
}
|
||||
case "services":
|
||||
svc := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "test",
|
||||
},
|
||||
}
|
||||
|
||||
svcPorts := []v1.ServicePort{}
|
||||
|
||||
for i := range numOfUpdates {
|
||||
svcPorts = append(svcPorts, v1.ServicePort{Port: int32(i)})
|
||||
svc.Spec.Ports = svcPorts
|
||||
fw.Add(svc)
|
||||
}
|
||||
|
||||
default:
|
||||
t.Fatal("unknown test", "type", tc)
|
||||
}
|
||||
|
||||
out := <-d.output
|
||||
|
||||
switch tc {
|
||||
case "endpointslices":
|
||||
outEps, ok := out.Object.(*discoveryv1.EndpointSlice)
|
||||
if !ok {
|
||||
t.Fatal("got different type of object than EndpointSlice, failed to cast")
|
||||
}
|
||||
|
||||
if len(outEps.Endpoints[0].Addresses) != numOfUpdates {
|
||||
t.Fatalf("expected to aggregate %d events, but got %d", numOfUpdates, len(outEps.Endpoints[0].Addresses))
|
||||
}
|
||||
case "endpoints":
|
||||
outEps, ok := out.Object.(*v1.Endpoints) //nolint:staticcheck
|
||||
if !ok {
|
||||
t.Fatal("got different type of object than EndpointSlice, failed to cast")
|
||||
}
|
||||
|
||||
if len(outEps.Subsets[0].Addresses) != numOfUpdates {
|
||||
t.Fatalf("expected to aggregate %d events, but got %d", numOfUpdates, len(outEps.Subsets[0].Addresses))
|
||||
}
|
||||
case "services":
|
||||
outSvc, ok := out.Object.(*v1.Service) //nolint:staticcheck
|
||||
if !ok {
|
||||
t.Fatal("got different type of object than EndpointSlice, failed to cast")
|
||||
}
|
||||
|
||||
if len(outSvc.Spec.Ports) != numOfUpdates {
|
||||
t.Fatalf("expected to aggregate %d events, but got %d", numOfUpdates, len(outSvc.Spec.Ports))
|
||||
}
|
||||
default:
|
||||
t.Fatal("unknown test", "type", tc)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
timedOut := waitTimeout(&wg, time.Second*3)
|
||||
|
||||
if timedOut {
|
||||
t.Fatal("debouncer was not closed before timeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeChange(t *testing.T) {
|
||||
tcs := []string{"endpointslices", "endpoints", "services"}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(fmt.Sprintf("Get the newest event as the only one when using %s", tc), func(t *testing.T) {
|
||||
expected := "500ms"
|
||||
|
||||
fw := watch.NewFake()
|
||||
defer fw.Stop()
|
||||
|
||||
d, err := New(fw.ResultChan(), expected)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create debouncer with debounce time %q", expected)
|
||||
}
|
||||
|
||||
if d.debounceTime.String() != expected {
|
||||
t.Fatalf("invalid debounce time %q was configured instead of expected %q", d.debounceTime.String(), expected)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
wg.Go(func() {
|
||||
if err := d.Start(ctx); err != nil {
|
||||
t.Fatalf("debouncer error: %s", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
numOfUpdates := 100
|
||||
|
||||
switch tc {
|
||||
case "endpointslices":
|
||||
epslice := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "test",
|
||||
},
|
||||
Endpoints: make([]discoveryv1.Endpoint, 1),
|
||||
}
|
||||
|
||||
addrEpslices := []string{}
|
||||
|
||||
for i := range numOfUpdates {
|
||||
addrEpslices = append(addrEpslices, strconv.Itoa(i))
|
||||
epslice.Endpoints[0].Addresses = addrEpslices
|
||||
if i < numOfUpdates-1 {
|
||||
fw.Add(epslice)
|
||||
} else {
|
||||
fw.Delete(epslice)
|
||||
}
|
||||
}
|
||||
case "endpoints":
|
||||
ep := &v1.Endpoints{ //nolint:staticcheck
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "test",
|
||||
},
|
||||
Subsets: make([]v1.EndpointSubset, 1), //nolint:staticcheck
|
||||
}
|
||||
|
||||
addrEp := []v1.EndpointAddress{}
|
||||
|
||||
for i := range numOfUpdates {
|
||||
addrEp = append(addrEp, v1.EndpointAddress{IP: strconv.Itoa(i)})
|
||||
ep.Subsets[0].Addresses = addrEp
|
||||
if i < numOfUpdates-1 {
|
||||
fw.Add(ep)
|
||||
} else {
|
||||
fw.Delete(ep)
|
||||
}
|
||||
}
|
||||
case "services":
|
||||
svc := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "test",
|
||||
},
|
||||
}
|
||||
|
||||
svcPorts := []v1.ServicePort{}
|
||||
|
||||
for i := range numOfUpdates {
|
||||
svcPorts = append(svcPorts, v1.ServicePort{Port: int32(i)})
|
||||
svc.Spec.Ports = svcPorts
|
||||
if i < numOfUpdates-1 {
|
||||
fw.Add(svc)
|
||||
} else {
|
||||
fw.Delete(svc)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
t.Fatal("unknown test", "type", tc)
|
||||
}
|
||||
|
||||
out := <-d.output
|
||||
|
||||
if out.Type != watch.Added {
|
||||
t.Fatalf("expected to get add event, but got %s event", out.Type)
|
||||
}
|
||||
|
||||
out = <-d.output
|
||||
|
||||
if out.Type != watch.Deleted {
|
||||
t.Fatalf("expected to get delete event, but got %s event", out.Type)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
timedOut := waitTimeout(&wg, time.Second*3)
|
||||
|
||||
if timedOut {
|
||||
t.Fatal("debouncer was not closed before timeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// waitTimeout waits for the waitgroup for the specified max timeout.
|
||||
// Returns true if waiting timed out.
|
||||
func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
|
||||
c := make(chan struct{})
|
||||
go func() {
|
||||
defer close(c)
|
||||
wg.Wait()
|
||||
}()
|
||||
select {
|
||||
case <-c:
|
||||
return false // completed normally
|
||||
case <-time.After(timeout):
|
||||
return true // timed out
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func FindIPAddress(addrName string) (string, string, error) {
|
||||
// If we're not searching for a specific adapter return the first one
|
||||
if addrName == "" {
|
||||
return iface.Name, address, nil
|
||||
} else
|
||||
}
|
||||
// If this is the correct adapter return the details
|
||||
if iface.Name == addrName {
|
||||
return iface.Name, address, nil
|
||||
@@ -37,5 +37,5 @@ func FindIPAddress(addrName string) (string, string, error) {
|
||||
}
|
||||
|
||||
}
|
||||
return "", "", fmt.Errorf("Unknown interface [%s]", addrName)
|
||||
return "", "", fmt.Errorf("unknown interface [%s]", addrName)
|
||||
}
|
||||
|
||||
98
pkg/egress/egress.go
Normal file
98
pkg/egress/egress.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package egress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/iptables"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/nftables"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
)
|
||||
|
||||
func Teardown(podIP, vipIP, namespace, serviceUUID string, annotations map[string]string, useNftables bool) error {
|
||||
// Look up the destination ports from the annotations on the service
|
||||
destinationPorts := annotations[kubevip.EgressDestinationPorts]
|
||||
deniedNetworks := annotations[kubevip.EgressDeniedNetworks]
|
||||
allowedNetworks := annotations[kubevip.EgressAllowedNetworks]
|
||||
internalEgress := annotations[kubevip.EgressInternal]
|
||||
|
||||
protocol := iptables.ProtocolIPv4
|
||||
if utils.IsIPv6(podIP) {
|
||||
protocol = iptables.ProtocolIPv6
|
||||
}
|
||||
|
||||
// Use the internal egress implementation
|
||||
if internalEgress != "" || useNftables {
|
||||
return nftables.DeleteSNATFromAllTables(serviceUUID)
|
||||
}
|
||||
|
||||
i, err := vip.CreateIptablesClient(useNftables, namespace, protocol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error Creating iptables client [%s]", err)
|
||||
}
|
||||
|
||||
if deniedNetworks != "" {
|
||||
networks := strings.Split(deniedNetworks, ",")
|
||||
for x := range networks {
|
||||
err = i.DeleteMangleReturnForNetwork(vip.MangleChainName, networks[x])
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting rules in mangle chain [%s], error [%s]", vip.MangleChainName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if allowedNetworks != "" {
|
||||
networks := strings.Split(allowedNetworks, ",")
|
||||
for x := range networks {
|
||||
err = i.DeleteMangleMarkingForNetwork(podIP, vip.MangleChainName, networks[x])
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting rules in mangle chain [%s], error [%s]", vip.MangleChainName, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove the marking of egress packets
|
||||
err = i.DeleteMangleMarking(podIP, vip.MangleChainName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error changing iptables rules for egress [%s]", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear up SNAT rules
|
||||
if destinationPorts != "" {
|
||||
fixedPorts := strings.Split(destinationPorts, ",")
|
||||
|
||||
for _, fixedPort := range fixedPorts {
|
||||
var proto, port string
|
||||
|
||||
data := strings.Split(fixedPort, ":")
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
} else if len(data) == 1 {
|
||||
proto = "tcp"
|
||||
port = data[0]
|
||||
} else {
|
||||
proto = data[0]
|
||||
port = data[1]
|
||||
}
|
||||
|
||||
err = i.DeleteSourceNatForDestinationPort(podIP, vipIP, port, proto)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error changing iptables rules for egress [%s]", err)
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
err = i.DeleteSourceNat(podIP, vipIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error changing iptables rules for egress [%s]", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = vip.DeleteExistingSessions(podIP, false, destinationPorts, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error changing iptables rules for egress [%s]", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
268
pkg/election/election.go
Normal file
268
pkg/election/election.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package election
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/etcd"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
"github.com/kube-vip/kube-vip/pkg/loadbalancer"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
clientv3 "go.etcd.io/etcd/client/v3"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
"k8s.io/client-go/tools/leaderelection"
|
||||
"k8s.io/client-go/tools/leaderelection/resourcelock"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
KubernetesClient *kubernetes.Clientset
|
||||
RetryWatcherClient *kubernetes.Clientset
|
||||
// This channel is used to signal a shutdown
|
||||
|
||||
EtcdClient *clientv3.Client
|
||||
}
|
||||
|
||||
// NewManager will create a new managing object
|
||||
func NewManager(config *kubevip.Config, k8sClientset, rwClientset *kubernetes.Clientset) (*Manager, error) {
|
||||
m := &Manager{}
|
||||
|
||||
switch config.LeaderElectionType {
|
||||
case "kubernetes", "":
|
||||
if k8sClientset == nil || rwClientset == nil {
|
||||
return nil, fmt.Errorf("provided nil clientset")
|
||||
}
|
||||
m.KubernetesClient = k8sClientset
|
||||
m.RetryWatcherClient = rwClientset
|
||||
case "etcd":
|
||||
client, err := etcd.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.EtcdClient = client
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid LeaderElectionMode %s not supported", config.LeaderElectionType)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func RunOrDie(ctx context.Context, run *RunConfig, c *kubevip.Config) error {
|
||||
switch c.LeaderElectionType {
|
||||
case "kubernetes", "":
|
||||
return runKubernetesLeaderElectionOrDie(ctx, run)
|
||||
case "etcd":
|
||||
if err := runEtcdLeaderElectionOrDie(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
log.Info("LeaderElectionMode not supported, exiting", "mode", c.LeaderElectionType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runKubernetesLeaderElectionOrDie(ctx context.Context, run *RunConfig) error {
|
||||
annotations, err := kubevip.WithLeaseVIPs(run.LeaseAnnotations, run.Config.InstanceName, run.Config.RoutingProtocol, run.VIPs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaseClient := run.Mgr.KubernetesClient.CoordinationV1().Leases(run.LeaseID.Namespace())
|
||||
// we use the Lease lock type since edits to Leases are less common
|
||||
// and fewer objects in the cluster watch "all Leases".
|
||||
baseLock := &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{
|
||||
Name: run.LeaseID.Name(),
|
||||
Namespace: run.LeaseID.Namespace(),
|
||||
},
|
||||
Client: run.Mgr.KubernetesClient.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{
|
||||
Identity: run.Config.NodeName,
|
||||
},
|
||||
}
|
||||
lock := newAnnotatedLeaseLock(baseLock, leaseClient, run.LeaseID.Name(), annotations)
|
||||
|
||||
// start the leader election code loop
|
||||
leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
|
||||
Lock: lock,
|
||||
// IMPORTANT: you MUST ensure that any code you have that
|
||||
// is protected by the lease must terminate **before**
|
||||
// you call cancel. Otherwise, you could have a background
|
||||
// loop still running and another process could
|
||||
// get elected before your background loop finished, violating
|
||||
// the stated goal of the lease.
|
||||
ReleaseOnCancel: true,
|
||||
LeaseDuration: time.Duration(run.Config.LeaseDuration) * time.Second,
|
||||
RenewDeadline: time.Duration(run.Config.RenewDeadline) * time.Second,
|
||||
RetryPeriod: time.Duration(run.Config.RetryPeriod) * time.Second,
|
||||
Callbacks: leaderelection.LeaderCallbacks{
|
||||
OnStartedLeading: run.OnStartedLeading,
|
||||
OnStoppedLeading: run.OnStoppedLeading,
|
||||
OnNewLeader: run.OnNewLeader,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEtcdLeaderElectionOrDie(ctx context.Context, run *RunConfig) error {
|
||||
if err := etcd.RunElectionOrDie(ctx, &etcd.LeaderElectionConfig{
|
||||
EtcdConfig: etcd.ClientConfig{Client: run.Mgr.EtcdClient},
|
||||
Name: run.LeaseID.NamespacedName(),
|
||||
MemberID: run.Config.NodeName,
|
||||
LeaseDurationSeconds: int64(run.Config.LeaseDuration),
|
||||
Callbacks: etcd.LeaderCallbacks{
|
||||
OnStartedLeading: run.OnStartedLeading,
|
||||
OnStoppedLeading: run.OnStoppedLeading,
|
||||
OnNewLeader: run.OnNewLeader,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("etcd leaderelection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Actions interface {
|
||||
OnStartedLeading(ctx context.Context)
|
||||
OnStoppedLeading()
|
||||
OnNewLeader(identity string)
|
||||
}
|
||||
|
||||
type RunConfig struct {
|
||||
Config *kubevip.Config
|
||||
LeaseID lease.ID
|
||||
Mgr *Manager
|
||||
LeaseAnnotations map[string]string
|
||||
VIPs []string
|
||||
|
||||
// onStartedLeading is called when this member starts leading.
|
||||
OnStartedLeading func(context.Context)
|
||||
// onStoppedLeading is called when this member stops leading.
|
||||
OnStoppedLeading func()
|
||||
// onNewLeader is called when the client observes a leader that is
|
||||
// not the previously observed leader. This includes the first observed
|
||||
// leader when the client starts.
|
||||
OnNewLeader func(identity string)
|
||||
}
|
||||
|
||||
func (em *Manager) NodeWatcher(ctx context.Context, lb *loadbalancer.IPVSLoadBalancer, port uint16) error {
|
||||
// Use a restartable watcher, as this should help in the event of etcd or timeout issues
|
||||
log.Info("Kube-Vip is watching nodes for control-plane labels")
|
||||
|
||||
listOptions := metav1.ListOptions{
|
||||
LabelSelector: "node-role.kubernetes.io/control-plane",
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
defer wg.Wait()
|
||||
|
||||
watchCtx, watchCancel := context.WithCancel(ctx)
|
||||
defer watchCancel()
|
||||
|
||||
rw, err := watchtools.NewRetryWatcherWithContext(watchCtx, "1", &cache.ListWatch{
|
||||
WatchFunc: func(_ metav1.ListOptions) (watch.Interface, error) {
|
||||
return utils.WatchWithAuthRetry(ctx, func(ctx context.Context) (watch.Interface, error) {
|
||||
return em.RetryWatcherClient.CoreV1().Nodes().Watch(watchCtx, listOptions)
|
||||
})
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating label watcher: %w", err)
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
<-watchCtx.Done()
|
||||
log.Info("Node watcher context cancelled, stopping")
|
||||
// Stop the retrywatcher
|
||||
rw.Stop()
|
||||
})
|
||||
|
||||
ch := rw.ResultChan()
|
||||
|
||||
var watchErr error
|
||||
for event := range ch {
|
||||
// We need to inspect the event and get ResourceVersion out of it
|
||||
switch event.Type {
|
||||
case watch.Added, watch.Modified:
|
||||
node, ok := event.Object.(*v1.Node)
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to parse Kubernetes Node from Annotation watcher")
|
||||
}
|
||||
// Find the node IP address (this isn't foolproof)
|
||||
for x := range node.Status.Addresses {
|
||||
if node.Status.Addresses[x].Type == v1.NodeInternalIP {
|
||||
if checkIfNodeIsReady(node) {
|
||||
err = lb.AddBackend(node.Status.Addresses[x].Address, port)
|
||||
if err != nil {
|
||||
log.Error("adding node to load balancer", "node", node.Name, "ip", node.Status.Addresses[x].Address, "err", err)
|
||||
if utils.IsPanicError(err) {
|
||||
return fmt.Errorf("add IPVS backend: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err = lb.RemoveBackend(node.Status.Addresses[x].Address, port)
|
||||
if err != nil {
|
||||
log.Error("removing node from load balancer", "node", node.Name, "ip", node.Status.Addresses[x].Address, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case watch.Deleted:
|
||||
node, ok := event.Object.(*v1.Node)
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to parse Kubernetes Node from Annotation watcher")
|
||||
}
|
||||
|
||||
// Find the node IP address (this isn't foolproof)
|
||||
for x := range node.Status.Addresses {
|
||||
if node.Status.Addresses[x].Type == v1.NodeInternalIP {
|
||||
err = lb.RemoveBackend(node.Status.Addresses[x].Address, port)
|
||||
if err != nil {
|
||||
log.Error("removing node from load balancer", "node", node.Name, "ip", node.Status.Addresses[x].Address, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Node deleted", "name", node.Name)
|
||||
|
||||
case watch.Bookmark:
|
||||
// Un-used
|
||||
case watch.Error:
|
||||
log.Error("Error attempting to watch Kubernetes Nodes")
|
||||
watchErr = fmt.Errorf("node watcher error: %w", utils.WatchError(event.Object))
|
||||
log.Error("watcher", "err", watchErr)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Exiting Node watcher")
|
||||
if watchErr != nil {
|
||||
return watchErr
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return utils.NewPanicError("node watcher channel closed unexpectedly")
|
||||
}
|
||||
|
||||
func checkIfNodeIsReady(node *v1.Node) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
for _, condition := range node.Status.Conditions {
|
||||
if condition.Type == v1.NodeReady {
|
||||
if condition.Status == v1.ConditionTrue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
90
pkg/election/lease_lock.go
Normal file
90
pkg/election/lease_lock.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package election
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
coordinationv1client "k8s.io/client-go/kubernetes/typed/coordination/v1"
|
||||
"k8s.io/client-go/tools/leaderelection/resourcelock"
|
||||
"k8s.io/client-go/util/retry"
|
||||
)
|
||||
|
||||
type annotatedLeaseLock struct {
|
||||
resourcelock.Interface
|
||||
leases coordinationv1client.LeaseInterface
|
||||
name string
|
||||
annotations map[string]string
|
||||
}
|
||||
|
||||
func newAnnotatedLeaseLock(lock resourcelock.Interface, leases coordinationv1client.LeaseInterface,
|
||||
name string, annotations map[string]string) resourcelock.Interface {
|
||||
return &annotatedLeaseLock{Interface: lock, leases: leases, name: name, annotations: annotations}
|
||||
}
|
||||
|
||||
func (lock *annotatedLeaseLock) Get(ctx context.Context) (*resourcelock.LeaderElectionRecord, []byte, error) {
|
||||
return lock.Interface.Get(ctx)
|
||||
}
|
||||
|
||||
func (lock *annotatedLeaseLock) Create(ctx context.Context, record resourcelock.LeaderElectionRecord) error {
|
||||
if err := lock.Interface.Create(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.HolderIdentity != lock.Identity() {
|
||||
return nil
|
||||
}
|
||||
changed, err := lock.ensureAnnotations(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
_, _, err = lock.Interface.Get(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (lock *annotatedLeaseLock) Update(ctx context.Context, record resourcelock.LeaderElectionRecord) error {
|
||||
if err := lock.Interface.Update(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.HolderIdentity != lock.Identity() {
|
||||
return nil
|
||||
}
|
||||
changed, err := lock.ensureAnnotations(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
_, _, err = lock.Interface.Get(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (lock *annotatedLeaseLock) ensureAnnotations(ctx context.Context) (bool, error) {
|
||||
changed := false
|
||||
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
resource, err := lock.leases.Get(ctx, lock.name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resource.Annotations == nil {
|
||||
resource.Annotations = make(map[string]string, len(lock.annotations))
|
||||
}
|
||||
resourceChanged := false
|
||||
for key, value := range lock.annotations {
|
||||
if resource.Annotations[key] == value {
|
||||
continue
|
||||
}
|
||||
resource.Annotations[key] = value
|
||||
resourceChanged = true
|
||||
}
|
||||
if !resourceChanged {
|
||||
return nil
|
||||
}
|
||||
_, err = lock.leases.Update(ctx, resource, metav1.UpdateOptions{})
|
||||
if err == nil {
|
||||
changed = true
|
||||
}
|
||||
return err
|
||||
})
|
||||
return changed, err
|
||||
}
|
||||
139
pkg/election/lease_lock_test.go
Normal file
139
pkg/election/lease_lock_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package election
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
"k8s.io/client-go/tools/leaderelection/resourcelock"
|
||||
)
|
||||
|
||||
func TestAnnotatedLeaseLockPersistsAnnotationsOnCreateAndUpdate(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
leaseClient := client.CoordinationV1().Leases("default")
|
||||
base := &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{Name: "lease", Namespace: "default"},
|
||||
Client: client.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{
|
||||
Identity: "node-a",
|
||||
},
|
||||
}
|
||||
annotations, err := kubevip.WithLeaseVIPs(map[string]string{"example.test/preserved": "true"},
|
||||
"release_a", 248, []string{"192.0.2.10"})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLeaseVIPs() error = %v", err)
|
||||
}
|
||||
lock := newAnnotatedLeaseLock(base, leaseClient, "lease", annotations)
|
||||
record := resourcelock.LeaderElectionRecord{HolderIdentity: "node-a"}
|
||||
if err := lock.Create(context.Background(), record); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if err := lock.Update(context.Background(), record); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
resource, err := leaseClient.Get(context.Background(), "lease", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("get Lease: %v", err)
|
||||
}
|
||||
value, err := kubevip.ParseLeaseVIPs(resource.Annotations[kubevip.LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLeaseVIPs() error = %v", err)
|
||||
}
|
||||
if value.InstanceName != "release_a" || value.IFAProto != 248 || len(value.VIPs) != 1 ||
|
||||
value.VIPs[0] != (kubevip.LeaseVIP{Index: 0, Value: "192.0.2.10"}) {
|
||||
t.Fatalf("Lease VIP metadata = %+v", value)
|
||||
}
|
||||
if resource.Annotations["example.test/preserved"] != "true" {
|
||||
t.Fatal("Lease update dropped a configured annotation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotatedLeaseLockFollowerDoesNotOverwriteAnnotations(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
leaseClient := client.CoordinationV1().Leases("default")
|
||||
newBase := func(identity string) *resourcelock.LeaseLock {
|
||||
return &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{Name: "lease", Namespace: "default"},
|
||||
Client: client.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{Identity: identity},
|
||||
}
|
||||
}
|
||||
ownerBase := newBase("node-a")
|
||||
followerBase := newBase("node-b")
|
||||
active, err := kubevip.WithLeaseVIPs(nil, "release_a", 248, []string{"192.0.2.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creator := newAnnotatedLeaseLock(ownerBase, leaseClient, "lease", active)
|
||||
if err := creator.Create(context.Background(), resourcelock.LeaderElectionRecord{HolderIdentity: "node-a"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
follower, err := kubevip.WithLeaseVIPs(nil, "release_b", 249, []string{"192.0.2.20"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observer := newAnnotatedLeaseLock(followerBase, leaseClient, "lease", follower)
|
||||
if _, _, err := observer.Get(context.Background()); err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
resource, err := leaseClient.Get(context.Background(), "lease", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata, err := kubevip.ParseLeaseVIPs(resource.Annotations[kubevip.LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.InstanceName != "release_a" || metadata.IFAProto != 248 {
|
||||
t.Fatalf("follower overwrote active metadata: %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotatedLeaseLockReleaseDoesNotOverwriteSuccessorMetadata(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
leaseClient := client.CoordinationV1().Leases("default")
|
||||
newLock := func(identity, instanceName string, protocol int, vip string) resourcelock.Interface {
|
||||
base := &resourcelock.LeaseLock{
|
||||
LeaseMeta: metav1.ObjectMeta{Name: "lease", Namespace: "default"},
|
||||
Client: client.CoordinationV1(),
|
||||
LockConfig: resourcelock.ResourceLockConfig{Identity: identity},
|
||||
}
|
||||
annotations, err := kubevip.WithLeaseVIPs(nil, instanceName, protocol, []string{vip})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return newAnnotatedLeaseLock(base, leaseClient, "lease", annotations)
|
||||
}
|
||||
|
||||
first := newLock("node-a", "release_a", 248, "192.0.2.10")
|
||||
if err := first.Create(context.Background(), resourcelock.LeaderElectionRecord{HolderIdentity: "node-a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Update(context.Background(), resourcelock.LeaderElectionRecord{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second := newLock("node-b", "release_b", 249, "192.0.2.20")
|
||||
if _, _, err := second.Get(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.Update(context.Background(), resourcelock.LeaderElectionRecord{HolderIdentity: "node-b"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resource, err := leaseClient.Get(context.Background(), "lease", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata, err := kubevip.ParseLeaseVIPs(resource.Annotations[kubevip.LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.InstanceName != "release_b" || metadata.IFAProto != 249 || metadata.VIPs[0].Value != "192.0.2.20" {
|
||||
t.Fatalf("successor metadata = %+v", metadata)
|
||||
}
|
||||
}
|
||||
152
pkg/endpoints/cleanup.go
Normal file
152
pkg/endpoints/cleanup.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/egress"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
"github.com/kube-vip/kube-vip/pkg/nftables"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/wireguard"
|
||||
)
|
||||
|
||||
// CleanupService stops one Service's datapath before its instance is detached.
|
||||
// The service processor owns labels and instance bookkeeping; this package owns
|
||||
// endpoint-dependent networking and waits for worker shutdown to complete.
|
||||
func CleanupService(ctx context.Context, config *kubevip.Config, bgpServer *bgp.Server, routeMgr *route.Manager,
|
||||
tunnelMgr *wireguard.TunnelManager, serviceInstance *instance.Instance, remaining []*instance.Instance) error {
|
||||
if serviceInstance == nil || serviceInstance.ServiceSnapshot == nil {
|
||||
return nil
|
||||
}
|
||||
service := serviceInstance.ServiceSnapshot
|
||||
for _, serviceCluster := range serviceInstance.Clusters {
|
||||
for _, network := range serviceCluster.Network {
|
||||
network.SetHasEndpoints(false)
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableBGP {
|
||||
ClearBGPHostsByInstance(ctx, serviceInstance, bgpServer)
|
||||
}
|
||||
if config.EnableRoutingTable {
|
||||
for _, err := range ClearRoutesByInstance(service, serviceInstance, &remaining, routeMgr) {
|
||||
log.Error("unable to clear routes", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
internalNftablesEgress := service.Annotations[kubevip.EgressInternal] != "" || config.EgressWithNftables
|
||||
if service.Annotations[kubevip.Egress] == "true" && internalNftablesEgress {
|
||||
if err := nftables.DeleteSNATFromAllTables(string(serviceInstance.UID())); err != nil {
|
||||
log.Error("[service] nftables egress teardown", "service", service.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
sharedVIPs := sharedServiceVIPs(config, serviceInstance, remaining)
|
||||
for _, serviceCluster := range serviceInstance.Clusters {
|
||||
preserve := make([]string, 0, len(serviceCluster.Network))
|
||||
for _, network := range serviceCluster.Network {
|
||||
if _, shared := sharedVIPs[network.IP()]; shared {
|
||||
preserve = append(preserve, network.IP())
|
||||
}
|
||||
}
|
||||
if len(preserve) != 0 {
|
||||
serviceCluster.StopAndWaitPreserving(preserve...)
|
||||
} else {
|
||||
serviceCluster.StopAndWait()
|
||||
}
|
||||
}
|
||||
if err := serviceInstance.CleanupLinkAttachments(remaining...); err != nil {
|
||||
return fmt.Errorf("clean Service link attachments: %w", err)
|
||||
}
|
||||
if service.Annotations[kubevip.Egress] == "true" && !internalNftablesEgress && service.Annotations[kubevip.ActiveEndpoint] != "" {
|
||||
if err := egress.Teardown(service.Annotations[kubevip.ActiveEndpoint], service.Spec.LoadBalancerIP, service.Namespace,
|
||||
string(serviceInstance.UID()), service.Annotations, config.EgressWithNftables); err != nil {
|
||||
log.Error("[service] egress teardown", "err", err)
|
||||
}
|
||||
}
|
||||
if config.EnableWireguard {
|
||||
cleanupWireguardService(tunnelMgr, service)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartService starts a Service's cluster datapath after endpoint handling has
|
||||
// made the Service eligible for activation.
|
||||
func StartService(ctx context.Context, service *v1.Service, serviceInstance *instance.Instance, bgpServer *bgp.Server,
|
||||
wg *sync.WaitGroup) error {
|
||||
if serviceInstance == nil {
|
||||
return fmt.Errorf("missing service instance for %s/%s", service.Namespace, service.Name)
|
||||
}
|
||||
for index := range serviceInstance.VIPConfigs {
|
||||
if err := serviceInstance.Clusters[index].StartLoadBalancerService(ctx, serviceInstance.VIPConfigs[index], bgpServer,
|
||||
lease.ServiceNamespacedName(service), wg); err != nil {
|
||||
return fmt.Errorf("start load balancer: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sharedServiceVIPs(config *kubevip.Config, serviceInstance *instance.Instance, remaining []*instance.Instance) map[string]struct{} {
|
||||
shared := make(map[string]struct{})
|
||||
if serviceInstance.ServiceSnapshot == nil ||
|
||||
serviceInstance.ServiceSnapshot.Spec.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
|
||||
return shared
|
||||
}
|
||||
serviceNamespace, serviceLeaseName := lease.ServiceName(serviceInstance.ServiceSnapshot)
|
||||
serviceLease := lease.NewID(config.LeaderElectionType, serviceNamespace, serviceLeaseName).NamespacedName()
|
||||
addresses := serviceInstance.Addresses()
|
||||
for _, candidate := range remaining {
|
||||
candidateInfo, ok := candidate.CleanupInfo()
|
||||
if !ok || candidateInfo.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
|
||||
continue
|
||||
}
|
||||
candidateNamespace, candidateLeaseName := lease.ServiceNameFor(candidateInfo.Namespace, candidateInfo.Name, candidateInfo.Lease)
|
||||
candidateLease := lease.NewID(config.LeaderElectionType, candidateNamespace, candidateLeaseName).NamespacedName()
|
||||
if config.EnableServicesElection && candidateLease != serviceLease {
|
||||
continue
|
||||
}
|
||||
for _, address := range candidate.Addresses() {
|
||||
for _, serviceAddress := range addresses {
|
||||
if address == serviceAddress {
|
||||
shared[address] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
func cleanupWireguardService(tunnelMgr *wireguard.TunnelManager, service *v1.Service) {
|
||||
if tunnelMgr == nil {
|
||||
return
|
||||
}
|
||||
forEachServiceDNATChain(service, func(ipv6 bool, serviceID string) {
|
||||
if err := nftables.DeleteIngressChains(ipv6, serviceID); err != nil {
|
||||
log.Error("[wireguard] failed to delete DNAT chains", "ipv6", ipv6, "service", service.Name, "err", err)
|
||||
}
|
||||
})
|
||||
releaseWireguardServiceTunnels(tunnelMgr, service)
|
||||
}
|
||||
|
||||
type wireguardTunnelReleaser interface {
|
||||
ReleaseTunnelForVIP(vip, owner string) error
|
||||
}
|
||||
|
||||
func releaseWireguardServiceTunnels(tunnelMgr wireguardTunnelReleaser, service *v1.Service) {
|
||||
serviceIPs, _ := utils.FetchServiceIPs(service)
|
||||
for _, serviceIP := range serviceIPs {
|
||||
if err := tunnelMgr.ReleaseTunnelForVIP(serviceIP, string(service.UID)); err != nil {
|
||||
log.Error("[wireguard] failed to tear down tunnel", "service", service.Name, "vip", serviceIP, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
355
pkg/endpoints/endpoints.go
Normal file
355
pkg/endpoints/endpoints.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
"github.com/kube-vip/kube-vip/pkg/servicecontext"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/wireguard"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
config *kubevip.Config
|
||||
provider providers.Provider
|
||||
bgpServer *bgp.Server
|
||||
worker endpointWorker
|
||||
instances *[]*instance.Instance
|
||||
instancesMutex *sync.RWMutex
|
||||
leaseMgr *lease.Manager
|
||||
lockService func(types.UID) func()
|
||||
}
|
||||
|
||||
func NewEndpointProcessor(config *kubevip.Config, provider providers.Provider, bgpServer *bgp.Server,
|
||||
instances *[]*instance.Instance, instancesMutex *sync.RWMutex, leaseMgr *lease.Manager, tunnelMgr *wireguard.TunnelManager, routeMgr *route.Manager,
|
||||
lockService func(types.UID) func()) *Processor {
|
||||
return &Processor{
|
||||
config: config,
|
||||
provider: provider,
|
||||
bgpServer: bgpServer,
|
||||
instances: instances,
|
||||
instancesMutex: instancesMutex,
|
||||
leaseMgr: leaseMgr,
|
||||
lockService: lockService,
|
||||
worker: newEndpointWorker(config, provider, bgpServer, leaseMgr, tunnelMgr, routeMgr),
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile applies a watch event to the provider and reconciles the service
|
||||
// against the endpoints that remain afterwards. A deleted object is only one of
|
||||
// potentially several backing the service, so deletions are recomputed rather
|
||||
// than assumed to empty it. It reports whether the caller should skip this event
|
||||
// and wait for the next one.
|
||||
func (p *Processor) Reconcile(svcCtx *servicecontext.Context, event watch.Event,
|
||||
lastKnownGoodEndpoint *string, service *v1.Service, id string,
|
||||
wg *sync.WaitGroup,
|
||||
clientSet *kubernetes.Clientset,
|
||||
egressUpdateFunc func(context.Context, *v1.Service, *instance.Instance) error) (bool, error) {
|
||||
if p.lockService == nil {
|
||||
return false, fmt.Errorf("service operation lock is not configured")
|
||||
}
|
||||
endpointCount := 0
|
||||
var readinessLossGeneration uint64
|
||||
clearNoEndpoints := false
|
||||
updatedService, inst, changed, skip, err := func() (*v1.Service, *instance.Instance, bool, bool, error) {
|
||||
unlockService := p.lockService(service.UID)
|
||||
defer unlockService()
|
||||
|
||||
if err := p.applyEvent(svcCtx, event); err != nil {
|
||||
return nil, nil, false, false, err
|
||||
}
|
||||
|
||||
endpoints, err := p.worker.getEndpoints(service, id)
|
||||
if err != nil {
|
||||
return nil, nil, false, false, fmt.Errorf("[%s] error getting endpoints: %w", p.provider.GetLabel(), err)
|
||||
}
|
||||
if service.Annotations[kubevip.EgressIPv6] == "true" && !hasV6(endpoints) {
|
||||
endpoints = nil
|
||||
}
|
||||
endpointCount = len(endpoints)
|
||||
|
||||
inst := p.findServiceInstance(service)
|
||||
|
||||
if err := p.worker.setInstanceEndpointsStatus(service, inst, endpoints); err != nil {
|
||||
log.Error("updating instance", "err", err)
|
||||
}
|
||||
|
||||
allowReconcileWithoutEndpoints := shouldAllowReconcileWithoutEndpoints(service)
|
||||
|
||||
if len(endpoints) != 0 {
|
||||
p.updateLastKnownGoodEndpoint(lastKnownGoodEndpoint, endpoints, service)
|
||||
|
||||
if err := p.startServiceHandlingIfNeeded(svcCtx, service, inst, wg); err != nil {
|
||||
return nil, nil, false, true, err
|
||||
}
|
||||
|
||||
svcCtx.SignalReadiness()
|
||||
|
||||
if p.shouldProcessInstance() {
|
||||
if err := p.worker.processInstance(svcCtx.Ctx, &svcCtx.ConfiguredNetworks, service, inst); err != nil {
|
||||
return nil, nil, false, false, fmt.Errorf("failed to process non-empty instance: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if allowReconcileWithoutEndpoints {
|
||||
// Explicit opt-in for controllers that create LoadBalancer services without endpoints
|
||||
if err := p.startServiceHandlingIfNeeded(svcCtx, service, inst, wg); err != nil {
|
||||
return nil, nil, false, true, err
|
||||
}
|
||||
svcCtx.SignalReadiness()
|
||||
|
||||
if p.shouldProcessInstance() {
|
||||
if err := p.worker.processInstance(svcCtx.Ctx, &svcCtx.ConfiguredNetworks, service, inst); err != nil {
|
||||
return nil, nil, false, false, fmt.Errorf("failed to process endpointless instance: %w", err)
|
||||
}
|
||||
}
|
||||
} else if svcCtx.IsReady() {
|
||||
readinessLossGeneration, _, _, _ = svcCtx.ReadinessState()
|
||||
clearNoEndpoints = true
|
||||
}
|
||||
}
|
||||
|
||||
updatedService, changed := p.updateAnnotations(service, inst, lastKnownGoodEndpoint, clientSet)
|
||||
return updatedService, inst, changed, false, nil
|
||||
}()
|
||||
if err != nil || skip {
|
||||
return skip, err
|
||||
}
|
||||
if clearNoEndpoints && svcCtx.ResetReadinessGeneration(readinessLossGeneration) {
|
||||
unlockService := p.lockService(service.UID)
|
||||
p.handleNoEndpoints(svcCtx, service, inst, lastKnownGoodEndpoint)
|
||||
unlockService()
|
||||
}
|
||||
|
||||
if changed && egressUpdateFunc != nil {
|
||||
if err := egressUpdateFunc(context.Background(), updatedService, inst); err != nil {
|
||||
log.Error("failed to reconfigure egress", "service", service.Name, "namespace", service.Namespace, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("watcher", "provider",
|
||||
p.provider.GetLabel(), "service name", service.Name, "namespace", service.Namespace, "endpoints", endpointCount, "last endpoint", *lastKnownGoodEndpoint)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// applyEvent updates the provider's view of the objects backing this service.
|
||||
func (p *Processor) applyEvent(svcCtx *servicecontext.Context, event watch.Event) error {
|
||||
if event.Type == watch.Deleted {
|
||||
if err := p.provider.DeleteObject(event.Object); err != nil {
|
||||
return fmt.Errorf("[%s] error deleting k8s object: %w", p.provider.GetLabel(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.provider.LoadObject(event.Object, svcCtx.Cancel); err != nil {
|
||||
return fmt.Errorf("[%s] error loading k8s object: %w", p.provider.GetLabel(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldProcessInstance reports whether this node has to program the datapath
|
||||
// itself, rather than waiting to be told to by a leader election callback.
|
||||
// WireGuard always reprograms, because its DNAT rules are per-endpoint.
|
||||
func (p *Processor) shouldProcessInstance() bool {
|
||||
return (!p.config.EnableServicesElection && !p.config.EnableLeaderElection) || p.config.EnableWireguard
|
||||
}
|
||||
|
||||
// handleNoEndpoints tears down everything backing a service that no longer has
|
||||
// any usable endpoints.
|
||||
func (p *Processor) handleNoEndpoints(svcCtx *servicecontext.Context, service *v1.Service, inst *instance.Instance, lastKnownGoodEndpoint *string) {
|
||||
p.worker.clear(svcCtx.Ctx, &svcCtx.ConfiguredNetworks, lastKnownGoodEndpoint, service, inst)
|
||||
stopWorkers := p.config.EnableARP || (p.config.EnableRoutingTable && p.config.EnableLeaderElection)
|
||||
if stopWorkers && !p.config.EnableServicesElection {
|
||||
if inst != nil {
|
||||
for _, c := range inst.Clusters {
|
||||
c.StopAndWait()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) updateLastKnownGoodEndpoint(lastKnownGoodEndpoint *string, endpoints []string, service *v1.Service) {
|
||||
// if we haven't populated one, then do so
|
||||
family := utils.IPv4Family
|
||||
if service.Annotations[kubevip.EgressIPv6] == "true" {
|
||||
family = utils.IPv6Family
|
||||
}
|
||||
|
||||
ep := getEndpoint(endpoints, family)
|
||||
|
||||
if *lastKnownGoodEndpoint == "" {
|
||||
*lastKnownGoodEndpoint = ep
|
||||
return
|
||||
}
|
||||
|
||||
// check out previous endpoint exists
|
||||
stillExists := false
|
||||
|
||||
for x := range endpoints {
|
||||
if endpoints[x] == *lastKnownGoodEndpoint {
|
||||
stillExists = true
|
||||
}
|
||||
}
|
||||
// If the last endpoint no longer exists, we cancel our leader Election, and set another endpoint as last known good
|
||||
if !stillExists {
|
||||
ip := net.ParseIP(*lastKnownGoodEndpoint)
|
||||
if (ip.To4() != nil && service.Annotations[kubevip.Egress] == "true") ||
|
||||
(ip.To4() == nil && service.Annotations[kubevip.EgressIPv6] == "true") {
|
||||
p.worker.removeEgress(service, lastKnownGoodEndpoint)
|
||||
}
|
||||
// Set our active endpoint to an existing one
|
||||
*lastKnownGoodEndpoint = ep
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) updateAnnotations(service *v1.Service, inst *instance.Instance, lastKnownGoodEndpoint *string,
|
||||
clientSet *kubernetes.Clientset) (*v1.Service, bool) {
|
||||
// Set the service accordingly
|
||||
if service.Annotations[kubevip.Egress] == "true" {
|
||||
if *lastKnownGoodEndpoint != "" {
|
||||
ip := net.ParseIP(*lastKnownGoodEndpoint)
|
||||
expectIPv6 := service.Annotations[kubevip.EgressIPv6] == "true"
|
||||
if ip == nil || (ip.To4() == nil) != expectIPv6 {
|
||||
log.Warn("ignoring active endpoint with unexpected address family",
|
||||
"service", service.Name,
|
||||
"namespace", service.Namespace,
|
||||
"endpoint", *lastKnownGoodEndpoint,
|
||||
"expected_ipv6", expectIPv6)
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// Store old values from ServiceSnapshot to detect if annotation actually changed
|
||||
// We use the ServiceSnapshot instead of the service parameter because the service parameter
|
||||
// may have stale annotations if the last update failed
|
||||
var oldEndpoint, oldEndpointIPv6 string
|
||||
snapshotFound := false
|
||||
if inst != nil {
|
||||
if inst.ServiceSnapshot != nil {
|
||||
snapshotFound = true
|
||||
oldEndpoint = inst.ServiceSnapshot.Annotations[kubevip.ActiveEndpoint]
|
||||
oldEndpointIPv6 = inst.ServiceSnapshot.Annotations[kubevip.ActiveEndpointIPv6]
|
||||
}
|
||||
}
|
||||
// Empty annotations in an existing snapshot are meaningful after a zero-endpoint transition.
|
||||
if !snapshotFound {
|
||||
oldEndpoint = service.Annotations[kubevip.ActiveEndpoint]
|
||||
oldEndpointIPv6 = service.Annotations[kubevip.ActiveEndpointIPv6]
|
||||
}
|
||||
|
||||
// Determine which annotation to update based on IP version
|
||||
var endpoint, endpointIPv6 string
|
||||
if service.Annotations[kubevip.EgressIPv6] == "true" && !p.config.EnableEndpoints {
|
||||
// IPv6
|
||||
endpointIPv6 = *lastKnownGoodEndpoint
|
||||
endpoint = oldEndpoint // Preserve existing IPv4 if any
|
||||
} else {
|
||||
// IPv4
|
||||
endpoint = *lastKnownGoodEndpoint
|
||||
endpointIPv6 = oldEndpointIPv6 // Preserve existing IPv6 if any
|
||||
}
|
||||
|
||||
// Check if annotation actually changed
|
||||
annotationChanged := (oldEndpoint != endpoint) || (oldEndpointIPv6 != endpointIPv6)
|
||||
if !annotationChanged {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Persist to Kubernetes
|
||||
ctx := context.Background()
|
||||
|
||||
if err := p.provider.UpdateServiceAnnotation(ctx, endpoint, endpointIPv6, service, clientSet); err != nil {
|
||||
log.Warn("failed to update service annotation", "service", service.Name, "namespace", service.Namespace, "err", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
log.Debug("updated active endpoint annotation", "service", service.Name, "namespace", service.Namespace, "endpoint", *lastKnownGoodEndpoint)
|
||||
|
||||
svcCopy := service.DeepCopy()
|
||||
svcCopy.Annotations[kubevip.ActiveEndpoint] = endpoint
|
||||
svcCopy.Annotations[kubevip.ActiveEndpointIPv6] = endpointIPv6
|
||||
return svcCopy, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (p *Processor) startServiceHandlingIfNeeded(svcCtx *servicecontext.Context, service *v1.Service,
|
||||
inst *instance.Instance, wg *sync.WaitGroup) error {
|
||||
if p.config.EnableServicesElection {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.config.EnableARP || (p.config.EnableRoutingTable && p.config.EnableLeaderElection) {
|
||||
if !svcCtx.IsReady() {
|
||||
if inst == nil {
|
||||
return fmt.Errorf("[%s] failed to find an instance for service %s/%s", p.provider.GetLabel(), service.Namespace, service.Name)
|
||||
}
|
||||
if err := StartService(svcCtx.Ctx, service, inst, p.bgpServer, wg); err != nil {
|
||||
return fmt.Errorf("start service datapath: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) findServiceInstance(service *v1.Service) *instance.Instance {
|
||||
if p.instances == nil {
|
||||
return nil
|
||||
}
|
||||
if p.instancesMutex != nil {
|
||||
p.instancesMutex.RLock()
|
||||
defer p.instancesMutex.RUnlock()
|
||||
}
|
||||
inst := instance.FindServiceInstance(service, *p.instances)
|
||||
return inst
|
||||
}
|
||||
|
||||
func shouldAllowReconcileWithoutEndpoints(service *v1.Service) bool {
|
||||
if service == nil || service.Spec.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(service.Annotations[kubevip.AllowReconcileWithoutEndpoints], "true")
|
||||
}
|
||||
|
||||
func hasV6(endpoints []string) bool {
|
||||
for _, e := range endpoints {
|
||||
ip := net.ParseIP(e)
|
||||
if ip != nil {
|
||||
if ip.To4() == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getEndpoint(endpoints []string, family string) string {
|
||||
for _, e := range endpoints {
|
||||
ip := net.ParseIP(e)
|
||||
if family == utils.IPv4Family && ip.To4() != nil {
|
||||
return e
|
||||
}
|
||||
if family == utils.IPv6Family && ip.To4() == nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
101
pkg/endpoints/endpoints_bgp.go
Normal file
101
pkg/endpoints/endpoints_bgp.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"context"
|
||||
log "log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
type BGP struct {
|
||||
generic
|
||||
bgpServer *bgp.Server
|
||||
}
|
||||
|
||||
func newBGP(generic generic, bgpServer *bgp.Server) endpointWorker {
|
||||
return &BGP{
|
||||
generic: generic,
|
||||
bgpServer: bgpServer,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BGP) processInstance(ctx context.Context, configuredNetworks *sync.Map, service *v1.Service, inst *instance.Instance) error {
|
||||
if inst != nil {
|
||||
for _, cluster := range inst.Clusters {
|
||||
for i := range cluster.Network {
|
||||
if _, configured := configuredNetworks.Load(cluster.Network[i].IP()); !configured {
|
||||
log.Debug("attempting to advertise BGP service", "provider", b.provider.GetLabel(), "ip", cluster.Network[i].IP())
|
||||
err := b.bgpServer.AddHost(ctx, cluster.Network[i].CIDR(), lease.ServiceNamespacedName(service))
|
||||
if err != nil {
|
||||
log.Error("error adding BGP host", "provider", b.provider.GetLabel(), "err", err)
|
||||
} else {
|
||||
log.Info("added BGP host", "provider",
|
||||
b.provider.GetLabel(), "ip", cluster.Network[i].CIDR(), "service name", service.Name, "namespace", service.Namespace)
|
||||
configuredNetworks.Store(cluster.Network[i].IP(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BGP) clear(ctx context.Context, configuredNetworks *sync.Map, lastKnownGoodEndpoint *string, service *v1.Service, inst *instance.Instance) {
|
||||
if !b.config.EnableServicesElection && !b.config.EnableLeaderElection {
|
||||
// If BGP mode is enabled - routes should be deleted
|
||||
if inst != nil {
|
||||
for _, cluster := range inst.Clusters {
|
||||
for i := range cluster.Network {
|
||||
err := b.bgpServer.DelHost(ctx, cluster.Network[i].CIDR(), lease.ServiceNamespacedName(service))
|
||||
if err != nil {
|
||||
log.Error("deleting BGP host", "provider", b.provider.GetLabel(), "ip", cluster.Network[i].IP(), "err", err)
|
||||
} else {
|
||||
log.Info("deleted BGP host", "provider",
|
||||
b.provider.GetLabel(), "ip", cluster.Network[i].IP(), "service name", service.Name, "namespace", service.Namespace)
|
||||
configuredNetworks.Delete(cluster.Network[i].IP())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
b.clearEgress(lastKnownGoodEndpoint, service)
|
||||
}
|
||||
|
||||
func (b *BGP) getEndpoints(service *v1.Service, id string) ([]string, error) {
|
||||
return b.getAllEndpoints(service, id)
|
||||
}
|
||||
|
||||
func (b *BGP) setInstanceEndpointsStatus(_ *v1.Service, _ *instance.Instance, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearBGPHosts(ctx context.Context, service *v1.Service, instances *[]*instance.Instance, bgpServer *bgp.Server) {
|
||||
if instance := instance.FindServiceInstance(service, *instances); instance != nil {
|
||||
ClearBGPHostsByInstance(ctx, instance, bgpServer)
|
||||
}
|
||||
}
|
||||
|
||||
func ClearBGPHostsByInstance(ctx context.Context, instance *instance.Instance, bgpServer *bgp.Server) {
|
||||
if instance == nil {
|
||||
log.Error("failed to clear BGP host for nil instance")
|
||||
return
|
||||
}
|
||||
for _, cluster := range instance.Clusters {
|
||||
for i := range cluster.Network {
|
||||
network := cluster.Network[i]
|
||||
err := bgpServer.DelHost(ctx, network.CIDR(), lease.ServiceNamespacedName(instance.ServiceSnapshot))
|
||||
if err != nil {
|
||||
log.Error("[endpoint] error deleting BGP host", "err", err)
|
||||
} else {
|
||||
log.Debug("[endpoint] deleted BGP host", "ip",
|
||||
network.CIDR(), "service name", instance.ServiceSnapshot.Name, "namespace", instance.ServiceSnapshot.Namespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
pkg/endpoints/endpoints_generic.go
Normal file
104
pkg/endpoints/endpoints_generic.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
log "log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/egress"
|
||||
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
"github.com/kube-vip/kube-vip/pkg/wireguard"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
type endpointWorker interface {
|
||||
processInstance(ctx context.Context, configuredNetworks *sync.Map, service *v1.Service, inst *instance.Instance) error
|
||||
clear(ctx context.Context, configuredNetworks *sync.Map, lastKnownGoodEndpoint *string, service *v1.Service, inst *instance.Instance)
|
||||
getEndpoints(service *v1.Service, id string) ([]string, error)
|
||||
removeEgress(service *v1.Service, lastKnownGoodEndpoint *string)
|
||||
setInstanceEndpointsStatus(service *v1.Service, inst *instance.Instance, endpoints []string) error
|
||||
}
|
||||
|
||||
func newEndpointWorker(config *kubevip.Config, provider providers.Provider, bgpServer *bgp.Server,
|
||||
leaseMgr *lease.Manager, tunnelMgr *wireguard.TunnelManager, routeMgr *route.Manager) endpointWorker {
|
||||
generic := newGeneric(config, provider, leaseMgr)
|
||||
|
||||
if config.EnableWireguard {
|
||||
return newWireguardWorker(config, provider, tunnelMgr)
|
||||
}
|
||||
if config.EnableRoutingTable {
|
||||
return newRoutingTable(generic, routeMgr)
|
||||
}
|
||||
if config.EnableBGP {
|
||||
return newBGP(generic, bgpServer)
|
||||
}
|
||||
|
||||
return &generic
|
||||
}
|
||||
|
||||
type generic struct {
|
||||
config *kubevip.Config
|
||||
provider providers.Provider
|
||||
leaseMgr *lease.Manager
|
||||
}
|
||||
|
||||
func newGeneric(config *kubevip.Config, provider providers.Provider, leaseMgr *lease.Manager) generic {
|
||||
return generic{
|
||||
config: config,
|
||||
provider: provider,
|
||||
leaseMgr: leaseMgr,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *generic) processInstance(_ context.Context, _ *sync.Map, _ *v1.Service, _ *instance.Instance) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *generic) clear(_ context.Context, _ *sync.Map, lastKnownGoodEndpoint *string, service *v1.Service, _ *instance.Instance) {
|
||||
g.clearEgress(lastKnownGoodEndpoint, service)
|
||||
}
|
||||
|
||||
func (g *generic) clearEgress(lastKnownGoodEndpoint *string, service *v1.Service) {
|
||||
if *lastKnownGoodEndpoint != "" {
|
||||
log.Warn("existing endpoint has been removed, no remaining endpoints for leaderElection", "provider", g.provider.GetLabel(), "endpoint", lastKnownGoodEndpoint)
|
||||
if err := egress.Teardown(*lastKnownGoodEndpoint, service.Spec.LoadBalancerIP, service.Namespace, string(service.UID), service.Annotations, g.config.EgressWithNftables); err != nil {
|
||||
log.Error("error removing redundant egress rules", "err", err)
|
||||
}
|
||||
|
||||
*lastKnownGoodEndpoint = "" // reset endpoint
|
||||
}
|
||||
}
|
||||
|
||||
func (g *generic) getEndpoints(service *v1.Service, id string) ([]string, error) {
|
||||
return g.getAllEndpoints(service, id)
|
||||
}
|
||||
|
||||
func (g *generic) getAllEndpoints(service *v1.Service, id string) ([]string, error) {
|
||||
// Build endpoints
|
||||
var err error
|
||||
var endpoints []string
|
||||
if service.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeCluster {
|
||||
if endpoints, err = g.provider.GetAllEndpoints(); err != nil {
|
||||
return nil, fmt.Errorf("[%s] error getting all endpoints: %w", g.provider.GetLabel(), err)
|
||||
}
|
||||
} else {
|
||||
if endpoints, err = g.provider.GetLocalEndpoints(id, g.config); err != nil {
|
||||
return nil, fmt.Errorf("[%s] error getting local endpoints: %w", g.provider.GetLabel(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
func (g *generic) removeEgress(_ *v1.Service, _ *string) {
|
||||
}
|
||||
|
||||
func (g *generic) setInstanceEndpointsStatus(_ *v1.Service, _ *instance.Instance, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
129
pkg/endpoints/endpoints_routing_table.go
Normal file
129
pkg/endpoints/endpoints_routing_table.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/egress"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
type RoutingTable struct {
|
||||
generic
|
||||
routeMgr *route.Manager
|
||||
}
|
||||
|
||||
func newRoutingTable(generic generic, routeMgr *route.Manager) endpointWorker {
|
||||
return &RoutingTable{
|
||||
generic: generic,
|
||||
routeMgr: routeMgr,
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *RoutingTable) processInstance(_ context.Context, configuredNetworks *sync.Map, service *v1.Service, inst *instance.Instance) error {
|
||||
if inst != nil {
|
||||
for _, cluster := range inst.Clusters {
|
||||
for i := range cluster.Network {
|
||||
if _, configured := configuredNetworks.Load(cluster.Network[i].IP()); !configured && cluster.Network[i].HasEndpoints() {
|
||||
if err := rt.routeMgr.Add(lease.ServiceNamespacedName(service), cluster.Network[i], false, true); err != nil {
|
||||
return fmt.Errorf("[%s] error adding route: %s", rt.provider.GetLabel(), err.Error())
|
||||
} else {
|
||||
log.Info("added route", "provider",
|
||||
rt.provider.GetLabel(), "ip", cluster.Network[i].IP(), "service name", service.Name, "namespace",
|
||||
service.Namespace, "interface", cluster.Network[i].Interface(), "tableID", rt.config.RoutingTableID)
|
||||
configuredNetworks.Store(cluster.Network[i].IP(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rt *RoutingTable) clear(_ context.Context, configuredNetworks *sync.Map, lastKnownGoodEndpoint *string, service *v1.Service, inst *instance.Instance) {
|
||||
if !rt.config.EnableServicesElection {
|
||||
if errs := ClearRoutesByInstance(service, inst, nil, rt.routeMgr); len(errs) == 0 {
|
||||
configuredNetworks.Clear()
|
||||
} else {
|
||||
for _, err := range errs {
|
||||
log.Error("error while clearing routes", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rt.clearEgress(lastKnownGoodEndpoint, service)
|
||||
}
|
||||
|
||||
func (rt *RoutingTable) getEndpoints(service *v1.Service, id string) ([]string, error) {
|
||||
return rt.getAllEndpoints(service, id)
|
||||
}
|
||||
|
||||
func (rt *RoutingTable) removeEgress(service *v1.Service, lastKnownGoodEndpoint *string) {
|
||||
if err := egress.Teardown(*lastKnownGoodEndpoint, service.Spec.LoadBalancerIP,
|
||||
service.Namespace, string(service.UID), service.Annotations, rt.config.EgressWithNftables); err != nil {
|
||||
log.Warn("removing redundant egress rules", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *RoutingTable) setInstanceEndpointsStatus(service *v1.Service, inst *instance.Instance, endpoints []string) error {
|
||||
if inst == nil {
|
||||
log.Error("failed to find the instance", "namespace", service.Namespace, "name", service.Name, "uid", service.UID, "provider", rt.provider.GetLabel())
|
||||
} else {
|
||||
for _, c := range inst.Clusters {
|
||||
for n := range c.Network {
|
||||
// if there are no endpoints set HasEndpoints false just in case
|
||||
if len(endpoints) < 1 {
|
||||
c.Network[n].SetHasEndpoints(false)
|
||||
} else {
|
||||
// check if endpoint are available and are of same IP family as service
|
||||
for _, ep := range endpoints {
|
||||
if (net.ParseIP(c.Network[n].IP()).To4() == nil) == (net.ParseIP(ep).To4() == nil) {
|
||||
c.Network[n].SetHasEndpoints(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearRoutes(service *v1.Service, instances *[]*instance.Instance, routeMgr *route.Manager) []error {
|
||||
errs := []error{}
|
||||
if svcInst := instance.FindServiceInstance(service, *instances); svcInst != nil {
|
||||
clearErrs := ClearRoutesByInstance(service, svcInst, instances, routeMgr)
|
||||
errs = append(errs, clearErrs...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func ClearRoutesByInstance(service *v1.Service, svcInst *instance.Instance, instances *[]*instance.Instance, routeMgr *route.Manager) []error {
|
||||
if svcInst == nil {
|
||||
return []error{fmt.Errorf("failed to remove routes for nil instance of service %s/%s, uid: %s", service.Namespace, service.Name, service.UID)}
|
||||
}
|
||||
errs := []error{}
|
||||
for _, cluster := range svcInst.Clusters {
|
||||
for i := range cluster.Network {
|
||||
err := routeMgr.Delete(lease.ServiceNamespacedName(service), cluster.Network[i])
|
||||
if err != nil {
|
||||
log.Error("failed to delete route", "ip", cluster.Network[i].IP(), "err", err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
log.Debug("deleted route", "ip",
|
||||
cluster.Network[i].IP(), "service name", service.Name, "namespace", service.Namespace, "interface", cluster.Network[i].Interface())
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
579
pkg/endpoints/endpoints_test.go
Normal file
579
pkg/endpoints/endpoints_test.go
Normal file
@@ -0,0 +1,579 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/cluster"
|
||||
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/lease"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
"github.com/kube-vip/kube-vip/pkg/servicecontext"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
func TestShouldAllowReconcileWithoutEndpoints(t *testing.T) {
|
||||
if shouldAllowReconcileWithoutEndpoints(nil) {
|
||||
t.Fatal("nil service should not be allowed")
|
||||
}
|
||||
|
||||
clusterOptIn := &v1.Service{
|
||||
Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster},
|
||||
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{kubevip.AllowReconcileWithoutEndpoints: "true"}},
|
||||
}
|
||||
if !shouldAllowReconcileWithoutEndpoints(clusterOptIn) {
|
||||
t.Fatal("cluster service with opt-in annotation should be allowed")
|
||||
}
|
||||
|
||||
localOptIn := &v1.Service{
|
||||
Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal},
|
||||
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{kubevip.AllowReconcileWithoutEndpoints: "true"}},
|
||||
}
|
||||
if shouldAllowReconcileWithoutEndpoints(localOptIn) {
|
||||
t.Fatal("local service should not be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWorker struct {
|
||||
endpoints []string
|
||||
clearCalled bool
|
||||
processCalled bool
|
||||
processHook func()
|
||||
}
|
||||
|
||||
type annotationUpdate struct {
|
||||
endpoint string
|
||||
endpointIPv6 string
|
||||
}
|
||||
|
||||
type recordingProvider struct {
|
||||
providers.Provider
|
||||
updates []annotationUpdate
|
||||
}
|
||||
|
||||
func (p *recordingProvider) UpdateServiceAnnotation(_ context.Context, endpoint, endpointIPv6 string,
|
||||
_ *v1.Service, _ *kubernetes.Clientset) error {
|
||||
p.updates = append(p.updates, annotationUpdate{endpoint: endpoint, endpointIPv6: endpointIPv6})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpdateAnnotationsZeroEndpointsThenSameEndpoint(t *testing.T) {
|
||||
for _, enableEndpoints := range []bool{true, false} {
|
||||
providerName := "EndpointSlices"
|
||||
provider := providers.NewEndpointslices()
|
||||
if enableEndpoints {
|
||||
providerName = "Endpoints"
|
||||
provider = providers.NewEndpoints()
|
||||
}
|
||||
|
||||
for _, family := range []struct {
|
||||
name string
|
||||
endpoint string
|
||||
other string
|
||||
egressIPv6 bool
|
||||
}{
|
||||
{name: "IPv4", endpoint: "10.0.0.1", other: "fd00::1"},
|
||||
{name: "IPv6", endpoint: "fd00::1", other: "10.0.0.1", egressIPv6: true},
|
||||
} {
|
||||
t.Run(providerName+"/"+family.name, func(t *testing.T) {
|
||||
annotations := map[string]string{kubevip.Egress: "true"}
|
||||
if family.egressIPv6 {
|
||||
annotations[kubevip.EgressIPv6] = "true"
|
||||
}
|
||||
if !enableEndpoints {
|
||||
if family.egressIPv6 {
|
||||
annotations[kubevip.ActiveEndpoint] = family.other
|
||||
annotations[kubevip.ActiveEndpointIPv6] = family.endpoint
|
||||
} else {
|
||||
annotations[kubevip.ActiveEndpoint] = family.endpoint
|
||||
annotations[kubevip.ActiveEndpointIPv6] = family.other
|
||||
}
|
||||
} else {
|
||||
annotations[kubevip.ActiveEndpoint] = family.endpoint
|
||||
}
|
||||
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-service", Namespace: "default", UID: "test-uid", Annotations: annotations,
|
||||
}}
|
||||
serviceInstance := &instance.Instance{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy()}
|
||||
instances := []*instance.Instance{serviceInstance}
|
||||
recorder := &recordingProvider{Provider: provider}
|
||||
processor := &Processor{
|
||||
config: &kubevip.Config{EnableEndpoints: enableEndpoints},
|
||||
provider: recorder,
|
||||
instances: &instances,
|
||||
}
|
||||
|
||||
noEndpoint := ""
|
||||
updated, changed := processor.updateAnnotations(service, serviceInstance, &noEndpoint, nil)
|
||||
if changed {
|
||||
serviceInstance.ServiceSnapshot = updated
|
||||
}
|
||||
repopulatedEndpoint := family.endpoint
|
||||
updated, changed = processor.updateAnnotations(service, serviceInstance, &repopulatedEndpoint, nil)
|
||||
if changed {
|
||||
serviceInstance.ServiceSnapshot = updated
|
||||
}
|
||||
|
||||
cleared := annotationUpdate{}
|
||||
repopulated := annotationUpdate{endpoint: family.endpoint}
|
||||
if !enableEndpoints {
|
||||
if family.egressIPv6 {
|
||||
cleared = annotationUpdate{endpoint: family.other}
|
||||
repopulated = annotationUpdate{endpoint: family.other, endpointIPv6: family.endpoint}
|
||||
} else {
|
||||
cleared = annotationUpdate{endpointIPv6: family.other}
|
||||
repopulated = annotationUpdate{endpoint: family.endpoint, endpointIPv6: family.other}
|
||||
}
|
||||
}
|
||||
want := []annotationUpdate{cleared, repopulated}
|
||||
if len(recorder.updates) != len(want) {
|
||||
t.Fatalf("annotation updates = %+v, want %+v", recorder.updates, want)
|
||||
}
|
||||
for index := range want {
|
||||
if recorder.updates[index] != want[index] {
|
||||
t.Errorf("annotation update %d = %+v, want %+v", index, recorder.updates[index], want[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAnnotationsEndpointSlicesClearsConfiguredFamily(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
egressIPv6 bool
|
||||
want annotationUpdate
|
||||
}{
|
||||
{name: "IPv4", want: annotationUpdate{endpointIPv6: "fd00::1"}},
|
||||
{name: "IPv6", egressIPv6: true, want: annotationUpdate{endpoint: "10.0.0.1"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
annotations := map[string]string{
|
||||
kubevip.Egress: "true",
|
||||
kubevip.ActiveEndpoint: "10.0.0.1",
|
||||
kubevip.ActiveEndpointIPv6: "fd00::1",
|
||||
}
|
||||
if test.egressIPv6 {
|
||||
annotations[kubevip.EgressIPv6] = "true"
|
||||
}
|
||||
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-service", Namespace: "default", UID: "test-uid", Annotations: annotations,
|
||||
}}
|
||||
instances := []*instance.Instance{{ServiceUID: service.UID, ServiceSnapshot: service.DeepCopy()}}
|
||||
recorder := &recordingProvider{Provider: providers.NewEndpointslices()}
|
||||
processor := &Processor{
|
||||
config: &kubevip.Config{EnableEndpoints: false},
|
||||
provider: recorder,
|
||||
instances: &instances,
|
||||
}
|
||||
|
||||
noEndpoint := ""
|
||||
processor.updateAnnotations(service, instances[0], &noEndpoint, nil)
|
||||
|
||||
if len(recorder.updates) != 1 || recorder.updates[0] != test.want {
|
||||
t.Fatalf("annotation updates = %+v, want [%+v]", recorder.updates, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAnnotationsValidatesEndpointFamily(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
egressIPv6 bool
|
||||
want annotationUpdate
|
||||
wantUpdate bool
|
||||
}{
|
||||
{name: "invalid address", endpoint: "not-an-ip"},
|
||||
{name: "IPv6 endpoint for IPv4 egress", endpoint: "fd00::1"},
|
||||
{name: "IPv4 endpoint for IPv6 egress", endpoint: "10.0.0.1", egressIPv6: true},
|
||||
{name: "IPv4 endpoint", endpoint: "10.0.0.2", want: annotationUpdate{endpoint: "10.0.0.2", endpointIPv6: "fd00::1"}, wantUpdate: true},
|
||||
{name: "IPv6 endpoint", endpoint: "fd00::2", egressIPv6: true, want: annotationUpdate{endpoint: "10.0.0.1", endpointIPv6: "fd00::2"}, wantUpdate: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
annotations := map[string]string{
|
||||
kubevip.Egress: "true",
|
||||
kubevip.ActiveEndpoint: "10.0.0.1",
|
||||
kubevip.ActiveEndpointIPv6: "fd00::1",
|
||||
}
|
||||
if test.egressIPv6 {
|
||||
annotations[kubevip.EgressIPv6] = "true"
|
||||
}
|
||||
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-service", Namespace: "default", Annotations: annotations,
|
||||
}}
|
||||
recorder := &recordingProvider{Provider: providers.NewEndpointslices()}
|
||||
processor := &Processor{
|
||||
config: &kubevip.Config{EnableEndpoints: false},
|
||||
provider: recorder,
|
||||
}
|
||||
|
||||
processor.updateAnnotations(service, nil, &test.endpoint, nil)
|
||||
|
||||
if !test.wantUpdate {
|
||||
if len(recorder.updates) != 0 {
|
||||
t.Fatalf("annotation updates = %+v, want none", recorder.updates)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(recorder.updates) != 1 || recorder.updates[0] != test.want {
|
||||
t.Fatalf("annotation updates = %+v, want [%+v]", recorder.updates, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeWorker) processInstance(_ context.Context, _ *sync.Map, _ *v1.Service, _ *instance.Instance) error {
|
||||
if f.processHook != nil {
|
||||
f.processHook()
|
||||
}
|
||||
f.processCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorker) clear(_ context.Context, _ *sync.Map, _ *string, _ *v1.Service, _ *instance.Instance) {
|
||||
f.clearCalled = true
|
||||
}
|
||||
|
||||
func (f *fakeWorker) getEndpoints(_ *v1.Service, _ string) ([]string, error) { return f.endpoints, nil }
|
||||
func (f *fakeWorker) removeEgress(_ *v1.Service, _ *string) {}
|
||||
func (f *fakeWorker) setInstanceEndpointsStatus(_ *v1.Service, _ *instance.Instance, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func noOpServiceLock(types.UID) func() {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// TestReconcile_RecomputesRemainingEndpoints asserts that deleting one EndpointSlice
|
||||
// reconciles against the endpoints that remain, instead of assuming the service
|
||||
// lost all of them.
|
||||
func TestReconcile_RecomputesRemainingEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remaining []string
|
||||
lastKnown string
|
||||
expectReady bool
|
||||
expectClear bool
|
||||
expectProcess bool
|
||||
expectedLastKnown string
|
||||
}{
|
||||
{
|
||||
name: "remaining endpoints keep the service up",
|
||||
remaining: []string{"10.0.0.2"},
|
||||
lastKnown: "10.0.0.2",
|
||||
expectReady: true,
|
||||
expectProcess: true,
|
||||
expectedLastKnown: "10.0.0.2",
|
||||
},
|
||||
{
|
||||
name: "stale last known endpoint moves to a survivor",
|
||||
remaining: []string{"10.0.0.2"},
|
||||
lastKnown: "10.0.0.1",
|
||||
expectReady: true,
|
||||
expectProcess: true,
|
||||
expectedLastKnown: "10.0.0.2",
|
||||
},
|
||||
{
|
||||
name: "last endpoint removed tears the service down",
|
||||
remaining: nil,
|
||||
lastKnown: "10.0.0.1",
|
||||
expectReady: false,
|
||||
expectClear: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
worker := &fakeWorker{endpoints: test.remaining}
|
||||
p := &Processor{
|
||||
config: &kubevip.Config{},
|
||||
provider: providers.NewEndpointslices(),
|
||||
worker: worker,
|
||||
lockService: noOpServiceLock,
|
||||
}
|
||||
|
||||
svcCtx := servicecontext.New(context.Background())
|
||||
svcCtx.SignalReadiness()
|
||||
|
||||
lastKnown := test.lastKnown
|
||||
restart, err := p.Reconcile(
|
||||
svcCtx,
|
||||
watch.Event{
|
||||
Type: watch.Deleted,
|
||||
Object: &discoveryv1.EndpointSlice{ObjectMeta: metav1.ObjectMeta{Name: "slice-1"}},
|
||||
},
|
||||
&lastKnown,
|
||||
&v1.Service{Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal}},
|
||||
"node-1",
|
||||
&sync.WaitGroup{},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
if restart {
|
||||
t.Fatal("Reconcile unexpectedly requested restart")
|
||||
}
|
||||
|
||||
if ready := svcCtx.IsReady(); ready != test.expectReady {
|
||||
t.Fatalf("readiness mismatch: expected %v, got %v", test.expectReady, ready)
|
||||
}
|
||||
if worker.clearCalled != test.expectClear {
|
||||
t.Fatalf("clearCalled mismatch: expected %v, got %v", test.expectClear, worker.clearCalled)
|
||||
}
|
||||
if worker.processCalled != test.expectProcess {
|
||||
t.Fatalf("processCalled mismatch: expected %v, got %v", test.expectProcess, worker.processCalled)
|
||||
}
|
||||
if test.expectedLastKnown != "" && lastKnown != test.expectedLastKnown {
|
||||
t.Fatalf("lastKnownGoodEndpoint mismatch: expected %q, got %q", test.expectedLastKnown, lastKnown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_ZeroEndpointsBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
run := func(t *testing.T, service *v1.Service, presetSignalled bool, expectReady bool, expectClear bool, expectProcess bool) {
|
||||
t.Helper()
|
||||
|
||||
worker := &fakeWorker{endpoints: []string{}}
|
||||
p := &Processor{
|
||||
config: &kubevip.Config{},
|
||||
provider: providers.NewEndpointslices(),
|
||||
worker: worker,
|
||||
lockService: noOpServiceLock,
|
||||
}
|
||||
|
||||
svcCtx := servicecontext.New(context.Background())
|
||||
if presetSignalled {
|
||||
svcCtx.SignalReadiness()
|
||||
}
|
||||
|
||||
restart, err := p.Reconcile(
|
||||
svcCtx,
|
||||
watch.Event{Type: watch.Modified, Object: &discoveryv1.EndpointSlice{}},
|
||||
new(string),
|
||||
service,
|
||||
"node-1",
|
||||
&sync.WaitGroup{},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
if restart {
|
||||
t.Fatal("Reconcile unexpectedly requested restart")
|
||||
}
|
||||
|
||||
if ready := svcCtx.IsReady(); ready != expectReady {
|
||||
t.Fatalf("readiness mismatch: expected %v, got %v", expectReady, ready)
|
||||
}
|
||||
if worker.clearCalled != expectClear {
|
||||
t.Fatalf("clearCalled mismatch: expected %v, got %v", expectClear, worker.clearCalled)
|
||||
}
|
||||
if worker.processCalled != expectProcess {
|
||||
t.Fatalf("processCalled mismatch: expected %v, got %v", expectProcess, worker.processCalled)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("cluster opt-in keeps readiness and skips clear", func(t *testing.T) {
|
||||
service := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{kubevip.AllowReconcileWithoutEndpoints: "true"}},
|
||||
Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster},
|
||||
}
|
||||
run(t, service, false, true, false, true)
|
||||
})
|
||||
|
||||
t.Run("cluster without opt-in resets and clears when pre-signalled", func(t *testing.T) {
|
||||
service := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{}},
|
||||
Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster},
|
||||
}
|
||||
run(t, service, true, false, true, false)
|
||||
})
|
||||
|
||||
t.Run("local opt-in still resets and clears when pre-signalled", func(t *testing.T) {
|
||||
service := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{kubevip.AllowReconcileWithoutEndpoints: "true"}},
|
||||
Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal},
|
||||
}
|
||||
run(t, service, true, false, true, false)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleNoEndpointsStopsGlobalRoutingTableWorkers(t *testing.T) {
|
||||
config := &kubevip.Config{EnableRoutingTable: true, KubernetesLeaderElection: kubevip.KubernetesLeaderElection{EnableLeaderElection: true}}
|
||||
serviceCluster, err := cluster.InitCluster(&kubevip.Config{}, true, nil, nil, route.NewManager(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("initializing Service cluster: %v", err)
|
||||
}
|
||||
var workers sync.WaitGroup
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
|
||||
t.Fatalf("starting Service cluster: %v", err)
|
||||
}
|
||||
instance := &instance.Instance{Clusters: []*cluster.Cluster{serviceCluster}}
|
||||
processor := &Processor{config: config, worker: &fakeWorker{}}
|
||||
|
||||
processor.handleNoEndpoints(servicecontext.New(context.Background()), &v1.Service{}, instance, new(string))
|
||||
if err := serviceCluster.StartLoadBalancerService(context.Background(), config, nil, "service", &workers); err != nil {
|
||||
t.Fatalf("starting Service cluster after endpoint loss: %v", err)
|
||||
}
|
||||
serviceCluster.StopAndWait()
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func TestReconcileIPv6EgressWithoutIPv6EndpointsClearsReadiness(t *testing.T) {
|
||||
worker := &fakeWorker{endpoints: []string{"192.0.2.10"}}
|
||||
processor := &Processor{
|
||||
config: &kubevip.Config{},
|
||||
provider: providers.NewEndpointslices(),
|
||||
worker: worker,
|
||||
lockService: noOpServiceLock,
|
||||
}
|
||||
service := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{kubevip.EgressIPv6: "true"}},
|
||||
Spec: v1.ServiceSpec{ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal},
|
||||
}
|
||||
svcCtx := servicecontext.New(context.Background())
|
||||
svcCtx.SignalReadiness()
|
||||
|
||||
restart, err := processor.Reconcile(svcCtx, watch.Event{Type: watch.Modified, Object: &discoveryv1.EndpointSlice{}},
|
||||
new(string), service, "node", &sync.WaitGroup{}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile() error = %v", err)
|
||||
}
|
||||
if restart {
|
||||
t.Fatal("Reconcile requested a restart for an unusable endpoint family")
|
||||
}
|
||||
if svcCtx.IsReady() {
|
||||
t.Fatal("IPv6 egress remained ready with only IPv4 endpoints")
|
||||
}
|
||||
if !worker.clearCalled {
|
||||
t.Fatal("IPv6 egress did not clear the worker with only IPv4 endpoints")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedServiceVIPRequiresClusterPolicyAndElectionLease(t *testing.T) {
|
||||
service := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "first", Namespace: "default", Annotations: map[string]string{kubevip.ServiceLease: "shared"},
|
||||
}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10", ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster}}
|
||||
candidate := service.DeepCopy()
|
||||
candidate.Name = "second"
|
||||
first := &instance.Instance{ServiceSnapshot: service}
|
||||
second := &instance.Instance{ServiceSnapshot: candidate}
|
||||
|
||||
if len(sharedServiceVIPs(&kubevip.Config{EnableServicesElection: true}, first, []*instance.Instance{second})) == 0 {
|
||||
t.Fatal("shared lease Services with Cluster traffic policy did not share their VIP")
|
||||
}
|
||||
if len(sharedServiceVIPs(&kubevip.Config{}, first, []*instance.Instance{second})) == 0 {
|
||||
t.Fatal("Cluster policy Services without per-Service election did not share their VIP")
|
||||
}
|
||||
candidate.Spec.ExternalTrafficPolicy = v1.ServiceExternalTrafficPolicyTypeLocal
|
||||
if len(sharedServiceVIPs(&kubevip.Config{}, first, []*instance.Instance{second})) != 0 {
|
||||
t.Fatal("Local traffic policy Services shared a VIP")
|
||||
}
|
||||
candidate.Spec.ExternalTrafficPolicy = v1.ServiceExternalTrafficPolicyTypeCluster
|
||||
candidate.Annotations[kubevip.ServiceLease] = "different"
|
||||
if len(sharedServiceVIPs(&kubevip.Config{EnableServicesElection: true}, first, []*instance.Instance{second})) != 0 {
|
||||
t.Fatal("Services with different leases shared a VIP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedServiceVIPsReturnsOnlyOverlappingAddresses(t *testing.T) {
|
||||
first := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "first", Namespace: "default", Annotations: map[string]string{kubevip.ServiceLease: "shared"},
|
||||
}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10", ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster}}
|
||||
second := first.DeepCopy()
|
||||
second.Name = "second"
|
||||
second.Spec.LoadBalancerIP = "192.0.2.11"
|
||||
firstInstance := &instance.Instance{ServiceSnapshot: first, ServiceAddresses: []string{"192.0.2.10", "2001:db8::10"}}
|
||||
secondInstance := &instance.Instance{ServiceSnapshot: second, ServiceAddresses: []string{"192.0.2.10", "2001:db8::11"}}
|
||||
|
||||
shared := sharedServiceVIPs(&kubevip.Config{EnableServicesElection: true}, firstInstance, []*instance.Instance{secondInstance})
|
||||
if len(shared) != 1 {
|
||||
t.Fatalf("shared VIPs = %v, want exactly one", shared)
|
||||
}
|
||||
if _, found := shared["192.0.2.10"]; !found {
|
||||
t.Fatalf("shared VIPs = %v, missing overlapping IPv4 address", shared)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileServicesElectionDoesNotStartElectionLoop asserts endpoint events
|
||||
// only update readiness. The services coordinator owns the election loop.
|
||||
func TestReconcileServicesElectionDoesNotStartElectionLoop(t *testing.T) {
|
||||
config := &kubevip.Config{
|
||||
EnableServicesElection: true,
|
||||
LeaderElectionType: "kubernetes",
|
||||
}
|
||||
|
||||
service := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "default", UID: "test-uid"},
|
||||
Spec: v1.ServiceSpec{Type: v1.ServiceTypeLoadBalancer},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
leaseMgr := lease.NewManager()
|
||||
leaseNamespace, serviceLease := lease.ServiceName(service)
|
||||
svcLease := leaseMgr.Add(ctx, lease.NewID(config.LeaderElectionType, leaseNamespace, serviceLease))
|
||||
|
||||
svcCtx := servicecontext.New(svcLease.Ctx)
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
defer wg.Wait()
|
||||
defer svcCtx.Cancel()
|
||||
|
||||
p := &Processor{
|
||||
config: config,
|
||||
provider: providers.NewEndpointslices(),
|
||||
worker: &fakeWorker{endpoints: []string{"10.0.0.1"}},
|
||||
leaseMgr: leaseMgr,
|
||||
lockService: noOpServiceLock,
|
||||
}
|
||||
|
||||
// Three endpoint events, as a flapping backend pod would produce.
|
||||
for range 3 {
|
||||
restart, err := p.Reconcile(svcCtx, watch.Event{Type: watch.Modified, Object: &discoveryv1.EndpointSlice{}},
|
||||
new(string), service, "node-1", wg, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
if restart {
|
||||
t.Fatal("Reconcile unexpectedly requested restart")
|
||||
}
|
||||
}
|
||||
|
||||
generation, ready, lost, isReady := svcCtx.ReadinessState()
|
||||
if generation != 1 || !isReady {
|
||||
t.Fatalf("readiness state = generation %d, ready %t; want generation 1 ready", generation, isReady)
|
||||
}
|
||||
select {
|
||||
case <-ready:
|
||||
default:
|
||||
t.Fatal("endpoint reconciliation did not signal readiness")
|
||||
}
|
||||
select {
|
||||
case <-lost:
|
||||
t.Fatal("endpoint reconciliation unexpectedly reset readiness")
|
||||
default:
|
||||
}
|
||||
}
|
||||
253
pkg/endpoints/endpoints_wireguard.go
Normal file
253
pkg/endpoints/endpoints_wireguard.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/endpoints/providers"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/nftables"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/wireguard"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
// wireguardWorker handles endpoint changes for WireGuard-based services
|
||||
type wireguardWorker struct {
|
||||
config *kubevip.Config
|
||||
provider providers.Provider
|
||||
tunnelMgr *wireguard.TunnelManager
|
||||
}
|
||||
|
||||
func newWireguardWorker(config *kubevip.Config, provider providers.Provider, tunnelMgr *wireguard.TunnelManager) *wireguardWorker {
|
||||
return &wireguardWorker{
|
||||
config: config,
|
||||
provider: provider,
|
||||
tunnelMgr: tunnelMgr,
|
||||
}
|
||||
}
|
||||
|
||||
// processInstance updates nftables DNAT rules when endpoints change
|
||||
// This is called by the endpoint watcher when endpoints are added/modified
|
||||
func (w *wireguardWorker) processInstance(ctx context.Context, _ *sync.Map, service *v1.Service, inst *instance.Instance) error {
|
||||
log.Debug("[wireguard] processing instance for endpoint change", "service", service.Name, "namespace", service.Namespace)
|
||||
|
||||
// Get the target endpoint for this service
|
||||
// For ExternalTrafficPolicy=Local, only use local endpoints
|
||||
// For ExternalTrafficPolicy=Cluster, use all endpoints
|
||||
var endpoints []string
|
||||
var err error
|
||||
if service.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeLocal {
|
||||
endpoints, err = w.provider.GetLocalEndpoints(w.config.NodeName, w.config)
|
||||
} else {
|
||||
endpoints, err = w.provider.GetAllEndpoints()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get endpoints: %w", err)
|
||||
}
|
||||
|
||||
if len(endpoints) == 0 {
|
||||
log.Debug("[wireguard] no endpoints available", "service", service.Name)
|
||||
w.clear(ctx, nil, nil, service, inst)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get service VIPs
|
||||
serviceIPs, err := utils.FetchServiceIPs(service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service IPs: %w", err)
|
||||
}
|
||||
if len(service.Spec.Ports) != 0 {
|
||||
if err := w.ensureTunnels(service, serviceIPs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w.clearDNAT(service)
|
||||
|
||||
log.Info("[wireguard] updating DNAT rules for endpoint change",
|
||||
"service", service.Name,
|
||||
"namespace", service.Namespace,
|
||||
"endpoints", endpoints,
|
||||
"vips", serviceIPs)
|
||||
|
||||
for _, port := range service.Spec.Ports {
|
||||
if port.Protocol != v1.ProtocolTCP && port.Protocol != v1.ProtocolUDP {
|
||||
continue
|
||||
}
|
||||
targetPort := w.provider.ResolvePort(port)
|
||||
log.Info("[wireguard] resolved port", "service", service.Name, "servicePort", port.Port, "targetPort", targetPort, "targetPortName", port.TargetPort.StrVal)
|
||||
|
||||
targets := make([]nftables.DNATTarget, len(endpoints))
|
||||
for index, endpoint := range endpoints {
|
||||
targets[index] = nftables.DNATTarget{
|
||||
IP: endpoint,
|
||||
Port: uint16(targetPort), //nolint:gosec // Port range validated by Kubernetes
|
||||
}
|
||||
}
|
||||
portServiceID, _ := wireguard.ServicePortIDs(service.Namespace, service.Name, port)
|
||||
|
||||
for _, serviceIP := range serviceIPs {
|
||||
vipAddress := utils.StripCIDR(serviceIP)
|
||||
if w.tunnelMgr == nil {
|
||||
return fmt.Errorf("WireGuard tunnel manager not configured")
|
||||
}
|
||||
tunnelConfig := w.tunnelMgr.GetConfigForVIP(vipAddress)
|
||||
if tunnelConfig == nil {
|
||||
return fmt.Errorf("wireguard interface name not configured for VIP %s", vipAddress)
|
||||
}
|
||||
if err := nftables.ApplyDNAT(
|
||||
tunnelConfig.InterfaceName,
|
||||
vipAddress,
|
||||
uint16(port.Port), //nolint:gosec // Port range validated by Kubernetes
|
||||
targets,
|
||||
portServiceID,
|
||||
port.Protocol,
|
||||
service.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeLocal,
|
||||
tunnelConfig.ListenPort,
|
||||
); err != nil {
|
||||
log.Error("[wireguard] failed to update DNAT rule", "service", service.Name, "vip", vipAddress, "port", port.Port, "err", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *wireguardWorker) ensureTunnels(service *v1.Service, serviceIPs []string) error {
|
||||
if w.tunnelMgr == nil {
|
||||
return fmt.Errorf("WireGuard tunnel manager not configured")
|
||||
}
|
||||
if len(serviceIPs) == 0 {
|
||||
return fmt.Errorf("no service IPs found for service %s/%s", service.Namespace, service.Name)
|
||||
}
|
||||
var successCount int
|
||||
var lastErr error
|
||||
for _, serviceIP := range serviceIPs {
|
||||
if !w.tunnelMgr.HasConfigForVIP(serviceIP) {
|
||||
lastErr = fmt.Errorf("no WireGuard tunnel configuration found for VIP %s", serviceIP)
|
||||
continue
|
||||
}
|
||||
if err := w.tunnelMgr.AcquireTunnelForVIP(serviceIP, string(service.UID)); err != nil {
|
||||
lastErr = fmt.Errorf("bring up WireGuard tunnel for VIP %s: %w", serviceIP, err)
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
if successCount == 0 {
|
||||
return fmt.Errorf("failed to setup WireGuard tunnel for any VIP in service %s/%s: %w", service.Namespace, service.Name, lastErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clear removes DNAT rules when no endpoints are available
|
||||
func (w *wireguardWorker) clear(_ context.Context, _ *sync.Map, _ *string, service *v1.Service, _ *instance.Instance) {
|
||||
w.clearDNAT(service)
|
||||
}
|
||||
|
||||
func (w *wireguardWorker) clearDNAT(service *v1.Service) {
|
||||
log.Info("[wireguard] clearing DNAT rules (no endpoints)", "service", service.Name, "namespace", service.Namespace)
|
||||
forEachServiceDNATChain(service, func(ipv6 bool, serviceID string) {
|
||||
if err := nftables.DeleteIngressChains(ipv6, serviceID); err != nil {
|
||||
family := utils.IPv4Family
|
||||
if ipv6 {
|
||||
family = utils.IPv6Family
|
||||
}
|
||||
log.Warn("[wireguard] failed to delete DNAT chains", "family", family, "service", service.Name, "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func forEachServiceDNATChain(service *v1.Service, visit func(bool, string)) {
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
serviceIPs, _ := utils.FetchServiceIPs(service)
|
||||
familyUnknown := len(serviceIPs) == 0
|
||||
hasIPv4, hasIPv6 := familyUnknown, familyUnknown
|
||||
for _, serviceIP := range serviceIPs {
|
||||
if isIPv6Address(serviceIP) {
|
||||
hasIPv6 = true
|
||||
} else {
|
||||
hasIPv4 = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, port := range service.Spec.Ports {
|
||||
if port.Protocol != v1.ProtocolTCP && port.Protocol != v1.ProtocolUDP {
|
||||
continue
|
||||
}
|
||||
portServiceID, legacyServiceID := wireguard.ServicePortIDs(service.Namespace, service.Name, port)
|
||||
// The legacy identifier is visited so an upgrade removes chains written
|
||||
// before rule IDs carried the protocol.
|
||||
for _, serviceID := range []string{portServiceID, legacyServiceID} {
|
||||
if hasIPv4 {
|
||||
visit(false, serviceID)
|
||||
}
|
||||
if hasIPv6 {
|
||||
visit(true, serviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getEndpoints retrieves the list of endpoints for a service
|
||||
// For ExternalTrafficPolicy=Local, only local endpoints are returned
|
||||
// For ExternalTrafficPolicy=Cluster, all endpoints are returned
|
||||
func (w *wireguardWorker) getEndpoints(service *v1.Service, id string) ([]string, error) {
|
||||
var endpoints []string
|
||||
var err error
|
||||
if service.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeLocal {
|
||||
endpoints, err = w.provider.GetLocalEndpoints(id, w.config)
|
||||
} else {
|
||||
endpoints, err = w.provider.GetAllEndpoints()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[wireguard] failed to get endpoints: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("[wireguard] retrieved endpoints", "service", service.Name, "count", len(endpoints), "endpoints", endpoints)
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
// removeEgress is a no-op for WireGuard since egress is handled separately
|
||||
func (w *wireguardWorker) removeEgress(service *v1.Service, lastKnownGoodEndpoint *string) {
|
||||
// WireGuard doesn't use egress in the same way as other modes
|
||||
log.Debug("[wireguard] removeEgress called (no-op)", "service", service.Name)
|
||||
}
|
||||
|
||||
// setInstanceEndpointsStatus updates the endpoint status on the service instance
|
||||
func (w *wireguardWorker) setInstanceEndpointsStatus(service *v1.Service, inst *instance.Instance, endpoints []string) error {
|
||||
hasEndpoints := len(endpoints) > 0
|
||||
|
||||
log.Debug("[wireguard] setting instance endpoint status",
|
||||
"service", service.Name,
|
||||
"hasEndpoints", hasEndpoints,
|
||||
"endpointCount", len(endpoints))
|
||||
|
||||
if inst != nil {
|
||||
// Update the network status for all clusters
|
||||
for _, cluster := range inst.Clusters {
|
||||
for i := range cluster.Network {
|
||||
cluster.Network[i].SetHasEndpoints(hasEndpoints)
|
||||
}
|
||||
}
|
||||
log.Debug("[wireguard] updated instance endpoint status",
|
||||
"service", service.Name,
|
||||
"hasEndpoints", hasEndpoints)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug("[wireguard] instance not found for endpoint status update", "service", service.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIPv6Address(ip string) bool {
|
||||
// Strip CIDR notation if present before checking
|
||||
addr := utils.StripCIDR(ip)
|
||||
return utils.IsIPv6(addr)
|
||||
}
|
||||
41
pkg/endpoints/endpoints_wireguard_test.go
Normal file
41
pkg/endpoints/endpoints_wireguard_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
type recordingTunnelReleaser struct {
|
||||
releases []string
|
||||
}
|
||||
|
||||
func (r *recordingTunnelReleaser) ReleaseTunnelForVIP(vip, owner string) error {
|
||||
r.releases = append(r.releases, fmt.Sprintf("%s:%s", vip, owner))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWireguardClearDoesNotDereferenceNilServiceContext(t *testing.T) {
|
||||
worker := &wireguardWorker{}
|
||||
service := &v1.Service{}
|
||||
|
||||
worker.clear(context.TODO(), nil, nil, service, nil)
|
||||
}
|
||||
|
||||
func TestReleaseWireguardServiceTunnelsUsesServiceUIDOwner(t *testing.T) {
|
||||
releaser := &recordingTunnelReleaser{}
|
||||
service := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{UID: types.UID("service-uid")},
|
||||
Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10"},
|
||||
}
|
||||
|
||||
releaseWireguardServiceTunnels(releaser, service)
|
||||
|
||||
if len(releaser.releases) != 1 || releaser.releases[0] != "192.0.2.10:service-uid" {
|
||||
t.Fatalf("tunnel releases = %v, want [192.0.2.10:service-uid]", releaser.releases)
|
||||
}
|
||||
}
|
||||
158
pkg/endpoints/providers/endpoints.go
Normal file
158
pkg/endpoints/providers/endpoints.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
"k8s.io/client-go/util/retry"
|
||||
|
||||
log "log/slog"
|
||||
)
|
||||
|
||||
type Endpoints struct {
|
||||
label string
|
||||
//nolint:staticcheck // SA1019 endpoints are moving to an opt-in only
|
||||
endpoints *v1.Endpoints
|
||||
}
|
||||
|
||||
func NewEndpoints() Provider {
|
||||
return &Endpoints{
|
||||
label: "endpoints",
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *Endpoints) CreateRetryWatcher(ctx context.Context, clientSet *kubernetes.Clientset,
|
||||
service *v1.Service) (*watchtools.RetryWatcher, error) {
|
||||
opts := metav1.ListOptions{
|
||||
FieldSelector: fields.OneTermEqualSelector("metadata.name", service.Name).String(),
|
||||
}
|
||||
|
||||
rw, err := watchtools.NewRetryWatcherWithContext(ctx, "1", &cache.ListWatch{
|
||||
WatchFunc: func(_ metav1.ListOptions) (watch.Interface, error) {
|
||||
return clientSet.CoreV1().Endpoints(service.Namespace).Watch(ctx, opts)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating endpoint watcher: %s", err.Error())
|
||||
}
|
||||
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
func (ep *Endpoints) LoadObject(endpoints runtime.Object, cancel context.CancelFunc) error {
|
||||
//nolint:staticcheck // SA1019 endpoints have to be explicitly requested now
|
||||
eps, ok := endpoints.(*v1.Endpoints)
|
||||
if !ok {
|
||||
cancel()
|
||||
return fmt.Errorf("[%s] unable to parse Kubernetes services from API watcher", ep.GetLabel())
|
||||
}
|
||||
ep.endpoints = eps
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteObject drops the tracked object. A service is backed by exactly one
|
||||
// v1.Endpoints object, so there is nothing to match on and the cache is reset.
|
||||
func (ep *Endpoints) DeleteObject(endpoints runtime.Object) error {
|
||||
//nolint:staticcheck // SA1019 endpoints have to be explicitly requested now
|
||||
if _, ok := endpoints.(*v1.Endpoints); !ok {
|
||||
return fmt.Errorf("[%s] unable to parse Kubernetes object", ep.GetLabel())
|
||||
}
|
||||
//nolint:staticcheck // SA1019 endpoints have to be explicitly requested now
|
||||
ep.endpoints = &v1.Endpoints{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ep *Endpoints) GetAllEndpoints() ([]string, error) {
|
||||
result := []string{}
|
||||
for subset := range ep.endpoints.Subsets {
|
||||
for address := range ep.endpoints.Subsets[subset].Addresses {
|
||||
addr := strings.Split(ep.endpoints.Subsets[subset].Addresses[address].IP, "/")
|
||||
result = append(result, addr[0])
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ep *Endpoints) GetLocalEndpoints(id string, _ *kubevip.Config) ([]string, error) {
|
||||
var localEndpoints []string
|
||||
|
||||
for _, subset := range ep.endpoints.Subsets {
|
||||
for _, address := range subset.Addresses {
|
||||
log.Debug("processing endpoint", "label", ep.label, "ip", address.IP)
|
||||
|
||||
// 1. Compare the Nodename
|
||||
if address.NodeName != nil && id == *address.NodeName {
|
||||
log.Debug("found local endpoint", "label", ep.label, "ip", address.IP, "hostname", address.Hostname, "nodename", *address.NodeName)
|
||||
localEndpoints = append(localEndpoints, address.IP)
|
||||
continue
|
||||
}
|
||||
// 2. Compare the Hostname (only useful if address.NodeName is not available)
|
||||
if address.NodeName == nil && id == address.Hostname {
|
||||
log.Debug("found local endpoint", "label", ep.label, "ip", address.IP, "hostname", address.Hostname)
|
||||
localEndpoints = append(localEndpoints, address.IP)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return localEndpoints, nil
|
||||
}
|
||||
|
||||
func (ep *Endpoints) UpdateServiceAnnotation(ctx context.Context, endpoint string, _ string, service *v1.Service, clientSet *kubernetes.Clientset) error {
|
||||
retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
// Retrieve the latest version of Deployment before attempting update
|
||||
// RetryOnConflict uses exponential backoff to avoid exhausting the apiserver
|
||||
currentService, err := clientSet.CoreV1().Services(service.Namespace).Get(ctx, service.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentServiceCopy := currentService.DeepCopy()
|
||||
if currentServiceCopy.Annotations == nil {
|
||||
currentServiceCopy.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
currentServiceCopy.Annotations[kubevip.ActiveEndpoint] = endpoint
|
||||
|
||||
_, err = clientSet.CoreV1().Services(currentService.Namespace).Update(ctx, currentServiceCopy, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
log.Error("error updating Service Spec", "label", ep.GetLabel(), "name", currentServiceCopy.Name, "err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if retryErr != nil {
|
||||
log.Error("failed to set Services", "label", ep.GetLabel(), "err", retryErr)
|
||||
return retryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ep *Endpoints) GetLabel() string {
|
||||
return ep.label
|
||||
}
|
||||
|
||||
func (ep *Endpoints) ResolvePort(servicePort v1.ServicePort) int32 {
|
||||
return ResolvePortWithLookup(servicePort, func(name string) int32 {
|
||||
for _, subset := range ep.endpoints.Subsets {
|
||||
for _, p := range subset.Ports {
|
||||
if p.Name == name {
|
||||
return p.Port
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
179
pkg/endpoints/providers/endpointslices.go
Normal file
179
pkg/endpoints/providers/endpointslices.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
"k8s.io/client-go/util/retry"
|
||||
)
|
||||
|
||||
type Endpointslices struct {
|
||||
label string
|
||||
slices map[string]*discoveryv1.EndpointSlice
|
||||
}
|
||||
|
||||
func NewEndpointslices() Provider {
|
||||
return &Endpointslices{
|
||||
label: "endpointslices",
|
||||
slices: make(map[string]*discoveryv1.EndpointSlice),
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *Endpointslices) CreateRetryWatcher(ctx context.Context, clientSet *kubernetes.Clientset,
|
||||
service *v1.Service) (*watchtools.RetryWatcher, error) {
|
||||
labelSelector := metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/service-name": service.Name}}
|
||||
|
||||
opts := metav1.ListOptions{
|
||||
LabelSelector: labels.Set(labelSelector.MatchLabels).String(),
|
||||
}
|
||||
|
||||
rw, err := watchtools.NewRetryWatcherWithContext(ctx, "1", &cache.ListWatch{
|
||||
WatchFunc: func(_ metav1.ListOptions) (watch.Interface, error) {
|
||||
return clientSet.DiscoveryV1().EndpointSlices(service.Namespace).Watch(ctx, opts)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[%s] error creating endpointslices watcher: %s", ep.label, err.Error())
|
||||
}
|
||||
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
func (ep *Endpointslices) LoadObject(endpoints runtime.Object, cancel context.CancelFunc) error {
|
||||
eps, ok := endpoints.(*discoveryv1.EndpointSlice)
|
||||
if !ok {
|
||||
cancel()
|
||||
return fmt.Errorf("[%s] error casting endpoints to v1.Endpoints struct", ep.label)
|
||||
}
|
||||
|
||||
if ep.slices == nil {
|
||||
ep.slices = make(map[string]*discoveryv1.EndpointSlice)
|
||||
}
|
||||
ep.slices[eps.Name] = eps.DeepCopy()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ep *Endpointslices) DeleteObject(endpoints runtime.Object) error {
|
||||
eps, ok := endpoints.(*discoveryv1.EndpointSlice)
|
||||
if !ok {
|
||||
return fmt.Errorf("[%s] unable to parse Kubernetes object", ep.GetLabel())
|
||||
}
|
||||
delete(ep.slices, eps.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isServing reports whether an endpoint should receive traffic. Per the
|
||||
// EndpointConditions godoc a nil Serving defers to Ready, and a nil Ready is an
|
||||
// unknown state that consumers should interpret as ready.
|
||||
func isServing(conditions discoveryv1.EndpointConditions) bool {
|
||||
serving := conditions.Serving
|
||||
if serving == nil {
|
||||
serving = conditions.Ready
|
||||
}
|
||||
return serving == nil || *serving
|
||||
}
|
||||
|
||||
func (ep *Endpointslices) GetAllEndpoints() ([]string, error) {
|
||||
result := []string{}
|
||||
for _, eps := range ep.slices {
|
||||
for _, e := range eps.Endpoints {
|
||||
if !isServing(e.Conditions) {
|
||||
continue
|
||||
}
|
||||
result = append(result, e.Addresses...)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ep *Endpointslices) GetLocalEndpoints(id string, _ *kubevip.Config) ([]string, error) {
|
||||
var localEndpoints []string
|
||||
for _, eps := range ep.slices {
|
||||
for _, endpoint := range eps.Endpoints {
|
||||
if !isServing(endpoint.Conditions) {
|
||||
continue
|
||||
}
|
||||
for _, address := range endpoint.Addresses {
|
||||
// 1. Compare the Nodename
|
||||
if endpoint.NodeName != nil && id == *endpoint.NodeName {
|
||||
if endpoint.Hostname != nil {
|
||||
log.Debug("found endpoint", "provider", ep.label, "ip", address, "hostname", *endpoint.Hostname, "nodename", *endpoint.NodeName)
|
||||
} else {
|
||||
log.Debug("found endpoint", "provider", ep.label, "ip", address, "nodename", *endpoint.NodeName)
|
||||
}
|
||||
localEndpoints = append(localEndpoints, address)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Compare the Hostname (only useful if endpoint.NodeName is not available)
|
||||
if endpoint.NodeName == nil && endpoint.Hostname != nil && id == *endpoint.Hostname {
|
||||
log.Debug("found endpoint", "provider", ep.label, "ip", address, "hostname", *endpoint.Hostname)
|
||||
localEndpoints = append(localEndpoints, address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return localEndpoints, nil
|
||||
}
|
||||
|
||||
func (ep *Endpointslices) UpdateServiceAnnotation(ctx context.Context, endpoint, endpointIPv6 string, service *v1.Service, clientSet *kubernetes.Clientset) error {
|
||||
retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
// Retrieve the latest version of Deployment before attempting update
|
||||
// RetryOnConflict uses exponential backoff to avoid exhausting the apiserver
|
||||
currentService, err := clientSet.CoreV1().Services(service.Namespace).Get(ctx, service.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentServiceCopy := currentService.DeepCopy()
|
||||
if currentServiceCopy.Annotations == nil {
|
||||
currentServiceCopy.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
currentServiceCopy.Annotations[kubevip.ActiveEndpoint] = endpoint
|
||||
currentServiceCopy.Annotations[kubevip.ActiveEndpointIPv6] = endpointIPv6
|
||||
|
||||
_, err = clientSet.CoreV1().Services(currentService.Namespace).Update(ctx, currentServiceCopy, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
log.Error("error updating Service Spec", "provider", ep.label, "service name", currentServiceCopy.Name, "err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if retryErr != nil {
|
||||
log.Error("failed to set Services", "provider", ep.label, "err", retryErr)
|
||||
return retryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ep *Endpointslices) GetLabel() string {
|
||||
return ep.label
|
||||
}
|
||||
|
||||
func (ep *Endpointslices) ResolvePort(servicePort v1.ServicePort) int32 {
|
||||
return ResolvePortWithLookup(servicePort, func(name string) int32 {
|
||||
for _, eps := range ep.slices {
|
||||
for _, p := range eps.Ports {
|
||||
if p.Name != nil && *p.Name == name && p.Port != nil {
|
||||
return *p.Port
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
149
pkg/endpoints/providers/endpointslices_test.go
Normal file
149
pkg/endpoints/providers/endpointslices_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestEndpointslicesTracksAndDeletesSlices(t *testing.T) {
|
||||
provider := NewEndpointslices().(*Endpointslices)
|
||||
serving := true
|
||||
nodeName := "node-1"
|
||||
|
||||
slice1 := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "slice-1"},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Endpoints: []discoveryv1.Endpoint{{
|
||||
Addresses: []string{"10.0.0.1"},
|
||||
Conditions: discoveryv1.EndpointConditions{Serving: &serving},
|
||||
NodeName: &nodeName,
|
||||
}},
|
||||
}
|
||||
slice2 := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "slice-2"},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Endpoints: []discoveryv1.Endpoint{{
|
||||
Addresses: []string{"10.0.0.2"},
|
||||
Conditions: discoveryv1.EndpointConditions{Serving: &serving},
|
||||
NodeName: &nodeName,
|
||||
}},
|
||||
}
|
||||
|
||||
for _, slice := range []*discoveryv1.EndpointSlice{slice1, slice2} {
|
||||
if err := provider.LoadObject(slice, func() {}); err != nil {
|
||||
t.Fatalf("LoadObject returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
assertEndpoints(t, provider, []string{"10.0.0.1", "10.0.0.2"})
|
||||
assertLocalEndpoints(t, provider, nodeName, []string{"10.0.0.1", "10.0.0.2"})
|
||||
|
||||
if err := provider.DeleteObject(slice1); err != nil {
|
||||
t.Fatalf("DeleteObject returned error: %v", err)
|
||||
}
|
||||
assertEndpoints(t, provider, []string{"10.0.0.2"})
|
||||
assertLocalEndpoints(t, provider, nodeName, []string{"10.0.0.2"})
|
||||
|
||||
if err := provider.DeleteObject(slice2); err != nil {
|
||||
t.Fatalf("DeleteObject returned error: %v", err)
|
||||
}
|
||||
assertEndpoints(t, provider, nil)
|
||||
assertLocalEndpoints(t, provider, nodeName, nil)
|
||||
}
|
||||
|
||||
func TestEndpointslicesReplacingSliceUpdatesState(t *testing.T) {
|
||||
provider := NewEndpointslices().(*Endpointslices)
|
||||
first := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "slice-1"},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Endpoints: []discoveryv1.Endpoint{{Addresses: []string{"10.0.0.1"}}},
|
||||
}
|
||||
replacement := first.DeepCopy()
|
||||
replacement.Endpoints[0].Addresses = []string{"10.0.0.2"}
|
||||
|
||||
if err := provider.LoadObject(first, context.CancelFunc(func() {})); err != nil {
|
||||
t.Fatalf("LoadObject returned error: %v", err)
|
||||
}
|
||||
if err := provider.LoadObject(replacement, context.CancelFunc(func() {})); err != nil {
|
||||
t.Fatalf("LoadObject returned error: %v", err)
|
||||
}
|
||||
|
||||
assertEndpoints(t, provider, []string{"10.0.0.2"})
|
||||
}
|
||||
|
||||
func TestEndpointslicesEndpointConditions(t *testing.T) {
|
||||
yes, no := true, false
|
||||
nodeName := "node-1"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
conditions discoveryv1.EndpointConditions
|
||||
want []string
|
||||
}{
|
||||
{"serving true", discoveryv1.EndpointConditions{Serving: &yes}, []string{"10.0.0.1"}},
|
||||
{"serving false", discoveryv1.EndpointConditions{Serving: &no}, nil},
|
||||
{"serving false overrides ready true", discoveryv1.EndpointConditions{Serving: &no, Ready: &yes}, nil},
|
||||
{"nil serving defers to ready true", discoveryv1.EndpointConditions{Ready: &yes}, []string{"10.0.0.1"}},
|
||||
{"nil serving defers to ready false", discoveryv1.EndpointConditions{Ready: &no}, nil},
|
||||
{"both nil is treated as ready", discoveryv1.EndpointConditions{}, []string{"10.0.0.1"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
provider := NewEndpointslices().(*Endpointslices)
|
||||
slice := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "slice-1"},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Endpoints: []discoveryv1.Endpoint{{
|
||||
Addresses: []string{"10.0.0.1"},
|
||||
Conditions: test.conditions,
|
||||
NodeName: &nodeName,
|
||||
}},
|
||||
}
|
||||
if err := provider.LoadObject(slice, func() {}); err != nil {
|
||||
t.Fatalf("LoadObject returned error: %v", err)
|
||||
}
|
||||
// Cluster and Local policy have to agree on which endpoints are usable.
|
||||
assertEndpoints(t, provider, test.want)
|
||||
assertLocalEndpoints(t, provider, nodeName, test.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEndpoints(t *testing.T, provider *Endpointslices, want []string) {
|
||||
t.Helper()
|
||||
got, err := provider.GetAllEndpoints()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllEndpoints returned error: %v", err)
|
||||
}
|
||||
assertStringSet(t, got, want)
|
||||
}
|
||||
|
||||
func assertLocalEndpoints(t *testing.T, provider *Endpointslices, nodeName string, want []string) {
|
||||
t.Helper()
|
||||
got, err := provider.GetLocalEndpoints(nodeName, &kubevip.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetLocalEndpoints returned error: %v", err)
|
||||
}
|
||||
assertStringSet(t, got, want)
|
||||
}
|
||||
|
||||
func assertStringSet(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
counts := map[string]int{}
|
||||
for _, value := range got {
|
||||
counts[value]++
|
||||
}
|
||||
for _, value := range want {
|
||||
counts[value]--
|
||||
}
|
||||
for value, count := range counts {
|
||||
if count != 0 {
|
||||
t.Fatalf("endpoint set mismatch for %q: got %v, want %v", value, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
pkg/endpoints/providers/interface.go
Normal file
40
pkg/endpoints/providers/interface.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
CreateRetryWatcher(context.Context, *kubernetes.Clientset,
|
||||
*v1.Service) (*watchtools.RetryWatcher, error)
|
||||
GetAllEndpoints() ([]string, error)
|
||||
GetLocalEndpoints(string, *kubevip.Config) ([]string, error)
|
||||
GetLabel() string
|
||||
UpdateServiceAnnotation(context.Context, string, string, *v1.Service, *kubernetes.Clientset) error
|
||||
LoadObject(runtime.Object, context.CancelFunc) error
|
||||
DeleteObject(runtime.Object) error
|
||||
// ResolvePort resolves a service port to the actual target port.
|
||||
// For named ports, it looks up the port number from the endpoint.
|
||||
// For numeric ports, it returns the port as-is.
|
||||
ResolvePort(servicePort v1.ServicePort) int32
|
||||
}
|
||||
|
||||
// ResolvePortWithLookup is a helper that resolves a service port using a lookup function
|
||||
// for named ports. This consolidates the common resolution logic.
|
||||
func ResolvePortWithLookup(servicePort v1.ServicePort, lookupNamedPort func(string) int32) int32 {
|
||||
if servicePort.TargetPort.IntVal != 0 {
|
||||
return servicePort.TargetPort.IntVal
|
||||
}
|
||||
if servicePort.TargetPort.StrVal != "" {
|
||||
if port := lookupNamedPort(servicePort.TargetPort.StrVal); port != 0 {
|
||||
return port
|
||||
}
|
||||
}
|
||||
return servicePort.Port
|
||||
}
|
||||
319
pkg/endpoints/providers/providers_test.go
Normal file
319
pkg/endpoints/providers/providers_test.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func TestEndpointProvidersParityForLocalAndAllEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeA := "node-a"
|
||||
nodeB := "node-b"
|
||||
serving := true
|
||||
v4Addresses := []discoveryv1.Endpoint{
|
||||
{Addresses: []string{"10.0.0.1"}, NodeName: &nodeA, Conditions: discoveryv1.EndpointConditions{Serving: &serving}},
|
||||
{Addresses: []string{"10.0.0.2"}, NodeName: &nodeB, Conditions: discoveryv1.EndpointConditions{Serving: &serving}},
|
||||
}
|
||||
v6Addresses := []discoveryv1.Endpoint{
|
||||
{Addresses: []string{"2001:db8::1"}, NodeName: &nodeA, Conditions: discoveryv1.EndpointConditions{Serving: &serving}},
|
||||
{Addresses: []string{"2001:db8::2"}, NodeName: &nodeB, Conditions: discoveryv1.EndpointConditions{Serving: &serving}},
|
||||
}
|
||||
|
||||
legacy := NewEndpoints()
|
||||
//nolint:staticcheck // this test covers the deprecated legacy Endpoints provider on purpose
|
||||
if err := legacy.LoadObject(&v1.Endpoints{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "service", Namespace: "default"},
|
||||
//nolint:staticcheck // deprecated legacy Endpoints API is the subject under test
|
||||
Subsets: []v1.EndpointSubset{{
|
||||
Addresses: []v1.EndpointAddress{
|
||||
{IP: "10.0.0.1", NodeName: &nodeA},
|
||||
{IP: "10.0.0.2", NodeName: &nodeB},
|
||||
{IP: "2001:db8::1", NodeName: &nodeA},
|
||||
{IP: "2001:db8::2", NodeName: &nodeB},
|
||||
},
|
||||
}},
|
||||
}, func() {}); err != nil {
|
||||
t.Fatalf("loading legacy Endpoints: %v", err)
|
||||
}
|
||||
|
||||
slices := NewEndpointslices()
|
||||
if err := slices.LoadObject(&discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "service-v4", Namespace: "default"},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Endpoints: v4Addresses,
|
||||
}, func() {}); err != nil {
|
||||
t.Fatalf("loading IPv4 EndpointSlice: %v", err)
|
||||
}
|
||||
if err := slices.LoadObject(&discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "service-v6", Namespace: "default"},
|
||||
AddressType: discoveryv1.AddressTypeIPv6,
|
||||
Endpoints: v6Addresses,
|
||||
}, func() {}); err != nil {
|
||||
t.Fatalf("loading IPv6 EndpointSlice: %v", err)
|
||||
}
|
||||
|
||||
legacyAll, err := legacy.GetAllEndpoints()
|
||||
if err != nil {
|
||||
t.Fatalf("legacy GetAllEndpoints() error = %v", err)
|
||||
}
|
||||
sliceAll, err := slices.GetAllEndpoints()
|
||||
if err != nil {
|
||||
t.Fatalf("EndpointSlice GetAllEndpoints() error = %v", err)
|
||||
}
|
||||
wantAll := endpointSet([]string{"10.0.0.1", "10.0.0.2", "2001:db8::1", "2001:db8::2"})
|
||||
if got := endpointSet(legacyAll); !reflect.DeepEqual(got, wantAll) {
|
||||
t.Errorf("legacy all endpoints = %v, want %v", got, wantAll)
|
||||
}
|
||||
if got := endpointSet(sliceAll); !reflect.DeepEqual(got, wantAll) {
|
||||
t.Errorf("EndpointSlice all endpoints = %v, want %v", got, wantAll)
|
||||
}
|
||||
|
||||
legacyLocal, err := legacy.GetLocalEndpoints(nodeA, &kubevip.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("legacy GetLocalEndpoints() error = %v", err)
|
||||
}
|
||||
sliceLocal, err := slices.GetLocalEndpoints(nodeA, &kubevip.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("EndpointSlice GetLocalEndpoints() error = %v", err)
|
||||
}
|
||||
wantLocal := endpointSet([]string{"10.0.0.1", "2001:db8::1"})
|
||||
if got := endpointSet(legacyLocal); !reflect.DeepEqual(got, wantLocal) {
|
||||
t.Errorf("legacy local endpoints = %v, want %v", got, wantLocal)
|
||||
}
|
||||
if got := endpointSet(sliceLocal); !reflect.DeepEqual(got, wantLocal) {
|
||||
t.Errorf("EndpointSlice local endpoints = %v, want %v", got, wantLocal)
|
||||
}
|
||||
|
||||
assertEndpointFamilies(t, legacyAll, 2, 2)
|
||||
assertEndpointFamilies(t, sliceAll, 2, 2)
|
||||
}
|
||||
|
||||
func TestEndpointSlicesLocalFilteringRequiresServingEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node := "node-a"
|
||||
serving := true
|
||||
notServing := false
|
||||
provider := NewEndpointslices()
|
||||
if err := provider.LoadObject(&discoveryv1.EndpointSlice{
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Endpoints: []discoveryv1.Endpoint{
|
||||
{Addresses: []string{"10.0.0.1"}, NodeName: &node, Conditions: discoveryv1.EndpointConditions{Serving: &serving}},
|
||||
{Addresses: []string{"10.0.0.2"}, NodeName: &node, Conditions: discoveryv1.EndpointConditions{Serving: ¬Serving}},
|
||||
},
|
||||
}, func() {}); err != nil {
|
||||
t.Fatalf("loading EndpointSlice: %v", err)
|
||||
}
|
||||
|
||||
local, err := provider.GetLocalEndpoints(node, &kubevip.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetLocalEndpoints() error = %v", err)
|
||||
}
|
||||
if got, want := endpointSet(local), endpointSet([]string{"10.0.0.1"}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("local endpoints = %v, want serving endpoints %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePortFromFakeClientObjects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:staticcheck // deprecated legacy Endpoints API is the subject under test
|
||||
legacyObject := &v1.Endpoints{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "service", Namespace: "default"},
|
||||
//nolint:staticcheck // deprecated legacy Endpoints API is the subject under test
|
||||
Subsets: []v1.EndpointSubset{{
|
||||
Ports: []v1.EndpointPort{{Name: "web", Port: 8080}},
|
||||
}},
|
||||
}
|
||||
legacyClient := fake.NewSimpleClientset(legacyObject)
|
||||
legacyLoaded, err := legacyClient.CoreV1().Endpoints("default").Get(t.Context(), "service", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("getting fake legacy Endpoints: %v", err)
|
||||
}
|
||||
legacy := NewEndpoints()
|
||||
if err := legacy.LoadObject(legacyLoaded, func() {}); err != nil {
|
||||
t.Fatalf("loading fake legacy Endpoints: %v", err)
|
||||
}
|
||||
|
||||
portName := "web"
|
||||
port := int32(8081)
|
||||
sliceObject := &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "service-slice", Namespace: "default"},
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Ports: []discoveryv1.EndpointPort{{Name: &portName, Port: &port}},
|
||||
}
|
||||
sliceClient := fake.NewSimpleClientset(sliceObject)
|
||||
sliceLoaded, err := sliceClient.DiscoveryV1().EndpointSlices("default").Get(t.Context(), "service-slice", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("getting fake EndpointSlice: %v", err)
|
||||
}
|
||||
slices := NewEndpointslices()
|
||||
if err := slices.LoadObject(sliceLoaded, func() {}); err != nil {
|
||||
t.Fatalf("loading fake EndpointSlice: %v", err)
|
||||
}
|
||||
|
||||
namedPort := v1.ServicePort{Port: 80, TargetPort: intstr.FromString("web")}
|
||||
if got := legacy.ResolvePort(namedPort); got != 8080 {
|
||||
t.Errorf("legacy ResolvePort() = %d, want 8080", got)
|
||||
}
|
||||
if got := slices.ResolvePort(namedPort); got != 8081 {
|
||||
t.Errorf("EndpointSlice ResolvePort() = %d, want 8081", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePortWithLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
port v1.ServicePort
|
||||
lookup func(string) int32
|
||||
want int32
|
||||
}{
|
||||
{
|
||||
name: "numeric target port wins",
|
||||
port: v1.ServicePort{Port: 80, TargetPort: intstr.FromInt(8080)},
|
||||
lookup: func(string) int32 { return 9090 },
|
||||
want: 8080,
|
||||
},
|
||||
{
|
||||
name: "named target port is looked up",
|
||||
port: v1.ServicePort{Port: 80, TargetPort: intstr.FromString("web")},
|
||||
lookup: func(name string) int32 {
|
||||
if name == "web" {
|
||||
return 8081
|
||||
}
|
||||
return 0
|
||||
},
|
||||
want: 8081,
|
||||
},
|
||||
{
|
||||
name: "missing named target falls back to service port",
|
||||
port: v1.ServicePort{Port: 80, TargetPort: intstr.FromString("missing")},
|
||||
lookup: func(string) int32 { return 0 },
|
||||
want: 80,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ResolvePortWithLookup(tt.port, tt.lookup); got != tt.want {
|
||||
t.Errorf("ResolvePortWithLookup() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func endpointSet(endpoints []string) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(endpoints))
|
||||
for _, endpoint := range endpoints {
|
||||
result[endpoint] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertEndpointFamilies(t *testing.T, endpoints []string, wantIPv4, wantIPv6 int) {
|
||||
t.Helper()
|
||||
ipv4, ipv6 := 0, 0
|
||||
for _, endpoint := range endpoints {
|
||||
ip := net.ParseIP(endpoint)
|
||||
if ip == nil {
|
||||
t.Errorf("endpoint %q is not an IP address", endpoint)
|
||||
continue
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
ipv4++
|
||||
} else {
|
||||
ipv6++
|
||||
}
|
||||
}
|
||||
if ipv4 != wantIPv4 || ipv6 != wantIPv6 {
|
||||
t.Errorf("endpoint families = IPv4 %d, IPv6 %d; want IPv4 %d, IPv6 %d", ipv4, ipv6, wantIPv4, wantIPv6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointProvidersPreferNodeNameOverHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeA := "node-a"
|
||||
nodeB := "node-b"
|
||||
serving := true
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
load func(Provider) error
|
||||
}{
|
||||
{
|
||||
name: "legacy Endpoints",
|
||||
load: func(provider Provider) error {
|
||||
//nolint:staticcheck // the legacy provider is deliberately under test
|
||||
return provider.LoadObject(&v1.Endpoints{
|
||||
Subsets: []v1.EndpointSubset{{
|
||||
Addresses: []v1.EndpointAddress{{
|
||||
IP: "10.0.0.1",
|
||||
NodeName: &nodeB,
|
||||
Hostname: nodeA,
|
||||
}},
|
||||
}},
|
||||
}, func() {})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EndpointSlice",
|
||||
load: func(provider Provider) error {
|
||||
hostname := nodeA
|
||||
return provider.LoadObject(&discoveryv1.EndpointSlice{
|
||||
AddressType: discoveryv1.AddressTypeIPv4,
|
||||
Endpoints: []discoveryv1.Endpoint{{
|
||||
Addresses: []string{"10.0.0.1"},
|
||||
NodeName: &nodeB,
|
||||
Hostname: &hostname,
|
||||
Conditions: discoveryv1.EndpointConditions{Serving: &serving},
|
||||
}},
|
||||
}, func() {})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var provider Provider
|
||||
if tt.name == "legacy Endpoints" {
|
||||
provider = NewEndpoints()
|
||||
} else {
|
||||
provider = NewEndpointslices()
|
||||
}
|
||||
if err := tt.load(provider); err != nil {
|
||||
t.Fatalf("LoadObject() error = %v", err)
|
||||
}
|
||||
|
||||
local, err := provider.GetLocalEndpoints(nodeA, &kubevip.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetLocalEndpoints(%q) error = %v", nodeA, err)
|
||||
}
|
||||
if len(local) != 0 {
|
||||
t.Fatalf("GetLocalEndpoints(%q) = %v, want no endpoints", nodeA, local)
|
||||
}
|
||||
|
||||
local, err = provider.GetLocalEndpoints(nodeB, &kubevip.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetLocalEndpoints(%q) error = %v", nodeB, err)
|
||||
}
|
||||
if got, want := endpointSet(local), endpointSet([]string{"10.0.0.1"}); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("GetLocalEndpoints(%q) = %v, want %v", nodeB, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package equinixmetal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/packethost/packngo"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// BGPLookup will use the Equinix Metal API functions to populate the BGP information
|
||||
func BGPLookup(c *packngo.Client, k *kubevip.Config) error {
|
||||
var thisDevice *packngo.Device
|
||||
if k.MetalProjectID == "" {
|
||||
proj := findProject(k.MetalProject, c)
|
||||
if proj == nil {
|
||||
return fmt.Errorf("Unable to find Project [%s]", k.MetalProject)
|
||||
}
|
||||
thisDevice = findSelf(c, proj.ID)
|
||||
} else {
|
||||
thisDevice = findSelf(c, k.MetalProjectID)
|
||||
}
|
||||
if thisDevice == nil {
|
||||
return fmt.Errorf("Unable to find local/this device in Equinix Metal API")
|
||||
}
|
||||
|
||||
log.Infof("Querying BGP settings for [%s]", thisDevice.Hostname)
|
||||
neighbours, _, err := c.Devices.ListBGPNeighbors(thisDevice.ID, &packngo.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Ensure neighbours exist (and it's enabled)
|
||||
if len(neighbours) == 0 {
|
||||
return fmt.Errorf("The server [%s]/[%s] has no BGP neighbours, ensure BGP is enabled", thisDevice.Hostname, thisDevice.ID)
|
||||
}
|
||||
|
||||
// Add a warning (TODO)
|
||||
if len(neighbours) > 1 {
|
||||
log.Warnf("There are [%d] neighbours, only designed to manage one", len(neighbours))
|
||||
}
|
||||
|
||||
// Ensure a peer exists
|
||||
if len(neighbours[0].PeerIps) == 0 {
|
||||
return fmt.Errorf("The server [%s]/[%s] has no BGP peers, ensure BGP is enabled", thisDevice.Hostname, thisDevice.ID)
|
||||
}
|
||||
|
||||
k.BGPConfig.RouterID = neighbours[0].CustomerIP
|
||||
k.BGPConfig.AS = uint32(neighbours[0].CustomerAs)
|
||||
|
||||
// Add the peer(s)
|
||||
for x := range neighbours[0].PeerIps {
|
||||
peer := bgp.Peer{
|
||||
Address: neighbours[0].PeerIps[x],
|
||||
AS: uint32(neighbours[0].PeerAs),
|
||||
MultiHop: neighbours[0].Multihop,
|
||||
Password: neighbours[0].Md5Password,
|
||||
}
|
||||
k.BGPConfig.Peers = append(k.BGPConfig.Peers, peer)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package equinixmetal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/packethost/packngo"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// AttachEIP will use the Equinix Metal APIs to move an EIP and attach to a host
|
||||
func AttachEIP(c *packngo.Client, k *kubevip.Config, _ string) error {
|
||||
// Use MetalProjectID if it is defined
|
||||
projID := k.MetalProjectID
|
||||
|
||||
if projID == "" {
|
||||
// Fallback to attempting to find the project by name
|
||||
proj := findProject(k.MetalProject, c)
|
||||
if proj == nil {
|
||||
return fmt.Errorf("unable to find Project [%s]", k.MetalProject)
|
||||
}
|
||||
|
||||
projID = proj.ID
|
||||
}
|
||||
|
||||
// Prefer Address over VIP
|
||||
vip := k.Address
|
||||
if vip == "" {
|
||||
vip = k.VIP
|
||||
}
|
||||
|
||||
ips, _, _ := c.ProjectIPs.List(projID, &packngo.ListOptions{})
|
||||
for _, ip := range ips {
|
||||
// Find the device id for our EIP
|
||||
if ip.Address == vip {
|
||||
log.Infof("Found EIP ->%s ID -> %s\n", ip.Address, ip.ID)
|
||||
// If attachments already exist then remove them
|
||||
if len(ip.Assignments) != 0 {
|
||||
hrefID := path.Base(ip.Assignments[0].Href)
|
||||
_, err := c.DeviceIPs.Unassign(hrefID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to unassign deviceIP %q: %v", hrefID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup this server through the Equinix Metal API
|
||||
thisDevice := findSelf(c, projID)
|
||||
if thisDevice == nil {
|
||||
return fmt.Errorf("unable to find local/this device in Equinix Metal API")
|
||||
}
|
||||
|
||||
// Assign the EIP to this device
|
||||
log.Infof("Assigning EIP to -> %s\n", thisDevice.Hostname)
|
||||
_, _, err := c.DeviceIPs.Assign(thisDevice.ID, &packngo.AddressStruct{
|
||||
Address: vip,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package equinixmetal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/packethost/packngo"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func findProject(project string, c *packngo.Client) *packngo.Project {
|
||||
l := &packngo.ListOptions{Includes: []string{project}}
|
||||
ps, _, err := c.Projects.List(l)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
for _, p := range ps {
|
||||
|
||||
// Find our project
|
||||
if p.Name == project {
|
||||
return &p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findSelf(c *packngo.Client, projectID string) *packngo.Device {
|
||||
// Go through devices
|
||||
dev, _, _ := c.Devices.List(projectID, &packngo.ListOptions{})
|
||||
for _, d := range dev {
|
||||
// TODO do we need to replace os.Hostname with config.NodeName here?
|
||||
me, _ := os.Hostname()
|
||||
if me == d.Hostname {
|
||||
return &d
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPacketConfig will lookup the configuration from a file path
|
||||
func GetPacketConfig(providerConfig string) (string, string, error) {
|
||||
var config struct {
|
||||
AuthToken string `json:"apiKey"`
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
// get our token and project
|
||||
if providerConfig != "" {
|
||||
configBytes, err := os.ReadFile(providerConfig)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get read configuration file at path %s: %v", providerConfig, err)
|
||||
}
|
||||
err = json.Unmarshal(configBytes, &config)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to process json of configuration file at path %s: %v", providerConfig, err)
|
||||
}
|
||||
}
|
||||
return config.AuthToken, config.ProjectID, nil
|
||||
}
|
||||
@@ -2,11 +2,14 @@ package etcd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
pb "go.etcd.io/etcd/api/v3/etcdserverpb"
|
||||
clientv3 "go.etcd.io/etcd/client/v3"
|
||||
"go.etcd.io/etcd/client/v3/concurrency"
|
||||
@@ -31,7 +34,7 @@ type LeaderElectionConfig struct {
|
||||
|
||||
// MemberUniqueID is the int equivalent to MemberID that allows to override the default conversion
|
||||
// from string to int using hashing.
|
||||
MemberUniqueID *int64
|
||||
MemberUniqueID *uint64
|
||||
|
||||
// LeaseDurationSeconds is the duration that non-leader candidates will
|
||||
// wait to force acquire leadership.
|
||||
@@ -63,17 +66,18 @@ type ClientConfig struct {
|
||||
}
|
||||
|
||||
// RunElectionOrDie behaves the same way as RunElection but panics if there is an error.
|
||||
func RunElectionOrDie(ctx context.Context, config *LeaderElectionConfig) {
|
||||
func RunElectionOrDie(ctx context.Context, config *LeaderElectionConfig) error {
|
||||
if err := RunElection(ctx, config); err != nil {
|
||||
panic(err)
|
||||
return fmt.Errorf("leaderelection error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunElection starts a client with the provided config or panics.
|
||||
// RunElection blocks until leader election loop is
|
||||
// stopped by ctx or it has stopped holding the leader lease.
|
||||
func RunElection(ctx context.Context, config *LeaderElectionConfig) error {
|
||||
var memberID int64
|
||||
var memberID uint64
|
||||
if config.MemberUniqueID != nil {
|
||||
memberID = *config.MemberUniqueID
|
||||
} else {
|
||||
@@ -81,11 +85,11 @@ func RunElection(ctx context.Context, config *LeaderElectionConfig) error {
|
||||
if _, err := h.Write(append([]byte(config.Name), []byte(config.MemberID)...)); err != nil {
|
||||
return err
|
||||
}
|
||||
memberID = int64(h.Sum64())
|
||||
memberID = h.Sum64()
|
||||
}
|
||||
|
||||
ttl := config.LeaseDurationSeconds
|
||||
r := &pb.LeaseGrantRequest{TTL: ttl, ID: memberID}
|
||||
r := &pb.LeaseGrantRequest{TTL: ttl, ID: int64(memberID)} //nolint
|
||||
lease, err := clientv3.RetryLeaseClient(
|
||||
config.EtcdConfig.Client,
|
||||
).LeaseGrant(ctx, r)
|
||||
@@ -115,7 +119,12 @@ func RunElection(ctx context.Context, config *LeaderElectionConfig) error {
|
||||
leaseTTL: lease.TTL,
|
||||
}
|
||||
|
||||
go m.tryToBeLeader(ctx)
|
||||
wg := sync.WaitGroup{}
|
||||
defer wg.Wait()
|
||||
|
||||
wg.Go(func() {
|
||||
m.tryToBeLeader(ctx, &wg)
|
||||
})
|
||||
m.watchLeaderChanges(ctx)
|
||||
|
||||
return nil
|
||||
@@ -147,9 +156,9 @@ watcher:
|
||||
|
||||
m.isLeader = true
|
||||
m.key = m.election.Key() // by this time, this should already be set, since Campaign has already returned
|
||||
log.Debugf("[%s] Marking self as leader with key %s\n", m.memberID, m.key)
|
||||
log.Debug("Marking self as leader with key", "id", m.memberID, "key", m.key)
|
||||
case response := <-changes:
|
||||
log.Debugf("[%s] Leader Changes: %+v\n", m.memberID, response)
|
||||
log.Debug("Leader Changes", "id", m.memberID, "response", response)
|
||||
if len(response.Kvs) == 0 {
|
||||
// There is a race condition where just after we stop being the leader
|
||||
// if there are no more leaders, we might get a response with no key-values
|
||||
@@ -181,15 +190,15 @@ watcher:
|
||||
m.callbacks.OnStoppedLeading()
|
||||
}
|
||||
|
||||
log.Debugf("[%s] Exiting watcher\n", m.memberID)
|
||||
log.Debug("Exiting watcher", "id", m.memberID)
|
||||
}
|
||||
|
||||
func (m *member) tryToBeLeader(ctx context.Context) {
|
||||
func (m *member) tryToBeLeader(ctx context.Context, wg *sync.WaitGroup) {
|
||||
if err := m.election.Campaign(ctx, m.memberID); err != nil {
|
||||
log.Errorf("Failed trying to become the leader: %s", err)
|
||||
log.Error("Failed trying to become the leader", "err", err)
|
||||
// Resign just in case we acquired leadership just before failing
|
||||
if err := m.election.Resign(m.client.Ctx()); err != nil {
|
||||
log.Warnf("Failed to resign after we failed becoming the leader, this might not be a problem if we were never the leader: %s", err)
|
||||
log.Warn("Failed to resign after we failed becoming the leader, this might not be a problem if we were never the leader", "err", err)
|
||||
}
|
||||
return
|
||||
// TODO: what to do here?
|
||||
@@ -203,13 +212,15 @@ func (m *member) tryToBeLeader(ctx context.Context) {
|
||||
m.weAreTheLeader <- struct{}{}
|
||||
|
||||
// Once we are the leader, start the routine to resign if context is canceled
|
||||
go m.resignOnCancel(ctx)
|
||||
wg.Go(func() {
|
||||
m.resignOnCancel(ctx)
|
||||
})
|
||||
|
||||
// After becoming the leader, we wait for at least a lease TTL to wait for
|
||||
// the previous leader to detect the new leadership (if there was one) and
|
||||
// stop its processes
|
||||
// TODO: is this too cautious?
|
||||
log.Debugf("[%s] Waiting %d seconds before running OnStartedLeading", m.memberID, m.leaseTTL)
|
||||
log.Debug("timeout before OnStartedLeading", "id", m.memberID, "timeout", m.leaseTTL)
|
||||
time.Sleep(time.Second * time.Duration(m.leaseTTL))
|
||||
|
||||
// We are the leader, execute our code
|
||||
@@ -220,7 +231,7 @@ func (m *member) tryToBeLeader(ctx context.Context) {
|
||||
|
||||
func (m *member) resignOnCancel(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
if err := m.election.Resign(m.client.Ctx()); err != nil {
|
||||
log.Errorf("Failed to resign after the context was canceled: %s", err)
|
||||
if err := m.election.Resign(m.client.Ctx()); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Error("Failed to resign after the context was canceled", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,19 +20,26 @@ import (
|
||||
func TestRunElectionWithMemberIDCollision(t *testing.T) {
|
||||
t.Parallel()
|
||||
g := NewWithT(t)
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
cli := client(g)
|
||||
defer cli.Close()
|
||||
|
||||
electionName := randomElectionNameForTest("memberIDConflict")
|
||||
log.Printf("Election name %s\n", electionName)
|
||||
memberCtx, cancelMember1 := context.WithCancel(ctx)
|
||||
|
||||
// Use a channel to signal when the first member has observed a new leader
|
||||
// This ensures proper ordering without relying on sleep timing
|
||||
firstMemberObservedLeader := make(chan struct{})
|
||||
var firstMemberObservedOnce sync.Once
|
||||
|
||||
config := &etcd.LeaderElectionConfig{
|
||||
EtcdConfig: etcd.ClientConfig{
|
||||
Client: cli,
|
||||
},
|
||||
Name: electionName,
|
||||
MemberID: "my-host",
|
||||
MemberID: randomElectionNameForTest("my-host"),
|
||||
LeaseDurationSeconds: 1,
|
||||
Callbacks: etcd.LeaderCallbacks{
|
||||
OnStartedLeading: func(ctx context.Context) {
|
||||
@@ -42,6 +49,11 @@ func TestRunElectionWithMemberIDCollision(t *testing.T) {
|
||||
},
|
||||
OnNewLeader: func(identity string) {
|
||||
log.Printf("New leader: %s\n", identity)
|
||||
// Signal that the first member has observed a leader
|
||||
// This means the lease has been created
|
||||
firstMemberObservedOnce.Do(func() {
|
||||
close(firstMemberObservedLeader)
|
||||
})
|
||||
},
|
||||
OnStoppedLeading: func() {
|
||||
log.Println("I'm not the leader anymore")
|
||||
@@ -59,8 +71,18 @@ func TestRunElectionWithMemberIDCollision(t *testing.T) {
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(time.Millisecond * 50) // make sure the first one becomes leader
|
||||
g.Expect(etcd.RunElection(ctx, config)).Should(MatchError(ContainSubstring("creating lease")))
|
||||
// Wait for the first member to observe a leader, which means the lease has been created
|
||||
select {
|
||||
case <-firstMemberObservedLeader:
|
||||
// First member has created the lease, now try to create a conflicting one
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("timeout waiting for first member to observe leader")
|
||||
return
|
||||
}
|
||||
// Use a cancellable context to prevent hanging if this goroutine unexpectedly succeeds
|
||||
member2Ctx, cancelMember2 := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancelMember2()
|
||||
g.Expect(etcd.RunElection(member2Ctx, config)).Should(MatchError(ContainSubstring("creating lease")))
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
@@ -69,7 +91,8 @@ func TestRunElectionWithMemberIDCollision(t *testing.T) {
|
||||
func TestRunElectionWithTwoMembersAndReelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
g := NewWithT(t)
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
cli := client(g)
|
||||
defer cli.Close()
|
||||
|
||||
@@ -85,24 +108,27 @@ func TestRunElectionWithTwoMembersAndReelection(t *testing.T) {
|
||||
LeaseDurationSeconds: 1,
|
||||
}
|
||||
|
||||
member1Ctx, _ := context.WithCancel(ctx)
|
||||
member1Ctx, cancelMember1 := context.WithCancel(ctx)
|
||||
member2Ctx, cancelMember2 := context.WithCancel(ctx)
|
||||
|
||||
config1 := configBase
|
||||
config1.EtcdConfig.Client = cliMember1
|
||||
config1.MemberID = "my-host"
|
||||
uniqueID := rand.Int63()
|
||||
config1.MemberID = randomElectionNameForTest("my-host")
|
||||
uniqueID := rand.Uint64()
|
||||
config1.MemberUniqueID = &uniqueID
|
||||
config1.Callbacks = baseCallbacksForName(config1.MemberID)
|
||||
syncMembers := make(chan (any))
|
||||
config1.Callbacks.OnStartedLeading = func(_ context.Context) {
|
||||
log.Println("I'm my-host, the new leader!!!!")
|
||||
log.Println("Loosing the leadership on purpose by stopping renewing the lease")
|
||||
close(syncMembers)
|
||||
log.Println("Losing the leadership on purpose by stopping renewing the lease")
|
||||
g.Expect(cliMember1.Lease.Close()).To(Succeed())
|
||||
log.Println("Member1 leases closed")
|
||||
cancelMember1()
|
||||
}
|
||||
|
||||
config2 := configBase
|
||||
config2.MemberID = "my-other-host"
|
||||
config2.MemberID = randomElectionNameForTest("my-other-host")
|
||||
config2.Callbacks = baseCallbacksForName(config2.MemberID)
|
||||
config2.Callbacks.OnStartedLeading = func(_ context.Context) {
|
||||
log.Println("I'm my-other-host, the new leader!!!!")
|
||||
@@ -116,17 +142,18 @@ func TestRunElectionWithTwoMembersAndReelection(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
g.Expect(etcd.RunElection(member1Ctx, &config1)).To(Succeed())
|
||||
log.Println("Member1 routine done")
|
||||
log.Printf("%s routine done\n", config1.MemberID)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(time.Millisecond * 50) // Make sure member1 becomes leader
|
||||
<-syncMembers
|
||||
g.Expect(etcd.RunElection(member2Ctx, &config2)).To(Succeed())
|
||||
log.Println("Member2 routine done")
|
||||
log.Printf("%s routine done\n", config2.MemberID)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
}
|
||||
|
||||
func baseCallbacksForName(name string) etcd.LeaderCallbacks {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,8 +27,8 @@ const (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
expectSuccess(startEtcd(ctx), "starting etcd")
|
||||
|
||||
os.Exit(runTestsWithCleanup(m, func() {
|
||||
|
||||
886
pkg/instance/instance.go
Normal file
886
pkg/instance/instance.go
Normal file
@@ -0,0 +1,886 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
log "log/slog"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/arp"
|
||||
"github.com/kube-vip/kube-vip/pkg/cluster"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/networkinterface"
|
||||
"github.com/kube-vip/kube-vip/pkg/node"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
"github.com/kube-vip/kube-vip/pkg/sysctl"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"github.com/kube-vip/kube-vip/pkg/vip"
|
||||
)
|
||||
|
||||
// Instance defines an instance of everything needed to manage vips
|
||||
type Instance struct {
|
||||
// Virtual IP / Load Balancer configuration
|
||||
VIPConfigs []*kubevip.Config
|
||||
|
||||
// cluster instances
|
||||
Clusters []*cluster.Cluster
|
||||
|
||||
// Service uses DHCP
|
||||
IsDHCPv4 bool
|
||||
IsDHCPv6 bool
|
||||
DHCPInterface string
|
||||
DHCPInterfaceHwaddr string
|
||||
DHCPInterfaceIP string
|
||||
DHCPInterfaceIPv4 string
|
||||
DHCPInterfaceIPv6 string
|
||||
DHCPHostname string
|
||||
DHCPv4Client vip.DHCPClient
|
||||
DHCPv6Client vip.DHCPClient
|
||||
macvlanName string
|
||||
dhcpBroadcast bool
|
||||
dhcpInterfaceOwned atomic.Bool
|
||||
|
||||
// Service use Vlan
|
||||
IsVLAN bool
|
||||
VLANInterface string
|
||||
vlanOwned atomic.Bool
|
||||
|
||||
// External Gateway IP the service is forwarded from
|
||||
UPNPGatewayIPs []string
|
||||
|
||||
// Kubernetes service mapping
|
||||
ServiceUID types.UID
|
||||
ServiceAddresses []string
|
||||
ServiceSnapshot *v1.Service
|
||||
cleanupInfo *ServiceCleanupInfo
|
||||
|
||||
// AddCalled determined that ActionAdd was already performed for the instance
|
||||
AddCalled bool
|
||||
|
||||
// LabelAdded determined that node was labeled with
|
||||
// service-provided.kube-vip.io label
|
||||
LabelAdded bool
|
||||
}
|
||||
|
||||
func (instance *Instance) UID() types.UID {
|
||||
return instance.ServiceUID
|
||||
}
|
||||
|
||||
func (instance *Instance) Addresses() []string {
|
||||
if instance.ServiceAddresses != nil {
|
||||
return append([]string(nil), instance.ServiceAddresses...)
|
||||
}
|
||||
addresses, _ := FetchServiceAddresses(instance.ServiceSnapshot)
|
||||
return addresses
|
||||
}
|
||||
|
||||
type ServiceCleanupInfo struct {
|
||||
Namespace string
|
||||
Name string
|
||||
Lease string
|
||||
ExternalTrafficPolicy v1.ServiceExternalTrafficPolicy
|
||||
}
|
||||
|
||||
// CleanupInfo returns Service policy captured when the instance was created.
|
||||
func (instance *Instance) CleanupInfo() (ServiceCleanupInfo, bool) {
|
||||
if instance == nil {
|
||||
return ServiceCleanupInfo{}, false
|
||||
}
|
||||
if instance.cleanupInfo != nil {
|
||||
return *instance.cleanupInfo, true
|
||||
}
|
||||
if instance.ServiceSnapshot == nil {
|
||||
return ServiceCleanupInfo{}, false
|
||||
}
|
||||
return serviceCleanupInfo(instance.ServiceSnapshot), true
|
||||
}
|
||||
|
||||
func serviceCleanupInfo(service *v1.Service) ServiceCleanupInfo {
|
||||
return ServiceCleanupInfo{
|
||||
Namespace: service.Namespace,
|
||||
Name: service.Name,
|
||||
Lease: service.Annotations[kubevip.ServiceLease],
|
||||
ExternalTrafficPolicy: service.Spec.ExternalTrafficPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
type Port struct {
|
||||
Port uint16
|
||||
Type string
|
||||
}
|
||||
|
||||
func NewInstance(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
intfMgr *networkinterface.Manager, arpMgr *arp.Manager, routeMgr *route.Manager,
|
||||
nodeLabelMgr node.Labeler, wg *sync.WaitGroup) (*Instance, error) {
|
||||
instanceAddresses, instanceHostnames := FetchServiceAddresses(svc)
|
||||
log.Info("new instance", "namespace", svc.Namespace, "service", svc.Name, "addresses", instanceAddresses, "hostnames", instanceHostnames)
|
||||
|
||||
cleanupInfo := serviceCleanupInfo(svc)
|
||||
instance := &Instance{
|
||||
ServiceUID: svc.UID,
|
||||
ServiceAddresses: append([]string(nil), instanceAddresses...),
|
||||
ServiceSnapshot: svc,
|
||||
cleanupInfo: &cleanupInfo,
|
||||
}
|
||||
if err := instance.initialize(ctx, svc, config, intfMgr, arpMgr, routeMgr, nodeLabelMgr, wg, instanceAddresses, instanceHostnames); err != nil {
|
||||
return nil, errors.Join(err, instance.CleanupLinkAttachments())
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (instance *Instance) initialize(ctx context.Context, svc *v1.Service, config *kubevip.Config,
|
||||
intfMgr *networkinterface.Manager, arpMgr *arp.Manager, routeMgr *route.Manager,
|
||||
nodeLabelMgr node.Labeler, wg *sync.WaitGroup, instanceAddresses, instanceHostnames []string) error {
|
||||
var newVips []*kubevip.Config
|
||||
var link netlink.Link
|
||||
var err error
|
||||
|
||||
for _, address := range instanceAddresses {
|
||||
// Detect if we're using a specific interface for services
|
||||
var svcInterface string
|
||||
|
||||
svcInterface = svc.Annotations[kubevip.ServiceVlan]
|
||||
if svcInterface != "" {
|
||||
parent, tag, err := utils.ParseVLANInterface(svcInterface)
|
||||
if err != nil {
|
||||
log.Error("failed to validate VLAN", "err", err)
|
||||
}
|
||||
|
||||
if err := instance.addVLAN(parent, tag); err != nil {
|
||||
log.Error("failed to create VLAN", "err", err)
|
||||
}
|
||||
} else {
|
||||
// If no vlan defined use specific interface from annotation
|
||||
svcInterface = svc.Annotations[kubevip.ServiceInterface]
|
||||
}
|
||||
|
||||
if svcInterface == kubevip.Auto {
|
||||
link, err = autoFindInterface(address)
|
||||
if err != nil {
|
||||
log.Error("automatically discover network interface for annotated IP", "address", address, "err", err)
|
||||
} else {
|
||||
if link == nil {
|
||||
log.Error("automatically discover network interface for annotated IP address", "address", address)
|
||||
}
|
||||
}
|
||||
if link == nil {
|
||||
svcInterface = ""
|
||||
} else {
|
||||
svcInterface = getAutoInterfaceName(link, config.Interface)
|
||||
}
|
||||
}
|
||||
// If it is still blank then use the
|
||||
if svcInterface == "" {
|
||||
switch config.ServicesInterface {
|
||||
case kubevip.Auto:
|
||||
link, err = autoFindInterface(address)
|
||||
if err != nil {
|
||||
log.Error("failed to automatically discover network interface for address", "ip", address, "err", err, "interface", config.Interface)
|
||||
} else if link == nil {
|
||||
log.Error("failed to automatically discover network interface for address", "ip", address, "defaulting to", config.Interface)
|
||||
}
|
||||
svcInterface = getAutoInterfaceName(link, config.Interface)
|
||||
case "":
|
||||
svcInterface = config.Interface
|
||||
default:
|
||||
svcInterface = config.ServicesInterface
|
||||
}
|
||||
}
|
||||
|
||||
if link == nil {
|
||||
if link, err = netlink.LinkByName(svcInterface); err != nil {
|
||||
return fmt.Errorf("failed to get interface %s: %w", svcInterface, err)
|
||||
}
|
||||
if link == nil {
|
||||
return fmt.Errorf("failed to get interface %s", svcInterface)
|
||||
}
|
||||
}
|
||||
|
||||
cidrs := vip.Split(config.VIPSubnet)
|
||||
|
||||
ipv4AutoSubnet := false
|
||||
ipv6AutoSubnet := false
|
||||
if cidrs[0] == kubevip.Auto {
|
||||
ipv4AutoSubnet = true
|
||||
}
|
||||
|
||||
if len(cidrs) > 1 && cidrs[1] == kubevip.Auto {
|
||||
ipv6AutoSubnet = true
|
||||
}
|
||||
|
||||
if (config.Address != "" || config.VIP != "") && (ipv4AutoSubnet || ipv6AutoSubnet) {
|
||||
return fmt.Errorf("auto subnet discovery cannot be used if VIP address was provided")
|
||||
}
|
||||
|
||||
subnet := ""
|
||||
var err error
|
||||
if utils.IsIPv4(address) {
|
||||
if ipv4AutoSubnet {
|
||||
subnet, err = autoFindSubnet(link, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to automatically find subnet for service %s/%s with IP address %s on interface %s: %w", svc.Namespace, svc.Name, address, svcInterface, err)
|
||||
}
|
||||
} else {
|
||||
if cidrs[0] != "" && cidrs[0] != kubevip.Auto {
|
||||
subnet = cidrs[0]
|
||||
} else {
|
||||
subnet = strconv.Itoa(vip.DefaultMaskIPv4)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ipv6AutoSubnet {
|
||||
subnet, err = autoFindSubnet(link, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to automatically find subnet for service %s/%s with IP address %s on interface %s: %w", svc.Namespace, svc.Name, address, svcInterface, err)
|
||||
}
|
||||
} else {
|
||||
if len(cidrs) > 1 && cidrs[1] != "" && cidrs[1] != kubevip.Auto {
|
||||
subnet = cidrs[1]
|
||||
} else {
|
||||
subnet = strconv.Itoa(vip.DefaultMaskIPv6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new Virtual IP configuration
|
||||
newVips = append(newVips, &kubevip.Config{
|
||||
VIP: address,
|
||||
Interface: svcInterface,
|
||||
SingleNode: true,
|
||||
EnableARP: config.EnableARP,
|
||||
EnableBGP: config.EnableBGP,
|
||||
BGPAttachIPToInterface: config.BGPAttachIPToInterface,
|
||||
VIPSubnet: subnet,
|
||||
EnableRoutingTable: config.EnableRoutingTable,
|
||||
RoutingTableID: config.RoutingTableID,
|
||||
RoutingTableType: config.RoutingTableType,
|
||||
RoutingProtocol: config.RoutingProtocol,
|
||||
SkipDAD: config.SkipDAD,
|
||||
ArpBroadcastRate: config.ArpBroadcastRate,
|
||||
EnableServiceSecurity: config.EnableServiceSecurity,
|
||||
DNSMode: config.DNSMode,
|
||||
DHCPMode: config.DHCPMode,
|
||||
DHCPBackoffAttempts: config.DHCPBackoffAttempts,
|
||||
DisableServiceUpdates: config.DisableServiceUpdates,
|
||||
EnableServicesElection: config.EnableServicesElection,
|
||||
// cleanupVIPs reads this from the per-VIP config, so Service VIPs need it too.
|
||||
PreserveVIPOnLeadershipLoss: config.PreserveVIPOnLeadershipLoss,
|
||||
KubernetesLeaderElection: kubevip.KubernetesLeaderElection{
|
||||
EnableLeaderElection: config.EnableLeaderElection,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for _, hostname := range instanceHostnames {
|
||||
log.Info("hostname", "addr", hostname)
|
||||
// Detect if we're using a specific interface for services
|
||||
var svcInterface string
|
||||
|
||||
svcInterface = svc.Annotations[kubevip.ServiceVlan]
|
||||
if svcInterface != "" {
|
||||
parent, tag, err := utils.ParseVLANInterface(svcInterface)
|
||||
if err != nil {
|
||||
log.Error("failed to validate VLAN", "err", err)
|
||||
}
|
||||
|
||||
if err := instance.addVLAN(parent, tag); err != nil {
|
||||
log.Error("failed to create VLAN", "err", err)
|
||||
}
|
||||
} else {
|
||||
// If no vlan defined use specific interface from annotation
|
||||
svcInterface = svc.Annotations[kubevip.ServiceInterface]
|
||||
}
|
||||
|
||||
// If it is still blank then use the
|
||||
if svcInterface == "" {
|
||||
switch config.ServicesInterface {
|
||||
case "":
|
||||
svcInterface = config.Interface
|
||||
default:
|
||||
svcInterface = config.ServicesInterface
|
||||
}
|
||||
}
|
||||
|
||||
if link == nil {
|
||||
if link, err = netlink.LinkByName(svcInterface); err != nil {
|
||||
return fmt.Errorf("failed to get interface %s: %w", svcInterface, err)
|
||||
}
|
||||
if link == nil {
|
||||
return fmt.Errorf("failed to get interface %s", svcInterface)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new Virtual IP configuration
|
||||
newVips = append(newVips, &kubevip.Config{
|
||||
VIP: hostname,
|
||||
Interface: svcInterface,
|
||||
SingleNode: true,
|
||||
EnableARP: config.EnableARP,
|
||||
EnableBGP: config.EnableBGP,
|
||||
BGPAttachIPToInterface: config.BGPAttachIPToInterface,
|
||||
VIPSubnet: config.VIPSubnet,
|
||||
EnableRoutingTable: config.EnableRoutingTable,
|
||||
RoutingTableID: config.RoutingTableID,
|
||||
RoutingTableType: config.RoutingTableType,
|
||||
RoutingProtocol: config.RoutingProtocol,
|
||||
SkipDAD: config.SkipDAD,
|
||||
ArpBroadcastRate: config.ArpBroadcastRate,
|
||||
EnableServiceSecurity: config.EnableServiceSecurity,
|
||||
DNSMode: config.DNSMode,
|
||||
DHCPMode: config.DHCPMode,
|
||||
DisableServiceUpdates: config.DisableServiceUpdates,
|
||||
EnableServicesElection: config.EnableServicesElection,
|
||||
KubernetesLeaderElection: kubevip.KubernetesLeaderElection{
|
||||
EnableLeaderElection: config.EnableLeaderElection,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if svc.Annotations != nil {
|
||||
instance.DHCPInterfaceHwaddr = svc.Annotations[kubevip.HwAddrKey]
|
||||
requestedIP := svc.Annotations[kubevip.RequestedIP]
|
||||
if requestedIP != "" {
|
||||
requestedIPs := strings.Split(requestedIP, ",")
|
||||
if len(requestedIPs) > 2 {
|
||||
return fmt.Errorf("annotation %q cannot request more than one IPv4 and one Ipv6 address", kubevip.RequestedIP)
|
||||
}
|
||||
for _, ip := range requestedIPs {
|
||||
netip := net.ParseIP(ip)
|
||||
if netip.To4() != nil {
|
||||
instance.DHCPInterfaceIPv4 = ip
|
||||
} else {
|
||||
instance.DHCPInterfaceIPv6 = ip
|
||||
}
|
||||
}
|
||||
}
|
||||
instance.DHCPHostname = svc.Annotations[kubevip.LoadbalancerHostname]
|
||||
instance.macvlanName = svc.Annotations[kubevip.MacvlanName]
|
||||
instance.dhcpBroadcast = svc.Annotations[kubevip.DHCPBroadcast] == "true"
|
||||
}
|
||||
|
||||
configPorts := make([]kubevip.Port, 0)
|
||||
for _, p := range svc.Spec.Ports {
|
||||
configPorts = append(configPorts, kubevip.Port{
|
||||
Type: string(p.Protocol),
|
||||
Port: int(p.Port),
|
||||
})
|
||||
}
|
||||
// Generate Load Balancer config
|
||||
newLB := kubevip.LoadBalancer{
|
||||
Name: fmt.Sprintf("%s-load-balancer", svc.Name),
|
||||
Ports: configPorts,
|
||||
BindToVip: true,
|
||||
}
|
||||
for _, vip := range newVips {
|
||||
// Add Load Balancer Configuration
|
||||
vip.LoadBalancers = append(vip.LoadBalancers, newLB)
|
||||
}
|
||||
// Create Add configuration to the new service
|
||||
instance.VIPConfigs = newVips
|
||||
|
||||
// If this was purposely created with the address '0.0.0.0', or '::'
|
||||
// we will create a macvlan on the main interface and a DHCP client
|
||||
if len(instanceAddresses) > 2 && (slices.Contains(instanceAddresses, "0.0.0.0") || slices.Contains(instanceAddresses, "::")) {
|
||||
return fmt.Errorf("DHCP cannot be used if more than 2 addresses (one IPv4 and one IPv6) were specified")
|
||||
}
|
||||
for index := range instance.VIPConfigs {
|
||||
if instance.VIPConfigs[index].VIP == "0.0.0.0" {
|
||||
err := instance.startDHCP(ctx, index, config.DHCPBackoffAttempts, wg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-instance.DHCPv4Client.ErrorChannel():
|
||||
return fmt.Errorf("error starting DHCPv4 for %s/%s: error: %s",
|
||||
instance.ServiceSnapshot.Namespace, instance.ServiceSnapshot.Name, err)
|
||||
case ip := <-instance.DHCPv4Client.IPChannel():
|
||||
instance.VIPConfigs[index].Interface = instance.DHCPInterface
|
||||
instance.VIPConfigs[index].VIP = ip
|
||||
instance.DHCPInterfaceIPv4 = ip
|
||||
}
|
||||
}
|
||||
if instance.VIPConfigs[index].VIP == "::" {
|
||||
err := instance.startDHCP(ctx, index, config.DHCPBackoffAttempts, wg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-instance.DHCPv6Client.ErrorChannel():
|
||||
return fmt.Errorf("error starting DHCPv6 for %s/%s: error: %s",
|
||||
instance.ServiceSnapshot.Namespace, instance.ServiceSnapshot.Name, err)
|
||||
case ip := <-instance.DHCPv6Client.IPChannel():
|
||||
instance.VIPConfigs[index].Interface = instance.DHCPInterface
|
||||
instance.VIPConfigs[index].VIP = ip
|
||||
instance.DHCPInterfaceIPv6 = ip
|
||||
}
|
||||
}
|
||||
|
||||
ddnsAnnotation, exists := svc.Annotations[kubevip.ServiceDDNS]
|
||||
|
||||
if exists {
|
||||
instance.VIPConfigs[index].DDNS, err = strconv.ParseBool(ddnsAnnotation)
|
||||
if err != nil {
|
||||
log.Error("Failed to add service", "err", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(svc.Spec.IPFamilies) > 0 {
|
||||
if len(svc.Spec.IPFamilies) > 1 {
|
||||
instance.VIPConfigs[index].DHCPMode = utils.DualFamily
|
||||
instance.VIPConfigs[index].DNSMode = utils.DualFamily
|
||||
switch *svc.Spec.IPFamilyPolicy {
|
||||
case v1.IPFamilyPolicyRequireDualStack:
|
||||
instance.VIPConfigs[index].IsDualStack = true
|
||||
instance.VIPConfigs[index].RequireDualStack = true
|
||||
case v1.IPFamilyPolicyPreferDualStack:
|
||||
instance.VIPConfigs[index].IsDualStack = true
|
||||
instance.VIPConfigs[index].RequireDualStack = false
|
||||
default:
|
||||
instance.VIPConfigs[index].IsDualStack = false
|
||||
instance.VIPConfigs[index].RequireDualStack = false
|
||||
}
|
||||
} else {
|
||||
if strings.EqualFold(string(svc.Spec.IPFamilies[0]), utils.IPv4Family) {
|
||||
instance.VIPConfigs[index].DHCPMode = strings.ToLower(utils.IPv4Family)
|
||||
instance.VIPConfigs[index].DNSMode = strings.ToLower(utils.IPv4Family)
|
||||
} else {
|
||||
instance.VIPConfigs[index].DHCPMode = strings.ToLower(utils.IPv6Family)
|
||||
instance.VIPConfigs[index].DNSMode = strings.ToLower(utils.IPv6Family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
instance.VIPConfigs[index].EgressWithNftables = config.EgressWithNftables
|
||||
|
||||
c, err := cluster.InitCluster(instance.VIPConfigs[index], false, intfMgr, arpMgr, routeMgr, nodeLabelMgr)
|
||||
if err != nil {
|
||||
log.Error("failed to add service", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for networkIndex := range c.Network {
|
||||
c.Network[networkIndex].SetServicePorts(svc)
|
||||
}
|
||||
|
||||
instance.Clusters = append(instance.Clusters, c)
|
||||
log.Info("(svcs) adding VIP", "ip", instance.VIPConfigs[index].VIP, "interface", instance.VIPConfigs[index].Interface, "namespace", svc.Namespace, "name", svc.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func autoFindInterface(ip string) (netlink.Link, error) {
|
||||
links, err := netlink.LinkList()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list network interfaces: %w", err)
|
||||
}
|
||||
|
||||
address := net.ParseIP(ip)
|
||||
|
||||
family := netlink.FAMILY_V4
|
||||
|
||||
if address.To4() == nil {
|
||||
family = netlink.FAMILY_V6
|
||||
}
|
||||
|
||||
for _, link := range links {
|
||||
addr, err := netlink.AddrList(link, family)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get IP addresses for interface %s: %w", link.Attrs().Name, err)
|
||||
}
|
||||
for _, a := range addr {
|
||||
if a.IPNet.Contains(address) {
|
||||
return link, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func autoFindSubnet(link netlink.Link, ip string) (string, error) {
|
||||
address := net.ParseIP(ip)
|
||||
|
||||
family := netlink.FAMILY_V4
|
||||
if address.To4() == nil {
|
||||
family = netlink.FAMILY_V6
|
||||
}
|
||||
|
||||
addr, err := netlink.AddrList(link, family)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get IP addresses for interface %s: %w", link.Attrs().Name, err)
|
||||
}
|
||||
for _, a := range addr {
|
||||
if a.IPNet.Contains(address) {
|
||||
m, _ := a.IPNet.Mask.Size()
|
||||
return strconv.Itoa(m), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("failed to find suitable subnet for address %s", ip)
|
||||
}
|
||||
|
||||
func getAutoInterfaceName(link netlink.Link, defaultInterface string) string {
|
||||
if link == nil {
|
||||
return defaultInterface
|
||||
}
|
||||
return link.Attrs().Name
|
||||
}
|
||||
|
||||
func (instance *Instance) addVLAN(parentInterface string, tag int) error {
|
||||
interfaceName := fmt.Sprintf("%s.%d", parentInterface, tag)
|
||||
parent, err := netlink.LinkByName(parentInterface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding VLAN parent interface %s: %w", parentInterface, err)
|
||||
}
|
||||
iface, err := netlink.LinkByName(interfaceName)
|
||||
if err != nil {
|
||||
var notFound netlink.LinkNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
return fmt.Errorf("finding VLAN interface %s: %w", interfaceName, err)
|
||||
}
|
||||
|
||||
log.Info("Creating new VLAN interface", "interface", interfaceName)
|
||||
|
||||
vlan := &netlink.Vlan{
|
||||
LinkAttrs: netlink.LinkAttrs{
|
||||
Name: interfaceName,
|
||||
ParentIndex: parent.Attrs().Index,
|
||||
},
|
||||
VlanId: tag,
|
||||
VlanProtocol: netlink.VLAN_PROTOCOL_8021Q,
|
||||
}
|
||||
|
||||
err = netlink.LinkAdd(vlan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add VLAN %s: %v", interfaceName, err)
|
||||
}
|
||||
instance.vlanOwned.Store(true)
|
||||
|
||||
err = netlink.LinkSetUp(vlan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not bring up VLAN interface [%s] : %v", interfaceName, err)
|
||||
}
|
||||
|
||||
_, err = net.InterfaceByName(interfaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding new VLAN interface by name [%v]", err)
|
||||
}
|
||||
} else {
|
||||
log.Info("Using existing VLAN interface", "interface", interfaceName)
|
||||
|
||||
if err := utils.ValidateVLANInterface(iface, parent, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
instance.VLANInterface = interfaceName
|
||||
instance.IsVLAN = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupLinkAttachments stops this instance's DHCP clients and removes only
|
||||
// VLAN or macvlan links created by this instance that are not used by a
|
||||
// remaining Service instance.
|
||||
func (instance *Instance) CleanupLinkAttachments(remaining ...*Instance) error {
|
||||
var errs []error
|
||||
if instance.DHCPv4Client != nil {
|
||||
instance.DHCPv4Client.Stop()
|
||||
}
|
||||
if instance.DHCPv6Client != nil {
|
||||
instance.DHCPv6Client.Stop()
|
||||
}
|
||||
if instance.dhcpInterfaceOwned.Load() {
|
||||
if transferLinkAttachmentOwnership(instance.DHCPInterface, remaining, false) {
|
||||
instance.dhcpInterfaceOwned.Store(false)
|
||||
} else if err := deleteOwnedLink(instance.DHCPInterface, "DHCP"); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
instance.dhcpInterfaceOwned.Store(false)
|
||||
}
|
||||
}
|
||||
if instance.vlanOwned.Load() {
|
||||
if transferLinkAttachmentOwnership(instance.VLANInterface, remaining, true) {
|
||||
instance.vlanOwned.Store(false)
|
||||
} else if err := deleteOwnedLink(instance.VLANInterface, "VLAN"); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
instance.vlanOwned.Store(false)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func transferLinkAttachmentOwnership(name string, instances []*Instance, vlan bool) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if instance == nil {
|
||||
continue
|
||||
}
|
||||
if vlan && instance.IsVLAN && instance.VLANInterface == name {
|
||||
instance.vlanOwned.Store(true)
|
||||
return true
|
||||
}
|
||||
if !vlan && (instance.IsDHCPv4 || instance.IsDHCPv6) && instance.DHCPInterface == name {
|
||||
instance.dhcpInterfaceOwned.Store(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deleteOwnedLink(name, kind string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
link, err := netlink.LinkByName(name)
|
||||
if err != nil {
|
||||
var notFound netlink.LinkNotFoundError
|
||||
if errors.As(err, ¬Found) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("find %s interface %q: %w", kind, name, err)
|
||||
}
|
||||
if err := netlink.LinkDel(link); err != nil {
|
||||
return fmt.Errorf("delete %s interface %q: %w", kind, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (instance *Instance) startDHCP(ctx context.Context, index int, backoffAttempts uint, wg *sync.WaitGroup) error {
|
||||
if len(instance.VIPConfigs) > 2 {
|
||||
return fmt.Errorf("DHCP can be used with 2 VIP config maximally, got: %v", len(instance.VIPConfigs))
|
||||
}
|
||||
parent, err := netlink.LinkByName(instance.VIPConfigs[index].Interface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding VIP Interface, for building DHCP Link : %v", err)
|
||||
}
|
||||
|
||||
interfaceName := instance.macvlanName
|
||||
|
||||
if interfaceName == "" {
|
||||
// Generate name from UID
|
||||
interfaceName = fmt.Sprintf("vip-%s", instance.UID()[0:8])
|
||||
}
|
||||
|
||||
// Check if the interface doesn't exist first
|
||||
iface, err := net.InterfaceByName(interfaceName)
|
||||
if err != nil {
|
||||
log.Info("creating new macvlan interface for DHCP", "interface", interfaceName)
|
||||
|
||||
hwaddr, err := net.ParseMAC(instance.DHCPInterfaceHwaddr)
|
||||
if instance.DHCPInterfaceHwaddr != "" && err != nil {
|
||||
return err
|
||||
} else if hwaddr == nil {
|
||||
hwaddr, err = net.ParseMAC(vip.GenerateMac())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("new macvlan interface", "interface", interfaceName, "hardware address", hwaddr)
|
||||
mac := &netlink.Macvlan{
|
||||
LinkAttrs: netlink.LinkAttrs{
|
||||
Name: interfaceName,
|
||||
ParentIndex: parent.Attrs().Index,
|
||||
HardwareAddr: hwaddr,
|
||||
},
|
||||
Mode: netlink.MACVLAN_MODE_DEFAULT,
|
||||
}
|
||||
|
||||
err = netlink.LinkAdd(mac)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add %s: %v", interfaceName, err)
|
||||
}
|
||||
instance.dhcpInterfaceOwned.Store(true)
|
||||
|
||||
err = netlink.LinkSetUp(mac)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not bring up interface [%s] : %v", interfaceName, err)
|
||||
}
|
||||
|
||||
iface, err = net.InterfaceByName(interfaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding new DHCP interface by name [%v]", err)
|
||||
}
|
||||
} else {
|
||||
log.Info("Using existing macvlan interface for DHCP", "interface", interfaceName)
|
||||
}
|
||||
|
||||
var initRebootFlag bool
|
||||
ip := net.ParseIP(instance.VIPConfigs[index].VIP)
|
||||
|
||||
var client vip.DHCPClient
|
||||
if ip.To4() != nil {
|
||||
// Default rp_filter setting (https://github.com/kube-vip/kube-vip/issues/1170)
|
||||
rpfilterSetting := "0"
|
||||
|
||||
// Check if we need to set an override rp_filter value for the interface
|
||||
if instance.ServiceSnapshot.Annotations[kubevip.RPFilter] != "" {
|
||||
// Check the rp_filter value
|
||||
rpFilter, err := strconv.Atoi(instance.ServiceSnapshot.Annotations[kubevip.RPFilter])
|
||||
if err != nil {
|
||||
log.Error("[DHCP] unable to process rp_filter", "value", rpFilter)
|
||||
} else {
|
||||
if rpFilter >= 0 && rpFilter < 3 { // Ensure the value is 0,1,2
|
||||
rpfilterSetting = instance.ServiceSnapshot.Annotations[kubevip.RPFilter]
|
||||
} else {
|
||||
log.Error("[DHCP] rp_filter value not within range 0-2", "value", rpFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = sysctl.WriteProcSys("/proc/sys/net/ipv4/conf/"+interfaceName+"/rp_filter", rpfilterSetting)
|
||||
if err != nil {
|
||||
log.Error("[DHCP] unable to write rp_filter", "value", rpfilterSetting, "err", err)
|
||||
}
|
||||
|
||||
if instance.DHCPInterfaceIPv4 != "" {
|
||||
initRebootFlag = true
|
||||
}
|
||||
|
||||
client = vip.NewDHCPv4Client(iface, initRebootFlag, instance.DHCPInterfaceIPv4, backoffAttempts, instance.dhcpBroadcast)
|
||||
|
||||
// Add the client so that we can call it to stop function
|
||||
instance.DHCPv4Client = client
|
||||
|
||||
// Set that DHCPv4 is enabled
|
||||
instance.IsDHCPv4 = true
|
||||
} else {
|
||||
if instance.DHCPInterfaceIPv6 != "" {
|
||||
initRebootFlag = true
|
||||
}
|
||||
|
||||
client, err = vip.NewDHCPv6Client(iface, parent, initRebootFlag, instance.DHCPInterfaceIPv6, backoffAttempts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create client: %w", err)
|
||||
}
|
||||
|
||||
// Add the client so that we can call it to stop function
|
||||
instance.DHCPv6Client = client
|
||||
|
||||
// Set that DHCPv6 is enabled
|
||||
instance.IsDHCPv6 = true
|
||||
}
|
||||
|
||||
// Add hostname to dhcp client if annotated
|
||||
if instance.DHCPHostname != "" {
|
||||
log.Info("Hostname specified for dhcp lease", "interface", interfaceName, "hostname", instance.DHCPHostname)
|
||||
client.WithHostName(instance.DHCPHostname)
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
if err := client.Start(ctx); err != nil {
|
||||
log.Error("[instance] DHCP client", "error", err)
|
||||
client.Stop()
|
||||
}
|
||||
})
|
||||
|
||||
// Set the name of the interface so that it can be removed on Service deletion
|
||||
instance.DHCPInterface = interfaceName
|
||||
instance.DHCPInterfaceHwaddr = iface.HardwareAddr.String()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchLoadBalancerIngressAddresses tries to get the addresses from status.loadBalancerIP
|
||||
func FetchLoadBalancerIngress(s *v1.Service) ([]string, []string) {
|
||||
// If the service has no status, return empty
|
||||
lbStatusAddresses := []string{}
|
||||
lbStatusHostnames := []string{}
|
||||
if len(s.Status.LoadBalancer.Ingress) == 0 {
|
||||
return lbStatusAddresses, lbStatusHostnames
|
||||
}
|
||||
|
||||
for _, ingress := range s.Status.LoadBalancer.Ingress {
|
||||
if ingress.IP != "" {
|
||||
lbStatusAddresses = append(lbStatusAddresses, ingress.IP)
|
||||
}
|
||||
if ingress.Hostname != "" {
|
||||
lbStatusHostnames = append(lbStatusHostnames, ingress.Hostname)
|
||||
}
|
||||
}
|
||||
return lbStatusAddresses, lbStatusHostnames
|
||||
}
|
||||
|
||||
// FetchServiceAddresses tries to get the addresses from annotations
|
||||
// kube-vip.io/loadbalancerIPs, then from spec.loadbalancerIP
|
||||
func FetchServiceAddresses(s *v1.Service) ([]string, []string) {
|
||||
annotationAvailable := false
|
||||
if s.Annotations != nil {
|
||||
|
||||
if v, annotationAvailable := s.Annotations[kubevip.LoadbalancerIPAnnotation]; annotationAvailable {
|
||||
ips := strings.Split(v, ",")
|
||||
var trimmedIPs []string
|
||||
var trimmedHostnames []string
|
||||
for _, a := range ips {
|
||||
a = strings.TrimSpace(a)
|
||||
ip := net.ParseIP(a)
|
||||
if ip == nil {
|
||||
// this is probably a DNS name
|
||||
trimmedHostnames = append(trimmedHostnames, a)
|
||||
} else {
|
||||
trimmedIPs = append(trimmedIPs, ip.String())
|
||||
}
|
||||
}
|
||||
return trimmedIPs, trimmedHostnames
|
||||
}
|
||||
}
|
||||
|
||||
lbStatusAddresses := []string{}
|
||||
lbStatusHostnames := []string{}
|
||||
if !annotationAvailable {
|
||||
lbStatusAddresses, lbStatusHostnames = FetchLoadBalancerIngress(s)
|
||||
}
|
||||
|
||||
// Spec.LoadBalancerIP legacy handling
|
||||
// if the loadBalancerIP is different from Status.LoadBalancer.Ingress IPs
|
||||
// return the legacy LB as spec wins over status.
|
||||
if lbIP := net.ParseIP(s.Spec.LoadBalancerIP); lbIP != nil && len(lbStatusAddresses) > 0 {
|
||||
isLbIPv4 := utils.IsIPv4(s.Spec.LoadBalancerIP)
|
||||
for _, a := range lbStatusAddresses {
|
||||
if lbStatusIP := net.ParseIP(a); lbStatusIP != nil && utils.IsIPv4(a) == isLbIPv4 && !lbIP.Equal(lbStatusIP) {
|
||||
return []string{s.Spec.LoadBalancerIP}, []string{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(lbStatusAddresses) > 0 || len(lbStatusHostnames) > 0 {
|
||||
return lbStatusAddresses, lbStatusHostnames
|
||||
}
|
||||
|
||||
if s.Spec.LoadBalancerIP != "" {
|
||||
return []string{s.Spec.LoadBalancerIP}, []string{}
|
||||
}
|
||||
|
||||
return []string{}, []string{}
|
||||
}
|
||||
|
||||
func FindServiceInstance(svc *v1.Service, instances []*Instance) *Instance {
|
||||
log.Debug("finding service", "namespace", svc.Namespace, "name", svc.Name, "UID", svc.UID)
|
||||
for index := range instances {
|
||||
if instances[index].UID() == svc.UID {
|
||||
return instances[index]
|
||||
}
|
||||
}
|
||||
log.Debug("instance not found", "namespace", svc.Namespace, "name", svc.Name, "UID", svc.UID)
|
||||
return nil
|
||||
}
|
||||
62
pkg/instance/instance_bgp_attach_test.go
Normal file
62
pkg/instance/instance_bgp_attach_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/arp"
|
||||
"github.com/kube-vip/kube-vip/pkg/instance"
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
"github.com/kube-vip/kube-vip/pkg/networkinterface"
|
||||
"github.com/kube-vip/kube-vip/pkg/route"
|
||||
)
|
||||
|
||||
func TestNewInstance_PropagatesBGPAttachIPToInterface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attach bool
|
||||
}{
|
||||
{name: "attach enabled is propagated", attach: true},
|
||||
{name: "attach disabled is propagated", attach: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
globalConfig := &kubevip.Config{
|
||||
Interface: "lo",
|
||||
VIPSubnet: "32",
|
||||
EnableBGP: true,
|
||||
BGPAttachIPToInterface: tt.attach,
|
||||
}
|
||||
|
||||
svc := &v1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
kubevip.LoadbalancerIPAnnotation: "10.0.1.2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
inst, err := instance.NewInstance(context.Background(), svc, globalConfig,
|
||||
networkinterface.NewManager(), arp.NewManager(globalConfig), route.NewManager(),
|
||||
nil, &sync.WaitGroup{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewInstance() error = %v", err)
|
||||
}
|
||||
|
||||
if len(inst.VIPConfigs) != 1 {
|
||||
t.Fatalf("VIPConfigs len = %d, want 1", len(inst.VIPConfigs))
|
||||
}
|
||||
|
||||
if got := inst.VIPConfigs[0].BGPAttachIPToInterface; got != tt.attach {
|
||||
t.Fatalf("BGPAttachIPToInterface = %t, want %t", got, tt.attach)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
65
pkg/instance/instance_test.go
Normal file
65
pkg/instance/instance_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
func TestUIDUsesImmutableServiceUID(t *testing.T) {
|
||||
serviceUID := types.UID("original-service")
|
||||
instance := &Instance{
|
||||
ServiceUID: serviceUID,
|
||||
ServiceSnapshot: &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID("replacement-service"),
|
||||
}},
|
||||
}
|
||||
|
||||
if got := instance.UID(); got != serviceUID {
|
||||
t.Fatalf("UID() = %q, want %q", got, serviceUID)
|
||||
}
|
||||
|
||||
instance.ServiceUID = ""
|
||||
if got := instance.UID(); got != "" {
|
||||
t.Fatalf("UID() without ServiceUID = %q, want empty UID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStateIsImmutable(t *testing.T) {
|
||||
original := &v1.Service{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "service", Namespace: "default", Annotations: map[string]string{kubevip.ServiceLease: "shared"},
|
||||
}, Spec: v1.ServiceSpec{LoadBalancerIP: "192.0.2.10", ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster}}
|
||||
immutableInfo := serviceCleanupInfo(original)
|
||||
instance := &Instance{
|
||||
ServiceSnapshot: original,
|
||||
ServiceAddresses: []string{"192.0.2.10"},
|
||||
cleanupInfo: &immutableInfo,
|
||||
}
|
||||
instance.ServiceSnapshot = &v1.Service{ObjectMeta: metav1.ObjectMeta{Name: "changed"}}
|
||||
|
||||
cleanupInfo, ok := instance.CleanupInfo()
|
||||
if !ok || cleanupInfo.Namespace != "default" || cleanupInfo.Name != "service" || cleanupInfo.Lease != "shared" ||
|
||||
cleanupInfo.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
|
||||
t.Fatalf("CleanupInfo() = %+v, %t, want creation-time Service policy", cleanupInfo, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferLinkAttachmentOwnershipConcurrent(t *testing.T) {
|
||||
target := &Instance{IsVLAN: true, VLANInterface: "eth0.42"}
|
||||
var wg sync.WaitGroup
|
||||
for range 100 {
|
||||
wg.Go(func() {
|
||||
if !transferLinkAttachmentOwnership("eth0.42", []*Instance{target}, true) {
|
||||
t.Error("transferLinkAttachmentOwnership() did not find target")
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if !target.vlanOwned.Load() {
|
||||
t.Fatal("target did not receive VLAN ownership")
|
||||
}
|
||||
}
|
||||
102
pkg/instance/links_linux_test.go
Normal file
102
pkg/instance/links_linux_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
//go:build linux
|
||||
|
||||
package instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"github.com/vishvananda/netns"
|
||||
)
|
||||
|
||||
// requireNetworkNamespaces makes the privileged CI job fail instead of silently
|
||||
// skipping when it cannot enter a network namespace.
|
||||
var requireNetworkNamespaces = os.Getenv("KUBE_VIP_REQUIRE_NETNS") != ""
|
||||
|
||||
func TestCleanupLinkAttachmentsOnlyDeletesOwnedVLAN(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
preexists bool
|
||||
inUse bool
|
||||
}{
|
||||
{name: "owned VLAN", preexists: false},
|
||||
{name: "adopted VLAN", preexists: true},
|
||||
{name: "owned VLAN used by another Service", inUse: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
originalNamespace, err := netns.Get()
|
||||
if err != nil {
|
||||
t.Fatalf("getting current network namespace: %v", err)
|
||||
}
|
||||
defer originalNamespace.Close()
|
||||
testNamespace, err := netns.New()
|
||||
if err != nil {
|
||||
if requireNetworkNamespaces {
|
||||
t.Fatalf("creating isolated network namespace: %v", err)
|
||||
}
|
||||
t.Skipf("creating isolated network namespace: %v", err)
|
||||
}
|
||||
defer testNamespace.Close()
|
||||
defer func() {
|
||||
if err := netns.Set(originalNamespace); err != nil {
|
||||
t.Errorf("restoring network namespace: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
parent := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "kvattach0"}}
|
||||
if err := netlink.LinkAdd(parent); err != nil {
|
||||
t.Fatalf("creating parent interface: %v", err)
|
||||
}
|
||||
if err := netlink.LinkSetUp(parent); err != nil {
|
||||
t.Fatalf("bringing up parent interface: %v", err)
|
||||
}
|
||||
if test.preexists {
|
||||
vlan := &netlink.Vlan{LinkAttrs: netlink.LinkAttrs{Name: "kvattach0.42", ParentIndex: parent.Attrs().Index}, VlanId: 42}
|
||||
if err := netlink.LinkAdd(vlan); err != nil {
|
||||
t.Fatalf("creating existing VLAN: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
instance := &Instance{}
|
||||
if err := instance.addVLAN(parent.Attrs().Name, 42); err != nil {
|
||||
t.Fatalf("adding VLAN attachment: %v", err)
|
||||
}
|
||||
if instance.vlanOwned.Load() == test.preexists {
|
||||
t.Fatalf("vlanOwned = %t, want %t", instance.vlanOwned.Load(), !test.preexists)
|
||||
}
|
||||
var remaining []*Instance
|
||||
if test.inUse {
|
||||
remaining = []*Instance{{IsVLAN: true, VLANInterface: instance.VLANInterface}}
|
||||
}
|
||||
if err := instance.CleanupLinkAttachments(remaining...); err != nil {
|
||||
t.Fatalf("cleaning attachments: %v", err)
|
||||
}
|
||||
if test.inUse {
|
||||
if !remaining[0].vlanOwned.Load() {
|
||||
t.Fatal("remaining Service did not receive VLAN cleanup ownership")
|
||||
}
|
||||
if _, err := netlink.LinkByName("kvattach0.42"); err != nil {
|
||||
t.Fatalf("VLAN was removed while a Service still used it: %v", err)
|
||||
}
|
||||
if err := remaining[0].CleanupLinkAttachments(); err != nil {
|
||||
t.Fatalf("cleaning transferred attachment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = netlink.LinkByName("kvattach0.42")
|
||||
var notFound netlink.LinkNotFoundError
|
||||
if test.preexists && err != nil {
|
||||
t.Fatalf("adopted VLAN was removed: %v", err)
|
||||
}
|
||||
if !test.preexists && !errors.As(err, ¬Found) {
|
||||
t.Fatalf("owned VLAN remains after cleanup: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -87,21 +86,6 @@ type IPTables struct {
|
||||
|
||||
nftables bool
|
||||
}
|
||||
|
||||
// Stat represents a structured statistic entry.
|
||||
type Stat struct {
|
||||
Packets uint64 `json:"pkts"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
Target string `json:"target"`
|
||||
Protocol string `json:"prot"`
|
||||
Opt string `json:"opt"`
|
||||
Input string `json:"in"`
|
||||
Output string `json:"out"`
|
||||
Source *net.IPNet `json:"source"`
|
||||
Destination *net.IPNet `json:"destination"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
type Option func(*IPTables)
|
||||
|
||||
func IPFamily(proto Protocol) Option {
|
||||
@@ -251,16 +235,6 @@ func (ipt *IPTables) DeleteIfExists(table, chain string, rulespec ...string) err
|
||||
return err
|
||||
}
|
||||
|
||||
// List rules in specified table/chain
|
||||
func (ipt *IPTables) ListByID(table, chain string, id int) (string, error) {
|
||||
args := []string{"-t", table, "-S", chain, strconv.Itoa(id)}
|
||||
rule, err := ipt.executeList(args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rule[0], nil
|
||||
}
|
||||
|
||||
// List rules in specified table/chain
|
||||
func (ipt *IPTables) List(table, chain string) ([]string, error) {
|
||||
args := []string{"-t", table, "-S", chain}
|
||||
@@ -313,129 +287,6 @@ func (ipt *IPTables) ChainExists(table, chain string) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stats lists rules including the byte and packet counts
|
||||
func (ipt *IPTables) Stats(table, chain string) ([][]string, error) {
|
||||
args := []string{"-t", table, "-L", chain, "-n", "-v", "-x"}
|
||||
lines, err := ipt.executeList(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
appendSubnet := func(addr string) string {
|
||||
if strings.IndexByte(addr, byte('/')) < 0 {
|
||||
if strings.IndexByte(addr, '.') < 0 {
|
||||
return addr + "/128"
|
||||
}
|
||||
return addr + "/32"
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
ipv6 := ipt.proto == ProtocolIPv6
|
||||
|
||||
rows := [][]string{}
|
||||
for i, line := range lines {
|
||||
// Skip over chain name and field header
|
||||
if i < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fields:
|
||||
// 0=pkts 1=bytes 2=target 3=prot 4=opt 5=in 6=out 7=source 8=destination 9=options
|
||||
line = strings.TrimSpace(line)
|
||||
fields := strings.Fields(line)
|
||||
|
||||
// The ip6tables verbose output cannot be naively split due to the default "opt"
|
||||
// field containing 2 single spaces.
|
||||
if ipv6 {
|
||||
// Check if field 6 is "opt" or "source" address
|
||||
dest := fields[6]
|
||||
ip, _, _ := net.ParseCIDR(dest)
|
||||
if ip == nil {
|
||||
ip = net.ParseIP(dest)
|
||||
}
|
||||
|
||||
// If we detected a CIDR or IP, the "opt" field is empty.. insert it.
|
||||
if ip != nil {
|
||||
f := []string{}
|
||||
f = append(f, fields[:4]...)
|
||||
f = append(f, " ") // Empty "opt" field for ip6tables
|
||||
f = append(f, fields[4:]...)
|
||||
fields = f
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust "source" and "destination" to include netmask, to match regular
|
||||
// List output
|
||||
fields[7] = appendSubnet(fields[7])
|
||||
fields[8] = appendSubnet(fields[8])
|
||||
|
||||
// Combine "options" fields 9... into a single space-delimited field.
|
||||
options := fields[9:]
|
||||
fields = fields[:9]
|
||||
fields = append(fields, strings.Join(options, " "))
|
||||
rows = append(rows, fields)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ParseStat parses a single statistic row into a Stat struct. The input should
|
||||
// be a string slice that is returned from calling the Stat method.
|
||||
func (ipt *IPTables) ParseStat(stat []string) (parsed Stat, err error) {
|
||||
// For forward-compatibility, expect at least 10 fields in the stat
|
||||
if len(stat) < 10 {
|
||||
return parsed, fmt.Errorf("stat contained fewer fields than expected")
|
||||
}
|
||||
|
||||
// Convert the fields that are not plain strings
|
||||
parsed.Packets, err = strconv.ParseUint(stat[0], 0, 64)
|
||||
if err != nil {
|
||||
return parsed, fmt.Errorf(err.Error(), "could not parse packets")
|
||||
}
|
||||
parsed.Bytes, err = strconv.ParseUint(stat[1], 0, 64)
|
||||
if err != nil {
|
||||
return parsed, fmt.Errorf(err.Error(), "could not parse bytes")
|
||||
}
|
||||
_, parsed.Source, err = net.ParseCIDR(stat[7])
|
||||
if err != nil {
|
||||
return parsed, fmt.Errorf(err.Error(), "could not parse source")
|
||||
}
|
||||
_, parsed.Destination, err = net.ParseCIDR(stat[8])
|
||||
if err != nil {
|
||||
return parsed, fmt.Errorf(err.Error(), "could not parse destination")
|
||||
}
|
||||
|
||||
// Put the fields that are strings
|
||||
parsed.Target = stat[2]
|
||||
parsed.Protocol = stat[3]
|
||||
parsed.Opt = stat[4]
|
||||
parsed.Input = stat[5]
|
||||
parsed.Output = stat[6]
|
||||
parsed.Options = stat[9]
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// StructuredStats returns statistics as structured data which may be further
|
||||
// parsed and marshaled.
|
||||
func (ipt *IPTables) StructuredStats(table, chain string) ([]Stat, error) {
|
||||
rawStats, err := ipt.Stats(table, chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
structStats := []Stat{}
|
||||
for _, rawStat := range rawStats {
|
||||
stat, err := ipt.ParseStat(rawStat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
structStats = append(structStats, stat)
|
||||
}
|
||||
|
||||
return structStats, nil
|
||||
}
|
||||
|
||||
func (ipt *IPTables) executeList(args []string) ([]string, error) {
|
||||
var stdout bytes.Buffer
|
||||
if err := ipt.runWithOutput(args, &stdout); err != nil {
|
||||
|
||||
@@ -6,31 +6,19 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
log "log/slog"
|
||||
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// NewClientset takes an optional configPath and creates a new clientset.
|
||||
// If the configPath is not specified, and inCluster is true, then an
|
||||
// InClusterConfig is used.
|
||||
// Also takes a hostname which allow for overriding the config's hostname
|
||||
// before generating a client.
|
||||
func NewClientset(configPath string, inCluster bool, hostname string) (*kubernetes.Clientset, error) {
|
||||
return newClientset(configPath, inCluster, hostname, time.Second*10)
|
||||
}
|
||||
|
||||
func newClientset(configPath string, inCluster bool, hostname string, timeout time.Duration) (*kubernetes.Clientset, error) {
|
||||
config, err := restConfig(configPath, inCluster, timeout)
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
if len(hostname) > 0 {
|
||||
config.Host = hostname
|
||||
}
|
||||
const (
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// NewClientset takes REST config and returns k8s clientest.
|
||||
func NewClientset(config *rest.Config) (*kubernetes.Clientset, error) {
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating kubernetes client: %s", err.Error())
|
||||
@@ -38,18 +26,36 @@ func newClientset(configPath string, inCluster bool, hostname string, timeout ti
|
||||
return clientset, nil
|
||||
}
|
||||
|
||||
// NewRestConfig takes an optional configPath and creates a new REST config for clientset.
|
||||
// If the configPath is not specified, and inCluster is true, then an
|
||||
// InClusterConfig is used.
|
||||
// Also takes a hostname which allow for overriding the config's hostname.
|
||||
func NewRestConfig(configPath string, inCluster bool, hostname string) (*rest.Config, error) {
|
||||
config, err := restConfig(configPath, inCluster, defaultTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create rest config: %w", err)
|
||||
}
|
||||
|
||||
if len(hostname) > 0 {
|
||||
config.Host = hostname
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func restConfig(kubeconfig string, inCluster bool, timeout time.Duration) (*rest.Config, error) {
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
log.Debugf("[k8s client] we try the incluster first, this error [%v] can safely be ignored", err)
|
||||
}
|
||||
|
||||
if kubeconfig != "" && !inCluster {
|
||||
cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var cfg *rest.Config
|
||||
var err error
|
||||
if inCluster {
|
||||
if cfg, err = rest.InClusterConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get incluster config: %w", err)
|
||||
}
|
||||
} else if kubeconfig != "" {
|
||||
if cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to build config from file '%s': %w", kubeconfig, err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to build config from file: path to KubeConfig not specified")
|
||||
}
|
||||
|
||||
// Override some of the defaults allowing a little bit more flexibility speaking with the API server
|
||||
@@ -87,23 +93,30 @@ func findAddressFromRemoteCert(address string) ([]net.IP, error) {
|
||||
return certs[0].IPAddresses, nil
|
||||
}
|
||||
|
||||
func FindWorkingKubernetesAddress(configPath string, inCluster bool) (*kubernetes.Clientset, error) {
|
||||
func FindWorkingKubernetesAddress(configPath string, inCluster bool) (*rest.Config, error) {
|
||||
// check with loopback, and retrieve its certificate
|
||||
ips, err := findAddressFromRemoteCert("127.0.0.1:6443")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for x := range ips {
|
||||
log.Debugf("[k8s client] checking with IP address [%s]", ips[x].String())
|
||||
|
||||
k, err := newClientset(configPath, inCluster, ips[x].String()+":6443", time.Second*2)
|
||||
log.Debug("[k8s client] testing", "address", ips[x].String())
|
||||
c, err := NewRestConfig(configPath, inCluster, net.JoinHostPort(ips[x].String(), "6443"))
|
||||
if err != nil {
|
||||
log.Info(err)
|
||||
log.Error("failed to create k8s REST config", "err", err)
|
||||
}
|
||||
|
||||
c.Timeout = 2 * time.Second
|
||||
k, err := NewClientset(c)
|
||||
if err != nil {
|
||||
log.Error("failed to create k8s clientset", "err", err)
|
||||
}
|
||||
|
||||
_, err = k.DiscoveryClient.ServerVersion()
|
||||
if err == nil {
|
||||
log.Infof("[k8s client] working with IP address [%s]", ips[x].String())
|
||||
return NewClientset(configPath, inCluster, ips[x].String()+":6443")
|
||||
log.Info("[k8s client] working", "address", ips[x].String())
|
||||
c.Timeout = defaultTimeout
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("unable to find a working address for the local API server [%v]", err)
|
||||
|
||||
97
pkg/kubevip/annotations.go
Normal file
97
pkg/kubevip/annotations.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package kubevip
|
||||
|
||||
const (
|
||||
// Hardware address of the host that has the VIP
|
||||
HwAddrKey = "kube-vip.io/hwaddr"
|
||||
|
||||
// The IP address that is requested
|
||||
RequestedIP = "kube-vip.io/requestedIP"
|
||||
|
||||
// The host that has the VIP
|
||||
VipHost = "kube-vip.io/vipHost"
|
||||
|
||||
// Enable Egress on a service
|
||||
Egress = "kube-vip.io/egress"
|
||||
|
||||
// Enable internal Egress
|
||||
EgressInternal = "kube-vip.io/egress-internal"
|
||||
|
||||
// Egress should be IPv6
|
||||
EgressIPv6 = "kube-vip.io/egress-ipv6"
|
||||
|
||||
// Ports that traffic is allowed to access from the egress VIP
|
||||
EgressDestinationPorts = "kube-vip.io/egress-destination-ports"
|
||||
|
||||
// Allowed incoming ports to the VIP
|
||||
EgressSourcePorts = "kube-vip.io/egress-source-ports"
|
||||
|
||||
// Allowed networks for the Egress to be enabled for
|
||||
EgressAllowedNetworks = "kube-vip.io/egress-allowed-networks"
|
||||
|
||||
// Networks that we wont Egress for
|
||||
EgressDeniedNetworks = "kube-vip.io/egress-denied-networks"
|
||||
|
||||
// EgressNoInternalTraffic, when enabled will prevent any internal traffic from being SNATed to the egress VIP, even if the internal SNAT rule is enabled
|
||||
EgressNoInternalTraffic = "kube-vip.io/egress-no-internal-traffic"
|
||||
|
||||
// EgressDetectAPIServer, this will attempt to detect the API server and add it to the allowed networks for egress
|
||||
EgressDetectAPIServer = "kube-vip.io/egress-detect-api-server"
|
||||
|
||||
// The current active endpoint(pod) for the Egress VIP
|
||||
ActiveEndpoint = "kube-vip.io/active-endpoint"
|
||||
|
||||
// The current active endpoint(pod) for the Egress VIP (v6)
|
||||
ActiveEndpointIPv6 = "kube-vip.io/active-endpoint-ipv6"
|
||||
|
||||
// The nftables egress table base name that owns this Service's SNAT chain
|
||||
EgressNftablesTable = "kube-vip.io/egress-nftables-table"
|
||||
|
||||
// Flush the conntrack rules (remove existing sessions) once Egress is configured
|
||||
FlushContrack = "kube-vip.io/flush-conntrack"
|
||||
|
||||
// Configure LoadBalancer IPs instead of relying on a controller
|
||||
LoadbalancerIPAnnotation = "kube-vip.io/loadbalancerIPs"
|
||||
|
||||
// Ignore the LoadBalancer Service
|
||||
LoadbalancerIgnore = "kube-vip.io/ignore"
|
||||
|
||||
// Used to configure DHCP with a Hostname
|
||||
LoadbalancerHostname = "kube-vip.io/loadbalancerHostname"
|
||||
|
||||
// Define an interface name to bind the address of the LoadBalancer to
|
||||
ServiceInterface = "kube-vip.io/serviceInterface"
|
||||
|
||||
// Specify VLAN subinterface for service (e.g. eth0.200)
|
||||
ServiceVlan = "kube-vip.io/serviceVLAN"
|
||||
|
||||
ServiceSecurityIgnore = "kube-vip.io/ignore-service-security"
|
||||
|
||||
// Enable UPNP on a Service
|
||||
UpnpEnabled = "kube-vip.io/forwardUPNP"
|
||||
|
||||
// Set the UPNP lease duration for a specific service using duration format (e.g., "30s", "1h")
|
||||
UpnpLeaseDuration = "kube-vip.io/upnp-lease-duration"
|
||||
|
||||
RPFilter = "kube-vip.io/rp_filter" // Set the return path filter for a specific service interface
|
||||
|
||||
// Name of the service lease object
|
||||
ServiceLease = "kube-vip.io/leaseName"
|
||||
|
||||
// Versioned kube-vip ownership metadata stored on Kubernetes election Leases
|
||||
LeaseVIPs = "kube-vip.io/lease-vips"
|
||||
|
||||
// Forces kube-vip to use per service election for this particular service
|
||||
ForcePerServiceElection = "kube-vip.io/forcePerServiceElection"
|
||||
|
||||
// Allow service reconciliation even when no endpoints are present (Cluster policy only)
|
||||
AllowReconcileWithoutEndpoints = "kube-vip.io/allow-reconcile-without-endpoints"
|
||||
|
||||
// Enable DDNS for the service
|
||||
ServiceDDNS = "kube-vip.io/ddns"
|
||||
|
||||
// Forces kube-vip to use the specified veth interface when DHCP is being used for a service
|
||||
MacvlanName = "kube-vip.io/macvlanName"
|
||||
|
||||
// Set the BROADCAST flag in DHCP DISCOVER/REQUEST packets
|
||||
DHCPBroadcast = "kube-vip.io/dhcp-broadcast"
|
||||
)
|
||||
334
pkg/kubevip/config_bgp.go
Normal file
334
pkg/kubevip/config_bgp.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
api "github.com/osrg/gobgp/v4/api"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// Peer defines a BGP Peer
|
||||
type BGPPeer struct {
|
||||
Address string
|
||||
Port uint16
|
||||
Interface string
|
||||
AS uint32
|
||||
Password string
|
||||
MultiHop bool
|
||||
MpbgpNexthop string
|
||||
MpbgpIPv4 string
|
||||
MpbgpIPv6 string
|
||||
|
||||
// BFD Configuration
|
||||
BFDEnabled bool
|
||||
BFDReceiveInterval uint32
|
||||
BFDTransmitInterval uint32
|
||||
BFDDetectMultiplier uint32
|
||||
}
|
||||
|
||||
// Config defines the BGP server configuration
|
||||
type BGPConfig struct {
|
||||
AS uint32
|
||||
RouterID string
|
||||
SourceIP string
|
||||
SourceIF string
|
||||
MpbgpNexthop string
|
||||
MpbgpIPv4 string
|
||||
MpbgpIPv6 string
|
||||
|
||||
HoldTime uint64
|
||||
KeepaliveInterval uint64
|
||||
|
||||
Peers []BGPPeer
|
||||
|
||||
Zebra ZebraConfig
|
||||
}
|
||||
|
||||
// Defines Zebra connection configuration. More on the topic - https://github.com/osrg/gobgp/blob/master/docs/sources/zebra.md#configuration
|
||||
type ZebraConfig struct {
|
||||
Enabled bool
|
||||
URL string
|
||||
Version uint32
|
||||
SoftwareName string
|
||||
}
|
||||
|
||||
// BGP Peer layout is as follows:
|
||||
// <address>:<AS>:<password>:<multihop>:<port>:<optional mpbgp options>:<BFD options>
|
||||
|
||||
// <address> - IP address of the peer. For IPv6 addresses, the address should be enclosed in square brackets (e.g. [fd00:100:64::2]). For unnumbered peers, the address should be prefixed with "unnumbered:" followed by the interface name (e.g. unnumbered:eth0).
|
||||
// <AS> - Autonomous System number of the peer (e.g. 65000)
|
||||
// <password> - Optional password for BGP authentication (e.g. secret)
|
||||
// <multihop> - Optional flag to indicate if this is a multihop peer (true/false, default: false)
|
||||
// <port> - Optional BGP port number (default: 179)
|
||||
// <optional mpbgp options> - Optional MP-BGP parameters in the format of key=value pairs separated by ';' (e.g. mpbgp_nexthop=auto_sourceif;mpbgp_ipv4=)
|
||||
// <BFD options> - Optional BFD parameters (if any) in the format of semicolon-separated values (enable, receive_interval, transmit_interval, detect_multiplier) (e.g. true;300;300;3)
|
||||
|
||||
// ParseBGPPeerConfig - take a string and parses it into an array of peers
|
||||
func ParseBGPPeerConfig(config string) (bgpPeers []BGPPeer, err error) {
|
||||
peers := strings.Split(config, ",")
|
||||
if len(peers) == 0 || config == "" {
|
||||
return nil, fmt.Errorf("no BGP Peer configurations found")
|
||||
}
|
||||
|
||||
for x := range peers {
|
||||
peerStr := peers[x]
|
||||
if peerStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Look at address peer
|
||||
isV6Peer := peerStr[0] == '['
|
||||
isUnnumberedPeer := strings.HasPrefix(peerStr, "unnumbered:")
|
||||
|
||||
address := ""
|
||||
if isV6Peer {
|
||||
addressEndPos := strings.IndexByte(peerStr, ']')
|
||||
if addressEndPos == -1 {
|
||||
return nil, fmt.Errorf("no matching ] found for IPv6 BGP Peer")
|
||||
}
|
||||
address = peerStr[1:addressEndPos]
|
||||
peerStr = peerStr[addressEndPos+1:]
|
||||
} else if isUnnumberedPeer {
|
||||
unnumberedEndPos := strings.IndexByte(peerStr, ':')
|
||||
peerStr = peerStr[unnumberedEndPos+1:]
|
||||
}
|
||||
|
||||
peer := strings.Split(peerStr, ":")
|
||||
if len(peer) < 2 && !isUnnumberedPeer {
|
||||
return nil, fmt.Errorf("mandatory peering params <host>:<AS> incomplete")
|
||||
}
|
||||
|
||||
iface := ""
|
||||
if isUnnumberedPeer {
|
||||
iface = peer[0]
|
||||
} else if !isV6Peer {
|
||||
address = peer[0]
|
||||
}
|
||||
|
||||
// Look at peer[1] for AS number
|
||||
var ASNumber uint64
|
||||
if len(peer) >= 2 {
|
||||
ASNumber, err = strconv.ParseUint(peer[1], 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BGP Peer AS format error [%s]", peer[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Look at peer[2] for password
|
||||
password := ""
|
||||
if len(peer) >= 3 {
|
||||
password = peer[2]
|
||||
}
|
||||
|
||||
// Look at peer[3] for multihop
|
||||
multiHop := false
|
||||
if len(peer) >= 4 && peer[3] != "" {
|
||||
multiHop, err = strconv.ParseBool(peer[3])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BGP MultiHop format error (true/false) [%s]", peer[3])
|
||||
}
|
||||
}
|
||||
|
||||
// Look at peer[4] for BGP port
|
||||
var port uint64
|
||||
if len(peer) >= 5 {
|
||||
if peer[4] == "" {
|
||||
port = 179
|
||||
} else {
|
||||
port, err = strconv.ParseUint(peer[4], 10, 16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BGP Peer Port format error [%s]", peer[4])
|
||||
}
|
||||
}
|
||||
} else if !isUnnumberedPeer {
|
||||
port = 179
|
||||
}
|
||||
|
||||
// Look at peer[5] for optional MP-BGP parameters
|
||||
var mpbgpNexthop, mpbgpIPv4, mpbgpIPv6 string
|
||||
|
||||
if len(peer) >= 6 && peer[5] != "" {
|
||||
configData := strings.Split(peer[5], ";")
|
||||
for _, cfg := range configData {
|
||||
c := strings.Split(cfg, "=")
|
||||
if len(c) < 2 {
|
||||
return nil, fmt.Errorf("peer configuration parameter '%s' is missing a value (expected key=value)", c[0])
|
||||
}
|
||||
switch c[0] {
|
||||
case "mpbgp_nexthop":
|
||||
mpbgpNexthop = c[1]
|
||||
case "mpbgp_ipv4":
|
||||
mpbgpIPv4 = c[1]
|
||||
case "mpbgp_ipv6":
|
||||
mpbgpIPv6 = c[1]
|
||||
default:
|
||||
return nil, fmt.Errorf("peer configuration parameter '%s' is not supported", c[0])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Look at peer[6] for optional BFD parameters (if any)
|
||||
bfdEnabled := false
|
||||
bfdReceiveInterval := uint64(300)
|
||||
bfdTransmitInterval := uint64(300)
|
||||
bfdDetectMultiplier := uint64(3)
|
||||
|
||||
if len(peer) >= 7 && peer[6] != "" {
|
||||
c := strings.Split(peer[6], ";")
|
||||
if len(c) < 4 {
|
||||
return nil, fmt.Errorf("BFD configuration error: at least 4 parameters are required (enable, receive_interval, transmit_interval, detect_multiplier) [%s]", peer[6])
|
||||
}
|
||||
bfdEnabled, err = strconv.ParseBool(c[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BFD configuration error: invalid value for bfd_enabled (true/false) [%s]", c[0])
|
||||
}
|
||||
|
||||
if c[1] != "" {
|
||||
bfdReceiveInterval, err = strconv.ParseUint(c[1], 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BFD configuration error: invalid value for bfd_receive_interval [%s]", c[1])
|
||||
}
|
||||
}
|
||||
|
||||
if c[2] != "" {
|
||||
bfdTransmitInterval, err = strconv.ParseUint(c[2], 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BFD configuration error: invalid value for bfd_transmit_interval [%s]", c[2])
|
||||
}
|
||||
}
|
||||
if c[3] != "" {
|
||||
bfdDetectMultiplier, err = strconv.ParseUint(c[3], 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("BFD configuration error: invalid value for bfd_detect_multiplier [%s]", c[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peerConfig := BGPPeer{
|
||||
Address: address,
|
||||
//nolint:gosec // previously parsed into uint32
|
||||
AS: uint32(ASNumber),
|
||||
Port: uint16(port),
|
||||
Interface: iface,
|
||||
Password: password,
|
||||
MultiHop: multiHop,
|
||||
MpbgpNexthop: mpbgpNexthop,
|
||||
MpbgpIPv4: mpbgpIPv4,
|
||||
MpbgpIPv6: mpbgpIPv6,
|
||||
BFDEnabled: bfdEnabled,
|
||||
BFDReceiveInterval: uint32(bfdReceiveInterval),
|
||||
BFDTransmitInterval: uint32(bfdTransmitInterval),
|
||||
BFDDetectMultiplier: uint32(bfdDetectMultiplier),
|
||||
}
|
||||
|
||||
bgpPeers = append(bgpPeers, peerConfig)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *BGPPeer) FindMpbgpAddresses(ap *api.Peer, server *BGPConfig) (string, string, error) {
|
||||
var ipv4Address, ipv6Address string
|
||||
|
||||
mode := server.MpbgpNexthop
|
||||
if p.MpbgpNexthop != "" {
|
||||
mode = p.MpbgpNexthop
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "fixed":
|
||||
ap.Transport.LocalAddress = server.SourceIP
|
||||
|
||||
ipv4 := server.MpbgpIPv4
|
||||
if p.MpbgpIPv4 != "" {
|
||||
ipv4 = p.MpbgpIPv4
|
||||
}
|
||||
|
||||
ipv6 := server.MpbgpIPv6
|
||||
if p.MpbgpIPv6 != "" {
|
||||
ipv6 = p.MpbgpIPv6
|
||||
}
|
||||
|
||||
if ipv4 == "" && ipv6 == "" {
|
||||
return "", "", fmt.Errorf("to use MP-BGP with fixed address at least one IPv4 or IPv6 address has to be provided [current - IPv4: %s, IPv6: %s]",
|
||||
ipv4, ipv6)
|
||||
}
|
||||
|
||||
if ipv4 != "" {
|
||||
if !utils.IsIPv4(ipv4) {
|
||||
return "", "", fmt.Errorf("provided address '%s' is not a valid IPv4 address", ipv4)
|
||||
}
|
||||
}
|
||||
if ipv6 != "" {
|
||||
if !utils.IsIPv6(ipv6) {
|
||||
return "", "", fmt.Errorf("provided address '%s' is not a valid IPv6 address", ipv6)
|
||||
}
|
||||
}
|
||||
|
||||
ipv4Address = ipv4
|
||||
ipv6Address = ipv6
|
||||
case "auto_sourceip":
|
||||
ap.Transport.LocalAddress = server.SourceIP
|
||||
|
||||
// Resolve the local interface by SourceIP
|
||||
iface, err := utils.GetInterfaceByIP(server.SourceIP)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get interface by IP: %v", err)
|
||||
}
|
||||
|
||||
if utils.IsIPv4(server.SourceIP) {
|
||||
// Get the non link-local IPv6 address on that interface
|
||||
ipv6Address, err = utils.GetNonLinkLocalIP(iface, netlink.FAMILY_V6)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get non link-local IPv6 address: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Get the non link-local IPv4 address on that interface
|
||||
ipv4Address, err = utils.GetNonLinkLocalIP(iface, netlink.FAMILY_V4)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get non link-local IPv4 address: %v", err)
|
||||
}
|
||||
}
|
||||
case "auto_sourceif":
|
||||
ap.Transport.BindInterface = server.SourceIF
|
||||
|
||||
iface, err := netlink.LinkByName(server.SourceIF)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get interface by name: %v", err)
|
||||
}
|
||||
|
||||
// Get the non link-local IPv4 address on that interface
|
||||
ipv4Address, err = utils.GetNonLinkLocalIP(&iface, netlink.FAMILY_V4)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get non link-local IPv4 address: %v", err)
|
||||
}
|
||||
|
||||
// Get the non link-local IPv6 address on that interface
|
||||
ipv6Address, err = utils.GetNonLinkLocalIP(&iface, netlink.FAMILY_V6)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get non link-local IPv6 address: %v", err)
|
||||
}
|
||||
default:
|
||||
return "", "", fmt.Errorf("option %q for MP-BPG nexthop is not supported", mode)
|
||||
}
|
||||
|
||||
return ipv4Address, ipv6Address, nil
|
||||
}
|
||||
|
||||
func (p *BGPPeer) SetMpbgpOptions(server *BGPConfig) {
|
||||
if p.MpbgpNexthop == "" {
|
||||
p.MpbgpNexthop = server.MpbgpNexthop
|
||||
}
|
||||
|
||||
if p.MpbgpIPv4 == "" {
|
||||
p.MpbgpIPv4 = server.MpbgpIPv4
|
||||
}
|
||||
|
||||
if p.MpbgpIPv6 == "" {
|
||||
p.MpbgpIPv6 = server.MpbgpIPv6
|
||||
}
|
||||
}
|
||||
38
pkg/kubevip/config_bgp_family_test.go
Normal file
38
pkg/kubevip/config_bgp_family_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
api "github.com/osrg/gobgp/v4/api"
|
||||
)
|
||||
|
||||
func TestFindMpbgpAddressesRejectsFixedAddressFamilyMismatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
peer BGPPeer
|
||||
}{
|
||||
{
|
||||
name: "IPv6 value in IPv4 field",
|
||||
peer: BGPPeer{
|
||||
MpbgpNexthop: "fixed",
|
||||
MpbgpIPv4: "2001:db8::20",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv4 value in IPv6 field",
|
||||
peer: BGPPeer{
|
||||
MpbgpNexthop: "fixed",
|
||||
MpbgpIPv6: "192.0.2.20",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := tt.peer.FindMpbgpAddresses(&api.Peer{Transport: &api.Transport{}}, &BGPConfig{})
|
||||
if err == nil {
|
||||
t.Fatal("FindMpbgpAddresses() error = nil, want address-family error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
116
pkg/kubevip/config_bgp_test.go
Normal file
116
pkg/kubevip/config_bgp_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBGPPeerConfig(t *testing.T) {
|
||||
type args struct {
|
||||
config string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantBgpPeers []BGPPeer
|
||||
wantErr bool
|
||||
}{
|
||||
|
||||
{
|
||||
name: "IPv4, default port",
|
||||
args: args{config: "192.168.0.10:65000::false,192.168.0.11:65000::false"},
|
||||
wantBgpPeers: []BGPPeer{
|
||||
{Address: "192.168.0.10", Port: 179, AS: 65000, MultiHop: false, BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
{Address: "192.168.0.11", Port: 179, AS: 65000, MultiHop: false, BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv4, different port",
|
||||
args: args{config: "192.168.0.10:65000::false:180,192.168.0.11:65000::false:190"},
|
||||
wantBgpPeers: []BGPPeer{
|
||||
{Address: "192.168.0.10", Port: 180, AS: 65000, MultiHop: false, BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
{Address: "192.168.0.11", Port: 190, AS: 65000, MultiHop: false, BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6, multi-protocol",
|
||||
args: args{config: "[fd00:1111:2222:3333:c7d9:7235:6bf7:5d52]:65501::false::mpbgp_nexthop=auto_sourceif"},
|
||||
wantBgpPeers: []BGPPeer{
|
||||
{Address: "fd00:1111:2222:3333:c7d9:7235:6bf7:5d52", Port: 179, AS: 65501, MultiHop: false, MpbgpNexthop: "auto_sourceif", BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6, multi-protocol, BFD, no multi-protocol options",
|
||||
args: args{config: "[fd00:1111:2222:3333:c7d9:7235:6bf7:5d52]:65501::false:::true;300;300;3"},
|
||||
wantBgpPeers: []BGPPeer{
|
||||
{Address: "fd00:1111:2222:3333:c7d9:7235:6bf7:5d52", Port: 179, AS: 65501, MultiHop: false, BFDEnabled: true, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6, multi-protocol, BFD",
|
||||
args: args{config: "[fd00:1111:2222:3333:c7d9:7235:6bf7:5d52]:65501::false::mpbgp_nexthop=auto_sourceif:true;300;300;3"},
|
||||
wantBgpPeers: []BGPPeer{
|
||||
{Address: "fd00:1111:2222:3333:c7d9:7235:6bf7:5d52", Port: 179, AS: 65501, MultiHop: false, MpbgpNexthop: "auto_sourceif", BFDEnabled: true, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6 bracketed, with password and multihop",
|
||||
args: args{config: "[fd00:100:64::2]:65000:secret:true"},
|
||||
wantBgpPeers: []BGPPeer{
|
||||
{Address: "fd00:100:64::2", Port: 179, AS: 65000, Password: "secret", MultiHop: true, BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6 bracketed, empty fields",
|
||||
args: args{config: "[fd00:100:64::2]:65000::false"},
|
||||
wantBgpPeers: []BGPPeer{
|
||||
{Address: "fd00:100:64::2", Port: 179, AS: 65000, MultiHop: false, BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unnumbered",
|
||||
args: args{config: "unnumbered:eth0,unnumbered:eth1:65000::true::mpbgp_nexthop=auto_sourceif"},
|
||||
wantBgpPeers: []BGPPeer{
|
||||
{Interface: "eth0", MultiHop: false, BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
{Interface: "eth1", Port: 179, AS: 65000, MultiHop: true, MpbgpNexthop: "auto_sourceif", BFDEnabled: false, BFDReceiveInterval: 300, BFDTransmitInterval: 300, BFDDetectMultiplier: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Completely empty config",
|
||||
args: args{config: ""},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Completely empty config (but with the seperators)",
|
||||
args: args{config: ":::::::"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed parameter (no value)",
|
||||
args: args{config: "1.2.3.4:65000/mpbgp_nexthop"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Unsupported parameter",
|
||||
args: args{config: "1.2.3.4:65000;unknown=value"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed IPv6 (no matching bracket)",
|
||||
args: args{config: "[fd00:100:64::2:65000"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotBgpPeers, err := ParseBGPPeerConfig(tt.args.config)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseBGPPeerConfig() error = \n%v, wantErr \n%v %v", err, tt.wantErr, gotBgpPeers)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(gotBgpPeers, tt.wantBgpPeers) {
|
||||
t.Errorf("ParseBGPPeerConfig() = \n%v, want \n%v", gotBgpPeers, tt.wantBgpPeers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,14 @@ import (
|
||||
"math"
|
||||
"math/bits"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
"github.com/kube-vip/kube-vip/pkg/debouncer"
|
||||
"github.com/kube-vip/kube-vip/pkg/detector"
|
||||
"github.com/kube-vip/kube-vip/pkg/utils"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
// ParseEnvironment - will popultate the configuration from environment variables
|
||||
@@ -19,17 +23,20 @@ func ParseEnvironment(c *Config) error {
|
||||
}
|
||||
// Ensure that logging is set through the environment variables
|
||||
env := os.Getenv(vipLogLevel)
|
||||
// Set default value
|
||||
if env == "" {
|
||||
env = "4"
|
||||
}
|
||||
|
||||
if env != "" {
|
||||
logLevel, err := strconv.ParseUint(env, 10, 32)
|
||||
logLevel, err := strconv.ParseInt(env, 10, 32)
|
||||
if err != nil {
|
||||
panic("Unable to parse environment variable [vip_loglevel], should be int")
|
||||
return fmt.Errorf("unable to parse environment variable [vip_loglevel], should be int: %w", err)
|
||||
}
|
||||
c.Logging = int(logLevel)
|
||||
c.Logging = int32(logLevel)
|
||||
}
|
||||
|
||||
if env = os.Getenv(instanceName); env == "" {
|
||||
env = os.Getenv(strings.ToUpper(instanceName))
|
||||
}
|
||||
if env != "" {
|
||||
c.InstanceName = env
|
||||
}
|
||||
|
||||
// Find interface
|
||||
@@ -38,16 +45,46 @@ func ParseEnvironment(c *Config) error {
|
||||
c.Interface = env
|
||||
}
|
||||
|
||||
env = os.Getenv(vipInterfaceLoGlobal)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.LoInterfaceGlobalScope = b
|
||||
}
|
||||
|
||||
env = os.Getenv(vipLoseLeadership)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.LoseLeadership = b
|
||||
}
|
||||
|
||||
env = os.Getenv(vipLoseLeadershipTimeoutSeconds)
|
||||
if env != "" {
|
||||
i, err := strconv.ParseInt(env, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing env var %s (value: %s): %w", vipLoseLeadershipTimeoutSeconds, env, err)
|
||||
}
|
||||
c.LoseLeadershipTimeoutSeconds = int(i)
|
||||
}
|
||||
// Find (services) interface
|
||||
env = os.Getenv(vipServicesInterface)
|
||||
if env != "" {
|
||||
c.ServicesInterface = env
|
||||
}
|
||||
|
||||
// Find provider configuration
|
||||
env = os.Getenv(providerConfig)
|
||||
// Tolerate a down interface
|
||||
env = os.Getenv(vipAllowInterfaceNotUp)
|
||||
if env != "" {
|
||||
c.ProviderConfig = env
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.AllowInterfaceNotUp = b
|
||||
}
|
||||
|
||||
// Find Kubernetes Leader Election configuration
|
||||
@@ -127,11 +164,11 @@ func ParseEnvironment(c *Config) error {
|
||||
// Find vip port
|
||||
env = os.Getenv(port)
|
||||
if env != "" {
|
||||
i, err := strconv.ParseInt(env, 10, 32)
|
||||
i, err := strconv.ParseUint(env, 10, 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Port = int(i)
|
||||
c.Port = uint16(i)
|
||||
}
|
||||
|
||||
// Find vipDdns
|
||||
@@ -233,12 +270,6 @@ func ParseEnvironment(c *Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Find vip address cidr range
|
||||
env = os.Getenv(vipCidr)
|
||||
if env != "" {
|
||||
c.VIPCIDR = env
|
||||
}
|
||||
|
||||
// Find vip address subnet
|
||||
env = os.Getenv(vipSubnet)
|
||||
if env != "" {
|
||||
@@ -296,6 +327,18 @@ func ParseEnvironment(c *Config) error {
|
||||
c.ArpBroadcastRate = 3000
|
||||
}
|
||||
|
||||
// Determine if VIP should be preserved on leadership loss
|
||||
// true: VIP addresses remain on interface, only ARP/NDP broadcasting stops
|
||||
// false (default): VIP addresses are deleted on leadership loss (legacy behavior)
|
||||
env = os.Getenv(vipPreserveOnLeadershipLoss)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.PreserveVIPOnLeadershipLoss = b
|
||||
}
|
||||
|
||||
// Wireguard Mode
|
||||
env = os.Getenv(vipWireguard)
|
||||
if env != "" {
|
||||
@@ -325,12 +368,12 @@ func ParseEnvironment(c *Config) error {
|
||||
}
|
||||
if i >= 0 && i <= math.MaxInt {
|
||||
c.RoutingTableID = int(i)
|
||||
return nil
|
||||
} else if i < 0 {
|
||||
return fmt.Errorf("no support of negative [%d] in env var %q", i, vipRoutingTableID)
|
||||
} else {
|
||||
// +1 for the signing bit as it is 0 for positive integers
|
||||
return fmt.Errorf("no support for int64, system natively supports [int%d]", bits.OnesCount(math.MaxInt)+1)
|
||||
}
|
||||
// +1 for the signing bit as it is 0 for positive integers
|
||||
return fmt.Errorf("no support for int64, system natively supports [int%d]", bits.OnesCount(math.MaxInt)+1)
|
||||
}
|
||||
|
||||
// Routing Table Type
|
||||
@@ -363,12 +406,46 @@ func ParseEnvironment(c *Config) error {
|
||||
c.CleanRoutingTable = b
|
||||
}
|
||||
|
||||
// Skip Duplicate Address Detection when adding the VIP address
|
||||
env = os.Getenv(vipSkipDAD)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.SkipDAD = b
|
||||
}
|
||||
|
||||
// DNS mode
|
||||
env = os.Getenv(dnsMode)
|
||||
if env != "" {
|
||||
c.DNSMode = env
|
||||
}
|
||||
|
||||
// DHCP mode
|
||||
env = os.Getenv(dhcpMode)
|
||||
if env != "" {
|
||||
c.DHCPMode = env
|
||||
} else {
|
||||
if c.DNSMode != "first" {
|
||||
c.DHCPMode = c.DNSMode
|
||||
} else {
|
||||
c.DHCPMode = strings.ToLower(utils.IPv4Family)
|
||||
}
|
||||
}
|
||||
|
||||
// DHCP backoff attempts
|
||||
env = os.Getenv(dhcpBackoffAttempts)
|
||||
if env != "" {
|
||||
tmp, err := strconv.ParseInt(env, 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tmp >= 0 {
|
||||
c.DHCPBackoffAttempts = uint(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
// Disable updates for services (status.LoadBalancer.Ingress will not be updated)
|
||||
env = os.Getenv(disableServiceUpdates)
|
||||
if env != "" {
|
||||
@@ -389,6 +466,15 @@ func ParseEnvironment(c *Config) error {
|
||||
c.EnableBGP = b
|
||||
}
|
||||
|
||||
env = os.Getenv(bgpAttachIPToInterface)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.BGPAttachIPToInterface = b
|
||||
}
|
||||
|
||||
// BGP Router interface determines an interface that we can use to find an address for
|
||||
env = os.Getenv(bgpRouterInterface)
|
||||
if env != "" {
|
||||
@@ -428,13 +514,31 @@ func ParseEnvironment(c *Config) error {
|
||||
// Peer AS
|
||||
env = os.Getenv(bgpPeers)
|
||||
if env != "" {
|
||||
peers, err := bgp.ParseBGPPeerConfig(env)
|
||||
peers, err := ParseBGPPeerConfig(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.BGPConfig.Peers = peers
|
||||
}
|
||||
|
||||
// MPBGP mode
|
||||
env = os.Getenv(mpbgpNexthop)
|
||||
if env != "" {
|
||||
c.BGPConfig.MpbgpNexthop = env
|
||||
}
|
||||
|
||||
// MPBGP fixed IPv4
|
||||
env = os.Getenv(mpbgpIPv4)
|
||||
if env != "" {
|
||||
c.BGPConfig.MpbgpIPv4 = env
|
||||
}
|
||||
|
||||
// MPBGP fixed IPv6
|
||||
env = os.Getenv(mpbgpIPv6)
|
||||
if env != "" {
|
||||
c.BGPConfig.MpbgpIPv6 = env
|
||||
}
|
||||
|
||||
// BGP Peer mutlihop
|
||||
env = os.Getenv(bgpMultiHop)
|
||||
if env != "" {
|
||||
@@ -489,28 +593,66 @@ func ParseEnvironment(c *Config) error {
|
||||
c.BGPConfig.KeepaliveInterval = u64
|
||||
}
|
||||
|
||||
// Enable the Equinix Metal API calls
|
||||
env = os.Getenv(vipPacket)
|
||||
// BGP health check options
|
||||
env = os.Getenv(controlPlaneHealthCheckAddress)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
c.ControlPlaneHealthCheck.Address = env
|
||||
}
|
||||
env = os.Getenv(controlPlaneHealthCheckPeriodSeconds)
|
||||
if env != "" {
|
||||
i, err := strconv.ParseInt(env, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing env var %s (value: %s): %w", controlPlaneHealthCheckPeriodSeconds, env, err)
|
||||
}
|
||||
c.ControlPlaneHealthCheck.PeriodSeconds = int(i)
|
||||
}
|
||||
env = os.Getenv(controlPlaneHealthCheckTimeoutSeconds)
|
||||
if env != "" {
|
||||
i, err := strconv.ParseInt(env, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing env var %s (value: %s): %w", controlPlaneHealthCheckTimeoutSeconds, env, err)
|
||||
}
|
||||
c.ControlPlaneHealthCheck.TimeoutSeconds = int(i)
|
||||
}
|
||||
env = os.Getenv(controlPlaneHealthCheckFailureThreshold)
|
||||
if env != "" {
|
||||
i, err := strconv.ParseInt(env, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing env var %s (value: %s): %w", controlPlaneHealthCheckFailureThreshold, env, err)
|
||||
}
|
||||
c.ControlPlaneHealthCheck.FailureThreshold = int(i)
|
||||
}
|
||||
env = os.Getenv(controlPlaneHealthCheckCAPath)
|
||||
if env != "" {
|
||||
c.ControlPlaneHealthCheck.CAPath = env
|
||||
}
|
||||
|
||||
env = os.Getenv(zebraEnable)
|
||||
if env != "" {
|
||||
result, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.EnableMetal = b
|
||||
c.BGPConfig.Zebra.Enabled = result
|
||||
}
|
||||
|
||||
// Find the Equinix Metal project name
|
||||
env = os.Getenv(vipPacketProject)
|
||||
env = os.Getenv(zebraURL)
|
||||
if env != "" {
|
||||
// TODO - parse address net.Host()
|
||||
c.MetalProject = env
|
||||
c.BGPConfig.Zebra.URL = env
|
||||
}
|
||||
|
||||
// Find the Equinix Metal project ID
|
||||
env = os.Getenv(vipPacketProjectID)
|
||||
env = os.Getenv(zebraVersion)
|
||||
if env != "" {
|
||||
// TODO - parse address net.Host()
|
||||
c.MetalProjectID = env
|
||||
u64, err := strconv.ParseUint(env, 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.BGPConfig.Zebra.Version = uint32(u64)
|
||||
}
|
||||
|
||||
env = os.Getenv(zebraSoftwareName)
|
||||
if env != "" {
|
||||
c.BGPConfig.Zebra.SoftwareName = env
|
||||
}
|
||||
|
||||
// Enable the load-balancer
|
||||
@@ -526,11 +668,11 @@ func ParseEnvironment(c *Config) error {
|
||||
// Find loadbalancer port
|
||||
env = os.Getenv(lbPort)
|
||||
if env != "" {
|
||||
i, err := strconv.ParseInt(env, 10, 32)
|
||||
i, err := strconv.ParseUint(env, 10, 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.LoadBalancerPort = int(i)
|
||||
c.LoadBalancerPort = uint16(i)
|
||||
}
|
||||
|
||||
// Find loadbalancer forwarding method
|
||||
@@ -585,19 +727,38 @@ func ParseEnvironment(c *Config) error {
|
||||
c.EgressWithNftables = b
|
||||
}
|
||||
|
||||
env = os.Getenv(perServiceElectionOnDemand)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.PerServiceElectionOnDemand = b
|
||||
}
|
||||
|
||||
// if this is set then we're enabling the internal SNAT rule that kube-vip adds to the egress chain
|
||||
env = os.Getenv(egressEnableInternalSNAT)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.EnableInternalSNAT = b
|
||||
}
|
||||
|
||||
// check to see if we're using a specific path to the Kubernetes config file
|
||||
env = os.Getenv(k8sConfigFile)
|
||||
if env != "" {
|
||||
c.K8sConfigFile = env
|
||||
}
|
||||
|
||||
env = os.Getenv(enableEndpointSlices)
|
||||
env = os.Getenv(enableEndpoints)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.EnableEndpointSlices = b
|
||||
c.EnableEndpoints = b
|
||||
}
|
||||
|
||||
env = os.Getenv(mirrorDestInterface)
|
||||
@@ -619,5 +780,364 @@ func ParseEnvironment(c *Config) error {
|
||||
c.BackendHealthCheckInterval = int(i)
|
||||
}
|
||||
|
||||
env = os.Getenv(healthCheckPort)
|
||||
if env != "" {
|
||||
i, err := strconv.ParseInt(env, 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if i < 1024 {
|
||||
return fmt.Errorf("health check port should be > 1024")
|
||||
}
|
||||
c.HealthCheckPort = int(i)
|
||||
}
|
||||
|
||||
env = os.Getenv(enableUPNP)
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.EnableUPNP = b
|
||||
}
|
||||
|
||||
if env = os.Getenv(egressClean); env == "" {
|
||||
env = os.Getenv(strings.ToUpper(egressClean))
|
||||
}
|
||||
if env != "" {
|
||||
b, err := strconv.ParseBool(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.EgressClean = b
|
||||
}
|
||||
|
||||
// check for configuration file path
|
||||
env = os.Getenv(configFile)
|
||||
if env != "" {
|
||||
c.ConfigFile = env
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfigFromFile loads configuration from a JSON or YAML file
|
||||
func LoadConfigFromFile(configFilePath string) (*Config, error) {
|
||||
if configFilePath == "" {
|
||||
return nil, fmt.Errorf("config file path is empty")
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(configFilePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("config file does not exist: %s", configFilePath)
|
||||
}
|
||||
|
||||
// Read file content
|
||||
data, err := os.ReadFile(configFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file %s: %v", configFilePath, err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
ext := strings.ToLower(filepath.Ext(configFilePath))
|
||||
|
||||
switch ext {
|
||||
case ".json":
|
||||
err = json.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON config file %s: %v", configFilePath, err)
|
||||
}
|
||||
case ".yaml", ".yml":
|
||||
err = yaml.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML config file %s: %v", configFilePath, err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported config file format %s. Supported formats: .json, .yaml, .yml", ext)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// MergeConfigFromFile merges configuration loaded from file with existing config
|
||||
// Priority: command line flags > environment variables > config file
|
||||
func MergeConfigFromFile(c *Config, configFilePath string) error {
|
||||
if configFilePath == "" {
|
||||
return nil // No config file specified, nothing to merge
|
||||
}
|
||||
|
||||
fileConfig, err := LoadConfigFromFile(configFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge file config with existing config
|
||||
// Only set values from file if they haven't been set by flags or env vars
|
||||
mergeConfigValues(c, fileConfig)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeConfigValues merges values from fileConfig into baseConfig
|
||||
// Only overwrites zero values in baseConfig
|
||||
func mergeConfigValues(baseConfig, fileConfig *Config) {
|
||||
// Basic configuration
|
||||
if baseConfig.Logging == 0 && fileConfig.Logging != 0 {
|
||||
baseConfig.Logging = fileConfig.Logging
|
||||
}
|
||||
|
||||
// Network configuration
|
||||
if baseConfig.Interface == "" && fileConfig.Interface != "" {
|
||||
baseConfig.Interface = fileConfig.Interface
|
||||
}
|
||||
if baseConfig.ServicesInterface == "" && fileConfig.ServicesInterface != "" {
|
||||
baseConfig.ServicesInterface = fileConfig.ServicesInterface
|
||||
}
|
||||
if baseConfig.VIP == "" && fileConfig.VIP != "" {
|
||||
baseConfig.VIP = fileConfig.VIP
|
||||
}
|
||||
if baseConfig.VIPSubnet == "" && fileConfig.VIPSubnet != "" {
|
||||
baseConfig.VIPSubnet = fileConfig.VIPSubnet
|
||||
}
|
||||
if baseConfig.Address == "" && fileConfig.Address != "" {
|
||||
baseConfig.Address = fileConfig.Address
|
||||
}
|
||||
if baseConfig.Port == 0 && fileConfig.Port != 0 {
|
||||
baseConfig.Port = fileConfig.Port
|
||||
}
|
||||
if baseConfig.NodeName == "" && fileConfig.NodeName != "" {
|
||||
baseConfig.NodeName = fileConfig.NodeName
|
||||
}
|
||||
|
||||
// Boolean flags - only merge if not explicitly set
|
||||
if !baseConfig.EnableARP && fileConfig.EnableARP {
|
||||
baseConfig.EnableARP = fileConfig.EnableARP
|
||||
}
|
||||
if !baseConfig.EnableBGP && fileConfig.EnableBGP {
|
||||
baseConfig.EnableBGP = fileConfig.EnableBGP
|
||||
}
|
||||
if !baseConfig.BGPAttachIPToInterface && fileConfig.BGPAttachIPToInterface {
|
||||
baseConfig.BGPAttachIPToInterface = fileConfig.BGPAttachIPToInterface
|
||||
}
|
||||
if !baseConfig.EnableWireguard && fileConfig.EnableWireguard {
|
||||
baseConfig.EnableWireguard = fileConfig.EnableWireguard
|
||||
}
|
||||
if !baseConfig.EnableRoutingTable && fileConfig.EnableRoutingTable {
|
||||
baseConfig.EnableRoutingTable = fileConfig.EnableRoutingTable
|
||||
}
|
||||
if !baseConfig.EnableControlPlane && fileConfig.EnableControlPlane {
|
||||
baseConfig.EnableControlPlane = fileConfig.EnableControlPlane
|
||||
}
|
||||
if !baseConfig.DetectControlPlane && fileConfig.DetectControlPlane {
|
||||
baseConfig.DetectControlPlane = fileConfig.DetectControlPlane
|
||||
}
|
||||
if !baseConfig.EnableServices && fileConfig.EnableServices {
|
||||
baseConfig.EnableServices = fileConfig.EnableServices
|
||||
}
|
||||
if !baseConfig.EnableServicesElection && fileConfig.EnableServicesElection {
|
||||
baseConfig.EnableServicesElection = fileConfig.EnableServicesElection
|
||||
}
|
||||
if !baseConfig.EnableNodeLabeling && fileConfig.EnableNodeLabeling {
|
||||
baseConfig.EnableNodeLabeling = fileConfig.EnableNodeLabeling
|
||||
}
|
||||
if !baseConfig.EnableLoadBalancer && fileConfig.EnableLoadBalancer {
|
||||
baseConfig.EnableLoadBalancer = fileConfig.EnableLoadBalancer
|
||||
}
|
||||
if !baseConfig.DDNS && fileConfig.DDNS {
|
||||
baseConfig.DDNS = fileConfig.DDNS
|
||||
}
|
||||
if !baseConfig.SingleNode && fileConfig.SingleNode {
|
||||
baseConfig.SingleNode = fileConfig.SingleNode
|
||||
}
|
||||
if !baseConfig.StartAsLeader && fileConfig.StartAsLeader {
|
||||
baseConfig.StartAsLeader = fileConfig.StartAsLeader
|
||||
}
|
||||
if !baseConfig.PreserveVIPOnLeadershipLoss && fileConfig.PreserveVIPOnLeadershipLoss {
|
||||
baseConfig.PreserveVIPOnLeadershipLoss = fileConfig.PreserveVIPOnLeadershipLoss
|
||||
}
|
||||
|
||||
// Service configuration
|
||||
if baseConfig.Namespace == "" && fileConfig.Namespace != "" {
|
||||
baseConfig.Namespace = fileConfig.Namespace
|
||||
}
|
||||
if baseConfig.ServiceNamespace == "" && fileConfig.ServiceNamespace != "" {
|
||||
baseConfig.ServiceNamespace = fileConfig.ServiceNamespace
|
||||
}
|
||||
if baseConfig.ServicesLeaseName == "" && fileConfig.ServicesLeaseName != "" {
|
||||
baseConfig.ServicesLeaseName = fileConfig.ServicesLeaseName
|
||||
}
|
||||
// LoadBalancer configuration
|
||||
if baseConfig.LoadBalancerPort == 0 && fileConfig.LoadBalancerPort != 0 {
|
||||
baseConfig.LoadBalancerPort = fileConfig.LoadBalancerPort
|
||||
}
|
||||
if baseConfig.LoadBalancerForwardingMethod == "" && fileConfig.LoadBalancerForwardingMethod != "" {
|
||||
baseConfig.LoadBalancerForwardingMethod = fileConfig.LoadBalancerForwardingMethod
|
||||
}
|
||||
if baseConfig.LoadBalancerClassName == "" && fileConfig.LoadBalancerClassName != "" {
|
||||
baseConfig.LoadBalancerClassName = fileConfig.LoadBalancerClassName
|
||||
}
|
||||
|
||||
// Routing Table configuration
|
||||
if baseConfig.RoutingTableID == 0 && fileConfig.RoutingTableID != 0 {
|
||||
baseConfig.RoutingTableID = fileConfig.RoutingTableID
|
||||
}
|
||||
if baseConfig.RoutingTableType == 0 && fileConfig.RoutingTableType != 0 {
|
||||
baseConfig.RoutingTableType = fileConfig.RoutingTableType
|
||||
}
|
||||
if baseConfig.RoutingProtocol == 0 && fileConfig.RoutingProtocol != 0 {
|
||||
baseConfig.RoutingProtocol = fileConfig.RoutingProtocol
|
||||
}
|
||||
|
||||
// BGP configuration
|
||||
mergeBGPConfig(&baseConfig.BGPConfig, &fileConfig.BGPConfig)
|
||||
|
||||
// Kubernetes configuration
|
||||
if baseConfig.K8sConfigFile == "" && fileConfig.K8sConfigFile != "" {
|
||||
baseConfig.K8sConfigFile = fileConfig.K8sConfigFile
|
||||
}
|
||||
|
||||
// Leader Election configuration
|
||||
mergeLeaderElectionConfig(&baseConfig.KubernetesLeaderElection, &fileConfig.KubernetesLeaderElection)
|
||||
|
||||
// BGP health check configuration
|
||||
mergeHealthCheck(&baseConfig.ControlPlaneHealthCheck, &fileConfig.ControlPlaneHealthCheck)
|
||||
|
||||
// Prometheus configuration
|
||||
if baseConfig.PrometheusHTTPServer == "" && fileConfig.PrometheusHTTPServer != "" {
|
||||
baseConfig.PrometheusHTTPServer = fileConfig.PrometheusHTTPServer
|
||||
}
|
||||
|
||||
// DNS configuration
|
||||
if baseConfig.DNSMode == "" && fileConfig.DNSMode != "" {
|
||||
baseConfig.DNSMode = fileConfig.DNSMode
|
||||
}
|
||||
|
||||
// DHCP configuration - mode
|
||||
if baseConfig.DHCPMode == "" && fileConfig.DHCPMode != "" {
|
||||
baseConfig.DHCPMode = fileConfig.DHCPMode
|
||||
}
|
||||
|
||||
// DHCP configuration - backoff attempts
|
||||
if baseConfig.DHCPBackoffAttempts == DefaultDHCPBackoffAttempts && fileConfig.DHCPBackoffAttempts != DefaultDHCPBackoffAttempts {
|
||||
baseConfig.DHCPBackoffAttempts = fileConfig.DHCPBackoffAttempts
|
||||
}
|
||||
|
||||
// Health check configuration (HTTP listener for kube-vip readiness)
|
||||
if baseConfig.HealthCheckPort == 0 && fileConfig.HealthCheckPort != 0 {
|
||||
baseConfig.HealthCheckPort = fileConfig.HealthCheckPort
|
||||
}
|
||||
|
||||
// Instance configuration
|
||||
if baseConfig.InstanceName == "" && fileConfig.InstanceName != "" {
|
||||
baseConfig.InstanceName = fileConfig.InstanceName
|
||||
}
|
||||
|
||||
// Egress configuration
|
||||
if baseConfig.EgressPodCidr == "" && fileConfig.EgressPodCidr != "" {
|
||||
baseConfig.EgressPodCidr = fileConfig.EgressPodCidr
|
||||
}
|
||||
if baseConfig.EgressServiceCidr == "" && fileConfig.EgressServiceCidr != "" {
|
||||
baseConfig.EgressServiceCidr = fileConfig.EgressServiceCidr
|
||||
}
|
||||
// Mirror configuration
|
||||
if baseConfig.MirrorDestInterface == "" && fileConfig.MirrorDestInterface != "" {
|
||||
baseConfig.MirrorDestInterface = fileConfig.MirrorDestInterface
|
||||
}
|
||||
|
||||
// Iptables configuration
|
||||
if baseConfig.IptablesBackend == "" && fileConfig.IptablesBackend != "" {
|
||||
baseConfig.IptablesBackend = fileConfig.IptablesBackend
|
||||
}
|
||||
|
||||
// Backend health check interval
|
||||
if baseConfig.BackendHealthCheckInterval == 0 && fileConfig.BackendHealthCheckInterval != 0 {
|
||||
baseConfig.BackendHealthCheckInterval = fileConfig.BackendHealthCheckInterval
|
||||
}
|
||||
|
||||
// ARP broadcast rate
|
||||
if baseConfig.ArpBroadcastRate == 0 && fileConfig.ArpBroadcastRate != 0 {
|
||||
baseConfig.ArpBroadcastRate = fileConfig.ArpBroadcastRate
|
||||
}
|
||||
|
||||
// Annotations
|
||||
if baseConfig.Annotations == "" && fileConfig.Annotations != "" {
|
||||
baseConfig.Annotations = fileConfig.Annotations
|
||||
}
|
||||
|
||||
// Load balancers slice
|
||||
if len(baseConfig.LoadBalancers) == 0 && len(fileConfig.LoadBalancers) > 0 {
|
||||
baseConfig.LoadBalancers = fileConfig.LoadBalancers
|
||||
}
|
||||
|
||||
// Debounce time for watch events
|
||||
if baseConfig.DebounceTime == debouncer.DefaultTime && fileConfig.DebounceTime != debouncer.DefaultTime {
|
||||
baseConfig.DebounceTime = fileConfig.DebounceTime
|
||||
}
|
||||
|
||||
if baseConfig.LoseLeadershipTimeoutSeconds == 0 && fileConfig.LoseLeadershipTimeoutSeconds != 0 {
|
||||
baseConfig.LoseLeadershipTimeoutSeconds = fileConfig.LoseLeadershipTimeoutSeconds
|
||||
}
|
||||
}
|
||||
|
||||
// mergeBGPConfig merges BGP configuration
|
||||
func mergeBGPConfig(base, file *BGPConfig) {
|
||||
if base.RouterID == "" && file.RouterID != "" {
|
||||
base.RouterID = file.RouterID
|
||||
}
|
||||
if base.AS == 0 && file.AS != 0 {
|
||||
base.AS = file.AS
|
||||
}
|
||||
if base.SourceIF == "" && file.SourceIF != "" {
|
||||
base.SourceIF = file.SourceIF
|
||||
}
|
||||
if base.SourceIP == "" && file.SourceIP != "" {
|
||||
base.SourceIP = file.SourceIP
|
||||
}
|
||||
if base.HoldTime == 0 && file.HoldTime != 0 {
|
||||
base.HoldTime = file.HoldTime
|
||||
}
|
||||
if base.KeepaliveInterval == 0 && file.KeepaliveInterval != 0 {
|
||||
base.KeepaliveInterval = file.KeepaliveInterval
|
||||
}
|
||||
if len(base.Peers) == 0 && len(file.Peers) > 0 {
|
||||
base.Peers = file.Peers
|
||||
}
|
||||
}
|
||||
|
||||
// mergeLeaderElectionConfig merges leader election configuration
|
||||
func mergeLeaderElectionConfig(base, file *KubernetesLeaderElection) {
|
||||
if base.LeaseName == "" && file.LeaseName != "" {
|
||||
base.LeaseName = file.LeaseName
|
||||
}
|
||||
if base.LeaseDuration == 0 && file.LeaseDuration != 0 {
|
||||
base.LeaseDuration = file.LeaseDuration
|
||||
}
|
||||
if base.RenewDeadline == 0 && file.RenewDeadline != 0 {
|
||||
base.RenewDeadline = file.RenewDeadline
|
||||
}
|
||||
if base.RetryPeriod == 0 && file.RetryPeriod != 0 {
|
||||
base.RetryPeriod = file.RetryPeriod
|
||||
}
|
||||
if len(base.LeaseAnnotations) == 0 && len(file.LeaseAnnotations) > 0 {
|
||||
base.LeaseAnnotations = file.LeaseAnnotations
|
||||
}
|
||||
}
|
||||
|
||||
// mergeHealthCheck merges HTTP health check configuration for BGP route advertisement.
|
||||
func mergeHealthCheck(base, file *HealthCheck) {
|
||||
if base.Address == "" && file.Address != "" {
|
||||
base.Address = file.Address
|
||||
}
|
||||
if base.PeriodSeconds == 0 && file.PeriodSeconds != 0 {
|
||||
base.PeriodSeconds = file.PeriodSeconds
|
||||
}
|
||||
if base.TimeoutSeconds == 0 && file.TimeoutSeconds != 0 {
|
||||
base.TimeoutSeconds = file.TimeoutSeconds
|
||||
}
|
||||
if base.FailureThreshold == 0 && file.FailureThreshold != 0 {
|
||||
base.FailureThreshold = file.FailureThreshold
|
||||
}
|
||||
if base.CAPath == "" && file.CAPath != "" {
|
||||
base.CAPath = file.CAPath
|
||||
}
|
||||
}
|
||||
|
||||
41
pkg/kubevip/config_environment_test.go
Normal file
41
pkg/kubevip/config_environment_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEnvironmentSkipDAD(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "unset keeps default false", value: "", want: false},
|
||||
{name: "true enables", value: "true", want: true},
|
||||
{name: "false disables", value: "false", want: false},
|
||||
{name: "garbage errors", value: "not-a-bool", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.value != "" {
|
||||
t.Setenv(vipSkipDAD, tc.value)
|
||||
}
|
||||
c := &Config{}
|
||||
err := ParseEnvironment(c)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c.SkipDAD != tc.want {
|
||||
t.Fatalf("SkipDAD = %v, want %v", c.SkipDAD, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,35 +9,50 @@ const (
|
||||
// vip_arpRate - defines the rate of gARP broadcasts
|
||||
vipArpRate = "vip_arpRate"
|
||||
|
||||
// vipPreserveOnLeadershipLoss - if true, VIP addresses will remain on interface when leadership is lost
|
||||
vipPreserveOnLeadershipLoss = "vip_preserve_on_leadership_loss"
|
||||
|
||||
// vipLeaderElection - defines if the kubernetes algorithm should be used
|
||||
vipLeaderElection = "vip_leaderelection"
|
||||
|
||||
// vipLeaseName - defines the name of the lease lock
|
||||
vipLeaseName = "vip_leasename"
|
||||
|
||||
// vipLeaderElection - defines if the kubernetes algorithm should be used
|
||||
// vipLeaseDuration - defines how long the current leader is considered valid
|
||||
vipLeaseDuration = "vip_leaseduration"
|
||||
|
||||
// vipLeaderElection - defines if the kubernetes algorithm should be used
|
||||
// vipRenewDeadline - defines how long the leader has to renew the lease before losing leadership
|
||||
vipRenewDeadline = "vip_renewdeadline"
|
||||
|
||||
// vipLeaderElection - defines if the kubernetes algorithm should be used
|
||||
// vipRetryPeriod - defines the time between attempts to acquire/renew the lease
|
||||
vipRetryPeriod = "vip_retryperiod"
|
||||
|
||||
// vipLeaderElection - defines the annotations given to the lease lock
|
||||
// vipLeaseAnnotations - defines the annotations given to the lease lock
|
||||
vipLeaseAnnotations = "vip_leaseannotations"
|
||||
|
||||
// vipLogLevel - defines the level of logging to produce (5 being the most verbose)
|
||||
vipLogLevel = "vip_loglevel"
|
||||
|
||||
// instanceName identifies this kube-vip deployment when naming host-global resources
|
||||
instanceName = "instance_name"
|
||||
|
||||
// vipInterface - defines the interface that the vip should bind too
|
||||
vipInterface = "vip_interface"
|
||||
|
||||
// vipLoseLeadership - defines if leader should lose leadership if network interface is down
|
||||
vipLoseLeadership = "vip_loseleadership"
|
||||
|
||||
// vipLoseLeadershipTimeout - defines the timeout for lose leadership
|
||||
vipLoseLeadershipTimeoutSeconds = "vip_loseleadership_timeout_seconds"
|
||||
|
||||
// vipInterfaceLoGlobal - defines if the lo interface (if used) should have a global scope
|
||||
vipInterfaceLoGlobal = "vip_interfaceloglobal"
|
||||
|
||||
// vipServicesInterface - defines the interface that the service vips should bind too
|
||||
vipServicesInterface = "vip_servicesinterface"
|
||||
|
||||
// vipCidr - defines the cidr that the vip will use (for BGP)
|
||||
vipCidr = "vip_cidr"
|
||||
// vipAllowInterfaceNotUp - defines if kube-vip should tolerate a down interface
|
||||
vipAllowInterfaceNotUp = "vip_allow_interface_not_up"
|
||||
|
||||
// vipSubnet - defines the subnet that the vip will use
|
||||
vipSubnet = "vip_subnet"
|
||||
@@ -51,6 +66,11 @@ const (
|
||||
// egressWithNftables - enables using nftables over iptables
|
||||
egressWithNftables = "egress_withnftables"
|
||||
|
||||
// perServiceElectionOnDemand - enables kube-vip to use per-service election for annotated services
|
||||
perServiceElectionOnDemand = "per_service_election_on_demand"
|
||||
|
||||
// egressEnableInternalSNAT - enables the internal SNAT rule that kube-vip adds to the egress chain
|
||||
egressEnableInternalSNAT = "egress_enableinternalsnat"
|
||||
/////////////////////////////////////
|
||||
// TO DO:
|
||||
// Determine how to tidy this mess up
|
||||
@@ -83,20 +103,10 @@ const (
|
||||
// vipStartLeader - will start this instance as the leader of the cluster
|
||||
vipStartLeader = "vip_startleader"
|
||||
|
||||
// vipPacket defines that the packet API will be used for EIP
|
||||
vipPacket = "vip_packet"
|
||||
|
||||
// vipPacketProject defines which project within Packet to use
|
||||
vipPacketProject = "vip_packetproject"
|
||||
|
||||
// vipPacketProjectID defines which projectID within Packet to use
|
||||
vipPacketProjectID = "vip_packetprojectid"
|
||||
|
||||
// providerConfig defines a path to a configuration that should be parsed
|
||||
providerConfig = "provider_config"
|
||||
|
||||
// bgpEnable defines if BGP should be enabled
|
||||
bgpEnable = "bgp_enable"
|
||||
// bgpAttachIPToInterface defines if BGP service VIPs should be assigned to the configured interface
|
||||
bgpAttachIPToInterface = "bgp_attach_ip_to_interface"
|
||||
// bgpRouterID defines the routerID for the BGP server
|
||||
bgpRouterID = "bgp_routerid"
|
||||
// bgpRouterInterface defines the interface that we can find the address for
|
||||
@@ -121,6 +131,32 @@ const (
|
||||
bgpHoldTime = "bgp_hold_time"
|
||||
// bgpKeepaliveInterval defines bgp timers keepalive interval
|
||||
bgpKeepaliveInterval = "bgp_keepalive_interval"
|
||||
// controlPlaneHealthCheckAddress defines the URL for control-plane health checks (BGP route withdrawal)
|
||||
controlPlaneHealthCheckAddress = "control_plane_health_check_address"
|
||||
// controlPlaneHealthCheckPeriodSeconds defines the period between control-plane health checks
|
||||
controlPlaneHealthCheckPeriodSeconds = "control_plane_health_check_period_seconds"
|
||||
// controlPlaneHealthCheckTimeoutSeconds defines the timeout for each control-plane health check request
|
||||
controlPlaneHealthCheckTimeoutSeconds = "control_plane_health_check_timeout_seconds"
|
||||
// controlPlaneHealthCheckFailureThreshold defines consecutive failures before BGP route withdrawal
|
||||
controlPlaneHealthCheckFailureThreshold = "control_plane_health_check_failure_threshold"
|
||||
// controlPlaneHealthCheckCAPath defines the path to a CA certificate for control-plane health check TLS verification
|
||||
controlPlaneHealthCheckCAPath = "control_plane_health_check_ca_path"
|
||||
|
||||
// zebraEnable defines if Zebra integraton should be enabled
|
||||
zebraEnable = "zebra_enable"
|
||||
// zebraUrl specifies path to the unix domain socket for connecting to Zebra daemon
|
||||
zebraURL = "zebra_url"
|
||||
// zebraVersion specifies Zebra API Version
|
||||
zebraVersion = "zebra_version"
|
||||
// zebraSoftwareName specifies Software Name for Zebra
|
||||
zebraSoftwareName = "zebra_software_name"
|
||||
|
||||
// mpbgpNexthop defines MPBGP mode
|
||||
mpbgpNexthop = "mpbgp_nexthop"
|
||||
// mpbgpIPv4 defines fixed IPv4 to be used with MPBGP
|
||||
mpbgpIPv4 = "mpbgp_ipv4"
|
||||
// mpbgpIPv6 defines fixed IPv6 to be used with MPBGP
|
||||
mpbgpIPv6 = "mpbgp_ipv6"
|
||||
|
||||
// vipWireguard - defines if wireguard will be used for vips
|
||||
vipWireguard = "vip_wireguard" //nolint
|
||||
@@ -145,6 +181,9 @@ const (
|
||||
// vipCleanRoutingTable - defines if routing table will be cleaned of redundant routes on kube-vip's start
|
||||
vipCleanRoutingTable = "vip_cleanroutingtable" //nolint
|
||||
|
||||
// vipSkipDAD - defines if Duplicate Address Detection is skipped when adding the VIP address (IFA_F_NODAD)
|
||||
vipSkipDAD = "vip_skipdad" //nolint
|
||||
|
||||
// cpNamespace defines the namespace the control plane pods will run in
|
||||
cpNamespace = "cp_namespace"
|
||||
|
||||
@@ -205,11 +244,17 @@ const (
|
||||
// dnsMode defines mode that DNS lookup will be performed with (first, ipv4, ipv6, dual)
|
||||
dnsMode = "dns_mode"
|
||||
|
||||
// dhcpMode defines mode that DHCP lookup will be performed with (ipv4, ipv6, dual)
|
||||
dhcpMode = "dhcp_mode"
|
||||
|
||||
// dhcpBackoffAttempts defines how many times DHCP client will try to obtain an IP address
|
||||
dhcpBackoffAttempts = "dhcp_backoff_attempts"
|
||||
|
||||
// disableServiceUpdates disables service updating
|
||||
disableServiceUpdates = "disable_service_updates"
|
||||
|
||||
// enableEndpointSlices enables use of EndpointSlices instead of Endpoints
|
||||
enableEndpointSlices = "enable_endpointslices"
|
||||
// enableEndpoints enables use of Endpoints instead of EndpointSlices
|
||||
enableEndpoints = "enable_endpoints"
|
||||
|
||||
// mirrorDestInterface is the network interface where all traffics that go through service interface
|
||||
// will be mirrored to. The source interface is ServicesInterface by default, fall back to Interface if not set.
|
||||
@@ -221,4 +266,19 @@ const (
|
||||
|
||||
// backendHealthCheckInterval Interval in seconds for checking backend health.
|
||||
backendHealthCheckInterval = "backend_health_check_interval"
|
||||
|
||||
// healthCheckPort, if set to non-zero will be the port the health check will listen on
|
||||
healthCheckPort = "health_check_port"
|
||||
|
||||
// enableUPNP enables UPNP functions
|
||||
enableUPNP = "enable_upnp"
|
||||
|
||||
// egressClean enables egress cleaning on kube-vip's start
|
||||
egressClean = "egress_clean"
|
||||
|
||||
// configFile defines the path to a JSON/YAML configuration file
|
||||
configFile = "config_file"
|
||||
|
||||
// debounceTime defines what time should the event debouncer wait for events
|
||||
debounceTime = "debounce_time"
|
||||
)
|
||||
|
||||
569
pkg/kubevip/config_file_test.go
Normal file
569
pkg/kubevip/config_file_test.go
Normal file
@@ -0,0 +1,569 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigFromFile(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tmpDir, err := os.MkdirTemp("", "kube-vip-config-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
content string
|
||||
expectedConfig *Config
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "Valid YAML config",
|
||||
filename: "config.yaml",
|
||||
content: `
|
||||
logging: 2
|
||||
enableARP: true
|
||||
enableControlPlane: true
|
||||
enableServices: true
|
||||
address: "192.168.1.100"
|
||||
port: 6443
|
||||
interface: "eth0"
|
||||
namespace: "kube-system"
|
||||
instanceName: "release_a"
|
||||
vipSubnet: "192.168.1.0/24"
|
||||
leaseName: "test-lease"
|
||||
leaseDuration: 15
|
||||
renewDeadline: 10
|
||||
retryPeriod: 2
|
||||
prometheusHTTPServer: ":2112"
|
||||
`,
|
||||
expectedConfig: &Config{
|
||||
Logging: 2,
|
||||
EnableARP: true,
|
||||
EnableControlPlane: true,
|
||||
EnableServices: true,
|
||||
Address: "192.168.1.100",
|
||||
Port: 6443,
|
||||
Interface: "eth0",
|
||||
Namespace: "kube-system",
|
||||
InstanceName: "release_a",
|
||||
VIPSubnet: "192.168.1.0/24",
|
||||
PrometheusHTTPServer: ":2112",
|
||||
KubernetesLeaderElection: KubernetesLeaderElection{
|
||||
LeaseName: "test-lease",
|
||||
LeaseDuration: 15,
|
||||
RenewDeadline: 10,
|
||||
RetryPeriod: 2,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid JSON config",
|
||||
filename: "config.json",
|
||||
content: `{
|
||||
"logging": 3,
|
||||
"enableBGP": true,
|
||||
"enableServices": true,
|
||||
"address": "10.0.0.100",
|
||||
"port": 8443,
|
||||
"interface": "ens192",
|
||||
"namespace": "kube-system",
|
||||
"loadBalancers": [
|
||||
{
|
||||
"name": "control-plane",
|
||||
"ports": [
|
||||
{
|
||||
"type": "TCP",
|
||||
"port": 6443
|
||||
}
|
||||
],
|
||||
"bindToVip": true,
|
||||
"forwardingMethod": "local"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectedConfig: &Config{
|
||||
Logging: 3,
|
||||
EnableBGP: true,
|
||||
EnableServices: true,
|
||||
Address: "10.0.0.100",
|
||||
Port: 8443,
|
||||
Interface: "ens192",
|
||||
Namespace: "kube-system",
|
||||
LoadBalancers: []LoadBalancer{
|
||||
{
|
||||
Name: "control-plane",
|
||||
Ports: []Port{
|
||||
{
|
||||
Type: "TCP",
|
||||
Port: 6443,
|
||||
},
|
||||
},
|
||||
BindToVip: true,
|
||||
ForwardingMethod: "local",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Complex BGP config",
|
||||
filename: "bgp-config.yaml",
|
||||
content: `
|
||||
enableBGP: true
|
||||
bgpConfig:
|
||||
routerID: "192.168.1.1"
|
||||
as: 65000
|
||||
sourceIF: "eth0"
|
||||
holdTime: 60
|
||||
keepaliveInterval: 20
|
||||
peers:
|
||||
- address: "192.168.1.2"
|
||||
as: 65001
|
||||
port: 179
|
||||
multiHop: false
|
||||
- address: "192.168.1.3"
|
||||
as: 65002
|
||||
port: 179
|
||||
multiHop: true
|
||||
`,
|
||||
expectedConfig: &Config{
|
||||
EnableBGP: true,
|
||||
BGPConfig: BGPConfig{
|
||||
RouterID: "192.168.1.1",
|
||||
AS: 65000,
|
||||
SourceIF: "eth0",
|
||||
HoldTime: 60,
|
||||
KeepaliveInterval: 20,
|
||||
Peers: []BGPPeer{
|
||||
{
|
||||
Address: "192.168.1.2",
|
||||
AS: 65001,
|
||||
Port: 179,
|
||||
MultiHop: false,
|
||||
},
|
||||
{
|
||||
Address: "192.168.1.3",
|
||||
AS: 65002,
|
||||
Port: 179,
|
||||
MultiHop: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON",
|
||||
filename: "invalid.json",
|
||||
content: `{"logging": 2, "invalid": }`,
|
||||
wantErr: true,
|
||||
errContains: "failed to parse JSON config file",
|
||||
},
|
||||
{
|
||||
name: "Invalid YAML",
|
||||
filename: "invalid.yaml",
|
||||
content: "logging: 2\ninvalid: [unclosed",
|
||||
wantErr: true,
|
||||
errContains: "failed to parse YAML config file",
|
||||
},
|
||||
{
|
||||
name: "Unsupported format",
|
||||
filename: "config.txt",
|
||||
content: "logging=2",
|
||||
wantErr: true,
|
||||
errContains: "unsupported config file format",
|
||||
},
|
||||
{
|
||||
name: "Empty path",
|
||||
filename: "",
|
||||
content: "",
|
||||
wantErr: true,
|
||||
errContains: "config file path is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var filePath string
|
||||
if tt.filename != "" {
|
||||
filePath = filepath.Join(tmpDir, tt.filename)
|
||||
if err := os.WriteFile(filePath, []byte(tt.content), 0600); err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
config, err := LoadConfigFromFile(filePath)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("LoadConfigFromFile() expected error, got nil")
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
|
||||
t.Errorf("LoadConfigFromFile() error = %v, expected to contain %v", err, tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("LoadConfigFromFile() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
t.Errorf("LoadConfigFromFile() returned nil config")
|
||||
return
|
||||
}
|
||||
|
||||
// Compare key fields
|
||||
if config.Logging != tt.expectedConfig.Logging {
|
||||
t.Errorf("Logging = %v, expected %v", config.Logging, tt.expectedConfig.Logging)
|
||||
}
|
||||
if config.EnableARP != tt.expectedConfig.EnableARP {
|
||||
t.Errorf("EnableARP = %v, expected %v", config.EnableARP, tt.expectedConfig.EnableARP)
|
||||
}
|
||||
if config.EnableBGP != tt.expectedConfig.EnableBGP {
|
||||
t.Errorf("EnableBGP = %v, expected %v", config.EnableBGP, tt.expectedConfig.EnableBGP)
|
||||
}
|
||||
if config.Address != tt.expectedConfig.Address {
|
||||
t.Errorf("Address = %v, expected %v", config.Address, tt.expectedConfig.Address)
|
||||
}
|
||||
if config.Port != tt.expectedConfig.Port {
|
||||
t.Errorf("Port = %v, expected %v", config.Port, tt.expectedConfig.Port)
|
||||
}
|
||||
if config.Interface != tt.expectedConfig.Interface {
|
||||
t.Errorf("Interface = %v, expected %v", config.Interface, tt.expectedConfig.Interface)
|
||||
}
|
||||
if config.InstanceName != tt.expectedConfig.InstanceName {
|
||||
t.Errorf("InstanceName = %v, expected %v", config.InstanceName, tt.expectedConfig.InstanceName)
|
||||
}
|
||||
|
||||
// Test BGP config if present
|
||||
if tt.expectedConfig.EnableBGP {
|
||||
if config.BGPConfig.RouterID != tt.expectedConfig.BGPConfig.RouterID {
|
||||
t.Errorf("BGPConfig.RouterID = %v, expected %v", config.BGPConfig.RouterID, tt.expectedConfig.BGPConfig.RouterID)
|
||||
}
|
||||
if config.BGPConfig.AS != tt.expectedConfig.BGPConfig.AS {
|
||||
t.Errorf("BGPConfig.AS = %v, expected %v", config.BGPConfig.AS, tt.expectedConfig.BGPConfig.AS)
|
||||
}
|
||||
if len(config.BGPConfig.Peers) != len(tt.expectedConfig.BGPConfig.Peers) {
|
||||
t.Errorf("BGPConfig.Peers length = %v, expected %v", len(config.BGPConfig.Peers), len(tt.expectedConfig.BGPConfig.Peers))
|
||||
}
|
||||
}
|
||||
|
||||
// Test LoadBalancers if present
|
||||
if len(tt.expectedConfig.LoadBalancers) > 0 {
|
||||
if len(config.LoadBalancers) != len(tt.expectedConfig.LoadBalancers) {
|
||||
t.Errorf("LoadBalancers length = %v, expected %v", len(config.LoadBalancers), len(tt.expectedConfig.LoadBalancers))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigFromFile(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tmpDir, err := os.MkdirTemp("", "kube-vip-merge-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test config file
|
||||
configFile := filepath.Join(tmpDir, "test-config.yaml")
|
||||
configContent := `
|
||||
logging: 3
|
||||
enableARP: true
|
||||
enableServices: true
|
||||
address: "192.168.1.200"
|
||||
port: 6443
|
||||
interface: "eth1"
|
||||
namespace: "test-namespace"
|
||||
leaseName: "file-lease"
|
||||
leaseDuration: 20
|
||||
prometheusHTTPServer: ":3000"
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write test config file: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseConfig *Config
|
||||
configFilePath string
|
||||
expected *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Merge with empty base config",
|
||||
baseConfig: &Config{},
|
||||
configFilePath: configFile,
|
||||
expected: &Config{
|
||||
Logging: 3,
|
||||
EnableARP: true,
|
||||
EnableServices: true,
|
||||
Address: "192.168.1.200",
|
||||
Port: 6443,
|
||||
Interface: "eth1",
|
||||
Namespace: "test-namespace",
|
||||
PrometheusHTTPServer: ":3000",
|
||||
KubernetesLeaderElection: KubernetesLeaderElection{
|
||||
LeaseName: "file-lease",
|
||||
LeaseDuration: 20,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Merge respects existing values (priority test)",
|
||||
baseConfig: &Config{
|
||||
Logging: 5, // Should not be overridden
|
||||
Port: 8443, // Should not be overridden
|
||||
Interface: "eth0", // Should not be overridden
|
||||
},
|
||||
configFilePath: configFile,
|
||||
expected: &Config{
|
||||
Logging: 5, // From base (higher priority)
|
||||
EnableARP: true, // From file
|
||||
EnableServices: true, // From file
|
||||
Address: "192.168.1.200", // From file
|
||||
Port: 8443, // From base (higher priority)
|
||||
Interface: "eth0", // From base (higher priority)
|
||||
Namespace: "test-namespace", // From file
|
||||
PrometheusHTTPServer: ":3000", // From file
|
||||
KubernetesLeaderElection: KubernetesLeaderElection{
|
||||
LeaseName: "file-lease", // From file
|
||||
LeaseDuration: 20, // From file
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Empty config file path",
|
||||
baseConfig: &Config{
|
||||
Logging: 1,
|
||||
},
|
||||
configFilePath: "",
|
||||
expected: &Config{
|
||||
Logging: 1, // Unchanged
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Non-existent config file",
|
||||
baseConfig: &Config{
|
||||
Logging: 1,
|
||||
},
|
||||
configFilePath: "/non/existent/file.yaml",
|
||||
expected: &Config{
|
||||
Logging: 1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := MergeConfigFromFile(tt.baseConfig, tt.configFilePath)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("MergeConfigFromFile() expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("MergeConfigFromFile() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Compare key fields
|
||||
if tt.baseConfig.Logging != tt.expected.Logging {
|
||||
t.Errorf("Logging = %v, expected %v", tt.baseConfig.Logging, tt.expected.Logging)
|
||||
}
|
||||
if tt.baseConfig.EnableARP != tt.expected.EnableARP {
|
||||
t.Errorf("EnableARP = %v, expected %v", tt.baseConfig.EnableARP, tt.expected.EnableARP)
|
||||
}
|
||||
if tt.baseConfig.Address != tt.expected.Address {
|
||||
t.Errorf("Address = %v, expected %v", tt.baseConfig.Address, tt.expected.Address)
|
||||
}
|
||||
if tt.baseConfig.Port != tt.expected.Port {
|
||||
t.Errorf("Port = %v, expected %v", tt.baseConfig.Port, tt.expected.Port)
|
||||
}
|
||||
if tt.baseConfig.Interface != tt.expected.Interface {
|
||||
t.Errorf("Interface = %v, expected %v", tt.baseConfig.Interface, tt.expected.Interface)
|
||||
}
|
||||
if tt.baseConfig.Namespace != tt.expected.Namespace {
|
||||
t.Errorf("Namespace = %v, expected %v", tt.baseConfig.Namespace, tt.expected.Namespace)
|
||||
}
|
||||
if tt.baseConfig.PrometheusHTTPServer != tt.expected.PrometheusHTTPServer {
|
||||
t.Errorf("PrometheusHTTPServer = %v, expected %v", tt.baseConfig.PrometheusHTTPServer, tt.expected.PrometheusHTTPServer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseConfig *Config
|
||||
fileConfig *Config
|
||||
expectedBase *Config
|
||||
}{
|
||||
{
|
||||
name: "Merge basic configuration",
|
||||
baseConfig: &Config{
|
||||
Logging: 5, // Should not be overridden
|
||||
Port: 0, // Should be overridden
|
||||
},
|
||||
fileConfig: &Config{
|
||||
Logging: 2,
|
||||
Port: 6443,
|
||||
Interface: "eth0",
|
||||
Address: "192.168.1.100",
|
||||
InstanceName: "release_a",
|
||||
},
|
||||
expectedBase: &Config{
|
||||
Logging: 5, // From base (non-zero)
|
||||
Port: 6443, // From file (base was zero)
|
||||
Interface: "eth0", // From file (base was empty)
|
||||
Address: "192.168.1.100", // From file (base was empty)
|
||||
InstanceName: "release_a", // From file (base was empty)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Merge boolean flags",
|
||||
baseConfig: &Config{
|
||||
EnableARP: true, // Should not be overridden
|
||||
},
|
||||
fileConfig: &Config{
|
||||
EnableARP: false, // Should not override true
|
||||
EnableBGP: true, // Should be set
|
||||
EnableServices: true, // Should be set
|
||||
EnableWireguard: false, // Should not be set (false doesn't override false)
|
||||
},
|
||||
expectedBase: &Config{
|
||||
EnableARP: true, // From base (true has priority)
|
||||
EnableBGP: true, // From file
|
||||
EnableServices: true, // From file
|
||||
EnableWireguard: false, // Remains false
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Merge BGP configuration",
|
||||
baseConfig: &Config{
|
||||
BGPConfig: BGPConfig{
|
||||
RouterID: "1.1.1.1", // Should not be overridden
|
||||
},
|
||||
},
|
||||
fileConfig: &Config{
|
||||
BGPConfig: BGPConfig{
|
||||
RouterID: "2.2.2.2", // Should not override
|
||||
AS: 65000, // Should be set
|
||||
SourceIF: "eth0", // Should be set
|
||||
HoldTime: 30, // Should be set
|
||||
KeepaliveInterval: 10, // Should be set
|
||||
},
|
||||
},
|
||||
expectedBase: &Config{
|
||||
BGPConfig: BGPConfig{
|
||||
RouterID: "1.1.1.1", // From base (non-empty)
|
||||
AS: 65000, // From file (base was zero)
|
||||
SourceIF: "eth0", // From file (base was empty)
|
||||
HoldTime: 30, // From file (base was zero)
|
||||
KeepaliveInterval: 10, // From file (base was zero)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Merge leader election configuration",
|
||||
baseConfig: &Config{
|
||||
KubernetesLeaderElection: KubernetesLeaderElection{
|
||||
LeaseName: "base-lease", // Should not be overridden
|
||||
},
|
||||
},
|
||||
fileConfig: &Config{
|
||||
KubernetesLeaderElection: KubernetesLeaderElection{
|
||||
LeaseName: "file-lease", // Should not override
|
||||
LeaseDuration: 15, // Should be set
|
||||
RenewDeadline: 10, // Should be set
|
||||
RetryPeriod: 2, // Should be set
|
||||
},
|
||||
},
|
||||
expectedBase: &Config{
|
||||
KubernetesLeaderElection: KubernetesLeaderElection{
|
||||
LeaseName: "base-lease", // From base (non-empty)
|
||||
LeaseDuration: 15, // From file (base was zero)
|
||||
RenewDeadline: 10, // From file (base was zero)
|
||||
RetryPeriod: 2, // From file (base was zero)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mergeConfigValues(tt.baseConfig, tt.fileConfig)
|
||||
|
||||
// Compare results
|
||||
if tt.baseConfig.Logging != tt.expectedBase.Logging {
|
||||
t.Errorf("Logging = %v, expected %v", tt.baseConfig.Logging, tt.expectedBase.Logging)
|
||||
}
|
||||
if tt.baseConfig.Port != tt.expectedBase.Port {
|
||||
t.Errorf("Port = %v, expected %v", tt.baseConfig.Port, tt.expectedBase.Port)
|
||||
}
|
||||
if tt.baseConfig.Interface != tt.expectedBase.Interface {
|
||||
t.Errorf("Interface = %v, expected %v", tt.baseConfig.Interface, tt.expectedBase.Interface)
|
||||
}
|
||||
if tt.baseConfig.InstanceName != tt.expectedBase.InstanceName {
|
||||
t.Errorf("InstanceName = %v, expected %v", tt.baseConfig.InstanceName, tt.expectedBase.InstanceName)
|
||||
}
|
||||
if tt.baseConfig.EnableARP != tt.expectedBase.EnableARP {
|
||||
t.Errorf("EnableARP = %v, expected %v", tt.baseConfig.EnableARP, tt.expectedBase.EnableARP)
|
||||
}
|
||||
if tt.baseConfig.EnableBGP != tt.expectedBase.EnableBGP {
|
||||
t.Errorf("EnableBGP = %v, expected %v", tt.baseConfig.EnableBGP, tt.expectedBase.EnableBGP)
|
||||
}
|
||||
if tt.baseConfig.BGPConfig.RouterID != tt.expectedBase.BGPConfig.RouterID {
|
||||
t.Errorf("BGPConfig.RouterID = %v, expected %v", tt.baseConfig.BGPConfig.RouterID, tt.expectedBase.BGPConfig.RouterID)
|
||||
}
|
||||
if tt.baseConfig.BGPConfig.AS != tt.expectedBase.BGPConfig.AS {
|
||||
t.Errorf("BGPConfig.AS = %v, expected %v", tt.baseConfig.BGPConfig.AS, tt.expectedBase.BGPConfig.AS)
|
||||
}
|
||||
if tt.baseConfig.KubernetesLeaderElection.LeaseName != tt.expectedBase.KubernetesLeaderElection.LeaseName {
|
||||
t.Errorf("KubernetesLeaderElection.LeaseName = %v, expected %v", tt.baseConfig.KubernetesLeaderElection.LeaseName, tt.expectedBase.KubernetesLeaderElection.LeaseName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromFile_FileNotExists(t *testing.T) {
|
||||
_, err := LoadConfigFromFile("/non/existent/path/config.yaml")
|
||||
if err == nil {
|
||||
t.Error("LoadConfigFromFile() expected error for non-existent file, got nil")
|
||||
}
|
||||
if !containsString(err.Error(), "config file does not exist") {
|
||||
t.Errorf("LoadConfigFromFile() error = %v, expected to contain 'config file does not exist'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func containsString(str, substr string) bool {
|
||||
return len(str) >= len(substr) && (str == substr || len(substr) == 0 ||
|
||||
(len(substr) > 0 && func() bool {
|
||||
for i := 0; i <= len(str)-len(substr); i++ {
|
||||
if str[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()))
|
||||
}
|
||||
@@ -2,23 +2,59 @@ package kubevip
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/kube-vip/kube-vip/pkg/debouncer"
|
||||
appv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
applyCoreV1 "k8s.io/client-go/applyconfigurations/core/v1"
|
||||
applyMetaV1 "k8s.io/client-go/applyconfigurations/meta/v1"
|
||||
applyRbacV1 "k8s.io/client-go/applyconfigurations/rbac/v1"
|
||||
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
// TransformApplyObjectToManifest transforms an apply object into a normal Kubernetes manifest
|
||||
func TransformApplyObjectToManifest(applyObject interface{}) string {
|
||||
// Convert the apply object to an unstructured object
|
||||
unstructuredObj := &unstructured.Unstructured{}
|
||||
err := runtime.DefaultUnstructuredConverter.FromUnstructured(applyConfigToMap(applyObject), unstructuredObj)
|
||||
if err != nil {
|
||||
log.Fatalf("Error converting apply object to unstructured: %v", err)
|
||||
}
|
||||
|
||||
// Marshal the unstructured object into YAML
|
||||
yamlData, err := yaml.Marshal(unstructuredObj.Object)
|
||||
if err != nil {
|
||||
log.Fatalf("Error marshaling unstructured object to YAML: %v", err)
|
||||
}
|
||||
|
||||
return string(yamlData)
|
||||
}
|
||||
|
||||
// Helper function to convert apply configuration to a map
|
||||
func applyConfigToMap(applyConfig interface{}) map[string]interface{} {
|
||||
data, err := runtime.DefaultUnstructuredConverter.ToUnstructured(applyConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error converting apply configuration to map: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// GenerateSA will create the service account for kube-vip
|
||||
func GenerateSA() *applyCoreV1.ServiceAccountApplyConfiguration {
|
||||
func GenerateSA(c *Config) *applyCoreV1.ServiceAccountApplyConfiguration {
|
||||
kind := "ServiceAccount"
|
||||
name := "kube-vip"
|
||||
namespace := "kube-system"
|
||||
var namespace string
|
||||
if c.ServiceNamespace != "" {
|
||||
namespace = c.ServiceNamespace
|
||||
} else {
|
||||
namespace = metav1.NamespaceSystem
|
||||
}
|
||||
newManifest := &applyCoreV1.ServiceAccountApplyConfiguration{
|
||||
TypeMetaApplyConfiguration: applyMetaV1.TypeMetaApplyConfiguration{APIVersion: &corev1.SchemeGroupVersion.Version, Kind: &kind},
|
||||
ObjectMetaApplyConfiguration: &applyMetaV1.ObjectMetaApplyConfiguration{
|
||||
@@ -30,15 +66,31 @@ func GenerateSA() *applyCoreV1.ServiceAccountApplyConfiguration {
|
||||
}
|
||||
|
||||
// GenerateCR will generate the Cluster role for kube-vip
|
||||
func GenerateCR() *applyRbacV1.ClusterRoleApplyConfiguration {
|
||||
name := "system:kube-vip-role"
|
||||
roleRefKind := "ClusterRole"
|
||||
apiVersion := "rbac.authorization.k8s.io/v1"
|
||||
func GenerateRole(c *Config, role bool) *applyRbacV1.RoleApplyConfiguration {
|
||||
var kind, name string
|
||||
var namespace *string
|
||||
if role {
|
||||
kind = "Role"
|
||||
name = "kube-vip"
|
||||
if c.ServiceNamespace != "" {
|
||||
namespace = &c.ServiceNamespace
|
||||
} else {
|
||||
// If the namespace is empty then we need to set it to the system namespace
|
||||
copiedNamespace := metav1.NamespaceSystem
|
||||
namespace = &copiedNamespace
|
||||
}
|
||||
|
||||
newManifest := &applyRbacV1.ClusterRoleApplyConfiguration{
|
||||
TypeMetaApplyConfiguration: applyMetaV1.TypeMetaApplyConfiguration{APIVersion: &apiVersion, Kind: &roleRefKind},
|
||||
} else {
|
||||
kind = "ClusterRole"
|
||||
name = "system:kube-vip-role"
|
||||
|
||||
}
|
||||
apiVersion := "rbac.authorization.k8s.io/v1"
|
||||
newManifest := &applyRbacV1.RoleApplyConfiguration{
|
||||
TypeMetaApplyConfiguration: applyMetaV1.TypeMetaApplyConfiguration{APIVersion: &apiVersion, Kind: &kind},
|
||||
ObjectMetaApplyConfiguration: &applyMetaV1.ObjectMetaApplyConfiguration{
|
||||
Name: &name,
|
||||
Name: &name,
|
||||
Namespace: namespace,
|
||||
},
|
||||
Rules: []applyRbacV1.PolicyRuleApplyConfiguration{
|
||||
{
|
||||
@@ -49,7 +101,7 @@ func GenerateCR() *applyRbacV1.ClusterRoleApplyConfiguration {
|
||||
{
|
||||
APIGroups: []string{""},
|
||||
Resources: []string{"services", "endpoints"},
|
||||
Verbs: []string{"list", "get", "watch", "endoints"},
|
||||
Verbs: []string{"list", "get", "watch", "update"},
|
||||
},
|
||||
{
|
||||
APIGroups: []string{""},
|
||||
@@ -61,38 +113,61 @@ func GenerateCR() *applyRbacV1.ClusterRoleApplyConfiguration {
|
||||
Resources: []string{"leases"},
|
||||
Verbs: []string{"list", "get", "watch", "update", "create"},
|
||||
},
|
||||
{
|
||||
APIGroups: []string{"discovery.k8s.io"},
|
||||
Resources: []string{"endpointslices"},
|
||||
Verbs: []string{"list", "get", "watch", "update"},
|
||||
},
|
||||
{
|
||||
APIGroups: []string{""},
|
||||
Resources: []string{"pods"},
|
||||
Verbs: []string{"list"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !role {
|
||||
newManifest.Rules = append(newManifest.Rules, applyRbacV1.PolicyRuleApplyConfiguration{
|
||||
APIGroups: []string{"networking.k8s.io"},
|
||||
Resources: []string{"servicecidrs"},
|
||||
Verbs: []string{"list", "get", "watch"},
|
||||
})
|
||||
}
|
||||
return newManifest
|
||||
}
|
||||
|
||||
// GenerateCRB will generate the clusterRoleBinding
|
||||
func GenerateCRB() *applyRbacV1.ClusterRoleBindingApplyConfiguration {
|
||||
kind := "ClusterRoleBinding"
|
||||
// GenerateCRB will generate the clusterRoleBinding or rolebinding
|
||||
func GenerateRoleBinding(rolebinding bool, saCfg *applyCoreV1.ServiceAccountApplyConfiguration, crCfg *applyRbacV1.RoleApplyConfiguration) *applyRbacV1.RoleBindingApplyConfiguration {
|
||||
apiVersion := "rbac.authorization.k8s.io/v1"
|
||||
subjectKind := "ServiceAccount"
|
||||
apiGroup := "rbac.authorization.k8s.io"
|
||||
roleRefKind := "ClusterRole"
|
||||
roleRefName := "system:kube-vip-role"
|
||||
name := "kube-vip"
|
||||
bindName := "system:kube-vip-role-binding"
|
||||
namespace := "kube-system"
|
||||
|
||||
newManifest := &applyRbacV1.ClusterRoleBindingApplyConfiguration{
|
||||
var kind, bindName string
|
||||
var namespace, objectNamespace *string
|
||||
if rolebinding {
|
||||
kind = "RoleBinding"
|
||||
bindName = "kube-vip"
|
||||
namespace = nil
|
||||
objectNamespace = saCfg.Namespace
|
||||
} else {
|
||||
kind = "ClusterRoleBinding"
|
||||
bindName = "system:kube-vip-binding"
|
||||
namespace = saCfg.Namespace
|
||||
objectNamespace = nil
|
||||
}
|
||||
newManifest := &applyRbacV1.RoleBindingApplyConfiguration{
|
||||
TypeMetaApplyConfiguration: applyMetaV1.TypeMetaApplyConfiguration{APIVersion: &apiVersion, Kind: &kind},
|
||||
ObjectMetaApplyConfiguration: &applyMetaV1.ObjectMetaApplyConfiguration{
|
||||
Name: &bindName,
|
||||
Name: &bindName,
|
||||
Namespace: objectNamespace,
|
||||
},
|
||||
RoleRef: &applyRbacV1.RoleRefApplyConfiguration{
|
||||
APIGroup: &apiGroup,
|
||||
Kind: &roleRefKind,
|
||||
Name: &roleRefName,
|
||||
Kind: crCfg.Kind,
|
||||
Name: crCfg.Name,
|
||||
},
|
||||
Subjects: []applyRbacV1.SubjectApplyConfiguration{
|
||||
{
|
||||
Kind: &subjectKind,
|
||||
Name: &name,
|
||||
Namespace: &namespace,
|
||||
Kind: saCfg.Kind,
|
||||
Name: saCfg.Name,
|
||||
Namespace: namespace,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -100,7 +175,11 @@ func GenerateCRB() *applyRbacV1.ClusterRoleBindingApplyConfiguration {
|
||||
}
|
||||
|
||||
// generatePodSpec will take a kube-vip config and generate a Pod spec
|
||||
func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod {
|
||||
func generatePodSpec(c *Config, image, imageVersion string, inCluster bool) (*corev1.Pod, error) {
|
||||
imageRef, err := name.NewTag(image, name.WeakValidation, name.WithDefaultTag(imageVersion))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse %q: %w", image, err)
|
||||
}
|
||||
command := "manager"
|
||||
|
||||
// Determine where the pods should be living (for multi-tenancy)
|
||||
@@ -130,6 +209,12 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
},
|
||||
},
|
||||
}
|
||||
if c.InstanceName != "" {
|
||||
newEnvironment = append(newEnvironment, corev1.EnvVar{
|
||||
Name: instanceName,
|
||||
Value: c.InstanceName,
|
||||
})
|
||||
}
|
||||
|
||||
// If we're specifically saying which interface to use then add it to the manifest
|
||||
if c.Interface != "" {
|
||||
@@ -139,6 +224,13 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
Value: c.Interface,
|
||||
},
|
||||
}
|
||||
// specify if global scope should be set when using the lo interface
|
||||
if c.LoInterfaceGlobalScope {
|
||||
iface = append(iface, corev1.EnvVar{
|
||||
Name: vipInterfaceLoGlobal,
|
||||
Value: strconv.FormatBool(c.LoInterfaceGlobalScope),
|
||||
})
|
||||
}
|
||||
newEnvironment = append(newEnvironment, iface...)
|
||||
}
|
||||
|
||||
@@ -154,16 +246,15 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
newEnvironment = append(newEnvironment, svcInterface...)
|
||||
}
|
||||
|
||||
// If a CIDR is used add it to the manifest
|
||||
if c.VIPCIDR != "" {
|
||||
// build environment variables
|
||||
cidr := []corev1.EnvVar{
|
||||
// Tolerate a down interface
|
||||
if c.AllowInterfaceNotUp {
|
||||
allowIface := []corev1.EnvVar{
|
||||
{
|
||||
Name: vipCidr,
|
||||
Value: c.VIPCIDR,
|
||||
Name: vipAllowInterfaceNotUp,
|
||||
Value: strconv.FormatBool(c.AllowInterfaceNotUp),
|
||||
},
|
||||
}
|
||||
newEnvironment = append(newEnvironment, cidr...)
|
||||
newEnvironment = append(newEnvironment, allowIface...)
|
||||
}
|
||||
|
||||
// If a subnet is required for the VIP
|
||||
@@ -189,6 +280,28 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
newEnvironment = append(newEnvironment, dnsModeSelector...)
|
||||
}
|
||||
|
||||
if c.DHCPMode != "" {
|
||||
// build environment variables
|
||||
dhcpModeSelector := []corev1.EnvVar{
|
||||
{
|
||||
Name: dhcpMode,
|
||||
Value: c.DHCPMode,
|
||||
},
|
||||
}
|
||||
newEnvironment = append(newEnvironment, dhcpModeSelector...)
|
||||
}
|
||||
|
||||
if c.DHCPBackoffAttempts != DefaultDHCPBackoffAttempts {
|
||||
// build environment variables
|
||||
dhcpBackoff := []corev1.EnvVar{
|
||||
{
|
||||
Name: dhcpBackoffAttempts,
|
||||
Value: strconv.FormatUint(uint64(c.DHCPBackoffAttempts), 10),
|
||||
},
|
||||
}
|
||||
newEnvironment = append(newEnvironment, dhcpBackoff...)
|
||||
}
|
||||
|
||||
// If we're doing the hybrid mode
|
||||
if c.EnableControlPlane {
|
||||
cp := []corev1.EnvVar{
|
||||
@@ -301,6 +414,23 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
newEnvironment = append(newEnvironment, leaderElection...)
|
||||
}
|
||||
|
||||
if c.LoseLeadership {
|
||||
loseLeadership := []corev1.EnvVar{
|
||||
{
|
||||
Name: vipLoseLeadership,
|
||||
Value: strconv.FormatBool(c.LoseLeadership),
|
||||
},
|
||||
}
|
||||
|
||||
if c.LoseLeadershipTimeoutSeconds > 0 {
|
||||
loseLeadership = append(loseLeadership, corev1.EnvVar{
|
||||
Name: vipLoseLeadership,
|
||||
Value: fmt.Sprintf("%d", c.LoseLeadershipTimeoutSeconds),
|
||||
})
|
||||
}
|
||||
newEnvironment = append(newEnvironment, loseLeadership...)
|
||||
}
|
||||
|
||||
// If we're enabling node labeling on leader election
|
||||
if c.EnableNodeLabeling {
|
||||
EnableNodeLabeling := []corev1.EnvVar{
|
||||
@@ -324,40 +454,6 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
|
||||
}
|
||||
|
||||
// If we're specifying a configuration
|
||||
if c.ProviderConfig != "" {
|
||||
provider := []corev1.EnvVar{
|
||||
{
|
||||
Name: providerConfig,
|
||||
Value: c.ProviderConfig,
|
||||
},
|
||||
}
|
||||
newEnvironment = append(newEnvironment, provider...)
|
||||
}
|
||||
|
||||
// If Equinix Metal is enabled then add it to the manifest
|
||||
if c.EnableMetal {
|
||||
packet := []corev1.EnvVar{
|
||||
{
|
||||
Name: vipPacket,
|
||||
Value: strconv.FormatBool(c.EnableMetal),
|
||||
},
|
||||
{
|
||||
Name: vipPacketProject,
|
||||
Value: c.MetalProject,
|
||||
},
|
||||
{
|
||||
Name: vipPacketProjectID,
|
||||
Value: c.MetalProjectID,
|
||||
},
|
||||
{
|
||||
Name: "PACKET_AUTH_TOKEN",
|
||||
Value: c.MetalAPIKey,
|
||||
},
|
||||
}
|
||||
newEnvironment = append(newEnvironment, packet...)
|
||||
}
|
||||
|
||||
// Detect and enable wireguard mode
|
||||
if c.EnableWireguard {
|
||||
wireguard := []corev1.EnvVar{
|
||||
@@ -379,8 +475,7 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
}
|
||||
newEnvironment = append(newEnvironment, routingtable...)
|
||||
}
|
||||
|
||||
// If BGP, but we're not using Equinix Metal
|
||||
// If BGP
|
||||
if c.EnableBGP {
|
||||
bgp := []corev1.EnvVar{
|
||||
{
|
||||
@@ -388,10 +483,17 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
Value: strconv.FormatBool(c.EnableBGP),
|
||||
},
|
||||
}
|
||||
if c.BGPAttachIPToInterface {
|
||||
bgp = append(bgp, corev1.EnvVar{
|
||||
Name: bgpAttachIPToInterface,
|
||||
Value: strconv.FormatBool(c.BGPAttachIPToInterface),
|
||||
})
|
||||
}
|
||||
newEnvironment = append(newEnvironment, bgp...)
|
||||
}
|
||||
// If BGP, but we're not using Equinix Metal
|
||||
if c.EnableBGP && !c.EnableMetal {
|
||||
|
||||
// If BGP
|
||||
if c.EnableBGP {
|
||||
bgpConfig := []corev1.EnvVar{
|
||||
{
|
||||
Name: bgpRouterID,
|
||||
@@ -453,6 +555,40 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
|
||||
}
|
||||
|
||||
if c.ControlPlaneHealthCheck.Address != "" {
|
||||
healthCheckVars := []corev1.EnvVar{
|
||||
{
|
||||
Name: controlPlaneHealthCheckAddress,
|
||||
Value: c.ControlPlaneHealthCheck.Address,
|
||||
},
|
||||
}
|
||||
if c.ControlPlaneHealthCheck.PeriodSeconds > 0 {
|
||||
healthCheckVars = append(healthCheckVars, corev1.EnvVar{
|
||||
Name: controlPlaneHealthCheckPeriodSeconds,
|
||||
Value: fmt.Sprintf("%d", c.ControlPlaneHealthCheck.PeriodSeconds),
|
||||
})
|
||||
}
|
||||
if c.ControlPlaneHealthCheck.TimeoutSeconds > 0 {
|
||||
healthCheckVars = append(healthCheckVars, corev1.EnvVar{
|
||||
Name: controlPlaneHealthCheckTimeoutSeconds,
|
||||
Value: fmt.Sprintf("%d", c.ControlPlaneHealthCheck.TimeoutSeconds),
|
||||
})
|
||||
}
|
||||
if c.ControlPlaneHealthCheck.FailureThreshold > 0 {
|
||||
healthCheckVars = append(healthCheckVars, corev1.EnvVar{
|
||||
Name: controlPlaneHealthCheckFailureThreshold,
|
||||
Value: fmt.Sprintf("%d", c.ControlPlaneHealthCheck.FailureThreshold),
|
||||
})
|
||||
}
|
||||
if c.ControlPlaneHealthCheck.CAPath != "" {
|
||||
healthCheckVars = append(healthCheckVars, corev1.EnvVar{
|
||||
Name: controlPlaneHealthCheckCAPath,
|
||||
Value: c.ControlPlaneHealthCheck.CAPath,
|
||||
})
|
||||
}
|
||||
newEnvironment = append(newEnvironment, healthCheckVars...)
|
||||
}
|
||||
|
||||
// If the load-balancer is enabled then add the configuration to the manifest
|
||||
if c.EnableLoadBalancer {
|
||||
lb := []corev1.EnvVar{
|
||||
@@ -493,10 +629,10 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
}
|
||||
newEnvironment = append(newEnvironment, prometheus...)
|
||||
|
||||
if c.EnableEndpointSlices {
|
||||
if c.EnableEndpoints {
|
||||
newEnvironment = append(newEnvironment, corev1.EnvVar{
|
||||
Name: enableEndpointSlices,
|
||||
Value: strconv.FormatBool(c.EnableEndpointSlices),
|
||||
Name: enableEndpoints,
|
||||
Value: strconv.FormatBool(c.EnableEndpoints),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -521,9 +657,19 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
newEnvironment = append(newEnvironment, mdif...)
|
||||
}
|
||||
|
||||
if c.HealthCheckPort != 0 {
|
||||
healthPort := []corev1.EnvVar{
|
||||
{
|
||||
Name: healthCheckPort,
|
||||
Value: fmt.Sprintf("%d", c.HealthCheckPort),
|
||||
},
|
||||
}
|
||||
newEnvironment = append(newEnvironment, healthPort...)
|
||||
}
|
||||
|
||||
var securityContext *corev1.SecurityContext
|
||||
if c.LoadBalancerForwardingMethod == "masquerade" {
|
||||
var privileged = true
|
||||
privileged := true
|
||||
securityContext = &corev1.SecurityContext{
|
||||
Privileged: &privileged,
|
||||
}
|
||||
@@ -534,10 +680,32 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
"NET_ADMIN",
|
||||
"NET_RAW",
|
||||
},
|
||||
Drop: []corev1.Capability{
|
||||
"ALL",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if c.PreserveVIPOnLeadershipLoss {
|
||||
preserveVIPOnLeadershipLoss := []corev1.EnvVar{
|
||||
{
|
||||
Name: vipPreserveOnLeadershipLoss,
|
||||
Value: strconv.FormatBool(c.PreserveVIPOnLeadershipLoss),
|
||||
},
|
||||
}
|
||||
newEnvironment = append(newEnvironment, preserveVIPOnLeadershipLoss...)
|
||||
}
|
||||
|
||||
if c.DebounceTime != debouncer.DefaultTime {
|
||||
debTime := corev1.EnvVar{
|
||||
Name: debounceTime,
|
||||
Value: c.DebounceTime,
|
||||
}
|
||||
|
||||
newEnvironment = append(newEnvironment, debTime)
|
||||
}
|
||||
|
||||
newManifest := &corev1.Pod{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Pod",
|
||||
@@ -551,7 +719,7 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "kube-vip",
|
||||
Image: fmt.Sprintf("ghcr.io/kube-vip/kube-vip:%s", imageVersion),
|
||||
Image: imageRef.Name(),
|
||||
ImagePullPolicy: corev1.PullIfNotPresent,
|
||||
SecurityContext: securityContext,
|
||||
Args: []string{
|
||||
@@ -592,38 +760,24 @@ func generatePodSpec(c *Config, imageVersion string, inCluster bool) *corev1.Pod
|
||||
newManifest.Spec.HostAliases = append(newManifest.Spec.HostAliases, hostAlias)
|
||||
}
|
||||
|
||||
if c.ProviderConfig != "" {
|
||||
providerConfigMount := corev1.VolumeMount{
|
||||
Name: "cloud-sa-volume",
|
||||
MountPath: "/etc/cloud-sa",
|
||||
ReadOnly: true,
|
||||
}
|
||||
newManifest.Spec.Containers[0].VolumeMounts = append(newManifest.Spec.Containers[0].VolumeMounts, providerConfigMount)
|
||||
|
||||
providerConfigVolume := corev1.Volume{
|
||||
Name: "cloud-sa-volume",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "metal-cloud-config",
|
||||
},
|
||||
},
|
||||
}
|
||||
newManifest.Spec.Volumes = append(newManifest.Spec.Volumes, providerConfigVolume)
|
||||
|
||||
}
|
||||
|
||||
return newManifest
|
||||
return newManifest, nil
|
||||
}
|
||||
|
||||
// GeneratePodManifestFromConfig will take a kube-vip config and generate a manifest
|
||||
func GeneratePodManifestFromConfig(c *Config, imageVersion string, inCluster bool) string {
|
||||
newManifest := generatePodSpec(c, imageVersion, inCluster)
|
||||
b, _ := yaml.Marshal(newManifest)
|
||||
return string(b)
|
||||
func GeneratePodManifestFromConfig(c *Config, image, imageVersion string, inCluster bool) (string, error) {
|
||||
newManifest, err := generatePodSpec(c, image, imageVersion, inCluster)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := yaml.Marshal(newManifest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal manifest: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// GenerateDaemonsetManifestFromConfig will take a kube-vip config and generate a manifest
|
||||
func GenerateDaemonsetManifestFromConfig(c *Config, imageVersion string, inCluster, taint bool) string {
|
||||
func GenerateDaemonsetManifestFromConfig(c *Config, image, imageVersion string, inCluster, taint bool) (string, error) {
|
||||
// Determine where the pod should be deployed
|
||||
var namespace string
|
||||
if c.ServiceNamespace != "" {
|
||||
@@ -632,7 +786,11 @@ func GenerateDaemonsetManifestFromConfig(c *Config, imageVersion string, inClust
|
||||
namespace = metav1.NamespaceSystem
|
||||
}
|
||||
|
||||
podSpec := generatePodSpec(c, imageVersion, inCluster).Spec
|
||||
pod, err := generatePodSpec(c, image, imageVersion, inCluster)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newManifest := &appv1.DaemonSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "DaemonSet",
|
||||
@@ -659,7 +817,7 @@ func GenerateDaemonsetManifestFromConfig(c *Config, imageVersion string, inClust
|
||||
"app.kubernetes.io/version": imageVersion,
|
||||
},
|
||||
},
|
||||
Spec: podSpec,
|
||||
Spec: pod.Spec,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -709,5 +867,5 @@ func GenerateDaemonsetManifestFromConfig(c *Config, imageVersion string, inClust
|
||||
delete(m, "status")
|
||||
|
||||
b, _ = yaml.Marshal(m)
|
||||
return string(b)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
package kubevip
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
applyRbacV1 "k8s.io/client-go/applyconfigurations/rbac/v1"
|
||||
)
|
||||
|
||||
func TestGenerateRoleServiceCIDRAccess(t *testing.T) {
|
||||
clusterRole := GenerateRole(&Config{}, false)
|
||||
if !hasServiceCIDRRule(clusterRole) {
|
||||
t.Fatal("generated ClusterRole is missing ServiceCIDR access")
|
||||
}
|
||||
|
||||
role := GenerateRole(&Config{ServiceNamespace: "kube-vip"}, true)
|
||||
if hasServiceCIDRRule(role) {
|
||||
t.Fatal("generated namespaced Role contains ineffective ServiceCIDR access")
|
||||
}
|
||||
}
|
||||
|
||||
func hasServiceCIDRRule(role *applyRbacV1.RoleApplyConfiguration) bool {
|
||||
for _, rule := range role.Rules {
|
||||
if slices.Contains(rule.APIGroups, "networking.k8s.io") &&
|
||||
slices.Contains(rule.Resources, "servicecidrs") &&
|
||||
slices.Contains(rule.Verbs, "get") &&
|
||||
slices.Contains(rule.Verbs, "list") &&
|
||||
slices.Contains(rule.Verbs, "watch") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestParseEnvironment(t *testing.T) {
|
||||
|
||||
@@ -9,8 +41,8 @@ func TestParseEnvironment(t *testing.T) {
|
||||
c *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{"", nil, false},
|
||||
{"", &Config{Interface: "eth0", ServicesInterface: "eth1"}, false},
|
||||
{"nil config", nil, false},
|
||||
{"basic config", &Config{Interface: "eth0", ServicesInterface: "eth1"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Logf("%v", tt.c)
|
||||
@@ -21,3 +53,146 @@ func TestParseEnvironment(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvironmentInstanceName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lowercase string
|
||||
uppercase string
|
||||
want string
|
||||
}{
|
||||
{name: "lowercase", lowercase: "release_a", want: "release_a"},
|
||||
{name: "uppercase fallback", uppercase: "release_b", want: "release_b"},
|
||||
{name: "lowercase takes precedence", lowercase: "release_a", uppercase: "release_b", want: "release_a"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(instanceName, tt.lowercase)
|
||||
t.Setenv(strings.ToUpper(instanceName), tt.uppercase)
|
||||
|
||||
config := &Config{}
|
||||
if err := ParseEnvironment(config); err != nil {
|
||||
t.Fatalf("ParseEnvironment() error = %v", err)
|
||||
}
|
||||
if config.InstanceName != tt.want {
|
||||
t.Fatalf("InstanceName = %q, want %q", config.InstanceName, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvironmentBGPAttachIPToInterface(t *testing.T) {
|
||||
t.Setenv(bgpAttachIPToInterface, "true")
|
||||
|
||||
config := &Config{}
|
||||
if err := ParseEnvironment(config); err != nil {
|
||||
t.Fatalf("ParseEnvironment() error = %v", err)
|
||||
}
|
||||
if !config.BGPAttachIPToInterface {
|
||||
t.Fatal("BGPAttachIPToInterface = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePodSpecBGPAttachIPToInterface(t *testing.T) {
|
||||
pod, err := generatePodSpec(&Config{
|
||||
EnableBGP: true,
|
||||
BGPAttachIPToInterface: true,
|
||||
}, "ghcr.io/kube-vip/kube-vip", "v0.0.0", true)
|
||||
if err != nil {
|
||||
t.Fatalf("generatePodSpec() error = %v", err)
|
||||
}
|
||||
|
||||
for _, env := range pod.Spec.Containers[0].Env {
|
||||
if env.Name == bgpAttachIPToInterface && env.Value == "true" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s=true is missing from generated pod environment", bgpAttachIPToInterface)
|
||||
}
|
||||
|
||||
func TestGeneratePodSpecInstanceName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceName string
|
||||
wantPresent bool
|
||||
}{
|
||||
{name: "configured", instanceName: "release_a", wantPresent: true},
|
||||
{name: "empty", wantPresent: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pod, err := generatePodSpec(&Config{InstanceName: tt.instanceName}, "ghcr.io/kube-vip/kube-vip", "v0.0.0", true)
|
||||
if err != nil {
|
||||
t.Fatalf("generatePodSpec() error = %v", err)
|
||||
}
|
||||
|
||||
var value string
|
||||
found := false
|
||||
for _, env := range pod.Spec.Containers[0].Env {
|
||||
if env.Name == instanceName {
|
||||
found = true
|
||||
value = env.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != tt.wantPresent {
|
||||
t.Fatalf("instance_name present = %t, want %t", found, tt.wantPresent)
|
||||
}
|
||||
if found && value != tt.instanceName {
|
||||
t.Fatalf("instance_name = %q, want %q", value, tt.instanceName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvironmentConfigFile(t *testing.T) {
|
||||
// Save original environment
|
||||
originalConfigFile := os.Getenv("config_file")
|
||||
defer func() {
|
||||
if originalConfigFile != "" {
|
||||
os.Setenv("config_file", originalConfigFile)
|
||||
} else {
|
||||
os.Unsetenv("config_file")
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
expectedConfig string
|
||||
}{
|
||||
{
|
||||
name: "config_file environment variable set",
|
||||
envValue: "/etc/kube-vip/config.yaml",
|
||||
expectedConfig: "/etc/kube-vip/config.yaml",
|
||||
},
|
||||
{
|
||||
name: "config_file environment variable empty",
|
||||
envValue: "",
|
||||
expectedConfig: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set environment variable
|
||||
if tt.envValue != "" {
|
||||
os.Setenv("config_file", tt.envValue)
|
||||
} else {
|
||||
os.Unsetenv("config_file")
|
||||
}
|
||||
|
||||
config := &Config{}
|
||||
err := ParseEnvironment(config)
|
||||
if err != nil {
|
||||
t.Errorf("ParseEnvironment() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if config.ConfigFile != tt.expectedConfig {
|
||||
t.Errorf("ConfigFile = %v, expected %v", config.ConfigFile, tt.expectedConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
log "log/slog"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
const (
|
||||
Auto = "auto"
|
||||
)
|
||||
|
||||
var ErrInterfaceNotUp = errors.New("interface is not up")
|
||||
|
||||
func (c *Config) CheckSubnetExists() error {
|
||||
if c.VIPSubnet == "" && c.VIP != "" && c.Address == "" {
|
||||
return fmt.Errorf("vip_subnet must be set if using vip_address instead of address environment variable")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) CheckInterface() error {
|
||||
if c.Interface != "" {
|
||||
if err := isValidInterface(c.Interface); err != nil {
|
||||
return fmt.Errorf("%s is not valid interface, reason: %w", c.Interface, err)
|
||||
if errors.Is(err, ErrInterfaceNotUp) && c.AllowInterfaceNotUp {
|
||||
log.Warn("interface is not up, continuing as allowInterfaceNotUp is set", "interface", c.Interface)
|
||||
} else {
|
||||
return fmt.Errorf("%s is not valid interface, reason: %w", c.Interface, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.ServicesInterface != "" {
|
||||
if err := isValidInterface(c.ServicesInterface); err != nil {
|
||||
return fmt.Errorf("%s is not valid interface, reason: %w", c.ServicesInterface, err)
|
||||
if errors.Is(err, ErrInterfaceNotUp) && c.AllowInterfaceNotUp {
|
||||
log.Warn("interface is not up, continuing as allowInterfaceNotUp is set", "interface", c.ServicesInterface)
|
||||
} else {
|
||||
return fmt.Errorf("%s is not valid interface, reason: %w", c.ServicesInterface, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +48,10 @@ func (c *Config) CheckInterface() error {
|
||||
}
|
||||
|
||||
func isValidInterface(iface string) error {
|
||||
// auto interface discovery for services is enabled
|
||||
if iface == Auto {
|
||||
return nil
|
||||
}
|
||||
l, err := netlink.LinkByName(iface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get %s failed, error: %w", iface, err)
|
||||
@@ -36,12 +64,12 @@ func isValidInterface(iface string) error {
|
||||
// userspace has set operational state. Interface must be considered for user
|
||||
// data as setting operational state has not been implemented in every driver."
|
||||
if attrs.OperState == netlink.OperUnknown {
|
||||
log.Warningf(
|
||||
"the status of the interface %s is unknown. Ensure your interface is ready to accept traffic, if so you can safely ignore this message",
|
||||
log.Warn(
|
||||
"the status of the interface is unknown. Ensure your interface is ready to accept traffic, if so you can safely ignore this message", "interface",
|
||||
iface,
|
||||
)
|
||||
} else if attrs.OperState != netlink.OperUp {
|
||||
return fmt.Errorf("%s is not up", iface)
|
||||
return fmt.Errorf("%s %w", iface, ErrInterfaceNotUp)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
56
pkg/kubevip/config_manager_test.go
Normal file
56
pkg/kubevip/config_manager_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package kubevip
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckSubnetExists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "vip only without subnet returns error",
|
||||
config: Config{
|
||||
VIP: "172.18.0.20",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "vip with subnet does not return error",
|
||||
config: Config{
|
||||
VIP: "172.18.0.20",
|
||||
VIPSubnet: "32",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "address only without subnet does not return error",
|
||||
config: Config{
|
||||
Address: "172.18.0.20",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "address overrides vip without subnet",
|
||||
config: Config{
|
||||
VIP: "172.18.0.20",
|
||||
Address: "172.18.0.30",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty config does not return error",
|
||||
config: Config{},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.CheckSubnetExists()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CheckSubnetExists() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"github.com/kube-vip/kube-vip/pkg/bgp"
|
||||
)
|
||||
|
||||
// Config defines all of the settings for the Kube-Vip Pod
|
||||
type Config struct {
|
||||
// Logging, settings
|
||||
Logging int `yaml:"logging"`
|
||||
Logging int32 `yaml:"logging"`
|
||||
|
||||
// EnableARP, will use ARP to advertise the VIP address
|
||||
EnableARP bool `yaml:"enableARP"`
|
||||
@@ -15,6 +11,9 @@ type Config struct {
|
||||
// EnableBGP, will use BGP to advertise the VIP address
|
||||
EnableBGP bool `yaml:"enableBGP"`
|
||||
|
||||
// BGPAttachIPToInterface assigns BGP-advertised service VIPs to the configured interface
|
||||
BGPAttachIPToInterface bool `yaml:"bgpAttachIPToInterface"`
|
||||
|
||||
// EnableWireguard, will use wireguard to advertise the VIP address
|
||||
EnableWireguard bool `yaml:"enableWireguard"`
|
||||
|
||||
@@ -54,6 +53,16 @@ type Config struct {
|
||||
// ArpBroadcastRate, defines how often kube-vip will update the network about updates to the network
|
||||
ArpBroadcastRate int64 `yaml:"arpBroadcastRate"`
|
||||
|
||||
// PreserveVIPOnLeadershipLoss, if true, VIP addresses will remain on interface when leadership is lost (only ARP/NDP broadcasting stops)
|
||||
// If false, VIP addresses are deleted on leadership loss (legacy behavior)
|
||||
PreserveVIPOnLeadershipLoss bool `yaml:"preserveVipOnLeadershipLoss"`
|
||||
|
||||
// LoseLeadership enables leadership loss if VIP interface(physical) is down
|
||||
LoseLeadership bool `yaml:"loseLeadership"`
|
||||
|
||||
// LoseLeadershipTimeoutSeconds defines the timeout after which interface will be considered down. Default is 30s
|
||||
LoseLeadershipTimeoutSeconds int `yaml:"loseLeadershipTimeoutSeconds"`
|
||||
|
||||
// Annotations will define if we're going to wait and lookup configuration from Kubernetes node annotations
|
||||
Annotations string
|
||||
|
||||
@@ -76,18 +85,18 @@ type Config struct {
|
||||
// VipSubnet is the Subnet that is applied to the VIP
|
||||
VIPSubnet string `yaml:"vipSubnet"`
|
||||
|
||||
// VIPCIDR is cidr range for the VIP (primarily needed for BGP)
|
||||
VIPCIDR string `yaml:"vipCidr"`
|
||||
|
||||
// Address is the IP or DNS Name to use as a VirtualIP
|
||||
Address string `yaml:"address"`
|
||||
|
||||
// Listen port for the VirtualIP
|
||||
Port int `yaml:"port"`
|
||||
Port uint16 `yaml:"port"`
|
||||
|
||||
// Namespace will define which namespace the control plane pods will run in
|
||||
Namespace string `yaml:"namespace"`
|
||||
|
||||
// InstanceName identifies this kube-vip deployment when naming host-global resources.
|
||||
InstanceName string `yaml:"instanceName"`
|
||||
|
||||
// Namespace will define which namespace the control plane pods will run in
|
||||
ServiceNamespace string `yaml:"serviceNamespace"`
|
||||
|
||||
@@ -109,11 +118,14 @@ type Config struct {
|
||||
// ServicesInterface is the network interface to bind to for services (optional)
|
||||
ServicesInterface string `yaml:"servicesInterface,omitempty"`
|
||||
|
||||
// AllowInterfaceNotUp allows kube-vip to start even when the interface is not up
|
||||
AllowInterfaceNotUp bool `yaml:"allowInterfaceNotUp,omitempty"`
|
||||
|
||||
// EnableLoadBalancer, provides the flexibility to make the load-balancer optional
|
||||
EnableLoadBalancer bool `yaml:"enableLoadBalancer"`
|
||||
|
||||
// Listen port for the IPVS Service
|
||||
LoadBalancerPort int `yaml:"lbPort"`
|
||||
LoadBalancerPort uint16 `yaml:"lbPort"`
|
||||
|
||||
// Forwarding method for the IPVS Service
|
||||
LoadBalancerForwardingMethod string `yaml:"lbForwardingMethod"`
|
||||
@@ -130,25 +142,17 @@ type Config struct {
|
||||
// Clean routing table of redundant routes on start
|
||||
CleanRoutingTable bool `yaml:"cleanRoutingTable"`
|
||||
|
||||
// Skip Duplicate Address Detection when adding the VIP address (IFA_F_NODAD)
|
||||
SkipDAD bool `yaml:"skipDAD"`
|
||||
|
||||
// BGP Configuration
|
||||
BGPConfig bgp.Config
|
||||
BGPPeerConfig bgp.Peer
|
||||
BGPConfig BGPConfig
|
||||
BGPPeerConfig BGPPeer
|
||||
BGPPeers []string
|
||||
|
||||
// EnableMetal, will use the metal API to update the EIP <-> VIP (if BGP is enabled then BGP will be used)
|
||||
EnableMetal bool `yaml:"enableMetal"`
|
||||
|
||||
// MetalAPIKey, is the API token used to authenticate to the API
|
||||
MetalAPIKey string
|
||||
|
||||
// MetalProject, is the name of a particular defined project
|
||||
MetalProject string
|
||||
|
||||
// MetalProjectID, is the name of a particular defined project
|
||||
MetalProjectID string
|
||||
|
||||
// ProviderConfig, is the path to a provider configuration file
|
||||
ProviderConfig string
|
||||
// ControlPlaneHealthCheck configures HTTP polling of the control plane when using BGP without
|
||||
// leader election. If the health check fails, the BGP route will be withdrawn.
|
||||
ControlPlaneHealthCheck HealthCheck `yaml:"controlPlaneHealthCheck,omitempty"`
|
||||
|
||||
// LoadBalancers are the various services we can load balance over
|
||||
LoadBalancers []LoadBalancer `yaml:"loadBalancers,omitempty"`
|
||||
@@ -164,6 +168,9 @@ type Config struct {
|
||||
// EgressServiceCidr, this contains the service cidr range to ignore
|
||||
EgressServiceCidr string
|
||||
|
||||
// EnableInternalSNAT, this will enable the internal SNAT rule that kube-vip adds to the egress chain
|
||||
EnableInternalSNAT bool
|
||||
|
||||
// EgressWithNftables, this will use the iptables-nftables OVER iptables
|
||||
EgressWithNftables bool
|
||||
|
||||
@@ -176,11 +183,21 @@ type Config struct {
|
||||
// DNSMode, this will set the mode DSN lookup will be performed (first, ipv4, ipv6, dual)
|
||||
DNSMode string `yaml:"dnsDualStackMode"`
|
||||
|
||||
// IsDualStack reports if service is DualStack.
|
||||
IsDualStack bool
|
||||
|
||||
// RequireDualStack defines if DualStack is required for the service. Based on service's Spec.ipFamilyPolicy field.
|
||||
RequireDualStack bool
|
||||
|
||||
// DNSMode, this will set the mode DHCP lookup will be performed for DDNS (ipv4, ipv6, dual). By default will be the same as DNSMode.
|
||||
// If DNSMode is 'first', IPv4 will be used.
|
||||
DHCPMode string `yaml:"dhcpDualStackMode"`
|
||||
|
||||
// DisableServiceUpdates, if true, kube-vip will only advertise service, but it will not update service's Status.LoadBalancer.Ingress slice
|
||||
DisableServiceUpdates bool `yaml:"disableServiceUpdates"`
|
||||
|
||||
// EnableEndpointSlices, if enabled, EndpointSlices will be used instead of Endpoints
|
||||
EnableEndpointSlices bool `yaml:"enableEndpointSlices"`
|
||||
// EnableEndpoints, if enabled, Endpoints will be used instead of EndpointSlices
|
||||
EnableEndpoints bool `yaml:"enableEndpoints"`
|
||||
|
||||
// MirrorDestInterface is the network interface where all traffics that go through service interface
|
||||
// will be mirrored to. If ServicesInterface is not set, fall back to Interface.
|
||||
@@ -192,6 +209,30 @@ type Config struct {
|
||||
|
||||
// BackendHealthCheckInterval Interval in seconds for checking backend health.
|
||||
BackendHealthCheckInterval int `yaml:"backendHealthCheckInterval"`
|
||||
|
||||
// LoInterfaceGlobalScope, if true will set global scope when using the lo interface, otherwise a host scope will be used
|
||||
LoInterfaceGlobalScope bool `yaml:"loInterfaceGlobalScope"`
|
||||
|
||||
// HealthCheckPort, if non-zero then will enable the healthcheck to return ok on this port
|
||||
HealthCheckPort int `yaml:"healthCheckPort"`
|
||||
|
||||
// EnableUPNP, enables UPNP functions
|
||||
EnableUPNP bool `yaml:"enableUPNP"`
|
||||
|
||||
// EgressClean, enables egress cleaning on Kube-vip's start
|
||||
EgressClean bool `yaml:"egressClean"`
|
||||
|
||||
// ConfigFile defines the path to a JSON/YAML configuration file
|
||||
ConfigFile string `yaml:"configFile"`
|
||||
|
||||
// DHCPBackoffAttempts defines how many times will DHCP client try to obtain address (unlimited when 0)
|
||||
DHCPBackoffAttempts uint `yaml:"dhcpBackoffAttempts"`
|
||||
|
||||
// DebounceTime defines how long will event debouncer wait for the events to arrive
|
||||
DebounceTime string `yaml:"debounceTime"`
|
||||
|
||||
// PerServiceElectionOnDemand will enable kube-vip to handle services with per-service election when annotation is used
|
||||
PerServiceElectionOnDemand bool `yaml:"perServiceElectionOnDemand"`
|
||||
}
|
||||
|
||||
// KubernetesLeaderElection defines all of the settings for Kubernetes KubernetesLeaderElection
|
||||
@@ -223,16 +264,30 @@ type Etcd struct {
|
||||
Endpoints []string
|
||||
}
|
||||
|
||||
// HealthCheck defines HTTP health-check settings for control-plane polling when using BGP
|
||||
// without leader election.
|
||||
type HealthCheck struct {
|
||||
// Address is the URL to poll to check the health of the control-plane. If the health
|
||||
// check fails, the BGP route will be withdrawn.
|
||||
Address string `yaml:"address"`
|
||||
// PeriodSeconds is the interval in seconds between health checks.
|
||||
PeriodSeconds int `yaml:"periodSeconds"`
|
||||
// TimeoutSeconds is the timeout per health check request. If a request takes longer
|
||||
// than this timeout, the health check is considered failed.
|
||||
TimeoutSeconds int `yaml:"timeoutSeconds"`
|
||||
// FailureThreshold is the number of consecutive failures before route withdrawal.
|
||||
FailureThreshold int `yaml:"failureThreshold"`
|
||||
// CAPath is the CA certificate path used for TLS verification when Address is an HTTPS URL.
|
||||
CAPath string `yaml:"caPath"`
|
||||
}
|
||||
|
||||
// LoadBalancer contains the configuration of a load balancing instance
|
||||
type LoadBalancer struct {
|
||||
// Name of a LoadBalancer
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Type of LoadBalancer, either TCP of HTTP(s)
|
||||
Type string `yaml:"type"`
|
||||
|
||||
// Listening frontend port of this LoadBalancer instance
|
||||
Port int `yaml:"port"`
|
||||
// Ports exposed by a LoadBalancer
|
||||
Ports []Port
|
||||
|
||||
// BindToVip will bind the load balancer port to the VIP itself
|
||||
BindToVip bool `yaml:"bindToVip"`
|
||||
@@ -240,3 +295,11 @@ type LoadBalancer struct {
|
||||
// Forwarding method of LoadBalancer, either Local, Tunnel, DirectRoute or Bypass
|
||||
ForwardingMethod string `yaml:"forwardingMethod"`
|
||||
}
|
||||
|
||||
type Port struct {
|
||||
// Type of LoadBalancer, either TCP or UDP
|
||||
Type string `yaml:"type"`
|
||||
|
||||
// Listening frontend port of this LoadBalancer instance
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
87
pkg/kubevip/config_validation.go
Normal file
87
pkg/kubevip/config_validation.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// nftables object names are limited to 255 bytes. Reserve space for the
|
||||
// prefix and address-family suffix added to the instance name.
|
||||
nftablesNameMaxLength = 255
|
||||
egressNftablesTablePrefix = "kube_vip_"
|
||||
egressNftablesTableSuffix = "_v4"
|
||||
instanceNameMaxLength = nftablesNameMaxLength - len(egressNftablesTablePrefix) - len(egressNftablesTableSuffix)
|
||||
)
|
||||
|
||||
// Validate runs configuration checks that are independent of host state.
|
||||
// This should be called after all config sources (flags, file, env vars) are merged.
|
||||
func (c *Config) Validate() error {
|
||||
if err := validateHealthCheckAddress(c.ControlPlaneHealthCheck.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInstanceName(c.InstanceName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRoutingProtocol(c.RoutingProtocol); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRoutingProtocol rejects values the kernel cannot represent: netlink
|
||||
// carries the address and route protocol in a single byte, so a larger value is
|
||||
// silently truncated on the wire and never matches on readback.
|
||||
func validateRoutingProtocol(protocol int) error {
|
||||
if protocol < 0 || protocol > math.MaxUint8 {
|
||||
return fmt.Errorf("routingProtocol %d is out of range, must be between 0 and %d", protocol, math.MaxUint8)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInstanceName(name string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if len(name) > instanceNameMaxLength {
|
||||
return fmt.Errorf("instance_name is %d bytes, must not exceed %d bytes so the %q prefix and %q or %q suffix fit within the nftables %d-byte name limit",
|
||||
len(name), instanceNameMaxLength, egressNftablesTablePrefix, "_v4", "_v6", nftablesNameMaxLength)
|
||||
}
|
||||
|
||||
for position, char := range name {
|
||||
if isValidNftablesNameCharacter(char) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("instance_name %q contains invalid character %q at byte %d; only ASCII letters, digits, '.', '-' and '_' are allowed",
|
||||
name, char, position)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidNftablesNameCharacter(char rune) bool {
|
||||
return char >= 'a' && char <= 'z' ||
|
||||
char >= 'A' && char <= 'Z' ||
|
||||
char >= '0' && char <= '9' ||
|
||||
char == '_' || char == '-' || char == '.'
|
||||
}
|
||||
|
||||
func validateHealthCheckAddress(address string) error {
|
||||
if address == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedURL, err := url.ParseRequestURI(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("control_plane_health_check_address %q is not a valid URL: %w", address, err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(parsedURL.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return fmt.Errorf("control_plane_health_check_address %q has unsupported scheme %q, expected http or https", address, parsedURL.Scheme)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
94
pkg/kubevip/config_validation_test.go
Normal file
94
pkg/kubevip/config_validation_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidate_HealthCheckAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty address (disabled)", "", false},
|
||||
{"valid http URL", "http://localhost:6443/livez", false},
|
||||
{"valid https URL", "https://localhost:6443/livez", false},
|
||||
{"https with path", "https://127.0.0.1:6443/readyz?verbose", false},
|
||||
{"invalid URL", "not-a-url", true},
|
||||
{"ftp scheme", "ftp://localhost/file", true},
|
||||
{"tcp scheme", "tcp://localhost:6443", true},
|
||||
{"missing scheme", "localhost:6443/livez", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Config{ControlPlaneHealthCheck: HealthCheck{Address: tt.address}}
|
||||
err := c.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_InstanceName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceName string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty uses legacy default", instanceName: "", wantErr: false},
|
||||
{name: "letters digits and separators", instanceName: "release_01.prod-a", wantErr: false},
|
||||
{name: "exact maximum length", instanceName: strings.Repeat("a", instanceNameMaxLength), wantErr: false},
|
||||
{name: "exceeds maximum length", instanceName: strings.Repeat("a", instanceNameMaxLength+1), wantErr: true},
|
||||
{name: "space", instanceName: "release a", wantErr: true},
|
||||
{name: "slash", instanceName: "namespace/release", wantErr: true},
|
||||
{name: "dollar sign", instanceName: "release$a", wantErr: true},
|
||||
{name: "at sign", instanceName: "release@a", wantErr: true},
|
||||
{name: "newline", instanceName: "release\na", wantErr: true},
|
||||
{name: "null byte", instanceName: "release\x00a", wantErr: true},
|
||||
{name: "unicode", instanceName: "rilascio-à", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := &Config{InstanceName: tt.instanceName}
|
||||
err := config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RoutingProtocol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
protocol int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "unset", protocol: 0, wantErr: false},
|
||||
{name: "kube-vip default", protocol: 248, wantErr: false},
|
||||
{name: "maximum byte value", protocol: 255, wantErr: false},
|
||||
{name: "truncated on the wire", protocol: 256, wantErr: true},
|
||||
{name: "negative", protocol: -1, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := &Config{RoutingProtocol: tt.protocol}
|
||||
err := config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceNameLimitReservesNftablesPrefixAndFamilySuffix(t *testing.T) {
|
||||
name := strings.Repeat("a", instanceNameMaxLength)
|
||||
if got := len(egressNftablesTablePrefix + name + egressNftablesTableSuffix); got != nftablesNameMaxLength {
|
||||
t.Fatalf("family-specific table name length = %d, want %d", got, nftablesNameMaxLength)
|
||||
}
|
||||
}
|
||||
7
pkg/kubevip/constants.go
Normal file
7
pkg/kubevip/constants.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package kubevip
|
||||
|
||||
const (
|
||||
LBClassName = "kube-vip.io/kube-vip-class"
|
||||
|
||||
DefaultDHCPBackoffAttempts = 3
|
||||
)
|
||||
22
pkg/kubevip/labels.go
Normal file
22
pkg/kubevip/labels.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
const (
|
||||
// ServiceProvided is the name of the label that will be added to the node
|
||||
ServiceProvided = "service-provided.kube-vip.io"
|
||||
|
||||
// label used on nodes, which announce the LoadBalancer IP
|
||||
HasIP = "kube-vip.io/has-ip"
|
||||
)
|
||||
|
||||
var kubevipLabelKeys = []string{
|
||||
ServiceProvided,
|
||||
HasIP,
|
||||
}
|
||||
|
||||
func GetKeysForCleanup() []string {
|
||||
return slices.Clone(kubevipLabelKeys)
|
||||
}
|
||||
102
pkg/kubevip/lease_annotations.go
Normal file
102
pkg/kubevip/lease_annotations.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package kubevip
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const LeaseVIPsVersion = "v1"
|
||||
|
||||
type LeaseVIPsValue struct {
|
||||
Version string `json:"version"`
|
||||
InstanceName string `json:"instance_name"`
|
||||
IFAProto int `json:"ifa_proto"`
|
||||
VIPs []LeaseVIP `json:"vips"`
|
||||
}
|
||||
|
||||
type LeaseVIP struct {
|
||||
Index int `json:"index"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func WithLeaseVIPs(annotations map[string]string, instanceName string, ifaProto int, vips []string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(annotations)+1)
|
||||
for key, value := range annotations {
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(LeaseVIPsValue{
|
||||
Version: LeaseVIPsVersion,
|
||||
InstanceName: instanceName,
|
||||
IFAProto: ifaProto,
|
||||
VIPs: normalizeLeaseVIPs(vips),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode %s annotation: %w", LeaseVIPs, err)
|
||||
}
|
||||
result[LeaseVIPs] = string(encoded)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ParseLeaseVIPs(value string) (LeaseVIPsValue, error) {
|
||||
var parsed LeaseVIPsValue
|
||||
if err := json.Unmarshal([]byte(value), &parsed); err != nil {
|
||||
return LeaseVIPsValue{}, fmt.Errorf("decode %s annotation: %w", LeaseVIPs, err)
|
||||
}
|
||||
if parsed.Version != LeaseVIPsVersion {
|
||||
return LeaseVIPsValue{}, fmt.Errorf("unsupported %s annotation version %q", LeaseVIPs, parsed.Version)
|
||||
}
|
||||
for index, vip := range parsed.VIPs {
|
||||
if vip.Index != index {
|
||||
return LeaseVIPsValue{}, fmt.Errorf("invalid %s VIP index %d at position %d", LeaseVIPs, vip.Index, index)
|
||||
}
|
||||
address, err := parseLeaseVIP(vip.Value)
|
||||
if err != nil {
|
||||
return LeaseVIPsValue{}, fmt.Errorf("invalid %s VIP at index %d: %w", LeaseVIPs, vip.Index, err)
|
||||
}
|
||||
parsed.VIPs[index].Value = address.String()
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func normalizeLeaseVIPs(values []string) []LeaseVIP {
|
||||
unique := make(map[netip.Addr]struct{})
|
||||
addresses := make([]netip.Addr, 0, len(values))
|
||||
for _, value := range values {
|
||||
for candidate := range strings.SplitSeq(value, ",") {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
address, err := parseLeaseVIP(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := unique[address]; exists {
|
||||
continue
|
||||
}
|
||||
unique[address] = struct{}{}
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
}
|
||||
// Sorting keeps the annotation byte-identical however callers happen to order VIPs.
|
||||
slices.SortFunc(addresses, netip.Addr.Compare)
|
||||
|
||||
result := make([]LeaseVIP, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
result = append(result, LeaseVIP{Index: len(result), Value: address.String()})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseLeaseVIP(value string) (netip.Addr, error) {
|
||||
address, err := netip.ParseAddr(value)
|
||||
if err == nil {
|
||||
return address.Unmap(), nil
|
||||
}
|
||||
prefix, prefixErr := netip.ParsePrefix(value)
|
||||
if prefixErr != nil {
|
||||
return netip.Addr{}, fmt.Errorf("parse address %q: %w", value, err)
|
||||
}
|
||||
return prefix.Addr().Unmap(), nil
|
||||
}
|
||||
78
pkg/kubevip/lease_annotations_test.go
Normal file
78
pkg/kubevip/lease_annotations_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package kubevip
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWithLeaseVIPsEncodesVersionedInstanceOwnership(t *testing.T) {
|
||||
base := map[string]string{"example.test/preserved": "true", LeaseVIPs: "stale"}
|
||||
annotations, err := WithLeaseVIPs(base, "release_a", 248, []string{
|
||||
"2001:db8::10/128", "192.0.2.10", "192.0.2.10/32", "api.example.test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLeaseVIPs() error = %v", err)
|
||||
}
|
||||
if annotations["example.test/preserved"] != "true" {
|
||||
t.Fatal("WithLeaseVIPs() dropped an existing annotation")
|
||||
}
|
||||
if base[LeaseVIPs] != "stale" {
|
||||
t.Fatal("WithLeaseVIPs() mutated the input annotations")
|
||||
}
|
||||
|
||||
value, err := ParseLeaseVIPs(annotations[LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLeaseVIPs() error = %v", err)
|
||||
}
|
||||
if value.Version != LeaseVIPsVersion || value.InstanceName != "release_a" || value.IFAProto != 248 {
|
||||
t.Fatalf("Lease VIP metadata = %+v", value)
|
||||
}
|
||||
if len(value.VIPs) != 2 ||
|
||||
value.VIPs[0] != (LeaseVIP{Index: 0, Value: "192.0.2.10"}) ||
|
||||
value.VIPs[1] != (LeaseVIP{Index: 1, Value: "2001:db8::10"}) {
|
||||
t.Fatalf("Lease VIPs = %v, want indexed VIPs in canonical address order", value.VIPs)
|
||||
}
|
||||
}
|
||||
|
||||
// The annotation is rewritten whenever a node starts campaigning, so the encoding
|
||||
// has to be stable even when callers collect the same VIPs in a different order.
|
||||
func TestWithLeaseVIPsIsIndependentOfInputOrder(t *testing.T) {
|
||||
first, err := WithLeaseVIPs(nil, "release_a", 248, []string{
|
||||
"2001:db8::10", "192.0.2.10", "10.0.0.2", "10.0.0.10",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLeaseVIPs() error = %v", err)
|
||||
}
|
||||
second, err := WithLeaseVIPs(nil, "release_a", 248, []string{
|
||||
"10.0.0.10", "192.0.2.10", "2001:db8::10", "10.0.0.2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithLeaseVIPs() error = %v", err)
|
||||
}
|
||||
if first[LeaseVIPs] != second[LeaseVIPs] {
|
||||
t.Fatalf("annotation changed with input order:\n%s\n%s", first[LeaseVIPs], second[LeaseVIPs])
|
||||
}
|
||||
|
||||
value, err := ParseLeaseVIPs(first[LeaseVIPs])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLeaseVIPs() error = %v", err)
|
||||
}
|
||||
want := []string{"10.0.0.2", "10.0.0.10", "192.0.2.10", "2001:db8::10"}
|
||||
if len(value.VIPs) != len(want) {
|
||||
t.Fatalf("Lease VIPs = %v, want %v", value.VIPs, want)
|
||||
}
|
||||
for index, address := range want {
|
||||
if value.VIPs[index] != (LeaseVIP{Index: index, Value: address}) {
|
||||
t.Fatalf("Lease VIPs = %v, want %v", value.VIPs, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaseVIPsRejectsUnknownVersion(t *testing.T) {
|
||||
if _, err := ParseLeaseVIPs(`{"version":"v2","instance_name":"release_a","ifa_proto":248,"vips":[]}`); err == nil {
|
||||
t.Fatal("ParseLeaseVIPs() accepted an unknown version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaseVIPsRejectsOutOfOrderIndexes(t *testing.T) {
|
||||
if _, err := ParseLeaseVIPs(`{"version":"v1","instance_name":"release_a","ifa_proto":248,"vips":[{"index":1,"value":"192.0.2.10"}]}`); err == nil {
|
||||
t.Fatal("ParseLeaseVIPs() accepted an out-of-order VIP index")
|
||||
}
|
||||
}
|
||||
395
pkg/lease/lease.go
Normal file
395
pkg/lease/lease.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package lease
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
log "log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/kube-vip/kube-vip/pkg/kubevip"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
// Manager is used to manage leases.
|
||||
type Manager struct {
|
||||
leases map[string]*Lease
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
// NewManager creates new lease manager.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
leases: make(map[string]*Lease),
|
||||
}
|
||||
}
|
||||
|
||||
// Add creates or retrieves the lease identified by id.
|
||||
func (m *Manager) Add(ctx context.Context, id ID) *Lease {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
return m.addLocked(ctx, id)
|
||||
}
|
||||
|
||||
// Acquire creates or retrieves a lease and atomically registers objectName as a
|
||||
// member. The returned bool reports whether this object was newly registered.
|
||||
func (m *Manager) Acquire(ctx context.Context, id ID, objectName string) (*Lease, bool) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
lease := m.addLocked(ctx, id)
|
||||
return lease, lease.Add(objectName)
|
||||
}
|
||||
|
||||
// Claim atomically registers objectName against an existing lease. It returns
|
||||
// nil when the lease was retired before the caller could join it.
|
||||
func (m *Manager) Claim(id ID, objectName string) (*Lease, bool) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
lease, exists := m.leases[id.NamespacedName()]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
return lease, lease.Add(objectName)
|
||||
}
|
||||
|
||||
func (m *Manager) addLocked(ctx context.Context, id ID) *Lease {
|
||||
|
||||
// A lease whose context is already cancelled cannot be handed out again:
|
||||
// anything derived from it would be cancelled straight away. Replace it.
|
||||
if l, exists := m.leases[id.NamespacedName()]; !exists || l.Ctx.Err() != nil {
|
||||
leaseCtx, leaseCancel := context.WithCancel(ctx)
|
||||
m.leases[id.NamespacedName()] = newLease(leaseCtx, leaseCancel)
|
||||
}
|
||||
|
||||
return m.leases[id.NamespacedName()]
|
||||
}
|
||||
|
||||
// Delete removes the object from the lease it was added to and cancels that lease
|
||||
// once its last object is gone. It reports whether the lease was retired. With a
|
||||
// common lease, the siblings that still use it keep it alive.
|
||||
//
|
||||
// The lease the caller was given has to be passed in, because cleanup is usually
|
||||
// deferred to a goroutine that runs long after the object went away. By then the
|
||||
// lease of that name may already have been replaced, for instance because the
|
||||
// service was torn down and rebuilt, and cancelling the replacement would leave
|
||||
// the service unhandled. A stale caller is therefore ignored.
|
||||
//
|
||||
// Teardown paths have to call this synchronously rather than leaving it to the
|
||||
// deferred cleanup: until the lease is out of the map, Add hands the same
|
||||
// instance back, so a service that is rebuilt straight away gets parented to a
|
||||
// lease that the pending cleanup is about to cancel.
|
||||
func (m *Manager) Delete(id ID, objectName string, l *Lease) bool {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
current := m.currentFor(id, l)
|
||||
if current == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
current.delete(objectName)
|
||||
if current.cnt.Load() < 1 {
|
||||
m.retire(id, current)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// currentFor returns the registered lease for id, or nil when the caller is
|
||||
// stale, meaning the lease it holds is no longer the registered one. Callers have
|
||||
// to hold m.lock.
|
||||
func (m *Manager) currentFor(id ID, l *Lease) *Lease {
|
||||
current, exist := m.leases[id.NamespacedName()]
|
||||
if !exist || (l != nil && current != l) {
|
||||
return nil
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// retire cancels the lease and drops it from the manager. Callers have to hold
|
||||
// m.lock.
|
||||
func (m *Manager) retire(id ID, l *Lease) {
|
||||
l.Cancel()
|
||||
delete(m.leases, id.NamespacedName())
|
||||
}
|
||||
|
||||
// Get returns lease for the service.
|
||||
func (m *Manager) Get(id ID) *Lease {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if lease, exist := m.leases[id.NamespacedName()]; exist {
|
||||
return lease
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lease holds lease data.
|
||||
type Lease struct {
|
||||
Ctx context.Context
|
||||
Cancel context.CancelFunc
|
||||
services sync.Map
|
||||
cnt atomic.Int64
|
||||
Elected atomic.Bool
|
||||
stateMu sync.Mutex
|
||||
running bool
|
||||
ended uint64
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
func newLease(ctx context.Context, cancel context.CancelFunc) *Lease {
|
||||
return &Lease{
|
||||
Ctx: ctx,
|
||||
Cancel: cancel,
|
||||
changed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// NewElectionContext returns a context for one election runner. Cancelling it
|
||||
// stops only that runner; the Lease context remains live until its final member
|
||||
// is deleted from the Manager.
|
||||
func (l *Lease) NewElectionContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(l.Ctx)
|
||||
stopParent := context.AfterFunc(parent, cancel)
|
||||
return ctx, func() {
|
||||
stopParent()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds the object to the lease and increments counter
|
||||
// it will return true if object was added
|
||||
func (l *Lease) Add(name string) bool {
|
||||
if _, exists := l.services.LoadOrStore(name, true); !exists {
|
||||
l.cnt.Add(1)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// delete removes the service from the lease and decrements the counter.
|
||||
func (l *Lease) delete(service string) {
|
||||
if _, exists := l.services.LoadAndDelete(service); exists {
|
||||
l.cnt.Add(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Lease) BeginElection() bool {
|
||||
l.stateMu.Lock()
|
||||
defer l.stateMu.Unlock()
|
||||
if l.Elected.Load() || l.running {
|
||||
return false
|
||||
}
|
||||
l.running = true
|
||||
l.signalStateLocked()
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *Lease) ElectionStarted() {
|
||||
l.stateMu.Lock()
|
||||
defer l.stateMu.Unlock()
|
||||
if l.Elected.Load() {
|
||||
return
|
||||
}
|
||||
l.Elected.Store(true)
|
||||
l.running = false
|
||||
l.signalStateLocked()
|
||||
}
|
||||
|
||||
func (l *Lease) ElectionStopped() {
|
||||
l.stateMu.Lock()
|
||||
defer l.stateMu.Unlock()
|
||||
if !l.Elected.Load() && !l.running {
|
||||
return
|
||||
}
|
||||
l.Elected.Store(false)
|
||||
l.running = false
|
||||
l.ended++
|
||||
l.signalStateLocked()
|
||||
}
|
||||
|
||||
// WaitForLeader waits for an in-flight lease election to either elect a leader
|
||||
// or finish without one. It never holds the lease state mutex while waiting.
|
||||
func (l *Lease) WaitForLeader(ctx context.Context) bool {
|
||||
_, elected := l.WaitForLeaderGeneration(ctx)
|
||||
return elected
|
||||
}
|
||||
|
||||
// WaitForLeaderGeneration waits for leadership and returns the election-end
|
||||
// generation observed atomically with the elected state.
|
||||
func (l *Lease) WaitForLeaderGeneration(ctx context.Context) (uint64, bool) {
|
||||
for {
|
||||
elected, running, changed, ended := l.state()
|
||||
if elected {
|
||||
return ended, true
|
||||
}
|
||||
if !running {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, false
|
||||
case <-l.Ctx.Done():
|
||||
return 0, false
|
||||
case <-changed:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForElectionEnd waits until an elected lease loses its leader. It never
|
||||
// holds the lease state mutex while waiting.
|
||||
func (l *Lease) WaitForElectionEnd(ctx context.Context) {
|
||||
_, _, _, initialEnded := l.state()
|
||||
l.WaitForElectionEndAfter(ctx, initialEnded)
|
||||
}
|
||||
|
||||
// WaitForElectionEndAfter waits until the leadership generation returned by
|
||||
// WaitForLeaderGeneration ends, even if a replacement election starts first.
|
||||
func (l *Lease) WaitForElectionEndAfter(ctx context.Context, initialEnded uint64) {
|
||||
for {
|
||||
elected, _, changed, ended := l.state()
|
||||
if !elected || ended != initialEnded {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-l.Ctx.Done():
|
||||
return
|
||||
case <-changed:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Lease) state() (bool, bool, <-chan struct{}, uint64) {
|
||||
l.stateMu.Lock()
|
||||
defer l.stateMu.Unlock()
|
||||
return l.Elected.Load(), l.running, l.changed, l.ended
|
||||
}
|
||||
|
||||
func (l *Lease) signalStateLocked() {
|
||||
close(l.changed)
|
||||
l.changed = make(chan struct{})
|
||||
}
|
||||
|
||||
// ServiceName gets lease name and id for the service.
|
||||
func ServiceName(service *v1.Service) (string, string) {
|
||||
return ServiceNameFor(service.Namespace, service.Name, service.Annotations[kubevip.ServiceLease])
|
||||
}
|
||||
|
||||
func ServiceNameFor(namespace, serviceName, leaseName string) (string, string) {
|
||||
name := leaseName
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("kubevip-%s", serviceName)
|
||||
}
|
||||
|
||||
serviceLeaseParts := strings.Split(name, "/")
|
||||
|
||||
if len(serviceLeaseParts) > 1 {
|
||||
namespace = serviceLeaseParts[0]
|
||||
name = serviceLeaseParts[1]
|
||||
}
|
||||
|
||||
return namespace, name
|
||||
}
|
||||
|
||||
func ServiceNamespacedName(service *v1.Service) string {
|
||||
return fmt.Sprintf("%s/%s", service.Namespace, service.Name)
|
||||
}
|
||||
|
||||
func ObjectName(id ID, suffix string) string {
|
||||
return fmt.Sprintf("%s-%s", id.NamespacedName(), suffix)
|
||||
}
|
||||
|
||||
func NamespaceName(lease string, c *kubevip.Config) (string, string) {
|
||||
leaseName := lease
|
||||
leasnameParts := strings.Split(lease, "/")
|
||||
var ns string
|
||||
var err error
|
||||
if len(leasnameParts) > 1 {
|
||||
ns = leasnameParts[0]
|
||||
leaseName = leasnameParts[1]
|
||||
} else {
|
||||
ns, err = returnNamespace()
|
||||
if err != nil {
|
||||
log.Warn("unable to auto-detect namespace, dropping to config", "namespace", c.Namespace)
|
||||
ns = c.Namespace
|
||||
}
|
||||
}
|
||||
return ns, leaseName
|
||||
}
|
||||
|
||||
func returnNamespace() (string, error) {
|
||||
if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
|
||||
if ns := strings.TrimSpace(string(data)); len(ns) > 0 {
|
||||
return ns, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return "", fmt.Errorf("unable to find Namespace")
|
||||
}
|
||||
|
||||
type ID interface {
|
||||
Name() string
|
||||
Namespace() string
|
||||
NamespacedName() string
|
||||
}
|
||||
|
||||
type CommonID struct {
|
||||
namespace string
|
||||
name string
|
||||
}
|
||||
|
||||
func NewID(leaseType, namespace, name string) ID {
|
||||
if leaseType == "etcd" {
|
||||
return newEtcdID(namespace, name)
|
||||
}
|
||||
return newKubernetesID(namespace, name)
|
||||
}
|
||||
|
||||
func newKubernetesID(namespace, name string) ID {
|
||||
return &KubernetesID{
|
||||
CommonID: CommonID{
|
||||
namespace: namespace,
|
||||
name: name,
|
||||
},
|
||||
}
|
||||
}
|
||||
func newEtcdID(namespace, name string) ID {
|
||||
return &EtcdID{
|
||||
CommonID: CommonID{
|
||||
namespace: namespace,
|
||||
name: name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommonID) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
func (c *CommonID) Namespace() string {
|
||||
return c.namespace
|
||||
}
|
||||
|
||||
type KubernetesID struct {
|
||||
CommonID
|
||||
}
|
||||
|
||||
func (k *KubernetesID) NamespacedName() string {
|
||||
return fmt.Sprintf("%s/%s", k.namespace, k.name)
|
||||
}
|
||||
|
||||
type EtcdID struct {
|
||||
CommonID
|
||||
}
|
||||
|
||||
func (e *EtcdID) NamespacedName() string {
|
||||
return fmt.Sprintf("%s-%s", e.namespace, e.name)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user