mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-27 11:33:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a1e61ccff | ||
|
|
db60a81b2d | ||
|
|
7c19323ea1 | ||
|
|
290e6db98e | ||
|
|
df29d9ba43 | ||
|
|
64769081d9 | ||
|
|
0107363d41 | ||
|
|
48dc1dd624 | ||
|
|
3a6eef410a | ||
|
|
9f5ca9a33d | ||
|
|
b046b78398 | ||
|
|
ff213e71d3 | ||
|
|
3cda5cc564 | ||
|
|
75fdbaf2f4 | ||
|
|
81f4e3a67a | ||
|
|
e99e769455 | ||
|
|
11949eeb3b | ||
|
|
b437659678 | ||
|
|
c01365b416 | ||
|
|
813c397644 | ||
|
|
5cc005a423 | ||
|
|
066d411367 | ||
|
|
03970862ab | ||
|
|
8bf9fc0c9c | ||
|
|
93fef88fac | ||
|
|
455174ccb0 | ||
|
|
1ce3e62dbb | ||
|
|
4be8339c51 | ||
|
|
8dd69b46da | ||
|
|
4cc1e556dd | ||
|
|
9186d31d12 | ||
|
|
dd7bc6c982 | ||
|
|
12c07812a2 | ||
|
|
ec73f9ecd8 | ||
|
|
879c7c1876 | ||
|
|
c67127f83f | ||
|
|
5d3f1fe225 | ||
|
|
2e585d0183 | ||
|
|
ae9f943404 | ||
|
|
8805178a58 | ||
|
|
ec3de3a099 | ||
|
|
2c02139369 | ||
|
|
6c244bb8f9 | ||
|
|
fc0bf4a91b | ||
|
|
bc577fe405 | ||
|
|
6cc0611d75 | ||
|
|
8beb9e2679 | ||
|
|
0c2ff499c6 | ||
|
|
b39585df4c | ||
|
|
35d0a90125 | ||
|
|
a7273c8c96 | ||
|
|
9b48003a3f | ||
|
|
1e750cc7e1 | ||
|
|
2d73b541c5 | ||
|
|
cd9c0a1d2c | ||
|
|
37a76809ec | ||
|
|
f6062cf343 | ||
|
|
4fe360c573 | ||
|
|
cac1550b7d | ||
|
|
0bd42b2fca | ||
|
|
ad83d942e1 | ||
|
|
65f5a714dd | ||
|
|
2714587ec5 | ||
|
|
4166474ee8 | ||
|
|
3faaac4da7 | ||
|
|
1b6130447f | ||
|
|
614212a24c | ||
|
|
3ab708a437 | ||
|
|
7a6c5bf6d7 | ||
|
|
2acfafe218 | ||
|
|
b24b55c4c7 | ||
|
|
9a88e914f4 | ||
|
|
f613d3ce6d | ||
|
|
2c3bea64e5 | ||
|
|
da3634b690 | ||
|
|
6fb07db4b2 | ||
|
|
fea7edcbc5 | ||
|
|
a54555cd13 | ||
|
|
87c7889c2e | ||
|
|
05d0078451 | ||
|
|
053d73048c | ||
|
|
4ec3e97a54 | ||
|
|
10bbe9fbc5 | ||
|
|
31cd4c77e9 | ||
|
|
492a0af2b2 | ||
|
|
ff7bb25f2c | ||
|
|
a73cbfd691 | ||
|
|
55773e92ce | ||
|
|
37f543b905 | ||
|
|
e5f96509bf | ||
|
|
2f35ac6498 | ||
|
|
f75afa0cec | ||
|
|
0f38b128fa | ||
|
|
535b91fe52 | ||
|
|
5c8beb8bd3 | ||
|
|
2b4da1e6fe | ||
|
|
be8b4f079f | ||
|
|
59b96cc01f | ||
|
|
4c77fe2cfd | ||
|
|
3b89af4a11 | ||
|
|
1abadaef50 | ||
|
|
3b1ce42dd7 | ||
|
|
1640b4c433 | ||
|
|
44549c00fd | ||
|
|
26f34df3e6 | ||
|
|
89fb9efcfa | ||
|
|
638f320959 | ||
|
|
3f2e60ea74 | ||
|
|
8e0a24e781 | ||
|
|
e3e39dde32 | ||
|
|
fc61e2fc07 | ||
|
|
2a0d31cec0 | ||
|
|
2f55af0cab | ||
|
|
971b21d6c1 | ||
|
|
b651d95183 | ||
|
|
d45c1b6869 | ||
|
|
a5fe9fbfb2 | ||
|
|
4590a516ad | ||
|
|
5f9ed6dacf | ||
|
|
e97497d737 | ||
|
|
b4bf1d17d0 | ||
|
|
cfb4693286 | ||
|
|
fd3facb6b0 | ||
|
|
bd03bb9bfa | ||
|
|
eec728620b | ||
|
|
d2dd5e3c89 | ||
|
|
2bfcce7803 | ||
|
|
1ec5184f21 | ||
|
|
23dfe45b2f | ||
|
|
d9e906a77e | ||
|
|
51f7ebf3bf | ||
|
|
ae04dea808 | ||
|
|
009c0e1b4c | ||
|
|
18d38e96b4 | ||
|
|
12c86f1dd4 | ||
|
|
4df1820f96 | ||
|
|
6f9ac1fcc7 | ||
|
|
6a98acbe79 | ||
|
|
b0059af9b1 | ||
|
|
7edd8f2976 | ||
|
|
735e36012a | ||
|
|
69ebce57e9 | ||
|
|
365a5fc0b5 | ||
|
|
624caf037b | ||
|
|
ff1a8fbcf9 | ||
|
|
3aa8fead76 | ||
|
|
4075f85b72 | ||
|
|
59f6fa75ca | ||
|
|
8b66a11394 | ||
|
|
fa3d992dbc | ||
|
|
a7d8afb052 | ||
|
|
0d820bc75a | ||
|
|
6a4e63dbd4 | ||
|
|
7699b530c2 | ||
|
|
f1abbbea6d | ||
|
|
683ad22358 | ||
|
|
8f00e0082e | ||
|
|
c34542a9d8 | ||
|
|
88db1cb6a9 | ||
|
|
dfa6e001c5 | ||
|
|
f49ec465d0 | ||
|
|
1ffe9acdd7 | ||
|
|
e35e69b4be | ||
|
|
11c2e641f1 | ||
|
|
bb97537df7 | ||
|
|
9f4b8179b5 | ||
|
|
f7b079c4f2 | ||
|
|
3648187219 | ||
|
|
93346301fb | ||
|
|
cc86c8660c | ||
|
|
2963436538 | ||
|
|
6e326277bb | ||
|
|
469d6fadcd | ||
|
|
5f214247ca | ||
|
|
0b8495e964 | ||
|
|
6068e24f4f | ||
|
|
7861d56dff | ||
|
|
7bc69b9a9e | ||
|
|
38800924ed | ||
|
|
cf7e333579 | ||
|
|
769f5e47c3 | ||
|
|
6e1165ec57 | ||
|
|
8d16da596c | ||
|
|
d6c06828b3 | ||
|
|
fe93d91894 | ||
|
|
3f70018e7c | ||
|
|
70e9385032 | ||
|
|
db3016fe89 | ||
|
|
c56385602e | ||
|
|
fbbf839258 | ||
|
|
eee28e2ead | ||
|
|
ef57b62625 | ||
|
|
e854bbb3e1 | ||
|
|
938e7d05c0 | ||
|
|
6c2759f315 | ||
|
|
72bf34632f | ||
|
|
7db8bba6ea | ||
|
|
badb8ecd3c | ||
|
|
9413f1c0b1 | ||
|
|
d8818ba770 | ||
|
|
440215b811 | ||
|
|
02a418aafb | ||
|
|
33550be65e | ||
|
|
edad2429c5 | ||
|
|
b605a64fbe | ||
|
|
16c876d83f | ||
|
|
d6ff62159d | ||
|
|
fcf164dda1 | ||
|
|
e5957d9d6b | ||
|
|
143e68cc95 | ||
|
|
835c310987 | ||
|
|
5fce84d33c | ||
|
|
45497fd20c | ||
|
|
f08d9fe405 | ||
|
|
565a8d8366 | ||
|
|
343357adf9 | ||
|
|
8ee551ca8f | ||
|
|
948201cb6f | ||
|
|
f71fc57f34 | ||
|
|
1acb0ff110 | ||
|
|
03c2681792 | ||
|
|
f3cb6657ae | ||
|
|
4532b985d4 | ||
|
|
000a8a1d3e | ||
|
|
7705db2c4f | ||
|
|
590a1b231f | ||
|
|
8709ac0ed5 | ||
|
|
f8ebd94a79 | ||
|
|
87331a9265 | ||
|
|
2f5a2c6344 | ||
|
|
97e032cb7b | ||
|
|
410a95ec88 | ||
|
|
df4ab40e12 | ||
|
|
c6205d4f57 | ||
|
|
13dea92311 | ||
|
|
9100c22e7f | ||
|
|
6125553a97 | ||
|
|
c9452f1509 | ||
|
|
25b173128a | ||
|
|
4e40a93991 | ||
|
|
9fc54c9d64 | ||
|
|
cacfca7a88 | ||
|
|
b3885b0888 | ||
|
|
3ada89c4f8 | ||
|
|
20b2ca2ea5 | ||
|
|
1bbefdd0d2 | ||
|
|
f467fad84c | ||
|
|
0e80f2e5b2 | ||
|
|
64f6a4f3d3 | ||
|
|
0975cbab66 | ||
|
|
615022c9ac | ||
|
|
ecbd4378e4 | ||
|
|
1becd033e6 | ||
|
|
1cba1eef0a | ||
|
|
3893005866 | ||
|
|
1911e03c82 | ||
|
|
641b11a92d | ||
|
|
7c7530f977 | ||
|
|
6e7fe405ef | ||
|
|
03c5c054e1 | ||
|
|
c42968c812 | ||
|
|
ffef41081b | ||
|
|
58b51b974c | ||
|
|
2d923380a3 | ||
|
|
3910101a88 | ||
|
|
7a9d0cf9db | ||
|
|
042dd594e0 | ||
|
|
1471a94162 | ||
|
|
b1abd07dde | ||
|
|
3757813b20 | ||
|
|
f1a150821c | ||
|
|
874af1ba10 | ||
|
|
2ad098c612 | ||
|
|
4172cb826f | ||
|
|
041d51b74f | ||
|
|
fb01604d21 | ||
|
|
bd8bcbd2f9 | ||
|
|
07501813c8 | ||
|
|
1bbcfa8519 | ||
|
|
e8715fce38 | ||
|
|
a0a66f34c4 | ||
|
|
b799b7518e | ||
|
|
2571498ed2 | ||
|
|
5bcbfc332d | ||
|
|
34574e41fc | ||
|
|
c7355afa3d | ||
|
|
a907fdb1af | ||
|
|
c9ba4adccb | ||
|
|
a0551d2d64 | ||
|
|
76129ea056 | ||
|
|
6ffd35f596 | ||
|
|
0ef776e7f1 | ||
|
|
0b22b2136a | ||
|
|
e1530bb6f3 | ||
|
|
7fc07ce4bd | ||
|
|
eb42082955 | ||
|
|
8719e37856 | ||
|
|
e1cc39b0d5 | ||
|
|
efe646f54c | ||
|
|
e130d803e4 | ||
|
|
56e7e8baa2 | ||
|
|
b963ef39d1 | ||
|
|
1df0fa290f | ||
|
|
c3bb0c9f49 | ||
|
|
cf65cecd9e | ||
|
|
fff17dbb60 | ||
|
|
586a4fa129 | ||
|
|
2a82f75cad | ||
|
|
805afd0d28 | ||
|
|
11098ea7d1 | ||
|
|
90e357a1dc | ||
|
|
91d58bc701 | ||
|
|
69d3a511d6 | ||
|
|
c3123820b2 | ||
|
|
2e49d94fad | ||
|
|
bb51e01c5c | ||
|
|
cdb2884e4e | ||
|
|
137abde808 | ||
|
|
342969b7f4 | ||
|
|
d9e350c4dd | ||
|
|
3f4f263c05 | ||
|
|
0760ba3b18 | ||
|
|
0896bd27fb | ||
|
|
a16b2d1c74 | ||
|
|
52117954a8 | ||
|
|
435a794fa5 | ||
|
|
9642863d7b | ||
|
|
6bb9bdeb5a | ||
|
|
30f1e135f1 | ||
|
|
58ecc17ac1 | ||
|
|
4c239ed79a | ||
|
|
968dd50705 | ||
|
|
d7f437107d | ||
|
|
72548fbd1b | ||
|
|
a5f6ec4a68 | ||
|
|
4cc68e3133 | ||
|
|
2bacebe900 | ||
|
|
09bea1d12d | ||
|
|
5e9b883ad5 | ||
|
|
1046396edc | ||
|
|
35804458b0 | ||
|
|
6bb64ac5dc | ||
|
|
e5f68947f9 | ||
|
|
289d964b2a | ||
|
|
d0fd9db258 | ||
|
|
0ea997e826 | ||
|
|
2d84059d7f | ||
|
|
ecec42ea60 | ||
|
|
af4629b679 | ||
|
|
f6cf5cc250 | ||
|
|
f37bfd01fe | ||
|
|
d9897e5d1e | ||
|
|
bcb67a4073 | ||
|
|
bcb5a2a173 | ||
|
|
c3b88077ac | ||
|
|
98e1bdc9b5 | ||
|
|
e13408d79f | ||
|
|
8610322682 | ||
|
|
0be42f1f59 | ||
|
|
dcbfcbd0fb | ||
|
|
d0e46186ed | ||
|
|
625a8baed3 | ||
|
|
98db5ed440 | ||
|
|
c4398f9f06 | ||
|
|
a53fa2c1d8 | ||
|
|
5e2b2ac836 | ||
|
|
43f28284f1 | ||
|
|
b17fd7531f | ||
|
|
9859be50a6 | ||
|
|
f209851986 | ||
|
|
4da51636fd | ||
|
|
76a9f8e8b6 | ||
|
|
a477db6b16 | ||
|
|
04fd1b1425 | ||
|
|
c0bf1f2930 | ||
|
|
0bf759333a | ||
|
|
b482d836aa | ||
|
|
3d2d9b78e8 | ||
|
|
99219dc376 | ||
|
|
408703360c | ||
|
|
ac48b8bee6 | ||
|
|
e3a310397b | ||
|
|
b2f7c6fb33 | ||
|
|
653f47f507 | ||
|
|
5b66cec627 | ||
|
|
0d9a2b6e07 | ||
|
|
fb68303825 | ||
|
|
cfe5b018f3 | ||
|
|
b451f2cf7c | ||
|
|
7d25bbafdf | ||
|
|
58e1c2eea3 | ||
|
|
6c791840bd | ||
|
|
3af97306e1 | ||
|
|
d645958631 | ||
|
|
48bc94828e | ||
|
|
a43f4bf5df | ||
|
|
22f705d6c9 | ||
|
|
efcb9ee947 | ||
|
|
a347b32b65 | ||
|
|
7e865b61c2 | ||
|
|
b959f5d9f3 | ||
|
|
df64a00813 | ||
|
|
07dc5c373d | ||
|
|
a6e8616b36 | ||
|
|
7041244728 | ||
|
|
bee101aa3e | ||
|
|
21e70dd846 | ||
|
|
91f870989a | ||
|
|
bda1123ae6 | ||
|
|
190d0de61f | ||
|
|
f2e730796f | ||
|
|
bb77ff9246 | ||
|
|
5061097dff | ||
|
|
911522c388 | ||
|
|
3ee7cb6e8e | ||
|
|
2e00632959 | ||
|
|
8e53fd46fa | ||
|
|
2835edadeb | ||
|
|
f7c4490c10 | ||
|
|
b3102480c0 | ||
|
|
cbe7c563cd | ||
|
|
b016252821 | ||
|
|
6c197085d0 | ||
|
|
c29fa571e1 | ||
|
|
092fe254aa | ||
|
|
6951221b88 | ||
|
|
fa54584128 | ||
|
|
d922f7bfed | ||
|
|
18ec186e81 | ||
|
|
2a898b3eee | ||
|
|
042b44cbb6 | ||
|
|
3f7726e5fb | ||
|
|
2b3f325075 | ||
|
|
be2a26e626 | ||
|
|
4479490db2 | ||
|
|
7556f8cb12 | ||
|
|
bfbcec34e8 | ||
|
|
8eea34d8dc | ||
|
|
6b80ef9db8 | ||
|
|
2134f3c1d9 | ||
|
|
0652123bc7 | ||
|
|
4de7ecd747 | ||
|
|
e8908ced9e | ||
|
|
deedbcb8a9 | ||
|
|
8bfb3b80ed | ||
|
|
848abbd2f8 | ||
|
|
9cb4b35546 | ||
|
|
26df6740e3 | ||
|
|
537789902b | ||
|
|
4088fdfd84 | ||
|
|
01a71b4b38 | ||
|
|
73d41c75b3 | ||
|
|
f85b5dd01d | ||
|
|
1fb8a3d7a0 | ||
|
|
3ce9c8be04 | ||
|
|
3ac166e6cb | ||
|
|
88ce2d5181 | ||
|
|
8bddbec7bd | ||
|
|
a40fd613e8 | ||
|
|
99c9aabdfd | ||
|
|
5504951a8a | ||
|
|
2727c3c97e | ||
|
|
7d47d274a0 | ||
|
|
a4247b0097 | ||
|
|
11612a802c | ||
|
|
d7bad5ed46 | ||
|
|
c0dff56378 | ||
|
|
00dbbec62b | ||
|
|
9755a6973d | ||
|
|
16120b80ba | ||
|
|
386e4e25f3 | ||
|
|
43ddb5c363 | ||
|
|
417427b4d5 | ||
|
|
5da1fc5387 | ||
|
|
5621a84e99 | ||
|
|
8db7ac84a0 | ||
|
|
77890b8a8a | ||
|
|
daada3bbe9 | ||
|
|
21d55dc69a | ||
|
|
6eb373e4de | ||
|
|
6a4a7f2e6a | ||
|
|
349dd0d2ef | ||
|
|
cf5818d963 | ||
|
|
faf3378659 | ||
|
|
bb967461d0 | ||
|
|
0b5dd2f7ba | ||
|
|
d5f0bca4a0 | ||
|
|
bdd034d169 | ||
|
|
48767125f4 | ||
|
|
035f44896d | ||
|
|
3d5fe0a824 | ||
|
|
6d1c3aa5b2 | ||
|
|
cd60d5178f | ||
|
|
7aa7a9fea1 | ||
|
|
73785007cb | ||
|
|
24a824b890 | ||
|
|
085ee285e8 | ||
|
|
6daa1291b5 | ||
|
|
c3d3a4242e | ||
|
|
871fd8c035 | ||
|
|
a6ccd6055a | ||
|
|
ca607ee82d | ||
|
|
1c8e60aac0 | ||
|
|
d02fc4d981 | ||
|
|
410f74425c | ||
|
|
6e0df53f13 | ||
|
|
e9d07687f2 | ||
|
|
95175d10c5 | ||
|
|
df25b0d098 | ||
|
|
5c1a3dd694 | ||
|
|
6d39174b51 | ||
|
|
e851f25b12 | ||
|
|
3c1d2201ce | ||
|
|
b4c2ec3a2a | ||
|
|
bf10b21da5 | ||
|
|
2819ba63aa | ||
|
|
54a8b09c9f | ||
|
|
24371eb401 | ||
|
|
9c9e143dc8 | ||
|
|
19d53c4f67 | ||
|
|
40f929847c | ||
|
|
e73e38957a | ||
|
|
e82b78df49 | ||
|
|
741378d0b7 | ||
|
|
d31595820d | ||
|
|
035601d366 | ||
|
|
5c96f75958 | ||
|
|
2c62e1f6df | ||
|
|
22bca16226 | ||
|
|
a4be45186d | ||
|
|
5e79697505 | ||
|
|
6736839ccb | ||
|
|
913fb2da0c | ||
|
|
bd0e7f44b2 | ||
|
|
81cf5f5605 | ||
|
|
cedafd069d | ||
|
|
93dfa5ca9d | ||
|
|
c70c8a6ec1 | ||
|
|
5c74b09f45 | ||
|
|
1316dd3ffc | ||
|
|
4493bb3c27 | ||
|
|
b23699470b | ||
|
|
34316c77cf | ||
|
|
c2b466b046 | ||
|
|
d36429e6e5 | ||
|
|
17b96a8744 | ||
|
|
a54666a7ac | ||
|
|
5cb765d286 | ||
|
|
5bf3cba2d6 | ||
|
|
8a3f0032ae | ||
|
|
5fe77c688b | ||
|
|
61dc97228d | ||
|
|
e6df331fb8 | ||
|
|
9dbcf1e902 | ||
|
|
2f6c988125 | ||
|
|
411c3398ce | ||
|
|
3a644771ca | ||
|
|
2eff7d1e22 | ||
|
|
f06dd865fa | ||
|
|
ee580df6af | ||
|
|
2c0cf28509 | ||
|
|
7b98ed34df | ||
|
|
5094149406 | ||
|
|
2fd1ee203a | ||
|
|
336bdf9f4b | ||
|
|
c80c75e4e1 | ||
|
|
e5bda92ec6 | ||
|
|
fd3ad91136 | ||
|
|
94186b2ae7 | ||
|
|
1bcb8836ef | ||
|
|
840943b3b0 | ||
|
|
518eae9bd2 | ||
|
|
06a4001080 | ||
|
|
b73577e1a0 | ||
|
|
c3ba43a8fa | ||
|
|
c275273617 | ||
|
|
b688742e5a | ||
|
|
4ec87db8e8 | ||
|
|
c6358474ac | ||
|
|
8fbf406007 | ||
|
|
e123f7214e | ||
|
|
3287691a31 | ||
|
|
6f9fc69c2e | ||
|
|
0effba0adb | ||
|
|
667ecadd30 | ||
|
|
68845e7607 | ||
|
|
6d4698fc1d | ||
|
|
708e14a4e0 | ||
|
|
c80de16ebe | ||
|
|
3e9718f08c | ||
|
|
69c9167355 | ||
|
|
b25938acc1 | ||
|
|
1ec3376be5 | ||
|
|
aa236aac10 | ||
|
|
9fcc319c3a | ||
|
|
2f7463ebe3 | ||
|
|
498d35e866 | ||
|
|
ba67b96734 |
@@ -0,0 +1,12 @@
|
|||||||
|
# Ignore everything and use an allowlist of what is allowed to not package any secrets by accident.
|
||||||
|
*
|
||||||
|
|
||||||
|
# Allow files and directories.
|
||||||
|
!cmd/
|
||||||
|
!internal/
|
||||||
|
!pkg/
|
||||||
|
!scripts/docker/
|
||||||
|
!go.*
|
||||||
|
|
||||||
|
# Ignore unnecessary files inside allowed directories.
|
||||||
|
**/.DS_Store
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# EditorConfig is awesome: https://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Default settings for all files
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# Settings for Go files
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
# Settings for Bash scripts
|
||||||
|
[*.sh]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [psviderski]
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../AI.md
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 525 KiB |
@@ -0,0 +1,69 @@
|
|||||||
|
name: Go Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
- "test/**"
|
||||||
|
- "release/**"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- ".github/**"
|
||||||
|
- "**.go"
|
||||||
|
- "**.proto"
|
||||||
|
- "go.*"
|
||||||
|
- "Makefile"
|
||||||
|
- "scripts/**"
|
||||||
|
- "test/**"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
|
with:
|
||||||
|
go-version: "1.23.2"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code ||
|
||||||
|
(echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1)
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
make ucind-image
|
||||||
|
make test
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
check-protobuf:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# Run on more platforms so that mise lockfile checksums are checked/generated for all platforms.
|
||||||
|
# See https://github.com/jdx/mise/issues/4276 for details.
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Install Mise
|
||||||
|
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
|
||||||
|
with:
|
||||||
|
version: "2025.6.5"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Generate Protobuf files
|
||||||
|
run: |
|
||||||
|
make proto
|
||||||
|
# check if the generated files are up to date
|
||||||
|
git diff --exit-code ||
|
||||||
|
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: Lint
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
- "test/**"
|
||||||
|
- "release/**"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "**.go"
|
||||||
|
- "go.*"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||||
|
with:
|
||||||
|
go-version: "1.23.2"
|
||||||
|
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
|
with:
|
||||||
|
version: v2.2.2
|
||||||
|
|
||||||
|
- name: Format code
|
||||||
|
run: |
|
||||||
|
make format
|
||||||
|
git diff --exit-code ||
|
||||||
|
(echo "Code is not formatted. Please run 'make format' and commit the changes." && exit 1)
|
||||||
|
|
||||||
|
timeout-minutes: 10
|
||||||
+13
-2
@@ -8,6 +8,9 @@
|
|||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
|
# OS X
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
@@ -24,5 +27,13 @@ go.work.sum
|
|||||||
.env
|
.env
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
data
|
/data/
|
||||||
dist/
|
/dist/
|
||||||
|
/uc
|
||||||
|
/tmp
|
||||||
|
|
||||||
|
# Web
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
concurrency: 4
|
||||||
|
tests: true
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- bodyclose
|
||||||
|
# - dogsled
|
||||||
|
- dupl
|
||||||
|
# - errcheck
|
||||||
|
- gochecknoinits
|
||||||
|
- goconst
|
||||||
|
# - gocritic
|
||||||
|
# - gocyclo
|
||||||
|
# - godot
|
||||||
|
# - gosec
|
||||||
|
# - govet
|
||||||
|
- ineffassign
|
||||||
|
- misspell
|
||||||
|
- nakedret
|
||||||
|
# - prealloc
|
||||||
|
# - revive
|
||||||
|
# - staticcheck
|
||||||
|
- unconvert
|
||||||
|
# - unparam
|
||||||
|
# - unused
|
||||||
|
- whitespace
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
- path: ^test/e2e
|
||||||
|
linters:
|
||||||
|
- goconst # constants here add no value, so we skip goconst only for test/e2e.
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofumpt
|
||||||
|
- goimports
|
||||||
|
|
||||||
+27
-4
@@ -24,6 +24,8 @@ builds:
|
|||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
|
||||||
|
|
||||||
- id: uncloudd
|
- id: uncloudd
|
||||||
main: ./cmd/uncloudd
|
main: ./cmd/uncloudd
|
||||||
@@ -35,28 +37,34 @@ builds:
|
|||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/psviderski/uncloud/internal/version.version={{ .Version }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: uncloud
|
- id: uncloud
|
||||||
builds:
|
builds:
|
||||||
- uncloud
|
- uncloud
|
||||||
format: gz
|
formats:
|
||||||
|
# gz is not compatible with homebrew releases, so we use tar.gz
|
||||||
|
- tar.gz
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
{{- if eq .Os "darwin"}}macos
|
{{- if eq .Os "darwin"}}macos
|
||||||
{{- else}}{{.Os}}{{end}}_
|
{{- else}}{{.Os}}{{end}}_
|
||||||
{{- .Arch }}"
|
{{- .Arch }}
|
||||||
# use zip for windows archives
|
# use zip for windows archives
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats:
|
||||||
|
- zip
|
||||||
files:
|
files:
|
||||||
- none*
|
- none*
|
||||||
|
|
||||||
- id: uncloudd
|
- id: uncloudd
|
||||||
builds:
|
builds:
|
||||||
- uncloudd
|
- uncloudd
|
||||||
format: gz
|
formats:
|
||||||
|
- tar.gz
|
||||||
name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}"
|
name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}"
|
||||||
files:
|
files:
|
||||||
- none*
|
- none*
|
||||||
@@ -70,3 +78,18 @@ changelog:
|
|||||||
exclude:
|
exclude:
|
||||||
- "^docs:"
|
- "^docs:"
|
||||||
- "^test:"
|
- "^test:"
|
||||||
|
|
||||||
|
brews:
|
||||||
|
- name: uncloud
|
||||||
|
description: "Uncloud CLI"
|
||||||
|
homepage: "https://uncloud.run"
|
||||||
|
ids:
|
||||||
|
- uncloud
|
||||||
|
install: |
|
||||||
|
bin.install "uncloud"
|
||||||
|
bin.install_symlink "uncloud" => "uc"
|
||||||
|
skip_upload: false
|
||||||
|
repository:
|
||||||
|
owner: psviderski
|
||||||
|
name: homebrew-tap
|
||||||
|
token: "{{ .Env.GITHUB_TOKEN_HOMEBREW_TAP }}"
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
[tools."aqua:vektra/mockery"]
|
||||||
|
version = "3.5.3"
|
||||||
|
backend = "aqua:vektra/mockery"
|
||||||
|
|
||||||
|
[tools."aqua:vektra/mockery".checksums]
|
||||||
|
"mockery_3.5.3_Darwin_arm64.tar.gz" = "sha256:a3a94b14c7414e148f2252199ffc4a0108d311358f3d336cbe05bb73cb203704"
|
||||||
|
"mockery_3.5.3_Linux_x86_64.tar.gz" = "sha256:ebce416b0175338525246c376885a1579ca6cd4d4015140ba0c70e6b5339a39c"
|
||||||
|
|
||||||
|
[tools.go]
|
||||||
|
version = "1.23.10"
|
||||||
|
backend = "core:go"
|
||||||
|
|
||||||
|
[tools.go.checksums]
|
||||||
|
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
|
||||||
|
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
|
||||||
|
|
||||||
|
[tools.golangci-lint]
|
||||||
|
version = "2.2.2"
|
||||||
|
backend = "aqua:golangci/golangci-lint"
|
||||||
|
|
||||||
|
[tools.golangci-lint.checksums]
|
||||||
|
"golangci-lint-2.2.2-darwin-arm64.tar.gz" = "sha256:d84d94d042c0d495fd1746f3d18948a75de163b17a14e8de3ef840928dd2df74"
|
||||||
|
"golangci-lint-2.2.2-linux-amd64.tar.gz" = "sha256:c27fbde948a87d326feacd21df2f61a9c54dbd2e3bfa185c0a1cd6917a6f964f"
|
||||||
|
|
||||||
|
[tools.protoc]
|
||||||
|
version = "27.3"
|
||||||
|
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||||
|
|
||||||
|
[tools.protoc.checksums]
|
||||||
|
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
|
||||||
|
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go]
|
||||||
|
version = "1.34.2"
|
||||||
|
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go.checksums]
|
||||||
|
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
|
||||||
|
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc]
|
||||||
|
version = "1.5.1"
|
||||||
|
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
|
||||||
|
|
||||||
|
[tools.protoc-gen-go-grpc.checksums]
|
||||||
|
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
|
||||||
|
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
|
||||||
+9
-1
@@ -1,5 +1,13 @@
|
|||||||
|
[settings]
|
||||||
|
experimental = true
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
go = "1.22"
|
"aqua:vektra/mockery" = "3.5.3"
|
||||||
|
go = "1.23"
|
||||||
|
golangci-lint = "2.2.2"
|
||||||
|
protoc = "27.3"
|
||||||
|
protoc-gen-go = "1.34.2"
|
||||||
|
protoc-gen-go-grpc = "1.5.1"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
_.file = ".env"
|
_.file = ".env"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
github.com/psviderski/uncloud/internal/machine/caddyconfig:
|
||||||
|
interfaces:
|
||||||
|
CaddyfileValidator:
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# AI.md - Uncloud Project Guide
|
||||||
|
|
||||||
|
This document provides comprehensive information about the Uncloud project for AI assistants to understand the codebase, architecture, and development practices.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Uncloud** is a lightweight clustering and container orchestration tool that enables deployment and management of web applications across cloud VMs and bare metal servers. It creates a secure WireGuard mesh network between Docker hosts and provides automatic service discovery, load balancing, HTTPS ingress, and simple CLI commands for application management.
|
||||||
|
|
||||||
|
### Key Characteristics
|
||||||
|
|
||||||
|
- **Language**: Go
|
||||||
|
- **Architecture**: Decentralized, no control plane
|
||||||
|
- **Target**: Self-hosted infrastructure without Kubernetes complexity
|
||||||
|
- **License**: View LICENSE file for details
|
||||||
|
- **Status**: Active development, not yet ready for production
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### 🏗️ Infrastructure
|
||||||
|
|
||||||
|
- **Multi-machine deployment**: Combine cloud VMs, dedicated servers, and bare metal
|
||||||
|
- **Zero-config networking**: Automatic WireGuard mesh with NAT traversal
|
||||||
|
- **Decentralized design**: No central control plane, all machines are equal
|
||||||
|
- **Service discovery**: Built-in DNS server resolves service names to container IPs
|
||||||
|
|
||||||
|
### 🚀 Application Management
|
||||||
|
|
||||||
|
- **Docker Compose compatibility**: Uses familiar Docker Compose format
|
||||||
|
- **Zero-downtime deployments**: Rolling updates without service interruption
|
||||||
|
- **Automatic HTTPS**: Caddy reverse proxy with Let's Encrypt integration
|
||||||
|
- **Managed DNS**: Free `*.cluster.uncloud.run` subdomains via Uncloud DNS service
|
||||||
|
- **Cross-machine scaling**: Run containers across multiple machines
|
||||||
|
|
||||||
|
### 🔧 Developer Experience
|
||||||
|
|
||||||
|
- **Docker-like CLI**: Familiar commands (`uc` binary)
|
||||||
|
- **Imperative operations**: Direct commands vs. declarative state reconciliation
|
||||||
|
- **Remote management**: Control entire infrastructure via SSH to any machine
|
||||||
|
- **Minimal overhead**: ~150MB RAM footprint per machine
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **CLI (`uc`)** - Main user interface for cluster management
|
||||||
|
2. **Daemon (`uncloudd`)** - Machine daemon running on each node
|
||||||
|
3. **Corrosion** - Distributed SQLite database for cluster state (Fly.io project)
|
||||||
|
4. **Caddy** - Reverse proxy for HTTPS termination and routing
|
||||||
|
5. **WireGuard** - Secure mesh networking between machines
|
||||||
|
|
||||||
|
### Network Architecture
|
||||||
|
|
||||||
|
- Each machine gets unique subnet (e.g., `10.210.0.0/24`, `10.210.1.0/24`)
|
||||||
|
- Containers get cluster-unique IPs for direct communication
|
||||||
|
- Automatic peer discovery and key management
|
||||||
|
- NAT traversal for machines behind firewalls
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- **CRDT-based distributed storage** using Corrosion
|
||||||
|
- **Eventually consistent** state across all machines
|
||||||
|
- **Gossip protocol** (Serf) for state propagation
|
||||||
|
- **No quorum requirements** - partial network splits remain functional
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Key Directories
|
||||||
|
|
||||||
|
- **`cmd/`**: Contains main applications
|
||||||
|
|
||||||
|
- `uncloud/`: CLI tool with subcommands for machine, service, volume management
|
||||||
|
- `uncloudd/`: Daemon that runs on each machine
|
||||||
|
- `ucind/`: Development cluster management for testing
|
||||||
|
|
||||||
|
- **`internal/`**: Internal implementation packages
|
||||||
|
|
||||||
|
- `cli/`: Command-line interface logic
|
||||||
|
- `machine/`: Machine lifecycle and state management
|
||||||
|
- `daemon/`: Daemon implementation and gRPC services
|
||||||
|
- `dns/`: Internal DNS server for service discovery
|
||||||
|
|
||||||
|
- **`pkg/`**: Public API packages for external use
|
||||||
|
|
||||||
|
- `api/`: Core API types and definitions
|
||||||
|
- `client/`: Client libraries for interacting with Uncloud
|
||||||
|
|
||||||
|
- **`experiment/`**: Experimental features and prototypes
|
||||||
|
- **`scripts/`**: Installation and utility scripts
|
||||||
|
- **`test/`**: Test suites and test infrastructure
|
||||||
|
- **`website/`**: Documentation website (Docusaurus)
|
||||||
|
- **`misc/`**: Design documents and guides
|
||||||
|
|
||||||
|
## Key Technologies
|
||||||
|
|
||||||
|
### Core Dependencies
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Networking and orchestration
|
||||||
|
github.com/docker/docker // Docker API client
|
||||||
|
github.com/docker/compose/v2 // Docker Compose integration
|
||||||
|
golang.zx2c4.com/wireguard // WireGuard implementation
|
||||||
|
github.com/hashicorp/serf // Gossip protocol
|
||||||
|
|
||||||
|
// State management
|
||||||
|
github.com/ipfs/go-ds-crdt // CRDT distributed storage
|
||||||
|
github.com/dgraph-io/badger/v3 // Embedded database
|
||||||
|
|
||||||
|
// Web proxy
|
||||||
|
github.com/caddyserver/caddy/v2 // HTTP server and reverse proxy
|
||||||
|
|
||||||
|
// CLI and UX
|
||||||
|
github.com/spf13/cobra // CLI framework
|
||||||
|
github.com/charmbracelet/huh // Interactive forms
|
||||||
|
|
||||||
|
// gRPC and networking
|
||||||
|
google.golang.org/grpc // gRPC framework
|
||||||
|
github.com/siderolabs/grpc-proxy // gRPC proxy for forwarding
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Build and Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build binaries
|
||||||
|
go build -o uncloud ./cmd/uncloud
|
||||||
|
go build -o uncloudd ./cmd/uncloudd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Make Targets
|
||||||
|
|
||||||
|
- `proto`: Generate protobuf code
|
||||||
|
- `ucind-cluster`: Create development cluster
|
||||||
|
- `update-dev`: Deploy to development machines
|
||||||
|
- `demo-reset`: Reset demo environment
|
||||||
|
- `fmt`: Format code
|
||||||
|
- `test`: Run all tests
|
||||||
|
- `lint`: Lint the code using golangci-lint
|
||||||
|
- `lint-and-fix`: Lint the code and fix issues whenever possible
|
||||||
|
|
||||||
|
## CLI Commands Structure
|
||||||
|
|
||||||
|
The `uc` CLI provides these main command groups:
|
||||||
|
|
||||||
|
### Machine Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc machine init <user@host> # Initialize new cluster
|
||||||
|
uc machine add <user@host> # Add machine to cluster
|
||||||
|
uc machine ls # List machines
|
||||||
|
uc machine rm <name> # Remove machine
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc run <image> # Run container from image
|
||||||
|
uc deploy # Deploy from compose.yaml
|
||||||
|
uc scale <service> <count> # Scale service replicas
|
||||||
|
uc ls # List services
|
||||||
|
uc rm <service> # Remove service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context and Connectivity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc context ls # List available contexts
|
||||||
|
uc context use <name> # Switch context
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Flags
|
||||||
|
|
||||||
|
- `--connect`: Connect to remote machine directly, without a config file
|
||||||
|
- `--uncloud-config`: Override config file path
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
|
||||||
|
- **Package naming**: Use clear, descriptive names
|
||||||
|
- **Error handling**: Wrap errors with context using `fmt.Errorf`
|
||||||
|
- **Logging**: Use structured logging with levels
|
||||||
|
- **gRPC**: Services defined in `internal/machine/api/pb/`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit tests alongside source files (`*_test.go`)
|
||||||
|
- Integration tests in `test/e2e/`
|
||||||
|
- Test fixtures in `test/fixtures/`
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Prefer standard library when possible
|
||||||
|
- Pin versions in `go.mod`
|
||||||
|
- Document rationale for external dependencies
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- Support environment variables for key settings
|
||||||
|
- Validate configuration early
|
||||||
|
- Provide sensible defaults
|
||||||
|
|
||||||
|
## Troubleshooting and Debugging
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
- **Networking**: Check WireGuard status, iptables rules
|
||||||
|
- **DNS**: Verify service discovery resolution
|
||||||
|
- **Containers**: Use standard Docker debugging tools
|
||||||
|
- **State sync**: Check Corrosion logs for replication issues
|
||||||
|
|
||||||
|
### Debugging Tools
|
||||||
|
|
||||||
|
- Standard Linux networking tools (`ping`, `traceroute`, `wireshark`)
|
||||||
|
- Docker commands (`docker ps`, `docker logs`)
|
||||||
|
- SSH access to machines for direct inspection
|
||||||
|
- gRPC debugging tools
|
||||||
|
|
||||||
|
### Logs and Monitoring
|
||||||
|
|
||||||
|
- Systemd services (getting logs via `journalctl -u SERVICE_NAME`)
|
||||||
|
- `uncloud` -- Uncloud daemon
|
||||||
|
- `uncloud-corrosion` -- Corrosion process
|
||||||
|
- Machine daemon logs
|
||||||
|
- Container logs via Docker
|
||||||
|
|
||||||
|
## File Patterns and Conventions
|
||||||
|
|
||||||
|
### Important Files to Understand
|
||||||
|
|
||||||
|
- `cmd/uncloud/main.go`: CLI entry point and command structure
|
||||||
|
- `internal/cli/cli.go`: CLI implementation and configuration
|
||||||
|
- `internal/machine/machine.go`: Core machine management
|
||||||
|
- `pkg/api/`: Public API definitions
|
||||||
|
- `misc/design.md`: Architecture and design philosophy
|
||||||
|
- `README.md`: User-facing documentation
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
- `go.mod/go.sum`: Go dependency management
|
||||||
|
- `Makefile`: Build and development tasks
|
||||||
|
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
|
||||||
|
|
||||||
|
This document should help AI assistants understand the project structure, make informed suggestions, and contribute effectively to the Uncloud codebase.
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
ARG ALPINE_VERSION=3.20.3
|
||||||
|
|
||||||
|
FROM golang:1.23.2-alpine AS uncloudd
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
# Download and cache dependencies and only redownload them in subsequent builds if they change.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main.go
|
||||||
|
|
||||||
|
|
||||||
|
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
RUN CORROSION_ARCH=$(case "${TARGETARCH}" in \
|
||||||
|
"amd64") echo "x86_64" ;; \
|
||||||
|
"arm64") echo "aarch64" ;; \
|
||||||
|
*) echo "Architecture '${TARGETARCH}' not supported" >&2 && exit 1 ;; \
|
||||||
|
esac) \
|
||||||
|
&& wget -q -O /tmp/corrosion.tar.gz \
|
||||||
|
"https://github.com/psviderski/corrosion/releases/latest/download/corrosion-${CORROSION_ARCH}-unknown-linux-gnu.tar.gz" \
|
||||||
|
&& tar -xzf /tmp/corrosion.tar.gz -C /tmp \
|
||||||
|
&& install /tmp/corrosion /usr/local/bin/corrosion \
|
||||||
|
&& rm /tmp/corrosion.tar.gz /tmp/corrosion
|
||||||
|
|
||||||
|
# Beware that more modern images like chainguard/wolfi-base build glibc with flags that require newer CPU features,
|
||||||
|
# e.g. x86-64-v2. So such image may fail with "Fatal glibc error: CPU does not support x86-64-v2" on older CPUs.
|
||||||
|
FROM gcr.io/distroless/cc-debian12:latest AS corrosion
|
||||||
|
COPY --from=corrosion-download /usr/local/bin/corrosion /usr/local/bin/corrosion
|
||||||
|
CMD ["corrosion", "agent"]
|
||||||
|
|
||||||
|
|
||||||
|
FROM alpine:${ALPINE_VERSION} AS corrosion-image-tarball
|
||||||
|
ARG CORROSION_IMAGE="ghcr.io/psviderski/corrosion:latest"
|
||||||
|
|
||||||
|
RUN apk --no-cache add crane
|
||||||
|
RUN crane pull "${CORROSION_IMAGE}" /corrosion.tar
|
||||||
|
|
||||||
|
|
||||||
|
# Uncloud-in-Docker (ucind) image for running Uncloud test clusters using Docker.
|
||||||
|
FROM docker:27.3.1-dind AS ucind
|
||||||
|
# Create system group and user 'uncloud'.
|
||||||
|
RUN addgroup -S uncloud && adduser -SHD -h /nonexistent -G uncloud -g "" uncloud
|
||||||
|
RUN apk --no-cache add \
|
||||||
|
socat \
|
||||||
|
wireguard-tools
|
||||||
|
|
||||||
|
COPY --from=corrosion-image-tarball /corrosion.tar /images/corrosion.tar
|
||||||
|
COPY scripts/docker/dind scripts/docker/entrypoint.sh /usr/local/bin/
|
||||||
|
COPY --from=uncloudd /build/uncloudd /usr/local/bin/
|
||||||
|
|
||||||
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
|
CMD ["uncloudd"]
|
||||||
@@ -1,16 +1,120 @@
|
|||||||
.PHONY: build
|
CORROSION_IMAGE ?= ghcr.io/psviderski/corrosion:latest
|
||||||
uncloud-dev:
|
UCIND_IMAGE ?= ghcr.io/psviderski/ucind:latest
|
||||||
|
|
||||||
|
update-dev:
|
||||||
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
|
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
|
||||||
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
|
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
|
||||||
ssh spy@192.168.40.243 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd
|
ssh spy@192.168.40.243 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd
|
||||||
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
|
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
|
||||||
ssh spy@192.168.40.176 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd
|
ssh spy@192.168.40.176 sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && \
|
||||||
GOOS=linux GOARCH=amd64 go build -o uncloud-linux-amd64 ./cmd/uncloud && \
|
rm uncloudd-linux-amd64
|
||||||
scp uncloud-linux-amd64 spy@192.168.40.243:~/ && \
|
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
|
||||||
ssh spy@192.168.40.243 sudo install ./uncloud-linux-amd64 /usr/local/bin/uncloud
|
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
|
||||||
scp uncloud-linux-amd64 spy@192.168.40.176:~/ && \
|
# ssh ubuntu@152.67.101.197 sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && \
|
||||||
ssh spy@192.168.40.176 sudo install ./uncloud-linux-amd64 /usr/local/bin/uncloud
|
# rm uncloudd-linux-arm64
|
||||||
|
|
||||||
|
update-restart-dev:
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o uncloudd-linux-amd64 ./cmd/uncloudd && \
|
||||||
|
scp uncloudd-linux-amd64 spy@192.168.40.243:~/ && \
|
||||||
|
ssh spy@192.168.40.243 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
||||||
|
scp uncloudd-linux-amd64 spy@192.168.40.176:~/ && \
|
||||||
|
ssh spy@192.168.40.176 "sudo install ./uncloudd-linux-amd64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
||||||
|
rm uncloudd-linux-amd64
|
||||||
|
# GOOS=linux GOARCH=arm64 go build -o uncloudd-linux-arm64 ./cmd/uncloudd && \
|
||||||
|
# scp uncloudd-linux-arm64 ubuntu@152.67.101.197:~/ && \
|
||||||
|
# ssh ubuntu@152.67.101.197 "sudo install ./uncloudd-linux-arm64 /usr/local/bin/uncloudd && sudo systemctl restart uncloud" && \
|
||||||
|
# rm uncloudd-linux-arm64
|
||||||
|
|
||||||
|
reset-dev:
|
||||||
|
ssh spy@192.168.40.243 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
|
ssh spy@192.168.40.176 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
|
ssh ubuntu@152.67.101.197 "sudo systemctl stop uncloud && sudo rm -rf /var/lib/uncloud"
|
||||||
|
|
||||||
|
demo-reset:
|
||||||
|
rm -fv ~/.config/uncloud/config.yaml
|
||||||
|
ssh ubuntu@152.67.101.197 "AUTO_CONFIRM=true sudo -E uncloud-uninstall && docker rmi caddy:2.9.1"
|
||||||
|
ssh root@5.223.45.199 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
||||||
|
ssh spy@192.168.40.243 "AUTO_CONFIRM=true sudo -E uncloud-uninstall"
|
||||||
|
|
||||||
|
.PHONY: ucind-cluster
|
||||||
|
ucind-cluster:
|
||||||
|
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
||||||
|
|
||||||
.PHONY: proto
|
.PHONY: proto
|
||||||
proto:
|
proto:
|
||||||
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative internal/machine/api/pb/*.proto
|
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
|
||||||
|
|
||||||
|
.PHONY: proto-mise
|
||||||
|
proto-mise:
|
||||||
|
mise exec -- protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
--proto_path=. --proto_path=internal/machine/api/vendor internal/machine/api/pb/*.proto
|
||||||
|
|
||||||
|
.PHONY: corrosion-image
|
||||||
|
corrosion-image:
|
||||||
|
docker build -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||||
|
|
||||||
|
.PHONY: corrosion-multiarch-image-push
|
||||||
|
corrosion-multiarch-image-push:
|
||||||
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(CORROSION_IMAGE)" --target corrosion .
|
||||||
|
|
||||||
|
.PHONY: ucind-image
|
||||||
|
ucind-image:
|
||||||
|
docker build -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
|
.PHONY: ucind-multiarch-image-push
|
||||||
|
ucind-multiarch-image-push:
|
||||||
|
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(UCIND_IMAGE)" --target ucind .
|
||||||
|
|
||||||
|
.PHONY: mocks
|
||||||
|
mocks:
|
||||||
|
@mockery
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
ifeq ($(TEST_NAME),)
|
||||||
|
go test -count=1 -v ./...
|
||||||
|
else
|
||||||
|
go test -race -count=1 -v -run ^$(TEST_NAME)$$ ./...
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: test-e2e
|
||||||
|
test-e2e:
|
||||||
|
go test -race -count=1 -v ./test/e2e
|
||||||
|
|
||||||
|
.PHONY: test-clean
|
||||||
|
test-clean:
|
||||||
|
@CONTAINERS=$$(docker ps --filter "name=ucind-test" -q); \
|
||||||
|
if [ -n "$$CONTAINERS" ]; then \
|
||||||
|
echo "Killing containers..."; \
|
||||||
|
docker kill $$CONTAINERS; \
|
||||||
|
fi; \
|
||||||
|
CONTAINERS_STOPPED=$$(docker ps -a --filter "name=ucind-test" -q); \
|
||||||
|
if [ -n "$$CONTAINERS_STOPPED" ]; then \
|
||||||
|
echo "Removing stopped containers..."; \
|
||||||
|
docker rm $$CONTAINERS_STOPPED; \
|
||||||
|
else \
|
||||||
|
echo "Nothing to clean."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
.PHONY: vet
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
.PHONY: format fmt
|
||||||
|
format fmt:
|
||||||
|
GOOS=linux golangci-lint fmt
|
||||||
|
|
||||||
|
LINT_TARGETS := lint lint-and-fix
|
||||||
|
.PHONY: $(LINT_TARGETS) _lint
|
||||||
|
$(LINT_TARGETS): _lint
|
||||||
|
lint: ARGS=
|
||||||
|
lint-and-fix: ARGS=--fix
|
||||||
|
_lint:
|
||||||
|
# Explicitly set OS to Linux to not skip *_linux.go files when running on macOS.
|
||||||
|
# Uncloud daemon won't likely support OS other than Linux anytime soon, so for now we can rely on that.
|
||||||
|
GOOS=linux golangci-lint run $(ARGS)
|
||||||
|
|
||||||
|
.PHONY: cli-docs
|
||||||
|
cli-docs:
|
||||||
|
go run ./cmd/uncloud docs
|
||||||
|
|||||||
@@ -1,9 +1,341 @@
|
|||||||
# Uncloud
|
<div align="center">
|
||||||
|
<img src="./website/landing/images/logo.svg" height="100" width="100" alt="Uncloud logo"/>
|
||||||
|
<h1>Uncloud</h1>
|
||||||
|
<p><strong>Docker simplicity. Multi-machine power.</strong></p>
|
||||||
|
|
||||||
Uncloud is a lightweight tool for deploying and managing containerised applications across a network of Docker hosts. It
|
<p>
|
||||||
bridges the gap between simple Docker deployments and complex cloud platforms and container orchestrators like
|
<a href="https://uncloud.run/docs"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
|
||||||
Kubernetes.
|
<a href="https://discord.gg/eR35KQJhPu"><img src="https://img.shields.io/badge/discord-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"></a>
|
||||||
|
<a href="https://x.com/psviderski"><img src="https://img.shields.io/badge/follow-black?style=for-the-badge&logo=X&logoColor=while" alt="Follow on X"></a>
|
||||||
|
<a href="https://github.com/sponsors/psviderski"><img src="https://img.shields.io/badge/Donate-EA4AAA.svg?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Donate"></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
Our goal is to become the go-to self-hosted alternative to platforms like [Fly.io](https://fly.io) and
|
Uncloud is a lightweight clustering and container orchestration tool that lets you deploy and manage web apps across
|
||||||
[Render](https://render.com/). Whether you're using bare metal servers, Raspberry Pis, or cloud VMs, Uncloud transforms
|
cloud VMs and bare metal with minimised cluster management overhead. It creates a secure WireGuard mesh network between
|
||||||
your hardware into a personal cloud platform.
|
your Docker hosts and provides automatic service discovery, load balancing, ingress with HTTPS, and simple CLI commands
|
||||||
|
to manage your apps.
|
||||||
|
|
||||||
|
Unlike traditional orchestrators, there's no central control plane and quorum to maintain. Each machine maintains a
|
||||||
|
synchronised copy of the cluster state through peer-to-peer communication, keeping cluster operations functional even if
|
||||||
|
some machines go offline.
|
||||||
|
|
||||||
|
Uncloud is the solution for developers who want the flexibility of self-hosted infrastructure without the operational
|
||||||
|
complexity of Kubernetes.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
||||||
|
regardless of location or provider.
|
||||||
|
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
||||||
|
need to learn a new bespoke DSL.
|
||||||
|
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
||||||
|
soon.
|
||||||
|
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||||
|
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
||||||
|
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
||||||
|
IPs for direct cross-machine communication.
|
||||||
|
* **No control plane**: Fully decentralised design eliminates single points of failure and reduces operational overhead.
|
||||||
|
* **Imperative over declarative**: Favoring imperative operations over state reconciliation simplifies both the mental
|
||||||
|
model and troubleshooting.
|
||||||
|
* **Managed DNS**: Automatic DNS records `*.<id>.cluster.uncloud.run` for services with public access via managed
|
||||||
|
[Uncloud DNS](https://github.com/psviderski/uncloud-dns) service.
|
||||||
|
* **Automatic HTTPS**: Built-in Caddy reverse proxy handles TLS certificate provisioning and renewal using Let's
|
||||||
|
Encrypt.
|
||||||
|
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||||
|
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||||
|
|
||||||
|
### 🚀 Coming soon
|
||||||
|
|
||||||
|
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
|
||||||
|
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
||||||
|
|
||||||
|
## 🎬 Quick demo
|
||||||
|
|
||||||
|
The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
|
||||||
|
the [`compose.yaml`](website/compose.yaml) file on my local machine.
|
||||||
|
|
||||||
|
It exposes the container port `8000/tcp` as HTTPS on the domain `uncloud.run`, served by the Caddy reverse proxy on the
|
||||||
|
remote machines. All managed by Uncloud.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Here is a more advanced use case. Deploy a highly available web app with automatic HTTPS across multiple regions and
|
||||||
|
on-premises in just a couple minutes.
|
||||||
|
|
||||||
|
<a href="https://uncloud.wistia.com/medias/k47uwt9uau?wvideo=k47uwt9uau">
|
||||||
|
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## 💫 Why Uncloud?
|
||||||
|
|
||||||
|
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
|
||||||
|
container orchestrators like Kubernetes provide power and flexibility but require significant operational expertise. I
|
||||||
|
believe there's a sweet spot in between — a pragmatic solution for the majority of us who aren't running at Google
|
||||||
|
scale. You should be able to:
|
||||||
|
|
||||||
|
* **Own your infrastructure and data**: Whether driven by costs, compliance, or flexibility, run applications on any
|
||||||
|
combination of cloud VMs and personal hardware while controlling your data and maintaining the cloud-like experience
|
||||||
|
you love.
|
||||||
|
* **Stay simple as you grow**: Start with a single machine and add more whenever you need without changing your
|
||||||
|
workflow. No worrying about highly-available control planes or complex YAML configurations.
|
||||||
|
* **Build with proven primitives**: Get production-grade networking, deployment primitives, service discovery, load
|
||||||
|
balancing, and ingress with HTTPS out of the box without becoming a distributed systems expert.
|
||||||
|
* **Support sustainable computing** 🌿: Minimise system overhead to maximise resources available for your applications.
|
||||||
|
|
||||||
|
Uncloud's goal is to make deployment and management of containerised applications feel as seamless as using a cloud
|
||||||
|
platform, whether you're running on a $5 VPS, a spare Mac mini, or a rack of bare metal servers.
|
||||||
|
|
||||||
|
## 🚀 Quick start
|
||||||
|
|
||||||
|
1. Install Uncloud CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install psviderski/tap/uncloud
|
||||||
|
|
||||||
|
# or using curl (macOS/Linux)
|
||||||
|
curl -fsS https://get.uncloud.run/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Installation](https://uncloud.run/docs/getting-started/install-cli) for more options.
|
||||||
|
|
||||||
|
2. Initialise your first machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc machine init root@your-server-ip
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Deploy your app from a Docker image and publish its container port 8000 as HTTPS using `app.example.com` domain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc run -p app.example.com:8000/https image/my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create a DNS A record in your DNS provider (Cloudflare, Namecheap, etc.) that points `app.example.com` to your
|
||||||
|
server's IP address. Allow a few minutes for DNS propagation.
|
||||||
|
|
||||||
|
That's it! Your app is now running and accessible at https://app.example.com ✨
|
||||||
|
|
||||||
|
5. Clean up when you're done:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uc ls
|
||||||
|
# Copy the service name from the output and run the rm command:
|
||||||
|
uc rm my-app-name
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to fully uninstall Uncloud on a machine, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uncloud-uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
View the [Documentation](https://uncloud.run/docs) for more information.
|
||||||
|
|
||||||
|
## ⚙️ How it works
|
||||||
|
|
||||||
|
Check out the [design document](misc/design.md) to understand Uncloud's design philosophy and goals.
|
||||||
|
|
||||||
|
Here is a diagram of an Uncloud multi-provider cluster of 3 machines:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Peek under the hood to see what happens when you run certain commands.</summary>
|
||||||
|
|
||||||
|
**When you initialise a new cluster on a machine:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ uc machine init --name oracle-vm ubuntu@152.67.101.197
|
||||||
|
|
||||||
|
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||||
|
⏳ Running Uncloud install script...
|
||||||
|
✓ Docker is already installed.
|
||||||
|
⏳ Installing Docker...
|
||||||
|
...
|
||||||
|
✓ Docker installed successfully.
|
||||||
|
✓ Linux user and group 'uncloud' created.
|
||||||
|
✓ Linux user 'ubuntu' added to group 'uncloud'.
|
||||||
|
⏳ Installing Uncloud binaries...
|
||||||
|
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_arm64.tar.gz
|
||||||
|
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||||
|
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||||
|
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||||
|
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||||
|
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-aarch64-unknown-linux-gnu.tar.gz
|
||||||
|
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||||
|
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||||
|
✓ Uncloud machine daemon started.
|
||||||
|
✓ Uncloud installed on the machine successfully! 🎉
|
||||||
|
Cluster "default" initialised with machine "oracle-vm"
|
||||||
|
Waiting for the machine to be ready...
|
||||||
|
|
||||||
|
Reserved cluster domain: xuw3xd.cluster.uncloud.run
|
||||||
|
[+] Deploying service caddy 1/1
|
||||||
|
✔ Container caddy-c47x on oracle-vm Started 0.9s
|
||||||
|
|
||||||
|
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||||
|
[+] Verifying internet access to caddy service 1/1
|
||||||
|
✔ Machine oracle-vm (152.67.101.197) Reachable 0.1s
|
||||||
|
|
||||||
|
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||||
|
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197
|
||||||
|
```
|
||||||
|
|
||||||
|
1. The CLI SSHs into the machine and installs Docker, the `uncloudd` machine daemon and
|
||||||
|
[corrosion](https://github.com/superfly/corrosion) service, managed by systemd.
|
||||||
|
2. Generates a unique WireGuard key pair, allocates a dedicated subnet `10.210.0.0/24` for the machine and its
|
||||||
|
containers, and configures `uncloudd` accordingly. All subsequent communication happens with `uncloudd`
|
||||||
|
through its gRPC API over SSH.
|
||||||
|
3. Configures and starts `corrosion`, a CRDT-based distributed SQLite database to share cluster state between machines.
|
||||||
|
4. Creates a Docker bridge network connected to the WireGuard interface.
|
||||||
|
5. This machine becomes an entry point for the newly created cluster which is stored in the cluster config under
|
||||||
|
`~/.config/uncloud` on your local machine.
|
||||||
|
|
||||||
|
**When you add another machine:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ uc machine add --name hetzner-server root@5.223.45.199
|
||||||
|
Downloading Uncloud install script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh
|
||||||
|
⏳ Running Uncloud install script...
|
||||||
|
✓ Docker is already installed.
|
||||||
|
✓ Linux user and group 'uncloud' created.
|
||||||
|
⏳ Installing Uncloud binaries...
|
||||||
|
⏳ Downloading uncloudd binary: https://github.com/psviderski/uncloud/releases/latest/download/uncloudd_linux_amd64.tar.gz
|
||||||
|
✓ uncloudd binary installed: /usr/local/bin/uncloudd
|
||||||
|
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
|
||||||
|
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
|
||||||
|
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
|
||||||
|
⏳ Downloading uncloud-corrosion binary: https://github.com/psviderski/corrosion/releases/latest/download/corrosion-x86_64-unknown-linux-gnu.tar.gz
|
||||||
|
✓ uncloud-corrosion binary installed: /usr/local/bin/uncloud-corrosion
|
||||||
|
✓ Systemd unit file created: /etc/systemd/system/uncloud-corrosion.service
|
||||||
|
⏳ Starting Uncloud machine daemon (uncloud.service)...
|
||||||
|
✓ Uncloud machine daemon started.
|
||||||
|
✓ Uncloud installed on the machine successfully! 🎉
|
||||||
|
Machine "hetzner-server" added to cluster
|
||||||
|
Waiting for the machine to be ready...
|
||||||
|
|
||||||
|
[+] Deploying service caddy 1/1
|
||||||
|
✔ Container caddy-d36c on hetzner-server Started 1.0s
|
||||||
|
|
||||||
|
Updating cluster domain records in Uncloud DNS to point to machines running caddy service...
|
||||||
|
[+] Verifying internet access to caddy service 2/2
|
||||||
|
✔ Machine hetzner-server (5.223.45.199) Reachable 0.2s
|
||||||
|
✔ Machine oracle-vm (152.67.101.197) Reachable 0.1s
|
||||||
|
|
||||||
|
DNS records updated to use only the internet-reachable machines running caddy service:
|
||||||
|
*.xuw3xd.cluster.uncloud.run A → 152.67.101.197, 5.223.45.199
|
||||||
|
|
||||||
|
$ uc machine ls
|
||||||
|
NAME STATE ADDRESS PUBLIC IP WIREGUARD ENDPOINTS
|
||||||
|
oracle-vm Up 10.210.0.1/24 152.67.101.197 10.0.0.95:51820, 152.67.101.197:51820
|
||||||
|
hetzner-server Up 10.210.1.1/24 5.223.45.199 5.223.45.199:51820, [2a01:4ff:2f0:128b::1]:51820
|
||||||
|
```
|
||||||
|
|
||||||
|
1. The second machine gets provisioned just like the first. A non-root SSH user will need `sudo` access.
|
||||||
|
2. Allocates a new subnet `10.210.1.0/24` for the second machine and its containers.
|
||||||
|
3. Registers the second machine in the cluster state and exchanges WireGuard keys with the first machine.
|
||||||
|
4. Both machines establish a WireGuard tunnel between each other, allowing Docker containers connected to the bridge
|
||||||
|
network to communicate directly across machines.
|
||||||
|
5. Configures and starts `corrosion` on the second machine to sync the cluster state.
|
||||||
|
6. The second machine is added as an alternative entry point in the cluster config.
|
||||||
|
7. If one of the machines goes offline, the other machine can still serve cluster operations.
|
||||||
|
|
||||||
|
If one more machine is added, the process repeats with a new subnet. The new machine needs to establish a WireGuard
|
||||||
|
connection with only one of the existing machines. Other machines will learn about it through the shared cluster state
|
||||||
|
and automatically establish a WireGuard tunnel with it.
|
||||||
|
|
||||||
|
**When you run a service:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ uc run -p app.example.com:8000/https image/my-app
|
||||||
|
|
||||||
|
[+] Running service my-app-1b3b (replicated mode) 1/1
|
||||||
|
✔ Container my-app-1b3b-tcex on oracle-vm Started
|
||||||
|
|
||||||
|
my-app-1b3b endpoints:
|
||||||
|
• https://app.example.com → :8000
|
||||||
|
• https://my-app-1b3b.xuw3xd.cluster.uncloud.run → :8000
|
||||||
|
```
|
||||||
|
|
||||||
|
1. CLI picks a machine to run your container.
|
||||||
|
2. `uncloudd` that the CLI communicates with uses [`grpc-proxy`](https://github.com/siderolabs/grpc-proxy) to forward
|
||||||
|
the request to the target machine to launch a container there.
|
||||||
|
3. `uncloudd` on the target machine starts the Docker container in the bridge network and stores its info in the
|
||||||
|
cluster's distributed state.
|
||||||
|
4. The container gets a cluster-unique IP address from the bridge network (in the `10.210.X.2-254` range) and becomes
|
||||||
|
accessible from other machines in the cluster.
|
||||||
|
5. Caddy reverse proxy which runs in [`global`](https://github.com/compose-spec/compose-spec/blob/main/deploy.md#mode)
|
||||||
|
mode on each machine watches the cluster state for new services and updates its configuration to route traffic to the
|
||||||
|
new container.
|
||||||
|
|
||||||
|
Look ma, no control plane or master nodes to maintain! Just a simple overlay network and eventually consistent state
|
||||||
|
sync that lets machines work together. Want to check on things or make changes? Connect to any machine either implicitly
|
||||||
|
using the CLI or directly over SSH. They all have the complete cluster state and can control everything. It's like each
|
||||||
|
machine is a full backup of your control plane.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 🏗 Project status
|
||||||
|
|
||||||
|
Uncloud is currently in active development and is **not ready for production use**. Features may change significantly
|
||||||
|
and there may be breaking changes between releases.
|
||||||
|
|
||||||
|
We'd love your input! Here's how you can contribute:
|
||||||
|
|
||||||
|
* 🐛 Found a bug? [Open an issue](https://github.com/psviderski/uncloud/issues)
|
||||||
|
* 💡 Have questions, ideas, or need help?
|
||||||
|
* Start a discussion or join an existing one in
|
||||||
|
the [Discussions](https://github.com/psviderski/uncloud/discussions).
|
||||||
|
* Join our [Discord community](https://discord.gg/eR35KQJhPu) where we discuss features, roadmap, implementation
|
||||||
|
details, and help each other out.
|
||||||
|
|
||||||
|
## 🙏 Inspiration & Acknowledgements
|
||||||
|
|
||||||
|
I'm grateful to the following projects that inspired Uncloud's design and implementation:
|
||||||
|
|
||||||
|
* [Kamal](https://kamal-deploy.org/) — for proving that even in the declarative era of Kubernetes there is a place for
|
||||||
|
simple deployment tools that use imperative commands without complex orchestration. Kamal powers the multi-billion
|
||||||
|
dollar company [37signals](https://37signals.com/) where it was created, and that's truly inspiring!
|
||||||
|
* [Fly.io](https://fly.io/) — for inspiring my vision for what self-hosted infrastructure should feel like, proving that
|
||||||
|
developer experience and powerful infrastructure can coexist beautifully.
|
||||||
|
* [Tailscale](https://tailscale.com/) — for pioneering the vision of decentralised flat mesh networking with an amazing
|
||||||
|
user experience that feels like magic.
|
||||||
|
* [Talos Linux](https://github.com/siderolabs/talos)
|
||||||
|
and [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/) — for the machine API design using
|
||||||
|
[grpc-proxy](https://github.com/siderolabs/grpc-proxy) and for its elegant approach to secure WireGuard-based overlay
|
||||||
|
networking with zero configuration.
|
||||||
|
* [Docker Swarm Classic](https://github.com/docker-archive/classicswarm) and
|
||||||
|
[Rancher 1.x](http://rancher-com-website-main-elb-elb-1798790864.us-west-2.elb.amazonaws.com/docs/rancher/v1.6/en/)
|
||||||
|
— for showing the power of simplicity and pragmatism in container orchestration and that not every problem needs the
|
||||||
|
complexity of Kubernetes.
|
||||||
|
|
||||||
|
Special thanks to the [Corrosion](https://github.com/superfly/corrosion) project by Fly.io for providing the distributed
|
||||||
|
SQLite database used to share Uncloud's cluster state.
|
||||||
|
|
||||||
|
## 📫 Stay updated
|
||||||
|
|
||||||
|
* Join our [Discord server](https://discord.gg/eR35KQJhPu) for real-time discussions, support, and updates.
|
||||||
|
* Follow [@psviderski](https://x.com/psviderski) on X/Twitter.
|
||||||
|
* Subscribe to [my newsletter](https://uncloud.run/#subscribe) to follow the progress, get early insights into new
|
||||||
|
features, and be the first to know when it's ready for production use.
|
||||||
|
* Watch this repository for releases.
|
||||||
|
|
||||||
|
## 💖 Sponsors
|
||||||
|
|
||||||
|
These companies and projects are helping Uncloud with their generous sponsorship and/or services:
|
||||||
|
|
||||||
|
<!-- Sentry -->
|
||||||
|
<a href="https://sentry.io/welcome/">
|
||||||
|
<img height="100" alt="Sentry" src="https://github.com/user-attachments/assets/6c1439c0-d20d-40dc-a669-c9aa94651dfa" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## ❤️ Contributors
|
||||||
|
|
||||||
|
Thank you [@cedws](https://github.com/cedws) for being the first contributor to Uncloud! 🎉
|
||||||
|
|
||||||
|
<a href="https://github.com/psviderski/uncloud/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=psviderski/uncloud" />
|
||||||
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCreateCommand() *cobra.Command {
|
||||||
|
opts := ucind.CreateClusterOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create [NAME]",
|
||||||
|
Short: "Create a new cluster.",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
|
||||||
|
|
||||||
|
name := DefaultClusterName
|
||||||
|
if len(args) > 0 {
|
||||||
|
name = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := p.CreateCluster(cmd.Context(), name, opts); err != nil {
|
||||||
|
return fmt.Errorf("create cluster '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Cluster '%s' created.\n", name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().IntVarP(&opts.Machines, "machines", "m", 1, "Number of machines to create.")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRemoveCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm [NAME]",
|
||||||
|
Short: "Remove a cluster.",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
p := cmd.Context().Value("provisioner").(*ucind.Provisioner)
|
||||||
|
|
||||||
|
name := DefaultClusterName
|
||||||
|
if len(args) > 0 {
|
||||||
|
name = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.RemoveCluster(cmd.Context(), name); err != nil {
|
||||||
|
return fmt.Errorf("remove cluster '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Cluster '%s' removed.\n", name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultClusterName = "ucind-default"
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "cluster",
|
||||||
|
Short: "Manage local Docker-based clusters.",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewCreateCommand(),
|
||||||
|
// NewListCommand(),
|
||||||
|
NewRemoveCommand(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/psviderski/uncloud/cmd/ucind/cluster"
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var configPath string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ucind",
|
||||||
|
Short: "A CLI tool for running Uncloud test clusters using Docker.",
|
||||||
|
Long: "A CLI tool for running Uncloud test clusters using Docker.\n" +
|
||||||
|
"Machines in a ucind cluster are Docker containers running a ucind image. All machines within a cluster " +
|
||||||
|
"are connected to the same Docker network.",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create Docker client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(configPath, "~/") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get user home directory to resolve %q: %w", configPath, err)
|
||||||
|
}
|
||||||
|
configPath = strings.Replace(configPath, "~", home, 1)
|
||||||
|
}
|
||||||
|
configUpdater := ucind.NewConfigUpdater(configPath)
|
||||||
|
|
||||||
|
p := ucind.NewProvisioner(cli, configUpdater)
|
||||||
|
// Persist the provisioner in the context so it can be used by subcommands.
|
||||||
|
cmd.SetContext(context.WithValue(cmd.Context(), "provisioner", p))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||||
|
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
|
"path to the Uncloud configuration file.")
|
||||||
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
cluster.NewRootCommand(),
|
||||||
|
)
|
||||||
|
cobra.CheckErr(cmd.Execute())
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewBuildCommand creates a new command to build services from a Compose file.
|
||||||
|
func NewBuildCommand() *cobra.Command {
|
||||||
|
opts := cli.BuildOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "build [FLAGS] [SERVICE...]",
|
||||||
|
Short: "Build services from a Compose file.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.Services = args
|
||||||
|
}
|
||||||
|
|
||||||
|
return runBuild(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Files, "file", "f", nil,
|
||||||
|
"One or more Compose files to build (default compose.yaml)")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Profiles, "profile", "p", nil,
|
||||||
|
"One or more Compose profiles to enable.")
|
||||||
|
cmd.Flags().BoolVarP(&opts.Push, "push", "P", false,
|
||||||
|
"Push built images to the registry after building. (default false)")
|
||||||
|
cmd.Flags().BoolVarP(&opts.NoCache, "no-cache", "n", false,
|
||||||
|
"Do not use cache when building images. (default false)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: deduplicate with a similar functino for deploy options
|
||||||
|
// projectOpts returns the project options for the Compose file(s).
|
||||||
|
func projectOptsFromBuildOpts(opts cli.BuildOptions) []composecli.ProjectOptionsFn {
|
||||||
|
projectOpts := []composecli.ProjectOptionsFn{}
|
||||||
|
|
||||||
|
if len(opts.Profiles) > 0 {
|
||||||
|
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.Profiles...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBuild parses the Compose file(s), builds the services, and pushes them if requested.
|
||||||
|
func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error {
|
||||||
|
projectOpts := projectOptsFromBuildOpts(opts)
|
||||||
|
project, err := compose.LoadProject(ctx, opts.Files, projectOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.Services) > 0 {
|
||||||
|
project, err = project.WithSelectedServices(opts.Services)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("select services: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||||
|
|
||||||
|
if len(servicesToBuild) == 0 {
|
||||||
|
fmt.Println("No services to build.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cli.BuildServices(ctx, servicesToBuild, opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package caddy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/alecthomas/chroma/v2/quick"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type configOptions struct {
|
||||||
|
machine string
|
||||||
|
noColor bool
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigCommand() *cobra.Command {
|
||||||
|
opts := configOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Show the current Caddy configuration (Caddyfile).",
|
||||||
|
Long: "Display the current Caddy configuration (Caddyfile) from the connected machine or a specified one.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return runConfig(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
|
"Name or ID of the machine to get the configuration from. (default is connected machine)")
|
||||||
|
cmd.Flags().BoolVar(&opts.noColor, "no-color", false,
|
||||||
|
"Disable syntax highlighting for the output.")
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfig(ctx context.Context, uncli *cli.CLI, opts configOptions) error {
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
if opts.machine != "" {
|
||||||
|
// If a specific machine is requested, use it to get the Caddy configuration.
|
||||||
|
ctx, _, err = api.ProxyMachinesContext(ctx, clusterClient, []string{opts.machine})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := clusterClient.Caddy.GetConfig(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get Caddy config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the Caddyfile with syntax highlighting.
|
||||||
|
if opts.noColor {
|
||||||
|
fmt.Print(config.Caddyfile)
|
||||||
|
} else {
|
||||||
|
if err = quick.Highlight(os.Stdout, config.Caddyfile, "caddy", "terminal256", "monokai"); err != nil {
|
||||||
|
// If highlighting fails, fall back to plain output.
|
||||||
|
fmt.Print(config.Caddyfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package caddy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/streams"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deployOptions struct {
|
||||||
|
caddyfile string
|
||||||
|
image string
|
||||||
|
machines []string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeployCommand() *cobra.Command {
|
||||||
|
opts := deployOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "deploy",
|
||||||
|
Short: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.",
|
||||||
|
Long: "Deploy or upgrade Caddy reverse proxy across all machines in the cluster.\n" +
|
||||||
|
"A rolling update is performed when updating existing containers to minimise disruption.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
||||||
|
"Path to a custom global Caddy config (Caddyfile) that will be prepended to the auto-generated Caddy config.")
|
||||||
|
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||||
|
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
|
"Machine names to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||||
|
"list of machine names. (default is all machines)")
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context to deploy to. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
|
caddyfile := ""
|
||||||
|
if opts.caddyfile != "" {
|
||||||
|
data, err := os.ReadFile(opts.caddyfile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read Caddyfile: %w", err)
|
||||||
|
}
|
||||||
|
caddyfile = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
svc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Service: %s (not running)\n", client.CaddyServiceName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Service: %s (%s mode)\n", svc.Name, svc.Mode)
|
||||||
|
|
||||||
|
// Collect unique images of all containers in the running caddy service.
|
||||||
|
images := make(map[string]struct{}, len(svc.Containers))
|
||||||
|
for _, c := range svc.Containers {
|
||||||
|
images[c.Container.Config.Image] = struct{}{}
|
||||||
|
}
|
||||||
|
currentImages := slices.Collect(maps.Keys(images))
|
||||||
|
|
||||||
|
if len(currentImages) > 1 {
|
||||||
|
commaSeparatedImages := strings.Join(currentImages, ", ")
|
||||||
|
fmt.Printf("Current images (multiple versions detected): %s\n", commaSeparatedImages)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Current image: %s\n", currentImages[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.image != "" {
|
||||||
|
fmt.Printf("Target image: %s\n", opts.image)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Preparing a deployment plan...")
|
||||||
|
|
||||||
|
placement := api.Placement{
|
||||||
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
|
}
|
||||||
|
d, err := clusterClient.NewCaddyDeployment(opts.image, caddyfile, placement)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.image == "" {
|
||||||
|
fmt.Printf("Target image: %s (latest stable)\n", d.Spec.Container.Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := d.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan caddy deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plan.Operations) == 0 {
|
||||||
|
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
|
||||||
|
} else {
|
||||||
|
if svc.ID == "" {
|
||||||
|
if len(opts.machines) > 0 {
|
||||||
|
fmt.Println("This will run a Caddy container on each selected machine.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("This will run a Caddy container on each machine.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise a machine and container name resolver to properly format the plan output.
|
||||||
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Deployment plan:")
|
||||||
|
fmt.Println(plan.Format(resolver))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
confirmed, err := cli.Confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = d.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
return UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateDomainRecords(ctx context.Context, clusterClient *client.Client, progressOut *streams.Out) error {
|
||||||
|
domain, err := clusterClient.GetDomain(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
|
fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("get cluster domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Updating cluster domain records in Uncloud DNS to point to machines running caddy service...")
|
||||||
|
// TODO: split the method into two: one to get the records and one to update them to ask for update confirmation.
|
||||||
|
|
||||||
|
var records []*pb.DNSRecord
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName)
|
||||||
|
return err
|
||||||
|
}, progressOut, "Verifying internet access to caddy service")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, client.ErrNoReachableMachines) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("DNS records for domain '%s' could not be updated as there are no internet-reachable "+
|
||||||
|
"machines running caddy containers.\n", domain)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Possible solutions:")
|
||||||
|
fmt.Println("- Ensure your machines have public IP addresses")
|
||||||
|
fmt.Println("- Use --public-ip flag when adding machines to override the automatically detected IPs")
|
||||||
|
fmt.Println("- Check firewall settings on your machines")
|
||||||
|
fmt.Println("- Configure port forwarding if behind NAT")
|
||||||
|
fmt.Println("- Retry Caddy deployment with 'uc caddy deploy' after resolving connectivity issues")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Your services won't be accessible from the internet until at least one machine " +
|
||||||
|
"becomes reachable. If you aren't planning to expose any services publicly, you can release " +
|
||||||
|
"the domain by running 'uc dns release'.")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to update DNS records pointing to caddy service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("DNS records updated to use only the internet-reachable machines running caddy service:")
|
||||||
|
for _, r := range records {
|
||||||
|
fmt.Printf(" %s %s → %s\n", r.Name, r.Type, strings.Join(r.Values, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package caddy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "caddy",
|
||||||
|
Short: "Manage Caddy reverse proxy service.",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewConfigCommand(),
|
||||||
|
NewDeployCommand(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
var clusterContext string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List available cluster contexts.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(uncli)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&clusterContext, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(uncli *cli.CLI) error {
|
||||||
|
if uncli.Config == nil {
|
||||||
|
return fmt.Errorf("context management is not available: Uncloud configuration file is not being used")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(uncli.Config.Contexts) == 0 {
|
||||||
|
fmt.Println("No contexts found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
|
||||||
|
currentContext := uncli.Config.CurrentContext
|
||||||
|
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "NAME\tCURRENT\tCONNECTIONS")
|
||||||
|
|
||||||
|
for _, name := range contextNames {
|
||||||
|
current := ""
|
||||||
|
if name == currentContext {
|
||||||
|
current = "*"
|
||||||
|
}
|
||||||
|
connCount := len(uncli.Config.Contexts[name].Connections)
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%d\n", name, current, connCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ctx",
|
||||||
|
Aliases: []string{"context"},
|
||||||
|
Short: "Switch between different cluster contexts. Contains subcommands to manage contexts.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return selectContext(uncli)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewListCommand(),
|
||||||
|
NewUseCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewUseCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "use [CONTEXT]",
|
||||||
|
Short: "Switch to a different cluster context.",
|
||||||
|
Long: "Switch to a different cluster context. If no context is provided, " +
|
||||||
|
"a list of available contexts will be displayed for selection.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
if len(args) == 1 {
|
||||||
|
if err := uncli.SetCurrentContext(args[0]); err != nil {
|
||||||
|
return fmt.Errorf("failed to set the current cluster context to '%s': %w", args[0], err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Current cluster context is now '%s'.\n", args[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectContext(uncli)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectContext(uncli *cli.CLI) error {
|
||||||
|
if uncli.Config == nil {
|
||||||
|
return fmt.Errorf("context management is not available: Uncloud configuration file is not being used")
|
||||||
|
}
|
||||||
|
if len(uncli.Config.Contexts) == 0 {
|
||||||
|
return fmt.Errorf("no contexts found in Uncloud config (%s)", uncli.Config.Path())
|
||||||
|
}
|
||||||
|
|
||||||
|
contextNames := slices.Sorted(maps.Keys(uncli.Config.Contexts))
|
||||||
|
|
||||||
|
var selected string
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Select a cluster context").
|
||||||
|
Options(buildContextOptions(contextNames, uncli.Config.CurrentContext)...).
|
||||||
|
Value(&selected),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return fmt.Errorf("select cluster context: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uncli.SetCurrentContext(selected); err != nil {
|
||||||
|
return fmt.Errorf("set current cluster context: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Current cluster context is now '%s'.\n", selected)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildContextOptions(contexts []string, current string) []huh.Option[string] {
|
||||||
|
options := make([]huh.Option[string], len(contexts))
|
||||||
|
|
||||||
|
for i, ctx := range contexts {
|
||||||
|
opt := huh.NewOption(ctx, ctx)
|
||||||
|
if ctx == current {
|
||||||
|
opt.Key += " (current)"
|
||||||
|
opt = opt.Selected(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
options[i] = opt
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deployOptions struct {
|
||||||
|
files []string
|
||||||
|
profiles []string
|
||||||
|
services []string
|
||||||
|
noBuild bool
|
||||||
|
recreate bool
|
||||||
|
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeployCommand creates a new command to deploy services from a Compose file.
|
||||||
|
func NewDeployCommand() *cobra.Command {
|
||||||
|
opts := deployOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "deploy [FLAGS] [SERVICE...]",
|
||||||
|
Short: "Deploy services from a Compose file.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.services = args
|
||||||
|
}
|
||||||
|
|
||||||
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
|
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||||
|
"One or more Compose profiles to enable.")
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context to deploy to (default is the current context)")
|
||||||
|
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
||||||
|
"Do not build images before deploying services. (default false)")
|
||||||
|
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
||||||
|
"Recreate containers even if their configuration and image haven't changed.")
|
||||||
|
|
||||||
|
// TODO: Consider adding a filter flag to specify which machines to deploy to but keep the rest running.
|
||||||
|
// Could be useful to test a new version on a subset of machines before rolling out to all.
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectOpts returns the project options for the Compose file(s).
|
||||||
|
func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn {
|
||||||
|
projectOpts := []composecli.ProjectOptionsFn{}
|
||||||
|
|
||||||
|
if len(opts.profiles) > 0 {
|
||||||
|
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDeploy parses the Compose file(s) and deploys the services.
|
||||||
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
|
projectOpts := projectOpts(opts)
|
||||||
|
|
||||||
|
project, err := compose.LoadProject(ctx, opts.files, projectOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.services) > 0 {
|
||||||
|
// Includes service dependencies by default. This is the default docker compose behavior.
|
||||||
|
project, err = project.WithSelectedServices(opts.services)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("select services: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
||||||
|
|
||||||
|
if len(servicesToBuild) > 0 {
|
||||||
|
if opts.noBuild {
|
||||||
|
fmt.Println("Not building services as requested.")
|
||||||
|
} else {
|
||||||
|
buildOpts := cli.BuildOptions{
|
||||||
|
Push: true,
|
||||||
|
NoCache: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil {
|
||||||
|
return fmt.Errorf("build services: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
var strategy deploy.Strategy
|
||||||
|
if opts.recreate {
|
||||||
|
strategy = &deploy.RollingStrategy{ForceRecreate: true}
|
||||||
|
}
|
||||||
|
composeDeploy, err := compose.NewDeploymentWithStrategy(ctx, clusterClient, project, strategy)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create compose deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := composeDeploy.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plan.Operations) == 0 {
|
||||||
|
fmt.Println("Services are up to date.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Deployment plan:")
|
||||||
|
if err = printPlan(ctx, clusterClient, plan); err != nil {
|
||||||
|
return fmt.Errorf("print deployment plan: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
confirmed, err := cli.Confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm deployment: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if err := plan.Execute(ctx, clusterClient); err != nil {
|
||||||
|
return fmt.Errorf("deploy services: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), "Deploying services")
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPlan(ctx context.Context, cli *client.Client, plan deploy.SequenceOperation) error {
|
||||||
|
for _, op := range plan.Operations {
|
||||||
|
svcPlan, ok := op.(*deploy.Plan)
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("- " + op.Format(nil))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := cli.InspectService(ctx, svcPlan.ServiceID)
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
// Initialise a machine and container name resolver to properly format the service plan output.
|
||||||
|
resolver, err := cli.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("- Deploy service [name=%s]\n", svcPlan.ServiceName)
|
||||||
|
fmt.Println(indent(svcPlan.Format(resolver), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(text, prefix string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = prefix + line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type releaseOptions struct {
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReleaseCommand() *cobra.Command {
|
||||||
|
opts := releaseOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "release",
|
||||||
|
Short: "Release the reserved cluster domain.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return release(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
domain, err := client.ReleaseDomain(ctx, &emptypb.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
|
return errors.New("no domain reserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Released cluster domain: %s\n", domain.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1"
|
||||||
|
|
||||||
|
type reserveOptions struct {
|
||||||
|
endpoint string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReserveCommand() *cobra.Command {
|
||||||
|
opts := reserveOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "reserve",
|
||||||
|
Short: "Reserve a cluster domain in Uncloud DNS.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return reserve(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint,
|
||||||
|
"API endpoint for the Uncloud DNS service.")
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error {
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
domain, err := clusterClient.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.endpoint})
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() == codes.AlreadyExists {
|
||||||
|
return errors.New("domain already reserved")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||||
|
|
||||||
|
// Update cluster domain records in Uncloud DNS to point to machines running caddy service if it has been deployed.
|
||||||
|
if _, err = clusterClient.InspectService(ctx, client.CaddyServiceName); err != nil {
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
|
fmt.Println("Deploy the Caddy reverse proxy service ('uc caddy deploy') to enable internet access " +
|
||||||
|
"to your services via the reserved or your custom domain.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return caddy.UpdateDomainRecords(ctx, clusterClient, uncli.ProgressOut())
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "dns",
|
||||||
|
Short: "Manage cluster domain in Uncloud DNS.",
|
||||||
|
Long: "Manage cluster domain in Uncloud DNS.\n" +
|
||||||
|
"DNS commands allow you to reserve or release a unique '<id>.cluster.uncloud.run' domain for your " +
|
||||||
|
"cluster. When reserved, Caddy service deployments will automatically update DNS records to route " +
|
||||||
|
"traffic to the services in the cluster.",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewReleaseCommand(),
|
||||||
|
NewReserveCommand(),
|
||||||
|
NewShowCommand(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type showOptions struct {
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewShowCommand() *cobra.Command {
|
||||||
|
opts := showOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "show",
|
||||||
|
Short: "Print the cluster domain name.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return show(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error {
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
domain, err := clusterClient.GetDomain(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
|
return errors.New("no domain reserved")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(domain)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/cobra/doc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const docsDir = "website/docs/9-cli-reference"
|
||||||
|
|
||||||
|
type cmdWrapper struct {
|
||||||
|
cmd *cobra.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDocsCommand creates a new hidden command to generate CLI reference docs.
|
||||||
|
func NewDocsCommand() *cobra.Command {
|
||||||
|
wrapper := &cmdWrapper{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "docs",
|
||||||
|
Short: "Generate Uncloud CLI reference docs",
|
||||||
|
SilenceUsage: true,
|
||||||
|
DisableFlagsInUseLine: true,
|
||||||
|
Hidden: true,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
ValidArgsFunction: cobra.NoFileCompletions,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
// Remove existing markdown files.
|
||||||
|
mdFiles, err := filepath.Glob(filepath.Join(docsDir, "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list existing CLI docs: %w", err)
|
||||||
|
}
|
||||||
|
for _, f := range mdFiles {
|
||||||
|
if err = os.Remove(f); err != nil {
|
||||||
|
return fmt.Errorf("remove '%s': %w", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new CLI reference docs.
|
||||||
|
wrapper.cmd.Root().DisableAutoGenTag = true
|
||||||
|
if err := doc.GenMarkdownTree(cmd.Root(), docsDir); err != nil {
|
||||||
|
return fmt.Errorf("generate CLI docs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove *completion*.md files that contain malformatted code blocks that break Docusaurus.
|
||||||
|
mdFiles, err = filepath.Glob(filepath.Join(docsDir, "*completion*.md"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list generated CLI docs: %w", err)
|
||||||
|
}
|
||||||
|
for _, f := range mdFiles {
|
||||||
|
if err = os.Remove(f); err != nil {
|
||||||
|
return fmt.Errorf("remove '%s': %w", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-process generated markdown files.
|
||||||
|
mdFiles, err = filepath.Glob(filepath.Join(docsDir, "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list generated CLI docs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range mdFiles {
|
||||||
|
if err = postProcessMarkdown(f); err != nil {
|
||||||
|
return fmt.Errorf("post-process '%s': %w", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.cmd = cmd
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// postProcessMarkdown applies transformations to generated markdown files.
|
||||||
|
func postProcessMarkdown(filename string) error {
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
// Replace "SEE ALSO" with "See also".
|
||||||
|
content = strings.ReplaceAll(content, "SEE ALSO", "See also")
|
||||||
|
// Escape <id> to avoid Docusaurus treating it as an HTML tag.
|
||||||
|
content = strings.ReplaceAll(content, "<id>", "\\<id>")
|
||||||
|
|
||||||
|
// Remove broken links to completion docs.
|
||||||
|
if strings.Contains(content, "[uc completion") {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
var filteredLines []string
|
||||||
|
for _, line := range lines {
|
||||||
|
if !strings.Contains(line, "[uc completion") {
|
||||||
|
filteredLines = append(filteredLines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = strings.Join(filteredLines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust heading levels. Process from shortest to longest to avoid double replacements.
|
||||||
|
replacements := []struct {
|
||||||
|
old, new string
|
||||||
|
}{
|
||||||
|
{`(?m)^## `, `# `},
|
||||||
|
{`(?m)^### `, `## `},
|
||||||
|
{`(?m)^#### `, `### `},
|
||||||
|
{`(?m)^##### `, `#### `},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range replacements {
|
||||||
|
re := regexp.MustCompile(r.old)
|
||||||
|
content = re.ReplaceAllString(content, r.new)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filename, []byte(content), 0o644)
|
||||||
|
}
|
||||||
+150
-11
@@ -1,16 +1,31 @@
|
|||||||
package machine
|
package machine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/cli"
|
"google.golang.org/grpc/codes"
|
||||||
"uncloud/internal/cli/config"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type addOptions struct {
|
type addOptions struct {
|
||||||
name string
|
name string
|
||||||
sshKey string
|
noCaddy bool
|
||||||
cluster string
|
publicIP string
|
||||||
|
sshKey string
|
||||||
|
context string
|
||||||
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAddCommand() *cobra.Command {
|
func NewAddCommand() *cobra.Command {
|
||||||
@@ -26,24 +41,148 @@ func NewAddCommand() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
remoteMachine := cli.RemoteMachine{
|
remoteMachine := &cli.RemoteMachine{
|
||||||
User: user,
|
User: user,
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
return uncli.AddMachine(cmd.Context(), remoteMachine, opts.cluster, opts.name)
|
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine")
|
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine.")
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&opts.noCaddy, "no-caddy", false,
|
||||||
|
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&opts.publicIP, "public-ip", "auto",
|
||||||
|
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||||
|
fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone),
|
||||||
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "",
|
||||||
"path to SSH private key for SSH remote login (default ~/.ssh/id_*)",
|
fmt.Sprintf("Path to SSH private key for remote login (if not already added to SSH agent). (default %q)",
|
||||||
|
cli.DefaultSSHKeyPath),
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&opts.version, "version", "latest",
|
||||||
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster to add the machine to (default is the current cluster)",
|
"Name of the cluster context to add the machine to. (default is the current context)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts addOptions) error {
|
||||||
|
var publicIP *netip.Addr
|
||||||
|
switch opts.publicIP {
|
||||||
|
case "auto":
|
||||||
|
publicIP = &netip.Addr{}
|
||||||
|
case "", PublicIPNone:
|
||||||
|
publicIP = nil
|
||||||
|
default:
|
||||||
|
ip, err := netip.ParseAddr(opts.publicIP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse public IP: %w", err)
|
||||||
|
}
|
||||||
|
publicIP = &ip
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, machineClient, err := uncli.AddMachine(ctx, cli.AddMachineOptions{
|
||||||
|
Context: opts.context,
|
||||||
|
MachineName: opts.name,
|
||||||
|
PublicIP: publicIP,
|
||||||
|
RemoteMachine: remoteMachine,
|
||||||
|
Version: opts.version,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
defer machineClient.Close()
|
||||||
|
|
||||||
|
if opts.noCaddy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the cluster to be initialised to be able to deploy the Caddy service.
|
||||||
|
fmt.Println("Waiting for the machine to be ready...")
|
||||||
|
fmt.Println()
|
||||||
|
if err = waitClusterInitialised(ctx, machineClient); err != nil {
|
||||||
|
return fmt.Errorf("wait for cluster to be initialised on machine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||||
|
// use the deployed image version. Otherwise, use the latest version.
|
||||||
|
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
|
||||||
|
// issues accessing the Machine API of existing machines in the cluster.
|
||||||
|
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
|
||||||
|
caddyImage := ""
|
||||||
|
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
return fmt.Errorf("inspect caddy service: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
caddyImage = caddySvc.Containers[0].Container.Config.Image
|
||||||
|
// Find the latest created container and use its image.
|
||||||
|
var latestCreated time.Time
|
||||||
|
for _, c := range caddySvc.Containers[1:] {
|
||||||
|
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if created.After(latestCreated) {
|
||||||
|
latestCreated = created
|
||||||
|
caddyImage = c.Container.Config.Image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||||
|
// that may cause a small downtime.
|
||||||
|
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = d.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitClusterInitialised(ctx context.Context, client *client.Client) error {
|
||||||
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(5*time.Minute),
|
||||||
|
), ctx)
|
||||||
|
|
||||||
|
check := func() error {
|
||||||
|
_, err := client.ListMachines(ctx, nil)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
statusErr := status.Convert(err)
|
||||||
|
if statusErr.Code() == codes.FailedPrecondition {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return backoff.Permanent(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return backoff.Retry(check, boff)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PublicIPNone is the value used to indicate removal of public IP
|
||||||
|
PublicIPNone = "none"
|
||||||
|
)
|
||||||
+127
-27
@@ -1,30 +1,41 @@
|
|||||||
package machine
|
package machine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"uncloud/internal/cli"
|
|
||||||
"uncloud/internal/cli/config"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
type initOptions struct {
|
type initOptions struct {
|
||||||
name string
|
dnsEndpoint string
|
||||||
network string
|
name string
|
||||||
userPublicKey string
|
network string
|
||||||
|
noCaddy bool
|
||||||
sshKey string
|
noDNS bool
|
||||||
cluster string
|
publicIP string
|
||||||
|
sshKey string
|
||||||
|
version string
|
||||||
|
context string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInitCommand() *cobra.Command {
|
func NewInitCommand() *cobra.Command {
|
||||||
opts := initOptions{}
|
opts := initOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init [USER@HOST:PORT]",
|
Use: "init [USER@HOST:PORT]",
|
||||||
|
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||||
|
// TODO: include usage examples of initialising a remote machine.
|
||||||
|
// TODO: support initialising a cluster on the local machine.
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
// TODO: include usage examples of initialising a local and remote machine.
|
|
||||||
Short: "Initialise a new cluster that consists of the local or remote machine.",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
@@ -41,30 +52,119 @@ func NewInitCommand() *cobra.Command {
|
|||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
netPrefix, err := netip.ParsePrefix(opts.network)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse network CIDR: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return uncli.InitCluster(cmd.Context(), remoteMachine, opts.cluster, opts.name, netPrefix)
|
return initCluster(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine")
|
cmd.Flags().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
||||||
|
"API endpoint for the Uncloud DNS service.")
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.name, "name", "n", "",
|
||||||
|
"Assign a name to the machine.",
|
||||||
|
)
|
||||||
cmd.Flags().StringVar(
|
cmd.Flags().StringVar(
|
||||||
&opts.network, "network", network.DefaultNetwork.String(),
|
&opts.network, "network", cluster.DefaultNetwork.String(),
|
||||||
"IPv4 network CIDR to use for machines and services",
|
"IPv4 network CIDR to use for machines and services.",
|
||||||
|
)
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&opts.noCaddy, "no-caddy", false,
|
||||||
|
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||||
|
)
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&opts.noDNS, "no-dns", false,
|
||||||
|
"Don't reserve a cluster domain in Uncloud DNS.",
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&opts.publicIP, "public-ip", "auto",
|
||||||
|
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||||
|
fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone),
|
||||||
)
|
)
|
||||||
//cmd.Flags().StringVar(&opts.userPublicKey, "user-pubkey", "",
|
|
||||||
// "User's public key which will be able to access the cluster (hex-encoded)")
|
|
||||||
|
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.sshKey, "ssh-key", "i", "",
|
&opts.sshKey, "ssh-key", "i", "",
|
||||||
"path to SSH private key for SSH remote login (default ~/.ssh/id_*)",
|
fmt.Sprintf("Path to SSH private key for remote login (if not already added to SSH agent). (default %q)",
|
||||||
|
cli.DefaultSSHKeyPath),
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&opts.version, "version", "latest",
|
||||||
|
"Version of the Uncloud daemon to install on the machine.",
|
||||||
)
|
)
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.cluster, "cluster", "c", "",
|
&opts.context, "context", "c", "default",
|
||||||
"Name of the cluster in the local config if initialising a remote machine",
|
"Name of the created context for the initialised cluster in the Uncloud config.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
||||||
|
netPrefix, err := netip.ParsePrefix(opts.network)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse network CIDR: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicIP *netip.Addr
|
||||||
|
switch opts.publicIP {
|
||||||
|
case "auto":
|
||||||
|
publicIP = &netip.Addr{}
|
||||||
|
case "", PublicIPNone:
|
||||||
|
publicIP = nil
|
||||||
|
default:
|
||||||
|
ip, err := netip.ParseAddr(opts.publicIP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse public IP: %w", err)
|
||||||
|
}
|
||||||
|
publicIP = &ip
|
||||||
|
}
|
||||||
|
client, err := uncli.InitCluster(ctx, cli.InitClusterOptions{
|
||||||
|
Context: opts.context,
|
||||||
|
MachineName: opts.name,
|
||||||
|
Network: netPrefix,
|
||||||
|
PublicIP: publicIP,
|
||||||
|
RemoteMachine: remoteMachine,
|
||||||
|
Version: opts.version,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if opts.noCaddy && opts.noDNS {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy the Caddy service to the initialised machine.
|
||||||
|
// The creation of a deployment plan talks to cluster API. Since the API needs a few moments to become available
|
||||||
|
// after cluster initialisation, we keep the user informed during this wait.
|
||||||
|
fmt.Println("Waiting for the machine to be ready...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if !opts.noDNS {
|
||||||
|
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.noCaddy {
|
||||||
|
d, err := client.NewCaddyDeployment("", "", api.Placement{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create caddy deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = d.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy caddy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package machine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"uncloud/internal/cli"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewListCommand() *cobra.Command {
|
|
||||||
opts := addOptions{}
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List machines in a cluster",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
|
||||||
return uncli.ListMachines(cmd.Context(), opts.cluster)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVarP(
|
|
||||||
&opts.cluster, "cluster", "c", "",
|
|
||||||
"Name of the cluster (default is the current cluster)",
|
|
||||||
)
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
var contextName string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List machines in a cluster.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(cmd.Context(), uncli, contextName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&contextName, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, clusterName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
machines, err := client.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the list of machines in a table format.
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
// Print header.
|
||||||
|
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
|
||||||
|
return fmt.Errorf("write header: %w", err)
|
||||||
|
}
|
||||||
|
// Print rows.
|
||||||
|
for _, member := range machines {
|
||||||
|
m := member.Machine
|
||||||
|
subnet, _ := m.Network.Subnet.ToPrefix()
|
||||||
|
subnet = netip.PrefixFrom(network.MachineIP(subnet), subnet.Bits())
|
||||||
|
|
||||||
|
publicIP := "-"
|
||||||
|
if m.PublicIp != nil {
|
||||||
|
ip, _ := m.PublicIp.ToAddr()
|
||||||
|
publicIP = ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoints := make([]string, len(m.Network.Endpoints))
|
||||||
|
for i, ep := range m.Network.Endpoints {
|
||||||
|
addrPort, _ := ep.ToAddrPort()
|
||||||
|
endpoints[i] = addrPort.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = fmt.Fprintf(
|
||||||
|
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||||
|
strings.Join(endpoints, ", "),
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("write row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// capitalise returns a string where the first character is upper case, and the rest is lower case.
|
||||||
|
func capitalise(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRenameCommand() *cobra.Command {
|
||||||
|
var contextName string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rename OLD_NAME NEW_NAME",
|
||||||
|
Short: "Rename a machine in the cluster.",
|
||||||
|
Long: `Rename a machine in the cluster.
|
||||||
|
|
||||||
|
This command changes the name of an existing machine while preserving all other
|
||||||
|
configuration including network settings, public IP, and cluster membership.`,
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return rename(cmd.Context(), uncli, contextName, args[0], args[1])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&contextName, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func rename(ctx context.Context, uncli *cli.CLI, contextName, oldName, newName string) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, contextName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
machine, err := client.RenameMachine(ctx, oldName, newName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rename machine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Machine %q renamed to %q (ID: %s)\n", oldName, machine.Name, machine.Id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/tree"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type removeOptions struct {
|
||||||
|
noReset bool
|
||||||
|
yes bool
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRmCommand() *cobra.Command {
|
||||||
|
opts := removeOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm MACHINE",
|
||||||
|
Aliases: []string{"remove", "delete"},
|
||||||
|
Short: "Remove a machine from a cluster and reset it.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return remove(cmd.Context(), uncli, args[0], opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
|
"Do not prompt for confirmation before removing the machine.")
|
||||||
|
cmd.Flags().BoolVar(&opts.noReset, "no-reset", false,
|
||||||
|
"Do not reset the machine after removing it from the cluster. This will leave all containers and data intact.")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOptions) error {
|
||||||
|
// TODO: automatically choose a connection to the machine that is not being removed.
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Verify the machine exists and list all service containers on it including stopped ones.
|
||||||
|
mctx, machines, err := api.ProxyMachinesContext(ctx, client, []string{nameOrID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(machines) == 0 {
|
||||||
|
return fmt.Errorf("machine '%s' not found in the cluster", nameOrID)
|
||||||
|
}
|
||||||
|
m := machines[0].Machine
|
||||||
|
|
||||||
|
// Verify if the machine being removed is the proxy machine we're connected to.
|
||||||
|
proxyMachine, err := client.MachineClient.Inspect(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect proxy machine: %w", err)
|
||||||
|
}
|
||||||
|
if proxyMachine.Id == m.Id {
|
||||||
|
allMachines, err := client.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
if len(allMachines) > 1 {
|
||||||
|
return errors.New("cannot remove the machine you are currently connected to. " +
|
||||||
|
"Please connect to another machine in the cluster and try again. " +
|
||||||
|
"Use --connect flag or update 'connections' for the cluster context in your Uncloud config")
|
||||||
|
// It's ok to remove the proxy machine if it's the last one in the cluster.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: mark the machine as being removed and unschedulable when this is possible to prevent new containers
|
||||||
|
// from being scheduled on it while the removal is in progress.
|
||||||
|
|
||||||
|
reset := !opts.noReset
|
||||||
|
var containers []api.ServiceContainer
|
||||||
|
reachable := false
|
||||||
|
if reset {
|
||||||
|
// Check if the machine is up and has service containers.
|
||||||
|
listOpts := container.ListOptions{All: true}
|
||||||
|
machineContainers, err := client.Docker.ListServiceContainers(mctx, "", listOpts)
|
||||||
|
if err == nil {
|
||||||
|
reachable = true
|
||||||
|
containers = machineContainers[0].Containers
|
||||||
|
if len(containers) > 0 {
|
||||||
|
plural := ""
|
||||||
|
if len(containers) > 1 {
|
||||||
|
plural = "s"
|
||||||
|
}
|
||||||
|
fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name)
|
||||||
|
fmt.Println(formatContainerTree(containers))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("This will remove all service containers from the machine, remove it from the cluster, " +
|
||||||
|
"and reset it to the uninitialised state.")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("No service containers found on machine '%s'.\n", m.Name)
|
||||||
|
fmt.Println("This will remove the machine from the cluster and reset it to the uninitialised state.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("This will remove machine '%s' from the cluster without resetting it as it's unreachable.\n",
|
||||||
|
m.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("This will remove machine '%s' from the cluster without resetting it.\n", m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.yes {
|
||||||
|
confirmed, err := cli.Confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm removal: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. Machine was not removed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reset && len(containers) > 0 {
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
return removeContainers(ctx, client, containers)
|
||||||
|
}, uncli.ProgressOut(), "Removing containers")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove containers: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = client.RemoveMachine(ctx, &pb.RemoveMachineRequest{Id: m.Id}); err != nil {
|
||||||
|
return fmt.Errorf("remove machine from cluster: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
|
||||||
|
|
||||||
|
if reset && reachable {
|
||||||
|
_, err = client.MachineClient.Reset(mctx, &pb.ResetRequest{})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("WARNING: Failed to reset machine: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Machine reset initiated and will complete in the background.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove the connection to the machine from the uncloud config if it exists. We need a way to associate
|
||||||
|
// the machine with its connection in the config, e.g. by storing the machine name in the connection metadata.
|
||||||
|
|
||||||
|
// TODO: If Caddy was running on this machine and a cluster domain is reserved,
|
||||||
|
// let the user know that the DNS records should be updated.
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatContainerTree formats a list of containers grouped by service as a tree structure.
|
||||||
|
func formatContainerTree(containers []api.ServiceContainer) string {
|
||||||
|
if len(containers) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group containers by service.
|
||||||
|
serviceContainers := make(map[string][]api.ServiceContainer)
|
||||||
|
for _, ctr := range containers {
|
||||||
|
serviceName := ctr.ServiceName()
|
||||||
|
serviceContainers[serviceName] = append(serviceContainers[serviceName], ctr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree output.
|
||||||
|
var output []string
|
||||||
|
serviceNames := slices.Sorted(maps.Keys(serviceContainers))
|
||||||
|
for _, serviceName := range serviceNames {
|
||||||
|
ctrs := serviceContainers[serviceName]
|
||||||
|
mode := ctrs[0].ServiceMode()
|
||||||
|
|
||||||
|
// Format a tree for the service with its containers.
|
||||||
|
plural := ""
|
||||||
|
if len(ctrs) > 1 {
|
||||||
|
plural = "s"
|
||||||
|
}
|
||||||
|
t := tree.Root(fmt.Sprintf("• %s (%s, %d container%s)", serviceName, mode, len(ctrs), plural)).
|
||||||
|
EnumeratorStyle(lipgloss.NewStyle().MarginLeft(2).MarginRight(1))
|
||||||
|
|
||||||
|
// Add containers as children.
|
||||||
|
for _, ctr := range ctrs {
|
||||||
|
state, _ := ctr.HumanState()
|
||||||
|
info := fmt.Sprintf("%s • %s • %s", ctr.Name, ctr.Config.Image, state)
|
||||||
|
t.Child(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
output = append(output, t.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(output, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeContainers removes the given service containers from the machine.
|
||||||
|
func removeContainers(ctx context.Context, client api.Client, containers []api.ServiceContainer) error {
|
||||||
|
if len(containers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
errCh := make(chan error)
|
||||||
|
|
||||||
|
for _, ctr := range containers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(c api.ServiceContainer) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Gracefully stop the container before removing it.
|
||||||
|
err := client.StopContainer(ctx, c.ServiceID(), c.ID, container.StopOptions{})
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
errCh <- fmt.Errorf("stop container '%s': %w", c.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.RemoveContainer(ctx, c.ServiceID(), c.ID, container.RemoveOptions{
|
||||||
|
// Remove anonymous volumes created by the container.
|
||||||
|
RemoveVolumes: true,
|
||||||
|
})
|
||||||
|
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||||
|
errCh <- fmt.Errorf("remove container '%s': %w", c.ID, err)
|
||||||
|
}
|
||||||
|
}(ctr)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
for e := range errCh {
|
||||||
|
err = errors.Join(err, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -6,13 +6,17 @@ import (
|
|||||||
|
|
||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "machine",
|
Use: "machine",
|
||||||
Short: "Manage machines in an Uncloud cluster.",
|
Aliases: []string{"m"},
|
||||||
|
Short: "Manage machines in an Uncloud cluster.",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
NewAddCommand(),
|
NewAddCommand(),
|
||||||
NewInitCommand(),
|
NewInitCommand(),
|
||||||
NewListCommand(),
|
NewListCommand(),
|
||||||
|
NewRenameCommand(),
|
||||||
|
NewRmCommand(),
|
||||||
|
NewUpdateCommand(),
|
||||||
NewTokenCommand(),
|
NewTokenCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package machine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"uncloud/internal/daemon"
|
|
||||||
"uncloud/internal/machine"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenOptions struct {
|
type tokenOptions struct {
|
||||||
@@ -31,7 +32,7 @@ func NewTokenCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&opts.dataDir, "data-dir", "d", machine.DefaultDataDir,
|
cmd.Flags().StringVarP(&opts.dataDir, "data-dir", "d", machine.DefaultDataDir,
|
||||||
"Directory for storing persistent machine state")
|
"Directory for storing persistent machine state.")
|
||||||
_ = cmd.MarkFlagDirname("data-dir")
|
_ = cmd.MarkFlagDirname("data-dir")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type updateOptions struct {
|
||||||
|
name string
|
||||||
|
publicIP string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateCommand() *cobra.Command {
|
||||||
|
opts := updateOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Update machine configuration in the cluster.",
|
||||||
|
Long: `Update machine configuration in the cluster.
|
||||||
|
|
||||||
|
This command allows setting various machine properties including:
|
||||||
|
- Machine name (--name)
|
||||||
|
- Public IP address (--public-ip)
|
||||||
|
|
||||||
|
At least one flag must be specified to perform an update operation.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return update(cmd.Context(), uncli, cmd, opts, args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&opts.name, "name", "",
|
||||||
|
"New name for the machine",
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&opts.publicIP, "public-ip", "",
|
||||||
|
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.", PublicIPNone),
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts updateOptions, machineNameOrID string) error {
|
||||||
|
// Check if at least one flag was explicitly set
|
||||||
|
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") {
|
||||||
|
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip)")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// First, resolve the machine to get its ID
|
||||||
|
machine, err := client.InspectMachine(ctx, machineNameOrID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find machine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the update request
|
||||||
|
req := &pb.UpdateMachineRequest{
|
||||||
|
MachineId: machine.Machine.Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.name != "" {
|
||||||
|
req.Name = &opts.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if --public-ip flag was explicitly provided
|
||||||
|
if cmd.Flags().Changed("public-ip") {
|
||||||
|
if opts.publicIP == "" || opts.publicIP == PublicIPNone {
|
||||||
|
req.PublicIp = &pb.IP{} // Empty IP to signal removal
|
||||||
|
} else {
|
||||||
|
// Parse and validate the public IP
|
||||||
|
ip, err := netip.ParseAddr(opts.publicIP)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid public IP address %q: %w", opts.publicIP, err)
|
||||||
|
}
|
||||||
|
req.PublicIp = pb.NewIP(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the update operation
|
||||||
|
updatedMachine, err := client.UpdateMachine(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update machine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report what was changed
|
||||||
|
changes := make([]string, 0)
|
||||||
|
if opts.name != "" {
|
||||||
|
changes = append(changes, fmt.Sprintf("name: %q -> %q", machine.Machine.Name, updatedMachine.Name))
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("public-ip") {
|
||||||
|
oldIP := PublicIPNone
|
||||||
|
if machine.Machine.PublicIp != nil {
|
||||||
|
if addr, err := machine.Machine.PublicIp.ToAddr(); err == nil {
|
||||||
|
oldIP = addr.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newIP := PublicIPNone
|
||||||
|
if updatedMachine.PublicIp != nil {
|
||||||
|
if addr, err := updatedMachine.PublicIp.ToAddr(); err == nil {
|
||||||
|
newIP = addr.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Machine %q (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
|
||||||
|
for _, change := range changes {
|
||||||
|
fmt.Printf(" %s\n", change)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+63
-16
@@ -3,44 +3,91 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/cmd/uncloud/machine"
|
|
||||||
"uncloud/internal/cli"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
|
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
|
"github.com/psviderski/uncloud/internal/version"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type globalOptions struct {
|
||||||
|
configPath string
|
||||||
|
connect string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
opts := globalOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uncloud",
|
Use: "uc",
|
||||||
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.",
|
||||||
|
Version: version.String(),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if strings.HasPrefix(configPath, "~/") {
|
var conn *config.MachineConnection
|
||||||
home, err := os.UserHomeDir()
|
if opts.connect != "" {
|
||||||
if err != nil {
|
if strings.HasPrefix(opts.connect, "tcp://") {
|
||||||
return fmt.Errorf("get user home directory to resolve %q: %w", configPath, err)
|
addrPort, err := netip.ParseAddrPort(opts.connect[len("tcp://"):])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse TCP address: %w", err)
|
||||||
|
}
|
||||||
|
conn = &config.MachineConnection{
|
||||||
|
TCP: &addrPort,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dest := opts.connect
|
||||||
|
if strings.HasPrefix(dest, "ssh://") {
|
||||||
|
dest = dest[len("ssh://"):]
|
||||||
|
}
|
||||||
|
conn = &config.MachineConnection{
|
||||||
|
SSH: config.SSHDestination(dest),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
configPath = strings.Replace(configPath, "~", home, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uncli, err := cli.New(configPath)
|
configPath := fs.ExpandHomeDir(opts.configPath)
|
||||||
|
uncli, err := cli.New(configPath, conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialize CLI: %w", err)
|
return fmt.Errorf("initialise CLI: %w", err)
|
||||||
}
|
}
|
||||||
cmd.SetContext(context.WithValue(cmd.Context(), "cli", uncli))
|
cmd.SetContext(context.WithValue(cmd.Context(), "cli", uncli))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
|
"Connect to a remote cluster machine without using the Uncloud configuration file.\n"+
|
||||||
|
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
||||||
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
// TODO: allow to override using UNCLOUD_CONFIG env var.
|
||||||
cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"path to the Uncloud configuration file")
|
"Path to the Uncloud configuration file.")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
|
// TODO: make --context a global flag and pass it as a value of the command context.
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
NewDeployCommand(),
|
||||||
|
NewDocsCommand(),
|
||||||
|
NewBuildCommand(),
|
||||||
|
caddy.NewRootCommand(),
|
||||||
|
cmdcontext.NewRootCommand(),
|
||||||
|
dns.NewRootCommand(),
|
||||||
machine.NewRootCommand(),
|
machine.NewRootCommand(),
|
||||||
|
service.NewRootCommand(),
|
||||||
|
service.NewInspectCommand(),
|
||||||
|
service.NewListCommand(),
|
||||||
|
service.NewRmCommand(),
|
||||||
|
service.NewRunCommand(),
|
||||||
|
service.NewScaleCommand(),
|
||||||
|
volume.NewRootCommand(),
|
||||||
)
|
)
|
||||||
cobra.CheckErr(cmd.Execute())
|
cobra.CheckErr(cmd.Execute())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inspectOptions struct {
|
||||||
|
service string
|
||||||
|
cluster string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInspectCommand() *cobra.Command {
|
||||||
|
opts := inspectOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "inspect SERVICE",
|
||||||
|
Short: "Display detailed information on a service.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
opts.service = args[0]
|
||||||
|
return inspect(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
svc, err := client.InspectService(ctx, opts.service)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
machines, err := client.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
machinesNamesByID := make(map[string]string)
|
||||||
|
for _, m := range machines {
|
||||||
|
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("ID: %s\n", svc.ID)
|
||||||
|
fmt.Printf("Name: %s\n", svc.Name)
|
||||||
|
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Print the list of containers in a table format.
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
|
||||||
|
return fmt.Errorf("write header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse created time: %w", err)
|
||||||
|
}
|
||||||
|
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||||
|
|
||||||
|
machine := machinesNamesByID[ctr.MachineID]
|
||||||
|
if machine == "" {
|
||||||
|
machine = ctr.MachineID
|
||||||
|
}
|
||||||
|
state, err := ctr.Container.HumanState()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get human state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(
|
||||||
|
tw,
|
||||||
|
"%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
stringid.TruncateID(ctr.Container.ID),
|
||||||
|
ctr.Container.Config.Image,
|
||||||
|
created,
|
||||||
|
state,
|
||||||
|
machine,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
// TODO(lhf): rename to context
|
||||||
|
var cluster string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List services.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(cmd.Context(), uncli, cluster)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, clusterName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
services, err := client.ListServices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list services: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceNames := make(map[string]struct{}, len(services))
|
||||||
|
haveDuplicateNames := false
|
||||||
|
for _, svc := range services {
|
||||||
|
if _, exists := serviceNames[svc.Name]; exists {
|
||||||
|
haveDuplicateNames = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
serviceNames[svc.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the list of services in a table format.
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
|
||||||
|
// Include the ID column if there are duplicate service names to differentiate them.
|
||||||
|
if haveDuplicateNames {
|
||||||
|
if _, err = fmt.Fprintf(tw, "ID\t"); err != nil {
|
||||||
|
return fmt.Errorf("write header: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
|
||||||
|
return fmt.Errorf("write header: %w", err)
|
||||||
|
}
|
||||||
|
for _, s := range services {
|
||||||
|
endpointsSlice := s.Endpoints()
|
||||||
|
endpoints := strings.Join(endpointsSlice, ", ")
|
||||||
|
|
||||||
|
if haveDuplicateNames {
|
||||||
|
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
||||||
|
return fmt.Errorf("write row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
|
||||||
|
return fmt.Errorf("write row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rmOptions struct {
|
||||||
|
services []string
|
||||||
|
cluster string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRmCommand() *cobra.Command {
|
||||||
|
opts := rmOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm SERVICE [SERVICE...]",
|
||||||
|
Aliases: []string{"remove", "delete"},
|
||||||
|
Short: "Remove one or more services.",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
opts.services = args
|
||||||
|
return rm(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
for _, s := range opts.services {
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if err = client.RemoveService(ctx, s); err != nil {
|
||||||
|
return fmt.Errorf("remove service '%s': %w", s, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), "Removing service "+s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "service",
|
||||||
|
Aliases: []string{"svc"},
|
||||||
|
Short: "Manage services in an Uncloud cluster.",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewInspectCommand(),
|
||||||
|
NewListCommand(),
|
||||||
|
NewRmCommand(),
|
||||||
|
NewRunCommand(),
|
||||||
|
NewScaleCommand(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
dockeropts "github.com/docker/cli/opts"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/docker/docker/daemon/names"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/deploy"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runOptions struct {
|
||||||
|
caddyfile string
|
||||||
|
command []string
|
||||||
|
cpu dockeropts.NanoCPUs
|
||||||
|
entrypoint string
|
||||||
|
entrypointChanged bool
|
||||||
|
env []string
|
||||||
|
image string
|
||||||
|
machines []string
|
||||||
|
memory dockeropts.MemBytes
|
||||||
|
mode string
|
||||||
|
name string
|
||||||
|
privileged bool
|
||||||
|
publish []string
|
||||||
|
pull string
|
||||||
|
replicas uint
|
||||||
|
user string
|
||||||
|
volumes []string
|
||||||
|
|
||||||
|
cluster string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRunCommand() *cobra.Command {
|
||||||
|
opts := runOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "run IMAGE [COMMAND...]",
|
||||||
|
Short: "Run a service.",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
opts.entrypointChanged = cmd.Flag("entrypoint").Changed
|
||||||
|
opts.image = args[0]
|
||||||
|
if len(args) > 1 {
|
||||||
|
opts.command = args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return run(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&opts.caddyfile, "caddyfile", "",
|
||||||
|
"Path to a custom Caddy config (Caddyfile) for the service. "+
|
||||||
|
"Cannot be used together with non-@host published ports.")
|
||||||
|
cmd.Flags().VarP(&opts.cpu, "cpu", "",
|
||||||
|
"Maximum number of CPU cores a service container can use. Fractional values are allowed: "+
|
||||||
|
"0.5 for half a core or 2.25 for two and a quarter cores.")
|
||||||
|
cmd.Flags().StringVar(&opts.entrypoint, "entrypoint", "",
|
||||||
|
"Overwrite the default ENTRYPOINT of the image. Pass an empty string \"\" to reset it.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.env, "env", "e", nil,
|
||||||
|
"Set an environment variable for service containers. Can be specified multiple times.\n"+
|
||||||
|
"Format: VAR=value or just VAR to use the value from the local environment.")
|
||||||
|
cmd.Flags().StringVar(&opts.mode, "mode", api.ServiceModeReplicated,
|
||||||
|
fmt.Sprintf("Replication mode of the service: either '%s' (a specified number of containers across "+
|
||||||
|
"the machines) or '%s' (one container on every machine).",
|
||||||
|
api.ServiceModeReplicated, api.ServiceModeGlobal))
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
|
"Placement constraint by machine names, limiting which machines the service can run on. Can be specified "+
|
||||||
|
"multiple times or as a comma-separated list of machine names. (default is any suitable machine)")
|
||||||
|
cmd.Flags().VarP(&opts.memory, "memory", "",
|
||||||
|
"Maximum amount of memory a service container can use. Value is a positive integer with optional unit suffix "+
|
||||||
|
"(b, k, m, g). Default unit is bytes if no suffix specified.\n"+
|
||||||
|
"Examples: 1073741824, 1024m, 1g (all equal 1 gibibyte)")
|
||||||
|
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
||||||
|
"Assign a name to the service. A random name is generated if not specified.")
|
||||||
|
cmd.Flags().BoolVar(&opts.privileged, "privileged", false,
|
||||||
|
"Give extended privileges to service containers. This is a security risk and should be used with caution.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.publish, "publish", "p", nil,
|
||||||
|
"Publish a service port to make it accessible outside the cluster. Can be specified multiple times.\n"+
|
||||||
|
"Format: [hostname:]container_port[/protocol] or [host_ip:]host_port:container_port[/protocol]@host\n"+
|
||||||
|
"Supported protocols: tcp, udp, http, https (default is tcp). If a hostname for http(s) port is not specified\n"+
|
||||||
|
"and a cluster domain is reserved, service-name.cluster-domain will be used as the hostname.\n"+
|
||||||
|
"Examples:\n"+
|
||||||
|
" -p 8080/https Publish port 8080 as HTTPS via reverse proxy with default service-name.cluster-domain hostname\n"+
|
||||||
|
" -p app.example.com:8080/https Publish port 8080 as HTTPS via reverse proxy with custom hostname\n"+
|
||||||
|
// TODO: add support for publishing L4 tcp/udp ports.
|
||||||
|
//" -p 9000:8080 Publish port 8080 as TCP port 9000 via reverse proxy\n"+
|
||||||
|
" -p 53:5353/udp@host Bind UDP port 5353 to host port 53")
|
||||||
|
cmd.Flags().StringVar(&opts.pull, "pull", api.PullPolicyMissing,
|
||||||
|
fmt.Sprintf("Pull image from the registry before running service containers ('%s', '%s', '%s').",
|
||||||
|
api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever))
|
||||||
|
cmd.Flags().UintVar(&opts.replicas, "replicas", 1,
|
||||||
|
"Number of containers to run for the service. Only valid for a replicated service.")
|
||||||
|
cmd.Flags().StringVarP(&opts.user, "user", "u", "",
|
||||||
|
"User name or UID and optionally group name or GID used for running the command inside service containers.\n"+
|
||||||
|
"Format: USER[:GROUP] or UID[:GID]. If not specified, the user is set to the default user of the image.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.volumes, "volume", "v", nil,
|
||||||
|
"Mount a data volume or host path into service containers. Service containers will be scheduled on the machine(s) where\n"+
|
||||||
|
"the volume is located. Can be specified multiple times.\n"+
|
||||||
|
"Format: volume_name:/container/path[:ro|volume-nocopy] or /host/path:/container/path[:ro]\n"+
|
||||||
|
"Examples:\n"+
|
||||||
|
" -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+
|
||||||
|
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
|
||||||
|
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context to run the service in. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, uncli *cli.CLI, opts runOptions) error {
|
||||||
|
spec, err := prepareServiceSpec(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
var resp api.RunServiceResponse
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
resp, err = clusterClient.RunService(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Running service %s (%s mode)", spec.Name, spec.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := clusterClient.InspectService(ctx, resp.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoints := svc.Endpoints()
|
||||||
|
if len(endpoints) > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("%s endpoints:\n", svc.Name)
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
fmt.Printf(" • %s\n", endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
|
||||||
|
var spec api.ServiceSpec
|
||||||
|
|
||||||
|
caddyfile := ""
|
||||||
|
if opts.caddyfile != "" {
|
||||||
|
data, err := os.ReadFile(opts.caddyfile)
|
||||||
|
if err != nil {
|
||||||
|
return spec, fmt.Errorf("read Caddyfile: %w", err)
|
||||||
|
}
|
||||||
|
caddyfile = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := parseEnv(opts.env)
|
||||||
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch opts.mode {
|
||||||
|
case api.ServiceModeReplicated, api.ServiceModeGlobal:
|
||||||
|
default:
|
||||||
|
return spec, fmt.Errorf("invalid replication mode: '%s'", opts.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch opts.pull {
|
||||||
|
case api.PullPolicyAlways, api.PullPolicyMissing, api.PullPolicyNever:
|
||||||
|
default:
|
||||||
|
return spec, fmt.Errorf("invalid pull policy: '%s'", opts.pull)
|
||||||
|
}
|
||||||
|
|
||||||
|
ports := make([]api.PortSpec, len(opts.publish))
|
||||||
|
for i, publishPort := range opts.publish {
|
||||||
|
port, err := api.ParsePortSpec(publishPort)
|
||||||
|
if err != nil {
|
||||||
|
return spec, fmt.Errorf("invalid service port '%s': %w", publishPort, err)
|
||||||
|
}
|
||||||
|
ports[i] = port
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes, mounts, err := parseVolumeFlags(opts.volumes)
|
||||||
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
placement := api.Placement{
|
||||||
|
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Command: opts.command,
|
||||||
|
Env: env,
|
||||||
|
Image: opts.image,
|
||||||
|
Privileged: opts.privileged,
|
||||||
|
PullPolicy: opts.pull,
|
||||||
|
Resources: api.ContainerResources{
|
||||||
|
CPU: opts.cpu.Value(),
|
||||||
|
Memory: opts.memory.Value(),
|
||||||
|
},
|
||||||
|
User: opts.user,
|
||||||
|
VolumeMounts: mounts,
|
||||||
|
},
|
||||||
|
Mode: opts.mode,
|
||||||
|
Name: opts.name,
|
||||||
|
Placement: placement,
|
||||||
|
Ports: ports,
|
||||||
|
Replicas: opts.replicas,
|
||||||
|
Volumes: volumes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if caddyfile != "" {
|
||||||
|
spec.Caddy = &api.CaddySpec{
|
||||||
|
Config: caddyfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite the default ENTRYPOINT of the image or reset it if an empty string is passed.
|
||||||
|
if opts.entrypoint != "" {
|
||||||
|
spec.Container.Entrypoint = []string{opts.entrypoint}
|
||||||
|
} else if opts.entrypointChanged {
|
||||||
|
spec.Container.Entrypoint = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = spec.Validate(); err != nil {
|
||||||
|
return spec, fmt.Errorf("invalid service configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a service name if not specified to be able to include it in the progress title.
|
||||||
|
if spec.Name == "" {
|
||||||
|
spec.Name, err = deploy.GenerateServiceName(spec.Container.Image)
|
||||||
|
if err != nil {
|
||||||
|
return spec, fmt.Errorf("generate service name: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEnv parses the environment variables from the command line arguments.
|
||||||
|
// It supports two formats: "VAR=value" or just "VAR" to use the value from the local environment.
|
||||||
|
func parseEnv(env []string) (api.EnvVars, error) {
|
||||||
|
envVars := make(api.EnvVars)
|
||||||
|
for _, e := range env {
|
||||||
|
key, value, hasValue := strings.Cut(e, "=")
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("invalid environment variable: '%s'", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasValue {
|
||||||
|
envVars[key] = value
|
||||||
|
} else {
|
||||||
|
if localEnvValue, ok := os.LookupEnv(key); ok {
|
||||||
|
envVars[key] = localEnvValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return envVars, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts.
|
||||||
|
// It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy])
|
||||||
|
// and bind mounts (/host/path:/container/path[:ro]).
|
||||||
|
func parseVolumeFlags(volumes []string) ([]api.VolumeSpec, []api.VolumeMount, error) {
|
||||||
|
specs := make([]api.VolumeSpec, 0, len(volumes))
|
||||||
|
mounts := make([]api.VolumeMount, 0, len(volumes))
|
||||||
|
|
||||||
|
// Track volume names to avoid duplicate specs.
|
||||||
|
seenVolumes := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, vol := range volumes {
|
||||||
|
spec, mount, err := parseVolumeFlagValue(vol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid volume mount '%s': %w", vol, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := seenVolumes[spec.Name]; !ok {
|
||||||
|
specs = append(specs, spec)
|
||||||
|
seenVolumes[spec.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
mounts = append(mounts, mount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return specs, mounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVolumeFlagValue(volume string) (api.VolumeSpec, api.VolumeMount, error) {
|
||||||
|
var spec api.VolumeSpec
|
||||||
|
var mount api.VolumeMount
|
||||||
|
|
||||||
|
parts := strings.Split(volume, ":")
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
return spec, mount, fmt.Errorf("invalid format, must contain at least one separator ':'")
|
||||||
|
case 2, 3:
|
||||||
|
// Format: (volume_name|/host/path):/container/path[:opts]
|
||||||
|
if !strings.HasPrefix(parts[1], "/") {
|
||||||
|
return spec, mount, fmt.Errorf("invalid container mount path: '%s', must be absolute path", parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
mount.ContainerPath = parts[1]
|
||||||
|
volumeNoCopy := false
|
||||||
|
|
||||||
|
if len(parts) == 3 {
|
||||||
|
opts := strings.Split(parts[2], ",")
|
||||||
|
for _, opt := range opts {
|
||||||
|
switch opt {
|
||||||
|
case "ro", "readonly":
|
||||||
|
mount.ReadOnly = true
|
||||||
|
case "volume-nocopy":
|
||||||
|
volumeNoCopy = true
|
||||||
|
default:
|
||||||
|
return spec, mount, fmt.Errorf("invalid option: '%s'", opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(parts[0], "/") {
|
||||||
|
// Host path bind mount: /host/path:/container/path
|
||||||
|
suffix, err := secret.RandomAlphaNumeric(4)
|
||||||
|
if err != nil {
|
||||||
|
return spec, mount, fmt.Errorf("generate random suffix: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = api.VolumeSpec{
|
||||||
|
Name: "bind-" + suffix,
|
||||||
|
Type: api.VolumeTypeBind,
|
||||||
|
BindOptions: &api.BindOptions{
|
||||||
|
HostPath: parts[0],
|
||||||
|
CreateHostPath: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Named volume mount: volume_name:/container/path
|
||||||
|
volumeName := parts[0]
|
||||||
|
if !names.RestrictedNamePattern.MatchString(volumeName) {
|
||||||
|
return spec, mount, fmt.Errorf("volume name '%s' includes invalid characters, only '%s' are allowed. "+
|
||||||
|
"If you intended to pass a host directory or file, use absolute path",
|
||||||
|
volumeName, names.RestrictedNameChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = api.VolumeSpec{
|
||||||
|
Name: volumeName,
|
||||||
|
Type: api.VolumeTypeVolume,
|
||||||
|
VolumeOptions: &api.VolumeOptions{
|
||||||
|
Name: volumeName,
|
||||||
|
NoCopy: volumeNoCopy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mount.VolumeName = spec.Name
|
||||||
|
default:
|
||||||
|
return spec, mount, fmt.Errorf("invalid format, must container at most 2 separators ':'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, mount, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scaleOptions struct {
|
||||||
|
service string
|
||||||
|
replicas uint
|
||||||
|
cluster string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScaleCommand() *cobra.Command {
|
||||||
|
opts := scaleOptions{}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "scale SERVICE REPLICAS",
|
||||||
|
Short: "Scale a replicated service by changing the number of replicas.",
|
||||||
|
Long: "Scale a replicated service by changing the number of replicas. Scaling down requires confirmation.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
opts.service = args[0]
|
||||||
|
replicas, err := strconv.ParseUint(args[1], 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid number of replicas: %w", err)
|
||||||
|
}
|
||||||
|
opts.replicas = uint(replicas)
|
||||||
|
|
||||||
|
return scale(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.cluster, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func scale(ctx context.Context, uncli *cli.CLI, opts scaleOptions) error {
|
||||||
|
if opts.replicas == 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"scaling to zero replicas is not supported. This would effectively remove the service without preserving "+
|
||||||
|
"its configuration, making it impossible to scale back up. Uncloud derives the service configuration "+
|
||||||
|
"from existing containers. Use 'uc rm %s' instead if you want to remove the service",
|
||||||
|
opts.service,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
svc, err := clusterClient.InspectService(ctx, opts.service)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect service '%s': %w", opts.service, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.Mode != api.ServiceModeReplicated {
|
||||||
|
return fmt.Errorf("scaling is only supported for services in %s mode, service '%s' is in %s mode",
|
||||||
|
api.ServiceModeReplicated, svc.Name, svc.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentReplicas := uint(len(svc.Containers))
|
||||||
|
|
||||||
|
if currentReplicas == opts.replicas {
|
||||||
|
fmt.Printf("Service '%s' already has %d replicas. No changes required.\n", svc.Name, currentReplicas)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Check if all containers have the same spec. If not, prompt user to choose which one to scale.
|
||||||
|
// This can happen if a service deployment failed midway and some containers were not updated.
|
||||||
|
spec := svc.Containers[0].Container.ServiceSpec
|
||||||
|
spec.Replicas = opts.replicas
|
||||||
|
deployment := clusterClient.NewDeployment(spec, nil)
|
||||||
|
plan, err := deployment.Plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan deployment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plan.Operations) == 0 {
|
||||||
|
fmt.Printf("Service '%s' is already scaled to %d replicas.\n", svc.Name, opts.replicas)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.replicas < currentReplicas {
|
||||||
|
// Initialise a machine and container name resolver to properly format the plan output.
|
||||||
|
resolver, err := clusterClient.ServiceOperationNameResolver(ctx, svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create machine and container name resolver for service operations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Scaling plan for service %s (%d → %d replicas):\n", svc.Name, currentReplicas, opts.replicas)
|
||||||
|
fmt.Println(plan.Format(resolver))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Ask for confirmation before scaling down as it may cause data loss.
|
||||||
|
confirmed, err := confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm scaling: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No changes were made.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("Scaling service %s (%d → %d replicas)", svc.Name, currentReplicas, opts.replicas)
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if _, err = deployment.Run(ctx); err != nil {
|
||||||
|
return fmt.Errorf("deploy service: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirm() (bool, error) {
|
||||||
|
var confirmed bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title(
|
||||||
|
"Do you want to continue?",
|
||||||
|
).
|
||||||
|
Affirmative("Yes!").
|
||||||
|
Negative("No").
|
||||||
|
Value(&confirmed),
|
||||||
|
),
|
||||||
|
).WithAccessible(true)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createOptions struct {
|
||||||
|
driver string
|
||||||
|
driverOpts []string
|
||||||
|
labels []string
|
||||||
|
machine string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateCommand() *cobra.Command {
|
||||||
|
opts := createOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create VOLUME_NAME",
|
||||||
|
Short: "Create a volume on a specific machine.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
opts.driver = strings.TrimSpace(opts.driver)
|
||||||
|
|
||||||
|
volumeName := args[0]
|
||||||
|
if volumeName == "" {
|
||||||
|
return fmt.Errorf("volume name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return create(cmd.Context(), uncli, volumeName, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.driver, "driver", "d", "local",
|
||||||
|
"Volume driver to use.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.driverOpts, "opt", "o", nil,
|
||||||
|
"Driver specific options in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.labels, "label", "l", nil,
|
||||||
|
"Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.")
|
||||||
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
|
"Name or ID of the machine to create the volume on.")
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Parse driver options.
|
||||||
|
driverOpts := make(map[string]string)
|
||||||
|
for _, opt := range opts.driverOpts {
|
||||||
|
k, v, ok := strings.Cut(opt, "=")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid driver option format: '%s' (expected key=value)", opt)
|
||||||
|
}
|
||||||
|
driverOpts[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse labels.
|
||||||
|
labels := make(map[string]string)
|
||||||
|
for _, label := range opts.labels {
|
||||||
|
k, v, ok := strings.Cut(label, "=")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid label format: '%s' (expected key=value)", label)
|
||||||
|
}
|
||||||
|
labels[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// List machines and filter by the specified machine name or ID.
|
||||||
|
// If no machine is specified, prompt the user to select one.
|
||||||
|
machines, err := client.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
var selectedMachine *pb.MachineInfo
|
||||||
|
|
||||||
|
if opts.machine == "" {
|
||||||
|
if len(machines) == 1 {
|
||||||
|
selectedMachine = machines[0].Machine
|
||||||
|
} else {
|
||||||
|
if selectedMachine, err = promptSelectMachine(ctx, machines); err != nil {
|
||||||
|
return fmt.Errorf("select machine: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m := machines.FindByNameOrID(opts.machine)
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("machine '%s' not found", opts.machine)
|
||||||
|
}
|
||||||
|
selectedMachine = m.Machine
|
||||||
|
}
|
||||||
|
|
||||||
|
createOpts := volume.CreateOptions{
|
||||||
|
Name: name,
|
||||||
|
Driver: opts.driver,
|
||||||
|
DriverOpts: driverOpts,
|
||||||
|
Labels: labels,
|
||||||
|
}
|
||||||
|
vol, err := client.CreateVolume(ctx, selectedMachine.Id, createOpts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create volume '%s' on machine '%s': %w", name, selectedMachine.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Volume '%s' created on machine '%s'.\n", vol.Volume.Name, vol.MachineName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptSelectMachine(ctx context.Context, machines api.MachineMembersList) (*pb.MachineInfo, error) {
|
||||||
|
options := make([]huh.Option[*pb.MachineInfo], len(machines))
|
||||||
|
for i, m := range machines {
|
||||||
|
options[i] = huh.NewOption(m.Machine.Name, m.Machine)
|
||||||
|
}
|
||||||
|
slices.SortFunc(options, func(a, b huh.Option[*pb.MachineInfo]) int {
|
||||||
|
return strings.Compare(a.Key, b.Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
var selected *pb.MachineInfo
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[*pb.MachineInfo]().
|
||||||
|
Title("Select a machine to create the volume on (or specify with --machine flag)").
|
||||||
|
Options(options...).
|
||||||
|
Value(&selected),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.RunWithContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inspectOptions struct {
|
||||||
|
machine string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInspectCommand() *cobra.Command {
|
||||||
|
opts := inspectOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "inspect VOLUME_NAME",
|
||||||
|
Short: "Display detailed information on a volume.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return inspect(cmd.Context(), uncli, args[0], opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.machine, "machine", "m", "",
|
||||||
|
"Name or ID of the machine where the volume is located. "+
|
||||||
|
"If not specified, the volume will be searched across all machines.")
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
filter := &api.VolumeFilter{
|
||||||
|
Names: []string{name},
|
||||||
|
}
|
||||||
|
if opts.machine != "" {
|
||||||
|
filter.Machines = []string{opts.machine}
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes, err := client.ListVolumes(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list volumes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(volumes) == 0 {
|
||||||
|
if opts.machine != "" {
|
||||||
|
return fmt.Errorf("volume '%s' not found on machine '%s'", name, opts.machine)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("volume '%s' not found on any machine", name)
|
||||||
|
}
|
||||||
|
if len(volumes) > 1 {
|
||||||
|
fmt.Printf("Volume '%s' found on multiple machines:\n", name)
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Printf(" • %s\n", v.MachineName)
|
||||||
|
}
|
||||||
|
return errors.New("specify --machine flag to choose which machine to use")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(volumes[0], "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal volume: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listOptions struct {
|
||||||
|
machines []string
|
||||||
|
quiet bool
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
opts := listOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List volumes across all machines in the cluster.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
|
"Filter volumes by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
||||||
|
"(default is include all machines)")
|
||||||
|
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false,
|
||||||
|
"Only display volume names.")
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Apply machine filter if specified.
|
||||||
|
var filter *api.VolumeFilter
|
||||||
|
if len(opts.machines) > 0 {
|
||||||
|
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||||
|
filter = &api.VolumeFilter{
|
||||||
|
Machines: machines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes, err := client.ListVolumes(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list volumes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(volumes) == 0 {
|
||||||
|
if !opts.quiet {
|
||||||
|
fmt.Println("No volumes found.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the volumes by name first, then by machine name.
|
||||||
|
slices.SortFunc(volumes, func(a, b api.MachineVolume) int {
|
||||||
|
cmp := strings.Compare(a.Volume.Name, b.Volume.Name)
|
||||||
|
if cmp != 0 {
|
||||||
|
return cmp
|
||||||
|
}
|
||||||
|
return strings.Compare(a.MachineName, b.MachineName)
|
||||||
|
})
|
||||||
|
|
||||||
|
// If quiet mode, just print volume names.
|
||||||
|
if opts.quiet {
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Println(v.Volume.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the volumes in a table format.
|
||||||
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "NAME\tDRIVER\tMACHINE")
|
||||||
|
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||||
|
v.Volume.Name,
|
||||||
|
v.Volume.Driver,
|
||||||
|
v.MachineName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type removeOptions struct {
|
||||||
|
force bool
|
||||||
|
machines []string
|
||||||
|
yes bool
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRemoveCommand() *cobra.Command {
|
||||||
|
opts := removeOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm VOLUME_NAME [VOLUME_NAME...]",
|
||||||
|
Aliases: []string{"remove", "delete"},
|
||||||
|
Short: "Remove one or more volumes.",
|
||||||
|
Long: "Remove one or more volumes. You cannot remove a volume that is in use by a container.",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return remove(cmd.Context(), uncli, args, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&opts.force, "force", "f", false,
|
||||||
|
"Force the removal of one or more volumes.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
|
"Name or ID of the machine to remove one or more volumes from. "+
|
||||||
|
"Can be specified multiple times or as a comma-separated list.\n"+
|
||||||
|
"If not specified, the found volume(s) will be removed from all machines.")
|
||||||
|
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||||
|
"Do not prompt for confirmation before removing the volume(s).")
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error {
|
||||||
|
client, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
filter := &api.VolumeFilter{
|
||||||
|
Names: names,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.machines) > 0 {
|
||||||
|
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||||
|
filter.Machines = machines
|
||||||
|
}
|
||||||
|
|
||||||
|
volumes, err := client.ListVolumes(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list volumes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(volumes) == 0 {
|
||||||
|
if len(names) == 1 {
|
||||||
|
return fmt.Errorf("volume '%s' not found", names[0])
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no volumes found matching the specified names")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm removal if not using --yes flag.
|
||||||
|
if !opts.yes {
|
||||||
|
fmt.Println("The following volumes will be removed:")
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Printf(" • '%s' on machine '%s'\n", v.Volume.Name, v.MachineName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
confirmed, err := cli.Confirm()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("confirm removal: %w", err)
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
fmt.Println("Cancelled. No volumes were removed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the volumes one by one collecting errors.
|
||||||
|
var removeErr error
|
||||||
|
for _, v := range volumes {
|
||||||
|
if err = client.RemoveVolume(ctx, v.MachineID, v.Volume.Name, opts.force); err != nil {
|
||||||
|
if !errors.Is(err, api.ErrNotFound) {
|
||||||
|
removeErr = errors.Join(removeErr, fmt.Errorf("failed to remove volume '%s' on machine '%s': %w",
|
||||||
|
v.Volume.Name, v.MachineName, err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Volume '%s' removed from machine '%s'.\n", v.Volume.Name, v.MachineName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeErr
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package volume
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "volume",
|
||||||
|
Short: "Manage volumes in an Uncloud cluster.",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewCreateCommand(),
|
||||||
|
NewInspectCommand(),
|
||||||
|
NewListCommand(),
|
||||||
|
NewRemoveCommand(),
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+11
-5
@@ -2,22 +2,29 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"uncloud/internal/daemon"
|
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/daemon"
|
||||||
|
"github.com/psviderski/uncloud/internal/log"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/version"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
logger := slog.New(log.NewSlogTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
var dataDir string
|
var dataDir string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uncloudd",
|
Use: "uncloudd",
|
||||||
Short: "Uncloud machine daemon.",
|
Short: "Uncloud machine daemon.",
|
||||||
|
Version: version.String(),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -48,5 +55,4 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
cobra.CheckErr(cmd.ExecuteContext(ctx))
|
cobra.CheckErr(cmd.ExecuteContext(ctx))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/hashicorp/serf/serf"
|
|
||||||
crdt "github.com/ipfs/go-ds-crdt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/serf/serf"
|
||||||
|
crdt "github.com/ipfs/go-ds-crdt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Implements the Broadcaster interface.
|
// Implements the Broadcaster interface.
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/dgraph-io/badger/v3"
|
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func customTimeEncoder(t time.Time) string {
|
func customTimeEncoder(t time.Time) string {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/ipfs/go-log/v2"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/ipfs/go-log/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ipfsLogger is an slog logger that implements the IPFS go-log StandardLogger interface.
|
// ipfsLogger is an slog logger that implements the IPFS go-log StandardLogger interface.
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/docker/docker/libnetwork/networkdb"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/libnetwork/networkdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
+13
-12
@@ -4,6 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/memberlist"
|
"github.com/hashicorp/memberlist"
|
||||||
"github.com/hashicorp/serf/cmd/serf/command/agent"
|
"github.com/hashicorp/serf/cmd/serf/command/agent"
|
||||||
"github.com/hashicorp/serf/serf"
|
"github.com/hashicorp/serf/serf"
|
||||||
@@ -11,12 +18,6 @@ import (
|
|||||||
badger "github.com/ipfs/go-ds-badger3"
|
badger "github.com/ipfs/go-ds-badger3"
|
||||||
crdt "github.com/ipfs/go-ds-crdt"
|
crdt "github.com/ipfs/go-ds-crdt"
|
||||||
"github.com/lmittmann/tint"
|
"github.com/lmittmann/tint"
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func createSerfAgentConfig(name, bindAddr, rpcAddr, profile string) *agent.Config {
|
func createSerfAgentConfig(name, bindAddr, rpcAddr, profile string) *agent.Config {
|
||||||
@@ -49,9 +50,9 @@ func createSerfAgent(config *agent.Config) (*agent.Agent, error) {
|
|||||||
|
|
||||||
serfConfig.MemberlistConfig.BindAddr = bindIP
|
serfConfig.MemberlistConfig.BindAddr = bindIP
|
||||||
serfConfig.MemberlistConfig.BindPort = bindPort
|
serfConfig.MemberlistConfig.BindPort = bindPort
|
||||||
//serfConfig.MemberlistConfig.AdvertiseAddr = advertiseIP
|
// serfConfig.MemberlistConfig.AdvertiseAddr = advertiseIP
|
||||||
//serfConfig.MemberlistConfig.AdvertisePort = advertisePort
|
// serfConfig.MemberlistConfig.AdvertisePort = advertisePort
|
||||||
//serfConfig.MemberlistConfig.SecretKey = encryptKey
|
// serfConfig.MemberlistConfig.SecretKey = encryptKey
|
||||||
serfConfig.NodeName = config.NodeName
|
serfConfig.NodeName = config.NodeName
|
||||||
serfConfig.Tags = config.Tags
|
serfConfig.Tags = config.Tags
|
||||||
serfConfig.SnapshotPath = config.SnapshotPath
|
serfConfig.SnapshotPath = config.SnapshotPath
|
||||||
@@ -129,7 +130,7 @@ func main() {
|
|||||||
logger := slog.New(tint.NewHandler(os.Stdout, &tint.Options{
|
logger := slog.New(tint.NewHandler(os.Stdout, &tint.Options{
|
||||||
AddSource: true,
|
AddSource: true,
|
||||||
Level: slog.LevelDebug,
|
Level: slog.LevelDebug,
|
||||||
//Level: slog.LevelInfo,
|
// Level: slog.LevelInfo,
|
||||||
TimeFormat: time.RFC3339Nano,
|
TimeFormat: time.RFC3339Nano,
|
||||||
}))
|
}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
@@ -164,7 +165,7 @@ func main() {
|
|||||||
|
|
||||||
opts := crdt.DefaultOptions()
|
opts := crdt.DefaultOptions()
|
||||||
opts.Logger = newIPFSLogger(logger)
|
opts.Logger = newIPFSLogger(logger)
|
||||||
//opts.MultiHeadProcessing = true
|
// opts.MultiHeadProcessing = true
|
||||||
// TODO: debug why the heads count may grow on the receiving side if the event backlog is huge and the processing
|
// TODO: debug why the heads count may grow on the receiving side if the event backlog is huge and the processing
|
||||||
// is slow.
|
// is slow.
|
||||||
store, err := crdt.New(localStore, ds.NewKey("/"), syncer, broadcaster, opts)
|
store, err := crdt.New(localStore, ds.NewKey("/"), syncer, broadcaster, opts)
|
||||||
@@ -172,7 +173,7 @@ func main() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
//ticker := time.NewTicker(10 * time.Millisecond)
|
// ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
"github.com/hashicorp/serf/serf"
|
"github.com/hashicorp/serf/serf"
|
||||||
"github.com/ipfs/boxo/datastore/dshelp"
|
"github.com/ipfs/boxo/datastore/dshelp"
|
||||||
dag "github.com/ipfs/boxo/ipld/merkledag"
|
dag "github.com/ipfs/boxo/ipld/merkledag"
|
||||||
"github.com/ipfs/go-cid"
|
"github.com/ipfs/go-cid"
|
||||||
ds "github.com/ipfs/go-datastore"
|
ds "github.com/ipfs/go-datastore"
|
||||||
ipld "github.com/ipfs/go-ipld-format"
|
ipld "github.com/ipfs/go-ipld-format"
|
||||||
"log/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Implements the DAGService interface.
|
// Implements the DAGService interface.
|
||||||
// TODO: implement SessionDAGService to optimize node fetching.
|
// TODO: implement SessionDAGService to optimize node fetching.
|
||||||
// TOOD: persistentSerfDAG?
|
// TODO: persistentSerfDAG?
|
||||||
type dagSyncer struct {
|
type dagSyncer struct {
|
||||||
// Persistent storage for the nodes.
|
// Persistent storage for the nodes.
|
||||||
store ds.Datastore
|
store ds.Datastore
|
||||||
@@ -50,8 +51,7 @@ func (d *dagSyncer) Get(ctx context.Context, cid cid.Cid) (ipld.Node, error) {
|
|||||||
}
|
}
|
||||||
slog.Debug("Queried node from peers", "cid", cid, "deadline", query.Deadline())
|
slog.Debug("Queried node from peers", "cid", cid, "deadline", query.Deadline())
|
||||||
|
|
||||||
responded := false
|
for {
|
||||||
for !responded {
|
|
||||||
select {
|
select {
|
||||||
case resp, ok := <-query.ResponseCh():
|
case resp, ok := <-query.ResponseCh():
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -63,7 +63,6 @@ func (d *dagSyncer) Get(ctx context.Context, cid cid.Cid) (ipld.Node, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Debug("Received node from peer", "cid", cid, "peer", resp.From)
|
slog.Debug("Received node from peer", "cid", cid, "peer", resp.From)
|
||||||
responded = true
|
|
||||||
query.Close()
|
query.Close()
|
||||||
|
|
||||||
node, err = nodeFromBytes(resp.Payload)
|
node, err = nodeFromBytes(resp.Payload)
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/siderolabs/discovery-api/api/v1alpha1/client/pb"
|
"github.com/siderolabs/discovery-api/api/v1alpha1/client/pb"
|
||||||
discovery "github.com/siderolabs/discovery-client/pkg/client"
|
discovery "github.com/siderolabs/discovery-client/pkg/client"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"net/netip"
|
|
||||||
"time"
|
|
||||||
"uncloud/internal/machine/network"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
module uncloud
|
module github.com/psviderski/uncloud
|
||||||
|
|
||||||
go 1.22.2
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.22.5
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.4.0
|
github.com/BurntSushi/toml v1.4.0
|
||||||
|
github.com/Masterminds/semver v1.5.0
|
||||||
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0
|
||||||
|
github.com/caddyserver/caddy/v2 v2.8.4
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/huh v0.6.0
|
||||||
|
github.com/charmbracelet/lipgloss v0.13.0
|
||||||
|
github.com/compose-spec/compose-go/v2 v2.4.5
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0
|
||||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||||
github.com/docker/docker v27.3.0+incompatible
|
github.com/distribution/reference v0.6.0
|
||||||
|
github.com/docker/cli v27.5.0+incompatible
|
||||||
|
github.com/docker/compose/v2 v2.31.0
|
||||||
|
github.com/docker/docker v27.4.0-rc.2+incompatible
|
||||||
github.com/docker/go-connections v0.5.0
|
github.com/docker/go-connections v0.5.0
|
||||||
|
github.com/docker/go-units v0.5.0
|
||||||
|
github.com/goccy/go-yaml v1.17.1
|
||||||
|
github.com/google/go-cmp v0.7.0
|
||||||
|
github.com/google/go-containerregistry v0.20.2
|
||||||
github.com/hashicorp/memberlist v0.5.1
|
github.com/hashicorp/memberlist v0.5.1
|
||||||
github.com/hashicorp/serf v0.10.1
|
github.com/hashicorp/serf v0.10.1
|
||||||
github.com/ipfs/boxo v0.21.0
|
github.com/ipfs/boxo v0.21.0
|
||||||
@@ -20,65 +33,118 @@ require (
|
|||||||
github.com/ipfs/go-ds-crdt v0.5.2
|
github.com/ipfs/go-ds-crdt v0.5.2
|
||||||
github.com/ipfs/go-ipld-format v0.6.0
|
github.com/ipfs/go-ipld-format v0.6.0
|
||||||
github.com/ipfs/go-log/v2 v2.5.1
|
github.com/ipfs/go-log/v2 v2.5.1
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/lmittmann/tint v1.0.5
|
github.com/lmittmann/tint v1.0.5
|
||||||
|
github.com/miekg/dns v1.1.65
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
|
github.com/moby/term v0.5.0
|
||||||
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
|
github.com/opencontainers/image-spec v1.1.0
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
github.com/siderolabs/discovery-client v0.1.9
|
github.com/siderolabs/discovery-client v0.1.9
|
||||||
|
github.com/siderolabs/grpc-proxy v0.5.1
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||||
golang.org/x/crypto v0.27.0
|
golang.org/x/crypto v0.33.0
|
||||||
golang.org/x/sync v0.8.0
|
golang.org/x/net v0.35.0
|
||||||
golang.org/x/sys v0.25.0
|
golang.org/x/sync v0.11.0
|
||||||
|
golang.org/x/sys v0.31.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||||
google.golang.org/grpc v1.66.0
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576
|
||||||
google.golang.org/protobuf v1.34.2
|
google.golang.org/grpc v1.68.1
|
||||||
|
google.golang.org/protobuf v1.36.3
|
||||||
|
modernc.org/sqlite v1.36.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
||||||
|
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
||||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/OneOfOne/xxhash v1.2.8 // indirect
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
||||||
github.com/armon/go-metrics v0.4.1 // indirect
|
github.com/armon/go-metrics v0.4.1 // indirect
|
||||||
github.com/armon/go-radix v1.0.0 // indirect
|
github.com/armon/go-radix v1.0.0 // indirect
|
||||||
|
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bgentry/speakeasy v0.2.0 // indirect
|
github.com/bgentry/speakeasy v0.2.0 // indirect
|
||||||
|
github.com/buger/goterm v1.0.4 // indirect
|
||||||
|
github.com/caddyserver/certmagic v0.21.4 // indirect
|
||||||
|
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||||
github.com/catppuccin/go v0.2.0 // indirect
|
github.com/catppuccin/go v0.2.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
|
||||||
github.com/cespare/xxhash v1.1.0 // indirect
|
github.com/cespare/xxhash v1.1.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.1.1 // indirect
|
github.com/charmbracelet/bubbletea v1.1.1 // indirect
|
||||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||||
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
|
github.com/cloudflare/cfssl v1.6.4 // indirect
|
||||||
|
github.com/containerd/console v1.0.4 // indirect
|
||||||
|
github.com/containerd/containerd v1.7.24 // indirect
|
||||||
|
github.com/containerd/containerd/api v1.7.19 // indirect
|
||||||
|
github.com/containerd/continuity v0.4.4 // indirect
|
||||||
|
github.com/containerd/errdefs v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
|
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||||
|
github.com/containerd/ttrpc v1.2.5 // indirect
|
||||||
|
github.com/containerd/typeurl/v2 v2.2.0 // indirect
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||||
|
github.com/dgraph-io/badger v1.6.2 // indirect
|
||||||
|
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
||||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
|
github.com/docker/buildx v0.18.0 // indirect
|
||||||
|
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||||
|
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
||||||
|
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
||||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-metrics v0.0.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
github.com/fatih/color v1.17.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/fvbommel/sortorder v1.1.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||||
|
github.com/go-kit/kit v0.13.0 // indirect
|
||||||
|
github.com/go-kit/log v0.2.1 // indirect
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/glog v1.2.2 // indirect
|
github.com/golang/glog v1.2.2 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/btree v1.1.2 // indirect
|
github.com/google/btree v1.1.2 // indirect
|
||||||
|
github.com/google/cel-go v0.20.1 // indirect
|
||||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||||
@@ -86,12 +152,14 @@ require (
|
|||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
|
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
|
||||||
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
github.com/hashicorp/go-syslog v1.0.0 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/hashicorp/logutils v1.0.0 // indirect
|
github.com/hashicorp/logutils v1.0.0 // indirect
|
||||||
github.com/hashicorp/mdns v1.0.5 // indirect
|
github.com/hashicorp/mdns v1.0.5 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // indirect
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/imdario/mergo v0.3.16 // indirect
|
github.com/imdario/mergo v0.3.16 // indirect
|
||||||
|
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||||
@@ -101,31 +169,53 @@ require (
|
|||||||
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
|
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
|
||||||
github.com/ipld/go-ipld-prime v0.21.0 // indirect
|
github.com/ipld/go-ipld-prime v0.21.0 // indirect
|
||||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
|
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 // indirect
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
|
github.com/jackc/pgconn v1.14.3 // indirect
|
||||||
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/pgtype v1.14.0 // indirect
|
||||||
|
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
||||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||||
github.com/josharian/native v1.1.0 // indirect
|
github.com/josharian/native v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.9 // indirect
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||||
|
github.com/libdns/libdns v0.2.2 // indirect
|
||||||
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
||||||
github.com/libp2p/go-libp2p v0.35.4 // indirect
|
github.com/libp2p/go-libp2p v0.35.4 // indirect
|
||||||
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
||||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/manifoldco/promptui v0.9.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/mattn/go-shellwords v1.0.12 // indirect
|
||||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/miekg/dns v1.1.62 // indirect
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||||
|
github.com/mholt/acmez/v2 v2.0.3 // indirect
|
||||||
|
github.com/miekg/pkcs11 v1.1.1 // indirect
|
||||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||||
github.com/mitchellh/cli v1.1.5 // indirect
|
github.com/mitchellh/cli v1.1.5 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
|
github.com/moby/buildkit v0.17.2 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/term v0.5.0 // indirect
|
github.com/moby/locker v1.0.1 // indirect
|
||||||
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
|
github.com/moby/sys/signal v0.7.1 // indirect
|
||||||
|
github.com/moby/sys/user v0.3.0 // indirect
|
||||||
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
@@ -140,37 +230,95 @@ require (
|
|||||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||||
github.com/multiformats/go-multistream v0.5.0 // indirect
|
github.com/multiformats/go-multistream v0.5.0 // indirect
|
||||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||||
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
|
github.com/pires/go-proxyproto v0.7.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/polydawn/refmt v0.89.0 // indirect
|
github.com/polydawn/refmt v0.89.0 // indirect
|
||||||
github.com/posener/complete v1.2.3 // indirect
|
github.com/posener/complete v1.2.3 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.20.5 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/common v0.61.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/rs/xid v1.5.0 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||||
|
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
||||||
|
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/siderolabs/gen v0.4.8 // indirect
|
github.com/siderolabs/gen v0.4.8 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
|
github.com/slackhq/nebula v1.6.1 // indirect
|
||||||
|
github.com/smallstep/certificates v0.26.1 // indirect
|
||||||
|
github.com/smallstep/nosql v0.6.1 // indirect
|
||||||
|
github.com/smallstep/pkcs7 v0.0.0-20231024181729-3b98ecc1ca81 // indirect
|
||||||
|
github.com/smallstep/scep v0.0.0-20231024192529-aee96d7ad34d // indirect
|
||||||
|
github.com/smallstep/truststore v0.13.0 // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/spf13/cast v1.7.0 // indirect
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
|
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
||||||
|
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||||
|
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
||||||
|
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
|
||||||
|
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
||||||
|
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||||
|
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
||||||
|
github.com/urfave/cli v1.22.16 // indirect
|
||||||
|
github.com/vbatts/tar-split v0.11.6 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
||||||
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
|
go.etcd.io/bbolt v1.3.11 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.30.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.30.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.30.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.33.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.33.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.33.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
||||||
|
go.step.sm/cli-utils v0.9.0 // indirect
|
||||||
|
go.step.sm/crypto v0.45.0 // indirect
|
||||||
|
go.step.sm/linkedca v0.20.1 // indirect
|
||||||
|
go.uber.org/automaxprocs v1.5.3 // indirect
|
||||||
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
go.uber.org/zap/exp v0.2.0 // indirect
|
||||||
golang.org/x/mod v0.21.0 // indirect
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
||||||
golang.org/x/net v0.29.0 // indirect
|
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
||||||
golang.org/x/text v0.18.0 // indirect
|
golang.org/x/mod v0.23.0 // indirect
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect
|
golang.org/x/term v0.29.0 // indirect
|
||||||
golang.org/x/tools v0.25.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
golang.org/x/time v0.8.0 // indirect
|
||||||
|
golang.org/x/tools v0.30.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect
|
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||||
gotest.tools/v3 v3.5.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||||
|
howett.net/plist v1.0.0 // indirect
|
||||||
lukechampine.com/blake3 v1.3.0 // indirect
|
lukechampine.com/blake3 v1.3.0 // indirect
|
||||||
|
modernc.org/libc v1.61.13 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.8.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/docker/cli/cli/config"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/archive"
|
||||||
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
"github.com/docker/docker/registry"
|
||||||
|
"github.com/moby/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuildOptions struct {
|
||||||
|
Files []string
|
||||||
|
Profiles []string
|
||||||
|
Services []string
|
||||||
|
Push bool
|
||||||
|
NoCache bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServicesThatNeedBuild returns a map of services that require building
|
||||||
|
func GetServicesThatNeedBuild(project *composetypes.Project) map[string]composetypes.ServiceConfig {
|
||||||
|
servicesToBuild := make(map[string]composetypes.ServiceConfig, len(project.Services))
|
||||||
|
for serviceName, service := range project.Services {
|
||||||
|
if service.Build == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
servicesToBuild[serviceName] = service
|
||||||
|
}
|
||||||
|
return servicesToBuild
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildServices builds the services defined in the provided map.
|
||||||
|
func BuildServices(ctx context.Context, servicesToBuild map[string]composetypes.ServiceConfig, opts BuildOptions) error {
|
||||||
|
fmt.Println("Building services...")
|
||||||
|
|
||||||
|
// Init docker client (can be local or remote, depending on DOCKER_HOST environment variable)
|
||||||
|
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dockerCli.Close()
|
||||||
|
|
||||||
|
serviceImages := make(map[string]string, len(servicesToBuild))
|
||||||
|
|
||||||
|
// Build the services using the local docker client and compose libraries
|
||||||
|
for _, service := range servicesToBuild {
|
||||||
|
fmt.Printf("Building service: %s\n", service.Name)
|
||||||
|
imageName, err := buildSingleService(ctx, dockerCli, service, opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build service %s: %w", service.Name, err)
|
||||||
|
}
|
||||||
|
serviceImages[service.Name] = imageName
|
||||||
|
}
|
||||||
|
fmt.Printf("Service images are built.\n")
|
||||||
|
|
||||||
|
if opts.Push {
|
||||||
|
err = pushServiceImages(ctx, dockerCli, serviceImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSingleService builds a single service using the Docker client and Compose libraries.
|
||||||
|
func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, service composetypes.ServiceConfig, opts BuildOptions) (string, error) {
|
||||||
|
if service.Build == nil {
|
||||||
|
return "", fmt.Errorf("service %s has no build configuration", service.Name)
|
||||||
|
}
|
||||||
|
if service.Image == "" {
|
||||||
|
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", service.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildContextPath := service.Build.Context
|
||||||
|
imageName := service.Image
|
||||||
|
|
||||||
|
// Create a tar archive of the build context
|
||||||
|
buildContext, err := archive.TarWithOptions(buildContextPath, &archive.TarOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOptions := types.ImageBuildOptions{
|
||||||
|
// TODO: Support Dockerfiles outside the build context
|
||||||
|
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
|
||||||
|
Dockerfile: service.Build.Dockerfile,
|
||||||
|
Tags: []string{imageName},
|
||||||
|
Remove: true, // Remove intermediate containers
|
||||||
|
NoCache: opts.NoCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
buildResponse, err := dockerCli.ImageBuild(ctx, buildContext, buildOptions)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to build image for service %s: %w", service.Name, err)
|
||||||
|
}
|
||||||
|
defer buildResponse.Body.Close()
|
||||||
|
|
||||||
|
// Display the build response
|
||||||
|
fd, isTerminal := term.GetFdInfo(os.Stdout)
|
||||||
|
if err := jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stdout, fd, isTerminal, nil); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to display build response for service %s: %w", service.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushSingleServiceImage pushes a single service image.
|
||||||
|
func pushSingleServiceImage(ctx context.Context, dockerCli *dockerclient.Client, serviceName string, imageName string) error {
|
||||||
|
ref, err := reference.ParseNormalizedNamed(imageName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
repoInfo, err := registry.ParseRepositoryInfo(ref)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
registryKey := repoInfo.Index.Name
|
||||||
|
if repoInfo.Index.Official {
|
||||||
|
registryKey = registry.IndexServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the Docker config file with auth details, if available
|
||||||
|
configFile := config.LoadDefaultConfigFile(os.Stderr)
|
||||||
|
|
||||||
|
authConfig, err := configFile.GetAuthConfig(registryKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
authJSON, err := json.Marshal(authConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal auth config for registry %s: %w", registryKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authStr := base64.URLEncoding.EncodeToString(authJSON)
|
||||||
|
|
||||||
|
pushOptions := image.PushOptions{
|
||||||
|
RegistryAuth: authStr,
|
||||||
|
}
|
||||||
|
pushResponse, err := dockerCli.ImagePush(ctx, imageName, pushOptions)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to push image %s: %w", imageName, err)
|
||||||
|
}
|
||||||
|
defer pushResponse.Close()
|
||||||
|
|
||||||
|
fmt.Printf("Pushing image %s for service %s...\n", imageName, serviceName)
|
||||||
|
|
||||||
|
fd, isTerminal := term.GetFdInfo(os.Stdout)
|
||||||
|
if err := jsonmessage.DisplayJSONMessagesStream(pushResponse, os.Stdout, fd, isTerminal, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to display push response for image %s: %w", imageName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Image %s pushed successfully.\n", imageName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushServiceImages pushes all built service images to the registry.
|
||||||
|
func pushServiceImages(ctx context.Context, dockerCli *dockerclient.Client, serviceImages map[string]string) error {
|
||||||
|
fmt.Printf("Pushing images...\n")
|
||||||
|
for serviceName, imageName := range serviceImages {
|
||||||
|
if err := pushSingleServiceImage(ctx, dockerCli, serviceName, imageName); err != nil {
|
||||||
|
return fmt.Errorf("push image for service %s: %w", serviceName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+308
-238
@@ -4,262 +4,329 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"google.golang.org/protobuf/types/known/emptypb"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"slices"
|
||||||
"text/tabwriter"
|
|
||||||
"uncloud/internal/cli/client"
|
"github.com/docker/cli/cli/streams"
|
||||||
"uncloud/internal/cli/client/connector"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
"uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
"uncloud/internal/sshexec"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultClusterName = "default"
|
const (
|
||||||
|
// DefaultSSHKeyPath is the fallback location for the SSH private key when provisioning remote machines.
|
||||||
var (
|
// Used when no key is explicitly provided and SSH agent authentication fails.
|
||||||
ErrNotFound = errors.New("not found")
|
DefaultSSHKeyPath = "~/.ssh/id_ed25519"
|
||||||
|
defaultContextName = "default"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CLI struct {
|
type CLI struct {
|
||||||
config *config.Config
|
Config *config.Config
|
||||||
|
conn *config.MachineConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(configPath string) (*CLI, error) {
|
// New creates a new CLI instance with the given config path or remote machine connection.
|
||||||
|
// If the connection is provided, the config is ignored for all operations which is useful for interacting with
|
||||||
|
// a cluster without creating a config.
|
||||||
|
func New(configPath string, conn *config.MachineConnection) (*CLI, error) {
|
||||||
|
if conn != nil {
|
||||||
|
return &CLI{conn: conn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.NewFromFile(configPath)
|
cfg, err := config.NewFromFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read Uncloud config: %w", err)
|
return nil, fmt.Errorf("read Uncloud config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CLI{
|
return &CLI{
|
||||||
config: cfg,
|
Config: cfg,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) CreateCluster(name string, userPrivateKey secret.Secret) error {
|
func (cli *CLI) CreateContext(name string) error {
|
||||||
if _, ok := cli.config.Clusters[name]; ok {
|
if _, ok := cli.Config.Contexts[name]; ok {
|
||||||
return fmt.Errorf("cluster %q already exists", name)
|
return fmt.Errorf("context '%s' already exists", name)
|
||||||
}
|
}
|
||||||
if userPrivateKey == nil {
|
cli.Config.Contexts[name] = &config.Context{
|
||||||
user, err := client.NewUser(nil)
|
Name: name,
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generate user: %w", err)
|
|
||||||
}
|
|
||||||
userPrivateKey = user.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
return cli.Config.Save()
|
||||||
cli.config.Clusters[name] = &config.Cluster{
|
|
||||||
Name: name,
|
|
||||||
UserPrivateKey: userPrivateKey,
|
|
||||||
}
|
|
||||||
return cli.config.Save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) CreateDefaultCluster() error {
|
func (cli *CLI) SetCurrentContext(name string) error {
|
||||||
if err := cli.CreateCluster(defaultClusterName, nil); err != nil {
|
if _, ok := cli.Config.Contexts[name]; !ok {
|
||||||
return err
|
return api.ErrNotFound
|
||||||
}
|
}
|
||||||
return cli.SetCurrentCluster(defaultClusterName)
|
cli.Config.CurrentContext = name
|
||||||
|
return cli.Config.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) SetCurrentCluster(name string) error {
|
// ConnectCluster connects to a cluster using the given context name or the current context if not specified.
|
||||||
if _, ok := cli.config.Clusters[name]; !ok {
|
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||||
return ErrNotFound
|
func (cli *CLI) ConnectCluster(ctx context.Context, contextName string) (*client.Client, error) {
|
||||||
|
if cli.conn != nil {
|
||||||
|
return connectCluster(ctx, *cli.conn)
|
||||||
}
|
}
|
||||||
cli.config.CurrentCluster = name
|
|
||||||
return cli.config.Save()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client.Client, error) {
|
if len(cli.Config.Contexts) == 0 {
|
||||||
if len(cli.config.Clusters) == 0 {
|
return nil, fmt.Errorf(
|
||||||
return nil, errors.New(
|
"no cluster contexts found in the Uncloud config (%s). "+
|
||||||
"no clusters found in the Uncloud config. " +
|
"Please initialise a cluster with 'uncloud machine init' first",
|
||||||
"Please initialise a cluster with `uncloud machine init` first",
|
cli.Config.Path(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if clusterName == "" {
|
if contextName == "" {
|
||||||
// If the cluster is not specified, use the current cluster if set.
|
// If the cluster is not specified, use the current cluster if set.
|
||||||
if cli.config.CurrentCluster == "" {
|
if cli.Config.CurrentContext == "" {
|
||||||
return nil, errors.New(
|
|
||||||
"the current cluster is not set in the Uncloud config. " +
|
|
||||||
"Please specify a cluster with the --cluster flag or set current_cluster in the config",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, ok := cli.config.Clusters[cli.config.CurrentCluster]; !ok {
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"current cluster %q not found in the config. "+
|
"the current cluster context is not set in the Uncloud config (%s). "+
|
||||||
"Please specify a cluster with the --cluster flag or update current_cluster in the config",
|
"Please specify the context with the '--context' flag or set 'current_context' in the config",
|
||||||
cli.config.CurrentCluster,
|
cli.Config.Path(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
clusterName = cli.config.CurrentCluster
|
if _, ok := cli.Config.Contexts[cli.Config.CurrentContext]; !ok {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"current cluster context '%s' not found in the Uncloud config (%s). "+
|
||||||
|
"Please specify the context with the '--context' flag or update 'current_context' in the config",
|
||||||
|
cli.Config.CurrentContext,
|
||||||
|
cli.Config.Path(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
contextName = cli.Config.CurrentContext
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, ok := cli.config.Clusters[clusterName]
|
cfg, ok := cli.Config.Contexts[contextName]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("cluster %q not found in the config", clusterName)
|
return nil, fmt.Errorf("cluster context '%s' not found in the Uncloud config (%s)",
|
||||||
|
contextName, cli.Config.Path())
|
||||||
}
|
}
|
||||||
if len(cfg.Connections) == 0 {
|
if len(cfg.Connections) == 0 {
|
||||||
return nil, fmt.Errorf("no connection configurations found for cluster %q in the config", clusterName)
|
return nil, fmt.Errorf(
|
||||||
|
"no connection configurations found for cluster context '%s' in the Uncloud config (%s)",
|
||||||
|
contextName, cli.Config.Path(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
// TODO: iterate over all connections and try to connect to the cluster using the first successful connection.
|
||||||
conn := cfg.Connections[0]
|
conn := cfg.Connections[0]
|
||||||
user, host, port, err := conn.SSH.Parse()
|
|
||||||
|
c, err := connectCluster(ctx, conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
return nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
sshConfig := &connector.SSHConnectorConfig{
|
|
||||||
User: user,
|
return c, nil
|
||||||
Host: host,
|
|
||||||
Port: port,
|
|
||||||
}
|
|
||||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) InitCluster(
|
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||||
ctx context.Context, remoteMachine *RemoteMachine, clusterName, machineName string, netPrefix netip.Prefix,
|
if conn.SSH != "" {
|
||||||
) error {
|
user, host, port, err := conn.SSH.Parse()
|
||||||
if remoteMachine != nil {
|
if err != nil {
|
||||||
return cli.initRemoteMachine(ctx, *remoteMachine, clusterName, machineName, netPrefix)
|
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||||
|
|
||||||
|
sshConfig := &connector.SSHConnectorConfig{
|
||||||
|
User: user,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
KeyPath: keyPath,
|
||||||
|
}
|
||||||
|
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||||
|
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||||
|
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("connection configuration is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitClusterOptions struct {
|
||||||
|
Context string
|
||||||
|
MachineName string
|
||||||
|
Network netip.Prefix
|
||||||
|
PublicIP *netip.Addr
|
||||||
|
RemoteMachine *RemoteMachine
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster.
|
||||||
|
// The client should be closed after use by the caller.
|
||||||
|
func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||||
|
if opts.RemoteMachine != nil {
|
||||||
|
return cli.initRemoteMachine(ctx, opts)
|
||||||
}
|
}
|
||||||
// TODO: implement local machine initialisation
|
// TODO: implement local machine initialisation
|
||||||
return fmt.Errorf("local machine initialisation is not implemented yet")
|
return nil, fmt.Errorf("local machine initialisation is not implemented yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) initRemoteMachine(
|
func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) {
|
||||||
ctx context.Context, remoteMachine RemoteMachine, clusterName, machineName string, netPrefix netip.Prefix,
|
contextName := opts.Context
|
||||||
) error {
|
if contextName == "" {
|
||||||
if clusterName == "" {
|
contextName = defaultContextName
|
||||||
clusterName = defaultClusterName
|
|
||||||
}
|
}
|
||||||
if _, ok := cli.config.Clusters[clusterName]; ok {
|
if _, ok := cli.Config.Contexts[contextName]; ok {
|
||||||
return fmt.Errorf("cluster %q already exists", clusterName)
|
return nil, fmt.Errorf("cluster context '%s' already exists", contextName)
|
||||||
}
|
|
||||||
user, err := client.NewUser(nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generate cluster user: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return nil, err
|
||||||
"SSH login to remote machine %s: %w",
|
|
||||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
exec := sshexec.NewRemote(sshClient)
|
// Ensure machineClient is closed on error.
|
||||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
defer func() {
|
||||||
if err = provisionMachine(ctx, exec); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("provision machine: %w", err)
|
machineClient.Close()
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
// Create a machine API client over the SSH connection to the remote machine.
|
|
||||||
machineClient, err := client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to remote machine: %w", err)
|
|
||||||
}
|
|
||||||
defer machineClient.Close()
|
|
||||||
|
|
||||||
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
||||||
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect machine: %w", err)
|
return nil, fmt.Errorf("inspect machine: %w", err)
|
||||||
}
|
}
|
||||||
if minfo.Id != "" {
|
if minfo.Id != "" {
|
||||||
if err = cli.promptResetMachine(); err != nil {
|
if err = promptResetMachine(ctx, machineClient.MachineClient); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check machine meets all necessary system requirements before proceeding.
|
||||||
|
checkResp, err := machineClient.CheckPrerequisites(ctx, &emptypb.Empty{})
|
||||||
|
// TODO(lhf): remove Unimplemented check when v0.9.0 is released.
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() != codes.Unimplemented {
|
||||||
|
return nil, fmt.Errorf("check machine prerequisites: %w", err)
|
||||||
|
}
|
||||||
|
} else if !checkResp.Satisfied {
|
||||||
|
return nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
req := &pb.InitClusterRequest{
|
req := &pb.InitClusterRequest{
|
||||||
MachineName: machineName,
|
MachineName: opts.MachineName,
|
||||||
Network: pb.NewIPPrefix(netPrefix),
|
Network: pb.NewIPPrefix(opts.Network),
|
||||||
User: &pb.User{
|
|
||||||
Network: &pb.NetworkConfig{
|
|
||||||
ManagementIp: pb.NewIP(user.ManagementIP()),
|
|
||||||
PublicKey: user.PublicKey(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
resp, err := machineClient.InitCluster(ctx, req)
|
if opts.PublicIP != nil {
|
||||||
if err != nil {
|
if opts.PublicIP.IsValid() {
|
||||||
return fmt.Errorf("init cluster: %w", err)
|
req.PublicIpConfig = &pb.InitClusterRequest_PublicIp{PublicIp: pb.NewIP(*opts.PublicIP)}
|
||||||
}
|
} else {
|
||||||
fmt.Printf("Cluster %q initialised with machine %q\n", clusterName, resp.Machine.Name)
|
// Invalid or in other words zero IP means to automatically detect the public IP.
|
||||||
|
req.PublicIpConfig = &pb.InitClusterRequest_PublicIpAuto{PublicIpAuto: true}
|
||||||
if err = cli.CreateCluster(clusterName, user.PrivateKey()); err != nil {
|
|
||||||
return fmt.Errorf("save cluster to config: %w", err)
|
|
||||||
}
|
|
||||||
// Set the current cluster to the just created one if it is the only cluster in the config.
|
|
||||||
if len(cli.config.Clusters) == 1 {
|
|
||||||
if err = cli.SetCurrentCluster(clusterName); err != nil {
|
|
||||||
return fmt.Errorf("set current cluster: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Save the machine's SSH connection details in the cluster config.
|
|
||||||
|
resp, err := machineClient.InitCluster(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init cluster: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Cluster initialised with machine '%s' and saved as context '%s' in your local config (%s)\n",
|
||||||
|
resp.Machine.Name, contextName, cli.Config.Path())
|
||||||
|
if err = cli.CreateContext(contextName); err != nil {
|
||||||
|
return nil, fmt.Errorf("save cluster context to config: %w", err)
|
||||||
|
}
|
||||||
|
if err = cli.SetCurrentContext(contextName); err != nil {
|
||||||
|
return nil, fmt.Errorf("set current cluster context: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Current cluster context is now '%s'.\n", contextName)
|
||||||
|
|
||||||
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port),
|
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
||||||
|
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||||
}
|
}
|
||||||
cli.config.Clusters[clusterName].Connections = append(cli.config.Clusters[clusterName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
return fmt.Errorf("save config: %w", err)
|
return nil, fmt.Errorf("save config: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return machineClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) AddMachine(ctx context.Context, remoteMachine RemoteMachine, clusterName, machineName string) error {
|
type AddMachineOptions struct {
|
||||||
c, err := cli.ConnectCluster(ctx, clusterName)
|
Context string
|
||||||
|
MachineName string
|
||||||
|
PublicIP *netip.Addr
|
||||||
|
RemoteMachine *RemoteMachine
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMachine provisions a remote machine and adds it to the cluster. It returns a cluster client and a machine client.
|
||||||
|
// The cluster client is connected to the existing machine in the cluster. It was used to add the new machine to the
|
||||||
|
// cluster. The machine client is connected to the new machine and can be used to interact with it.
|
||||||
|
// Both client should be closed after use by the caller.
|
||||||
|
func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client.Client, *client.Client, error) {
|
||||||
|
contextName := opts.Context
|
||||||
|
if contextName == "" {
|
||||||
|
contextName = cli.Config.CurrentContext
|
||||||
|
}
|
||||||
|
c, err := cli.ConnectCluster(ctx, contextName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return nil, nil, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = c.Close()
|
if err != nil {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
machineClient, err := provisionRemoteMachine(ctx, opts.RemoteMachine, opts.Version)
|
||||||
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return nil, nil, err
|
||||||
"SSH login to remote machine %s: %w",
|
|
||||||
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
exec := sshexec.NewRemote(sshClient)
|
defer func() {
|
||||||
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
if err != nil {
|
||||||
if err = provisionMachine(ctx, exec); err != nil {
|
machineClient.Close()
|
||||||
return fmt.Errorf("provision machine: %w", err)
|
}
|
||||||
}
|
}()
|
||||||
|
|
||||||
// Create a machine API client over the SSH connection to the remote machine.
|
|
||||||
machineClient, err := client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connect to remote machine: %w", err)
|
|
||||||
}
|
|
||||||
defer machineClient.Close()
|
|
||||||
|
|
||||||
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
// Check if the machine is already initialised as a cluster member and prompt the user to reset it first.
|
||||||
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
minfo, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect machine: %w", err)
|
return nil, nil, fmt.Errorf("inspect machine: %w", err)
|
||||||
}
|
}
|
||||||
if minfo.Id != "" {
|
if minfo.Id != "" {
|
||||||
if err = cli.promptResetMachine(); err != nil {
|
// Check if the machine is already a member of this cluster.
|
||||||
return err
|
machines, err := c.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list cluster machines: %w", err)
|
||||||
}
|
}
|
||||||
|
if slices.ContainsFunc(machines, func(m *pb.MachineMember) bool {
|
||||||
|
return m.Machine.Id == minfo.Id
|
||||||
|
}) {
|
||||||
|
return nil, nil, fmt.Errorf("machine is already a member of this cluster (%s)", minfo.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = promptResetMachine(ctx, machineClient.MachineClient); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check machine meets all necessary system requirements before proceeding.
|
||||||
|
checkResp, err := machineClient.CheckPrerequisites(ctx, &emptypb.Empty{})
|
||||||
|
// TODO(lhf): remove Unimplemented check when v0.9.0 is released.
|
||||||
|
if err != nil {
|
||||||
|
if status.Convert(err).Code() != codes.Unimplemented {
|
||||||
|
return nil, nil, fmt.Errorf("check machine prerequisites: %w", err)
|
||||||
|
}
|
||||||
|
} else if !checkResp.Satisfied {
|
||||||
|
return nil, nil, fmt.Errorf("machine prerequisites not satisfied: %s", checkResp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenResp, err := machineClient.Token(ctx, &emptypb.Empty{})
|
tokenResp, err := machineClient.Token(ctx, &emptypb.Empty{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get remote machine token: %w", err)
|
return nil, nil, fmt.Errorf("get remote machine token: %w", err)
|
||||||
}
|
}
|
||||||
token, err := machine.ParseToken(tokenResp.Token)
|
token, err := machine.ParseToken(tokenResp.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine token: %w", err)
|
return nil, nil, fmt.Errorf("parse remote machine token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the machine in the cluster using its public key and endpoints from the token.
|
// Register the machine in the cluster using its public key and endpoints from the token.
|
||||||
@@ -268,26 +335,35 @@ func (cli *CLI) AddMachine(ctx context.Context, remoteMachine RemoteMachine, clu
|
|||||||
endpoints[i] = pb.NewIPPort(addrPort)
|
endpoints[i] = pb.NewIPPort(addrPort)
|
||||||
}
|
}
|
||||||
addReq := &pb.AddMachineRequest{
|
addReq := &pb.AddMachineRequest{
|
||||||
Name: machineName,
|
Name: opts.MachineName,
|
||||||
Network: &pb.NetworkConfig{
|
Network: &pb.NetworkConfig{
|
||||||
Endpoints: endpoints,
|
Endpoints: endpoints,
|
||||||
PublicKey: token.PublicKey,
|
PublicKey: token.PublicKey,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
addResp, err := c.AddMachine(ctx, addReq)
|
if opts.PublicIP != nil {
|
||||||
if err != nil {
|
if opts.PublicIP.IsValid() {
|
||||||
return fmt.Errorf("add machine to cluster: %w", err)
|
addReq.PublicIp = pb.NewIP(*opts.PublicIP)
|
||||||
|
} else if token.PublicIP.IsValid() {
|
||||||
|
// Invalid or in other words zero IP means to use an automatically detected public IP from the token.
|
||||||
|
addReq.PublicIp = pb.NewIP(token.PublicIP)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List other machines in the cluster to include them in the join request.
|
addResp, err := c.AddMachine(ctx, addReq)
|
||||||
listResp, err := c.ListMachines(ctx, &emptypb.Empty{})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list cluster machines: %w", err)
|
return nil, nil, fmt.Errorf("add machine to cluster (context '%s'): %w", contextName, err)
|
||||||
}
|
}
|
||||||
otherMachines := make([]*pb.MachineInfo, 0, len(listResp.Machines)-1)
|
|
||||||
for _, m := range listResp.Machines {
|
// Get the most up-to-date list of other machines in the cluster to include them in the join request.
|
||||||
if m.Id != addResp.Machine.Id {
|
machines, err := c.ListMachines(ctx, nil)
|
||||||
otherMachines = append(otherMachines, m)
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list cluster machines: %w", err)
|
||||||
|
}
|
||||||
|
otherMachines := make([]*pb.MachineInfo, 0, len(machines)-1)
|
||||||
|
for _, m := range machines {
|
||||||
|
if m.Machine.Id != addResp.Machine.Id {
|
||||||
|
otherMachines = append(otherMachines, m.Machine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,85 +373,79 @@ func (cli *CLI) AddMachine(ctx context.Context, remoteMachine RemoteMachine, clu
|
|||||||
OtherMachines: otherMachines,
|
OtherMachines: otherMachines,
|
||||||
}
|
}
|
||||||
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
|
if _, err = machineClient.JoinCluster(ctx, joinReq); err != nil {
|
||||||
return fmt.Errorf("join cluster: %w", err)
|
return nil, nil, fmt.Errorf("join cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Machine %q added to cluster\n", addResp.Machine.Name)
|
// TODO: fix empty context name when using the current context (contextName == "").
|
||||||
|
fmt.Printf("Machine '%s' added to the cluster (context '%s').\n", addResp.Machine.Name, contextName)
|
||||||
|
|
||||||
// Save the machine's SSH connection details in the cluster config.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port),
|
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
||||||
|
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
||||||
}
|
}
|
||||||
if clusterName == "" {
|
if contextName == "" {
|
||||||
clusterName = cli.config.CurrentCluster
|
contextName = cli.Config.CurrentContext
|
||||||
}
|
}
|
||||||
cli.config.Clusters[clusterName].Connections = append(cli.config.Clusters[clusterName].Connections, connCfg)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
return fmt.Errorf("save config: %w", err)
|
return nil, nil, fmt.Errorf("save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return c, machineClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cli *CLI) promptResetMachine() error {
|
// provisionRemoteMachine installs the Uncloud daemon and dependencies on the remote machine over SSH and returns
|
||||||
var confirm bool
|
// a machine API client to interact with the machine. The client should be closed after use by the caller.
|
||||||
form := huh.NewForm(
|
// The version parameter specifies the version of the Uncloud daemon to install. If empty, the latest version is used.
|
||||||
huh.NewGroup(
|
// The remoteMachine.SSHKeyPath could be updated to the default SSH key path if it is not set and the SSH agent
|
||||||
huh.NewConfirm().
|
// authentication fails.
|
||||||
Title(
|
func provisionRemoteMachine(
|
||||||
"The remote machine is already initialised as a cluster member. Do you want to reset it first?",
|
ctx context.Context, remoteMachine *RemoteMachine, version string,
|
||||||
).
|
) (*client.Client, error) {
|
||||||
Affirmative("Yes!").
|
// Provision the remote machine by installing the Uncloud daemon and dependencies over SSH.
|
||||||
Negative("No").
|
sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath)
|
||||||
Value(&confirm),
|
// If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key.
|
||||||
),
|
if err != nil && remoteMachine.KeyPath == "" {
|
||||||
)
|
remoteMachine.KeyPath = DefaultSSHKeyPath
|
||||||
if err := form.Run(); err != nil {
|
sshClient, err = sshexec.Connect(
|
||||||
return fmt.Errorf("prompt user to confirm: %w", err)
|
remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !confirm {
|
|
||||||
return fmt.Errorf("remote machine is already initialised as a cluster member")
|
|
||||||
}
|
|
||||||
// TODO: implement resetting the remote machine.
|
|
||||||
return fmt.Errorf("resetting the remote machine is not implemented yet")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cli *CLI) ListMachines(ctx context.Context, clusterName string) error {
|
|
||||||
c, err := cli.ConnectCluster(ctx, clusterName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect to cluster: %w", err)
|
return nil, fmt.Errorf(
|
||||||
|
"SSH login to remote machine %s: %w",
|
||||||
|
config.NewSSHDestination(remoteMachine.User, remoteMachine.Host, remoteMachine.Port), err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
exec := sshexec.NewRemote(sshClient)
|
||||||
|
// Install and run the Uncloud daemon and dependencies on the remote machine.
|
||||||
|
if err = provisionMachine(ctx, exec, version); err != nil {
|
||||||
|
return nil, fmt.Errorf("provision machine: %w", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
_ = c.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
listResp, err := c.ListMachines(ctx, &emptypb.Empty{})
|
var machineClient *client.Client
|
||||||
|
if remoteMachine.User == "root" {
|
||||||
|
// Create a machine API client over the established SSH connection to the remote machine.
|
||||||
|
machineClient, err = client.New(ctx, connector.NewSSHConnectorFromClient(sshClient))
|
||||||
|
} else {
|
||||||
|
// Since the user is not root, we need to establish a new SSH connection to make the user's addition
|
||||||
|
// to the uncloud group effective, thus allowing access to the Uncloud daemon Unix socket.
|
||||||
|
sshConfig := &connector.SSHConnectorConfig{
|
||||||
|
User: remoteMachine.User,
|
||||||
|
Host: remoteMachine.Host,
|
||||||
|
Port: remoteMachine.Port,
|
||||||
|
KeyPath: remoteMachine.KeyPath,
|
||||||
|
}
|
||||||
|
machineClient, err = client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list machines: %w", err)
|
return nil, fmt.Errorf("connect to remote machine: %w", err)
|
||||||
}
|
}
|
||||||
|
return machineClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Print the list of machines in a table format.
|
// ProgressOut returns an output stream for progress writer.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
func (cli *CLI) ProgressOut() *streams.Out {
|
||||||
// Print header.
|
return streams.NewOut(os.Stdout)
|
||||||
// TODO: print ADDRESS instead of SUBNET which is a machine IP with prefix.
|
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tSUBNET\tPUBLIC KEY\tENDPOINTS"); err != nil {
|
|
||||||
return fmt.Errorf("write header: %w", err)
|
|
||||||
}
|
|
||||||
// Print rows.
|
|
||||||
for _, m := range listResp.Machines {
|
|
||||||
subnet, _ := m.Network.Subnet.ToPrefix()
|
|
||||||
endpoints := make([]string, len(m.Network.Endpoints))
|
|
||||||
for i, ep := range m.Network.Endpoints {
|
|
||||||
addrPort, _ := ep.ToAddrPort()
|
|
||||||
endpoints[i] = addrPort.String()
|
|
||||||
}
|
|
||||||
publicKey := secret.Secret(m.Network.PublicKey)
|
|
||||||
if _, err = fmt.Fprintf(
|
|
||||||
tw, "%s\t%s\t%s\t%s\n", m.Name, subnet, publicKey, strings.Join(endpoints, ", "),
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("write row: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tw.Flush()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"uncloud/internal/machine/api/pb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client is a client for the machine API.
|
|
||||||
type Client struct {
|
|
||||||
connector Connector
|
|
||||||
conn *grpc.ClientConn
|
|
||||||
|
|
||||||
pb.MachineClient
|
|
||||||
pb.ClusterClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connector is an interface for establishing a connection to the machine API.
|
|
||||||
type Connector interface {
|
|
||||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new client for the machine API. The connector is used to establish the connection
|
|
||||||
// either locally or remotely. The client is responsible for closing the connector.
|
|
||||||
func New(ctx context.Context, connector Connector) (*Client, error) {
|
|
||||||
c := &Client{
|
|
||||||
connector: connector,
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
c.conn, err = connector.Connect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("connect to machine: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.MachineClient = pb.NewMachineClient(c.conn)
|
|
||||||
c.ClusterClient = pb.NewClusterClient(c.conn)
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
|
||||||
err := c.conn.Close()
|
|
||||||
return errors.Join(err, c.connector.Close())
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import "uncloud/internal/secret"
|
|
||||||
|
|
||||||
type Cluster struct {
|
|
||||||
Name string `toml:"-"`
|
|
||||||
Connections []MachineConnection `toml:"connections"`
|
|
||||||
// UserPrivateKey is the user's WireGuard private key used to connect to cluster machines.
|
|
||||||
UserPrivateKey secret.Secret `toml:"user_private_key"`
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,15 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/BurntSushi/toml"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Clusters map[string]*Cluster `toml:"clusters"`
|
CurrentContext string `yaml:"current_context"`
|
||||||
CurrentCluster string `toml:"current_cluster"`
|
Contexts map[string]*Context `yaml:"contexts"`
|
||||||
|
|
||||||
// path is the file path config is read from.
|
// path is the file path config is read from.
|
||||||
path string
|
path string
|
||||||
@@ -18,10 +19,10 @@ type Config struct {
|
|||||||
func NewFromFile(path string) (*Config, error) {
|
func NewFromFile(path string) (*Config, error) {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("check file permissions %q: %w", path, err)
|
return nil, fmt.Errorf("check file permissions '%s': %w", path, err)
|
||||||
}
|
}
|
||||||
c := &Config{
|
c := &Config{
|
||||||
Clusters: map[string]*Cluster{},
|
Contexts: map[string]*Context{},
|
||||||
path: path,
|
path: path,
|
||||||
}
|
}
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -34,30 +35,37 @@ func NewFromFile(path string) (*Config, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) Path() string {
|
||||||
|
return c.path
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) Read() error {
|
func (c *Config) Read() error {
|
||||||
_, err := toml.DecodeFile(c.path, c)
|
data, err := os.ReadFile(c.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read config file %q: %w", c.path, err)
|
return fmt.Errorf("read config file '%s': %w", c.path, err)
|
||||||
}
|
}
|
||||||
|
if err = yaml.Unmarshal(data, c); err != nil {
|
||||||
|
return fmt.Errorf("parse config file '%s': %s", c.path, yaml.FormatError(err, true, true))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
dir, _ := filepath.Split(c.path)
|
dir, _ := filepath.Split(c.path)
|
||||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return fmt.Errorf("create config directory %q: %w", dir, err)
|
return fmt.Errorf("create config directory '%s': %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write config file %q: %w", c.path, err)
|
return fmt.Errorf("write config file '%s': %w", c.path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := toml.NewEncoder(f)
|
encoder := yaml.NewEncoder(f, yaml.Indent(2), yaml.IndentSequence(true))
|
||||||
encoder.Indent = ""
|
|
||||||
if err = encoder.Encode(c); err != nil {
|
if err = encoder.Encode(c); err != nil {
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
return fmt.Errorf("encode config file %q: %w", c.path, err)
|
return fmt.Errorf("encode config file '%s': %w", c.path, err)
|
||||||
}
|
}
|
||||||
return f.Close()
|
return f.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"uncloud/internal/secret"
|
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -13,9 +15,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type MachineConnection struct {
|
type MachineConnection struct {
|
||||||
SSH SSHDestination `toml:"ssh,omitempty"`
|
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||||
Host string `toml:"host,omitempty"`
|
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
||||||
PublicKey secret.Secret `toml:"public_key,omitempty"`
|
// TCP is the address and port of the machine's API server.
|
||||||
|
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
||||||
|
TCP *netip.AddrPort `yaml:"tcp,omitempty"`
|
||||||
|
Host string `yaml:"host,omitempty"`
|
||||||
|
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
Name string `yaml:"-"`
|
||||||
|
Connections []MachineConnection `yaml:"connections"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExpandCommaSeparatedValues takes a slice of strings and expands any comma-separated values into individual elements.
|
||||||
|
func ExpandCommaSeparatedValues(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var expanded []string
|
||||||
|
for _, value := range values {
|
||||||
|
for _, v := range strings.Split(value, ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
expanded = append(expanded, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
+110
-13
@@ -4,11 +4,21 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/sshexec"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: support pinning the script version to the CLI version.
|
const (
|
||||||
const installScriptURL = "https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh"
|
// TODO: support pinning the script version to the CLI version.
|
||||||
|
installScriptURL = "https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/install.sh"
|
||||||
|
rootUser = "root"
|
||||||
|
)
|
||||||
|
|
||||||
type RemoteMachine struct {
|
type RemoteMachine struct {
|
||||||
User string
|
User string
|
||||||
@@ -17,26 +27,113 @@ type RemoteMachine struct {
|
|||||||
KeyPath string
|
KeyPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installCmd(user string, version string) string {
|
||||||
|
sudoPrefix := ""
|
||||||
|
var env []string
|
||||||
|
|
||||||
|
// Add the SSH user (non-root) to the uncloud group to allow access to the Uncloud daemon unix socket.
|
||||||
|
if user != rootUser {
|
||||||
|
sudoPrefix = "sudo"
|
||||||
|
env = append(env, "UNCLOUD_GROUP_ADD_USER="+sshexec.Quote(user))
|
||||||
|
}
|
||||||
|
if version != "" {
|
||||||
|
env = append(env, "UNCLOUD_VERSION="+sshexec.Quote(version))
|
||||||
|
}
|
||||||
|
|
||||||
|
envCmd := strings.Join(env, " ")
|
||||||
|
curlBashCmd := fmt.Sprintf("curl -fsSL %s | %s %s bash", sshexec.Quote(installScriptURL), sudoPrefix, envCmd)
|
||||||
|
|
||||||
|
return curlBashCmd
|
||||||
|
}
|
||||||
|
|
||||||
// provisionMachine provisions the remote machine by downloading the Uncloud install script from GitHub and running it.
|
// provisionMachine provisions the remote machine by downloading the Uncloud install script from GitHub and running it.
|
||||||
func provisionMachine(ctx context.Context, exec sshexec.Executor) error {
|
// If version is specified, it will be passed to the install script as UNCLOUD_VERSION environment variable.
|
||||||
|
func provisionMachine(ctx context.Context, exec sshexec.Executor, version string) error {
|
||||||
user, err := exec.Run(ctx, "whoami")
|
user, err := exec.Run(ctx, "whoami")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run whoami: %w", err)
|
return fmt.Errorf("run whoami: %w", err)
|
||||||
}
|
}
|
||||||
sudoPrefix, env := "", ""
|
|
||||||
if user != "root" {
|
if user != rootUser {
|
||||||
sudoPrefix = "sudo"
|
// 'sudo -n' is not used because it fails with 'sudo: a password is required' when the user has no password
|
||||||
// Add the SSH user (non-root) to the uncloud group to allow access to the Uncloud daemon unix socket.
|
// in /etc/shadow even though it may have valid sudo access.
|
||||||
env = "UNCLOUD_GROUP_ADD_USER=" + user
|
out, err := exec.Run(ctx, "sudo true")
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(out, "password is required") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"user '%[1]s' requires a password for sudo, but Uncloud needs passwordless sudo or root access "+
|
||||||
|
"to install and configure the uncloudd daemon on the remote machine.\n\n"+
|
||||||
|
"Possible solutions:\n"+
|
||||||
|
"1. Use root user or a user with passwordless sudo instead.\n"+
|
||||||
|
"2. Configure passwordless sudo for the user '%[1]s' by running on the remote machine:\n"+
|
||||||
|
" echo '%[1]s ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/%[1]s",
|
||||||
|
user)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("sudo command failed for user '%s': %w. "+
|
||||||
|
"Please ensure the user has sudo privileges or use root user instead", user, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd := installCmd(user, version)
|
||||||
|
|
||||||
fmt.Println("Downloading Uncloud install script:", installScriptURL)
|
fmt.Println("Downloading Uncloud install script:", installScriptURL)
|
||||||
curlBashCmd := fmt.Sprintf(
|
|
||||||
"curl -fsSL %s | %s %s bash", sshexec.Quote(installScriptURL), sudoPrefix, sshexec.Quote(env),
|
cmd = sshexec.QuoteCommand("bash", "-c", "set -o pipefail; "+cmd)
|
||||||
)
|
|
||||||
cmd := sshexec.QuoteCommand("bash", "-c", "set -o pipefail; "+curlBashCmd)
|
|
||||||
if err = exec.Stream(ctx, cmd, os.Stdout, os.Stderr); err != nil {
|
if err = exec.Stream(ctx, cmd, os.Stdout, os.Stderr); err != nil {
|
||||||
return fmt.Errorf("download and run install script: %w", err)
|
return fmt.Errorf("download and run install script: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func promptResetMachine(ctx context.Context, machineClient pb.MachineClient) error {
|
||||||
|
var confirm bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title(
|
||||||
|
"The remote machine is already initialised as a cluster member. Do you want to reset it first?\n" +
|
||||||
|
"This will:\n" +
|
||||||
|
"- Remove all service containers from the machine\n" +
|
||||||
|
"- Reset the machine to the uninitialised state",
|
||||||
|
).
|
||||||
|
Affirmative("Yes!").
|
||||||
|
Negative("No").
|
||||||
|
Value(&confirm),
|
||||||
|
),
|
||||||
|
).WithAccessible(true)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return fmt.Errorf("prompt user to confirm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !confirm {
|
||||||
|
return fmt.Errorf("remote machine is already initialised as a cluster member")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := machineClient.Reset(ctx, &pb.ResetRequest{}); err != nil {
|
||||||
|
return fmt.Errorf("reset remote machine: %w. You can also manually run 'uncloud-uninstall' "+
|
||||||
|
"on the remote machine to fully uninstall Uncloud from it", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Resetting the remote machine...")
|
||||||
|
if err := waitMachineReady(ctx, machineClient, 1*time.Minute); err != nil {
|
||||||
|
return fmt.Errorf("wait for machine to be ready after reset: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitMachineReady waits for the machine to be ready to serve requests.
|
||||||
|
func waitMachineReady(ctx context.Context, machineClient pb.MachineClient, timeout time.Duration) error {
|
||||||
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(timeout),
|
||||||
|
), ctx)
|
||||||
|
|
||||||
|
inspect := func() error {
|
||||||
|
_, err := machineClient.Inspect(ctx, &emptypb.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect machine: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return backoff.Retry(inspect, boff)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstallCmd(t *testing.T) {
|
||||||
|
t.Run("root", func(t *testing.T) {
|
||||||
|
cmd := installCmd("root", "")
|
||||||
|
assert.NotContains(t, cmd, "sudo")
|
||||||
|
assert.NotContains(t, cmd, "UNCLOUD_GROUP_ADD_USER")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test with version
|
||||||
|
t.Run("root with version", func(t *testing.T) {
|
||||||
|
cmd := installCmd("root", "v1.2.3")
|
||||||
|
assert.NotContains(t, cmd, "sudo")
|
||||||
|
assert.NotContains(t, cmd, "UNCLOUD_GROUP_ADD_USER")
|
||||||
|
assert.Contains(t, cmd, "UNCLOUD_VERSION=v1.2.3")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nonroot", func(t *testing.T) {
|
||||||
|
cmd := installCmd("nonroot", "")
|
||||||
|
assert.Contains(t, cmd, "sudo")
|
||||||
|
assert.Contains(t, cmd, "UNCLOUD_GROUP_ADD_USER=nonroot")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nonroot with version", func(t *testing.T) {
|
||||||
|
cmd := installCmd("nonroot", "v1.2.3")
|
||||||
|
assert.Contains(t, cmd, "sudo")
|
||||||
|
assert.Contains(t, cmd, "UNCLOUD_GROUP_ADD_USER=nonroot")
|
||||||
|
assert.Contains(t, cmd, "UNCLOUD_VERSION=v1.2.3")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
func Confirm() (bool, error) {
|
||||||
|
var confirmed bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title(
|
||||||
|
"Do you want to continue?",
|
||||||
|
).
|
||||||
|
Affirmative("Yes!").
|
||||||
|
Negative("No").
|
||||||
|
Value(&confirmed),
|
||||||
|
),
|
||||||
|
).WithAccessible(true)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package corrosion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminClient is a client for the Corrosion admin API.
|
||||||
|
type AdminClient struct {
|
||||||
|
sockPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminClient(sockPath string) (*AdminClient, error) {
|
||||||
|
return &AdminClient{sockPath: sockPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
JSON map[string]any
|
||||||
|
// Err is set if the response is an error or if an error occurred while processing the response.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCommand sends a command to the Corrosion admin API and returns a channel that will receive responses.
|
||||||
|
// The channel will be closed after sending the last or error response. The caller must read from the channel until
|
||||||
|
// it is closed.
|
||||||
|
func (c *AdminClient) SendCommand(cmd []byte) (<-chan Response, error) {
|
||||||
|
conn, err := net.Dial("unix", c.sockPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to admin socket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = conn.Write(encodeFrame(cmd)); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("send command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan Response)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
r := Response{}
|
||||||
|
|
||||||
|
data, err := readFrame(conn)
|
||||||
|
if err != nil {
|
||||||
|
r.Err = err
|
||||||
|
ch <- r
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded any
|
||||||
|
if err = json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
r.Err = fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
ch <- r
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := decoded.(type) {
|
||||||
|
case string:
|
||||||
|
if v == "Success" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Ignore other strings.
|
||||||
|
case map[string]any:
|
||||||
|
if errData, ok := v["Error"].(map[string]any); ok {
|
||||||
|
if errMsg, ok := errData["msg"].(string); ok {
|
||||||
|
r.Err = errors.New(errMsg)
|
||||||
|
} else {
|
||||||
|
r.Err = fmt.Errorf("invalid error response: %v", errData)
|
||||||
|
}
|
||||||
|
ch <- r
|
||||||
|
return
|
||||||
|
} else if jsonData, ok := v["Json"].(map[string]any); ok {
|
||||||
|
r.JSON = jsonData
|
||||||
|
ch <- r
|
||||||
|
}
|
||||||
|
// Ignore other maps.
|
||||||
|
default:
|
||||||
|
// Ignore other types.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeFrame encodes a length_delimited Tokio frame by prefacing frame data with a frame head that specifies
|
||||||
|
// the length of the frame.
|
||||||
|
func encodeFrame(data []byte) []byte {
|
||||||
|
encoded := make([]byte, 4+len(data))
|
||||||
|
binary.BigEndian.PutUint32(encoded, uint32(len(data)))
|
||||||
|
copy(encoded[4:], data)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrame reads a length_delimited Tokio frame from the connection by extracting the frame data that follows
|
||||||
|
// the frame head.
|
||||||
|
func readFrame(conn net.Conn) ([]byte, error) {
|
||||||
|
// Read the frame head (4 bytes).
|
||||||
|
head := make([]byte, 4)
|
||||||
|
if _, err := io.ReadFull(conn, head); err != nil {
|
||||||
|
return nil, fmt.Errorf("read frame head: %w", err)
|
||||||
|
}
|
||||||
|
// Read the frame data (length specified in the frame head).
|
||||||
|
length := binary.BigEndian.Uint32(head)
|
||||||
|
data := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(conn, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("read frame data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClusterMembershipState struct {
|
||||||
|
ID string
|
||||||
|
Addr netip.AddrPort
|
||||||
|
State string
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// MembershipStateAlive indicates that the member is active.
|
||||||
|
MembershipStateAlive string = "Alive"
|
||||||
|
// MembershipStateSuspect indicates that the member is active, but at least one cluster member suspects its down.
|
||||||
|
// For all purposes, a Suspect member is treated as if it were Alive until either it refutes the suspicion
|
||||||
|
// (becoming Alive) or fails to do so (being declared Down).
|
||||||
|
MembershipStateSuspect string = "Suspect"
|
||||||
|
// MembershipStateDown indicates that the member is confirmed Down. A member that reaches this state can't join
|
||||||
|
// the cluster with the same identity until the cluster forgets this knowledge.
|
||||||
|
MembershipStateDown string = "Down"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseClusterMembershipState(json map[string]any) (ClusterMembershipState, error) {
|
||||||
|
// Example JSON:
|
||||||
|
// {
|
||||||
|
// "id": {
|
||||||
|
// "addr": "[fdcc:1d51:6bae:6bb2:53c0:8796:1be0:b783]:51001",
|
||||||
|
// "cluster_id": 0,
|
||||||
|
// "id": "10d69d6f-6578-4dcf-a285-e860e40c5f06",
|
||||||
|
// "ts": 7435936225798880256
|
||||||
|
// },
|
||||||
|
// "incarnation": 0,
|
||||||
|
// "state": "Down"
|
||||||
|
// }
|
||||||
|
|
||||||
|
var state ClusterMembershipState
|
||||||
|
var err error
|
||||||
|
|
||||||
|
idObj, ok := json["id"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return state, fmt.Errorf("missing or invalid 'id' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID
|
||||||
|
if id, ok := idObj["id"].(string); ok {
|
||||||
|
state.ID = id
|
||||||
|
} else {
|
||||||
|
return state, fmt.Errorf("missing or invalid 'id' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr
|
||||||
|
if addr, ok := idObj["addr"].(string); ok {
|
||||||
|
state.Addr, err = netip.ParseAddrPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return state, fmt.Errorf("parse 'addr' field: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return state, fmt.Errorf("missing or invalid 'addr' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
if stateStr, ok := json["state"].(string); ok {
|
||||||
|
switch stateStr {
|
||||||
|
case MembershipStateAlive, MembershipStateSuspect, MembershipStateDown:
|
||||||
|
state.State = stateStr
|
||||||
|
default:
|
||||||
|
return state, fmt.Errorf("invalid 'state' field: %s", stateStr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return state, fmt.Errorf("missing or invalid 'state' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
if ts, ok := idObj["ts"].(float64); ok {
|
||||||
|
state.Timestamp = ntp64ToTime(uint64(ts))
|
||||||
|
} else {
|
||||||
|
return state, fmt.Errorf("missing or invalid 'ts' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nt64ToTime converts a 64-bit NTP timestamp relative to the Unix epoch (1st Jan 1970) to time.Time.
|
||||||
|
// See for more details: https://datatracker.ietf.org/doc/html/rfc5905#section-6
|
||||||
|
func ntp64ToTime(ntp uint64) time.Time {
|
||||||
|
// The NTP timestamp returned from Corrosion is relative to the Unix epoch (1st Jan 1970)
|
||||||
|
// so no need to subtract the 70 years offset.
|
||||||
|
secs := uint32(ntp >> 32)
|
||||||
|
frac := uint32(ntp)
|
||||||
|
// Convert the fraction to nanoseconds: frac * 1e9 / 2^32
|
||||||
|
nsecs := (uint64(frac) * 1000_000_000) >> 32
|
||||||
|
|
||||||
|
return time.Unix(int64(secs), int64(nsecs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterMembershipStates returns the current membership SWIM states of all cluster members.
|
||||||
|
// If latest is true, only the latest state of each member is returned.
|
||||||
|
func (c *AdminClient) ClusterMembershipStates(latest bool) ([]ClusterMembershipState, error) {
|
||||||
|
respCh, err := c.SendCommand([]byte("{\"Cluster\":\"MembershipStates\"}"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
states []ClusterMembershipState
|
||||||
|
latestStates map[string]ClusterMembershipState
|
||||||
|
parseErr error
|
||||||
|
)
|
||||||
|
if latest {
|
||||||
|
latestStates = make(map[string]ClusterMembershipState)
|
||||||
|
}
|
||||||
|
|
||||||
|
for r := range respCh {
|
||||||
|
if r.Err != nil {
|
||||||
|
// It's safe to return here because the channel is closed after the first error response.
|
||||||
|
return nil, r.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := parseClusterMembershipState(r.JSON)
|
||||||
|
if err != nil {
|
||||||
|
// Do not return early to drain the channel.
|
||||||
|
parseErr = errors.Join(parseErr, err)
|
||||||
|
} else {
|
||||||
|
if latest {
|
||||||
|
if existing, ok := latestStates[s.ID]; !ok || existing.Timestamp.Before(s.Timestamp) {
|
||||||
|
latestStates[s.ID] = s
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
states = append(states, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest {
|
||||||
|
states = make([]ClusterMembershipState, 0, len(latestStates))
|
||||||
|
for _, s := range latestStates {
|
||||||
|
states = append(states, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return states, parseErr
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package corrosion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// http2ConnectTimeout is the maximum amount of time an HTTP2 client will wait for a connection to be established.
|
||||||
|
http2ConnectTimeout = 3 * time.Second
|
||||||
|
// http2MaxRetryTime is the maximum amount of time an HTTP2 client will retry a request.
|
||||||
|
http2MaxRetryTime = 10 * time.Second
|
||||||
|
// resubscribeMaxRetryTime is the maximum amount of time an API client will retry resubscribing to a query after
|
||||||
|
// an error occurs.
|
||||||
|
resubscribeMaxRetryTime = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIClient is a client for the Corrosion API.
|
||||||
|
type APIClient struct {
|
||||||
|
baseURL *url.URL
|
||||||
|
client *http.Client
|
||||||
|
newResubBackoff func() backoff.BackOff
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPIClient creates a new Corrosion API client. The client retries on network errors using an exponential backoff
|
||||||
|
// policy with a maximum interval of 1 second and a maximum elapsed time of 10 seconds.
|
||||||
|
// It automatically resubscribes to active subscriptions if an error occurs using an exponential backoff policy with a
|
||||||
|
// maximum interval of 1 second and a maximum elapsed time of 60 seconds.
|
||||||
|
// Use the WithHTTP2Client option to provide a custom HTTP client and the WithResubscribeBackoff option to change the
|
||||||
|
// backoff policy for resubscribing to a query.
|
||||||
|
func NewAPIClient(addr netip.AddrPort, opts ...APIClientOption) (*APIClient, error) {
|
||||||
|
baseURL, err := url.Parse(fmt.Sprintf("http://%s", addr))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
c := &APIClient{
|
||||||
|
baseURL: baseURL,
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: &RetryRoundTripper{
|
||||||
|
Base: &http2.Transport{
|
||||||
|
AllowHTTP: true,
|
||||||
|
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: http2ConnectTimeout,
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NewBackoff: func() backoff.BackOff {
|
||||||
|
return backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithInitialInterval(100*time.Millisecond),
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(http2MaxRetryTime),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
newResubBackoff: func() backoff.BackOff {
|
||||||
|
return backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithInitialInterval(100*time.Millisecond),
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(resubscribeMaxRetryTime),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(c)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIClientOption func(*APIClient)
|
||||||
|
|
||||||
|
func WithHTTP2Client(client *http.Client) APIClientOption {
|
||||||
|
return func(c *APIClient) {
|
||||||
|
c.client = client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithResubscribeBackoff sets the backoff policy for resubscribing to a query if an error occurs.
|
||||||
|
// Pass nil to disable resubscribing.
|
||||||
|
func WithResubscribeBackoff(newBackoff func() backoff.BackOff) APIClientOption {
|
||||||
|
return func(c *APIClient) {
|
||||||
|
c.newResubBackoff = newBackoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetryRoundTripper struct {
|
||||||
|
Base http.RoundTripper
|
||||||
|
// NewBackoff creates a new backoff policy for each request.
|
||||||
|
NewBackoff func() backoff.BackOff
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
roundTrip := func() (*http.Response, error) {
|
||||||
|
resp, err := rt.Base.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
// Not certain, but I expect operational errors should generally be retryable.
|
||||||
|
slog.Debug("Retrying corrosion API request due to network error.", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Don't retry on other errors.
|
||||||
|
return nil, backoff.Permanent(err)
|
||||||
|
}
|
||||||
|
// Success, don't retry.
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
boff := backoff.WithContext(rt.NewBackoff(), req.Context())
|
||||||
|
return backoff.RetryWithData(roundTrip, boff)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetrySubscription struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
client *APIClient
|
||||||
|
sub *Subscription
|
||||||
|
changes chan *ChangeEvent
|
||||||
|
lastChangeID uint64
|
||||||
|
err error
|
||||||
|
backoff backoff.BackOff
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRetrySubscription(ctx context.Context, client *APIClient, sub *Subscription, boff backoff.BackOff) *RetrySubscription {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
if boff == nil {
|
||||||
|
boff = backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithInitialInterval(100*time.Millisecond),
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(60*time.Second),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return &RetrySubscription{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
client: client,
|
||||||
|
sub: sub,
|
||||||
|
backoff: backoff.WithContext(boff, ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RetrySubscription) ID() string {
|
||||||
|
return rs.sub.ID()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RetrySubscription) Changes() (<-chan *ChangeEvent, error) {
|
||||||
|
if rs.changes != nil {
|
||||||
|
return rs.changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the rows are consumed if they have been requested.
|
||||||
|
changes, err := rs.sub.Changes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rs.lastChangeID = rs.sub.lastChangeID
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(rs.changes)
|
||||||
|
|
||||||
|
var change *ChangeEvent
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case change = <-changes:
|
||||||
|
case <-rs.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if change != nil {
|
||||||
|
select {
|
||||||
|
case rs.changes <- change:
|
||||||
|
case <-rs.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rs.lastChangeID = change.ChangeID
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The underlying subscription has been closed due to an error or context cancellation.
|
||||||
|
// Return if the context is done or try to resubscribe otherwise.
|
||||||
|
if rs.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = rs.resubscribe(); err != nil {
|
||||||
|
// resubscribe returns a permanent error after unsuccessful retries.
|
||||||
|
rs.err = fmt.Errorf("resubscribe to query: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changes, err = rs.sub.Changes()
|
||||||
|
if err != nil {
|
||||||
|
// Unexpected error but report it anyway.
|
||||||
|
rs.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return rs.changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RetrySubscription) resubscribe() error {
|
||||||
|
return backoff.Retry(func() error {
|
||||||
|
slog.Debug("Resubscribing to Corrosion query.", "id", rs.ID(), "from_change", rs.lastChangeID)
|
||||||
|
sub, err := rs.client.ResubscribeContext(rs.ctx, rs.ID(), rs.lastChangeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rs.sub = sub
|
||||||
|
return nil
|
||||||
|
}, rs.backoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RetrySubscription) Err() error {
|
||||||
|
return rs.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *RetrySubscription) Close() {
|
||||||
|
rs.cancel()
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package corrosion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Statement struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Params []any `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecResponse struct {
|
||||||
|
Results []ExecResult `json:"results"`
|
||||||
|
Time float64 `json:"time"`
|
||||||
|
Version *uint `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecResult struct {
|
||||||
|
RowsAffected uint `json:"rows_affected"`
|
||||||
|
Time float64 `json:"time"`
|
||||||
|
Error *string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecContext writes changes to the Corrosion database for propagation through the cluster. The args are for any
|
||||||
|
// placeholder parameters in the query. Corrosion does not sync schema changes made using this method. Use Corrosion's
|
||||||
|
// schema_files to create and update the cluster's database schema.
|
||||||
|
func (c *APIClient) ExecContext(ctx context.Context, query string, args ...any) (*ExecResult, error) {
|
||||||
|
statements := []Statement{
|
||||||
|
{
|
||||||
|
Query: query,
|
||||||
|
Params: args,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := c.ExecMultiContext(ctx, statements...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Results) == 0 {
|
||||||
|
return nil, fmt.Errorf("no results: %+v", resp)
|
||||||
|
}
|
||||||
|
return &resp.Results[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecMultiContext writes changes to the Corrosion database for propagation through the cluster.
|
||||||
|
// Unlike ExecContext, this method allows multiple statements to be executed in a single transaction.
|
||||||
|
func (c *APIClient) ExecMultiContext(ctx context.Context, statements ...Statement) (*ExecResponse, error) {
|
||||||
|
body, err := json.Marshal(statements)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal queries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionsURL := c.baseURL.JoinPath("/v1/transactions").String()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", transactionsURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var execResp ExecResponse
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
if err = json.NewDecoder(resp.Body).Decode(&execResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
// The response may still contain DB errors even if the status code is OK. Return them along with the response.
|
||||||
|
var errs []error
|
||||||
|
for _, result := range execResp.Results {
|
||||||
|
if result.Error != nil {
|
||||||
|
errs = append(errs, errors.New(*result.Error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &execResp, errors.Join(errs...)
|
||||||
|
} else if resp.StatusCode == http.StatusInternalServerError {
|
||||||
|
// If the response is an Internal Server Error, the response body may contain the error encoded as ExecResponse.
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(respBody, &execResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("internal server error: %s", respBody)
|
||||||
|
}
|
||||||
|
if len(execResp.Results) > 0 && execResp.Results[0].Error != nil {
|
||||||
|
return nil, errors.New(*execResp.Results[0].Error)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("internal server error: %s", respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryEvent struct {
|
||||||
|
Columns []string `json:"columns"`
|
||||||
|
Row *RowEvent `json:"row"`
|
||||||
|
EOQ *EndOfQuery `json:"eoq"`
|
||||||
|
Change *ChangeEvent `json:"change"`
|
||||||
|
// Error is a server-side error that occurred during query execution. It's considered fatal for the client
|
||||||
|
// as it cannot be recovered from server-side.
|
||||||
|
Error *string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EndOfQuery struct {
|
||||||
|
Time float64 `json:"time"`
|
||||||
|
ChangeID *uint64 `json:"change_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowEvent struct {
|
||||||
|
RowID uint64
|
||||||
|
Values []json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (re *RowEvent) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw []json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return fmt.Errorf("invalid row event: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) != 2 {
|
||||||
|
return fmt.Errorf("invalid row event: expected an array of 2 elements")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw[0], &re.RowID); err != nil {
|
||||||
|
return fmt.Errorf("invalid row event: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw[1], &re.Values); err != nil {
|
||||||
|
return fmt.Errorf("invalid row event: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (re *RowEvent) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal([]any{re.RowID, re.Values})
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryContext executes a query that returns rows, typically a SELECT.
|
||||||
|
// The args are for any placeholder parameters in the query.
|
||||||
|
func (c *APIClient) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) {
|
||||||
|
statement := Statement{
|
||||||
|
Query: query,
|
||||||
|
Params: args,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(statement)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
queriesURL := c.baseURL.JoinPath("/v1/queries").String()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", queriesURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := newRows(ctx, resp.Body, true)
|
||||||
|
if err != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("parse query response: %w", err)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows is the result of a query. Its cursor starts before the first row of the result set.
|
||||||
|
// Use [Rows.Next] to advance from row to row.
|
||||||
|
type Rows struct {
|
||||||
|
ctx context.Context
|
||||||
|
body io.ReadCloser
|
||||||
|
decoder *json.Decoder
|
||||||
|
eoq *EndOfQuery
|
||||||
|
closeOnEOQ bool
|
||||||
|
|
||||||
|
columns []string
|
||||||
|
row RowEvent
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRows(ctx context.Context, body io.ReadCloser, closeOnEOQ bool) (*Rows, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(body)
|
||||||
|
var e QueryEvent
|
||||||
|
if err := decoder.Decode(&e); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode query event: %w", err)
|
||||||
|
}
|
||||||
|
if e.Columns == nil {
|
||||||
|
return nil, fmt.Errorf("expected columns event, got: %+v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Rows{
|
||||||
|
ctx: ctx,
|
||||||
|
body: body,
|
||||||
|
decoder: decoder,
|
||||||
|
closeOnEOQ: closeOnEOQ,
|
||||||
|
columns: e.Columns,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns returns the column names.
|
||||||
|
func (rs *Rows) Columns() []string {
|
||||||
|
return rs.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next prepares the next result row for reading with the [Rows.Scan] method. It returns true on success, or false
|
||||||
|
// if there is no next result row or an error happened while preparing it. [Rows.Err] should be consulted to distinguish
|
||||||
|
// between the two cases.
|
||||||
|
//
|
||||||
|
// Every call to [Rows.Scan], even the first one, must be preceded by a call to [Rows.Next].
|
||||||
|
func (rs *Rows) Next() bool {
|
||||||
|
select {
|
||||||
|
case <-rs.ctx.Done():
|
||||||
|
rs.err = rs.ctx.Err()
|
||||||
|
_ = rs.Close()
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
var e QueryEvent
|
||||||
|
if err := rs.decoder.Decode(&e); err != nil {
|
||||||
|
rs.err = fmt.Errorf("decode query event: %w", err)
|
||||||
|
_ = rs.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Server-side query error.
|
||||||
|
if e.Error != nil {
|
||||||
|
rs.err = fmt.Errorf("query error: %s", *e.Error)
|
||||||
|
_ = rs.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Row != nil {
|
||||||
|
if len(e.Row.Values) != len(rs.columns) {
|
||||||
|
rs.err = fmt.Errorf("expected %d column values, got %d", len(rs.columns), len(e.Row.Values))
|
||||||
|
_ = rs.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rs.row = *e.Row
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if e.EOQ != nil {
|
||||||
|
rs.eoq = e.EOQ
|
||||||
|
// Rows could be used as part of a subscription, so don't close the body if so.
|
||||||
|
if rs.closeOnEOQ {
|
||||||
|
_ = rs.Close()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rs.err = fmt.Errorf("expected row or eof event, got: %+v", e)
|
||||||
|
_ = rs.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err returns the error, if any, that was encountered during iteration.
|
||||||
|
// Err may be called after an explicit or implicit [Rows.Close].
|
||||||
|
func (rs *Rows) Err() error {
|
||||||
|
return rs.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan copies the columns in the current row into the values pointed at by dest.
|
||||||
|
// The number of values in dest must be the same as the number of columns in [Rows].
|
||||||
|
// Scan converts JSON-encoded column values to the provided Go types using [json.Unmarshal].
|
||||||
|
func (rs *Rows) Scan(dest ...any) error {
|
||||||
|
if rs.err != nil {
|
||||||
|
return rs.err
|
||||||
|
}
|
||||||
|
if len(dest) != len(rs.columns) {
|
||||||
|
return fmt.Errorf("expected %d values, got %d", len(rs.columns), len(dest))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range rs.row.Values {
|
||||||
|
if err := json.Unmarshal(v, dest[i]); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal column value #%d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time returns the time taken to execute the query in seconds. It's only available after all rows have been consumed.
|
||||||
|
// It doesn't include the time to send the query, receive the response, or iterate over the rows.
|
||||||
|
func (rs *Rows) Time() (float64, error) {
|
||||||
|
if rs.eoq == nil {
|
||||||
|
if rs.Err() != nil {
|
||||||
|
return 0, fmt.Errorf("time is not available: %w", rs.Err())
|
||||||
|
}
|
||||||
|
return 0, errors.New("time is not available until all rows are consumed")
|
||||||
|
}
|
||||||
|
return rs.eoq.Time, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the [Rows], preventing further enumeration. If [Rows.Next] is called and returns false,
|
||||||
|
// the [Rows] are closed automatically and it will suffice to check the result of [Rows.Err].
|
||||||
|
// Close is idempotent and does not affect the result of [Rows.Err].
|
||||||
|
func (rs *Rows) Close() error {
|
||||||
|
return rs.body.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package corrosion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeType string
|
||||||
|
|
||||||
|
var (
|
||||||
|
ChangeTypeInsert ChangeType = "insert"
|
||||||
|
ChangeTypeUpdate ChangeType = "update"
|
||||||
|
ChangeTypeDelete ChangeType = "delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeEvent struct {
|
||||||
|
Type ChangeType
|
||||||
|
RowID uint64
|
||||||
|
Values []json.RawMessage
|
||||||
|
ChangeID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *ChangeEvent) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw []json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return fmt.Errorf("invalid change event: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) != 4 {
|
||||||
|
return fmt.Errorf("invalid change event: expected an array of 4 elements")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw[0], &ce.Type); err != nil {
|
||||||
|
return fmt.Errorf("invalid change event type: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw[1], &ce.RowID); err != nil {
|
||||||
|
return fmt.Errorf("invalid change event row ID: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw[2], &ce.Values); err != nil {
|
||||||
|
return fmt.Errorf("invalid change event values: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw[3], &ce.ChangeID); err != nil {
|
||||||
|
return fmt.Errorf("invalid change event change ID: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *ChangeEvent) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal([]any{ce.Type, ce.RowID, ce.Values, ce.ChangeID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan copies the column values in the change event into the values pointed at by dest.
|
||||||
|
// The number of values in dest must be the same as the number of columns in the change.
|
||||||
|
// Scan converts JSON-encoded column values to the provided Go types using [json.Unmarshal].
|
||||||
|
func (ce *ChangeEvent) Scan(dest ...any) error {
|
||||||
|
if len(dest) != len(ce.Values) {
|
||||||
|
return fmt.Errorf("expected %d values, got %d", len(ce.Values), len(dest))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range ce.Values {
|
||||||
|
if err := json.Unmarshal(v, dest[i]); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal column value #%d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription receives updates from the Corrosion database for a desired SQL query.
|
||||||
|
type Subscription struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
id string
|
||||||
|
rows *Rows
|
||||||
|
body io.ReadCloser
|
||||||
|
decoder *json.Decoder
|
||||||
|
resubscribe func(ctx context.Context, fromChange uint64) (*Subscription, error)
|
||||||
|
changes chan *ChangeEvent
|
||||||
|
lastChangeID uint64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSubscription(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
rows *Rows,
|
||||||
|
body io.ReadCloser,
|
||||||
|
decoder *json.Decoder,
|
||||||
|
resubscribe func(ctx context.Context, fromChange uint64) (*Subscription, error),
|
||||||
|
) *Subscription {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
if decoder == nil {
|
||||||
|
decoder = json.NewDecoder(body)
|
||||||
|
}
|
||||||
|
return &Subscription{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
id: id,
|
||||||
|
rows: rows,
|
||||||
|
body: body,
|
||||||
|
decoder: decoder,
|
||||||
|
resubscribe: resubscribe,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the subscription ID.
|
||||||
|
func (s *Subscription) ID() string {
|
||||||
|
return s.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows returns the rows of the query or nil if skipRows was true when creating the subscription or if the subscription
|
||||||
|
// was created with [APIClient.ResubscribeContext].
|
||||||
|
func (s *Subscription) Rows() *Rows {
|
||||||
|
return s.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes returns a channel that receives change events for the query. Changes are not available until all rows
|
||||||
|
// are consumed. The channel is closed when the context is done, or an error occurs while reading the changes,
|
||||||
|
// or when the subscription is closed explicitly. If it's closed due to an error, [Subscription.Err] will return
|
||||||
|
// the error.
|
||||||
|
func (s *Subscription) Changes() (<-chan *ChangeEvent, error) {
|
||||||
|
if s.changes != nil {
|
||||||
|
return s.changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.rows != nil {
|
||||||
|
if s.rows.eoq == nil {
|
||||||
|
return nil, errors.New("changes are not available until all rows are consumed")
|
||||||
|
}
|
||||||
|
s.lastChangeID = *s.rows.eoq.ChangeID
|
||||||
|
}
|
||||||
|
s.changes = make(chan *ChangeEvent)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Close the body when the context is done to unblock the decoder in the following goroutine.
|
||||||
|
<-s.ctx.Done()
|
||||||
|
s.body.Close()
|
||||||
|
}()
|
||||||
|
go s.handleChangeEvents()
|
||||||
|
|
||||||
|
return s.changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subscription) handleChangeEvents() {
|
||||||
|
defer s.cancel()
|
||||||
|
defer close(s.changes)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
var e QueryEvent
|
||||||
|
var err error
|
||||||
|
if err = s.decoder.Decode(&e); err != nil {
|
||||||
|
// Do not report an error that occurred due to context cancellation, just return.
|
||||||
|
if s.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = fmt.Errorf("decode query event: %w", err)
|
||||||
|
} else if e.Error != nil {
|
||||||
|
err = fmt.Errorf("query error: %s", *e.Error)
|
||||||
|
} else if e.Change == nil {
|
||||||
|
err = fmt.Errorf("expected change event, got: %+v", e)
|
||||||
|
} else if s.lastChangeID != 0 && e.Change.ChangeID != s.lastChangeID+1 {
|
||||||
|
// If skipRows is true, the last change ID is unknown.
|
||||||
|
err = fmt.Errorf("missed a change: expected change ID %d, got %d",
|
||||||
|
s.lastChangeID+1, e.Change.ChangeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
s.lastChangeID = e.Change.ChangeID
|
||||||
|
select {
|
||||||
|
case s.changes <- e.Change:
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Report the error if resubscribing is disabled.
|
||||||
|
if s.resubscribe == nil {
|
||||||
|
s.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Resubscribing to Corrosion query due to an error.",
|
||||||
|
"err", err, "id", s.id, "from_change", s.lastChangeID)
|
||||||
|
sub, sErr := s.resubscribe(s.ctx, s.lastChangeID)
|
||||||
|
if sErr != nil {
|
||||||
|
// resubscribe returns a permanent error after unsuccessful retries.
|
||||||
|
s.err = fmt.Errorf("resubscribe to query with backoff: %w", sErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Reset the subscription to the new one.
|
||||||
|
s.rows = nil
|
||||||
|
s.body = sub.body
|
||||||
|
s.decoder = sub.decoder
|
||||||
|
// Do not close the sub to not close the body.
|
||||||
|
sub.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err returns the error, if any, that was encountered during fetching changes.
|
||||||
|
// Err may be called after an explicit or implicit [Subscription.Close].
|
||||||
|
func (s *Subscription) Err() error {
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subscription) Close() error {
|
||||||
|
s.cancel()
|
||||||
|
return s.body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeContext creates a subscription to receive updates for a desired SQL query. If skipRows is false,
|
||||||
|
// Subscription.Rows must be consumed before Subscription.Changes can be called. If skipRows is true, Subscription.Rows
|
||||||
|
// will return nil.
|
||||||
|
func (c *APIClient) SubscribeContext(
|
||||||
|
ctx context.Context, query string, args []any, skipRows bool,
|
||||||
|
) (*Subscription, error) {
|
||||||
|
statement := Statement{
|
||||||
|
Query: query,
|
||||||
|
Params: args,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(statement)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subURL := c.baseURL.JoinPath("/v1/subscriptions")
|
||||||
|
if skipRows {
|
||||||
|
q := subURL.Query()
|
||||||
|
q.Set("skip_rows", "true")
|
||||||
|
subURL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", subURL.String(), bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := resp.Header.Get("corro-query-id")
|
||||||
|
if id == "" {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, errors.New("missing corro-query-id header in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skipRows {
|
||||||
|
return newSubscription(ctx, id, nil, resp.Body, nil, c.resubscribeWithBackoffFn(id)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := newRows(ctx, resp.Body, false)
|
||||||
|
if err != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("parse query response: %w", err)
|
||||||
|
}
|
||||||
|
return newSubscription(ctx, id, rows, rows.body, rows.decoder, c.resubscribeWithBackoffFn(id)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *APIClient) resubscribeWithBackoffFn(id string) func(context.Context, uint64) (*Subscription, error) {
|
||||||
|
if c.newResubBackoff == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(ctx context.Context, fromChange uint64) (*Subscription, error) {
|
||||||
|
return backoff.RetryWithData(func() (*Subscription, error) {
|
||||||
|
slog.Debug("Retrying to resubscribe to Corrosion query.", "id", id, "from_change", fromChange)
|
||||||
|
return c.ResubscribeContext(ctx, id, fromChange)
|
||||||
|
}, c.newResubBackoff())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *APIClient) ResubscribeContext(ctx context.Context, id string, fromChange uint64) (*Subscription, error) {
|
||||||
|
subURL := c.baseURL.JoinPath("/v1/subscriptions", id)
|
||||||
|
q := subURL.Query()
|
||||||
|
q.Set("from", strconv.FormatUint(fromChange, 10))
|
||||||
|
subURL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", subURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSubscription(ctx, id, nil, resp.Body, nil, c.resubscribeWithBackoffFn(id)), nil
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ package daemon
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
systemd "github.com/coreos/go-systemd/daemon"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"uncloud/internal/machine"
|
|
||||||
|
systemd "github.com/coreos/go-systemd/daemon"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
@@ -35,7 +36,7 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||||||
case <-d.machine.Started():
|
case <-d.machine.Started():
|
||||||
_, err := systemd.SdNotify(false, systemd.SdNotifyReady)
|
_, err := systemd.SdNotify(false, systemd.SdNotifyReady)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to notify systemd that the daemon is ready.", "error", err)
|
slog.Error("Failed to notify systemd that the daemon is ready.", "err", err)
|
||||||
}
|
}
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"uncloud/internal/machine"
|
|
||||||
"uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
// MachineToken returns the local machine's token that can be used for adding the machine to a cluster.
|
||||||
@@ -37,5 +38,5 @@ func MachineToken(dataDir string) (machine.Token, error) {
|
|||||||
for i, ip := range ips {
|
for i, ip := range ips {
|
||||||
endpoints[i] = netip.AddrPortFrom(ip, network.WireGuardPort)
|
endpoints[i] = netip.AddrPortFrom(ip, network.WireGuardPort)
|
||||||
}
|
}
|
||||||
return machine.NewToken(state.Network.PublicKey, endpoints), nil
|
return machine.NewToken(state.Network.PublicKey, publicIP, endpoints), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
const (
|
||||||
|
RecordTypeA RecordType = "A"
|
||||||
|
RecordTypeAAAA RecordType = "AAAA"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RecordType string
|
||||||
|
|
||||||
|
type DomainResponse struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordRequest struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Type RecordType `json:"type,omitempty"`
|
||||||
|
Values []string `json:"values,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordResponse struct {
|
||||||
|
RecordRequest
|
||||||
|
FQDN string `json:"fqdn,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthErrorResponse struct {
|
||||||
|
Status int `json:"status,omitempty"`
|
||||||
|
Message string `json:"msg,omitempty"`
|
||||||
|
Data authErrorData `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type authErrorData struct {
|
||||||
|
NoDomain bool `json:"noDomain,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The dns package code is based on https://github.com/acorn-io/runtime/blob/main/pkg/dns.
|
||||||
|
|
||||||
|
// Client handles interactions with the Uncloud DNS API service.
|
||||||
|
type Client interface {
|
||||||
|
// ReserveDomain calls Uncloud DNS to reserve a new domain. It returns the domain, a token for authentication,
|
||||||
|
// and an error.
|
||||||
|
ReserveDomain(endpoint string) (string, string, error)
|
||||||
|
|
||||||
|
// CreateRecords calls Uncloud DNS to create or update DNS records based on the supplied RecordRequests
|
||||||
|
// for the specified domain.
|
||||||
|
CreateRecords(endpoint, domain, token string, records []RecordRequest) ([]RecordResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrAuthNoDomain indicates that a request failed authentication because the domain was not found.
|
||||||
|
// If encountered, a new domain needs to be reserved.
|
||||||
|
var ErrAuthNoDomain = errors.New("the supplied domain failed authentication")
|
||||||
|
|
||||||
|
// NewClient creates a new AcornDNS client
|
||||||
|
func NewClient() Client {
|
||||||
|
return &client{
|
||||||
|
c: http.DefaultClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
c *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) ReserveDomain(endpoint string) (string, string, error) {
|
||||||
|
url := fmt.Sprintf("%s/%s", endpoint, "domains")
|
||||||
|
|
||||||
|
req, err := c.request(http.MethodPost, url, nil, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &DomainResponse{}
|
||||||
|
err = c.do(req, resp)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Name, resp.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) CreateRecords(endpoint, domain, token string, records []RecordRequest) ([]RecordResponse, error) {
|
||||||
|
url := fmt.Sprintf("%s/domains/%s/records", endpoint, domain)
|
||||||
|
|
||||||
|
var resp []RecordResponse
|
||||||
|
for _, recordRequest := range records {
|
||||||
|
body, err := jsonBody(recordRequest)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := c.request(http.MethodPost, url, body, token)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordResp RecordResponse
|
||||||
|
if err = c.do(req, &recordResp); err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
resp = append(resp, recordResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) request(method string, url string, body io.Reader, token string) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(method, url, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
bearer := "Bearer " + token
|
||||||
|
req.Header.Add("Authorization", bearer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) do(req *http.Request, responseBody any) error {
|
||||||
|
slog.Debug("Making request to DNS service.", "method", req.Method, "url", req.URL)
|
||||||
|
|
||||||
|
resp, err := c.c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Response code for request to DNS service.",
|
||||||
|
"method", req.Method, "url", req.URL, "code", resp.StatusCode)
|
||||||
|
// When err is nil, resp contains a non-nil resp.Body which must be closed.
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
var authError AuthErrorResponse
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &authError)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unmarshal auth error response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authError.Data.NoDomain {
|
||||||
|
return ErrAuthNoDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("authentication failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := resp.StatusCode; code < 200 || code > 300 {
|
||||||
|
return fmt.Errorf("unexpected response status code: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if responseBody != nil {
|
||||||
|
err = json.Unmarshal(body, responseBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unmarshal response body (%s): %w", string(body), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonBody(payload any) (io.Reader, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
err := json.NewEncoder(buf).Encode(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
||||||
|
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
||||||
|
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
||||||
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
|
backoff.WithInitialInterval(100*time.Millisecond),
|
||||||
|
backoff.WithMaxInterval(1*time.Second),
|
||||||
|
backoff.WithMaxElapsedTime(0),
|
||||||
|
), ctx)
|
||||||
|
|
||||||
|
waitingLogged := false
|
||||||
|
ping := func() error {
|
||||||
|
_, err := cli.Ping(ctx)
|
||||||
|
if err == nil {
|
||||||
|
if waitingLogged {
|
||||||
|
slog.Info("Docker daemon is ready.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !client.IsErrConnectionFailed(err) {
|
||||||
|
return backoff.Permanent(fmt.Errorf("connect to Docker daemon: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !waitingLogged {
|
||||||
|
slog.Info("Waiting for Docker daemon to start and be ready.")
|
||||||
|
waitingLogged = true
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backoff.Retry(ping, boff); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ping Docker: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package fs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExpandHomeDir(path string) string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if path[0] == '~' {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return strings.Replace(path, "~", home, 1)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupUIDGID returns the user and group IDs for the given username.
|
||||||
|
func LookupUIDGID(username string) (uid, gid int, err error) {
|
||||||
|
usr, err := user.Lookup(username)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("lookup user %q: %w", username, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, err = strconv.Atoi(usr.Uid)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("parse %q user ID (UID) %q: %w", username, usr.Uid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gid, err = strconv.Atoi(usr.Gid)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("parse %q user group ID (GID) %q: %w", username, usr.Gid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func Chown(path, username, group string) error {
|
||||||
|
uid, gid := -1, -1
|
||||||
|
if username != "" {
|
||||||
|
usr, err := user.Lookup(username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lookup user %q: %w", username, err)
|
||||||
|
}
|
||||||
|
uid, err = strconv.Atoi(usr.Uid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse %q user ID (UID) %q: %w", username, usr.Uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if group != "" {
|
||||||
|
grp, err := user.LookupGroup(group)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lookup group %q: %w", group, err)
|
||||||
|
}
|
||||||
|
gid, err = strconv.Atoi(grp.Gid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse %q group ID (GID) %q: %w", group, grp.Gid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chown(path, uid, gid); err != nil {
|
||||||
|
return fmt.Errorf("chown %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package fs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExpandHomeDir(t *testing.T) {
|
||||||
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "", ExpandHomeDir(""))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no home", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "/path", ExpandHomeDir("/path"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("home", func(t *testing.T) {
|
||||||
|
t.Setenv("HOME", "/home/user")
|
||||||
|
assert.Equal(t, "/home/user/path", ExpandHomeDir("~/path"))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlogTextHandler extends the standard [slog.TextHandler] to provide custom formatting that is very similar
|
||||||
|
// to the default slog handler. It prefixes each log entry with a right-padded level indicator and message,
|
||||||
|
// while excluding the default time, level, and message attributes from the structured output.
|
||||||
|
//
|
||||||
|
// The output format is:
|
||||||
|
// LEVEL MESSAGE key1=value1 key2=value2
|
||||||
|
type SlogTextHandler struct {
|
||||||
|
*slog.TextHandler
|
||||||
|
opts slog.HandlerOptions
|
||||||
|
mu sync.Mutex
|
||||||
|
w io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSlogTextHandler(w io.Writer, opts *slog.HandlerOptions) *SlogTextHandler {
|
||||||
|
if opts == nil {
|
||||||
|
opts = &slog.HandlerOptions{}
|
||||||
|
}
|
||||||
|
// Remove time, level, and message from the default attributes.
|
||||||
|
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
|
||||||
|
if a.Key == slog.TimeKey || a.Key == slog.LevelKey || a.Key == slog.MessageKey {
|
||||||
|
return slog.Attr{}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SlogTextHandler{
|
||||||
|
TextHandler: slog.NewTextHandler(w, opts),
|
||||||
|
opts: *opts,
|
||||||
|
w: w,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SlogTextHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||||
|
return h.TextHandler.Enabled(ctx, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SlogTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
return &SlogTextHandler{
|
||||||
|
TextHandler: h.TextHandler.WithAttrs(attrs).(*slog.TextHandler),
|
||||||
|
opts: h.opts,
|
||||||
|
w: h.w,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SlogTextHandler) WithGroup(name string) slog.Handler {
|
||||||
|
return &SlogTextHandler{
|
||||||
|
TextHandler: h.TextHandler.WithGroup(name).(*slog.TextHandler),
|
||||||
|
opts: h.opts,
|
||||||
|
w: h.w,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SlogTextHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||||
|
// Only log entries that are at or above the minimum level (INFO by default).
|
||||||
|
minLevel := slog.LevelInfo
|
||||||
|
if h.opts.Level != nil {
|
||||||
|
minLevel = h.opts.Level.Level()
|
||||||
|
}
|
||||||
|
if r.Level < minLevel {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
levelMsg := fmt.Sprintf("%-5s %s ", r.Level.String(), r.Message)
|
||||||
|
if _, err := h.w.Write([]byte(levelMsg)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.TextHandler.Handle(ctx, r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.34.2
|
||||||
|
// protoc v5.27.3
|
||||||
|
// source: internal/machine/api/pb/caddy.proto
|
||||||
|
|
||||||
|
package pb
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetCaddyConfigResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
// The generated Caddyfile content.
|
||||||
|
Caddyfile string `protobuf:"bytes,1,opt,name=caddyfile,proto3" json:"caddyfile,omitempty"`
|
||||||
|
// Timestamp when the config was last modified.
|
||||||
|
ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCaddyConfigResponse) Reset() {
|
||||||
|
*x = GetCaddyConfigResponse{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_internal_machine_api_pb_caddy_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCaddyConfigResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetCaddyConfigResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetCaddyConfigResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_internal_machine_api_pb_caddy_proto_msgTypes[0]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetCaddyConfigResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetCaddyConfigResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_internal_machine_api_pb_caddy_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCaddyConfigResponse) GetCaddyfile() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Caddyfile
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetCaddyConfigResponse) GetModifiedAt() *timestamppb.Timestamp {
|
||||||
|
if x != nil {
|
||||||
|
return x.ModifiedAt
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_internal_machine_api_pb_caddy_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_internal_machine_api_pb_caddy_proto_rawDesc = []byte{
|
||||||
|
0x0a, 0x23, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
||||||
|
0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x63, 0x61, 0x64, 0x64, 0x79, 0x2e,
|
||||||
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67,
|
||||||
|
0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74,
|
||||||
|
0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f,
|
||||||
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
|
||||||
|
0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x73, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x43,
|
||||||
|
0x61, 0x64, 0x64, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||||
|
0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x64, 0x64, 0x79, 0x66, 0x69, 0x6c, 0x65, 0x18,
|
||||||
|
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x64, 0x64, 0x79, 0x66, 0x69, 0x6c, 0x65,
|
||||||
|
0x12, 0x3b, 0x0a, 0x0b, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18,
|
||||||
|
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
|
||||||
|
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
||||||
|
0x70, 0x52, 0x0a, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x41, 0x74, 0x32, 0x49, 0x0a,
|
||||||
|
0x05, 0x43, 0x61, 0x64, 0x64, 0x79, 0x12, 0x40, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e,
|
||||||
|
0x66, 0x69, 0x67, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
|
||||||
|
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1b, 0x2e, 0x61, 0x70,
|
||||||
|
0x69, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x61, 0x64, 0x64, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
|
||||||
|
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68,
|
||||||
|
0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b,
|
||||||
|
0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
|
||||||
|
0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70,
|
||||||
|
0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_internal_machine_api_pb_caddy_proto_rawDescOnce sync.Once
|
||||||
|
file_internal_machine_api_pb_caddy_proto_rawDescData = file_internal_machine_api_pb_caddy_proto_rawDesc
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_internal_machine_api_pb_caddy_proto_rawDescGZIP() []byte {
|
||||||
|
file_internal_machine_api_pb_caddy_proto_rawDescOnce.Do(func() {
|
||||||
|
file_internal_machine_api_pb_caddy_proto_rawDescData = protoimpl.X.CompressGZIP(file_internal_machine_api_pb_caddy_proto_rawDescData)
|
||||||
|
})
|
||||||
|
return file_internal_machine_api_pb_caddy_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_internal_machine_api_pb_caddy_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
|
||||||
|
var file_internal_machine_api_pb_caddy_proto_goTypes = []any{
|
||||||
|
(*GetCaddyConfigResponse)(nil), // 0: api.GetCaddyConfigResponse
|
||||||
|
(*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp
|
||||||
|
(*emptypb.Empty)(nil), // 2: google.protobuf.Empty
|
||||||
|
}
|
||||||
|
var file_internal_machine_api_pb_caddy_proto_depIdxs = []int32{
|
||||||
|
1, // 0: api.GetCaddyConfigResponse.modified_at:type_name -> google.protobuf.Timestamp
|
||||||
|
2, // 1: api.Caddy.GetConfig:input_type -> google.protobuf.Empty
|
||||||
|
0, // 2: api.Caddy.GetConfig:output_type -> api.GetCaddyConfigResponse
|
||||||
|
2, // [2:3] is the sub-list for method output_type
|
||||||
|
1, // [1:2] is the sub-list for method input_type
|
||||||
|
1, // [1:1] is the sub-list for extension type_name
|
||||||
|
1, // [1:1] is the sub-list for extension extendee
|
||||||
|
0, // [0:1] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_internal_machine_api_pb_caddy_proto_init() }
|
||||||
|
func file_internal_machine_api_pb_caddy_proto_init() {
|
||||||
|
if File_internal_machine_api_pb_caddy_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !protoimpl.UnsafeEnabled {
|
||||||
|
file_internal_machine_api_pb_caddy_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*GetCaddyConfigResponse); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: file_internal_machine_api_pb_caddy_proto_rawDesc,
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 1,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_internal_machine_api_pb_caddy_proto_goTypes,
|
||||||
|
DependencyIndexes: file_internal_machine_api_pb_caddy_proto_depIdxs,
|
||||||
|
MessageInfos: file_internal_machine_api_pb_caddy_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_internal_machine_api_pb_caddy_proto = out.File
|
||||||
|
file_internal_machine_api_pb_caddy_proto_rawDesc = nil
|
||||||
|
file_internal_machine_api_pb_caddy_proto_goTypes = nil
|
||||||
|
file_internal_machine_api_pb_caddy_proto_depIdxs = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package api;
|
||||||
|
|
||||||
|
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||||
|
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
service Caddy {
|
||||||
|
// GetConfig retrieves the current Caddy configuration from the machine.
|
||||||
|
rpc GetConfig(google.protobuf.Empty) returns (GetCaddyConfigResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetCaddyConfigResponse {
|
||||||
|
// The generated Caddyfile content.
|
||||||
|
string caddyfile = 1;
|
||||||
|
// Timestamp when the config was last modified.
|
||||||
|
google.protobuf.Timestamp modified_at = 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
|
// - protoc v5.27.3
|
||||||
|
// source: internal/machine/api/pb/caddy.proto
|
||||||
|
|
||||||
|
package pb
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
Caddy_GetConfig_FullMethodName = "/api.Caddy/GetConfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaddyClient is the client API for Caddy service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
type CaddyClient interface {
|
||||||
|
// GetConfig retrieves the current Caddy configuration from the machine.
|
||||||
|
GetConfig(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*GetCaddyConfigResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type caddyClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaddyClient(cc grpc.ClientConnInterface) CaddyClient {
|
||||||
|
return &caddyClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *caddyClient) GetConfig(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*GetCaddyConfigResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetCaddyConfigResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Caddy_GetConfig_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaddyServer is the server API for Caddy service.
|
||||||
|
// All implementations must embed UnimplementedCaddyServer
|
||||||
|
// for forward compatibility.
|
||||||
|
type CaddyServer interface {
|
||||||
|
// GetConfig retrieves the current Caddy configuration from the machine.
|
||||||
|
GetConfig(context.Context, *emptypb.Empty) (*GetCaddyConfigResponse, error)
|
||||||
|
mustEmbedUnimplementedCaddyServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedCaddyServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedCaddyServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedCaddyServer) GetConfig(context.Context, *emptypb.Empty) (*GetCaddyConfigResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCaddyServer) mustEmbedUnimplementedCaddyServer() {}
|
||||||
|
func (UnimplementedCaddyServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeCaddyServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to CaddyServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeCaddyServer interface {
|
||||||
|
mustEmbedUnimplementedCaddyServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterCaddyServer(s grpc.ServiceRegistrar, srv CaddyServer) {
|
||||||
|
// If the following call pancis, it indicates UnimplementedCaddyServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&Caddy_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Caddy_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(emptypb.Empty)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CaddyServer).GetConfig(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Caddy_GetConfig_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CaddyServer).GetConfig(ctx, req.(*emptypb.Empty))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caddy_ServiceDesc is the grpc.ServiceDesc for Caddy service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var Caddy_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "api.Caddy",
|
||||||
|
HandlerType: (*CaddyServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "GetConfig",
|
||||||
|
Handler: _Caddy_GetConfig_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "internal/machine/api/pb/caddy.proto",
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package pb
|
package pb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
@@ -40,10 +39,3 @@ func (c *NetworkConfig) Validate() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AddMachineRequest) Validate() error {
|
|
||||||
if r.Network == nil {
|
|
||||||
return fmt.Errorf("network not set")
|
|
||||||
}
|
|
||||||
return r.Network.Validate()
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,62 +6,93 @@ option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
|||||||
|
|
||||||
import "google/protobuf/empty.proto";
|
import "google/protobuf/empty.proto";
|
||||||
import "internal/machine/api/pb/common.proto";
|
import "internal/machine/api/pb/common.proto";
|
||||||
|
import "internal/machine/api/pb/machine.proto";
|
||||||
|
|
||||||
service Cluster {
|
service Cluster {
|
||||||
rpc AddMachine(AddMachineRequest) returns (AddMachineResponse);
|
rpc AddMachine(AddMachineRequest) returns (AddMachineResponse);
|
||||||
rpc ListMachines(google.protobuf.Empty) returns (ListMachinesResponse);
|
rpc ListMachines(google.protobuf.Empty) returns (ListMachinesResponse);
|
||||||
rpc ListMachineEndpoints(ListMachineEndpointsRequest) returns (ListMachineEndpointsResponse);
|
rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse);
|
||||||
|
rpc RemoveMachine(RemoveMachineRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
|
rpc ReserveDomain(ReserveDomainRequest) returns (Domain);
|
||||||
|
rpc GetDomain(google.protobuf.Empty) returns (Domain);
|
||||||
|
rpc ReleaseDomain(google.protobuf.Empty) returns (Domain);
|
||||||
|
rpc CreateDomainRecords(CreateDomainRecordsRequest) returns (CreateDomainRecordsResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message AddMachineRequest {
|
message AddMachineRequest {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
NetworkConfig network = 2;
|
NetworkConfig network = 2;
|
||||||
|
IP public_ip = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AddMachineResponse {
|
message AddMachineResponse {
|
||||||
MachineInfo machine = 1;
|
MachineInfo machine = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message MachineMember {
|
||||||
|
MachineInfo machine = 1;
|
||||||
|
|
||||||
|
enum MembershipState {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
// The member is active.
|
||||||
|
UP = 1;
|
||||||
|
// The member is active, but at least one cluster member suspects its down. For all purposes,
|
||||||
|
// a SUSPECT member is treated as if it were UP until either it refutes the suspicion (becoming UP)
|
||||||
|
// or fails to do so (being declared DOWN).
|
||||||
|
SUSPECT = 2;
|
||||||
|
// The member is confirmed DOWN.
|
||||||
|
DOWN = 3;
|
||||||
|
}
|
||||||
|
MembershipState state = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message ListMachinesResponse {
|
message ListMachinesResponse {
|
||||||
repeated MachineInfo machines = 1;
|
repeated MachineMember machines = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListMachineEndpointsRequest {
|
message UpdateMachineRequest {
|
||||||
|
// Machine to update
|
||||||
|
string machine_id = 1;
|
||||||
|
|
||||||
|
// Updated machine information
|
||||||
|
optional string name = 2;
|
||||||
|
optional IP public_ip = 3;
|
||||||
|
repeated IPPort endpoints = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateMachineResponse {
|
||||||
|
MachineInfo machine = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveMachineRequest {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListMachineEndpointsResponse {
|
message Domain {
|
||||||
MachineEndpoints endpoints = 1;
|
string name = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MachineInfo {
|
message ReserveDomainRequest {
|
||||||
string id = 1;
|
string endpoint = 1;
|
||||||
string name = 2;
|
|
||||||
NetworkConfig network = 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message NetworkConfig {
|
message CreateDomainRecordsRequest {
|
||||||
IPPrefix subnet = 1;
|
repeated DNSRecord records = 1;
|
||||||
IP management_ip = 2;
|
|
||||||
repeated IPPort endpoints = 3;
|
|
||||||
bytes publicKey = 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message MachineEndpoints {
|
message CreateDomainRecordsResponse {
|
||||||
string id = 1;
|
repeated DNSRecord records = 1;
|
||||||
repeated IPPort endpoints = 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message User {
|
message DNSRecord {
|
||||||
NetworkConfig network = 1;
|
string name = 1;
|
||||||
}
|
|
||||||
|
|
||||||
message State {
|
enum RecordType {
|
||||||
IPPrefix Network = 1;
|
UNSPECIFIED = 0;
|
||||||
// The machine configuration in the state is the source of truth set by the administrator.
|
A = 1;
|
||||||
// The machine itself can't update it.
|
AAAA = 2;
|
||||||
map<string, MachineInfo> machines = 2;
|
}
|
||||||
map<string, MachineEndpoints> endpoints = 3;
|
RecordType type = 2;
|
||||||
repeated User users = 4;
|
repeated string values = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,14 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Cluster_AddMachine_FullMethodName = "/api.Cluster/AddMachine"
|
Cluster_AddMachine_FullMethodName = "/api.Cluster/AddMachine"
|
||||||
Cluster_ListMachines_FullMethodName = "/api.Cluster/ListMachines"
|
Cluster_ListMachines_FullMethodName = "/api.Cluster/ListMachines"
|
||||||
Cluster_ListMachineEndpoints_FullMethodName = "/api.Cluster/ListMachineEndpoints"
|
Cluster_UpdateMachine_FullMethodName = "/api.Cluster/UpdateMachine"
|
||||||
|
Cluster_RemoveMachine_FullMethodName = "/api.Cluster/RemoveMachine"
|
||||||
|
Cluster_ReserveDomain_FullMethodName = "/api.Cluster/ReserveDomain"
|
||||||
|
Cluster_GetDomain_FullMethodName = "/api.Cluster/GetDomain"
|
||||||
|
Cluster_ReleaseDomain_FullMethodName = "/api.Cluster/ReleaseDomain"
|
||||||
|
Cluster_CreateDomainRecords_FullMethodName = "/api.Cluster/CreateDomainRecords"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClusterClient is the client API for Cluster service.
|
// ClusterClient is the client API for Cluster service.
|
||||||
@@ -31,7 +36,12 @@ const (
|
|||||||
type ClusterClient interface {
|
type ClusterClient interface {
|
||||||
AddMachine(ctx context.Context, in *AddMachineRequest, opts ...grpc.CallOption) (*AddMachineResponse, error)
|
AddMachine(ctx context.Context, in *AddMachineRequest, opts ...grpc.CallOption) (*AddMachineResponse, error)
|
||||||
ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListMachinesResponse, error)
|
ListMachines(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListMachinesResponse, error)
|
||||||
ListMachineEndpoints(ctx context.Context, in *ListMachineEndpointsRequest, opts ...grpc.CallOption) (*ListMachineEndpointsResponse, error)
|
UpdateMachine(ctx context.Context, in *UpdateMachineRequest, opts ...grpc.CallOption) (*UpdateMachineResponse, error)
|
||||||
|
RemoveMachine(ctx context.Context, in *RemoveMachineRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
|
ReserveDomain(ctx context.Context, in *ReserveDomainRequest, opts ...grpc.CallOption) (*Domain, error)
|
||||||
|
GetDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error)
|
||||||
|
ReleaseDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error)
|
||||||
|
CreateDomainRecords(ctx context.Context, in *CreateDomainRecordsRequest, opts ...grpc.CallOption) (*CreateDomainRecordsResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type clusterClient struct {
|
type clusterClient struct {
|
||||||
@@ -62,10 +72,60 @@ func (c *clusterClient) ListMachines(ctx context.Context, in *emptypb.Empty, opt
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *clusterClient) ListMachineEndpoints(ctx context.Context, in *ListMachineEndpointsRequest, opts ...grpc.CallOption) (*ListMachineEndpointsResponse, error) {
|
func (c *clusterClient) UpdateMachine(ctx context.Context, in *UpdateMachineRequest, opts ...grpc.CallOption) (*UpdateMachineResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListMachineEndpointsResponse)
|
out := new(UpdateMachineResponse)
|
||||||
err := c.cc.Invoke(ctx, Cluster_ListMachineEndpoints_FullMethodName, in, out, cOpts...)
|
err := c.cc.Invoke(ctx, Cluster_UpdateMachine_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clusterClient) RemoveMachine(ctx context.Context, in *RemoveMachineRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(emptypb.Empty)
|
||||||
|
err := c.cc.Invoke(ctx, Cluster_RemoveMachine_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clusterClient) ReserveDomain(ctx context.Context, in *ReserveDomainRequest, opts ...grpc.CallOption) (*Domain, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(Domain)
|
||||||
|
err := c.cc.Invoke(ctx, Cluster_ReserveDomain_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clusterClient) GetDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(Domain)
|
||||||
|
err := c.cc.Invoke(ctx, Cluster_GetDomain_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clusterClient) ReleaseDomain(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*Domain, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(Domain)
|
||||||
|
err := c.cc.Invoke(ctx, Cluster_ReleaseDomain_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clusterClient) CreateDomainRecords(ctx context.Context, in *CreateDomainRecordsRequest, opts ...grpc.CallOption) (*CreateDomainRecordsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreateDomainRecordsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Cluster_CreateDomainRecords_FullMethodName, in, out, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -78,7 +138,12 @@ func (c *clusterClient) ListMachineEndpoints(ctx context.Context, in *ListMachin
|
|||||||
type ClusterServer interface {
|
type ClusterServer interface {
|
||||||
AddMachine(context.Context, *AddMachineRequest) (*AddMachineResponse, error)
|
AddMachine(context.Context, *AddMachineRequest) (*AddMachineResponse, error)
|
||||||
ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error)
|
ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error)
|
||||||
ListMachineEndpoints(context.Context, *ListMachineEndpointsRequest) (*ListMachineEndpointsResponse, error)
|
UpdateMachine(context.Context, *UpdateMachineRequest) (*UpdateMachineResponse, error)
|
||||||
|
RemoveMachine(context.Context, *RemoveMachineRequest) (*emptypb.Empty, error)
|
||||||
|
ReserveDomain(context.Context, *ReserveDomainRequest) (*Domain, error)
|
||||||
|
GetDomain(context.Context, *emptypb.Empty) (*Domain, error)
|
||||||
|
ReleaseDomain(context.Context, *emptypb.Empty) (*Domain, error)
|
||||||
|
CreateDomainRecords(context.Context, *CreateDomainRecordsRequest) (*CreateDomainRecordsResponse, error)
|
||||||
mustEmbedUnimplementedClusterServer()
|
mustEmbedUnimplementedClusterServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +160,23 @@ func (UnimplementedClusterServer) AddMachine(context.Context, *AddMachineRequest
|
|||||||
func (UnimplementedClusterServer) ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error) {
|
func (UnimplementedClusterServer) ListMachines(context.Context, *emptypb.Empty) (*ListMachinesResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListMachines not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method ListMachines not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedClusterServer) ListMachineEndpoints(context.Context, *ListMachineEndpointsRequest) (*ListMachineEndpointsResponse, error) {
|
func (UnimplementedClusterServer) UpdateMachine(context.Context, *UpdateMachineRequest) (*UpdateMachineResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListMachineEndpoints not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method UpdateMachine not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedClusterServer) RemoveMachine(context.Context, *RemoveMachineRequest) (*emptypb.Empty, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveMachine not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedClusterServer) ReserveDomain(context.Context, *ReserveDomainRequest) (*Domain, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ReserveDomain not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedClusterServer) GetDomain(context.Context, *emptypb.Empty) (*Domain, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetDomain not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedClusterServer) ReleaseDomain(context.Context, *emptypb.Empty) (*Domain, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ReleaseDomain not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedClusterServer) CreateDomainRecords(context.Context, *CreateDomainRecordsRequest) (*CreateDomainRecordsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method CreateDomainRecords not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedClusterServer) mustEmbedUnimplementedClusterServer() {}
|
func (UnimplementedClusterServer) mustEmbedUnimplementedClusterServer() {}
|
||||||
func (UnimplementedClusterServer) testEmbeddedByValue() {}
|
func (UnimplementedClusterServer) testEmbeddedByValue() {}
|
||||||
@@ -155,20 +235,110 @@ func _Cluster_ListMachines_Handler(srv interface{}, ctx context.Context, dec fun
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _Cluster_ListMachineEndpoints_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _Cluster_UpdateMachine_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListMachineEndpointsRequest)
|
in := new(UpdateMachineRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if interceptor == nil {
|
if interceptor == nil {
|
||||||
return srv.(ClusterServer).ListMachineEndpoints(ctx, in)
|
return srv.(ClusterServer).UpdateMachine(ctx, in)
|
||||||
}
|
}
|
||||||
info := &grpc.UnaryServerInfo{
|
info := &grpc.UnaryServerInfo{
|
||||||
Server: srv,
|
Server: srv,
|
||||||
FullMethod: Cluster_ListMachineEndpoints_FullMethodName,
|
FullMethod: Cluster_UpdateMachine_FullMethodName,
|
||||||
}
|
}
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
return srv.(ClusterServer).ListMachineEndpoints(ctx, req.(*ListMachineEndpointsRequest))
|
return srv.(ClusterServer).UpdateMachine(ctx, req.(*UpdateMachineRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Cluster_RemoveMachine_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveMachineRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ClusterServer).RemoveMachine(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Cluster_RemoveMachine_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ClusterServer).RemoveMachine(ctx, req.(*RemoveMachineRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Cluster_ReserveDomain_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ReserveDomainRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ClusterServer).ReserveDomain(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Cluster_ReserveDomain_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ClusterServer).ReserveDomain(ctx, req.(*ReserveDomainRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Cluster_GetDomain_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(emptypb.Empty)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ClusterServer).GetDomain(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Cluster_GetDomain_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ClusterServer).GetDomain(ctx, req.(*emptypb.Empty))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Cluster_ReleaseDomain_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(emptypb.Empty)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ClusterServer).ReleaseDomain(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Cluster_ReleaseDomain_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ClusterServer).ReleaseDomain(ctx, req.(*emptypb.Empty))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _Cluster_CreateDomainRecords_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreateDomainRecordsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ClusterServer).CreateDomainRecords(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Cluster_CreateDomainRecords_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ClusterServer).CreateDomainRecords(ctx, req.(*CreateDomainRecordsRequest))
|
||||||
}
|
}
|
||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
@@ -189,8 +359,28 @@ var Cluster_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Handler: _Cluster_ListMachines_Handler,
|
Handler: _Cluster_ListMachines_Handler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListMachineEndpoints",
|
MethodName: "UpdateMachine",
|
||||||
Handler: _Cluster_ListMachineEndpoints_Handler,
|
Handler: _Cluster_UpdateMachine_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveMachine",
|
||||||
|
Handler: _Cluster_RemoveMachine_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ReserveDomain",
|
||||||
|
Handler: _Cluster_ReserveDomain_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetDomain",
|
||||||
|
Handler: _Cluster_GetDomain_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ReleaseDomain",
|
||||||
|
Handler: _Cluster_ReleaseDomain_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreateDomainRecords",
|
||||||
|
Handler: _Cluster_CreateDomainRecords_Handler,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
package pb
|
package pb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
status "google.golang.org/genproto/googleapis/rpc/status"
|
||||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
@@ -20,6 +21,171 @@ const (
|
|||||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Common metadata message nested in all reply message types, injected by the gRPC proxy to provide information
|
||||||
|
// about the machine that responded to the request.
|
||||||
|
type Metadata struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
// Address of the machine the response came from.
|
||||||
|
Machine string `protobuf:"bytes,1,opt,name=machine,proto3" json:"machine,omitempty"`
|
||||||
|
// error is set if the request to upstream failed. The rest of the response is undefined.
|
||||||
|
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||||
|
// error as a gRPC Status message.
|
||||||
|
Status *status.Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Metadata) Reset() {
|
||||||
|
*x = Metadata{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Metadata) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Metadata) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Metadata) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[0]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Metadata.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Metadata) Descriptor() ([]byte, []int) {
|
||||||
|
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Metadata) GetMachine() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Machine
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Metadata) GetError() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Error
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Metadata) GetStatus() *status.Status {
|
||||||
|
if x != nil {
|
||||||
|
return x.Status
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A helper message for marshalling the metadata field and injecting it into the response by the gRPC proxy.
|
||||||
|
type Empty struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Empty) Reset() {
|
||||||
|
*x = Empty{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Empty) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Empty) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Empty) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[1]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Empty) Descriptor() ([]byte, []int) {
|
||||||
|
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Empty) GetMetadata() *Metadata {
|
||||||
|
if x != nil {
|
||||||
|
return x.Metadata
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyResponse is a response message to be returned by the gRPC proxy when a request to the upstream failed.
|
||||||
|
// The nested Empty.Metadata message should contain an error.
|
||||||
|
type EmptyResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Messages []*Empty `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EmptyResponse) Reset() {
|
||||||
|
*x = EmptyResponse{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EmptyResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*EmptyResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *EmptyResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[2]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use EmptyResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*EmptyResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EmptyResponse) GetMessages() []*Empty {
|
||||||
|
if x != nil {
|
||||||
|
return x.Messages
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type IP struct {
|
type IP struct {
|
||||||
state protoimpl.MessageState
|
state protoimpl.MessageState
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -31,7 +197,7 @@ type IP struct {
|
|||||||
func (x *IP) Reset() {
|
func (x *IP) Reset() {
|
||||||
*x = IP{}
|
*x = IP{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[0]
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[3]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -44,7 +210,7 @@ func (x *IP) String() string {
|
|||||||
func (*IP) ProtoMessage() {}
|
func (*IP) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *IP) ProtoReflect() protoreflect.Message {
|
func (x *IP) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[0]
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[3]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -57,7 +223,7 @@ func (x *IP) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use IP.ProtoReflect.Descriptor instead.
|
// Deprecated: Use IP.ProtoReflect.Descriptor instead.
|
||||||
func (*IP) Descriptor() ([]byte, []int) {
|
func (*IP) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{0}
|
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{3}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *IP) GetIp() []byte {
|
func (x *IP) GetIp() []byte {
|
||||||
@@ -79,7 +245,7 @@ type IPPort struct {
|
|||||||
func (x *IPPort) Reset() {
|
func (x *IPPort) Reset() {
|
||||||
*x = IPPort{}
|
*x = IPPort{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[1]
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -92,7 +258,7 @@ func (x *IPPort) String() string {
|
|||||||
func (*IPPort) ProtoMessage() {}
|
func (*IPPort) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *IPPort) ProtoReflect() protoreflect.Message {
|
func (x *IPPort) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[1]
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[4]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -105,7 +271,7 @@ func (x *IPPort) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use IPPort.ProtoReflect.Descriptor instead.
|
// Deprecated: Use IPPort.ProtoReflect.Descriptor instead.
|
||||||
func (*IPPort) Descriptor() ([]byte, []int) {
|
func (*IPPort) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{1}
|
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *IPPort) GetIp() *IP {
|
func (x *IPPort) GetIp() *IP {
|
||||||
@@ -134,7 +300,7 @@ type IPPrefix struct {
|
|||||||
func (x *IPPrefix) Reset() {
|
func (x *IPPrefix) Reset() {
|
||||||
*x = IPPrefix{}
|
*x = IPPrefix{}
|
||||||
if protoimpl.UnsafeEnabled {
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[2]
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[5]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -147,7 +313,7 @@ func (x *IPPrefix) String() string {
|
|||||||
func (*IPPrefix) ProtoMessage() {}
|
func (*IPPrefix) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *IPPrefix) ProtoReflect() protoreflect.Message {
|
func (x *IPPrefix) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_internal_machine_api_pb_common_proto_msgTypes[2]
|
mi := &file_internal_machine_api_pb_common_proto_msgTypes[5]
|
||||||
if protoimpl.UnsafeEnabled && x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -160,7 +326,7 @@ func (x *IPPrefix) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use IPPrefix.ProtoReflect.Descriptor instead.
|
// Deprecated: Use IPPrefix.ProtoReflect.Descriptor instead.
|
||||||
func (*IPPrefix) Descriptor() ([]byte, []int) {
|
func (*IPPrefix) Descriptor() ([]byte, []int) {
|
||||||
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{2}
|
return file_internal_machine_api_pb_common_proto_rawDescGZIP(), []int{5}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *IPPrefix) GetIp() *IP {
|
func (x *IPPrefix) GetIp() *IP {
|
||||||
@@ -182,20 +348,34 @@ var File_internal_machine_api_pb_common_proto protoreflect.FileDescriptor
|
|||||||
var file_internal_machine_api_pb_common_proto_rawDesc = []byte{
|
var file_internal_machine_api_pb_common_proto_rawDesc = []byte{
|
||||||
0x0a, 0x24, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
0x0a, 0x24, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69,
|
||||||
0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e,
|
0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e,
|
||||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x22, 0x14, 0x0a, 0x02, 0x49,
|
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x61, 0x70, 0x69, 0x1a, 0x17, 0x67, 0x6f, 0x6f,
|
||||||
0x50, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69,
|
0x67, 0x6c, 0x65, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x70,
|
||||||
0x70, 0x22, 0x35, 0x0a, 0x06, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69,
|
0x72, 0x6f, 0x74, 0x6f, 0x22, 0x66, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
|
||||||
0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50,
|
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01,
|
0x09, 0x52, 0x07, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72,
|
||||||
0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x37, 0x0a, 0x08, 0x49, 0x50, 0x50, 0x72,
|
0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
|
||||||
0x65, 0x66, 0x69, 0x78, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
|
0x12, 0x2a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
|
||||||
0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a,
|
0x32, 0x12, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x74,
|
||||||
0x04, 0x62, 0x69, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x62, 0x69, 0x74,
|
0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x32, 0x0a, 0x05,
|
||||||
0x73, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
|
0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
|
||||||
0x70, 0x73, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f,
|
0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4d, 0x65,
|
||||||
0x75, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68,
|
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
|
||||||
0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
|
0x22, 0x37, 0x0a, 0x0d, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||||
0x6f, 0x33,
|
0x65, 0x12, 0x26, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20,
|
||||||
|
0x03, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x52,
|
||||||
|
0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x14, 0x0a, 0x02, 0x49, 0x50, 0x12,
|
||||||
|
0x0e, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x70, 0x22,
|
||||||
|
0x35, 0x0a, 0x06, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18,
|
||||||
|
0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02,
|
||||||
|
0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d,
|
||||||
|
0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x37, 0x0a, 0x08, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66,
|
||||||
|
0x69, 0x78, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07,
|
||||||
|
0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x02, 0x69, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x62,
|
||||||
|
0x69, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x62, 0x69, 0x74, 0x73, 0x42,
|
||||||
|
0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x73,
|
||||||
|
0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x6b, 0x69, 0x2f, 0x75, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64,
|
||||||
|
0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e,
|
||||||
|
0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -210,20 +390,27 @@ func file_internal_machine_api_pb_common_proto_rawDescGZIP() []byte {
|
|||||||
return file_internal_machine_api_pb_common_proto_rawDescData
|
return file_internal_machine_api_pb_common_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_internal_machine_api_pb_common_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
var file_internal_machine_api_pb_common_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||||
var file_internal_machine_api_pb_common_proto_goTypes = []any{
|
var file_internal_machine_api_pb_common_proto_goTypes = []any{
|
||||||
(*IP)(nil), // 0: api.IP
|
(*Metadata)(nil), // 0: api.Metadata
|
||||||
(*IPPort)(nil), // 1: api.IPPort
|
(*Empty)(nil), // 1: api.Empty
|
||||||
(*IPPrefix)(nil), // 2: api.IPPrefix
|
(*EmptyResponse)(nil), // 2: api.EmptyResponse
|
||||||
|
(*IP)(nil), // 3: api.IP
|
||||||
|
(*IPPort)(nil), // 4: api.IPPort
|
||||||
|
(*IPPrefix)(nil), // 5: api.IPPrefix
|
||||||
|
(*status.Status)(nil), // 6: google.rpc.Status
|
||||||
}
|
}
|
||||||
var file_internal_machine_api_pb_common_proto_depIdxs = []int32{
|
var file_internal_machine_api_pb_common_proto_depIdxs = []int32{
|
||||||
0, // 0: api.IPPort.ip:type_name -> api.IP
|
6, // 0: api.Metadata.status:type_name -> google.rpc.Status
|
||||||
0, // 1: api.IPPrefix.ip:type_name -> api.IP
|
0, // 1: api.Empty.metadata:type_name -> api.Metadata
|
||||||
2, // [2:2] is the sub-list for method output_type
|
1, // 2: api.EmptyResponse.messages:type_name -> api.Empty
|
||||||
2, // [2:2] is the sub-list for method input_type
|
3, // 3: api.IPPort.ip:type_name -> api.IP
|
||||||
2, // [2:2] is the sub-list for extension type_name
|
3, // 4: api.IPPrefix.ip:type_name -> api.IP
|
||||||
2, // [2:2] is the sub-list for extension extendee
|
5, // [5:5] is the sub-list for method output_type
|
||||||
0, // [0:2] is the sub-list for field type_name
|
5, // [5:5] is the sub-list for method input_type
|
||||||
|
5, // [5:5] is the sub-list for extension type_name
|
||||||
|
5, // [5:5] is the sub-list for extension extendee
|
||||||
|
0, // [0:5] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_internal_machine_api_pb_common_proto_init() }
|
func init() { file_internal_machine_api_pb_common_proto_init() }
|
||||||
@@ -233,7 +420,7 @@ func file_internal_machine_api_pb_common_proto_init() {
|
|||||||
}
|
}
|
||||||
if !protoimpl.UnsafeEnabled {
|
if !protoimpl.UnsafeEnabled {
|
||||||
file_internal_machine_api_pb_common_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_common_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*IP); i {
|
switch v := v.(*Metadata); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -245,7 +432,7 @@ func file_internal_machine_api_pb_common_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_common_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_common_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*IPPort); i {
|
switch v := v.(*Empty); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
case 1:
|
||||||
@@ -257,6 +444,42 @@ func file_internal_machine_api_pb_common_proto_init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
file_internal_machine_api_pb_common_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
file_internal_machine_api_pb_common_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*EmptyResponse); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_internal_machine_api_pb_common_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*IP); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_internal_machine_api_pb_common_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*IPPort); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_internal_machine_api_pb_common_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
||||||
switch v := v.(*IPPrefix); i {
|
switch v := v.(*IPPrefix); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
@@ -275,7 +498,7 @@ func file_internal_machine_api_pb_common_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: file_internal_machine_api_pb_common_proto_rawDesc,
|
RawDescriptor: file_internal_machine_api_pb_common_proto_rawDesc,
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 3,
|
NumMessages: 6,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,31 @@ package api;
|
|||||||
|
|
||||||
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";
|
||||||
|
|
||||||
|
// Vendored at internal/machine/api/vendor/google/rpc/status.proto.
|
||||||
|
import "google/rpc/status.proto";
|
||||||
|
|
||||||
|
// Common metadata message nested in all reply message types, injected by the gRPC proxy to provide information
|
||||||
|
// about the machine that responded to the request.
|
||||||
|
message Metadata {
|
||||||
|
// Address of the machine the response came from.
|
||||||
|
string machine = 1;
|
||||||
|
// error is set if the request to upstream failed. The rest of the response is undefined.
|
||||||
|
string error = 2;
|
||||||
|
// error as a gRPC Status message.
|
||||||
|
google.rpc.Status status = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A helper message for marshalling the metadata field and injecting it into the response by the gRPC proxy.
|
||||||
|
message Empty {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyResponse is a response message to be returned by the gRPC proxy when a request to the upstream failed.
|
||||||
|
// The nested Empty.Metadata message should contain an error.
|
||||||
|
message EmptyResponse {
|
||||||
|
repeated Empty messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message IP {
|
message IP {
|
||||||
bytes ip = 1;
|
bytes ip = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user